Bounding Volumes and Intersection Tests
What You’ll Learn
Section titled “What You’ll Learn”The four shapes collision is actually done with - sphere, axis-aligned box, oriented box and capsule - and what each one costs. The tests between them, all of which turn out to be the same subtraction. Ray against a box by slabs, which is three intervals and their overlap. The separating-axis idea, including a pair of boxes that proves you need more axes than you would guess. And why every engine has a broad phase, which is a fact about rather than a fact about collision.
Nobody Collides the Real Shape
Section titled “Nobody Collides the Real Shape”A character model has thousands of triangles. Testing them against a level’s thousands of triangles, sixty times a second, for every pair of objects, is not a thing that can be made fast enough. So nothing does it. Everything collides a bounding volume instead: a simple shape that contains the model, chosen because the test against it is cheap.
| Volume | Turns? | Fits a person | Cost |
|---|---|---|---|
| Sphere | free | badly | trivial |
| AABB, axis-aligned box | no | loosely | trivial |
| Capsule | yes | well | small |
| OBB, oriented box | yes | loosely | fifteen axes |
A sphere is rotation-proof: turning it changes nothing, so a rotating object needs no work at all. An AABB cannot turn - rotate the object and you have to rebuild the box, and the rebuilt box is bigger than the old one. A capsule can turn and fits a standing body far better than either, which is why it is what almost every character controller in every engine actually uses.
The capsule wins for a reason worth stating. It has no corners. A box-shaped character catches its corners on stair edges and doorframes and needs special cases to stop it; a sphere-shaped one is either too wide at the shoulders or too short at the head. A capsule is a segment with a radius, so it slides over steps and around corners with no special handling, and it is barely more expensive than a sphere.
Every Test Is the Same Subtraction
Section titled “Every Test Is the Same Subtraction”Here is the part that makes this Section short. Take Section 6.1’s signed distance and apply it to a pair of shapes instead of to a point and a shape:
Negative means overlapping, zero means exactly touching, positive is the gap. So separation < 0
is the collision test for all of them, and the number itself is what Section 6.3 needs in order
to push things apart.
| Pair | Distance between what | Then subtract |
|---|---|---|
| sphere, sphere | the two centres | both radii |
| sphere, capsule | the centre and the segment | both radii |
| capsule, capsule | the two segments | both radii |
| sphere, AABB | the centre and the closest box point | the radius |
Three of those four are Section 6.1’s closest-point functions with a subtraction after them. Move the shapes and watch the one number decide all four cases:
src/lib/gamedev/demos/overlap.scene.ts /** Four pairings of volumes, one moved by sliders, and the separation that decides each. */
import * as THREE from "three";
import {
KINDS,
MOVING_BOX_HALF,
MOVING_CAPSULE_HALF,
MOVING_CAPSULE_RADIUS,
MOVING_SPHERE_RADIUS,
STATIC_BOX,
STATIC_CAPSULE,
STATIC_SPHERE,
testAt,
type Kind,
} from "./overlap-shared.ts";
import { makeCanvas, addSlider, addReadout, addButtonRow } from "./ui.ts";
import type { MountFn } from "./runner.ts";
const CLEAR = 0x39d3c3;
const TOUCHING = 0xff7b72;
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 330);
const scene = new THREE.Scene();
scene.background = background;
const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 200);
const wire = (geom: THREE.BufferGeometry) => {
const mat = new THREE.MeshBasicMaterial({ color: CLEAR, wireframe: true });
const mesh = new THREE.Mesh(geom, mat);
scene.add(mesh);
return { mesh, mat };
};
const capsuleAxis = new THREE.Vector3(
STATIC_CAPSULE.b.x - STATIC_CAPSULE.a.x,
STATIC_CAPSULE.b.y - STATIC_CAPSULE.a.y,
STATIC_CAPSULE.b.z - STATIC_CAPSULE.a.z,
);
// Every shape is built once and shown or hidden, so switching pairings costs nothing.
const staticShapes: Record<Kind, ReturnType<typeof wire>> = {
spheres: wire(new THREE.SphereGeometry(STATIC_SPHERE.radius, 20, 14)),
"sphere and box": wire(
new THREE.BoxGeometry(
STATIC_BOX.max.x - STATIC_BOX.min.x,
STATIC_BOX.max.y - STATIC_BOX.min.y,
STATIC_BOX.max.z - STATIC_BOX.min.z,
),
),
boxes: wire(
new THREE.BoxGeometry(
STATIC_BOX.max.x - STATIC_BOX.min.x,
STATIC_BOX.max.y - STATIC_BOX.min.y,
STATIC_BOX.max.z - STATIC_BOX.min.z,
),
),
capsules: wire(
new THREE.CapsuleGeometry(
STATIC_CAPSULE.radius,
capsuleAxis.length(),
8,
16,
),
),
};
staticShapes.capsules.mesh.quaternion.setFromUnitVectors(
new THREE.Vector3(0, 1, 0),
capsuleAxis.clone().normalize(),
);
const movingShapes: Record<Kind, ReturnType<typeof wire>> = {
spheres: wire(new THREE.SphereGeometry(MOVING_SPHERE_RADIUS, 18, 12)),
"sphere and box": wire(
new THREE.SphereGeometry(MOVING_SPHERE_RADIUS, 18, 12),
),
boxes: wire(
new THREE.BoxGeometry(
MOVING_BOX_HALF * 2,
MOVING_BOX_HALF * 2,
MOVING_BOX_HALF * 2,
),
),
capsules: wire(
new THREE.CapsuleGeometry(
MOVING_CAPSULE_RADIUS,
MOVING_CAPSULE_HALF * 2,
8,
14,
),
),
};
let kind: Kind = "spheres";
const show = addReadout(el);
const mark = addButtonRow(
el,
KINDS.map((k) => ({
label: k,
apply: () => {
kind = k;
draw();
},
})),
);
const mx = addSlider(el, "move across", -5, 5, 2.6, draw, " m", 0.1);
const my = addSlider(el, "move up", -5, 5, 0.7, draw, " m", 0.1);
const mz = addSlider(el, "move towards you", -5, 5, 0, draw, " m", 0.1);
const spin = addSlider(el, "walk around it", -180, 180, 30, draw);
function draw() {
const p = { x: mx(), y: my(), z: mz() };
const { separation, detail } = testAt(kind, p);
const colour = separation < 0 ? TOUCHING : CLEAR;
for (const k of KINDS) {
staticShapes[k].mesh.visible = k === kind;
movingShapes[k].mesh.visible = k === kind;
staticShapes[k].mat.color.setHex(colour);
movingShapes[k].mat.color.setHex(colour);
}
movingShapes[kind].mesh.position.set(p.x, p.y, p.z);
mark(KINDS.indexOf(kind));
const a = (spin() * Math.PI) / 180;
camera.position.set(Math.sin(a) * 13, 5.5, Math.cos(a) * 13);
camera.lookAt(0.8, 0, 0);
show(
separation < 0
? `overlapping by ${(-separation).toFixed(2)} m \u00B7 ${detail}`
: `clear by ${separation.toFixed(2)} m \u00B7 ${detail}`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; Pick a pairing, then move the second shape through the first. Red means overlapping, teal means clear, and the readout gives the separation and names what the test measured.
Two things worth noticing while you do it. Sliding the sphere at the box, the separation stops
changing once the centre is inside - closestOnBox returns the centre itself there, so the
answer saturates at minus the radius. That is fine for a test against zero and wrong for a
push-out depth, which is why Section 6.3 uses the signed distance instead. And on the capsule
pairing, the shapes touch at whatever pair of points happens to be closest, which is the whole
reason a capsule test needs a segment-to-segment routine rather than a distance between centres.
Boxes: Gaps, and the Axis That Proves It
Section titled “Boxes: Gaps, and the Axis That Proves It”Two axis-aligned boxes do not fit the distance-minus-radii pattern, and what they do instead turns out to be more interesting.
Look at one axis at a time. The gap on that axis is whichever box starts after the other one ends:
Do it three times and take the largest. If any axis has a positive gap the boxes cannot be touching, whatever the other two say — and that axis is the proof.
That is a separating axis: a direction on which the two shapes’ shadows do not overlap. One is enough. And this is not a trick specific to boxes, it is the general principle:
If you can find a direction along which two convex shapes’ shadows are apart, the shapes are apart. If no such direction exists, they touch.
For axis-aligned boxes there are only three candidate directions, so the test is three comparisons. For oriented boxes there are more, and the number is not obvious.
src/lib/gamedev/demos/satmath.ts /** Two turned boxes that overlap on all six face axes, and the axis that proves they do not touch. */
import { obbSeparationAlong } from "../collision.ts";
import { BOX_A, BOX_B, CROSS_AXES, FACE_AXES, worstGap } from "./sat-shared.ts";
import type { Demo } from "./runner.ts";
const demo: Demo = (log) => {
const face = worstGap(FACE_AXES);
const cross = worstGap(CROSS_AXES);
log(
"the six face axes: widest gap found",
face.gap.toFixed(4),
"negative, so every one of them overlaps",
);
log(
"...on which axis",
face.name,
"the tightest of the six, and still not separating",
);
// Any one positive gap ends the test, and here it is on an edge-versus-edge direction.
log(
"the nine cross axes: widest gap found",
cross.gap.toFixed(4),
"positive, so the boxes are apart",
);
log(
"...on which axis",
cross.name,
"one box's edge crossed with the other's",
);
log(
"checking only the six face axes",
"reports a collision",
"which is wrong, and only for boxes at these angles",
);
log(
"gap along box A's long axis",
obbSeparationAlong(BOX_A, BOX_B, BOX_A.axes[0]).toFixed(4),
"deeply overlapping, which is why the mistake looks reasonable",
);
};
export default demo; Those two boxes overlap on all six of their own face axes — the widest gap among the six is , comfortably negative. A test that checked only those six would report a collision. They are not touching: a direction perpendicular to one edge of each box separates them by , and brute force confirms it, with no sampled point of either box inside the other.
So the full test needs fifteen axes: three from each box, plus the nine cross products of one box’s axis with the other’s. Those nine catch exactly the case above, an edge of one crossing an edge of the other with a gap between them.
Notice how small both numbers are — against . An edge-edge separation is always a near miss, which is what makes the shortcut so dangerous: skipping the nine cross axes does not break anything obviously. It produces a rare false contact at particular angles, the kind of bug that gets closed as unreproducible.
The good news is that finding one positive gap ends the test. Most pairs are separated by an obvious axis and the loop exits on the first or second try.
Ray Against a Box: Slabs
Section titled “Ray Against a Box: Slabs”A pair of parallel faces defines a slab — everything between two parallel planes. A box is three slabs overlapping, one per axis. So a ray is inside the box exactly where it is inside all three slabs at once.
For each slab, work out where the ray crosses the two planes:
Order them, and that stretch of the ray is inside that slab. Then intersect the three stretches:
enter is the largest of the three entries, exit is the smallest of the three exits. If
enter ends up past exit, the ray misses — it was inside two slabs at one moment and the third
at another, never all three together.
src/lib/gamedev/demos/slabs.scene.ts /** A ray against a box, with the three slab stretches stacked on one axis underneath. */
import * as THREE from "three";
import { AXES, BOX, RAY_ORIGIN, resultFor } from "./slabs-shared.ts";
import { makeCanvas, addSlider, addReadout, addTimeline } from "./ui.ts";
import type { MountFn } from "./runner.ts";
const AXIS_COLOUR = { x: 0x58a6ff, y: 0x7ee787, z: 0xd2a8ff } as const;
const FACES = {
x: "left and right",
y: "top and bottom",
z: "front and back",
} as const;
const INSIDE = 0x39d3c3;
const RAY = 0xf0883e;
const DIM = 0x484f58;
const REACH = 13;
const FROM = -6;
const TO = 14;
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 300);
const scene = new THREE.Scene();
scene.background = background;
const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 200);
scene.add(
new THREE.LineSegments(
new THREE.EdgesGeometry(
new THREE.BoxGeometry(
BOX.max.x - BOX.min.x,
BOX.max.y - BOX.min.y,
BOX.max.z - BOX.min.z,
),
),
new THREE.LineBasicMaterial({ color: DIM }),
),
);
const lineOf = (color: number) => {
const geom = new THREE.BufferGeometry();
const mesh = new THREE.Line(geom, new THREE.LineBasicMaterial({ color }));
scene.add(mesh);
// Hide with visibility. An empty point list poisons the buffer or leaves stale vertices.
return (pts: THREE.Vector3[]) => {
mesh.visible = pts.length > 1;
if (mesh.visible) geom.setFromPoints(pts);
};
};
const rayLine = lineOf(RAY);
const insideLine = lineOf(INSIDE);
const dotAt = (color: number, radius: number) => {
const m = new THREE.Mesh(
new THREE.SphereGeometry(radius, 14, 10),
new THREE.MeshBasicMaterial({ color }),
);
scene.add(m);
return m;
};
const start = dotAt(RAY, 0.2);
start.position.set(RAY_ORIGIN.x, RAY_ORIGIN.y, RAY_ORIGIN.z);
const ends = [dotAt(INSIDE, 0.17), dotAt(INSIDE, 0.17)];
const show = addReadout(el);
const bars = addTimeline(
el,
"distance along the ray, in meters",
[...AXES.map((a) => AXIS_COLOUR[a]), INSIDE],
FROM,
TO,
);
const yaw = addSlider(el, "aim sideways", -25, 25, -5, draw, "\u00B0", 0.5);
const pitch = addSlider(el, "aim up", -25, 25, 4, draw, "\u00B0", 0.5);
const spin = addSlider(el, "walk around it", -180, 180, 24, draw);
function draw() {
const { direction, hit, slabs, blame } = resultFor(yaw(), pitch());
const at = (t: number) =>
new THREE.Vector3(
RAY_ORIGIN.x + direction.x * t,
RAY_ORIGIN.y + direction.y * t,
RAY_ORIGIN.z + direction.z * t,
);
rayLine([at(0), at(REACH)]);
if (hit) {
insideLine([at(Math.max(hit.enter, 0)), at(hit.exit)]);
ends[0].visible = true;
ends[1].visible = true;
ends[0].position.copy(at(Math.max(hit.enter, 0)));
ends[1].position.copy(at(hit.exit));
} else {
insideLine([]);
ends.forEach((d) => (d.visible = false));
}
bars([
...slabs.map((s) => {
const name = `${s.axis}, ${FACES[s.axis]}`;
if (s.interval === null) return { text: `${name}: none`, span: null };
if (!Number.isFinite(s.interval.enter)) {
return { text: `${name}: all of it`, span: { from: FROM, to: TO } };
}
return {
text: `${name}: ${s.interval.enter.toFixed(1)} to ${s.interval.exit.toFixed(1)}`,
span: { from: s.interval.enter, to: s.interval.exit },
};
}),
hit
? {
text: `all three: ${hit.enter.toFixed(1)} to ${hit.exit.toFixed(1)}`,
span: { from: hit.enter, to: hit.exit },
}
: { text: "all three: no overlap", span: null },
]);
const a = (spin() * Math.PI) / 180;
camera.position.set(Math.sin(a) * 14, 5, Math.cos(a) * 14);
camera.lookAt(-1, -0.6, 0);
show(
hit
? `the three stretches overlap from ${hit.enter.toFixed(2)} m to ${hit.exit.toFixed(2)} m, and that overlap is the part inside the box`
: `miss: ${blame}`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; The orange line is the ray, starting at the orange dot. The bars underneath are the three stretches, all measured along that same ray and stacked so they can be compared directly:
| Bar | Slab | Bounded by |
|---|---|---|
| blue | the slab | left and right faces |
| green | the slab | top and bottom faces |
| purple | the slab | front and back faces |
| teal | all three at once | the box itself |
The axis under the bars is distance along the ray in meters, with a tick marking where the ray starts. The teal bar is the overlap of the other three, and the teal segment up in the scene is that same stretch drawn where it actually sits, with a dot at each end.
Aim away from the box and watch the bars slide apart. The teal bar vanishes the moment any two of them stop overlapping, and the readout names which two, because a miss is never vague: it is always two specific stretches that failed to share any of the ray.
A stretch can begin before zero, and the and ones usually do. The ray starts level with the middle of the box, so it is already between the top and bottom faces when it sets off, and the crossing that would have put it there sits behind its start at a negative . Those two slabs are not what stops this ray. The slab is, because the box is off to one side.
Which is exactly how a miss happens here. Aim well below level and the stretch ends early — the ray has dropped below the bottom face — while the stretch does not begin until the ray is alongside the box. One ends before the other starts, so the ray passes underneath, and the readout says so in those words.
Two cases the sliders will find. Set the pitch to exactly zero and the ray becomes parallel to the top and bottom faces. There is no crossing to compute, so that slab is either the whole ray or none of it depending on which side the ray started — and dividing anyway gives , or if it starts exactly on a face. Same guard as Section 6.1’s ray-plane test, same reason.
And a negative enter with a positive exit means the ray started inside the box. Callers
usually want to know that rather than be handed a hit at a negative distance.
The build check sweeps 729 rays through the box and compares the slab answer against walking along the ray and asking whether any step of it is inside — two completely different methods, zero disagreements, with the sweep verified to contain both hits and misses so it cannot pass by being trivial.
source Bounding volumes and the tests between them
/**
* Bounding volumes, and the tests that ask whether two of them touch.
*
* **Every test in this file returns one number: the separation.** Negative means the shapes
* overlap, zero means they are exactly touching, positive is the gap between them. So
* "are we colliding" is `separation < 0` for all of them, and the number itself is useful -
* it is how far apart they are, or how deep they are in, which is what Section 6.3 needs.
*
* That is Section 6.1's signed distance again, applied to pairs of shapes instead of to a
* point and a shape, and it is the reason these tests are short. Two spheres reduce to a
* distance minus two radii. A sphere and a box reduce to `closestOnBox` plus that same
* subtraction. Two capsules reduce to the distance between two segments, minus two radii.
*
* Ray-sphere lives in `projection.ts`, where picking needed it in Section 5.2, and is not
* repeated here.
*/
import type { Vec3 } from "./matrices.ts";
import { closestOnBox, closestOnSegment, magnitude } from "./geometry.ts";
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,
});
const dot3 = (a: Vec3, b: Vec3) => a.x * b.x + a.y * b.y + a.z * b.z;
const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
/** A ball. The cheapest volume there is, and rotation-proof: turning it changes nothing. */
export type Sphere = { centre: Vec3; radius: number };
/** An axis-aligned box, stored as two opposite corners. Cannot be rotated. */
export type Aabb = { min: Vec3; max: Vec3 };
/** A segment with a radius: a cylinder with a hemisphere on each end. */
export type Capsule = { a: Vec3; b: Vec3; radius: number };
/** A box that can be turned. Its own three axes, plus half-widths along each of them. */
export type Obb = { centre: Vec3; axes: [Vec3, Vec3, Vec3]; half: Vec3 };
// ---- Volume against volume ---------------------------------------------------------------
/**
* Two spheres: the distance between the centres, minus both radii.
*
* Nothing else needs saying, which is exactly why spheres are the first thing a broad phase
* reaches for. Note that a real broad phase compares **squared** distance against the
* squared radius sum, as Section 1.2 argued, and only takes the square root when it needs
* the separation as a number rather than a yes or no.
*/
export function sphereSphere(a: Sphere, b: Sphere): number {
return magnitude(sub(b.centre, a.centre)) - a.radius - b.radius;
}
/**
* A sphere against an axis-aligned box: the closest point on the box, minus the radius.
*
* This is Section 6.1's three clamps and one subtraction. It is worth noticing that the
* result is only correct **outside** the box - once the centre is inside, `closestOnBox`
* returns the centre itself and the answer saturates at `-radius` instead of continuing to
* grow. For a test against zero that does not matter. For a push-out depth it does, and
* Section 6.3 uses the signed distance instead for that reason.
*/
export function sphereAabb(s: Sphere, box: Aabb): number {
return (
magnitude(sub(s.centre, closestOnBox(box.min, box.max, s.centre))) -
s.radius
);
}
/**
* Two axis-aligned boxes, and **which axis separated them**.
*
* The gap along one axis is whichever box starts after the other one ends. Do that three
* times and take the largest: if any axis has a positive gap the boxes cannot be touching,
* no matter what the other two say, and that axis is the proof.
*
* This is the separating-axis idea in its simplest possible form. An axis on which the two
* shadows do not overlap is a **separating axis**, and one is enough to rule out contact.
* When all three overlap, the largest gap is the negative number closest to zero, which is
* the shallowest direction - the cheapest way back out.
*/
export function aabbAabb(
a: Aabb,
b: Aabb,
): { separation: number; axis: "x" | "y" | "z" } {
const axes: Array<"x" | "y" | "z"> = ["x", "y", "z"];
let worst = -Infinity;
let which: "x" | "y" | "z" = "x";
for (const k of axes) {
const gap = Math.max(a.min[k] - b.max[k], b.min[k] - a.max[k]);
if (gap > worst) {
worst = gap;
which = k;
}
}
return { separation: worst, axis: which };
}
/**
* The closest pair of points on two segments.
*
* This is the only routine here that is genuinely fiddly, and it is worth the trouble
* because it is what makes capsules cheap. Solve for the closest points as if both were
* infinite lines, clamp one parameter to its segment, then **re-solve the other** against
* the clamped value. Skipping that re-solve is the classic bug: it gives a point that is
* closest to the wrong line and it is only wrong near the ends.
*
* The parallel case has no unique answer - every pair along the overlap ties - so a zero
* denominator picks one rather than dividing by it.
*/
export function closestBetweenSegments(
p1: Vec3,
q1: Vec3,
p2: Vec3,
q2: Vec3,
): { c1: Vec3; c2: Vec3 } {
const d1 = sub(q1, p1);
const d2 = sub(q2, p2);
const r = sub(p1, p2);
const a = dot3(d1, d1);
const e = dot3(d2, d2);
const f = dot3(d2, r);
const tiny = 1e-12;
// Degenerate segments are points, and a point against a segment is Section 6.1's clamp.
if (a <= tiny && e <= tiny) return { c1: p1, c2: p2 };
if (a <= tiny) return { c1: p1, c2: add(p2, mul(d2, clamp01(f / e))) };
const c = dot3(d1, r);
if (e <= tiny) return { c1: add(p1, mul(d1, clamp01(-c / a))), c2: p2 };
const b = dot3(d1, d2);
const denominator = a * e - b * b;
let s = denominator !== 0 ? clamp01((b * f - c * e) / denominator) : 0;
let t = (b * s + f) / e;
// t left its segment, so pin it and solve s again against the pinned value.
if (t < 0) {
t = 0;
s = clamp01(-c / a);
} else if (t > 1) {
t = 1;
s = clamp01((b - c) / a);
}
return { c1: add(p1, mul(d1, s)), c2: add(p2, mul(d2, t)) };
}
/**
* Two capsules: the distance between their segments, minus both radii.
*
* Structurally identical to two spheres, with a segment where each centre was. That is the
* whole reason a capsule is the shape almost every character uses - it covers a standing
* body far better than a sphere, it has no corners to catch on stairs the way a box does,
* and it stays this cheap.
*/
export function capsuleCapsule(a: Capsule, b: Capsule): number {
const { c1, c2 } = closestBetweenSegments(a.a, a.b, b.a, b.b);
return magnitude(sub(c2, c1)) - a.radius - b.radius;
}
/** A sphere against a capsule: distance from the centre to the segment, minus both radii. */
export function sphereCapsule(s: Sphere, c: Capsule): number {
return (
magnitude(sub(s.centre, closestOnSegment(c.a, c.b, s.centre))) -
s.radius -
c.radius
);
}
// ---- Ray against an axis-aligned box, by slabs -------------------------------------------
/**
* The stretch of the ray that lies between one pair of parallel planes.
*
* A box is three of these overlapping. Each pair of faces defines a **slab**, the ray enters
* it at one `t` and leaves at another, and the ray is inside the box only where all three
* stretches overlap at once.
*
* A ray parallel to a slab never crosses either plane, so the interval is everything or
* nothing depending on which side it started - and that is the case that produces
* `0 / 0` if the division is done blindly. Note the swap: a negative direction component
* makes the far face the entry.
*/
export function slabInterval(
origin: number,
direction: number,
lo: number,
hi: number,
): { enter: number; exit: number } | null {
if (Math.abs(direction) < 1e-12) {
return origin >= lo && origin <= hi
? { enter: -Infinity, exit: Infinity }
: null;
}
const t1 = (lo - origin) / direction;
const t2 = (hi - origin) / direction;
return t1 <= t2 ? { enter: t1, exit: t2 } : { enter: t2, exit: t1 };
}
/**
* Ray against a box: intersect the three slab intervals.
*
* `enter` is the largest of the three entries and `exit` the smallest of the three exits.
* If the entry ends up past the exit, the ray misses - it was inside two slabs at one
* moment and the third at another, never all three together.
*
* A negative `enter` with a positive `exit` means the ray started **inside** the box, which
* callers usually want to know about rather than treat as a hit at a negative distance.
*/
export function rayAabb(
origin: Vec3,
direction: Vec3,
box: Aabb,
): { enter: number; exit: number; startedInside: boolean } | null {
let enter = -Infinity;
let exit = Infinity;
for (const k of ["x", "y", "z"] as const) {
const slab = slabInterval(origin[k], direction[k], box.min[k], box.max[k]);
if (slab === null) return null;
enter = Math.max(enter, slab.enter);
exit = Math.min(exit, slab.exit);
if (enter > exit) return null;
}
if (exit < 0) return null;
return { enter, exit, startedInside: enter < 0 };
}
// ---- Oriented boxes, and the general form of the same idea -------------------------------
/**
* The shadow an oriented box casts on a direction, as an interval.
*
* Project the centre for the middle of the interval, and add up how far each half-width
* reaches along the axis for its width. The absolute values are there because it does not
* matter which way each of the box's own axes happens to point.
*/
export function obbInterval(box: Obb, axis: Vec3): { lo: number; hi: number } {
const middle = dot3(box.centre, axis);
const reach =
Math.abs(dot3(box.axes[0], axis)) * box.half.x +
Math.abs(dot3(box.axes[1], axis)) * box.half.y +
Math.abs(dot3(box.axes[2], axis)) * box.half.z;
return { lo: middle - reach, hi: middle + reach };
}
/** The gap between two shadows on one axis. Positive means that axis separates them. */
export function intervalGap(
a: { lo: number; hi: number },
b: { lo: number; hi: number },
): number {
return Math.max(a.lo - b.hi, b.lo - a.hi);
}
/**
* Two oriented boxes, tested along one axis you supply.
*
* The full test needs **fifteen** axes: the three of each box, plus the nine cross products
* of one box's axis with the other's. Find a positive gap on any of them and you are done -
* the boxes are apart, and you can stop. Only when all fifteen overlap do they touch.
*
* Two boxes can be clear of each other while overlapping on all six of their own axes, and
* the nine cross-product axes are what catch that: an edge of one crossing an edge of the
* other. This function is the piece the loop calls, kept separate so the idea is visible
* without the bookkeeping around it.
*/
export function obbSeparationAlong(a: Obb, b: Obb, axis: Vec3): number {
const length = magnitude(axis);
if (length < 1e-9) return -Infinity; // A degenerate axis proves nothing either way.
const unit = mul(axis, 1 / length);
return intervalGap(obbInterval(a, unit), obbInterval(b, unit));
}
// ---- Broad phase -------------------------------------------------------------------------
/** The box that contains a sphere. What a broad phase stores instead of the sphere. */
export function aabbOfSphere(s: Sphere): Aabb {
const r = { x: s.radius, y: s.radius, z: s.radius };
return { min: sub(s.centre, r), max: add(s.centre, r) };
}
/** The box that contains a capsule: both end spheres, combined. */
export function aabbOfCapsule(c: Capsule): Aabb {
const r = { x: c.radius, y: c.radius, z: c.radius };
return {
min: sub(
{
x: Math.min(c.a.x, c.b.x),
y: Math.min(c.a.y, c.b.y),
z: Math.min(c.a.z, c.b.z),
},
r,
),
max: add(
{
x: Math.max(c.a.x, c.b.x),
y: Math.max(c.a.y, c.b.y),
z: Math.max(c.a.z, c.b.z),
},
r,
),
};
}
/**
* How many pairs `n` objects have. This is the number that forces a broad phase to exist.
*
* It grows as the square, so it is not a constant factor you can optimise away by making
* the narrow test faster - it is the reason the narrow test must not run on most pairs.
*/
export function pairCount(n: number): number {
return (n * (n - 1)) / 2;
} Ray against a sphere is not in there. It was needed for picking in Section 5.2 and lives in
projection.ts; the algebra is substituting the ray into and solving the
quadratic, where the discriminant’s sign is the hit test.
Why a Broad Phase Has to Exist
Section titled “Why a Broad Phase Has to Exist”None of the tests above is slow. The problem is how many of them there are.
| Objects | Pairs |
|---|---|
| 10 | 45 |
| 50 | 1,225 |
| 200 | 19,900 |
| 1,000 | 499,500 |
| 5,000 | 12,497,500 |
That growth is the problem, and making the narrow test faster does not solve it. Halving the cost of a capsule test buys you a factor of two; going from 1,000 objects to 5,000 costs you a factor of twenty-five.
So collision runs in two stages:
- Broad phase. Cheaply rule out pairs that cannot possibly touch, using loose bounding boxes and a spatial structure — a uniform grid, a sweep along one axis, or a bounding-volume hierarchy. The output is a short list of candidate pairs.
- Narrow phase. Run the real test on what survives.
This is why aabbOfSphere and aabbOfCapsule are in the module. A broad phase stores one loose
box per object regardless of the object’s real shape, because a box is what a grid or a tree can
index.
A broad phase must never miss a real contact. It is allowed to pass through pairs that turn out not to touch — the narrow phase will reject those — but a bounding box that fails to contain its object drops real collisions, and that bug looks like objects occasionally passing through each other. The check asserts containment for exactly that reason.
Where This Shows Up
Section titled “Where This Shows Up”- Any character touching any wall, which is a capsule against level geometry.
- Hitscan weapons and line of sight, which are rays against bounding volumes before anything more expensive gets considered.
- Trigger volumes and pickup radii, usually a sphere test with squared distance so no square root is taken.
- Frustum culling, which is Section 5.1’s six planes against each object’s bounding sphere or box — the same separating-axis idea with the planes already chosen.
- Selection boxes in a strategy game, an AABB against every unit’s bounds.
- Spatial queries like “everything within 20 meters”, which is the broad phase used directly.
- Section 6.3, which takes the separation and the direction and turns them into movement.