WebGPU Camera & Matrices
A matrix is a grid of numbers that moves, turns or scales every vertex in one step.
Three matrices turn a 3D model into a picture on the screen: the model, the view and the projection. In this lesson you will write them in plain JavaScript and draw a spinning cube.
Model, View and Projection
Each matrix does one job:
| Matrix | Question it answers | Example |
|---|---|---|
| Model | Where is the object, and how is it turned? | Spin the cube |
| View | Where is the camera, and where is it looking? | Camera above and in front |
| Projection | How does 3D become flat? | Far things look smaller |
You multiply them together into one model-view-projection matrix. The vertex shader multiplies each position by it, and the result is a clip-space position, just like the vec4f you returned in earlier lessons.
4×4 Matrices in a Float32Array
A 4×4 matrix has 16 numbers. In JavaScript, store them in a Float32Array(16).
WGSL reads matrices in column-major order. That means the first 4 numbers are the first column, not the first row. The number at row row and column column lives at index column * 4 + row.
const identity = new Float32Array([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]);
This is the identity matrix. It changes nothing, like multiplying by 1.
Multiplying Matrices
Multiplying two matrices gives one matrix that does both jobs. The right-hand matrix happens first. So multiply(projection, view) means "apply the view, then the projection".
function multiply(a, b) {
const result = new Float32Array(16);
for (let column = 0; column < 4; column++) {
for (let row = 0; row < 4; row++) {
let sum = 0;
for (let k = 0; k < 4; k++) {
sum += a[k * 4 + row] * b[column * 4 + k];
}
result[column * 4 + row] = sum;
}
}
return result;
}
A Rotation Matrix
A rotation uses the cosine and sine of the angle. This one turns around the Z axis, which points out of the screen, so it spins things flat in 2D:
function rotationZ(angle) {
const cosine = Math.cos(angle);
const sine = Math.sin(angle);
return new Float32Array([
cosine, sine, 0, 0,
-sine, cosine, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]);
}
In WGSL, the uniform has the type mat4x4f. Multiply it with the position, matrix on the left:
@group(0) @binding(0) var<uniform> transform: mat4x4f;
@vertex
fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
let corner = corners[vertexIndex];
var output: VertexOutput;
output.position = transform * vec4f(corner, 0.0, 1.0);
output.color = vec3f(corner + 0.5, 0.8);
return output;
}
Example
Spin a square with a rotation matrix uniform:
const uniformBuffer = device.createBuffer({
size: 16 * Float32Array.BYTES_PER_ELEMENT,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST
});
function render(timeInMilliseconds) {
const angle = timeInMilliseconds / MILLISECONDS_PER_SECOND;
const aspect = canvas.width / canvas.height;
const transform = multiply(scaling(1 / aspect, 1, 1), rotationZ(angle));
device.queue.writeBuffer(uniformBuffer, 0, transform);
requestAnimationFrame(render);
}
Edit & Run »
The scaling matrix squeezes x by the aspect ratio, so the square stays square on a wide canvas. It is applied after the rotation, because it is on the left.
The Projection Matrix
A perspective projection makes far things smaller, like a real camera. It needs four values:
- Field of view: how wide the camera sees, in radians, from top to bottom.
- Aspect: the canvas width divided by its height.
- Near and far: the closest and farthest distances that are drawn.
function perspective(fieldOfView, aspect, near, far) {
const focalLength = 1 / Math.tan(fieldOfView / 2);
const depthRange = 1 / (near - far);
return new Float32Array([
focalLength / aspect, 0, 0, 0,
0, focalLength, 0, 0,
0, 0, far * depthRange, -1,
0, 0, near * far * depthRange, 0
]);
}
The View Matrix
The view matrix places the camera. The easiest way to build it is lookAt: give it where the camera is, what it looks at, and which way is up.
function lookAt(eye, target, up) {
const zAxis = normalize(subtract(eye, target));
const xAxis = normalize(cross(up, zAxis));
const yAxis = cross(zAxis, xAxis);
return new Float32Array([
xAxis[0], yAxis[0], zAxis[0], 0,
xAxis[1], yAxis[1], zAxis[1], 0,
xAxis[2], yAxis[2], zAxis[2], 0,
-dot(xAxis, eye), -dot(yAxis, eye), -dot(zAxis, eye), 1
]);
}
const view = lookAt([0, 1.5, 4.5], [0, 0, 0], [0, 1, 0]);
It works out three directions for the camera: right (xAxis), up (yAxis) and backwards (zAxis). Then it moves the whole world so the camera sits at the centre. The small vector helpers subtract, cross, dot and normalize are in the full example.
Rotating in 3D
To spin the cube, build the model matrix from two rotations: one around the Y axis (up) and one around the X axis (right).
function rotationX(angle) {
const cosine = Math.cos(angle);
const sine = Math.sin(angle);
return new Float32Array([
1, 0, 0, 0,
0, cosine, sine, 0,
0, -sine, cosine, 0,
0, 0, 0, 1
]);
}
function rotationY(angle) {
const cosine = Math.cos(angle);
const sine = Math.sin(angle);
return new Float32Array([
cosine, 0, -sine, 0,
0, 1, 0, 0,
sine, 0, cosine, 0,
0, 0, 0, 1
]);
}
Building the Cube
A cube has 6 faces. Each face is 2 triangles, so the cube needs 6 × 2 × 3 = 36 vertices. Every vertex has a 3D position and the colour of its face.
Each face lists its 4 corners going counter-clockwise when you look at it from outside. The order matters, as you will see below.
const faces = [
{ color: [1.0, 0.35, 0.35], corners: [[-1, -1, 1], [1, -1, 1], [1, 1, 1], [-1, 1, 1]] },
{ color: [0.35, 0.85, 1.0], corners: [[1, -1, -1], [-1, -1, -1], [-1, 1, -1], [1, 1, -1]] }
];
const CORNER_ORDER = [0, 1, 2, 0, 2, 3];
function buildCubeVertices() {
const vertexList = [];
for (const face of faces) {
for (const cornerIndex of CORNER_ORDER) {
vertexList.push(...face.corners[cornerIndex], ...face.color);
}
}
return new Float32Array(vertexList);
}
Only two faces are shown here. The full example has all six. CORNER_ORDER turns each 4-corner face into two triangles.
Hiding the Back Faces
Without help, the GPU draws all 6 faces in order. Faces on the far side can be painted over faces on the near side.
Set cullMode: "back" in the pipeline. WebGPU then skips every triangle whose corners appear clockwise on screen. Those are the faces pointing away from the camera.
const pipeline = device.createRenderPipeline({
layout: "auto",
vertex: { module: shaderModule, entryPoint: "vertexMain", buffers: [vertexBufferLayout] },
fragment: { module: shaderModule, entryPoint: "fragmentMain", targets: [{ format }] },
primitive: { topology: "triangle-list", cullMode: "back" }
});
Putting It Together
Each frame, build the model matrix from the time, combine all three matrices, and write the result into the uniform buffer:
Example
Draw a spinning cube with a perspective camera:
const view = lookAt(CAMERA_POSITION, CAMERA_TARGET, UP_DIRECTION);
function render(timeInMilliseconds) {
const seconds = timeInMilliseconds / MILLISECONDS_PER_SECOND;
const aspect = canvas.width / canvas.height;
const projection = perspective(FIELD_OF_VIEW, aspect, NEAR_PLANE, FAR_PLANE);
const model = multiply(rotationY(seconds), rotationX(seconds * 0.6));
const modelViewProjection = multiply(projection, multiply(view, model));
device.queue.writeBuffer(uniformBuffer, 0, modelViewProjection);
requestAnimationFrame(render);
}
Edit & Run »
The vertex shader does a single multiply:
@group(0) @binding(0) var<uniform> modelViewProjection: mat4x4f;
@vertex
fn vertexMain(input: VertexInput) -> VertexOutput {
var output: VertexOutput;
output.position = modelViewProjection * vec4f(input.position, 1.0);
output.color = input.color;
return output;
}
Matrix Helper Reference
All helpers return a column-major Float32Array(16):
| Function | Description |
|---|---|
multiply(a, b) | One matrix that applies b first, then a |
perspective(fieldOfView, aspect, near, far) | Projection where far things look smaller. Depth 0 to 1. |
lookAt(eye, target, up) | View matrix for a camera at eye looking at target |
rotationX(angle) | Turns around the X axis (tilts forward and back) |
rotationY(angle) | Turns around the Y axis (spins left and right) |
rotationZ(angle) | Turns around the Z axis (spins flat on the screen) |
scaling(x, y, z) | Stretches or squeezes along each axis |
Angles are in radians. A full turn is 2 * Math.PI.
Exercises
1. In a column-major Float32Array(16), which index holds row 1, column 2?
Show answer
2 * 4 + 1 = 9
2. You want the model to rotate first, then the view, then the projection. Write the multiply call.
Show answer
const modelViewProjection = multiply(projection, multiply(view, model));3. In the cube example, move the camera further away so the cube looks smaller.
Show answer
const CAMERA_POSITION = [0, 2.5, 8];4. In the cube example, change cullMode to "front". What do you see, and why?
Show answer
You see the inside of the cube. Now the faces pointing towards the camera are skipped, and only the far faces are drawn.
Summary
- The model matrix places the object, the view matrix places the camera, and the projection matrix adds perspective.
- WGSL matrices are column-major. Store them in a
Float32Array(16)and send them as amat4x4funiform. - In
multiply(a, b),bhappens first. - WebGPU projection uses depth from 0 to 1.
- A cube is 36 vertices with counter-clockwise faces.
cullMode: "back"hides the faces pointing away.
Official documentation
Go deeper with the specifications and reference documentation:
- developer.mozilla.org
MDN: model, view and projection
The maths behind 3D cameras, explained step by step. It applies to WebGPU too.
- w3.org
WGSL specification: matrix types
mat4x4f and the other matrix types, stored column by column.
- developer.mozilla.org
MDN: GPUQueue.writeBuffer()
Updating a matrix uniform every frame.