Local, World, View and Clip Space
What You’ll Learn
Section titled “What You’ll Learn”That “where is this thing” has no single answer, because there are several spaces and a position only means something once you say which one you meant. How parent-child hierarchies fall out of one multiplication. How to get back, which needs an inverse. And the one genuine exception in this whole Part: a normal must not be transformed by the object’s own matrix, and what goes wrong when it is.
The Same Point, Several Answers
Section titled “The Same Point, Several Answers”A vertex on a character’s hand has a position. That position is a different set of three numbers depending on who is asking.
| Space | Origin is at | Set by | Used for |
|---|---|---|---|
| Local | the object’s own pivot | whoever made the model | the mesh as it was authored |
| World | an agreed point in the level | the object’s transform | positions, distances, physics, gameplay |
| View | the camera | the camera’s transform, inverted | what is in front of you and how far |
| Clip | the middle of the screen | the projection matrix | deciding what is on screen at all |
Each arrow between two rows is a matrix, and the whole trip is those matrices multiplied together - which is Section 2.3’s machinery with nothing new added:
Read right to left, as always. is the object’s own transform, usually called the model matrix, and it is exactly the from Section 2.3. is the view matrix. is the projection matrix, and it is the one thing here that Part 5 covers rather than this Section.
Here is one point making that trip. The numbers below ran during the build.
src/lib/gamedev/demos/pipeline.ts /** One corner of one object, written out in each space it passes through. */
import {
applyMat4,
multiplyMat4,
point,
rotationY4,
translation4,
} from "../matrices.ts";
import { toWorld, viewFrom } from "../spaces.ts";
import type { Demo } from "./runner.ts";
const fmt = (v: { x: number; y: number; z: number }) =>
`(${v.x.toFixed(2)}, ${v.y.toFixed(2)}, ${v.z.toFixed(2)})`;
const demo: Demo = (log) => {
// A child sitting on a parent that has been moved and turned a quarter turn.
const parent = multiplyMat4(translation4(1, 0, 0), rotationY4(90));
const child = translation4(0, 0.6, 1.3);
const model = toWorld([parent, child]);
// A camera two meters up and six back, looking down its own -Z as everything does.
const cameraWorld = translation4(0, 2, 6);
const view = viewFrom(cameraWorld)!;
const local = point(0, 0, 0);
const world = applyMat4(model, local);
const eye = applyMat4(view, world);
log("local space, the child's own origin", fmt(local));
log("world space, model * local", fmt(world), "the parent's turn moved it");
log(
"view space, view * world",
fmt(eye),
"negative z is in front of the camera",
);
log(
"the camera itself, in view space",
fmt(applyMat4(view, point(0, 2, 6))),
"always the origin - that is what view space means",
);
};
export default demo; The last row is the one worth pausing on. In view space the camera is always at the origin, because that is the definition of view space rather than a coincidence. And the point in front of it has a negative , since forward is .
World Space Is a Choice, Not a Fact
Section titled “World Space Is a Choice, Not a Fact”Local and view space have obvious origins - the model’s pivot, the camera. World space does not. Somebody picked it.
That matters more than it sounds, because floating point precision gets worse the further you are from the origin. A 32-bit float has about seven significant digits, so at 10 meters from the origin you can resolve about a micrometer, and at 10,000 kilometers you can resolve about a meter. Objects visibly jitter.
Which is why large games do not use a single world space. They re-centre it on the player periodically, or store positions relative to a nearby chunk. If you have ever seen a space game wobble as you fly away from the start, that is this.
Parenting Is One Multiplication
Section titled “Parenting Is One Multiplication”A hierarchy - a turret on a tank, a hand on an arm, a camera on a rig - is not a new feature. It is the child’s transform multiplied by the parent’s.
The child’s matrix goes on the right because the child’s own transform happens first, and the parent’s is applied to the result. Same right-to-left reading as ever.
Below, the blue box is a parent and the teal box is its child. The child’s local position is fixed at and never changes, no matter what you do with the sliders. The dashed orange box is that same child drawn as though its local transform were a world transform - parent ignored.
src/lib/gamedev/demos/parenting.scene.ts /**
* A child attached to a parent, and the same child with its parent ignored.
*/
import * as THREE from "three";
import {
applyMat4,
multiplyMat4,
point,
rotationY4,
scale4,
translation4,
type Mat4,
} from "../matrices.ts";
import { toWorld } from "../spaces.ts";
import {
makeCanvas,
addSlider,
addReadout,
addBoxWire,
type Place,
} from "./ui.ts";
import type { MountFn } from "./runner.ts";
/** Where the child sits in its parent's space. This never changes, whatever the parent does. */
const CHILD_OFFSET = { x: 0, y: 0.6, z: 1.3 };
const via =
(m: Mat4): Place =>
(c) => {
const p = applyMat4(m, point(c[0], c[1], c[2]));
return [p.x, p.y, p.z];
};
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 300);
const scene = new THREE.Scene();
scene.background = background;
scene.add(new THREE.GridHelper(10, 10, 0x30363d, 0x21262d));
const camera = new THREE.PerspectiveCamera(42, width / height, 0.1, 100);
camera.position.set(4.5, 4.5, 6.5);
camera.lookAt(0, 0.5, 0);
const parentBox = addBoxWire(scene, 0x58a6ff);
const childBox = addBoxWire(scene, 0x39d3c3);
// The same child placed as if its local transform were a world transform.
const looseBox = addBoxWire(scene, 0xf0883e, { dashed: true });
// The link from parent origin to child origin, which is what "attached" means.
const link = new THREE.Line(
new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({ color: 0x7d8590 }),
);
scene.add(link);
const show = addReadout(el);
const parentTurn = addSlider(el, "parent yaw", 0, 360, 35, draw);
const parentX = addSlider(el, "parent along x", -3, 3, 1, draw, "", 0.5);
const childTurn = addSlider(
el,
"child yaw, in parent space",
0,
360,
0,
draw,
);
function draw() {
// Each object's own transform, in its own space. Neither knows about the other.
const parentLocal = multiplyMat4(
translation4(parentX(), 0, 0),
rotationY4(parentTurn()),
);
const childLocal = multiplyMat4(
translation4(CHILD_OFFSET.x, CHILD_OFFSET.y, CHILD_OFFSET.z),
multiplyMat4(rotationY4(childTurn()), scale4(0.45, 0.45, 0.45)),
);
// Parenting is one multiplication. That is the entire mechanism.
const childWorld = toWorld([parentLocal, childLocal]);
parentBox(via(parentLocal));
childBox(via(childWorld));
looseBox(via(childLocal));
const a = applyMat4(parentLocal, point(0, 0, 0));
const b = applyMat4(childWorld, point(0, 0, 0));
link.geometry.setFromPoints([
new THREE.Vector3(a.x, a.y, a.z),
new THREE.Vector3(b.x, b.y, b.z),
]);
show(
`child local (${CHILD_OFFSET.x.toFixed(1)}, ${CHILD_OFFSET.y.toFixed(1)}, ` +
`${CHILD_OFFSET.z.toFixed(1)}) never changes · ` +
`child world (${b.x.toFixed(1)}, ${b.y.toFixed(1)}, ${b.z.toFixed(1)})`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; Drag the parent sliders. The teal child follows, because its world matrix is being rebuilt from the parent’s. The orange one sits still, because nothing multiplied it.
That gap between teal and orange is what “local space” means. The child’s numbers did not change; what they are measured against did.
Going Back Needs an Inverse
Section titled “Going Back Needs an Inverse”Every arrow in that table runs the other way too, and running it backwards means inverting the matrix.
- World to local to ask “is that point inside my box?” - far easier in the box’s own space, where the box is axis-aligned.
- World to view, which is the view matrix, and is literally the camera’s transform inverted. Moving the camera two meters right is the same as moving the entire world two meters left.
- Parent to child when re-parenting something without letting it move.
For the transforms in this Part the inverse has a shortcut. Split the matrix into its 3×3 basis and its translation ; then
The translation is rather than just , because you have to undo the rotation and scale before an offset means anything in the original space.
Normals Are the Exception
Section titled “Normals Are the Exception”Everything so far transforms with the object’s own matrix. Here is the one thing that does not, and it is the most commonly gotten-wrong item in this Part.
A normal is perpendicular to a surface. That word - perpendicular - is the problem, because perpendicularity is not preserved by uneven scaling.
Squash a sphere vertically. Every direction along the surface tips towards horizontal. So a direction perpendicular to the surface has to tip the other way, towards vertical. Push a normal through the object’s own matrix and it tips along with the surface, which is exactly backwards.
The correct matrix is the inverse transpose of the basis:
Below, both are drawn. Orange normals went through the object’s matrix. Teal normals went through the inverse transpose.
src/lib/gamedev/demos/normals.scene.ts /**
* A squashed sphere with its normals drawn twice: through the object's matrix, and correctly.
*/
import * as THREE from "three";
import { scale4 } from "../matrices.ts";
import {
profileSamples,
transformSample,
degreesOff,
type Sample,
} from "./normals-shared.ts";
import { makeCanvas, addSlider, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";
const SAMPLES: Sample[] = profileSamples(16);
const ARROW = 0.55;
/** A set of line segments we can rewrite each frame. */
function addArrows(scene: THREE.Scene, color: number) {
const geom = new THREE.BufferGeometry();
geom.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array(SAMPLES.length * 6), 3),
);
scene.add(
new THREE.LineSegments(geom, new THREE.LineBasicMaterial({ color })),
);
return (pts: THREE.Vector3[]) => geom.setFromPoints(pts);
}
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(38, width / height, 0.1, 100);
camera.position.set(1.6, 1.2, 5.4);
camera.lookAt(0, 0, 0);
const shell = new THREE.Mesh(
new THREE.SphereGeometry(1, 24, 14),
new THREE.MeshBasicMaterial({
color: 0x30363d,
wireframe: true,
}),
);
scene.add(shell);
const naiveArrows = addArrows(scene, 0xf0883e);
const goodArrows = addArrows(scene, 0x39d3c3);
const show = addReadout(el);
const squash = addSlider(el, "squash along y", 0.15, 1, 0.35, draw, "", 0.05);
function draw() {
const m = scale4(1, squash(), 1);
shell.scale.set(1, squash(), 1);
const naive: THREE.Vector3[] = [];
const good: THREE.Vector3[] = [];
let worstNaive = 0;
let worstGood = 0;
for (const s of SAMPLES) {
const t = transformSample(m, s);
const from = new THREE.Vector3(t.at.x, t.at.y, t.at.z);
naive.push(
from,
from
.clone()
.add(
new THREE.Vector3(t.naive.x, t.naive.y, t.naive.z).multiplyScalar(
ARROW,
),
),
);
if (t.correct) {
good.push(
from,
from
.clone()
.add(
new THREE.Vector3(
t.correct.x,
t.correct.y,
t.correct.z,
).multiplyScalar(ARROW),
),
);
worstGood = Math.max(worstGood, degreesOff(t.correct, t.tangents));
}
worstNaive = Math.max(worstNaive, degreesOff(t.naive, t.tangents));
}
naiveArrows(naive);
goodArrows(good);
show(
`orange is off the surface by up to ${worstNaive.toFixed(1)}\u00B0 · ` +
`teal by ${worstGood.toFixed(1)}\u00B0`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; Drag the squash slider. The teal arrows stay perpendicular to the surface at every point. The orange ones lean over, and at the default squash they are more than fifty degrees off.
The readout is not a claim about the picture - it is the measured angle between each normal and the surface directions at that point, so a normal that is correct reads zero by construction rather than by assertion.
source Inverses, parent chains and the normal matrix
/**
* Moving between spaces - local, world, view - and the one transform that is not the
* object's own.
*
* Everything here is built from `matrices.ts`. The reason it is a separate file is that
* every function below exists to answer one of two questions: "how do I get back?" and
* "which matrix does a normal need?" The first is an inverse. The second is an inverse
* transpose, and the difference between them is a bug that renders rather than crashes.
*/
import {
IDENTITY4,
applyMat3,
applyMat4,
determinant3,
direction,
multiplyMat4,
point,
type Mat3,
type Mat4,
type Vec3,
} from "./matrices.ts";
/** The rotation-and-scale block of a 4x4, with the translation dropped. */
export function basisOf(m: Mat4): Mat3 {
return {
i: { x: m.i.x, y: m.i.y, z: m.i.z },
j: { x: m.j.x, y: m.j.y, z: m.j.z },
k: { x: m.k.x, y: m.k.y, z: m.k.z },
};
}
/** Swap rows and columns. */
export function transpose3(m: Mat3): Mat3 {
return {
i: { x: m.i.x, y: m.j.x, z: m.k.x },
j: { x: m.i.y, y: m.j.y, z: m.k.y },
k: { x: m.i.z, y: m.j.z, z: m.k.z },
};
}
/**
* Undo a 3x3, by cofactors. Returns `null` when the matrix flattens space, because then
* there is genuinely nothing to undo - a collapsed volume cannot be un-collapsed.
*/
export function inverse3(m: Mat3): Mat3 | null {
const det = determinant3(m);
if (Math.abs(det) < 1e-12) return null;
// Entry names follow the written matrix, row then column. Recall the columns are stored.
const a = m.i.x;
const b = m.j.x;
const c = m.k.x;
const d = m.i.y;
const e = m.j.y;
const f = m.k.y;
const g = m.i.z;
const h = m.j.z;
const k = m.k.z;
// The nine cofactors: each is the 2x2 determinant left when one row and column are struck.
const c00 = e * k - f * h;
const c01 = -(d * k - f * g);
const c02 = d * h - e * g;
const c10 = -(b * k - c * h);
const c11 = a * k - c * g;
const c12 = -(a * h - b * g);
const c20 = b * f - c * e;
const c21 = -(a * f - c * d);
const c22 = a * e - b * d;
// The inverse is the cofactors transposed and divided through - so a row above becomes a
// column below, which is why the grouping looks shuffled.
return {
i: { x: c00 / det, y: c01 / det, z: c02 / det },
j: { x: c10 / det, y: c11 / det, z: c12 / det },
k: { x: c20 / det, y: c21 / det, z: c22 / det },
};
}
/**
* Undo a whole transform: invert the basis, then send the origin back where it came from.
*
* The translation is `-A^-1 t` rather than `-t`, because you have to undo the rotation and
* scale before the offset means anything in the original space.
*/
export function inverseAffine4(m: Mat4): Mat4 | null {
const inv = inverse3(basisOf(m));
if (inv === null) return null;
const back = applyMat3(inv, { x: m.t.x, y: m.t.y, z: m.t.z });
return {
i: direction(inv.i.x, inv.i.y, inv.i.z),
j: direction(inv.j.x, inv.j.y, inv.j.z),
k: direction(inv.k.x, inv.k.y, inv.k.z),
t: point(-back.x, -back.y, -back.z),
};
}
/**
* The view matrix is not a new kind of thing. It is the camera's own transform, inverted.
*
* Moving the camera right is identical to moving the whole world left, and this is the
* matrix that says so.
*/
export function viewFrom(cameraWorld: Mat4): Mat4 | null {
return inverseAffine4(cameraWorld);
}
/**
* Collapse a parent chain into one world matrix. Outermost ancestor first.
*
* Each child multiplies onto the **right**, because a child's own transform happens first
* and its parent's is applied to the result.
*/
export function toWorld(chain: readonly Mat4[]): Mat4 {
let m = IDENTITY4;
for (const local of chain) m = multiplyMat4(m, local);
return m;
}
/**
* The matrix a **normal** has to be transformed by: the inverse transpose of the basis.
*
* A normal is not an arrow lying along the surface, it is an arrow perpendicular to it, and
* those two behave differently under uneven scaling. Squash a sphere vertically and every
* direction *along* the surface tilts towards horizontal, so a direction *perpendicular* to
* it has to tilt the other way - towards vertical. Pushing a normal through the object's own
* matrix tilts it the wrong way.
*
* For a pure rotation this returns the rotation unchanged, and for uniform scale it returns
* the same direction with a different length, which is why the mistake survives so long.
*/
export function normalMatrix(m: Mat4): Mat3 | null {
const inv = inverse3(basisOf(m));
return inv === null ? null : transpose3(inv);
}
/** Transform a direction by a 4x4, which is `w = 0` and so ignores the translation. */
export function transformDirection(m: Mat4, v: Vec3): Vec3 {
const out = applyMat4(m, direction(v.x, v.y, v.z));
return { x: out.x, y: out.y, z: out.z };
} Clip Space, Briefly
Section titled “Clip Space, Briefly”The last matrix in the chain is the projection, and it does something the others do not: it writes to the bottom row, so stops being 1.
That is the loose end from Section 2.2. Once carries depth, dividing everything by it is what makes distant things smaller, and the result lands in a cube where anything outside the range is off screen and gets clipped. Hence the name.
Part 5 does it properly. What matters here is that clip space is the end of the same chain, reached by one more multiplication, and that it is the point where the fourth component finally earns the generality it was given.
Where This Shows Up
Section titled “Where This Shows Up”- Anything attached to anything. Weapons in hands, wheels on cars, cameras on rigs.
- Wrong-place bugs, which are usually a local position used where a world position was wanted, or the reverse.
- Collision tests, which are far cheaper in an object’s local space where its box is axis-aligned. Part 6 leans on this constantly.
- Lighting on stretched models, which is the normal matrix, every time.
- Jitter far from the origin, which is world space precision rather than a bug in your maths.
- Billboards and UI markers, which need a world position projected all the way to screen space. Section 3.2 of Part 5.