This chapter links to the course’s interactive demos and uses MathJax for its formulas. Read it through the course website (or download the file and open it in a browser); a Canvas inline preview will reach neither the demos nor the math. All chapters.

Affine Transformations over Homogeneous Coordinates

Homogeneous coordinates were introduced by August Ferdinand Möbius in 1827, in Der barycentrische Calcul; Lawrence Roberts brought them to 3D computer graphics in his 1963 MIT thesis. Möbius, 1827; Roberts, Machine Perception of Three-Dimensional Solids, MIT, 1963

To place a model in a scene you translate, rotate, scale and sometimes shear it, in some order, and then undo those operations for picking and for cameras. All of these are one kind of map, an affine map: a linear part and a shift, and one extra coordinate, \(w\), turns the whole family into a single matrix multiplication. The chapter starts with that coordinate and the reasons graphics uses it, names the family and what it preserves, and derives from two block formulas what the pipeline needs: why composition order matters, the inverse, a matrix read as a coordinate frame and read backward as a change of coordinates, the TRS form engines store and the shear it cannot hold, the rule for transforming normals, pivots and conjugation, and the boundary of the family, where the perspective matrix leaves it. It also settles which way round the sixteen numbers are stored. The Appendix holds the conditioning of the inverse, the polar decomposition and the transformation of planes. The pictures are two-dimensional wherever the idea allows, and every number is computed by the course’s own transform library.

Contents
  1. Homogeneous coordinates, and why graphics uses them
  2. Affine maps and what they preserve
  3. Homogeneous coordinates: the lift
  4. Composition, and why order matters
  5. The inverse
  6. A matrix is a frame; object and world space
  7. TRS, and the shear it cannot store
  8. Normals transform by the inverse transpose
  9. Pivots and conjugation
  10. Where affine ends
  11. In code
  12. Exercises
Appendix: A.1 Conditioning · A.2 The polar decomposition · A.3 Planes · A.4 Exercises

0 · Homogeneous coordinates, and why graphics uses them

Homogeneous coordinates add one number, \(w\), to every point: the 2D point \((x, y)\) is written \((x, y, 1)\) and the 3D point \((x, y, z)\) is written \((x, y, z, 1)\). Any nonzero multiple names the same point, so \((x, y, w)\) is the point \((x/w,\, y/w)\). Seen one dimension up, a homogeneous point is a whole ray through the origin, and the ordinary point is where that ray crosses \(w = 1\), found by dividing by \(w\).

Points of a line lifted to pairs (x, w): the points (1, 0.5), (2, 1) and (4, 2) lie on one ray through the origin and all name x = 2; keeping x = 2 and shrinking w to 0.5 and 1/3 flattens the ray so it meets the line w = 1 at x = 4 and x = 6; the ray along w = 0 is the direction +x and never meets the line.
One dimension down, so it fits a page: points \(x\) of a line lifted to \((x, w)\). Every point on a ray through the origin names the same \(x = x/w\); as \(w\) shrinks the crossing runs outward, and at \(w = 0\) there is no crossing: a direction.

Graphics uses this representation for four reasons.

Worked example (a point light and a directional light) A shader needs the direction from a surface point \(\mathbf{p}\) toward the light. Store the light as a homogeneous \(\mathbf{L} = (L_x, L_y, L_z, L_w)\); then that direction is \(\mathbf{L}_{xyz} - L_w\,\mathbf{p}\) with no branch. For a point light at \((1, 4)\), \(\mathbf{L} = (1, 4, 1)\) and the direction is \(\mathbf{L} - \mathbf{p}\), different at every surface point; for the light “infinitely far toward \((-1, 2)\)”, \(\mathbf{L} = (-1, 2, 0)\) and the direction is \((-1, 2)\) everywhere. A unit box on the ground at \(2.5 \le x \le 3.5\) casts the shadow of its corner \((3.5, 1)\) to \(x = 1 + 2.5\cdot\tfrac{4}{3} = 4.333\) under the point light and to \(x = 3.5 + \tfrac12 = 4\) under the directional one. Fixed-function OpenGL treated a light position with \(w = 0\) as a directional light, and Unity’s built-in shaders still receive _WorldSpaceLightPos0 as (direction, 0) or (position, 1).
Left: a point light at (1, 4, 1) with rays spreading to the ground and a box whose shadow ends at x = 4.333. Right: a directional light (−1, 2, 0) with parallel rays; the same box's shadow ends at x = 4.
The same box under a light with \(w = 1\) and with \(w = 0\). The rays spread from a point and are parallel from a direction; the shadow ends were computed from \(\mathbf{L}_{xyz} - L_w\,\mathbf{p}\).

1 · Affine maps and what they preserve

