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.
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\).
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.
One matrix for every placement. Rotation, scale and shear are matrix
products, but translation is an addition. With \(w\) all four become one \(3\times3\) product in 2D or
one \(4\times4\) product in 3D (Section 2), and a chain of placements multiplies down to one matrix
(Section 3).
Perspective. A matrix can write a point’s depth into \(w\); dividing
\(x, y, z\) by \(w\) afterward makes distant things smaller. That is the perspective projection of
the viewing chapter, and Section 9 is where it leaves the affine family.
Points at infinity. \((x, y, z, 0)\) never reaches \(w = 1\): it is a direction, which no
translation moves (Sections 1.2 and 2). A light placed there is a directional light, such as the sun.
One data type from file to shader. Shading languages make the 4-vector
(vec4 in GLSL, the OpenGL Shading Language; float4 in HLSL, the High-Level
Shading Language) and the \(4\times4\) product built-in types, so vertices, matrices and shader math share
one representation. The first programmable vertex processors (DirectX 8 vertex shaders, 2000, in
hardware on the GeForce 3 in 2001) computed on four-component registers. NVIDIA’s G80 (2006) and
AMD’s GCN (2012) moved to scalar instructions per thread, so on a current GPU (graphics processing
unit) a vec4 operation is four scalar ones; the four-wide representation stayed.
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).
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.
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).
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
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}).
\]
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 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 \(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 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.
system
vectors
storage
“rotate then translate” is written
translation lives at
OpenGL, three.js, this library, Unity’s Matrix4x4
column
column-major
\(T\cdot R\)
indices 12, 13, 14 (the last column)
DirectX, HLSL by default, Unreal
row
row-major
\(R\cdot T\)
indices 12, 13, 14 (the last row)
textbooks, this text
column
as 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.
PitfallMixing 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}\):
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.
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.
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 liveOrder 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.
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.
\(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:
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.
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:
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 liveA 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\).
PitfallA 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\).
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.
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)\).
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.
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)\).
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.
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.
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=>{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.
The silent failure on an oblique scale (Section 6.1): the call succeeds and the first column of its answer is wrong:
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.
Solutionnode -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\).
\(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.
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.