Matrices as Transformations
What You’ll Learn
Section titled “What You’ll Learn”What a matrix actually does, and the one idea that makes every matrix readable at a glance: the columns are where the axes land. How scaling, rotation and shearing all come from that. What the determinant measures, and what a negative one means. And why the row-versus- column convention from Part 1 reverses the order you write your multiplications in.
What a Matrix Is
Section titled “What a Matrix Is”A matrix is a grid of numbers. That description is true and completely useless, so here is a better one.
A matrix is a machine that moves every point in space at once. You feed it a position and it hands back a different position. Feed it every point of a square and you get a new shape. Feed it every vertex of a character model and the character has been scaled, rotated, or tipped over.
The grid of numbers is just the machine’s settings. A 2×2 matrix has four of them:
and it transforms a point like this:
Written out that way it looks like something to memorize. It is not, and the next section is the reason why.
The Columns Are Where the Axes Land
Section titled “The Columns Are Where the Axes Land”Here is the whole idea. Put the point - one step along the x axis, nothing else - into the formula above:
That is the first column, exactly. Now try and you get - the second column.
So the matrix is not an arbitrary grid. It is a record of where the axes end up:
- The first column is where the x axis lands.
- The second column is where the y axis lands.
And every other point follows along. A point at was always “3 steps along x plus 2 steps along y”, so after the transformation it is simply 3 steps along the new x plus 2 steps along the new y. Nothing else needs to be worked out.
Drag the sliders below. Each one moves the tip of one arrow, and the whole square follows.
src/lib/gamedev/demos/matrix2d.scene.ts /**
* A 2x2 matrix as a machine that moves the plane, with its two columns as the controls.
*/
import * as THREE from "three";
import { applyMat2, determinant2, type Mat2 } from "../matrices.ts";
import {
makeCanvas,
addSlider,
addCheckbox,
addReadout,
addButtonRow,
} from "./ui.ts";
import type { MountFn } from "./runner.ts";
const U = 46; // screen pixels per 1 unit of the grid
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 320);
const scene = new THREE.Scene();
scene.background = background;
const camera = new THREE.OrthographicCamera(
-width / 2,
width / 2,
height / 2,
-height / 2,
0.1,
100,
);
camera.position.z = 10;
const line = (pts: THREE.Vector3[], colour: number, opacity = 1) =>
new THREE.LineSegments(
new THREE.BufferGeometry().setFromPoints(pts),
new THREE.LineBasicMaterial({
color: colour,
transparent: opacity < 1,
opacity,
}),
);
const gridPts: THREE.Vector3[] = [];
for (let n = -8; n <= 8; n++) {
gridPts.push(
new THREE.Vector3(n * U, -height / 2, 0),
new THREE.Vector3(n * U, height / 2, 0),
new THREE.Vector3(-width / 2, n * U, 0),
new THREE.Vector3(width / 2, n * U, 0),
);
}
scene.add(line(gridPts, 0x21262d));
scene.add(
line(
[
new THREE.Vector3(-width / 2, 0, 0),
new THREE.Vector3(width / 2, 0, 0),
new THREE.Vector3(0, -height / 2, 0),
new THREE.Vector3(0, height / 2, 0),
],
0x484f58,
),
);
// Where the unit square started, so you have something to compare against.
const before = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 1),
new THREE.Vector3(U, 0, 1),
new THREE.Vector3(U, U, 1),
new THREE.Vector3(0, U, 1),
new THREE.Vector3(0, 0, 1),
]),
new THREE.LineDashedMaterial({
color: 0x7d8590,
dashSize: 6,
gapSize: 5,
}),
);
before.computeLineDistances();
scene.add(before);
// The square after the matrix has moved it.
const fillGeom = new THREE.BufferGeometry();
fillGeom.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array(18), 3),
);
const fillMat = new THREE.MeshBasicMaterial({
color: 0x39d3c3,
transparent: true,
opacity: 0.3,
side: THREE.DoubleSide,
});
scene.add(new THREE.Mesh(fillGeom, fillMat));
const edgeGeom = new THREE.BufferGeometry();
edgeGeom.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array(15), 3),
);
const edgeMat = new THREE.LineBasicMaterial({ color: 0x39d3c3 });
scene.add(new THREE.Line(edgeGeom, edgeMat));
// The two columns, as arrows. These are the whole point of the scene.
const arrow = (colour: number) => {
const a = new THREE.ArrowHelper(
new THREE.Vector3(1, 0, 0),
new THREE.Vector3(0, 0, 2),
U,
colour,
12,
8,
);
scene.add(a);
return a;
};
const iArrow = arrow(0xff7b72);
const jArrow = arrow(0x7ee787);
const show = addReadout(el);
const ix = addSlider(el, "x axis \u2192 x", -3, 3, 1, draw, "", 0.1);
const iy = addSlider(el, "x axis \u2192 y", -3, 3, 0, draw, "", 0.1);
const jx = addSlider(el, "y axis \u2192 x", -3, 3, 0, draw, "", 0.1);
const jy = addSlider(el, "y axis \u2192 y", -3, 3, 1, draw, "", 0.1);
const showBefore = addCheckbox(el, "show the original square", true, draw);
const set = (a: number, b: number, c: number, d: number) => {
ix.set(a);
iy.set(b);
jx.set(c);
jy.set(d);
draw();
};
addButtonRow(el, [
{ label: "Identity", apply: () => set(1, 0, 0, 1) },
{ label: "Scale 2\u00D7", apply: () => set(2, 0, 0, 2) },
{ label: "Rotate", apply: () => set(0.7, 0.7, -0.7, 0.7) },
{ label: "Shear", apply: () => set(1, 0, 1, 1) },
{ label: "Mirror", apply: () => set(-1, 0, 0, 1) },
{ label: "Collapse", apply: () => set(1, 0.5, 2, 1) },
]);
function draw() {
const m: Mat2 = { i: { x: ix(), y: iy() }, j: { x: jx(), y: jy() } };
// The unit square's corners, each pushed through the matrix.
const pts = [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 0, y: 1 },
]
.map((c) => applyMat2(m, c))
.map((c) => new THREE.Vector3(c.x * U, c.y * U, 1.5));
fillGeom.setFromPoints([pts[0], pts[1], pts[2], pts[0], pts[2], pts[3]]);
edgeGeom.setFromPoints([pts[0], pts[1], pts[2], pts[3], pts[0]]);
const det = determinant2(m);
const collapsed = Math.abs(det) < 1e-6;
const flipped = det < 0;
// Purple when the plane has been turned over, which the determinant's sign reports.
const colour = flipped ? 0xd2a8ff : 0x39d3c3;
fillMat.color.setHex(colour);
edgeMat.color.setHex(colour);
fillMat.opacity = collapsed ? 0 : 0.3;
before.visible = showBefore();
const place = (a: THREE.ArrowHelper, v: { x: number; y: number }) => {
const len = Math.hypot(v.x, v.y);
a.visible = len > 1e-6;
if (a.visible) {
a.setDirection(new THREE.Vector3(v.x, v.y, 0).normalize());
a.setLength(len * U, 12, 8);
}
};
place(iArrow, m.i);
place(jArrow, m.j);
const f = (n: number) => n.toFixed(1);
show(
`x axis \u2192 (${f(m.i.x)}, ${f(m.i.y)}) ` +
`y axis \u2192 (${f(m.j.x)}, ${f(m.j.y)}) ` +
`determinant ${det.toFixed(2)} ` +
(collapsed
? "\u2190 flattened to a line"
: flipped
? `\u2190 mirrored, area \u00D7 ${Math.abs(det).toFixed(2)}`
: `\u2190 area \u00D7 ${det.toFixed(2)}`),
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; The red arrow is where the x axis landed. The green arrow is where the y axis landed. The dashed outline is where the square started, so you can see what changed.
Try the preset buttons and watch what the columns are doing in each case:
- Identity leaves both arrows where they started. The matrix does nothing, which is exactly what a matrix of and should do.
- Scale stretches both arrows to twice their length. The square gets bigger, and stays a square.
- Rotate swings both arrows around together, keeping them the same length and still at right angles. That is all a rotation is.
- Shear leaves the red arrow alone and tips the green one sideways. The square becomes a parallelogram - this is how italic text works.
- Mirror points the red arrow backwards. The shape turns purple, for a reason the next section explains.
- Collapse puts both arrows on the same line. The square has nowhere to be and flattens.
source The code the scene above runs on
/**
* Matrices as transformations, in the form that makes them readable.
*
* A 2x2 matrix is usually written as four loose numbers, which hides what it does. Here it
* is stored as its two **columns** instead, because that is what the columns are: the places
* the x and y axes land after the transformation. Read a matrix that way and you can predict
* what it does to a shape without multiplying anything.
*/
export type Vec2 = { x: number; y: number };
export type Mat2 = {
/** Where the x axis lands. The matrix's first column. */
i: Vec2;
/** Where the y axis lands. The matrix's second column. */
j: Vec2;
};
/** The do-nothing matrix. The axes stay exactly where they started. */
export const IDENTITY2: Mat2 = { i: { x: 1, y: 0 }, j: { x: 0, y: 1 } };
/**
* Transform a vector by a matrix.
*
* Read the two lines as a sentence: the result is `x` copies of wherever the x axis landed,
* plus `y` copies of wherever the y axis landed. That is all matrix multiplication is.
*/
export function applyMat2(m: Mat2, v: Vec2): Vec2 {
return {
x: v.x * m.i.x + v.y * m.j.x,
y: v.x * m.i.y + v.y * m.j.y,
};
}
/**
* How much the matrix scales area, and whether it flips the plane.
*
* This is the 2D cross product of the two columns, which is the same "signed area of the
* parallelogram they span" from Part 1. A determinant of 1 preserves area, 2 doubles it, 0
* collapses the plane onto a line, and a **negative** value means the shape was mirrored.
*/
export function determinant2(m: Mat2): number {
return m.i.x * m.j.y - m.i.y * m.j.x;
}
/** Turn the plane counter-clockwise by an angle, in degrees. */
export function rotation2(degrees: number): Mat2 {
const a = (degrees * Math.PI) / 180;
const c = Math.cos(a);
const s = Math.sin(a);
return { i: { x: c, y: s }, j: { x: -s, y: c } };
}
/** Stretch each axis independently. */
export function scale2(sx: number, sy: number): Mat2 {
return { i: { x: sx, y: 0 }, j: { x: 0, y: sy } };
}
/** Slide the plane sideways in proportion to height, like italic text. */
export function shear2(kx: number, ky = 0): Mat2 {
return { i: { x: 1, y: ky }, j: { x: kx, y: 1 } };
}
/**
* Apply `second` after `first`.
*
* Note the order: transforming by the result is the same as transforming by `first` and then
* by `second`. Matrix multiplication reads right to left, which is the opposite of how you
* would say it out loud, and it is the source of most transform-order bugs.
*/
export function multiplyMat2(second: Mat2, first: Mat2): Mat2 {
return {
i: applyMat2(second, first.i),
j: applyMat2(second, first.j),
};
}
// ---- Three dimensions ------------------------------------------------------------------
export type Vec3 = { x: number; y: number; z: number };
/** Same idea with one more axis: three columns, three places the axes land. */
export type Mat3 = { i: Vec3; j: Vec3; k: Vec3 };
export const IDENTITY3: Mat3 = {
i: { x: 1, y: 0, z: 0 },
j: { x: 0, y: 1, z: 0 },
k: { x: 0, y: 0, z: 1 },
};
export function applyMat3(m: Mat3, v: Vec3): Vec3 {
return {
x: v.x * m.i.x + v.y * m.j.x + v.z * m.k.x,
y: v.x * m.i.y + v.y * m.j.y + v.z * m.k.y,
z: v.x * m.i.z + v.y * m.j.z + v.z * m.k.z,
};
}
/**
* How much the matrix scales **volume**, and whether it turns the space inside out.
*
* In 3D the determinant is the scalar triple product of the three columns - cross two of
* them and dot the result with the third, which is Part 1's machinery again.
*/
export function determinant3(m: Mat3): number {
const { i, j, k } = m;
return (
i.x * (j.y * k.z - j.z * k.y) -
j.x * (i.y * k.z - i.z * k.y) +
k.x * (i.y * j.z - i.z * j.y)
);
}
// ---- Four components, so that translation fits -------------------------------------------
/**
* A 3D value with a fourth number attached.
*
* `w` says what kind of thing this is: **1 for a place, 0 for a direction**. That single
* number is what lets one matrix move positions while leaving directions alone.
*/
export type Vec4 = { x: number; y: number; z: number; w: number };
/** A location in space. Translating it moves it. */
export const point = (x: number, y: number, z: number): Vec4 => ({
x,
y,
z,
w: 1,
});
/** A direction with a length. Translating it does nothing, which is correct. */
export const direction = (x: number, y: number, z: number): Vec4 => ({
x,
y,
z,
w: 0,
});
/**
* A 4x4 matrix, stored as its four columns.
*
* The first three are the same "where the axes land" columns as a 3x3. The fourth, `t`, is
* new: it is **where the origin lands**, which is to say the translation.
*/
export type Mat4 = { i: Vec4; j: Vec4; k: Vec4; t: Vec4 };
export const IDENTITY4: Mat4 = {
i: direction(1, 0, 0),
j: direction(0, 1, 0),
k: direction(0, 0, 1),
t: point(0, 0, 0),
};
/**
* Transform a value by a 4x4 matrix.
*
* Read the last term. The translation column is multiplied by `w`, so a place (`w = 1`) picks
* up the full translation and a direction (`w = 0`) picks up none of it. Nothing else in the
* function treats them differently - the fourth number does all of the work.
*/
export function applyMat4(m: Mat4, v: Vec4): Vec4 {
return {
x: v.x * m.i.x + v.y * m.j.x + v.z * m.k.x + v.w * m.t.x,
y: v.x * m.i.y + v.y * m.j.y + v.z * m.k.y + v.w * m.t.y,
z: v.x * m.i.z + v.y * m.j.z + v.z * m.k.z + v.w * m.t.z,
w: v.x * m.i.w + v.y * m.j.w + v.z * m.k.w + v.w * m.t.w,
};
}
/** Slide everything by a fixed offset. Impossible without the fourth column. */
export function translation4(tx: number, ty: number, tz: number): Mat4 {
return { ...IDENTITY4, t: point(tx, ty, tz) };
}
export function scale4(sx: number, sy: number, sz: number): Mat4 {
return {
i: direction(sx, 0, 0),
j: direction(0, sy, 0),
k: direction(0, 0, sz),
t: point(0, 0, 0),
};
}
/** Turn about the y axis, the usual "which way is this facing" rotation. */
export function rotationY4(degrees: number): Mat4 {
const a = (degrees * Math.PI) / 180;
const c = Math.cos(a);
const s = Math.sin(a);
return {
i: direction(c, 0, -s),
j: direction(0, 1, 0),
k: direction(s, 0, c),
t: point(0, 0, 0),
};
}
/** Turn about the x axis. Nose up and nose down, for something facing -Z. */
export function rotationX4(degrees: number): Mat4 {
const a = (degrees * Math.PI) / 180;
const c = Math.cos(a);
const s = Math.sin(a);
return {
i: direction(1, 0, 0),
j: direction(0, c, s),
k: direction(0, -s, c),
t: point(0, 0, 0),
};
}
/** Turn about the z axis. Tilting sideways without changing where you face. */
export function rotationZ4(degrees: number): Mat4 {
const a = (degrees * Math.PI) / 180;
const c = Math.cos(a);
const s = Math.sin(a);
return {
i: direction(c, s, 0),
j: direction(-s, c, 0),
k: direction(0, 0, 1),
t: point(0, 0, 0),
};
}
/** Apply `second` after `first`. Same right-to-left reading as the 2x2 version. */
export function multiplyMat4(second: Mat4, first: Mat4): Mat4 {
return {
i: applyMat4(second, first.i),
j: applyMat4(second, first.j),
k: applyMat4(second, first.k),
t: applyMat4(second, first.t),
};
}
/**
* The sixteen numbers laid out as rows, the way a matrix is written on paper.
*
* Only needed for display. Note that the translation appears in the right-hand **column**,
* not the bottom row - a mix-up worth seeing written down once, because a transposed matrix
* translates along the wrong axes rather than failing outright.
*/
export function rowsOf(m: Mat4): number[][] {
return [
[m.i.x, m.j.x, m.k.x, m.t.x],
[m.i.y, m.j.y, m.k.y, m.t.y],
[m.i.z, m.j.z, m.k.z, m.t.z],
[m.i.w, m.j.w, m.k.w, m.t.w],
];
}
// ---- Composing: the order is the whole problem -------------------------------------------
/** The three ingredients of an object's transform, before any decision about order. */
export type TRS = {
/** Per-axis scale. Equal values behave very differently from unequal ones. */
scale: Vec3;
/** Yaw in degrees. One rotation axis is enough to show what ordering does. */
degrees: number;
translate: Vec3;
};
/** One of the three operations. */
export type Step = "scale" | "rotate" | "translate";
/** An order to apply them in, read left to right as "do this, then this, then this". */
export type Sequence = readonly [Step, Step, Step];
/** All six orders, so a demo can walk them and a check can compare them. */
export const SEQUENCES: readonly Sequence[] = [
["scale", "rotate", "translate"],
["scale", "translate", "rotate"],
["rotate", "scale", "translate"],
["rotate", "translate", "scale"],
["translate", "scale", "rotate"],
["translate", "rotate", "scale"],
];
/** The matrix for one step on its own. */
export function matrixFor(v: TRS, step: Step): Mat4 {
if (step === "scale") return scale4(v.scale.x, v.scale.y, v.scale.z);
if (step === "rotate") return rotationY4(v.degrees);
return translation4(v.translate.x, v.translate.y, v.translate.z);
}
/**
* Build one matrix that applies the three steps in the order given.
*
* Each new step multiplies on the **left**, because that is what "after" means for column
* vectors: whichever matrix sits nearest the vector acts first. So the sequence
* `["scale", "rotate", "translate"]` accumulates into `T * R * S` - written in the reverse
* of the order it happens in, which is the single most confusing thing about transforms.
*/
export function composeSequence(v: TRS, seq: Sequence): Mat4 {
let m = IDENTITY4;
for (const step of seq) m = multiplyMat4(matrixFor(v, step), m);
return m;
}
// ---- The other convention ----------------------------------------------------------------
/** Swap rows and columns. The bridge between the two conventions. */
export function transpose4(m: Mat4): Mat4 {
return {
i: { x: m.i.x, y: m.j.x, z: m.k.x, w: m.t.x },
j: { x: m.i.y, y: m.j.y, z: m.k.y, w: m.t.y },
k: { x: m.i.z, y: m.j.z, z: m.k.z, w: m.t.z },
t: { x: m.i.w, y: m.j.w, z: m.k.w, w: m.t.w },
};
}
/**
* The row-vector convention: the vector sits on the **left** of the matrix.
*
* Same arithmetic, transposed layout, and - the part that bites - reversed composition
* order. `demos/checks.ts` asserts that column-order `T * R * S` and row-order `S * R * T`
* describe the very same transform.
*/
export function applyRow4(v: Vec4, m: Mat4): Vec4 {
const rows = rowsOf(m);
const c = [v.x, v.y, v.z, v.w];
const out = [0, 0, 0, 0];
for (let col = 0; col < 4; col += 1) {
for (let row = 0; row < 4; row += 1) out[col] += c[row] * rows[row][col];
}
return { x: out[0], y: out[1], z: out[2], w: out[3] };
} Notice how the type is written. Rather than four loose numbers, a Mat2 holds i and j -
the two columns - so the code says what the maths means. And applyMat2 reads as one
sentence: the result is x copies of where i landed, plus y copies of where j landed.
The Determinant
Section titled “The Determinant”The scene has a number in the readout labelled determinant. It answers one question: how much bigger did the area get?
For a 2×2 matrix it is one subtraction:
which, in terms of the columns, is the 2D cross product of and from Part 1. That is not a coincidence. The cross product gave you the area of the parallelogram two vectors span, and the transformed square is that parallelogram.
Read the value like this:
| Determinant | What happened |
|---|---|
| Area unchanged. Every rotation has this. | |
| Area doubled. | |
| Area halved. | |
| The shape collapsed onto a line and lost all of its area. | |
| Area unchanged, but the shape was mirrored. |
Sweep the sliders and watch the number. A rotation holds it at exactly 1.00 at every
angle. A shear also holds it at 1.00, which is worth noticing - sliding a shape sideways
changes how it looks but not how much of it there is.
A negative determinant means the space was turned over, which is why the scene switches to purple. It is not a smaller or a larger transformation, it is a mirrored one, and no amount of rotating will reproduce it. This matters in practice: a model scaled by on one axis has all of its surfaces facing inward, and it will render inside-out for exactly the reason Part 1’s winding demo showed.
A determinant of zero is the one to fear. The shape has been flattened, and flattening throws information away permanently. There is no way to un-flatten it, which means the matrix has no inverse - you cannot undo the transformation. Section 2.4 comes back to this.
The Same Machine in 3D
Section titled “The Same Machine in 3D”Nothing new happens with a third axis, there is just one more column. A 3×3 matrix records where x, y and z land, and the determinant measures volume instead of area.
src/lib/gamedev/demos/matrix3d.scene.ts /**
* The same idea with one more axis: a unit cube, three columns, and volume as the determinant.
*/
import * as THREE from "three";
import { applyMat3, determinant3, type Mat3 } from "../matrices.ts";
import { makeCanvas, addSlider, addReadout, addButtonRow } from "./ui.ts";
import type { MountFn } from "./runner.ts";
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 320);
const scene = new THREE.Scene();
scene.background = background;
const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 100);
camera.position.set(4.6, 3.6, 6);
camera.lookAt(0.5, 0.5, 0.5);
scene.add(new THREE.GridHelper(8, 8, 0x30363d, 0x21262d));
// The eight corners of the unit cube, and the twelve edges joining them.
const CORNERS = [
{ x: 0, y: 0, z: 0 },
{ x: 1, y: 0, z: 0 },
{ x: 1, y: 0, z: 1 },
{ x: 0, y: 0, z: 1 },
{ x: 0, y: 1, z: 0 },
{ x: 1, y: 1, z: 0 },
{ x: 1, y: 1, z: 1 },
{ x: 0, y: 1, z: 1 },
];
const EDGES = [
[0, 1],
[1, 2],
[2, 3],
[3, 0],
[4, 5],
[5, 6],
[6, 7],
[7, 4],
[0, 4],
[1, 5],
[2, 6],
[3, 7],
];
const edgeGeom = new THREE.BufferGeometry();
edgeGeom.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array(EDGES.length * 6), 3),
);
const edgeMat = new THREE.LineBasicMaterial({ color: 0x39d3c3 });
scene.add(new THREE.LineSegments(edgeGeom, edgeMat));
const arrow = (colour: number) => {
const a = new THREE.ArrowHelper(
new THREE.Vector3(1, 0, 0),
new THREE.Vector3(),
1,
colour,
0.16,
0.09,
);
scene.add(a);
return a;
};
const iArrow = arrow(0xff7b72);
const jArrow = arrow(0x7ee787);
const kArrow = arrow(0x58a6ff);
const show = addReadout(el);
// Three scales plus one shear entry: enough to reach every interesting case without
// putting nine sliders on the page.
const sx = addSlider(el, "x axis length", -2, 2, 1, draw, "", 0.1);
const sy = addSlider(el, "y axis length", -2, 2, 1, draw, "", 0.1);
const sz = addSlider(el, "z axis length", -2, 2, 1, draw, "", 0.1);
const sh = addSlider(el, "lean the y axis", -2, 2, 0, draw, "", 0.1);
const set = (a: number, b: number, c: number, d: number) => {
sx.set(a);
sy.set(b);
sz.set(c);
sh.set(d);
draw();
};
addButtonRow(el, [
{ label: "Identity", apply: () => set(1, 1, 1, 0) },
{ label: "Double", apply: () => set(2, 2, 2, 0) },
{ label: "Squash", apply: () => set(1, 0.3, 1, 0) },
{ label: "Lean", apply: () => set(1, 1, 1, 1) },
{ label: "Mirror", apply: () => set(-1, 1, 1, 0) },
{ label: "Flatten", apply: () => set(1, 0, 1, 0) },
]);
function draw() {
const m: Mat3 = {
i: { x: sx(), y: 0, z: 0 },
j: { x: sh(), y: sy(), z: 0 },
k: { x: 0, y: 0, z: sz() },
};
const moved = CORNERS.map((c) => applyMat3(m, c));
const pts: THREE.Vector3[] = [];
for (const [a, b] of EDGES) {
pts.push(
new THREE.Vector3(moved[a].x, moved[a].y, moved[a].z),
new THREE.Vector3(moved[b].x, moved[b].y, moved[b].z),
);
}
edgeGeom.setFromPoints(pts);
const det = determinant3(m);
const flat = Math.abs(det) < 1e-6;
const flipped = det < 0;
edgeMat.color.setHex(flipped ? 0xd2a8ff : 0x39d3c3);
const place = (
a: THREE.ArrowHelper,
v: { x: number; y: number; z: number },
) => {
const len = Math.hypot(v.x, v.y, v.z);
a.visible = len > 1e-6;
if (a.visible) {
a.setDirection(new THREE.Vector3(v.x, v.y, v.z).normalize());
a.setLength(len, 0.16, 0.09);
}
};
place(iArrow, m.i);
place(jArrow, m.j);
place(kArrow, m.k);
show(
`determinant ${det.toFixed(2)} ` +
(flat
? "\u2190 the cube flattened, all volume gone"
: flipped
? `\u2190 turned inside out, volume \u00D7 ${Math.abs(det).toFixed(2)}`
: `\u2190 volume \u00D7 ${det.toFixed(2)}`),
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; Same story, one dimension up. Double multiplies every axis by 2, and the volume goes up by . Squash flattens one axis and the volume shrinks with it. Mirror turns the determinant negative and the cube inside out. Flatten takes one axis to zero and the whole cube becomes a flat sheet with no volume at all.
The determinant formula is longer in 3D, but it is built from Part 1’s tools: cross two of the columns and dot the result with the third. That is the scalar triple product, and it is the signed volume of the box the three columns span.
Row Vectors and Column Vectors
Section titled “Row Vectors and Column Vectors”Part 1’s conventions table had a column that has not mattered until now. Here is where it starts to.
Everything above assumed the vector is a column, standing upright, with the matrix on its left:
Three.js, Godot, Unity and OpenGL all work this way. Unreal does not - it treats vectors as rows, lying flat, with the matrix on the right:
Both describe the same transformations. The consequence is about order. With column vectors, applying and then is written - right to left, which reads backwards from how you would say it aloud. With row vectors it is written , left to right.
So a chain of transformations copied from an Unreal tutorial into Three.js needs its order reversed, and the symptom is not an error. It is a model in the wrong place, or spinning around the wrong point. Section 2.3 is entirely about this.
Where This Shows Up
Section titled “Where This Shows Up”- Every object in a scene, which carries a matrix describing where it sits and how it is turned relative to its parent.
- Squashed and stretched models, where a non-uniform scale is a matrix whose columns have different lengths - and the reason surface normals need special treatment in Section 2.4.
- Mirrored assets. Flipping a prop to make a left-handed version negates a determinant, and the renderer has to be told to reverse its winding or the prop turns inside out.
- Debugging a broken transform. When something is in the wrong place, print the matrix and read its columns. Three numbers you recognize tell you far more than a determinant does.