Definition (affine map) \[ f(\mathbf{x}) = A\,\mathbf{x} + \mathbf{t}, \qquad A \in \mathbb{R}^{3\times3} \text{ (the linear part)},\quad \mathbf{t}\in\mathbb{R}^3 \text{ (the translation)}. \] A linear map is the case \(\mathbf{t} = 0\); it fixes the origin. Translation is the one thing a matrix alone cannot do, and the \(+\,\mathbf{t}\) is what makes the family affine.
map\(A\)\(\mathbf{t}\)
translation by \(\mathbf{t}\)\(I\)\(\mathbf{t}\)
rotation \(R\) about the origin\(R\)\(0\)
scale by \((s_x, s_y, s_z)\)\(\mathrm{diag}(s_x, s_y, s_z)\)\(0\)
shear \(x' = x + k\,y\)\(\begin{pmatrix} 1 & k & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1\end{pmatrix}\)\(0\)
any composition of thesea product of the \(A\)’ssome vector (Section 3)
What affine maps preserve Lines go to lines; parallel lines stay parallel; ratios of lengths along a line are kept (a midpoint maps to the midpoint of the image), and so are barycentric coordinates, which is why the weights of the vectors chapter can be computed in any frame. Lengths and angles are not preserved in general; they are only when \(A\) is orthogonal (a rotation or reflection).
The unit square in blue and its image under the shear A = [[1, 0.5],[0,1]] in red, a parallelogram; the diagonal's midpoint at (0.5, 0.5) maps to (0.75, 0.5), the midpoint of the image diagonal; horizontal grid lines stay parallel.
A shear is the honest example: it breaks angles and lengths (the square leans) while keeping every line straight and every midpoint a midpoint. A triangle mesh survives any affine map as a triangle mesh for exactly this reason.
Worked example (the shear, and a midpoint) \(A = \begin{pmatrix} 1 & 0.5 \\ 0 & 1\end{pmatrix}\): \((1,1)\mapsto(1.5, 1)\), \((0,1)\mapsto(0.5, 1)\), \((1,0)\mapsto(1,0)\). The diagonal from \((0,0)\) to \((1,1)\) maps to the segment from \((0,0)\) to \((1.5,1)\), and the midpoint \((\tfrac12,\tfrac12)\) maps to \((0.75, 0.5)\), which is the midpoint of that image. The angle at the origin changed from \(45^\circ\) to \(\arctan(1/1.5) = 33.7^\circ\).

1.1 The determinant: volume and handedness

\(\det A\) is the factor by which \(A\) scales volume (the scalar triple product of its columns, the vectors chapter, Section 3.2), and its sign says whether handedness flipped. Computed by the library:

\(A\)\(\det A\)meaning
\(\mathrm{diag}(2, 1, 1)\)\(2\)twice the volume
\(R_y(60^\circ)\)\(1\)a rotation keeps volume and handedness
\(\mathrm{diag}(-1, 1, 1)\)\(-1\)a mirror: same volume, right hand to left hand
the shear above\(1\)area kept, shape not
a zero scale\(0\)a dimension collapsed; no inverse
Left: the unit square and its image under the matrix with columns (2, 0) and (1, 1.5), a parallelogram of area 3. Right: a counter-clockwise triangle and its mirror image under diag(−1, 1), which is clockwise.
In 2D, \(\det\begin{pmatrix} 2 & 1 \\ 0 & 1.5\end{pmatrix} = 2\cdot1.5 - 1\cdot0 = 3\): the unit square’s area 1 becomes 3, and so does every other area. The mirror \(\mathrm{diag}(-1, 1)\) has determinant \(-1\): the area is kept and the order of the corners reverses.
Worked example (a mirror flips the winding) The triangle \((0,0), (1,0), (0,1)\) is counter-clockwise: its edge function \((1 - 0)(1 - 0) - (0 - 0)(0 - 0) = +1\), and in three dimensions its normal \((1,0,0)\times(0,1,0) = (0,0,1)\) points out of the page. Under \(\mathrm{diag}(-1, 1)\) the corners become \((0,0), (-1,0), (0,1)\), the edge function is \(-1\), and the normal is \((-1,0,0)\times(0,1,0) = (0,0,-1)\). The corner order was not touched; the mirror reversed it. A model imported with one negative scale factor therefore renders inside out: every face that the meshes chapter wound counter-clockwise is now clockwise, the back-face test of the rasterization chapter culls the fronts, and the normals of Section 7 point in. The fix is not to re-wind the mesh but to flip the culling side when \(\det A < 0\), which is what engines do.

1.2 Points move; displacements do not

\[ f(\mathbf{p}) - f(\mathbf{q}) = (A\mathbf{p} + \mathbf{t}) - (A\mathbf{q} + \mathbf{t}) = A\,(\mathbf{p} - \mathbf{q}). \]
Two points P and Q with the displacement arrow between them, then their images under a translation by (2, 0): the points moved right, the arrow between them is identical.
A displacement (the arrow between two points, a velocity, a normal) feels only the linear part. Under a pure translation it does not change at all.

A position is a place and moves with the world; a direction has no place and must not move when the world is shifted. Section 2 makes the matrix enforce this automatically.

2 · Homogeneous coordinates: the lift

Append a \(1\) to every point, \((x, y, z)\mapsto(x, y, z, 1)\), and pack \(A\) and \(\mathbf{t}\) into one \(4\times4\) matrix:

Definition (the homogeneous matrix of an affine map) \[ \begin{pmatrix} A & \mathbf{t} \\ \mathbf{0}^{\mathsf T} & 1 \end{pmatrix} \begin{pmatrix} \mathbf{x} \\ 1 \end{pmatrix} = \begin{pmatrix} A\mathbf{x} + \mathbf{t} \\ 1 \end{pmatrix}, \qquad \begin{pmatrix} A & \mathbf{t} \\ \mathbf{0}^{\mathsf T} & 1 \end{pmatrix} \begin{pmatrix} \mathbf{v} \\ 0 \end{pmatrix} = \begin{pmatrix} A\mathbf{v} \\ 0 \end{pmatrix}. \] A point carries \(w = 1\) and is moved by the translation; a vector carries \(w = 0\) and the translation column is multiplied away.
The anatomy of a 4 by 4 affine matrix: a 3 by 3 block A in blue, the translation column t in red, a bottom row of three zeros, and a 1 in the corner.
The four blocks. The bottom row \((0\;0\;0\;|\;1)\) is the signature of an affine matrix: it keeps \(w = 1\). Section 9 is about what happens when a matrix changes it.

In two dimensions the same construction is a \(3\times3\): translation by \((t_x, t_y)\) is \(\begin{pmatrix} 1 & 0 & t_x \\ 0 & 1 & t_y \\ 0 & 0 & 1 \end{pmatrix}\), and \((x, y, 1)\mapsto(x + t_x,\, y + t_y,\, 1)\). In the three-dimensional \((x, y, w)\) space this matrix is linear: it fixes the origin and slides each horizontal layer by \(w\) times \(\mathbf{t}\), a shear. The floor \(w = 0\) does not move; the plane \(w = 1\), where the points live, moves by \(\mathbf{t}\). Translation, which no \(2\times2\) matrix can do, is a shear one dimension up.

The plane w = 1 above the floor w = 0 in (x, y, w) space: a flag on the plane slides by t = (2, 1), the w axis leans toward (2, 1, 1), and a direction on the floor does not move.
The \(3\times3\) translation by \(\mathbf{t} = (2, 1)\) acting on \((x, y, w)\) space: \(T(x, y, 1) = (x + 2, y + 1, 1)\) and \(T(x, y, 0) = (x, y, 0)\). Drawn in an oblique view; the numbers are the library’s makeTRS on the plane \(z = 0\).

This is the entire engineering reason for \(4\times4\) matrices in graphics: translation, rotation, scale and shear become one data type with one multiply, one inverse, and one upload to the GPU. The \(w\) arithmetic is a pleasant type check: point minus point gives \(w = 0\), a vector; point plus vector gives \(w = 1\), a point.

Worked example (move a point, not a vector) \(T\) = translate by \((2, 0, 0)\), whose fourth column is \((2, 0, 0, 1)\). \[ \begin{gathered} T\,(0, 0, 0, 1)^{\mathsf T} = (2, 0, 0, 1)^{\mathsf T} \;\text{(the origin moved)}, \\ T\,(1, 0, 0, 0)^{\mathsf T} = (1, 0, 0, 0)^{\mathsf T} \;\text{(the direction did not)}. \end{gathered} \] One line of arithmetic proves the convention: the zero in \(w\) killed the translation column.

2.1 Sixteen numbers, two conventions

A \(4\times4\) matrix is sixteen numbers in memory, and two decisions turn them into a matrix: whether consecutive numbers run down a column or along a row (column-major or row-major storage), and whether a point is a column that the matrix multiplies from the left, \(\mathbf{p}' = M\mathbf{p}\), or a row that multiplies the matrix from the right, \(\mathbf{p}' = \mathbf{p}M\) (column-vector or row-vector convention). The mathematics is identical, since \((M\mathbf{p})^{\mathsf T} = \mathbf{p}^{\mathsf T}M^{\mathsf T}\); the bookkeeping is not.

The 4 by 4 matrix of makeTRS(1,2,3, 0,60,0, 2,1,0.5) drawn as a grid with each entry's array index in its corner: the translation 1, 2, 3 sits in the right column at indices 12, 13, 14; the notes beside it explain column-major indexing and the row-vector transpose.
The library’s storage. Entry \((\text{row}, \text{col})\) is at index \(4\cdot\text{col} + \text{row}\), so each column is four consecutive numbers and the translation occupies indices 12, 13, 14. A row-vector system holds the same sixteen numbers in the same order and reads them as the transpose.
systemvectorsstorage“rotate then translate” is writtentranslation lives at
OpenGL, three.js, this library, Unity’s Matrix4x4columncolumn-major\(T\cdot R\)indices 12, 13, 14 (the last column)
DirectX, HLSL by default, Unrealrowrow-major\(R\cdot T\)indices 12, 13, 14 (the last row)
textbooks, this textcolumnas printed\(T\cdot R\)the last column
Worked example (the same transform in both conventions) The \(T\cdot R\) matrix of Section 3, with the translation \((1.5, 0, 0)\) in its last column, sends the column point \((1, 0, 0, 1)^{\mathsf T}\) to \((2, 0, -0.866, 1)^{\mathsf T}\). A row-vector system stores its transpose, with \((1.5, 0, 0)\) in the last row, and computes \((1, 0, 0, 1)\cdot M^{\mathsf T}\), which is the row \((2, 0, -0.866, 1)\): the same point. Its composition reads left to right, \(R\cdot T\) for “rotate, then translate”, the reverse of this text’s \(T\cdot R\). The memory of the two systems, sixteen numbers with the translation at indices 12 to 14, is byte for byte the same.
Pitfall Mixing the conventions. A matrix copied from a row-vector source into a column-vector system without transposing has its translation in the bottom row, where it becomes a projective term (Section 9): points shift in \(w\), the divide distorts the scene, and the object appears skewed and far away rather than simply misplaced. The tell is a bottom row other than \((0, 0, 0, 1)\); the library’s invertTRS refuses such a matrix with an explicit message rather than inverting it wrongly. Composition order errors are the milder cousin: writing \(R\cdot T\) where \(T\cdot R\) was meant puts the object on an arc instead of at its position, which Section 3 shows.

3 · Composition, and why order matters

Do \(f(\mathbf{x}) = A\mathbf{x} + \mathbf{t}\), then \(g(\mathbf{x}) = B\mathbf{x} + \mathbf{s}\):

\[ g(f(\mathbf{x})) = B(A\mathbf{x} + \mathbf{t}) + \mathbf{s} = (BA)\,\mathbf{x} + (B\mathbf{t} + \mathbf{s}). \]
The block product \[ \begin{pmatrix} B & \mathbf{s} \\ \mathbf{0}^{\mathsf T} & 1 \end{pmatrix} \begin{pmatrix} A & \mathbf{t} \\ \mathbf{0}^{\mathsf T} & 1 \end{pmatrix} = \begin{pmatrix} BA & B\mathbf{t} + \mathbf{s} \\ \mathbf{0}^{\mathsf T} & 1 \end{pmatrix}, \qquad \text{first } A \text{ (nearest the point), then } B . \] The linear parts multiply. The translations do not simply add: the first translation is pushed through the second linear part before the second translation is added. Order matters, and it matters through the translation term.

On column vectors, the matrix nearest the vector acts first, so a product \(B\cdot A\) means “\(A\) first, then \(B\)” and is read right to left, the same convention as the quaternion product in the rotation chapter. A chain of placements, an arm on a base on a table, is a product of several such matrices, and the scene-graph chapter is this formula applied down a tree.

A flag at the origin; T·R turns it 90 degrees in place and slides it to (2, 0); R·T slides it to (2, 0) first and then swings it about the origin to (0, 2) along a dashed quarter circle.
In 2D, with \(T\) the translation by \((2, 0)\) and \(R\) a \(90^\circ\) counter-clockwise turn: \(T\cdot R\) turns in place and slides, its column 3 is \(\mathbf{t} = (2, 0)\); \(R\cdot T\) slides out and swings about the origin, its column 3 is \(R\,\mathbf{t} = (0, 2)\). Dashed: the state after the first factor.
Worked example (\(T\cdot R\) and \(R\cdot T\) from the block formula) \(T\) = translate by \(\mathbf{t} = (1.5, 0, 0)\); \(R = R_y(60^\circ)\), \(\cos 60^\circ = 0.5\), \(\sin 60^\circ = 0.866\).
\(T\cdot R\)second map \(T\): \(B = I\), \(\mathbf{s} = \mathbf{t}\); first map \(R\): \(A = R\), translation \(0\). Linear part \(R\); translation \(I\cdot 0 + \mathbf{t} = (1.5, 0, 0)\).
\(R\cdot T\)second map \(R\): \(B = R\), \(\mathbf{s} = 0\); first map \(T\): \(A = I\), translation \(\mathbf{t}\). Linear part \(R\); translation \(R\,\mathbf{t} = (1.5\cos60^\circ, 0, -1.5\sin60^\circ) = (0.75, 0, -1.299)\).
\[ T\cdot R = \begin{pmatrix} 0.5 & 0 & 0.866 & 1.5 \\ 0 & 1 & 0 & 0 \\ -0.866 & 0 & 0.5 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}, \qquad R\cdot T = \begin{pmatrix} 0.5 & 0 & 0.866 & 0.75 \\ 0 & 1 & 0 & 0 \\ -0.866 & 0 & 0.5 & -1.299 \\ 0 & 0 & 0 & 1 \end{pmatrix}. \] Same linear part, different translation column. Rotate then translate drops the move in as is; translate then rotate swings the move by \(60^\circ\), so the object lands on an arc of radius \(1.5\) instead of at \((1.5, 0, 0)\). Both matrices are the library’s matMul(makeTRS(…), makeTRS(…)), and they are what the demo’s panels print.
Top view of the x-z plane: a gray L-shaped object at the origin; in blue its image under T·R, rotated in place and slid to (1.5, 0, 0); in red its image under R·T, slid and then swung about the origin to (0.75, 0, -1.30), with the arc of radius 1.5 it traveled.
One object, two orders, seen from above. The blue copy rotated about its own center and slid over; the red copy slid out and then swung around the world origin on a lever arm.
See it live Order matters: two cubes built from the same \(T\) and \(R\) in opposite orders, with all four matrices printed. Compare column 3 of the two products while you drag.

4 · The inverse

Solve \(\mathbf{y} = A\mathbf{x} + \mathbf{t}\) for \(\mathbf{x}\): \(\mathbf{x} = A^{-1}\mathbf{y} - A^{-1}\mathbf{t}\).

The block inverse \[ \begin{pmatrix} A & \mathbf{t} \\ \mathbf{0}^{\mathsf T} & 1 \end{pmatrix}^{-1} = \begin{pmatrix} A^{-1} & -A^{-1}\mathbf{t} \\ \mathbf{0}^{\mathsf T} & 1 \end{pmatrix}, \qquad (B\cdot A)^{-1} = A^{-1}\cdot B^{-1} . \] For a rigid transform (\(A = R\) a rotation) \(R^{-1} = R^{\mathsf T}\), so the inverse costs a transpose and one matrix-vector product: \(\big[\,R^{\mathsf T},\; -R^{\mathsf T}\mathbf{t}\,\big]\). Scale inverts by reciprocals; a zero scale has no inverse. For \(M = T\,R\,S\) the whole inverse is \(S^{-1}R^{\mathsf T}T^{-1}\), three cheap factors, and that is what the library’s invertTRS assembles.

The reversal rule for products is the socks-then-shoes rule: to undo, take the shoes off first. The two uses that matter most: bringing a world point into an object’s own frame for a hit test (Section 5), and the view matrix, which is the inverse of the camera’s placement (the viewing chapter).

Worked example (invert \(M = T(2,0,0)\cdot R_y(90^\circ)\)) \(R_y(90^\circ)\) has columns \((0,0,-1)\), \((0,1,0)\), \((1,0,0)\), and \(\mathbf{t} = (2,0,0)\). \[ M = \begin{pmatrix} 0 & 0 & 1 & 2 \\ 0 & 1 & 0 & 0 \\ -1 & 0 & 0 & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix}, \qquad M^{-1} = \begin{pmatrix} 0 & 0 & -1 & 0 \\ 0 & 1 & 0 & 0 \\ 1 & 0 & 0 & -2 \\ 0 & 0 & 0 & 1 \end{pmatrix}. \] The linear part of \(M^{-1}\) is \(R^{\mathsf T}\) (rows of \(R\) become columns). Its translation is \(-R^{\mathsf T}\mathbf{t}\): the rows of \(R^{\mathsf T}\) are the columns of \(R\), so \(R^{\mathsf T}\mathbf{t} = \big((0,0,-1)\cdot\mathbf{t},\; (0,1,0)\cdot\mathbf{t},\; (1,0,0)\cdot\mathbf{t}\big) = (0, 0, 2)\), negated \((0, 0, -2)\). The library’s invertTRS returns exactly this matrix, and \(M\cdot M^{-1}\) evaluates to the identity in every entry.

The rigid inverse has a picture. \(M^{-1}\mathbf{p} = R^{\mathsf T}(\mathbf{p} - \mathbf{t})\): subtract the frame’s origin, then measure along the frame’s axes, one dot product each, because the rows of \(R^{\mathsf T}\) are the columns of \(R\), the frame’s axes.

