Under the Hood
Threejs

The matrices: model, view, and projection

The vertex shader's one required job is to decide where a vertex lands in clip space, and this lesson is the full mechanism behind that decision — the chain of four 4x4 matrix multiplies and one division that turns a point defined in an object's own local space into a position on your screen.

The matrices: model, view, and projection

The pipeline lesson told you the vertex shader's one required job is to decide "where this vertex lands in clip space," and it showed you the one-line proof: gl_Position = modelViewProjection * vec4(position, 1.0). That line is doing an enormous amount of work packed into a single matrix multiply, and if you don't unpack it, half of Three.js — camera setup, object transforms, why things look stretched or clipped — stays opaque. This lesson unpacks it. It is arguably the single most important piece of math in all of 3D graphics, and it is really just four coordinate-space changes chained together.

The framing question: a vertex in your geometry is defined as (0.5, 1.0, -0.3). That number means nothing on its own — it's a point in the mesh's own private coordinate system. Somehow that needs to become "this pixel, right here, on your 1920x1080 screen." How does a coordinate survive four changes of reference frame and come out the other end as a screen position?

The chain of spaces

Here's the whole trip, named stage by stage. Every arrow below is a 4x4 matrix multiply, except the last one, which is a division.

Local (model) space. When a modeler builds a chair, they don't ask "where in the world is this chair." They build it around its own origin — legs might sit at y = 0, the seat around y = 0.5. Every vertex your BufferGeometry stores (from geometry and buffers) is in this private frame. Ten copies of the same chair mesh in a scene all share the exact same local-space vertex data; what makes them different chairs in different places is everything that comes next.

World space, via the MODEL matrix. World space is the one shared coordinate system the whole scene agrees on — the floor, the walls, every chair, all measured from the same origin. The model matrix is the 4x4 matrix that encodes one object's position, rotation, and scale, and multiplying a local-space vertex by it produces that vertex's world-space position. In Three.js you never write this matrix by hand: it's exactly what a Mesh's .position, .rotation, and .scale (or .matrix directly) produce, combined. And it's rarely just that object's transform in isolation — a mesh nested under a parent Object3D inherits the parent's transform too, so the real model matrix is the product of the whole parent chain up to the scene root. That chain-multiplication is exactly the mechanism the scene graph lesson covers; for now, just know "world space" means "after applying this object's transform and every ancestor's transform above it."

View (camera) space, via the VIEW matrix. Knowing where everything is in the world still doesn't tell you what the camera sees — you need everything expressed relative to the camera, as if the camera sat at the origin looking down one fixed axis. That re-expression is the view matrix.

Clip space, via the PROJECTION matrix. View space still has real, un-squashed 3D depth — things farther away are just "more negative Z," not yet smaller-looking. The projection matrix is what encodes the actual shape of what the camera can see and folds depth into apparent size. Three.js gives you two flavors:

  • Perspective (PerspectiveCamera): the camera's visible volume is a frustum — a pyramid with its tip chopped off, defined by fov (the vertical field-of-view angle), aspect (width/height of the viewport), near, and far (the closest and farthest distances the camera renders). The projection matrix built from those four numbers is specifically shaped so that points farther from the camera get compressed toward the center more aggressively than close points — the mathematical source of "far things look smaller," i.e. foreshortening.
  • Orthographic (OrthographicCamera): the visible volume is just a box (left/right/top/bottom/near/far), with parallel sides. Nothing is compressed based on distance, so two objects of the same size look the same size regardless of how far they are from the camera — no foreshortening at all. This is what you want for blueprints, isometric games, or UI-like 3D.

Multiplying a view-space point by the projection matrix produces a 4-component clip-space coordinate: (x, y, z, w). That fourth component, w, is not decoration — it's about to do the actual work of perspective.

NDC, via the perspective divide. This is the step that is easy to skip past but is actually where "things farther away look smaller" gets computed, not just set up. The GPU takes the clip-space coordinate and divides x, y, and z each by w: (x/w, y/w, z/w). The projection matrix was built specifically so that w ends up equal to the point's original view-space depth (roughly, -z). So a point twice as far from the camera gets a w twice as large, and dividing by a bigger w squashes its x and y proportionally more — which is exactly "looks smaller the farther away it is." The result, called normalized device coordinates (NDC), has x, y, and z each squeezed into the range -1 to 1 (in an orthographic projection, w is just 1, so this divide is a no-op — which is precisely why there's no foreshortening).

Screen space, via the viewport transform. The last step is fixed-function bookkeeping: NDC's -1..1 range gets remapped to actual pixel coordinates — 0 to canvas.width and 0 to canvas.height — using whatever viewport the renderer configured. This is the coordinate rasterization actually consumes to decide which pixels a triangle covers.

Why 4x4 matrices and that mysterious fourth component

Two questions are worth answering directly, because both look like arbitrary conventions until you see the reason.

Why 4x4 matrices for 3D points? A plain 3x3 matrix can rotate and scale a 3D vector just fine, but it fundamentally cannot translate one — translation is "add a constant offset," and adding is not a linear operation a matrix-times-vector multiply can express in 3 dimensions (a linear map always sends the origin to the origin; translation moves it). The standard trick is homogeneous coordinates: represent every 3D point as a 4-vector (x, y, z, w) with w = 1, and suddenly translation can be folded into a matrix multiply, because a 4x4 matrix can mix that constant w = 1 term into the output x, y, and z. That's the entire reason positions get a 1.0 tacked on (vec4(position, 1.0)) while direction-only vectors like normals typically get a 0.0 — a 0 in the w slot means "translation doesn't apply to this," which is correct, because rotating and scaling a direction makes sense but translating a direction does not.

Why does w also drive perspective? It's a genuinely elegant reuse of the same fourth slot. Homogeneous coordinates were invented to make translation matrix-friendly, but it turns out that if you let w end up as something other than 1 after a matrix multiply, and then divide the whole vector by that w to bring it back to 1, you get to redistribute x and y by however much w grew — which is precisely the divide-by-depth behavior perspective needs. One extra coordinate slot ends up solving two unrelated-looking problems: making translation linear, and making distance shrink apparent size.

Three.js building the matrices for you

You essentially never construct any of these matrices by hand. You describe intent — an object's transform, a camera's field of view — and Three.js multiplies out the matrices per frame.

import * as THREE from "three";

// Model matrix: comes from this mesh's own transform (and its parents')
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(2, 0, -5);
mesh.rotation.y = Math.PI / 4;

// Projection matrix: comes from these four numbers
const camera = new THREE.PerspectiveCamera(
  50,          // fov, in degrees
  window.innerWidth / window.innerHeight, // aspect
  0.1,         // near
  100          // far
);
camera.position.set(0, 1, 5);
camera.lookAt(0, 0, 0);

// Every frame, the renderer:
// 1. reads mesh.matrixWorld (the model matrix, built from position/rotation/scale
//    and any parent transforms)
// 2. inverts camera.matrixWorld to get the view matrix
// 3. reads camera.projectionMatrix (built from fov/aspect/near/far)
// 4. multiplies all three into one modelViewProjection matrix and uploads
//    it as a uniform for the vertex shader to use

Inside the vertex shader that Three.js generates (or one you write yourself, the subject of the next lesson), the whole chain above collapses to the single multiply you already saw in the flagship lesson:

attribute vec3 position;
uniform mat4 modelViewProjection; // model * view * projection, premultiplied on the CPU

void main() {
  // One multiply does local space -> clip space in one step.
  // The GPU performs the perspective divide (clip space -> NDC) automatically
  // right after this shader runs, before rasterization.
  gl_Position = modelViewProjection * vec4(position, 1.0);
}

That's the payoff: model, view, and projection are conceptually three separate transforms with three separate jobs, but because matrix multiplication is associative, Three.js (or you) can pre-multiply them into a single modelViewProjection matrix on the CPU, once per object per frame, and the vertex shader spends exactly one matrix-vector multiply per vertex to run the entire chain from local space to clip space. Everything after gl_Position — the perspective divide and the viewport transform — is fixed-function hardware, not something you write.

Where this goes next

You now know precisely what the vertex shader's required output means and where the matrix that produces it comes from. What you haven't seen yet is the shader itself as a program — what GLSL looks like, what the three kinds of variables flowing through it are, and how a value written once per vertex ends up smoothly available to every pixel. That's shaders: the two programs you run on the GPU.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Name the four spaces a vertex passes through from local space to screen space, and which matrix (or operation) moves it from each space to the next.
  2. Why is the model matrix for a nested object the product of its own transform and every ancestor's transform, and which upcoming lesson explains that chain in depth?
  3. Explain precisely why the view matrix is the inverse of the camera's world transform rather than the transform itself.
  4. What does the projection matrix actually define for a PerspectiveCamera, and what four numbers determine it?
  5. Walk through what the perspective divide computes and why it's the step that actually makes farther objects appear smaller.
  6. Why can't a 3x3 matrix express translation, and what does adding a fourth, w, component fix?
  7. Explain why a position uses w = 1.0 in vec4(position, 1.0) while a direction vector like a normal typically uses w = 0.0.