The Dot Product
What You’ll Learn
Section titled “What You’ll Learn”The dot product as three tools wearing one hat: a facing test, a projection, and an
angle. Which of the three you actually want most of the time (the first). And the
floating-point guard that stops the third from producing NaN.
The Formula, and What It Means
Section titled “The Formula, and What It Means”Multiply matching components, add the results:
The output is a single number, not a vector. That is worth saying out loud, because it is the difference between the dot product and the cross product and the source of a lot of confusion.
The reason it is useful is the second identity:
where is the angle between them. Two very different-looking expressions for the same number: one you can compute with three multiplies, one that tells you what it means.
source The code doing this, and everything else in this section
/**
* The dot product, and the three questions it answers.
*
* Displayed in the lesson and imported by the figure above it.
*/
import { type Vec, length, normalize } from "./vectors.ts";
/** Multiply matching components, add the results. Returns a number, not a vector. */
export function dot(a: Vec, b: Vec): number {
let sum = 0;
for (let i = 0; i < a.length; i++) sum += a[i] * b[i];
return sum;
}
/**
* Is `toTarget` broadly in the same direction as `forward`?
*
* Only the sign is read, and normalizing cannot change a sign - it only divides by a
* positive length - so neither input needs normalizing here. Skip the square roots.
*/
export function isInFront(forward: Vec, toTarget: Vec): boolean {
return dot(forward, toTarget) > 0;
}
/**
* Is `toTarget` inside a cone of total width `fovDegrees` around `forward`?
*
* Compares against a cosine rather than computing an angle, which avoids an arccosine
* per call. Note that a wider cone gives a *smaller* threshold, because cosine
* decreases as the angle grows.
*/
export function isInCone(
forward: Vec,
toTarget: Vec,
fovDegrees: number,
): boolean {
const f = normalize(forward);
const t = normalize(toTarget);
if (f === null || t === null) return false;
const threshold = Math.cos((fovDegrees * 0.5 * Math.PI) / 180);
return dot(f, t) > threshold;
}
/**
* The unsigned angle between two vectors, in radians.
*
* The clamp is not optional. Mathematically the quotient cannot leave [-1, 1], but in
* floating point it can arrive as 1.0000000000000002, and `Math.acos` of that is NaN.
* Even the angle between a vector and itself can trigger it.
*/
export function angleBetween(a: Vec, b: Vec): number {
const denom = length(a) * length(b);
if (denom < 1e-12) return 0;
const c = dot(a, b) / denom;
return Math.acos(Math.min(1, Math.max(-1, c)));
}
/**
* The part of `v` that lies along `onto`.
*
* When `onto` is already a unit vector this reduces to `dot(v, onto) * onto`, which is
* why unit vectors are worth keeping around.
*/
export function project(v: Vec, onto: Vec): Vec {
const denom = dot(onto, onto);
if (denom < 1e-12) return v.map(() => 0);
const k = dot(v, onto) / denom;
return onto.map((c) => c * k);
}
/**
* Everything except the part going into a surface - which is how sliding works.
*
* Remove the component of `v` along the surface normal and whatever remains is parallel
* to the surface. This one function is the heart of "move and slide", and it comes back
* in the collision response lesson.
*/
export function slide(v: Vec, normal: Vec): Vec {
const n = normalize(normal);
if (n === null) return [...v];
const along = dot(v, n);
return v.map((c, i) => c - along * n[i]);
} The figure imports dot and angleBetween from that file. The angle readout is a real
clamped arccosine, not the slider value relabelled, which is why it folds back after half
a turn.
Use One: Is It In Front Of Me?
Section titled “Use One: Is It In Front Of Me?”This is the one you will reach for constantly, and it does not need the angle at all - only the sign.
Since and are always positive, the sign of the dot product is the sign of :
| Dot product | Angle | Meaning |
|---|---|---|
| positive | less than | pointing broadly the same way |
| zero | exactly | perpendicular |
| negative | more than | pointing broadly opposite |
So to ask “is the player in front of this guard”, take the guard’s forward vector, take the vector from guard to player, and check the sign. No angle, no trigonometry, no square roots.
That is isInFront in the panel above. In Three.js:
const forward = new THREE.Vector3();guard.getWorldDirection(forward); // -Z forward, from section 1
const toPlayer = new THREE.Vector3().subVectors( player.position, guard.position,);
if (forward.dot(toPlayer) > 0) { console.log("player is somewhere ahead");}Neither vector needs normalizing for a sign test, because normalizing only divides by a positive length and cannot change a sign. Skip it and save two square roots per check.
A field of view test
Section titled “A field of view test”Narrowing “ahead” to “within a 60 degree cone” needs one more step. A 60 degree cone means 30 degrees either side of forward, so compare the dot product against :
Here both vectors must be normalized first - meaning you divide each one by its own length so it becomes a unit vector of length 1 (see the previous section). If you skip this, the dot product’s value depends on how far away the target is, not just the angle, and the comparison against the threshold becomes meaningless. Normalizing strips out the distance and leaves only the direction, which is all the cone test cares about.
Note that a wider cone means a smaller threshold, which reads backwards at first: decreases as the angle grows. A 60 degree cone uses as its threshold, while a 140 degree cone uses . The wider the opening, the lower the bar the dot product has to clear.
That is isInCone in the panel above. Here it is running - drag the sliders and watch
the comparison happen:
src/lib/gamedev/demos/cone.scene.ts /** A guard's vision cone, with a checkbox that removes the normalize and breaks it. */
import {
makeCanvas2D,
arrow,
dot as fillDot,
label,
line,
} from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addCheckbox, addReadout, addSlider } from "../controls.ts";
import { GUARD, RANGE, report, targetAt } from "./cone-shared.ts";
import type { MountFn } from "../runner.ts";
const CONE = "#7ee787";
const MISS = "#ff7b72";
const AXIS = "#30363d";
const TEXT = "#9198a1";
const mount: MountFn = (el) => {
const { ctx, width, height, clear } = makeCanvas2D(el, 300);
const show = addReadout(el);
const note = addReadout(el);
const half = addSlider(el, "cone half-angle", 5, 90, 45, draw);
const bearing = addSlider(el, "target angle off facing", -180, 180, 60, draw);
const distance = addSlider(el, "target distance", 1, 10, 3, draw, " m", 0.5);
const normalized = addCheckbox(
el,
"normalize first (uncheck for the bug)",
true,
draw,
);
function draw() {
clear();
// The guard sits left of centre so the whole cone fits when it is wide.
const ox = 90;
const oy = height / 2;
const unit = 24;
// World y is up, so drawing negates it. Section 1.1's one conversion, in one place.
const at = (p: { x: number; y: number }) => ({
x: ox + p.x * unit,
y: oy - p.y * unit,
});
line(ctx, { x: 0, y: oy }, { x: width, y: oy }, AXIS);
line(ctx, { x: ox, y: 0 }, { x: ox, y: height }, AXIS);
const r = report(half(), bearing(), distance(), normalized());
const colour = r.seen ? CONE : MISS;
// The cone as a filled wedge, drawn in canvas angles, so both edges are negated.
const edge = (half() * Math.PI) / 180;
ctx.save();
ctx.fillStyle = r.seen
? "rgba(126, 231, 135, 0.16)"
: "rgba(255, 123, 114, 0.12)";
ctx.beginPath();
ctx.moveTo(ox, oy);
ctx.arc(ox, oy, RANGE * unit, -edge, edge);
ctx.closePath();
ctx.fill();
ctx.restore();
// The range limit, so a target failing on distance rather than angle is legible.
ctx.save();
ctx.strokeStyle = AXIS;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.arc(ox, oy, RANGE * unit, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
// The guard, and the direction it is facing.
arrow(ctx, at(GUARD), at({ x: 2.4, y: 0 }), TEXT, 2);
fillDot(ctx, at(GUARD).x, at(GUARD).y, 5, TEXT);
label(ctx, "guard", at(GUARD).x - 6, at(GUARD).y + 20, TEXT, "center");
// The target, and the displacement the test measures.
const target = at(r.target);
line(ctx, at(GUARD), target, colour, { dashed: !r.seen });
fillDot(ctx, target.x, target.y, 5, colour);
label(
ctx,
r.seen ? "seen" : r.inRange ? "outside the cone" : "out of range",
target.x + 9,
target.y + 4,
colour,
);
// The cone edges, labelled, since the wedge alone does not say what its angle is.
for (const sign of [-1, 1]) {
const e = targetAt(sign * half(), RANGE);
line(ctx, at(GUARD), at(e), CONE, { width: 1 });
}
label(ctx, `half-angle ${half()}\u00B0`, 10, 18, CONE);
label(ctx, `range ${RANGE} m`, 10, 32, TEXT);
show(
`${normalized() ? "dot of unit directions" : "dot of the raw displacement"} ` +
`${r.measured.toFixed(3)} vs threshold ${r.threshold.toFixed(3)} \u2192 ` +
`${r.inCone ? "inside" : "outside"} the cone, ${r.seen ? "seen" : "not seen"}`,
);
note(
normalized()
? `at ${Math.abs(bearing())}\u00B0 off the facing the answer does not change with distance`
: `without the normalize the number grows with distance: at ${distance().toFixed(1)} m it is ` +
`${r.measured.toFixed(2)}, so moving further away makes the guard more likely to "see" you`,
);
}
draw();
return () => {};
};
export default mount; The guard is the grey dot, looking up the screen. The blue wedge is its field of view.
Move the target bearing and the dot turns green the moment forward.dot(toPlayer)
climbs above the threshold, and red when it drops below. Widen the field of view and
the threshold falls - which is the decreasing as the angle grows.
In Three.js the same thing is four lines:
const FOV_DEG = 60;const threshold = Math.cos(THREE.MathUtils.degToRad(FOV_DEG * 0.5)); // 0.866, once
const toPlayer = new THREE.Vector3() .subVectors(player.position, guard.position) .normalize();
if (forward.dot(toPlayer) > threshold) { console.log("player is in the guard's cone of vision");}Compare the cosine, not the angle. Calling Math.acos per enemy per frame just to compare
against 30 degrees does the same job and throws away the arccosine’s cost for nothing.
Use Two: How Much Of This Lies Along That?
Section titled “Use Two: How Much Of This Lies Along That?”The projection of onto is the part of that points along :
If is already normalized this collapses to the pleasant form , which is the teal bar in the figure above.
Two immediate uses. Speed in a direction: the dot product of velocity with forward tells you how fast you are going forwards, ignoring any sideways drift. And decomposition: any vector splits into a part along a direction and a part perpendicular to it, and subtracting the projection leaves the perpendicular part:
Hold on to that last equation. It is exactly how sliding along a wall works, and it comes back in the collision response section: remove the component of velocity going into the wall, keep the rest, and the character slides instead of sticking.
Those are project and slide in the panel above. Three.js gives you the first and not
the second:
// The part going into the wall.const along = velocity.clone().projectOnVector(wallNormal);
// Everything except that part, which is the sliding motion.const sliding = velocity.clone().sub(along);Three.js has projectOnVector and projectOnPlane; projectOnPlane is the slide, so
velocity.clone().projectOnPlane(wallNormal) does it in one call. Both expect the normal
to be normalized.
Use Three: The Actual Angle
Section titled “Use Three: The Actual Angle”Rearranging the second identity gives the angle:
Here - spelled acos in most languages - is the inverse of cosine: where
turns an angle into a ratio, turns a ratio back into an angle. It reads
as “the angle whose cosine is this”. Its input must lie between and , and its
output is always between and . The next section leans on that output
range, because an always-positive angle cannot tell you which way to turn.
Use this when you genuinely need a number of degrees - to display it, or to feed a rotation speed. Prefer the sign test or the cosine comparison when you do not.
The clamp that prevents NaN
Section titled “The clamp that prevents NaN”arccos is only defined on . Mathematically the fraction above can never
leave that range. In floating point it absolutely can, because a normalized vector’s
length is not exactly 1 and the division can land on 1.0000001.
acos(1.0000001) is NaN, and once NaN is in a rotation it stays there.
So clamp, always:
That is angleBetween in the panel above, and the clamp is the reason it exists as a
function rather than a one-liner:
const c = a.dot(b) / (a.length() * b.length());const angle = Math.acos(THREE.MathUtils.clamp(c, -1, 1)); // the clamp is not optionalThree.js’s a.angleTo(b) already clamps internally, so prefer it when you can:
const angle = a.angleTo(b); // safe, and shorterThe clamp matters when you write the arithmetic yourself, which you will as soon as you
need a signed angle - the subject of section 5, since angleTo always returns a positive
value and cannot tell you which way to turn.
Worked Example
Section titled “Worked Example”A guard faces , Godot’s forward. The player is at relative to the guard. Is the player within a 90 degree cone?
Distance and direction. , so .
Dot product. .
Threshold. A 90 degree cone is 45 degrees each side, and .
Verdict. , so yes, just inside. The actual angle is from forward, comfortably under 45.
Notice we answered the question with a comparison and only computed the angle afterwards to check the work.
See It Work
Section titled “See It Work”The NaN hiding inside acos
Section titled “The NaN hiding inside acos”Nothing to look at here - the whole event is one number going a single bit too far.
src/lib/gamedev/demos/acos-clamp.ts /** The angle between a vector and itself is zero, unless you forget to clamp. */
import { dot, angleBetween } from "../dot.ts";
import { normalize } from "../vectors.ts";
import { HEADING, type Demo } from "./runner.ts";
const demo: Demo = (log) => {
const u = normalize([1, 1, 1])!;
const d = dot(u, u); // exactly 1 in mathematics
log("a unit vector dotted with itself", d.toPrecision(18), "should be 1");
log("is it above 1?", d > 1);
log("Math.acos of it", Math.acos(d), "arccosine of anything above 1");
log("with the clamp", HEADING);
log("Math.min(1, Math.max(-1, d))", Math.min(1, Math.max(-1, d)));
log("angleBetween(u, u)", angleBetween(u, u), "zero, as it should be");
log(
"angleBetween([1,0,0], [0,1,0])",
(angleBetween([1, 0, 0], [0, 1, 0]) * 180) / Math.PI,
"degrees, so the clamp costs nothing when unneeded",
);
};
export default demo; What is the angle between a vector and itself? Zero, obviously. But a normalized vector dotted
with itself comes out as 1.00000000000000022 rather than 1, and Math.acos of anything
above 1 is NaN.
The clamp fixes it in one line and costs nothing when it is not needed - the 90-degree case
still answers 90. That is why angleBetween above applies it unconditionally instead of
checking whether it looks necessary.
Where This Shows Up
Section titled “Where This Shows Up”- Enemy perception - facing tests and vision cones, exactly as above.
- Lighting. Diffuse shading is : how much a surface
faces the light. The
maxis a facing test, throwing away the surfaces pointing away. - Backface culling. A triangle is skipped when its normal points away from the camera, which is one dot product per triangle.
- Wall sliding and surface friction, decomposing velocity into “into the surface” and “along the surface”.
- Audio panning, using the dot product of the listener’s right vector with the direction to a sound source to decide left versus right.