Left: a frame at t = (3, 1) turned 30 degrees, with axes u and v, and the world point p = (4, 3). Right: the same point in the frame's coordinates, (1.866, 1.232), the dot products of p − t with u and v.
\(M = T(3, 1)\cdot R(30^\circ)\) and the world point \(\mathbf{p} = (4, 3)\): \(\mathbf{p} - \mathbf{t} = (1, 2)\), and its dot products with the frame’s axes \(\mathbf{u} = (0.866, 0.5)\) and \(\mathbf{v} = (-0.5, 0.866)\) are \(1.866\) and \(1.232\), the coordinates of \(\mathbf{p}\) in the frame. As one matrix, \(M^{-1}\) has translation column \(-R^{\mathsf T}\mathbf{t} = (-3.098, 0.634)\).

5 · A matrix is a frame; object and world space

Feed the matrix the basis vectors and the origin, in homogeneous form:

\[ \begin{aligned} M\,(1,0,0,0)^{\mathsf T} &= \text{column 0}, & M\,(0,1,0,0)^{\mathsf T} &= \text{column 1}, \\ M\,(0,0,1,0)^{\mathsf T} &= \text{column 2}, & M\,(0,0,0,1)^{\mathsf T} &= \text{column 3}. \end{aligned} \]
A matrix is an affine frame Columns 0 to 2 are where the object’s axes land in the world (vectors); column 3 is where its origin lands (a point). Given any placement matrix in a debugger: the position is column 3 verbatim, the object’s world-space axes are the other columns, and their lengths are its scale factors.
In world coordinates, a rotated and scaled unit square drawn in purple; the red arrow from its origin is column 0 of the matrix (length 1.5), the green arrow is column 1 (length 1), the black dot at (1.5, 1) is column 3; a point at object coordinates (0.5, 0.5) lands at (1.9, 1.808).
Two dimensions for legibility. \(M = [\,\mathbf{c}_0\;\mathbf{c}_1\;\mathbf{t}\,]\) with \(\mathbf{c}_0 = (1.299, 0.75)\), \(\mathbf{c}_1 = (-0.5, 0.866)\), \(\mathbf{t} = (1.5, 1)\): a frame rotated \(30^\circ\), stretched \(1.5\times\) along its own \(x\), placed at \((1.5, 1)\). A point at object coordinates \((\tfrac12, \tfrac12)\) lands at \(\mathbf{t} + \tfrac12\mathbf{c}_0 + \tfrac12\mathbf{c}_1 = (1.9, 1.808)\).

Every object has its own frame, the one its mesh was authored in (a cube’s corners at \(\pm\tfrac12\), forever). The model matrix \(M\) is the bridge: \(\mathbf{p}_{\text{world}} = M\,\mathbf{p}_{\text{object}}\) places the mesh without touching its vertices, and \(\mathbf{p}_{\text{object}} = M^{-1}\mathbf{p}_{\text{world}}\) brings a world point (a mouse ray’s hit, another object’s position) into the object’s frame for tests like “is this point inside me”. In Unity, transform.localPosition lives in the parent’s frame and transform.position in the world; the difference is exactly the parent chain, which the scene-graph chapter composes and inverts.

5.1 Active and passive: one matrix, two readings

The same matrix answers two questions. Read actively, \(M\) moves the object: the cube’s corner at object \((\tfrac12, \tfrac12)\) is carried to world \((1.9, 1.808)\). Read passively, \(M\) changes coordinates: the world point \((1.9, 1.808)\), which never moved, has coordinates \((\tfrac12, \tfrac12)\) when measured along the object’s axes from the object’s origin, and \(M^{-1}\) is the map that measures. When the columns are orthonormal the passive reading is the dot-product recipe of the vectors chapter, Section 1.1; when they are not, it is a linear solve, which the inverse packages.

Worked example (a world point in the object’s coordinates) The \(2\times2\) block \([\mathbf{c}_0\;\mathbf{c}_1]\) of the figure has determinant \(1.299\cdot0.866 - 0.75\cdot(-0.5) = 1.5\) (the scale, times \(\cos^2 + \sin^2\)), and its inverse has rows \((0.577, 0.333)\) and \((-0.5, 0.866)\). The world point \((3, 3)\) is first moved by \(-\mathbf{t}\) to \((1.5, 2)\), then multiplied: \((0.577\cdot1.5 + 0.333\cdot2,\; -0.5\cdot1.5 + 0.866\cdot2) = (1.533, 0.982)\). In the object’s frame the point is \(1.533\) of the object’s stretched \(x\) units along and \(0.982\) up: outside the unit square, just past its right edge. The same procedure on \((1.9, 1.808)\) returns \((0.5, 0.5)\), closing the loop with the figure.

5.2 One throw, one chain

A placement is usually a product of several frames, each one simple in its own coordinates. A spiral pass, from the ball’s vertices to a pixel:

\[ \mathbf{p}_{\text{clip}} = P\,V\,F\,H\,T(t)\,A(t)\,W(t)\,S(t)\,\mathbf{p}_{\text{mesh}}. \]

Read from the right, each factor maps one frame into the next:

LinkMatrixFrom → to
\(S(t)\), spin\(R_z(\varphi)\), \(\varphi = 2\pi\cdot\tfrac{\text{rpm}}{60}\cdot t\)object → spin axis
\(W(t)\), wobble\(R_z(\psi)\,R_x(\beta)\,R_z(-\psi)\), \(\psi = 2\pi f t\)spin axis → velocity
\(A(t)\), aim\(R_x(-\theta)\), \(\theta = \operatorname{atan2}(v_y, v_z)\)velocity → flight
\(T(t)\), flighttranslation by \((0,\; v\sin\alpha\,t - \tfrac12 g t^2,\; v\cos\alpha\,t)\)flight → throw
\(H\), set-up\(T(\mathbf{s})\,R_y(h)\): release point \(\mathbf{s}\), heading \(h\)throw → field
\(F\), field\(R_y(\gamma)\,T(-25, 0, -55)\)field → world
\(V\), view\((F\,C)^{-1}\), \(C\) the camera’s placement in the fieldworld → camera
\(P\), projectiona perspective map (the viewing chapter)camera → clip

The ball is modeled with its long axis on \(+z\) and its laces on \(+y\), so the spin is a rotation about its own \(z\). \(W\) is the pivot sandwich of Section 8: the tilt \(R_x(\beta)\) done in a frame turned by \(\psi\) about \(z\), so the nose circles the direction of flight on a cone of half-angle \(\beta\), the wobble of a real spiral. \(A\) turns \(+z\) onto the velocity; a positive turn about \(x\) carries \(+z\) toward \(-y\), hence the minus sign. \(T\) is a pure translation, so the flight frame keeps the throw frame’s axes and the path lies in the throw frame’s \(yz\) plane. \(H\) and \(F\) are built once, from the throw’s parameters. Every partial product is a frame placement: \(F H\) is the throw frame in the world, \(F H\,T(t)\) the frame at the ball’s center, and column 3 of each is where that frame’s origin is.

Worked example (the top of the flight) The default throw: \(v = 22\) m/s at \(\alpha = 30^\circ\), released at \(\mathbf{s} = (20, 2, 25)\) on the field with heading \(h = 12^\circ\), field yaw \(\gamma = 0\), the wobble set to 0, and Seattle’s gravity, \(g = 9.808\) m/s². The top comes at \(t = v\sin\alpha / g = 11/9.808 = 1.1215\) s, where \(v_y = 0\), so \(\theta = 0\) and \(A = I\). \(T\) translates by \((0,\; 11t - 4.904t^2,\; 19.053t) = (0, 6.168, 21.368)\). \(H\) turns that by \(12^\circ\) about \(y\), to \((21.368\sin12^\circ,\; 6.168,\; 21.368\cos12^\circ) = (4.443, 6.168, 20.901)\), and adds \(\mathbf{s}\): the center is at field \((24.443, 8.168, 45.901)\). \(F\) subtracts \((25, 0, 55)\): world \((-0.557, 8.168, -9.099)\). The nose tip \((0, 0, 0.14)\) lies on the spin axis, so \(S\) leaves it where it is, and it lands \(0.14\) m along the heading \((\sin12^\circ, 0, \cos12^\circ) = (0.208, 0, 0.978)\): world \((-0.528, 8.168, -8.962)\).

