Computer Graphics (rasterisation, ray tracing, WebGPU) Module 2 – The maths toolkit: vectors, matrices and precision
Matrices and homogeneous coordinates
Why T R S applies S first, w = 1 for points and w = 0 for directions, rigid and general inverses, the normal matrix, and column-major storage.
What you will learn
- Represent linear transformations as matrices and compose them in the correct order
- Use homogeneous coordinates for translation and tell points (w = 1) from directions (w = 0)
- Invert rigid and affine transforms and transform normals with the inverse transpose
- Read and write the column-major layouts used by WGSL and three.js
Before you start
What you’ll learn · Before you start
What you will learn
- Represent linear transformations as matrices and compose them in the correct order
- Use homogeneous coordinates for translation and tell points (w = 1) from directions (w = 0)
- Invert rigid and affine transforms and transform normals with the inverse transpose
- Read and write the column-major layouts used by WGSL and three.js
Before you start
On this page
Scale the point (1, 0, 0) by 2, turn it 90° about z and move it 10 along x, and it ends at (10, 2, 0); apply the same three matrices in the opposite order and it ends at (0, 22, 0). Every object a GPU draws is placed by a product of matrices like these, and the order of that product, the meaning of a fourth coordinate and the layout of 16 numbers in memory are where transform code goes wrong.
The sections below read a matrix as a transformation and compose matrices in the right order, add the fourth coordinate that lets one matrix translate, invert transforms cheaply and in general, transform normals so they stay perpendicular, and store all of it the way WGSL, glTF and three.js expect.
A matrix is a transformation, and the rightmost one acts first
A linear transformation: A map that keeps the origin in place and preserves sums and scaling. Rotations, scalings and shears are linear; translations are not. such as a rotation or a scale is written as a matrix whose columns say where the axes go. The rotation R by 90° about z has the columns (0, 1, 0), (−1, 0, 0) and (0, 0, 1): the x axis turns into y, the y axis into −x, and z stays. Multiplying R by a column vector p mixes those columns with p’s coordinates as weights, and that is the whole of R p. Composing two transformations is a matrix product, and with column vectors the product acts from right to left: (T R S) p = T (R (S p)), so S touches the point first. glTF fixes exactly this order for a node, translation × rotation × scale: first the scale is applied, then the rotation, then the translation. Step through the lesson’s point under T R S and under S R T:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The same three matrices. Both columns start from the point (1, 0, 0) and use the same S (scale by 2), R (rotate 90 degrees about z) and T (move 10 along x). Only the order of the product differs.
- The rightmost acts first. With column vectors, M p = T (R (S p)), so the matrix written last touches the point first. On the left that is S, which doubles the point to (2, 0, 0).
- Right column: T first. In S R T the translation acts first, so the point moves to (11, 0, 0) before anything rotates or scales it.
- Rotating a scaled point. R turns the x axis into the y axis, so (2, 0, 0) becomes (0, 2, 0). The point is still near the origin, because nothing has moved it yet.
- Rotating a moved point. On the right, R rotates a point that is already 11 away from the origin, so it swings round to (0, 11, 0). Rotations turn about the origin, not about the object.
- T R S: scale, rotate, move. T moves the result by 10 along x, to (10, 2, 0). This is glTF's order for a node's translation, rotation and scale, and the usual one for placing an object.
- S R T: everything scaled. On the right, S doubles everything, including the 11 units the point was moved, so it ends at (0, 22, 0), far from (10, 2, 0).
- Order is the bug. The same three matrices gave (10, 2, 0) and (0, 22, 0). Composing in the wrong order is a classic transform bug, and it usually shows as an object that orbits or flies away when it should spin or grow in place.
The program computes both columns of the figure, first one matrix at a time and then as a single product:
// The same three transformations composed in two orders, applied to the point (1, 0, 0). Matrices are 4 x 4, stored
// as arrays of 16 numbers column by column (column-major), and act on column vectors: M p.
const at = (m, row, col) => m[col * 4 + row];
const multiply = (a, b) => {
const out = new Array(16).fill(0);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) out[c * 4 + r] += at(a, r, k) * at(b, k, c);
return out;
};
const apply = (m, [x, y, z, w]) => [0, 1, 2, 3].map((r) => at(m, r, 0) * x + at(m, r, 1) * y + at(m, r, 2) * z + at(m, r, 3) * w);
// Each builder lists its four columns.
const S = [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]; // scale by 2
const R = [0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; // rotate 90 degrees about z, written exactly
const T = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 0, 0, 1]; // translate by (10, 0, 0): the fourth column
const p = [1, 0, 0, 1]; // a point, w = 1
const show = ([x, y, z]) => `(${x}, ${y}, ${z})`;
console.log(`R's columns are where x, y and z go: ${show(apply(R, [1, 0, 0, 0]))}, ${show(apply(R, [0, 1, 0, 0]))}, ${show(apply(R, [0, 0, 1, 0]))}`);
console.log(`\nM = T R S, applied right to left: S first`);
console.log(` S p = ${show(apply(S, p))}, R S p = ${show(apply(R, apply(S, p)))}, T R S p = ${show(apply(T, apply(R, apply(S, p))))}`);
console.log(` one matrix: (T R S) p = ${show(apply(multiply(T, multiply(R, S)), p))}`);
console.log(`M = S R T, the same three in the other order: T first`);
console.log(` T p = ${show(apply(T, p))}, R T p = ${show(apply(R, apply(T, p)))}, S R T p = ${show(apply(S, apply(R, apply(T, p))))}`);
console.log(` one matrix: (S R T) p = ${show(apply(multiply(S, multiply(R, T)), p))}`);
console.log(`\nR S equals S R here (a uniform scale commutes with a rotation): ${multiply(R, S).every((v, i) => v === multiply(S, R)[i])}`);
console.log(`T R equals R T: ${multiply(T, R).every((v, i) => v === multiply(R, T)[i])}`); Output
R's columns are where x, y and z go: (0, 1, 0), (-1, 0, 0), (0, 0, 1) M = T R S, applied right to left: S first S p = (2, 0, 0), R S p = (0, 2, 0), T R S p = (10, 2, 0) one matrix: (T R S) p = (10, 2, 0) M = S R T, the same three in the other order: T first T p = (11, 0, 0), R T p = (0, 11, 0), S R T p = (0, 22, 0) one matrix: (S R T) p = (0, 22, 0) R S equals S R here (a uniform scale commutes with a rotation): true T R equals R T: false
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node compose_order.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Both ways of computing agree, (10, 2, 0) and (0, 22, 0), because matrix products are associative: the three matrices can be multiplied into one M before any vertex is touched, which is how a GPU gets one matrix per object. They are not commutative. A uniform scale happens to commute with a rotation, R S = S R in the last line but one, but a translation and a rotation do not, so T R and R T differ.
Key idea
With column vectors, M = T R S applies S first and T last; compose right to left, in the order glTF uses for a node, and the object scales and turns about its own origin before it moves.
Checkpoint
Check: Matrices and composition order
A fourth coordinate makes translation a product
A translation cannot be a 3 × 3 matrix: every linear transformation keeps the origin where it is, and a translation moves it. homogeneous coordinates: Coordinates with an extra component w. Points have w = 1, so a 4 × 4 matrix can translate them; directions have w = 0, so translation leaves them alone. add a fourth component, w. A point is written (x, y, z, 1) and a direction (x, y, z, 0), and a 4 × 4 matrix keeps the translation in its fourth column, so the w = 1 of a point picks it up and the w = 0 of a direction ignores it. The matrix of the previous section, M = T R S, applied to both:
// Points (w = 1) and directions (w = 0) through one 4 x 4 matrix, M = T R S from the previous program, stored
// column-major. The fourth column holds the translation, and only w decides whether it is used.
const at = (m, row, col) => m[col * 4 + row];
const apply = (m, v) => [0, 1, 2, 3].map((r) => at(m, r, 0) * v[0] + at(m, r, 1) * v[1] + at(m, r, 2) * v[2] + at(m, r, 3) * v[3]);
const M = [0, 2, 0, 0, -2, 0, 0, 0, 0, 0, 2, 0, 10, 0, 0, 1]; // T R S: columns (0, 2, 0, 0), (-2, 0, 0, 0), (0, 0, 2, 0), (10, 0, 0, 1)
const show = (v) => `(${v.join(', ')})`;
const len = ([x, y, z]) => Math.hypot(x, y, z);
const point = [1, 0, 0, 1];
const direction = [1, 0, 0, 0];
console.log(`the point ${show(point)} becomes ${show(apply(M, point))}: rotated, scaled AND moved by (10, 0, 0)`);
console.log(`the direction ${show(direction)} becomes ${show(apply(M, direction))}: rotated and scaled, never moved`);
console.log(`the origin (0, 0, 0, 1) becomes ${show(apply(M, [0, 0, 0, 1]))}: a translation moves the origin, so it is not linear`);
const a = [1, 0, 0, 1];
const b = [3, 0, 0, 1];
const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2], 0]; // point - point = direction, w = 1 - 1 = 0
const ma = apply(M, a);
const mb = apply(M, b);
console.log(`\nb - a = ${show(ab)} (w = 0); M(b) - M(a) = ${show(mb.map((v, i) => v - ma[i]))} = M(b - a) = ${show(apply(M, ab))}`);
console.log(`a length of ${len(ab)} becomes ${len(apply(M, ab))}: directions are scaled, so normalise them again after a scale`); Output
the point (1, 0, 0, 1) becomes (10, 2, 0, 1): rotated, scaled AND moved by (10, 0, 0) the direction (1, 0, 0, 0) becomes (0, 2, 0, 0): rotated and scaled, never moved the origin (0, 0, 0, 1) becomes (10, 0, 0, 1): a translation moves the origin, so it is not linear b - a = (2, 0, 0, 0) (w = 0); M(b) - M(a) = (0, 4, 0, 0) = M(b - a) = (0, 4, 0, 0) a length of 2 becomes 4: directions are scaled, so normalise them again after a scale
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node homogeneous.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The point (1, 0, 0, 1) is rotated, scaled and moved, to (10, 2, 0, 1); the direction (1, 0, 0, 0) is rotated and scaled but stays put, (0, 2, 0, 0). The origin itself moves to (10, 0, 0), which is the sign that M is an affine transformation: A linear transformation followed by a translation. It keeps straight lines straight and parallel lines parallel., a linear one followed by a translation, and not a linear one. Subtracting two points gives w = 1 − 1 = 0, a direction, and M treats it consistently: M(b) − M(a) = M(b − a) = (0, 4, 0, 0). Its length doubled, from 2 to 4, because the scale scales directions too.
three.js keeps the same distinction in two methods. Vector3.applyMatrix4 multiplies with an implicit 1 in the fourth
dimension and divides by the result’s w, as a point needs; transformDirection uses only the upper 3 × 3 part and
normalises the result, as a direction needs.
A direction transformed as a point
Transforming a light direction or a velocity with w = 1 adds the object’s translation to it: with M above, the direction (1, 0, 0) would become (10, 2, 0) instead of (0, 2, 0). Give directions w = 0, or use the upper 3 × 3 part only, and normalise again after any scale.
Checkpoint
Check: Points, directions and w
Rigid transforms invert by a transpose; others need a general inverse
Inverses turn a camera’s placement into its view matrix and a hit point in world space back into an object’s own coordinates. A rigid transformation: A rotation followed by a translation. It keeps every length and angle, and its inverse is the transposed rotation with a negated, rotated translation., a rotation R followed by a translation t, inverts cheaply: a rotation’s inverse is its transpose, so the inverse of [R t; 0 1] is [Rᵀ, −Rᵀ t; 0 1], with no division at all. An affine transform [A t; 0 1] with a scale or a shear in A needs A itself inverted: its inverse is [A⁻¹, −A⁻¹ t; 0 1]. Any other invertible matrix needs a general method such as Gauss-Jordan elimination. Whether a matrix is invertible at all is told by its determinant: A number computed from a square matrix: the factor by which it scales areas or volumes. It is negative when the matrix mirrors and 0 when the matrix has no inverse., the factor by which it scales volumes: 0 means it flattens space and has no inverse, and a negative value means it mirrors. The program checks the shortcut against elimination, breaks it with a scale, and prints four determinants:
// Inverting 4 x 4 matrices (column-major): a rigid transform by transposing its rotation, any invertible one by
// Gauss-Jordan elimination, and the determinant, whose sign says whether a transform mirrors.
const at = (m, row, col) => m[col * 4 + row];
const multiply = (a, b) => {
const out = new Array(16).fill(0);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) out[c * 4 + r] += at(a, r, k) * at(b, k, c);
return out;
};
const rotationZ = (deg) => {
const t = (deg * Math.PI) / 180;
const [c, s] = [Math.cos(t), Math.sin(t)];
return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
};
const translation = (x, y, z) => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];
const scaling = (x, y, z) => [x, 0, 0, 0, 0, y, 0, 0, 0, 0, z, 0, 0, 0, 0, 1];
// Rigid: M = [R t; 0 1] has the inverse [R^T, -R^T t; 0 1].
function invertRigid(m) {
const out = new Array(16).fill(0);
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) out[c * 4 + r] = at(m, c, r); // transpose the 3 x 3 part
for (let r = 0; r < 3; r++) out[12 + r] = -(out[r] * m[12] + out[4 + r] * m[13] + out[8 + r] * m[14]);
out[15] = 1;
return out;
}
// Gauss-Jordan elimination with partial pivoting on [M | I]; returns null for a singular matrix.
function invertGeneral(m) {
const a = Array.from({ length: 4 }, (_, r) => [...[0, 1, 2, 3].map((c) => at(m, r, c)), ...[0, 1, 2, 3].map((c) => (r === c ? 1 : 0))]);
for (let col = 0; col < 4; col++) {
let pivot = col;
for (let r = col + 1; r < 4; r++) if (Math.abs(a[r][col]) > Math.abs(a[pivot][col])) pivot = r;
if (Math.abs(a[pivot][col]) < 1e-12) return null;
[a[col], a[pivot]] = [a[pivot], a[col]];
const p = a[col][col];
for (let c = 0; c < 8; c++) a[col][c] /= p;
for (let r = 0; r < 4; r++) {
if (r === col) continue;
const f = a[r][col];
for (let c = 0; c < 8; c++) a[r][c] -= f * a[col][c];
}
}
const out = new Array(16);
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) out[c * 4 + r] = a[r][4 + c];
return out;
}
// The determinant of the upper 3 x 3 part: the volume factor of the linear part.
const det3 = (m) =>
at(m, 0, 0) * (at(m, 1, 1) * at(m, 2, 2) - at(m, 1, 2) * at(m, 2, 1)) -
at(m, 0, 1) * (at(m, 1, 0) * at(m, 2, 2) - at(m, 1, 2) * at(m, 2, 0)) +
at(m, 0, 2) * (at(m, 1, 0) * at(m, 2, 1) - at(m, 1, 1) * at(m, 2, 0));
const maxDiff = (x, y) => Math.max(...x.map((v, i) => Math.abs(v - y[i])));
const identity = translation(0, 0, 0);
const rigid = multiply(translation(4, -2, 7), rotationZ(30));
console.log(`rigid M = T(4, -2, 7) R(30 degrees)`);
console.log(` rigid inverse vs Gauss-Jordan: largest difference ${maxDiff(invertRigid(rigid), invertGeneral(rigid)).toExponential(1)}`);
console.log(` M times its rigid inverse vs the identity: largest difference ${maxDiff(multiply(rigid, invertRigid(rigid)), identity).toExponential(1)}`);
const affine = multiply(rigid, scaling(2, 1, 1));
console.log(`affine M S(2, 1, 1): the rigid shortcut is now wrong by ${maxDiff(invertRigid(affine), invertGeneral(affine)).toFixed(3)}; Gauss-Jordan still inverts it`);
console.log(` M S times its Gauss-Jordan inverse vs the identity: ${maxDiff(multiply(affine, invertGeneral(affine)), identity).toExponential(1)}`);
console.log(`\ndeterminants: T R ${det3(rigid).toFixed(3)}, T R S(2, 1, 1) ${det3(affine).toFixed(3)}, S(-1, 1, 1) ${det3(scaling(-1, 1, 1))}, S(1, 0, 1) ${det3(scaling(1, 0, 1))}`);
console.log(`S(1, 0, 1) flattens y away; its inverse: ${invertGeneral(scaling(1, 0, 1))}`); Output
rigid M = T(4, -2, 7) R(30 degrees) rigid inverse vs Gauss-Jordan: largest difference 5.6e-17 M times its rigid inverse vs the identity: largest difference 4.4e-16 affine M S(2, 1, 1): the rigid shortcut is now wrong by 3.696; Gauss-Jordan still inverts it M S times its Gauss-Jordan inverse vs the identity: 4.4e-16 determinants: T R 1.000, T R S(2, 1, 1) 2.000, S(-1, 1, 1) -1, S(1, 0, 1) 0 S(1, 0, 1) flattens y away; its inverse: null
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node inverse.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
For the rigid T(4, −2, 7) R(30°) the transpose shortcut and Gauss-Jordan agree to 5.6 × 10⁻¹⁷, and the product of the matrix with its inverse is the identity to 4.4 × 10⁻¹⁶. Add the scale (2, 1, 1) and the shortcut is wrong by 3.696, while elimination still inverts it to 4.4 × 10⁻¹⁶. The determinants say the rest: 1 for the rigid transform, 2 for the scaled one, −1 for the mirror S(−1, 1, 1), and 0 for S(1, 0, 1), which flattens y away and has no inverse.
Libraries handle that last case quietly. three.js documents that Matrix4.invert() cannot invert a matrix with a
determinant of 0 and returns a zero matrix instead, so check the determinant first wherever a scale can reach 0. The
sign matters too: glTF says the determinant of a node’s global transform decides its triangles’ winding, positive
for counter-clockwise front faces and negative for clockwise, which is how a mirrored copy keeps facing outwards.
Checkpoint
Check: Inverses and determinants
Normals need the inverse transpose
A surface normal: A vector perpendicular to a surface at a point. Lighting uses it to see how directly light arrives, and culling to see which side faces the viewer. More in “Vectors, points, dot and cross products” has to stay perpendicular to its surface, and under a non-uniform scale the matrix that moves the points does not keep it so. Scale a unit sphere by (2, 1, 1) into an ellipsoid and look at the point that came from 45° on its equator:
The matrix moves points correctly and normals wrongly
Text description of the diagram
A dashed unit circle and, around it, the solid ellipse it becomes when x is scaled by 2 and y is left alone, both centred on the origin with the axes drawn faintly.
One point is marked on the ellipse at (1.414, 0.707): the point (0.707, 0.707) of the circle, at 45 degrees, after the scale. A grey line through it is the ellipse's tangent there.
Two arrows start at the point. The dashed grey arrow, labelled "by M", is the circle's normal at 45 degrees transformed by the scale matrix itself: it points along (1.414, 0.707) and is visibly not at a right angle to the tangent, 36.87 degrees off. The solid blue arrow, labelled "by the inverse transpose", is the same normal transformed by the inverse transpose of the scale: it points along (0.354, 0.707) and stands at a right angle to the tangent, as a normal must.
The legend under the drawing repeats the four marks and the point.
The matrix stretches the normal along x, as it stretches the surface, but stretching a surface along x tilts its normals the other way. The normal that stays perpendicular comes from the inverse transpose of the upper 3 × 3 part, the normal matrix: The inverse transpose of a transformation's upper 3 × 3 part. Normals transformed by it stay perpendicular to the transformed surface, even under non-uniform scaling.; for a scale by (2, 1, 1) it is the scale by (1/2, 1, 1). The program compares both with the ellipsoid’s true normals at 360 points round the equator:
// A unit sphere scaled by (2, 1, 1) into an ellipsoid. Its normals transformed by the matrix itself, and by the inverse
// transpose of its 3 x 3 part, are compared with the ellipsoid's true normals, at 360 points around its equator.
const deg = (rad) => (rad * 180) / Math.PI;
// atan2(|u x v|, u . v) stays accurate for tiny angles, where acos of a cosine near 1 loses most of its digits.
const angleBetween = (u, v) => {
const cross = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
return deg(Math.atan2(Math.hypot(...cross), u[0] * v[0] + u[1] * v[1] + u[2] * v[2]));
};
const S = [2, 1, 1]; // the scale; its matrix is diagonal, so its inverse transpose is diagonal with 1 / 2, 1, 1
const errorsByM = [];
let worstByInverseTranspose = 0;
for (let k = 0; k < 360; k++) {
const t = (k * Math.PI) / 180;
const n = [Math.cos(t), Math.sin(t), 0]; // on a unit sphere the normal is the point itself
const q = [S[0] * n[0], S[1] * n[1], S[2] * n[2]]; // the point on the ellipsoid x^2/4 + y^2 + z^2 = 1
const truth = [q[0] / 4, q[1], q[2]]; // the gradient of x^2/4 + y^2 + z^2, which is perpendicular to the surface
const byM = [S[0] * n[0], S[1] * n[1], S[2] * n[2]];
const byInverseTranspose = [n[0] / S[0], n[1] / S[1], n[2] / S[2]];
errorsByM.push(angleBetween(byM, truth));
worstByInverseTranspose = Math.max(worstByInverseTranspose, angleBetween(byInverseTranspose, truth));
}
const worstByM = Math.max(...errorsByM);
const where = errorsByM.map((e, k) => (e > worstByM - 1e-9 ? k : -1)).filter((k) => k >= 0);
console.log(`normals transformed by M: wrong by up to ${worstByM.toFixed(2)} degrees, at ${where.join(', ')} degrees round the equator`);
console.log(`normals transformed by the inverse transpose: wrong by at most ${worstByInverseTranspose === 0 ? '0 degrees, the same directions exactly' : `${worstByInverseTranspose.toExponential(1)} degrees`}`);
const t = Math.PI / 4;
console.log(`at 45 degrees: M gives the direction (${(2 * Math.cos(t)).toFixed(3)}, ${Math.sin(t).toFixed(3)}), the true normal is (${(Math.cos(t) / 2).toFixed(3)}, ${Math.sin(t).toFixed(3)})`); Output
normals transformed by M: wrong by up to 36.87 degrees, at 45, 135, 225, 315 degrees round the equator normals transformed by the inverse transpose: wrong by at most 0 degrees, the same directions exactly at 45 degrees: M gives the direction (1.414, 0.707), the true normal is (0.354, 0.707)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node normals_scale.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Transformed by M, the normals are wrong by up to 36.87°, at 45°, 135°, 225° and 315° round the equator, enough to
put a highlight in the wrong place. The inverse transpose gives the true directions exactly. For rotations and
uniform scales the two agree up to length, which is why the bug hides until an artist scales an object unevenly.
three.js computes this matrix with Matrix3.getNormalMatrix, the inverse transpose of the upper-left 3 × 3 part of a
4 × 4 matrix.
Checkpoint
Check: The normal matrix
Column-major storage: what WGSL, glTF and three.js expect
Sixteen numbers can be laid out row by row or column by column, and the formats this track uses all store
column-major order: Storing a matrix one column after another in memory. WGSL, glTF and the elements array of three.js store 4 × 4 matrices this way.. In WGSL a mat4x4f is four vec4f column vectors, and the
16-number constructor fills the first column first. glTF stores accessors of matrix type in column-major order.
three.js keeps Matrix4.elements column-major, while Matrix4.set() takes its 16 arguments row by row, in the order a
matrix is printed on paper. The program builds a translation by (5, 6, 7) with a function that takes its arguments
in that reading order, then reads the same numbers the wrong way:
// Column-major storage, as WGSL, glTF and the elements array of three.js use it. setRowMajor takes its 16 arguments
// in reading order, row by row, the order three.js's Matrix4.set() documents, and stores them column by column.
function setRowMajor(...rowByRow) {
const elements = new Array(16);
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) elements[c * 4 + r] = rowByRow[r * 4 + c];
return elements;
}
const apply = (e, v) => [0, 1, 2, 3].map((r) => e[r] * v[0] + e[4 + r] * v[1] + e[8 + r] * v[2] + e[12 + r] * v[3]);
const transpose = (e) => e.map((_, i) => e[(i % 4) * 4 + Math.floor(i / 4)]);
// A translation by (5, 6, 7), written as it is printed in a textbook: the translation in the last column.
const T = setRowMajor(1, 0, 0, 5, 0, 1, 0, 6, 0, 0, 1, 7, 0, 0, 0, 1);
console.log(`elements: [${T}]`);
console.log(`the translation sits at elements 12, 13 and 14: ${T[12]}, ${T[13]}, ${T[14]}`);
console.log(`as four columns (WGSL's mat4x4f): ${[0, 1, 2, 3].map((c) => `(${T.slice(c * 4, c * 4 + 4)})`).join(' ')}`);
const p = [1, 2, 3, 1];
console.log(`\nT p = (${apply(T, p)}): moved by (5, 6, 7)`);
const wrong = transpose(T); // the same 16 numbers read row by row: the translation lands in the bottom row
const q = apply(wrong, p);
console.log(`read in the wrong order: (${q}); after dividing by w = ${q[3]}: (${q.slice(0, 3).map((v) => (v / q[3]).toFixed(3))})`); Output
elements: [1,0,0,0,0,1,0,0,0,0,1,0,5,6,7,1] the translation sits at elements 12, 13 and 14: 5, 6, 7 as four columns (WGSL's mat4x4f): (1,0,0,0) (0,1,0,0) (0,0,1,0) (5,6,7,1) T p = (6,8,10,1): moved by (5, 6, 7) read in the wrong order: (1,2,3,39); after dividing by w = 39: (0.026,0.051,0.077)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node layout.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The elements come out as [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 6, 7, 1]: the translation sits at indices 12, 13 and 14, the fourth column. Read row by row by mistake, the same numbers put the translation in the bottom row, where it multiplies into w: the point (1, 2, 3) gets w = 39 and, after the division by w, lands near the origin at (0.026, 0.051, 0.077). A model that collapses towards the camera or the origin after an upload is usually this bug.
| Where | Layout | Translation of T(5, 6, 7) |
|---|---|---|
WGSL mat4x4f |
Four column vectors | The fourth column, m[3].xyz |
glTF accessor or node.matrix |
Column-major | Elements 12 to 14 |
three.js Matrix4.elements |
Column-major | Elements 12 to 14 |
three.js Matrix4.set(…) arguments |
Row by row, as printed | Arguments 4, 8 and 12 |
Upload the elements as they are
A three.js elements array, a glTF matrix and a WGSL mat4x4f already agree, so copy the 16 numbers into a
uniform buffer unchanged. Transposing them “for the GPU” is the mistake, not the fix.
Checkpoint
Check: Column-major storage
Practise: compose matrices, then invert them
Two steps build a small matrix library in the column-major layout. The first composes and applies matrices, starting
from a multiply that reads its arrays the wrong way round; the second inverts affine transforms and builds the
normal matrix, with exact cases from this lesson:
Code lab · 2 steps
Build a column-major matrix library
Compose transforms in the right order, then invert them and transform normals so they stay perpendicular.
- Column-major 4 × 4 matrices that compose in the right order To do
- Invert an affine transform and build its normal matrix To do
Exercise · Medium · JavaScript
Column-major 4 × 4 matrices that compose in the right order
A matrix here is an array of 16 numbers in column-major order, as WGSL and three.js's elements store it: the entry in row r and column c is at index c * 4 + r. Matrices act on column vectors, M p. Write, in mat4.mjs:
multiply(a, b): the producta b, so thatmultiply(a, b)applied to a point appliesbfirst.translation(x, y, z)andscaling(x, y, z): the two builders (rotationZ90is given).transformPoint(m, p)andtransformDirection(m, d):[x, y, z]in,[x, y, z]out, with w = 1 for a point and w = 0 for a direction.
The starter's multiply reads and writes the arrays as if they were row-major. Every product it makes is silently the wrong way round, which the sample tests catch with the lesson's point: T R S must move (1, 0, 0) to (10, 2, 0), and S R T to (0, 22, 0).
Starter code · mat4.mjs
/** Column-major 4 x 4 matrices: entry (row r, column c) is m[c * 4 + r]. They act on column vectors, M p. */
export const identity = () => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
/** A rotation of 90 degrees about z, written exactly: x goes to y, y goes to -x. */
export const rotationZ90 = () => [0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
/** The product a b: applied to a point, b acts first. */
export function multiply(a, b) {
const out = new Array(16).fill(0);
// Reads and writes as if the arrays were row-major: fix this.
for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) for (let k = 0; k < 4; k++) out[r * 4 + c] += a[r * 4 + k] * b[k * 4 + c];
return out;
}
export function translation(x, y, z) {
return identity(); // replace
}
export function scaling(x, y, z) {
return identity(); // replace
}
/** The point p = [x, y, z] (w = 1) transformed by m. */
export function transformPoint(m, p) {
return [...p]; // replace
}
/** The direction d = [x, y, z] (w = 0) transformed by m: never translated. */
export function transformDirection(m, d) {
return [...d]; // replace
} The sample tests · mat4.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { identity, multiply, rotationZ90, scaling, transformDirection, transformPoint, translation } from './mat4.mjs';
// -0 and 0 are the same number for a matrix; deepEqual tells them apart, so results are compared after adding 0.
const z = (v) => v.map((x) => x + 0);
const T = translation(10, 0, 0);
const R = rotationZ90();
const S = scaling(2, 2, 2);
test('the builders store column-major: the translation sits at indices 12, 13 and 14', () => {
assert.deepEqual(z(translation(5, 6, 7)), [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 6, 7, 1]);
assert.deepEqual(z(scaling(2, 3, 4)), [2, 0, 0, 0, 0, 3, 0, 0, 0, 0, 4, 0, 0, 0, 0, 1]);
});
test('points move with w = 1; directions ignore the translation', () => {
assert.deepEqual(z(transformPoint(translation(5, 6, 7), [1, 2, 3])), [6, 8, 10]);
assert.deepEqual(z(transformDirection(translation(5, 6, 7), [1, 2, 3])), [1, 2, 3]);
assert.deepEqual(z(transformPoint(R, [1, 0, 0])), [0, 1, 0], 'R turns x into y');
assert.deepEqual(z(transformPoint(R, [0, 1, 0])), [-1, 0, 0], 'and y into -x');
assert.deepEqual(z(transformDirection(scaling(2, 3, 4), [1, 1, 1])), [2, 3, 4]);
});
test('T R S moves (1, 0, 0) to (10, 2, 0), and S R T to (0, 22, 0)', () => {
assert.deepEqual(z(transformPoint(multiply(T, multiply(R, S)), [1, 0, 0])), [10, 2, 0], 'T R S: scale, rotate, then move');
assert.deepEqual(z(transformPoint(multiply(S, multiply(R, T)), [1, 0, 0])), [0, 22, 0], 'S R T: move, rotate, then scale');
});
test('the identity changes nothing, and products are associative but not commutative', () => {
const M = multiply(T, multiply(R, scaling(2, 1, 3)));
assert.deepEqual(z(multiply(identity(), M)), z(M));
assert.deepEqual(z(multiply(M, identity())), z(M));
assert.deepEqual(z(multiply(multiply(T, R), S)), z(multiply(T, multiply(R, S))));
assert.notDeepEqual(z(multiply(T, R)), z(multiply(R, T)), 'T R and R T differ');
}); A hint
Write a helper at(m, r, c) that returns m[c * 4 + r] and use it everywhere, so that no index is written by hand twice. Entry (r, c) of the product a b is the sum over k of at(a, r, k) * at(b, k, c), stored at c * 4 + r. A translation keeps the identity's diagonal and puts x, y and z in the fourth column, at indices 12, 13 and 14.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · JavaScript
Invert an affine transform and build its normal matrix
Matrices are column-major arrays, as in the previous step: a 4 × 4 matrix has 16 numbers, a 3 × 3 matrix 9, and entry (row r, column c) is at c * size + r. Write, in inverse.mjs:
invertAffine(m): the inverse of an affine 4 × 4 matrix, one whose bottom row is (0, 0, 0, 1). If its upper 3 × 3 part A has a determinant of 0, returnnull; otherwise the inverse has A⁻¹ in the upper 3 × 3 part and −A⁻¹ t in the fourth column, where t is m's translation.normalMatrix(m): the 3 × 3 matrix that transforms the normals of m correctly, the transpose of A⁻¹, ornullwhen A has no inverse.
The starter inverts with the rigid shortcut, the transposed rotation, which is right only without scale, and returns A itself as the normal matrix. The sample tests use exact cases: a rigid transform, the scale (2, 1, 1) of the lesson's ellipse, a mirror, and a matrix that flattens y away.
Starter code · inverse.mjs
const at = (m, r, c) => m[c * 4 + r];
/** The inverse of an affine column-major 4 x 4 matrix, or null when its upper 3 x 3 part is singular. */
export function invertAffine(m) {
// The rigid shortcut: transpose the rotation and move back. Wrong as soon as m scales or shears.
const out = new Array(16).fill(0);
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) out[c * 4 + r] = at(m, c, r);
for (let r = 0; r < 3; r++) out[12 + r] = -(out[r] * m[12] + out[4 + r] * m[13] + out[8 + r] * m[14]);
out[15] = 1;
return out;
}
/** The 3 x 3 normal matrix of m, column-major (9 numbers): the transpose of the inverse of its upper 3 x 3 part. */
export function normalMatrix(m) {
return [m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10]]; // A itself: wrong under non-uniform scale
} The sample tests · inverse.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { invertAffine, normalMatrix } from './inverse.mjs';
const multiply = (a, b) => {
const out = new Array(16).fill(0);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) out[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
return out;
};
const close = (got, want, what) => {
assert.ok(Array.isArray(got) && got.length === want.length, `${what}: expected ${want.length} numbers, got ${got}`);
for (let i = 0; i < want.length; i++) assert.ok(Math.abs(got[i] - want[i]) < 1e-12, `${what}: entry ${i} is ${got[i]}, expected ${want[i]}`);
};
const I = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
const translateThenRotate = [0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 4, -2, 7, 1]; // T(4, -2, 7) R(90 degrees about z)
const scale211 = [2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
test('a rigid transform: the inverse undoes it exactly', () => {
const inv = invertAffine(translateThenRotate);
close(inv, [0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 2, 4, -7, 1], 'the inverse of T(4, -2, 7) R(90)');
close(multiply(translateThenRotate, inv), I, 'M times its inverse');
});
test('a scale and a translation: the rigid shortcut is not enough', () => {
const m = [2, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0.5, 0, 6, 8, 1, 1]; // scale (2, 4, 0.5), then move by (6, 8, 1)
close(invertAffine(m), [0.5, 0, 0, 0, 0, 0.25, 0, 0, 0, 0, 2, 0, -3, -2, -2, 1], 'the inverse');
close(multiply(m, invertAffine(m)), I, 'M times its inverse');
});
test('the normal matrix of the scale (2, 1, 1) is diagonal (1/2, 1, 1), and a rotation keeps its own', () => {
close(normalMatrix(scale211), [0.5, 0, 0, 0, 1, 0, 0, 0, 1], 'normal matrix of S(2, 1, 1)');
close(normalMatrix(translateThenRotate), [0, 1, 0, -1, 0, 0, 0, 0, 1], 'a rotation is its own inverse transpose');
const n = normalMatrix(scale211);
const t = Math.SQRT1_2;
const normal = [n[0] * t + n[3] * t, n[1] * t + n[4] * t, n[2] * t + n[5] * t];
close(normal, [t / 2, t, 0], 'the normal from 45 degrees on the ellipse');
});
test('a full 3 x 3 part with an integer inverse', () => {
// rows (1, 2, 3), (0, 1, 4), (5, 6, 0): determinant 1, and the inverse has rows (-24, 18, 5), (20, -15, -4), (-5, 4, 1)
const m = [1, 0, 5, 0, 2, 1, 6, 0, 3, 4, 0, 0, 1, 2, 3, 1];
close(invertAffine(m), [-24, 20, -5, 0, 18, -15, 4, 0, 5, -4, 1, 0, -27, 22, -6, 1], 'the inverse');
close(normalMatrix(m), [-24, 18, 5, 20, -15, -4, -5, 4, 1], 'the normal matrix, the inverse transposed');
close(multiply(m, invertAffine(m)), I, 'M times its inverse');
});
test('a mirror inverts to itself; a flattening matrix has no inverse', () => {
const mirror = [-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
close(invertAffine(mirror), mirror, 'S(-1, 1, 1) is its own inverse');
const flat = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 3, 3, 3, 1]; // y scaled to 0
assert.equal(invertAffine(flat), null, 'no inverse when y is flattened away');
assert.equal(normalMatrix(flat), null, 'and no normal matrix either');
}); A hint
For a 3 × 3 matrix A, the inverse is the transposed matrix of cofactors divided by the determinant. Compute the nine cofactors once, use the first row of them for the determinant, return null if it is 0, and then fill the inverse. The translation of the inverse is A⁻¹ applied to t, negated; the normal matrix is A⁻¹ with rows and columns swapped.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then work through the self-check. Questions on the ideas you have answered least well come first, and the session brings back any question you miss before it ends:
Practice that adapts to you
Practise this lesson
13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2-5 years): Why do graphics programs use 4 × 4 matrices for 3D points?
Because a translation is not linear: a 3 × 3 matrix always keeps the origin fixed, so it can rotate, scale and shear but not move anything. Homogeneous coordinates add a fourth component, w. A point is (x, y, z, 1) and a direction is (x, y, z, 0), and a 4 × 4 matrix keeps the translation in its fourth column, so points pick it up and directions, which have no position, ignore it. Every rotation, scale, shear and translation then becomes one kind of object, and a whole chain of them multiplies into a single matrix before any vertex is transformed. The same form also carries the perspective projection of the cameras module, which uses the fourth row to put depth into w, followed by the division by w.
These two questions put the matrices of this lesson under pressure; for each, the module’s question page names a first move and the follow-ups to expect:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A game object placed at (10, 0, 0) should spin in place, but it swings round the world origin instead. Its matrix is built every frame as R times T, with R's angle growing. Explain what happens with numbers, fix it, and say how you would stop the bug from coming back.
-
Mid to senior
After an artist stretches a round lamp to twice its width, its highlights and shading look wrong, while unstretched copies of the same mesh look right. Diagnose it with numbers and fix it in the renderer, not in the mesh.
Key takeaways
- A matrix’s columns are the images of the axes, and with column vectors T R S applies S first: (1, 0, 0) ends at (10, 2, 0), while S R T sends it to (0, 22, 0).
- Homogeneous coordinates give points w = 1 and directions w = 0, so a 4 × 4 matrix translates points and leaves directions alone; translations make a transform affine, not linear.
- A rigid transform inverts as [Rᵀ, −Rᵀ t; 0 1], an affine one as [A⁻¹, −A⁻¹ t; 0 1] and any other matrix by elimination; a determinant of 0 means no inverse at all, and a negative one means a mirror.
- Normals are transformed by the inverse transpose of the upper 3 × 3 part: by the matrix itself they were wrong by up to 36.87° on a sphere scaled by (2, 1, 1).
- WGSL, glTF and three.js’s
elementsare column-major, with the translation at indices 12 to 14, whileMatrix4.set()takes its arguments row by row.
Terms in this lesson
- affine transformation
- A linear transformation followed by a translation. It keeps straight lines straight and parallel lines parallel.
- column-major order
- Storing a matrix one column after another in memory. WGSL, glTF and the elements array of three.js store 4 × 4 matrices this way.
- determinant
- A number computed from a square matrix: the factor by which it scales areas or volumes. It is negative when the matrix mirrors and 0 when the matrix has no inverse.
- homogeneous coordinates
- Coordinates with an extra component w. Points have w = 1, so a 4 × 4 matrix can translate them; directions have w = 0, so translation leaves them alone.
- linear transformation
- A map that keeps the origin in place and preserves sums and scaling. Rotations, scalings and shears are linear; translations are not.
- normal matrix
- The inverse transpose of a transformation's upper 3 × 3 part. Normals transformed by it stay perpendicular to the transformed surface, even under non-uniform scaling.
- rigid transformation
- A rotation followed by a translation. It keeps every length and angle, and its inverse is the transposed rotation with a negated, rotated translation.
References
- WGSL: 6.2.7 Matrix Types, and the matrix constructors (World Wide Web Consortium (W3C))
- glTF 2.0 Specification: 3.5.3 Transformations, 3.7.4 Instantiation, and accessors of matrix type (Khronos Group)
- three.js documentation: Matrix4 (set, elements, invert) (three.js authors)
- three.js documentation: Matrix3 (getNormalMatrix) (three.js authors)
- three.js documentation: Vector3 (applyMatrix4, transformDirection) (three.js authors)
- Computer Science Curricula 2023: Graphics and Interactive Techniques (GIT) (ACM, IEEE Computer Society and AAAI (CS2023 task force))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress