Length, Normalization and Distance
What You’ll Learn
Section titled “What You’ll Learn”How to get a length out of a vector, how to strip the length off and keep only the direction, why comparing squared distances is faster and just as correct, and the one edge case that turns a working movement script into a character that vanishes.
Length
Section titled “Length”The length of a vector is the Pythagorean theorem, once per dimension:
That is it. Three dimensions is the same formula with one more term, and so is four. Length is also called magnitude or norm, and all three words mean this.
source The code doing this, and everything else in this section
/**
* Length, normalization and distance.
*
* Displayed in the lesson and imported by the figure above it, so the code on the page
* is the code that ran.
*
* Everything here takes plain number arrays, so the same function works for 2 or 3
* components. Three.js, Godot and Unity all wrap these in a Vector class; the
* arithmetic underneath is exactly this.
*/
export type Vec = number[];
/** Squared length. Prefer this whenever you are only comparing. */
export function lengthSq(v: Vec): number {
let sum = 0;
for (const c of v) sum += c * c;
return sum;
}
/** Length, also called magnitude or norm. The Pythagorean theorem, once per axis. */
export function length(v: Vec): number {
return Math.sqrt(lengthSq(v));
}
/**
* A unit vector pointing the same way, or `null` if there is no direction to report.
*
* Returning null rather than a zero vector is deliberate: a zero-length input has no
* direction, and forcing the caller to handle that is better than handing back
* something that looks like an answer. Dividing without this check produces NaN, which
* spreads silently through every later calculation.
*/
export function normalize(v: Vec, epsilon = 1e-6): Vec | null {
const len = length(v);
if (len < epsilon) return null;
return v.map((c) => c / len);
}
/** Distance between two points. The length of the vector between them. */
export function distance(a: Vec, b: Vec): number {
return Math.sqrt(distanceSq(a, b));
}
/** Squared distance, for comparisons. No square root taken. */
export function distanceSq(a: Vec, b: Vec): number {
let sum = 0;
for (let i = 0; i < a.length; i++) {
const d = b[i] - a[i];
sum += d * d;
}
return sum;
}
/**
* Is b within `radius` of a?
*
* Squares the radius instead of rooting the distance. Both sides are non-negative, so
* squaring preserves the comparison, and the answer is identical to
* `distance(a, b) < radius` with one fewer square root.
*/
export function isWithin(a: Vec, b: Vec, radius: number): boolean {
return distanceSq(a, b) < radius * radius;
}
/**
* Direction and speed as separate decisions, which is how movement should be written.
*
* Passing a raw input vector straight in as a velocity is the diagonal-speed bug: two
* keys held at once gives length 1.414, so diagonal movement runs 41% fast.
*/
export function velocityFromInput(input: Vec, speed: number): Vec {
const dir = normalize(input);
if (dir === null) return input.map(() => 0);
return dir.map((c) => c * speed);
} The figure above imports length, lengthSq and normalize from that file, so every
readout you just swept was produced by the code in the panel. The rest of the section
explains the same functions.
Normalization
Section titled “Normalization”A unit vector has length exactly 1. Normalizing means scaling a vector down (or up) until it has length 1, keeping its direction:
Divide each component by the length. The result answers “which way?” with no answer to “how far?”.
This matters because direction and speed are separate decisions, and most bugs in movement code come from conflating them. If a player holds two arrow keys, the raw input vector is with length , so a diagonal-walking character moves 41% faster than one walking straight - the classic diagonal speed bug. Normalize the input first, then multiply by the speed you meant:
That is velocityFromInput in the panel above. In Three.js the same thing reads:
// input is whatever the keys gave you, e.g. (1, 1) for two keys held.const dir = input.lengthSq() > 1e-12 ? input.clone().normalize() : new THREE.Vector3();velocity.copy(dir).multiplyScalar(SPEED);Note input.clone().normalize() rather than input.normalize(). Three.js normalizes in
place, so the second form destroys your input vector, and the next line that reads it gets
a unit vector instead of the raw keys. This mutability is the most common early Three.js
surprise.
The Zero-Length Trap
Section titled “The Zero-Length Trap”Look at the normalization formula again and ask what happens when .
You divide by zero. In floating-point arithmetic that does not crash - it produces
NaN, which then contaminates everything it touches. NaN + 1 is NaN. NaN > 0 is
false, and so is NaN < 0, and so is NaN == NaN. A position that becomes NaN puts
your character nowhere, and the node usually just disappears with no error message.
The trigger is mundane: the player lets go of the keys, so input is , so normalizing it is a division by zero. Or an enemy reaches the player exactly, so the “direction to target” vector is zero length.
Drag the slider above to exactly zero and the readout tells you it is undefined rather than pretending.
Guard it by checking the length first, and compare against a small epsilon rather than exactly zero, because floating point rarely lands on exact values.
An epsilon () is just a very small number you choose as a threshold -
typically something like 0.000001 (written 1e-6 in code). You are saying: “anything
smaller than this is close enough to zero that I will treat it as zero.” You need it because
computers store numbers in floating point, and the result of a calculation that should be
exactly zero might come back as 0.0000000000000004 instead. Checking === 0 would miss
it, so you check < epsilon instead.
The normalize in the panel above returns null in that case rather than a vector
full of NaN. That is a deliberate choice: a zero-length vector has no direction, so
there is no honest answer, and forcing the caller to handle it is better than handing back
something that merely looks like one.
const toTarget = new THREE.Vector3().subVectors(target.position, mesh.position);
// Three.js is unusually kind: normalize() on a zero vector leaves it at (0,0,0)// rather than producing NaN. Do not rely on that when you write the maths yourself.if (toTarget.lengthSq() > 1e-6) { mesh.lookAt(target.position);}lookAt on a zero-length direction, or one exactly parallel to the up vector, is the
same edge case wearing a different hat. The cross product section explains why.
Distance
Section titled “Distance”Distance between two points is the length of the vector between them, which is why the point-minus-point rule from the previous section matters:
Skip the Square Root
Section titled “Skip the Square Root”Square roots are the expensive part of that formula, and often you do not need one.
Suppose you want to know whether an enemy is within 10 meters. The obvious test is . But since both sides are non-negative, and squaring preserves order for non-negative numbers, this is equivalent:
So compare squared distance against squared radius and never take the root. The comparison gives an identical answer, and the sqrt is gone.
This works for any comparison: nearest enemy, inside a radius, which of two things is closer. It does not work when you need the actual number - a distance to display, or a value to divide by - because is not .
The rule: if you are comparing, square the other side. If you are reporting, take the root.
That is isWithin in the panel above. In Three.js:
const AGGRO_RANGE = 10;const aggroSq = AGGRO_RANGE * AGGRO_RANGE; // compute once, not per frame
for (const enemy of enemies) { if (player.position.distanceToSquared(enemy.position) < aggroSq) { enemy.notice(player); }}distanceToSquared and lengthSq exist precisely for this. On a handful of enemies it
will not matter; in a loop over a few thousand, every frame, it does.
See It Work
Section titled “See It Work”The diagonal speed bug
Section titled “The diagonal speed bug”Sweep the slider through every direction a character could move. Each dot sits at the speed it would travel in that direction, so the outline each one traces is the set of all speeds available.
src/lib/gamedev/demos/diagonal.scene.ts /** Every direction a player can hold, drawn as the square you get and the circle you wanted. */
import { makeCanvas2D, arrow, dot, label, line } from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addSlider, addReadout } from "../controls.ts";
import {
circleLoop,
rawAt,
fixedAt,
speedRatio,
squareLoop,
} from "./diagonal-shared.ts";
import type { MountFn } from "../runner.ts";
const RAW = "#ff7b72";
const FIXED = "#39d3c3";
const AXIS = "#30363d";
const TEXT = "#9198a1";
const mount: MountFn = (el) => {
const { ctx, width, height, clear } = makeCanvas2D(el, 320);
const show = addReadout(el);
const note = addReadout(el);
const heading = addSlider(el, "direction held", 0, 360, 45, draw);
function draw() {
clear();
const cx = width / 2;
const cy = height / 2;
const unit = 96;
// World y is up, so drawing negates it. Section 1.1's one conversion, in one place.
const at = (v: { x: number; y: number }) => ({
x: cx + v.x * unit,
y: cy - v.y * unit,
});
line(ctx, { x: cx - 150, y: cy }, { x: cx + 150, y: cy }, AXIS);
line(ctx, { x: cx, y: cy - 150 }, { x: cx, y: cy + 150 }, AXIS);
// The two reachable sets: the square raw input traces, the circle normalizing gives.
const trace = (loop: Array<{ x: number; y: number }>, colour: string) => {
ctx.save();
ctx.strokeStyle = colour;
ctx.lineWidth = 1.6;
ctx.beginPath();
loop.forEach((v, i) => {
const p = at(v);
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
});
ctx.stroke();
ctx.restore();
};
trace(squareLoop(), RAW);
trace(circleLoop(), FIXED);
const radians = (heading() * Math.PI) / 180;
const raw = rawAt(radians);
const fixed = fixedAt(radians);
const ratio = speedRatio(radians);
arrow(ctx, { x: cx, y: cy }, at(raw), RAW, 2.4);
arrow(ctx, { x: cx, y: cy }, at(fixed), FIXED, 2.4);
dot(ctx, at(raw).x, at(raw).y, 4, RAW);
dot(ctx, at(fixed).x, at(fixed).y, 4, FIXED);
label(ctx, "raw input", 10, 18, RAW);
label(ctx, `reaches the square, length ${ratio.toFixed(3)}`, 10, 32, TEXT);
label(ctx, "normalized", 10, 52, FIXED);
label(ctx, "reaches the circle, length 1.000", 10, 66, TEXT);
show(
`holding ${heading()}\u00B0: raw input is ${ratio.toFixed(3)} long, normalized is 1.000 \u00B7 ` +
`${((ratio - 1) * 100).toFixed(1)}% faster than intended`,
);
note(
ratio > 1.4
? "a full diagonal is the worst case: root two, so 41% faster than walking along an axis"
: ratio < 1.001
? "along an axis the two agree exactly, which is why this bug hides during testing"
: "anywhere off an axis the raw version is already too fast",
);
}
draw();
return () => {};
};
export default mount; The teal outline is a circle. The red one is a square. That is the entire bug, and it is a shape rather than a special case.
Both come from the same function; the only difference is whether the input was normalized first. A two-axis control pushes each axis to its limit independently, so the raw input lands on the edge of a square: going straight, but going diagonally, and that corner is from the centre instead of 1. Use it as a velocity and the extra length becomes an accidental speed multiplier.
Watch the readout as you sweep. Teal holds at 6.0 everywhere. Red reads 6.0 on the axes -
so the bug is invisible if you only ever test straight lines - and climbs to 8.5 at the
corners, 41% too fast.
The small red dots mark the eight directions a keyboard can produce. Four of them are on the axes and correct; the four diagonals are the fast ones. A thumbstick clamped per axis gives you the whole square outline, so the error varies smoothly with the angle instead.
Dividing by the length pulls every one of those points back onto the circle. That is what
normalize is for, and it is the whole fix.
Why a range check needs no square root
Section titled “Why a range check needs no square root”src/lib/gamedev/demos/squared-distance.ts /** Comparing squared distances gives the same answer with no square root. */
import { distance, distanceSq } from "../vectors.ts";
import { HEADING, type Demo } from "./runner.ts";
const RADIUS = 10;
const demo: Demo = (log) => {
for (const p of [
[3, 4],
[6, 8],
[7, 8],
]) {
log(
`point (${p.join(", ")})`,
{
distance: distance([0, 0], p),
squared: distanceSq([0, 0], p),
within: distanceSq([0, 0], p) < RADIUS * RADIUS,
},
`radius ${RADIUS}`,
);
}
log("checked on a grid of 90,601 points", HEADING);
let disagree = 0;
for (let i = -150; i <= 150; i++) {
for (let j = -150; j <= 150; j++) {
const p = [i / 10, j / 10];
const plain = distance([0, 0], p) < RADIUS;
const squared = distanceSq([0, 0], p) < RADIUS * RADIUS;
if (plain !== squared) disagree += 1;
}
}
log("times the two tests disagreed", disagree, "so use the cheap one");
};
export default demo; Comparing gives the same answer as , because squaring cannot change the order of two non-negative numbers. The last line checks that across a grid of 90,601 points and finds zero disagreements, so the cheaper test is safe to use everywhere.
One catch: the equivalence needs the same comparison on both sides. Mixing < on one
side with <= on the other disagrees for every point landing exactly on the circle.
What NaN is, and why it matters here
Section titled “What NaN is, and why it matters here”NaN stands for “Not a Number”. It is a special value that JavaScript (and every
other language using floating point) produces when a calculation has no meaningful numeric
result. Dividing zero by zero gives NaN. Taking the square root of a negative number
gives NaN. And dividing each component of a zero-length vector by its length - which is
zero - gives NaN in every component.
The dangerous thing about NaN is that it does not crash your program. It does not throw
an error. It just quietly poisons every calculation it touches from that point forward:
src/lib/gamedev/demos/nan-guard.ts /** Every comparison against NaN is false, including the guard meant to catch it. */
import { HEADING, type Demo } from "./runner.ts";
const demo: Demo = (log) => {
const bad = 0 / 0; // what normalizing a zero-length vector gives you
log("0 / 0", bad);
log("bad > 0", bad > 0);
log("bad < 0", bad < 0);
log("bad === bad", bad === bad, "not even equal to itself");
log("Number.isNaN(bad)", Number.isNaN(bad), "the only test that works");
log("so these two guards disagree", HEADING);
log("if (speed > 0) move()", bad > 0 ? "moves" : "stops");
log("if (speed <= 0) return", bad <= 0 ? "stops" : "moves");
};
export default demo; NaN is neither greater than, less than, nor equal to anything - not even itself. That
last one is the strangest part: NaN === NaN is false. The only way to detect it is
Number.isNaN(value).
That is why the last two lines in the panel matter. if (speed > 0) move() and
if (speed <= 0) return look like the same guard to any reviewer - but they disagree when
speed is NaN. One treats the object as stopped, the other as moving. Clamping does not
help either: Math.max(0, NaN) is still NaN.
In a game, the result is an object whose position becomes NaN, which means it has no
position at all. It simply disappears from the screen with no error message to tell you
what happened. This is why normalize in the code above returns null instead of a vector
full of NaN: it forces the caller to handle the empty case explicitly, right where the
decision can actually be made.
Where This Shows Up
Section titled “Where This Shows Up”- Movement, every time you separate a direction from a speed.
- Aggro and interaction ranges, which are squared-distance comparisons in almost every shipped game.
- Sorting by proximity - nearest cover, nearest pickup, nearest enemy - where sorting by squared distance gives the same order for free.
- Lighting falloff, where inverse-square attenuation wants directly, so taking a root and squaring it again is pure waste.
- Anywhere a character disappears without an error, which is very often a
NaNthat started as a zero-length normalize.