A chain can also change its root. In the demo a cart carrying an open bowl drives to where the ball comes down and catches it at \(t_c\). From then on the ball is the cart’s child, and its placement must not jump at the switch, so its new local matrix is its old placement measured in the cart’s frame, \(L = K(t_c)^{-1}\,H\,T\,A\,W\,S\,(t_c)\), and afterwards it is \(F\,K(t)\,L\): the cart’s placement \(K(t)\) times a constant. That is what Unity’s SetParent(cart, true) (“world position stays”) computes, and what three.js’s Object3D.attach does.

See it live A spiral pass, step by step: the chain built one link at a time, a cart that catches the ball, and two more balls with chains of their own. Ride any frame (riding frame \(X\) shows exactly the links to the right of \(X\)), choose among nine shot cameras, switch a link off, edit each link’s three.js code beside its Unity C#, follow the nose to its pixel, compare the Euler angles typed with the quaternion stored, and read the whole program for both tracks. The ball itself, “Football” by rnepb13 (BlendSwap, CC BY 3.0) as OBJ and GLB with its textures, is in lib/assets/models/football/.

6 · TRS, and the shear it cannot store

Any invertible \(A\) factors as a rotation times a symmetric stretch, \(A = R\,P\) (the polar decomposition, Appendix A.2). Engines store the special case in which the stretch is axis-aligned:

\[ \begin{gathered} M = T\cdot R\cdot S, \\ \text{columns 0 to 2} = \text{the rotated axes, each scaled by its factor}, \qquad \text{column 3} = \mathbf{t}. \end{gathered} \]
Worked example (read a TRS matrix back) The library’s makeTRS(1,2,3, 0,60,0, 2,1,0.5) is \[ M = \begin{pmatrix} 1 & 0 & 0.433 & 1 \\ 0 & 1 & 0 & 2 \\ -1.732 & 0 & 0.25 & 3 \\ 0 & 0 & 0 & 1 \end{pmatrix}. \] Column lengths: \(\|(1, 0, -1.732)\| = 2\), \(\|(0,1,0)\| = 1\), \(\|(0.433, 0, 0.25)\| = 0.5\): the scale \((2, 1, 0.5)\). Normalize the columns: \((0.5, 0, -0.866)\), \((0,1,0)\), \((0.866, 0, 0.5)\), the columns of \(R_y(60^\circ)\). Column 3 is \((1, 2, 3)\). This read-back is the first step of invertTRS, and it silently assumes the normalized columns are orthonormal.

An affine map has twelve degrees of freedom (nine in \(A\), three in \(\mathbf{t}\)). A Transform stores nine: position, a rotation (three degrees of freedom, as a quaternion), and three scale factors. The missing three are shear: \(T\cdot R\cdot S\) cannot represent a sheared object. This is not academic. A child rotated \(45^\circ\) under a parent scaled non-uniformly is sheared in world space; Unity’s Transform cannot hold that matrix, and its lossyScale property is the honest admission that the world-space scale it reports is an approximation.

6.1 What the read-back does to a shear

The stretch need not come from a shear matrix. Scaling by 2 along the diagonal \((1, 1, 0)/\sqrt2\), the conjugation \(R_z(45^\circ)\,\mathrm{diag}(2, 1, 1)\,R_z(-45^\circ)\) of Section 8, is a symmetric matrix with no rotation at all, and TRS cannot hold it either:

Worked example (an oblique scale, and what invertTRS makes of it) \(M = \begin{pmatrix} 1.5 & 0.5 & 0 \\ 0.5 & 1.5 & 0 \\ 0 & 0 & 1 \end{pmatrix}\): \(M(1, 1, 0) = (2, 2, 0)\) (stretched twice along the diagonal) and \(M(1, -1, 0) = (1, -1, 0)\) (unchanged across it), \(\det M = 2\). Its first two columns have length \(1.581\) each and dot product \(1.5\), an angle of \(53.1^\circ\), not \(90^\circ\). The TRS read-back reports scale \((1.581, 1.581, 1)\) and a “rotation” whose columns are not orthogonal, and invertTRS, which trusts the read-back, returns a matrix whose first column is \((0.6, 0.2, 0)\) where the true inverse has \((0.75, -0.25, 0)\); \(M\) times that answer has \(0.6\) in an entry that should be 0. The shear of Appendix A.2 fails the same way: its columns have lengths \(1\) and \(1.118\) and meet at \(63.4^\circ\).
Pitfall A TRS inverse of a non-TRS matrix is wrong without warning. The library checks the bottom row and rejects projective matrices, but a sheared or obliquely scaled affine matrix passes that check and produces a confident, wrong inverse; the picking test in an object’s frame then misses. The symptom appears exactly where Section 6 says shear appears: a rotated child of a non-uniformly scaled parent. The test is cheap, the dot products of the normalized columns (all three should be 0), and a general \(4\times4\) inverse (Gaussian elimination, or the cofactor formula every math library carries) is the fallback.

7 · Normals transform by the inverse transpose

A tangent direction \(\mathbf{d}\) is a difference of points and transforms by \(A\). A normal is defined by being perpendicular to tangents, and \(A\mathbf{n}\) is not, in general, perpendicular to \(A\mathbf{d}\). What is: \(A^{-\mathsf T}\mathbf{n}\), because \((A\mathbf{d})\cdot(A^{-\mathsf T}\mathbf{n}) = \mathbf{d}^{\mathsf T}A^{\mathsf T}A^{-\mathsf T}\mathbf{n} = \mathbf{d}\cdot\mathbf{n} = 0\).

The unit circle with its normal and tangent at 45 degrees, and the ellipse after diag(2, 1): the normal pushed through A leans away from the true normal, while the inverse-transpose normal is perpendicular to the transformed tangent.
Squash a circle into an ellipse with \(A = \mathrm{diag}(2, 1)\). At \(\mathbf{p} = (0.707, 0.707)\) the circle’s normal is \(\mathbf{p}\) itself. After \(A\) the tangent is \((-1.414, 0.707)\); \(A\mathbf{n} = (1.414, 0.707)\) has dot product \(-1.5\) with it, while \(A^{-\mathsf T}\mathbf{n} = (0.354, 0.707)\) has dot product 0 and, renormalized, \((0.447, 0.894)\) is the ellipse’s true normal.
Left: a surface line along d = (1,1) with its normal n. Right: after the scale diag(2,1) the surface runs along (2,1); the naively transformed normal A n in red is visibly not perpendicular to it, while the inverse-transpose normal in green is.
A non-uniform scale is the case that exposes the bug. For a rotation \(A^{-\mathsf T} = A\), so nothing goes wrong until the first squashed model.
Worked example (the wrong normal and the right one) \(A = \mathrm{diag}(2, 1, 1)\); surface along \(\mathbf{d} = (1, 1, 0)\); normal \(\mathbf{n} = (1, -1, 0)/\sqrt2 = (0.707, -0.707, 0)\).
tangent after\(A\mathbf{d} = (2, 1, 0)\)
naive\(A\mathbf{n} = (1.414, -0.707, 0)\); \((2,1,0)\cdot(1.414, -0.707, 0) = 2.828 - 0.707 = 2.121 \ne 0\)
inverse transpose\(A^{-\mathsf T} = \mathrm{diag}(\tfrac12, 1, 1)\); \(A^{-\mathsf T}\mathbf{n} = (0.354, -0.707, 0)\); \((2,1,0)\cdot(0.354, -0.707, 0) = 0.707 - 0.707 = 0\)
Then renormalize. The shaders of the illumination chapter do exactly this, or approximate it when the inverse is unavailable.

