Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · step by step One point, the same three matrices, two orders Step through both products. Each row applies one more matrix, starting from the one written last; compare the two columns at every step.
One point, the same three matrices, two orders, step 8 of 8. Showing p = (1, p = (1, S p = (2, T p = (11, R S p = (0, R T p = (0, T R S p = (10, S R T p = (0. In focus: T R S p = (10, S R T p = (0.p = (1M = T R Sp = (1M = S R TS p = (2S first: scale by 2T p = (11T first: move 10 along xR S p = (0then rotate 90 degreesR T p = (0then rotate 90 degreesT R S p = (10then move 10 along xS R T p = (0then scale by 2

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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).
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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).
  8. 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:

T R S and S R T applied to the same point JavaScript · order/compose_order.mjs
// 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

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

  1. Question 1 of 2 The rotation R has the columns (0, 1, 0), (−1, 0, 0) and (0, 0, 1). Where does it send the y axis, (0, 1, 0)?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: To (−1, 0, 0), its second column

    R p is the columns of R weighted by p's coordinates, so R applied to an axis is one column: R sends x to (0, 1, 0) and y to (−1, 0, 0), a turn of 90 degrees about z.

  2. Question 2 of 2 With column vectors, which of the three matrices in M = T R S acts on a point first?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: S, the rightmost, because M p = T (R (S p))

    T R S scales, then rotates, then translates, so (1, 0, 0) goes to (2, 0, 0), (0, 2, 0) and (10, 2, 0). The same three in the order S R T send it to (0, 22, 0).

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 and directions through one 4 × 4 matrix JavaScript · homogeneous/homogeneous.mjs
// 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

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

  1. Question 1 of 2 With the lesson's M = T R S, a light direction (1, 0, 0) is transformed as if it were a point, with w = 1. What comes out, instead of the right (0, 2, 0)?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: (10, 2, 0), because w = 1 picks up the translation by (10, 0, 0)

    Give directions w = 0, or use only the upper 3 × 3 part, so the translation is ignored; then normalise again, because the scale changes their length.

  2. Question 2 of 2 Why can no 3 × 3 matrix translate points?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A 3 × 3 matrix always maps the origin to itself, and a translation moves the origin

    With w = 1, a 4 × 4 matrix adds its fourth column to every point: the origin (0, 0, 0, 1) moved to (10, 0, 0, 1) under M.

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:

A rigid inverse, a general inverse and four determinants JavaScript · inverse/inverse.mjs
// 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

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

  1. Question 1 of 2 What is the inverse of a rigid transform [R t; 0 1], where R is a rotation and t a translation?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: [Rᵀ, −Rᵀ t; 0 1]

    For T(4, −2, 7) R(30°) this shortcut matched Gauss-Jordan to 5.6 × 10⁻¹⁷. With a scale in the matrix it is wrong, by 3.696 in the lesson's run.

  2. Question 2 of 2 What is the determinant of the scale S(1, 0, 1), and what does three.js's Matrix4.invert() do with such a matrix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 0, so there is no inverse; three.js returns a zero matrix instead of an error

    The determinant is the factor by which a matrix scales volumes: 0 flattens, so information is lost and there is no inverse. Check it wherever a scale can reach 0, for example in an animation.

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:

A unit circle stretched by 2 along x into an ellipse; at one point the normal times M leans 36.87° off, the inverse transpose is right.A circle scaled by (2, 1), and one normalThe point that came from 45 degreesby Mby the inverse transposenormal times M: (1.414, 0.707), 36.87° offtimes the inverse transpose: (0.354, 0.707)tangent: the true normal is at 90° to itthe unit circle before scalingthe point (1.414, 0.707), from (0.707, 0.707)

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:

Normals of a scaled sphere, by M and by the inverse transpose JavaScript · normals/normals_scale.mjs
// 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

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

  1. Question 1 of 2 What is the normal matrix of the scale S(2, 1, 1)?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The scale S(1/2, 1, 1)

    The normal matrix is the inverse transpose of the upper 3 × 3 part. For S(2, 1, 1) it halves x, which turns the normal from 45 degrees into (0.354, 0.707), perpendicular to the ellipse.

  2. Question 2 of 2 For which transforms does multiplying normals by the matrix itself give the right directions?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Rotations and uniform scales, whose inverse transpose differs from the matrix only by a factor

    The inverse transpose and the matrix agree, up to a factor, exactly for rotations and uniform scales, so the bug only appears when a model is stretched unevenly.

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:

A translation in column-major storage, read right and wrong JavaScript · layout/layout.mjs
// 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

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

  1. Question 1 of 2 A 4 × 4 affine matrix is stored column-major in an array of 16 numbers. At which index is its translation's x?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 12

    The translation is the fourth column, and in column-major order the fourth column starts at index 3 × 4 = 12, so x, y and z are at 12, 13 and 14, as three.js's elements and WGSL's fourth column vector hold them.

  2. Question 2 of 2 In three.js, m.set(1, 0, 0, 5, 0, 1, 0, 6, 0, 0, 1, 7, 0, 0, 0, 1) is called. What is m.elements[12]?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 5, because set() takes its arguments row by row and elements stores the columns

    three.js documents that set() takes row-major arguments and that elements is column-major, so the translation written in the last column of the arguments ends at indices 12, 13 and 14.

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.

  1. Column-major 4 × 4 matrices that compose in the right order To do
  2. 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 product a b, so that multiply(a, b) applied to a point applies b first.
  • translation(x, y, z) and scaling(x, y, z): the two builders (rotationZ90 is given).
  • transformPoint(m, p) and transformDirection(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.

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, return null; 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⁻¹, or null when 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.

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.

Matrix Calculator Check an inverse or a determinant by hand in exact fractions, such as the scale (2, 4, 0.5) of the second step.

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.

  1. Question 1 of 13 In the lesson's run, which pair of matrices gave the same product in both orders?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: The uniform scale S(2, 2, 2) and the rotation R

    Matrix products are associative but not commutative. Some pairs happen to commute, such as a uniform scale and a rotation, but translations commute with neither.

  2. Question 2 of 13 An object meant to spin in place instead swings in a big circle round the origin. Which composition causes it?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: R T, which moves the object first and then rotates it about the origin

    For spinning in place, rotate the object about its own origin before translating it: M = T R, not R T, as in glTF's translation × rotation × scale.

  3. Question 3 of 13 Two points both have w = 1. What is the w of their difference?

    Type a number.

    Show the answer: Question 3 of 13

    Answer: 0

    1 − 1 = 0: the difference of two points is a direction, which a 4 × 4 matrix rotates and scales but never translates, so M(b) − M(a) = M(b − a).

  4. Question 4 of 13 Which three.js Vector3 method treats the vector as a direction rather than a point?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: transformDirection, which uses the upper 3 × 3 part of the matrix and normalises the result

    applyMatrix4 is the point version (w = 1 and a division by w); transformDirection is the direction version (no translation, then normalised).

  5. Question 5 of 13 Under the lesson's M = T R S, with S a uniform scale by 2, a direction of length 2 is transformed with w = 0. What is its length afterwards?

    Type a number.

    Show the answer: Question 5 of 13

    Answer: 4

    The translation is ignored and the rotation keeps lengths, but the scale doubles them: 2 becomes 4, as the program printed. Normalise directions again after a scale.

  6. Question 6 of 13 What is the determinant of the mirror S(−1, 1, 1)?

    Type a number.

    Show the answer: Question 6 of 13

    Answer: -1

    The product of the diagonal, −1 × 1 × 1 = −1. A negative determinant means the transform mirrors, which glTF uses to switch the winding of a node's triangles to clockwise.

  7. Question 7 of 13 A camera is placed in the world by a rotation and a translation only. Why can its view matrix, the inverse of that placement, be built without Gauss-Jordan elimination?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: The placement is rigid, so its inverse is the transposed rotation with the translation rotated back and negated

    Keep camera placements rigid and invert them with the shortcut. Give a camera a scale and the shortcut silently fails, so the code must then use a general inverse.

  8. Question 8 of 13 What is the determinant of the upper 3 × 3 part of T(4, −2, 7) R(30°) S(2, 1, 1)?

    Type a number.

    Show the answer: Question 8 of 13

    Answer: 2 (anything from 1.999 to 2.001 counts)

    Determinants multiply: the translation does not touch the 3 × 3 part, the rotation's determinant is 1, and the scale's is 2 × 1 × 1 = 2. The program printed 2.000.

  9. Question 9 of 13 An affine transform scales by (2, 4, 0.5) and then moves by (6, 8, 1). What is the translation part of its inverse?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: (−3, −2, −2), which is −A⁻¹ t

    With M = [A t; 0 1] the inverse is [A⁻¹, −A⁻¹ t; 0 1]. Here A⁻¹ = S(1/2, 1/4, 2), so −A⁻¹ t = −(3, 2, 2) = (−3, −2, −2): the inverse the second exercise's sample test expects.

  10. Question 10 of 13 What is a transform's normal matrix?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: The inverse transpose of its upper 3 × 3 part

    Points move with M; normals move with (M₃ₓ₃⁻¹)ᵀ. They agree, up to a factor, only for rotations and uniform scales.

  11. Question 11 of 13 A unit sphere is scaled by (2, 1, 1) and its normals are transformed by the scale matrix itself. By how many degrees, at most, are they wrong?

    Type a number.

    Show the answer: Question 11 of 13

    Answer: 36.87 degrees (anything from 36.82 to 36.92 counts)

    At 45 degrees round the equator the matrix gives the direction (1.414, 0.707) and the true normal is (0.354, 0.707); the angle between them is 63.43° − 26.57° = 36.87°.

  12. Question 12 of 13 In WGSL, what does m[3] give for a matrix m of type mat4x4f?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: Its fourth column, a vec4f, which holds the translation of an affine matrix

    A mat4x4f is four vec4f column vectors, so m[3].xyz is the translation, matching indices 12 to 14 of the column-major array.

  13. Question 13 of 13 A renderer transposes three.js's elements before writing them into a WGSL uniform, and every translated model collapses towards the origin. Why?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: The translation lands in the bottom row, so it is multiplied into w, and the division by w shrinks everything

    three.js's elements, glTF's matrices and WGSL's mat4x4f are all column-major, so the array is uploaded as it is; transposing it moves the translation into the row that computes w.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

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 elements are column-major, with the translation at indices 12 to 14, while Matrix4.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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.