Rays, Planes and Closest Points
What You’ll Learn
Section titled “What You’ll Learn”The arithmetic every collision test is built out of: a ray as a start plus a direction, a plane as one equation, and where the two meet - including the case where they never do, which is the one that ships bugs. Then the closest point on a line, a segment, a plane, a box and a sphere. And running under all of it, signed distance: one number that says both how far you are from a shape and which side of it you are on.
Part 6 builds collision from scratch, because Three.js has no physics. This Section is the foundation the next two stand on.
A Ray Is a Point Plus a Direction
Section titled “A Ray Is a Point Plus a Direction”That is the whole idea. is where the ray starts, is the direction it goes, and picks a point along it. One number, one point - which is why this is called the parametric form.
Keep unit length and is a distance in meters. Not “some amount along the ray”: actual meters, directly comparable to any other distance in the scene. Skip the normalize and every you get back is scaled by however long happened to be, and the two nearest hits from two different rays are no longer comparable.
Three values of are worth naming:
| Where you are | |
|---|---|
| at the origin | |
| ahead of the start - the part a ray actually means | |
| behind the start, which a line includes and a ray does not |
A line is all of . A ray is . A segment is with going all the way from one end to the other. Same equation, three different ranges, and the difference between them is a clamp.
A Plane Is One Equation
Section titled “A Plane Is One Equation”A plane needs a direction to face and somewhere to sit. Store the facing as a unit normal and the position as a single number :
Every point satisfying that is on the plane. Points off it do not give zero, and what they give is the useful part:
Positive on the side the normal points to, negative on the other side, zero exactly on the surface. Not the distance and a separate flag - one number carrying both.
That only works if is unit length. Scale the normal by 3 and every distance the plane reports
comes out three times too large, with nothing to indicate it. So planeThrough normalizes what it
is handed, and the build check confirms it.
Where a Ray Meets a Plane
Section titled “Where a Ray Meets a Plane”Substitute the ray into the plane equation and solve for . Two lines:
The numerator is the signed distance from the plane to where the ray starts. The denominator, , is how steeply the ray heads into the plane - Section 1.3’s dot product, doing the job it always does.
The denominator is the entire story. Watch it, and with it:
src/lib/gamedev/demos/rayplane.scene.ts /** One ray, one floor, and the distance to the hit as the ray flattens out. */
import * as THREE from "three";
import { RAY_ORIGIN, hitAtPitch, rayAtPitch } from "./rayplane-shared.ts";
import { makeCanvas, addSlider, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";
const HIT = 0x39d3c3;
const RAY = 0xf0883e;
const DIM = 0x484f58;
const REACH = 26;
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 340);
const scene = new THREE.Scene();
scene.background = background;
const camera = new THREE.PerspectiveCamera(38, width / height, 0.1, 300);
const grid = new THREE.GridHelper(60, 30);
grid.material = new THREE.LineBasicMaterial({ color: DIM });
scene.add(grid);
// The plane's normal, drawn once, because it is half of the denominator in the readout.
scene.add(
new THREE.ArrowHelper(
new THREE.Vector3(0, 1, 0),
new THREE.Vector3(0, 0, 0),
2.4,
HIT,
),
);
const lineOf = (color: number, dashed = false) => {
const geom = new THREE.BufferGeometry();
const mesh = new THREE.Line(
geom,
dashed
? new THREE.LineDashedMaterial({ color, dashSize: 0.5, gapSize: 0.4 })
: new THREE.LineBasicMaterial({ color }),
);
scene.add(mesh);
/* Hide by visibility, never by handing over an empty list. An empty list on the first
call allocates a zero-length buffer that can never be filled again, and on a later
call it leaves the previous vertices in place and still drawn. */
return (pts: THREE.Vector3[]) => {
mesh.visible = pts.length > 1;
if (!mesh.visible) return;
geom.setFromPoints(pts);
if (dashed) mesh.computeLineDistances();
};
};
const solid = lineOf(RAY);
const onwards = lineOf(RAY, true);
const backwards = lineOf(DIM, true);
const start = new THREE.Mesh(
new THREE.SphereGeometry(0.22, 14, 10),
new THREE.MeshBasicMaterial({ color: RAY }),
);
start.position.set(RAY_ORIGIN.x, RAY_ORIGIN.y, RAY_ORIGIN.z);
scene.add(start);
const hitMat = new THREE.MeshBasicMaterial({ color: HIT });
const hitDot = new THREE.Mesh(new THREE.SphereGeometry(0.26, 14, 10), hitMat);
scene.add(hitDot);
const show = addReadout(el);
const pitch = addSlider(
el,
"pitch of the ray",
-60,
60,
-30,
draw,
"\u00B0",
0.5,
);
const spin = addSlider(el, "walk around it", -180, 180, 28, draw);
function draw() {
const { denominator, t } = hitAtPitch(pitch());
const ray = rayAtPitch(pitch());
const at = (d: number) =>
new THREE.Vector3(
ray.origin.x + ray.direction.x * d,
ray.origin.y + ray.direction.y * d,
ray.origin.z + ray.direction.z * d,
);
const from = at(0);
const den = denominator.toFixed(3);
// Solid as far as the hit, dashed when there is nothing ahead to stop at, and dashed
// backwards when the only solution is behind the start - a miss, for a ray.
const hits = t !== null && t > 0;
solid(hits ? [from, at(Math.min(t, REACH))] : []);
onwards(hits ? [] : [from, at(REACH)]);
backwards(t !== null && t < 0 ? [from, at(t)] : []);
hitDot.visible = t !== null && (t < 0 || t <= REACH);
if (hitDot.visible && t !== null) {
hitDot.position.copy(at(t));
hitMat.color.setHex(t < 0 ? DIM : HIT);
}
const a = (spin() * Math.PI) / 180;
camera.position.set(Math.sin(a) * 34, 15, Math.cos(a) * 34);
camera.lookAt(0, 0, -6);
show(
t === null
? `n \u00B7 D = ${den}, so the ray runs parallel and never meets the floor`
: t < 0
? `n \u00B7 D = ${den}, t = ${t.toFixed(2)} m, negative, so the floor is behind the ray`
: t > REACH
? `n \u00B7 D = ${den}, t = ${t.toFixed(1)} m, past the far edge of the grid`
: `n \u00B7 D = ${den}, t = ${t.toFixed(2)} m`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; The teal arrow is the plane’s normal. Bring the pitch towards level and the denominator shrinks toward zero, so grows without bound - 6 meters at a 30 degree dive, 17 at 10 degrees, 172 at 1 degree, and past 1700 at a tenth of a degree. The hit is still real. It is just somewhere out past the far edge of the world.
At exactly level, there is no answer. The ray runs parallel and never meets the plane at all, so
rayPlane returns null:
Divide anyway and you get - or NaN, if the ray happens to lie in the plane, where
the numerator is zero too and the division is . Section 1.2 covered how far a NaN travels
before anybody notices: it survives every comparison, spreads through everything it touches, and
surfaces three subsystems later as an object at no position at all.
So the guard is not defensive tidiness. It is the difference between a miss and a corrupted scene.
Turn the pitch upward and you get the other case: goes negative. That is still a solution - the grey dot behind the ray shows it sitting exactly on the plane - but it is behind where the ray started, so for anything that means “shoot forwards”, it is a miss. A negative and no intersection are different answers, and code that treats them the same will happily let you shoot through the floor you are standing on.
Signed Distance, for Shapes That Are Not Planes
Section titled “Signed Distance, for Shapes That Are Not Planes”A plane gets signed distance for free from its own equation. Other shapes need a formula, and it is worth having, because once a shape can answer “how far, and which side” the rest gets easy:
- Are we touching? A comparison against zero.
- Which way do I push out? The direction the number grows fastest.
- Is this whole object clear of that one? Signed distance minus radius.
A sphere is a one-liner: distance to the centre minus the radius. A box takes a little more, and the numbers are worth looking at directly.
src/lib/gamedev/demos/signeddist.ts /** One number that says both how far from a box you are and which side you are on. */
import { signedDistanceToBox } from "../geometry.ts";
import type { Demo } from "./runner.ts";
const MIN = { x: -1, y: -1, z: -1 };
const MAX = { x: 1, y: 1, z: 1 };
const demo: Demo = (log) => {
const at = (p: { x: number; y: number; z: number }, note?: string) =>
log(
`signedDistanceToBox(min, max, { x: ${p.x}, y: ${p.y}, z: ${p.z} })`,
signedDistanceToBox(MIN, MAX, p).toFixed(6),
note,
);
// A box two meters on a side, centred on the origin. Walking out from the middle.
at({ x: 0, y: 0, z: 0 }, "dead centre, and one meter from every face");
at({ x: 0.5, y: 0.5, z: 0.5 }, "still inside, so still negative");
at({ x: 1, y: 0, z: 0 }, "on a face - this is the surface");
at({ x: 1.5, y: 0, z: 0 }, "outside, straight off one face");
at({ x: 2, y: 2, z: 0 }, "past two faces at once, so the corner decides it");
at({ x: 3, y: 4, z: 0 }, "and it keeps behaving like a distance");
};
export default demo; Read down the list. Inside, it counts how far you are from the nearest face, as a negative. On the surface, exactly zero. Outside, it becomes an ordinary distance - and at , past two faces at once, it gives rather than 1, because the nearest part of the box is a corner and the corner is genuinely away.
Two halves do that. One measures how far past the box you are in each axis, ignoring the axes you are still inside, and takes the length of what is left - which is what handles corners. The other only bites when every axis is inside, and reports the nearest face as a negative.
Part 6.3 comes back to this: the signed distance is how deep a penetration is, and the direction it grows is which way to push out.
The Closest Point
Section titled “The Closest Point”Related question, different answer type. Not “how far” but “where”.
src/lib/gamedev/demos/closest.scene.ts /** Four primitives, and the nearest point on each to a point you move. */
import * as THREE from "three";
import { signedDistanceToBox } from "../geometry.ts";
import {
BOX_MAX,
BOX_MIN,
GROUND,
SEG_A,
SEG_B,
SPHERE_C,
SPHERE_R,
nearestPoints,
} from "./closest-shared.ts";
import { makeCanvas, addSlider, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";
const NEAREST = 0x39d3c3;
const DIM = 0x484f58;
const QUERY = 0xf0883e;
const v = (p: { x: number; y: number; z: number }) =>
new THREE.Vector3(p.x, p.y, p.z);
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 340);
const scene = new THREE.Scene();
scene.background = background;
const camera = new THREE.PerspectiveCamera(38, width / height, 0.1, 200);
// One material per primitive, in the order `nearestPoints` reports them, so a single
// index picks out the winner everywhere below.
const segMat = new THREE.MeshBasicMaterial({ color: DIM });
const boxMat = new THREE.LineBasicMaterial({ color: DIM });
const sphereMat = new THREE.MeshBasicMaterial({
color: DIM,
wireframe: true,
});
const groundMat = new THREE.LineBasicMaterial({ color: DIM });
const MATS = [segMat, boxMat, sphereMat, groundMat];
const segDir = v(SEG_B).sub(v(SEG_A));
const segment = new THREE.Mesh(
new THREE.CylinderGeometry(0.08, 0.08, segDir.length(), 10),
segMat,
);
segment.position.copy(v(SEG_A)).add(v(SEG_B)).multiplyScalar(0.5);
segment.quaternion.setFromUnitVectors(
new THREE.Vector3(0, 1, 0),
segDir.clone().normalize(),
);
scene.add(segment);
const size = v(BOX_MAX).sub(v(BOX_MIN));
const box = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)),
boxMat,
);
box.position.copy(v(BOX_MIN)).add(v(BOX_MAX)).multiplyScalar(0.5);
scene.add(box);
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(SPHERE_R, 20, 14),
sphereMat,
);
sphere.position.copy(v(SPHERE_C));
scene.add(sphere);
// The plane is infinite, so it is drawn as a grid at its own height: -d for a +Y normal.
const grid = new THREE.GridHelper(24, 24);
grid.material = groundMat;
grid.position.y = -GROUND.d;
scene.add(grid);
const links = MATS.map(() => {
const geom = new THREE.BufferGeometry();
const mat = new THREE.LineBasicMaterial({ color: DIM });
scene.add(new THREE.Line(geom, mat));
const dotMat = new THREE.MeshBasicMaterial({ color: DIM });
const dot = new THREE.Mesh(new THREE.SphereGeometry(0.13, 12, 9), dotMat);
scene.add(dot);
return { geom, mat, dot, dotMat };
});
const queryDot = new THREE.Mesh(
new THREE.SphereGeometry(0.19, 16, 11),
new THREE.MeshBasicMaterial({ color: QUERY }),
);
scene.add(queryDot);
const show = addReadout(el);
const px = addSlider(el, "point across", -8, 8, 2.2, draw, " m", 0.1);
const py = addSlider(el, "point up", -4, 6, 2.6, draw, " m", 0.1);
const pz = addSlider(el, "point towards you", -8, 8, 2.2, draw, " m", 0.1);
const spin = addSlider(el, "walk around it", -180, 180, 35, draw);
function draw() {
const p = { x: px(), y: py(), z: pz() };
queryDot.position.set(p.x, p.y, p.z);
const all = nearestPoints(p);
let best = 0;
all.forEach((n, i) => {
if (n.distance < all[best].distance) best = i;
});
all.forEach((n, i) => {
const colour = i === best ? NEAREST : DIM;
MATS[i].color.setHex(colour);
links[i].mat.color.setHex(colour);
links[i].dotMat.color.setHex(colour);
links[i].dot.position.set(n.point.x, n.point.y, n.point.z);
links[i].geom.setFromPoints([
new THREE.Vector3(p.x, p.y, p.z),
new THREE.Vector3(n.point.x, n.point.y, n.point.z),
]);
});
const a = (spin() * Math.PI) / 180;
camera.position.set(Math.sin(a) * 20, 9, Math.cos(a) * 20);
camera.lookAt(0, 0, 0);
const inBox = signedDistanceToBox(BOX_MIN, BOX_MAX, p);
show(
inBox < 0
? `inside the box, ${inBox.toFixed(2)} m in, so the nearest point is the point itself`
: `nearest: the ${all[best].name}, ${all[best].distance.toFixed(2)} m away`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; Move the orange point with the three sliders. Each shape gets a dot on it and a line back to you, and whichever shape is actually nearest turns teal. That last part is the job a broad-phase does: four shapes answer the same question and something has to pick a winner.
Each answer is a different amount of work.
On an infinite line
Section titled “On an infinite line”Project the offset onto the line’s direction, using Section 1.3’s projection:
comes out as a fraction of the way from to . Nothing constrains it, so it can be or .
On a segment
Section titled “On a segment”The same thing, then clamp(u, 0, 1).
That clamp is the whole difference, and forgetting it is a bug with a recognisable feel: a character gets pulled toward a point somewhere off the end of a wall rather than to the wall’s corner, and it only shows up when they walk past the end. Between the endpoints the two functions are identical to the last bit, which is why it passes every test taken near the middle.
On a plane
Section titled “On a plane”Step off the plane by exactly the signed distance, in the direction of the normal:
One multiply-subtract per axis. This is why the signed-distance form is worth storing.
On a box
Section titled “On a box”Clamp each coordinate to its own interval. That is all:
No cases, no working out which face is nearest, no branches. It works because an axis-aligned box is three independent intervals, so each axis can be decided on its own - and it lands on a face, an edge or a corner automatically depending on how many axes needed clamping. Section 6.2’s sphere-versus-box test is this function plus one comparison.
One thing to know about it: for a point inside the box, the closest point is the point itself, so the distance comes back as zero. The scene says so when you move inside. If you need a nearest surface point from inside, that is a different function, and it needs the signed distance to tell it which face to push to.
On a sphere
Section titled “On a sphere”Go from the centre towards the point, and stop at the radius. Undefined at the exact centre, where
every direction is equally close - so the code names one rather than dividing by zero and returning
NaN for a position.
source Rays, planes, closest points and signed distance
/**
* Rays, planes, and the closest point on things - the arithmetic every collision test is built
* from.
*
* One idea runs through all of it: **signed distance**. A single number that is negative inside a
* shape, zero on its surface and positive outside. Once a shape can answer that, "are we touching"
* is a comparison and "which way do I push out" is the direction it grows fastest. Part 6 keeps
* coming back to it.
*
* The `Plane` type is the one `projection.ts` already defined for frustum planes. Same maths, so
* the same type - a frustum plane and a wall are not different kinds of thing.
*/
import type { Vec3 } from "./matrices.ts";
import { distanceToPlane, type Plane } from "./projection.ts";
export type { Plane };
export { distanceToPlane };
const dot = (a: Vec3, b: Vec3) => a.x * b.x + a.y * b.y + a.z * b.z;
const sub = (a: Vec3, b: Vec3): Vec3 => ({
x: a.x - b.x,
y: a.y - b.y,
z: a.z - b.z,
});
const add = (a: Vec3, b: Vec3): Vec3 => ({
x: a.x + b.x,
y: a.y + b.y,
z: a.z + b.z,
});
const mul = (a: Vec3, k: number): Vec3 => ({
x: a.x * k,
y: a.y * k,
z: a.z * k,
});
export const magnitude = (a: Vec3) => Math.hypot(a.x, a.y, a.z);
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
/** A ray is a start and a direction. Keep the direction unit length and `t` is a distance. */
export type Ray = { origin: Vec3; direction: Vec3 };
/** The point `t` along a ray. This is the whole parametric idea: one number picks a point. */
export function pointAt(ray: Ray, t: number): Vec3 {
return add(ray.origin, mul(ray.direction, t));
}
/**
* A plane through a point with a given normal.
*
* Stored as `n` plus `d`, so that `n · p + d` is the signed distance from the plane for any point -
* which is why this form is worth using over storing a point and a normal separately.
*/
export function planeThrough(point: Vec3, normal: Vec3): Plane {
const len = magnitude(normal) || 1;
const n = mul(normal, 1 / len);
return { x: n.x, y: n.y, z: n.z, d: -dot(n, point) };
}
/**
* Where a ray meets a plane, as a distance along the ray. `null` when they never meet.
*
* The derivation is two lines. A point on the ray is `O + tD`, and a point is on the plane when
* `n · p + d = 0`. Substitute and solve:
*
* ```
* n · (O + tD) + d = 0
* t = -(n · O + d) / (n · D)
* ```
*
* **That denominator is the whole story.** `n · D` measures how much the ray heads into the plane;
* when it is zero the ray runs parallel and there is no answer at all. Divide anyway and you get
* `Infinity`, or `NaN` if the ray happens to lie in the plane - and Part 1 covered how far a `NaN`
* travels before anyone notices.
*
* A negative result means the plane is **behind** the ray's start, which is a different thing from
* no intersection and is usually still a miss for the caller's purposes.
*/
export function rayPlane(ray: Ray, plane: Plane): number | null {
const denominator =
plane.x * ray.direction.x +
plane.y * ray.direction.y +
plane.z * ray.direction.z;
if (Math.abs(denominator) < 1e-9) return null;
return -distanceToPlane(plane, ray.origin) / denominator;
}
// ---- Closest points ----------------------------------------------------------------------
/** The closest point on an **infinite** line through `a` and `b`. */
export function closestOnLine(a: Vec3, b: Vec3, p: Vec3): Vec3 {
const ab = sub(b, a);
const lengthSquared = dot(ab, ab);
if (lengthSquared < 1e-12) return a;
return add(a, mul(ab, dot(sub(p, a), ab) / lengthSquared));
}
/**
* The closest point on a **segment**, which is the line version with one `clamp`.
*
* That clamp is the entire difference, and forgetting it is why a character sometimes gets pulled
* towards a point off the end of a wall rather than to the wall's corner.
*/
export function closestOnSegment(a: Vec3, b: Vec3, p: Vec3): Vec3 {
const ab = sub(b, a);
const lengthSquared = dot(ab, ab);
if (lengthSquared < 1e-12) return a;
return add(a, mul(ab, clamp01(dot(sub(p, a), ab) / lengthSquared)));
}
/** The closest point on a plane: step off it by exactly the signed distance. */
export function closestOnPlane(plane: Plane, p: Vec3): Vec3 {
const distance = distanceToPlane(plane, p);
return {
x: p.x - plane.x * distance,
y: p.y - plane.y * distance,
z: p.z - plane.z * distance,
};
}
/**
* The closest point on an axis-aligned box: clamp each coordinate on its own.
*
* Three independent clamps, no cases, no branches on which face is nearest. It works because the
* box is a product of three intervals, so the nearest point in each axis is decided separately.
* Section 6.2's sphere-versus-box test is this function plus one comparison.
*/
export function closestOnBox(min: Vec3, max: Vec3, p: Vec3): Vec3 {
const clamp = (v: number, lo: number, hi: number) =>
v < lo ? lo : v > hi ? hi : v;
return {
x: clamp(p.x, min.x, max.x),
y: clamp(p.y, min.y, max.y),
z: clamp(p.z, min.z, max.z),
};
}
/** The closest point on a sphere's surface. Undefined at the centre, so it names a direction. */
export function closestOnSphere(centre: Vec3, radius: number, p: Vec3): Vec3 {
const away = sub(p, centre);
const length = magnitude(away);
if (length < 1e-12) return { x: centre.x + radius, y: centre.y, z: centre.z };
return add(centre, mul(away, radius / length));
}
// ---- Signed distance ---------------------------------------------------------------------
/**
* Signed distance to a box: negative inside, zero on the surface, positive outside.
*
* The two halves do different jobs. `outside` measures how far past the box you are in each axis,
* ignoring axes you are still within, and takes the length - which handles corners correctly. The
* `inside` term only bites when every axis is within, and gives the distance to the nearest face
* as a negative number.
*/
export function signedDistanceToBox(min: Vec3, max: Vec3, p: Vec3): number {
const centre = mul(add(min, max), 0.5);
const half = mul(sub(max, min), 0.5);
const q = {
x: Math.abs(p.x - centre.x) - half.x,
y: Math.abs(p.y - centre.y) - half.y,
z: Math.abs(p.z - centre.z) - half.z,
};
const outside = magnitude({
x: Math.max(q.x, 0),
y: Math.max(q.y, 0),
z: Math.max(q.z, 0),
});
const inside = Math.min(Math.max(q.x, q.y, q.z), 0);
return outside + inside;
}
/** Signed distance to a sphere. Same convention, and a one-liner. */
export function signedDistanceToSphere(
centre: Vec3,
radius: number,
p: Vec3,
): number {
return magnitude(sub(p, centre)) - radius;
} The build check does two things worth mentioning. Ray-plane distances are verified against , which reaches the same number by a completely different route - the implementation solves a general plane equation and knows nothing about the ray starting 3 meters up. And every closest-point answer is checked by brute force: sample hundreds of points on the shape and confirm none of them is nearer. A function that returns a point on the right shape in the wrong place passes every residual test there is, so sampling is the only thing that catches it.
Outside the box, the closest-point distance and the signed distance agree across 4,000 sample
points to better than , which is the strongest cross-check here: the two are computed by
formulas that share no code. The box those points sweep is deliberately not a cube - with equal
extents on every axis, mixing up two of them inside closestOnBox produces a wrong answer that is
also a right answer, and nothing catches it.
Where This Shows Up
Section titled “Where This Shows Up”- Shooting at the ground or a wall, which is a ray against a plane and nothing more.
- Placing things in an editor, where a click becomes Section 5.2’s ray and the ray meets the ground plane.
- Character controllers, which need the closest point on the geometry near them to work out how far they can move - Section 6.3’s whole job.
- Sphere-versus-box and capsule-versus-box tests, which are
closestOnBoxfollowed by one comparison. Section 6.2. - Penetration depth and push-out direction, which are the signed distance and its gradient.
- Snapping to a path or a rail, which is the closest point on a segment, per segment, keeping the winner.
- Water and lava planes, where “am I under it” is a sign test on one number.