8 · Pivots and conjugation

\(R\) spins about the origin. To spin about a point \(\mathbf{p}\), bring \(\mathbf{p}\) to the origin, rotate, and send it back, and apply the block product of Section 3:

\[ M = T(\mathbf{p})\cdot R\cdot T(-\mathbf{p}) \;=\; \begin{pmatrix} R & \mathbf{p} - R\mathbf{p} \\ \mathbf{0}^{\mathsf T} & 1 \end{pmatrix} = \begin{pmatrix} R & (I - R)\,\mathbf{p} \\ \mathbf{0}^{\mathsf T} & 1 \end{pmatrix}. \]

\(\mathbf{p}\) is the fixed point: \(T(-\mathbf{p})\) sends it to the origin, \(R\) leaves the origin, \(T(\mathbf{p})\) returns it. Swap \(R\) for \(S\) to scale about a point. Every off-center rotation, joint articulation and scale-about-a-corner is this conjugation, and the translation column \((I - R)\mathbf{p}\) is a number nobody would enter by hand, which is why the sandwich is built rather than typed.

Four panels: a square with its corner at the pivot c = (2, 1); moved so the pivot is at the origin; turned 90 degrees about the origin; moved back, with the pivot fixed and the square turned a quarter turn about it.
The sandwich in 2D, one factor at a time, with the pivot \(\mathbf{c} = (2, 1)\) and a \(90^\circ\) turn: \(T(-\mathbf{c})\), then \(R\), then \(T(\mathbf{c})\). The product has linear part \(R\) and translation \((I - R)\,\mathbf{c} = (2, 1) - (-1, 2) = (3, -1)\); the pivot is fixed and the corner \((3, 1)\) ends at \((2, 2)\).
Top view: a unit square with corners (2,0,0), (3,0,0), (2,0,1), (3,0,1) in blue, and its image under a 90 degree turn about the y axis through the pivot (2,0,0) in red: the pivot stays, (3,0,0) swings to (2,0,-1), (2,0,1) swings to (3,0,0).
A right-handed quarter turn about the vertical through \((2, 0, 0)\); \(z\) runs down the page.
Worked example (pivot at \((2,0,0)\), \(90^\circ\) about \(y\)) \(R\mathbf{p} = 2\cdot\text{column 0 of } R_y(90^\circ) = (0, 0, -2)\), so \((I - R)\mathbf{p} = (2,0,0) - (0,0,-2) = (2, 0, 2)\): \[ M = \begin{pmatrix} 0 & 0 & 1 & 2 \\ 0 & 1 & 0 & 0 \\ -1 & 0 & 0 & 2 \\ 0 & 0 & 0 & 1 \end{pmatrix}, \qquad \begin{aligned} M(2,0,0) &= (2, 0, 0), \\ M(3,0,0) &= (2, 0, -1), \\ M(2,0,1) &= (3, 0, 0). \end{aligned} \] The pivot stays; a neighbor one unit in \(+x\) swings to \(-z\); one unit in \(+z\) swings to \(+x\).
Conjugation: doing anything in another frame The pivot sandwich is a special case of \(M = F\,X\,F^{-1}\): move into the frame \(F\) where the operation \(X\) is simple, do it, move back. With \(F = T(\mathbf{p})\) it rotates about a point; with \(F = R_z(45^\circ)\) and \(X = \mathrm{diag}(2, 1, 1)\) it scales along the diagonal (the oblique scale of Section 6.1, \(M = R_z(45^\circ)\,S\,R_z(-45^\circ)\) with columns \((1.5, 0.5, 0)\) and \((0.5, 1.5, 0)\)); with \(F = T(\mathbf{p})\,R_{\text{axis}}\) it rotates about an arbitrary line. Joints in the scene-graph chapter and the texture placement matrix of the texture chapter, which rotates about the image’s center, are the same sandwich.

9 · Where affine ends

Homogeneous coordinates are defined only up to scale: \((x, y, z, w)\) and \((\lambda x, \lambda y, \lambda z, \lambda w)\) name the same point, \((x/w, y/w, z/w)\). An affine matrix never changes \(w\), so the divide is invisible. A general \(4\times4\) whose last row is not \((0,0,0,1)\) does change it, and the map it defines after the divide is projective: lines still go to lines, but parallel lines can meet and ratios along a line are not kept.

Left: a unit grid under an affine map, sheared and squashed but with straight, parallel, evenly spaced lines. Right: the same grid under a projective map with last row (0, 0.5, 1): the vertical lines converge toward a point and the horizontal rows bunch together.
Two dimensions, homogeneous \(3\times3\). Left, an affine map. Right, the last row \((0, 0.5, 1)\) makes \(w = 1 + 0.5\,y\), and the divide pulls far rows together: perspective.
Worked example (the divide, in two dimensions) With last row \((0, 0.5, 1)\), a point \((x, y)\) lifts to \((x, y, 1)\), maps to \((x, y, 1 + 0.5y)\), and divides to \(\big(x/(1 + 0.5y),\; y/(1 + 0.5y)\big)\):
in\(w\)out
\((1, 0)\)1\((1, 0)\)
\((1, 1)\)1.5\((0.667, 0.667)\)
\((1, 2)\)2\((0.5, 1)\)
Equal steps in \(y\) (0, 1, 2) become unequal steps out (0, 0.667, 1), and the vertical line \(x = 1\) leans toward \(x = 0\) as \(y\) grows: parallels meet at a vanishing point, which no affine map can do.

Three consequences. The perspective projection of the viewing chapter deliberately writes \(-z\) into \(w\), so that dividing by \(w\) divides by depth and distant things shrink; it is the one non-affine matrix in the pipeline, and the divide by \(w\) is its signature. A vector with \(w = 0\) is, in this language, a point at infinity: a direction is where parallel lines meet, which is why directions never move under translation. And the library’s invertTRS refuses a matrix whose last row is not \((0,0,0,1)\), because the block inverse of Section 4 only holds inside the affine family; the interaction chapter inverts the full projective matrix by elimination to send a mouse position back into the world.

10 · In code

Hands-on Run these from the css551/ directory. Matrices print as their 16-element column-major arrays: four numbers per column, translation last.
  1. The TRS matrix of Section 6 and its storage (compare with the figure of Section 2.1):
    node -e "import('./lib/core/xform.js').then(x=>{console.log(x.makeTRS(1,2,3,0,60,0,2,1,0.5).map(v=>+v.toFixed(3)));})"
    [ 1, 0, -1.732, 0,  0, 1, 0, 0,  0.433, 0, 0.25, 0,  1, 2, 3, 1 ]
  2. The translation columns of \(T\cdot R\) and \(R\cdot T\) (Section 3):
    node -e "import('./lib/core/xform.js').then(x=>{const T=x.makeTRS(1.5,0,0,0,0,0,1,1,1),R=x.makeTRS(0,0,0,0,60,0,1,1,1);console.log(x.matMul(T,R).slice(12,15).map(v=>+v.toFixed(3)), x.matMul(R,T).slice(12,15).map(v=>+v.toFixed(3)));})"
    [ 1.5, 0, 0 ] [ 0.75, 0, -1.299 ]
  3. A round trip through a TRS matrix and its inverse (in double precision; Appendix A.1 measures the float32 version):
    node -e "import('./lib/core/xform.js').then(x=>{const M=x.makeTRS(1,2,3,30,60,10,2,1,0.5);const p=x.applyMat4(M,[1,1,1]);console.log(x.applyMat4(x.invertTRS(M),p.slice(0,3)).slice(0,3).map(v=>+v.toFixed(6)));})"
    [ 1, 1, 1 ]
  4. The refusal on a projective matrix (Section 9):
    node -e "import('./lib/core/xform.js').then(x=>{try{x.invertTRS(x.perspective(45,1.78,1,8))}catch(e){console.log(e.message)}})"
    invertTRS: bottom row must be (0,0,0,1) ... Got a perspective/shear-shaped bottom row.
  5. The silent failure on an oblique scale (Section 6.1): the call succeeds and the first column of its answer is wrong:
    node -e "import('./lib/core/xform.js').then(x=>{const Rd=x.axisAngleMatrix(0,0,1,45),S=x.makeTRS(0,0,0,0,0,0,2,1,1);const M=x.matMul(x.matMul(Rd,S),x.invertTRS(Rd));console.log(x.invertTRS(M).slice(0,3).map(v=>+v.toFixed(3)));})"
    [ 0.6, 0.2, 0 ]
  6. In the order demo, set the rotation to \(180^\circ\) with the translation at \(1.5\): the \(R\cdot T\) cube lands at \(x = -1.5\), the \(T\cdot R\) cube at \(+1.5\), the widest the two orders can disagree.

11 · Exercises

Exercise 1 (composition) \(S\) = scale by \((2, 2, 2)\), \(T\) = translate by \((1, 0, 0)\). Compute the translation column of \(T\cdot S\) and of \(S\cdot T\), and say where the origin lands under each.
Solution \(T\cdot S\): \(B = I\), so the column is \(I\cdot0 + (1,0,0) = (1,0,0)\); the origin lands at \((1,0,0)\). \(S\cdot T\): \(B = 2I\), column \(2I\cdot(1,0,0) + 0 = (2, 0, 0)\); the origin lands at \((2,0,0)\). Scaling after translating scales the translation too.
Exercise 2 (inverse) Invert \(M = T(0, 3, 0)\cdot R_z(90^\circ)\) by the block formula and check on the point \(M(1,0,0)\).
Solution \(R_z(90^\circ)\) has columns \((0,1,0), (-1,0,0), (0,0,1)\); \(R^{\mathsf T}\mathbf{t} = \big((0,1,0)\cdot(0,3,0),\; (-1,0,0)\cdot(0,3,0),\; 0\big) = (3, 0, 0)\), so the inverse has linear part \(R^{\mathsf T}\) and translation \((-3, 0, 0)\). Forward: \(M(1,0,0) = R(1,0,0) + \mathbf{t} = (0,1,0) + (0,3,0) = (0,4,0)\). Back: \(R^{\mathsf T}(0,4,0) + (-3,0,0) = (4, 0, 0) + (-3, 0, 0) = (1, 0, 0)\).
Exercise 3 (read a frame) A matrix has columns \((0, 2, 0)\), \((-3, 0, 0)\), \((0, 0, 1)\), \((5, 5, 5)\). Give its position, scale, and rotation, and say whether it is right-handed.
Solution Position \((5,5,5)\); scale \((2, 3, 1)\) from the column lengths; normalized columns \((0,1,0), (-1,0,0), (0,0,1)\) are \(R_z(90^\circ)\). Determinant of the normalized part is \(+1\) (a rotation), so right-handed; with the scale, \(\det = 6 > 0\).
Exercise 4 (normals) A model is scaled by \(\mathrm{diag}(1, 1, 3)\). A face normal was \((0, 0.707, 0.707)\). Find the correct world-space normal.
Solution \(A^{-\mathsf T} = \mathrm{diag}(1, 1, \tfrac13)\): \((0, 0.707, 0.236)\), renormalized \((0, 0.949, 0.316)\). Stretching along \(z\) makes the surface flatter, so its normal tilts toward \(y\), away from the stretch; the naive \(A\mathbf{n} = (0, 0.707, 2.121)\) tilts the wrong way.
Exercise 5 (pivot) Scale by \(2\) about the point \((1, 1, 0)\). Write the matrix by the block formula and check that the pivot is fixed and that \((2, 1, 0)\) maps to \((3, 1, 0)\).
Solution Linear part \(2I\); translation \((I - 2I)\mathbf{p} = -\mathbf{p} = (-1, -1, 0)\). \(M(1,1,0) = (2,2,0) + (-1,-1,0) = (1,1,0)\), fixed. \(M(2,1,0) = (4,2,0) + (-1,-1,0) = (3,1,0)\): the point one unit right of the pivot is now two units right.
Exercise 6 (projective) With the \(3\times3\) of Section 9, where does the point \((1, 6)\) land, and what point does the vertical line \(x = 1\) approach as \(y\to\infty\)?
Solution \(w = 1 + 3 = 4\): \((0.25, 1.5)\). As \(y\to\infty\), \(x/(1 + 0.5y)\to0\) and \(y/(1 + 0.5y)\to 2\): the line approaches \((0, 2)\), the vanishing point shared by every vertical line.
Exercise 7 (storage) In the library’s column-major array, which index holds the entry in row 2, column 0 of the TRS matrix of Section 6, and what is its value? Which index would hold the translation’s \(y\) in a row-vector system’s row-major array?
Solution Index \(4\cdot0 + 2 = 2\), value \(-1.732\) (the \(-\sin 60^\circ\) of \(R_y\), times the scale 2). The row-vector system stores the transpose row-major, which is the same sixteen numbers in the same order, so \(t_y\) is again at index 13; only the reading changes.
Exercise 8 (passive) Using the \(2\times2\) frame of Section 5, find the object coordinates of the world point \((1.5, 1)\) and of \((2.799, 1.75)\), without computing the inverse matrix.
Solution \((1.5, 1)\) is the frame’s origin, column 3: object \((0, 0)\). \((2.799, 1.75) - (1.5, 1) = (1.299, 0.75) = \mathbf{c}_0\): object \((1, 0)\), the far end of the object’s stretched \(x\) axis. Recognizing a column saves the solve; for any other point the inverse of the worked example does it.
Exercise 9 (shear, with code) Build \(M = R_z(30^\circ)\,\mathrm{diag}(3, 1, 1)\,R_z(-30^\circ)\) with axisAngleMatrix, makeTRS and matMul, print the dot product of its first two columns, and explain the number.
Solution node -e "import('./lib/core/xform.js').then(x=>{const R=x.axisAngleMatrix(0,0,1,30),S=x.makeTRS(0,0,0,0,0,0,3,1,1);const M=x.matMul(x.matMul(R,S),x.invertTRS(R));console.log(x.dot([M[0],M[1],M[2]],[M[4],M[5],M[6]]).toFixed(4));})" prints 3.4641. The matrix is symmetric, \(\begin{pmatrix} 2.5 & 0.866 \\ 0.866 & 1.5 \end{pmatrix}\) in its \(xy\) block, and \(2.5\cdot0.866 + 0.866\cdot1.5 = 3.464\) is far from the 0 of orthogonal columns: a stretch along a tilted axis is not a TRS matrix, and the read-back would report a scale of \(2.65\) and \(1.73\) with a bogus rotation.
Exercise 10 (a camera’s parent) In the chain of Section 5.2, a camera on the ball is placed by \(C_{\text{ball}} = H\,T(t)\,A(t)\,K\), and a camera on the spinning ball by \(C_{\text{spin}} = H\,T(t)\,A(t)\,W(t)\,S(t)\,K\), with the same seat \(K\) (0.3 m up, 1 m back, a half turn about \(y\)). Write both view matrices and explain why one picture rolls at the spin rate and the other does not.
Solution \(V_{\text{ball}} = (F\,C_{\text{ball}})^{-1} = K^{-1}A^{-1}T^{-1}H^{-1}F^{-1}\) and \(V_{\text{spin}} = K^{-1}S^{-1}W^{-1}A^{-1}T^{-1}H^{-1}F^{-1}\): the inverse of a product is the product of the inverses in reverse order. The spin camera’s view matrix carries the extra \(S(t)^{-1} = R_z(-\varphi)\), a turn about the ball’s long axis, which is nearly the camera’s line of sight, so the world rolls by \(-\varphi\): ten turns a second at 600 rpm. The ball, a child of the same frame, holds still in that picture. A camera inherits every link between it and the root; mount it above the links whose motion the picture should not show.

Appendix

A.1 Conditioning: how much precision the inverse costs

An inverse exists whenever \(\det A \ne 0\), but existing and being computable in single precision are different things. The condition number \(\kappa(A)\), the ratio of the largest to the smallest stretch \(A\) applies to any direction, bounds how much a relative error in the input grows in the output: a round trip \(\mathbf{x}\to M\mathbf{x}\to M^{-1}(M\mathbf{x})\) in arithmetic with relative precision \(\varepsilon\) returns \(\mathbf{x}\) only to about \(\kappa\,\varepsilon\). For a rotation \(\kappa = 1\); for \(\mathrm{diag}(s, 1, 1)\), \(\kappa = s\).

A log-log plot of the round-trip error of x through M and back through its inverse, in float32, against the scale ratio s from 1 to ten million: the measured points climb along a line just below the dashed prediction kappa times two to the minus 23, reaching 0.34 at s equals ten million.
\(M = T\,R\,S\) with \(S = \mathrm{diag}(s, 1, 1)\), every multiply and add rounded to float32. The error follows \(\kappa\cdot2^{-23}\) within a small factor; at \(s = 10^7\) the point \((1,1,1)\) comes back with a third of a unit of error.
Worked example (the round trip, measured)
scale ratio \(s = \kappa\)1\(10^2\)\(10^4\)\(10^6\)\(10^7\)
round-trip error of \((1,1,1)\)\(2.4\times10^{-7}\)\(2.3\times10^{-6}\)\(2.7\times10^{-4}\)\(2.4\times10^{-2}\)\(0.34\)
\(\kappa\cdot2^{-23}\)\(1.2\times10^{-7}\)\(1.2\times10^{-5}\)\(1.2\times10^{-3}\)\(0.12\)\(1.2\)
At \(\kappa = 10^4\) the object’s own coordinates are known to three decimals after a pick; at \(10^6\) to one. Scales that extreme occur when a model authored in millimeters is placed in a world of kilometers, or when a flattened object (a decal scaled to \(10^{-4}\) in one axis) is inverted for picking. The remedy is the one every engine takes: keep unit scales in the hierarchy and bake extreme scales into the mesh, and never invert a scale of 0.

A.2 The polar decomposition

Polar decomposition Every invertible \(A\) is uniquely \(A = R\,P\) with \(R\) orthogonal and \(P\) symmetric positive definite: \(P = \sqrt{A^{\mathsf T}A}\), the square root taken by diagonalizing the symmetric matrix \(A^{\mathsf T}A\), and \(R = A\,P^{-1}\). \(P\) stretches by the square roots of the eigenvalues of \(A^{\mathsf T}A\) along its eigenvectors; \(R\) then turns the result. TRS is the case where \(P\)’s eigenvectors are the coordinate axes.
Three panels: the unit square; the square after the symmetric stretch P, a tilted rhombus elongated along a dashed axis at 52 degrees and compressed across it; that rhombus rotated by minus 14 degrees, which is the sheared parallelogram of the shear A.
The shear \(A = \begin{pmatrix}1 & 0.5\\0 & 1\end{pmatrix}\) as a stretch followed by a rotation. There is no axis-aligned stretch that produces the middle picture, which is why TRS cannot store the right one.
Worked example (decomposing the shear) \(A^{\mathsf T}A = \begin{pmatrix} 1 & 0.5 \\ 0.5 & 1.25 \end{pmatrix}\), with eigenvalues \(1.640\) and \(0.610\) along the directions \((0.615, 0.788)\) (at \(52.0^\circ\)) and \((0.788, -0.615)\). So \(P\) stretches by \(\sqrt{1.640} = 1.281\) along the first direction and by \(\sqrt{0.610} = 0.781\) along the second, and as a matrix \(P = \begin{pmatrix} 0.970 & 0.243 \\ 0.243 & 1.091 \end{pmatrix}\). Then \(R = A\,P^{-1} = \begin{pmatrix} 0.970 & 0.243 \\ -0.243 & 0.970 \end{pmatrix}\), a rotation by \(-14.04^\circ\), and \(R\,P\) multiplies back to \(A\) in every entry. A shear of \(0.5\) is a \(28\,\%\) stretch on one diagonal, a \(22\,\%\) squeeze on the other, and a small turn.

A.3 Planes

A plane is a normal and an offset, and the two travel together. Write the plane \(\hat{\mathbf{n}}\cdot P = D\) of the vectors chapter as the homogeneous row \(\boldsymbol\pi = (n_x, n_y, n_z, -D)\), so that a point is on the plane exactly when \(\boldsymbol\pi\cdot(x, y, z, 1) = 0\). If points move by \(M\), the plane through their images must satisfy \(\boldsymbol\pi'\cdot(M\tilde{\mathbf{p}}) = 0\) whenever \(\boldsymbol\pi\cdot\tilde{\mathbf{p}} = 0\), which holds for \(\boldsymbol\pi' = M^{-\mathsf T}\boldsymbol\pi\): the full \(4\times4\) inverse transpose, which moves the offset as well as tilting the normal. Renormalize the first three components afterward and \(-\boldsymbol\pi'_3\) is the new \(D\).

Worked example (the vectors chapter’s plane under a TRS) \(\boldsymbol\pi = (0.333, 0.667, 0.667, -0.333)\) and \(M = \)makeTRS(1,2,3, 0,60,0, 2,1,0.5). \(M^{-\mathsf T}\boldsymbol\pi = (1.238, 0.667, 0.522, -4.472)\), normalized on its first three components to \((0.825, 0.444, 0.348, -2.981)\): the new normal is \((0.825, 0.444, 0.348)\) and the new offset \(D' = 2.981\). Check on two points known to be on the old plane, \(Q = (1, 0, 0)\) and the foot \((3.444, -0.111, -1.111)\): their images \(M Q = (2, 2, 1.268)\) and \((3.963, 1.889, -3.243)\) both satisfy \(\hat{\mathbf{n}}'\cdot P - D' = 0\) to six decimals. The naive normal \(A\hat{\mathbf{n}}\), normalized to \((0.622, 0.667, -0.411)\), fails: the two image points differ by \(3.0\) in their “signed distance” from a plane that should contain both. Clipping planes, mirrors and shadow-caster planes all move this way.

A.4 Exercises

Exercise A1 (planes) The ground plane \(y = 0\) is \(\boldsymbol\pi = (0, 1, 0, 0)\). Transform it by \(M = T(0, 2, 0)\cdot\mathrm{diag}(1, 3, 1)\) and give the new plane. Check with the image of the origin.
Solution \(M^{-1}\) has linear part \(\mathrm{diag}(1, \tfrac13, 1)\) and translation \(-\mathrm{diag}(1, \tfrac13, 1)(0,2,0) = (0, -\tfrac23, 0)\); its transpose puts that translation in the bottom row, so \(M^{-\mathsf T}\boldsymbol\pi = (0, \tfrac13, 0, -\tfrac23)\), normalized \((0, 1, 0, -2)\): the plane \(y = 2\). The origin maps to \((0, 2, 0)\), which lies on it. The scale did nothing to a plane through the origin except be undone; the translation moved it.