Skip to content

Homogeneous Coordinates and 4x4 Matrices

Why a 3×3 matrix can rotate and scale but can never move anything, and what adding a fourth number fixes. What the fourth column of a 4×4 matrix holds. And the payoff: w = 1 for a place, w = 0 for a direction, which turns Part 1’s careful naming discipline into something the arithmetic does for you.

Section 2.1 ended on a limitation. Here it is again, because everything in this section exists to solve it.

A matrix transforms a point by scaling its columns and adding them up. Put the origin in:

M[000]=0ı^+0ȷ^+0k^=[000]M \begin{bmatrix} 0 \\ 0 \\ 0 \end{bmatrix} = 0 \cdot \hat{\imath} + 0 \cdot \hat{\jmath} + 0 \cdot \hat{k} = \begin{bmatrix} 0 \\ 0 \\ 0 \end{bmatrix}

Every term gets multiplied by zero, so the answer is zero. The origin always stays put, no matter which nine numbers you choose. And if the origin cannot move, nothing can be slid sideways as a whole - you can spin a model, squash it, or mirror it, but always around the origin, never away from it.

That is useless for a game. Objects need positions.

The trick is almost silly. Give every point a fourth component and set it to 1:

[xyz][xyz1]\begin{bmatrix} x \\ y \\ z \end{bmatrix} \quad\longrightarrow\quad \begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix}

Now the matrix needs a fourth column to multiply that 1 against. And since the 1 is always there and always exactly 1, whatever sits in that fourth column gets added on unconditionally:

[      tx  R  ty      tz0001][xyz1]=R[xyz]+[txtytz]\begin{bmatrix} \; & \; & \; & t_x \\ \; & R & \; & t_y \\ \; & \; & \; & t_z \\ 0 & 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix} = R\begin{bmatrix} x \\ y \\ z \end{bmatrix} + \begin{bmatrix} t_x \\ t_y \\ t_z \end{bmatrix}

That t\vec{t} is the translation, and it lives in the fourth column. The 3×3 block labelled RR is the rotation-and-scale part from Section 2.1, unchanged. The bottom row is (0,0,0,1)(0, 0, 0, 1) for every transform in this module.

Coordinates carrying that extra component are called homogeneous coordinates. The name is unhelpful and the idea is small: one padding number, so translation has somewhere to live.

The fourth column keeps the meaning it had in 2.1. It is still “where something lands” - it is just where the origin lands. Drag the sliders and watch the orange dot.

A 4x4 matrix, and the sixteen numbers behind it
The code that draws it src/lib/gamedev/demos/matrix4.scene.ts
/**
 * A cube driven by a 4x4 matrix, with the sixteen numbers shown as they change.
 */
import * as THREE from "three";
import {
  applyMat4,
  multiplyMat4,
  rotationY4,
  scale4,
  translation4,
  rowsOf,
  point,
  type Mat4,
} from "../matrices.ts";
import { makeCanvas, addSlider, addReadout, addMatrixGrid } from "./ui.ts";
import type { MountFn } from "./runner.ts";

const CORNERS = [
  [-0.5, -0.5, -0.5],
  [0.5, -0.5, -0.5],
  [0.5, -0.5, 0.5],
  [-0.5, -0.5, 0.5],
  [-0.5, 0.5, -0.5],
  [0.5, 0.5, -0.5],
  [0.5, 0.5, 0.5],
  [-0.5, 0.5, 0.5],
];
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 mount: MountFn = (el) => {
  const { renderer, width, height, background } = makeCanvas(el, 300);

  const scene = new THREE.Scene();
  scene.background = background;
  scene.add(new THREE.GridHelper(10, 10, 0x30363d, 0x21262d));

  const camera = new THREE.PerspectiveCamera(42, width / height, 0.1, 100);
  camera.position.set(5.5, 4.5, 7.5);
  camera.lookAt(0, 0, 0);

  // Where the cube started, dashed.
  const ghostPts: THREE.Vector3[] = [];
  for (const [a, b] of EDGES) {
    ghostPts.push(
      new THREE.Vector3(...(CORNERS[a] as [number, number, number])),
      new THREE.Vector3(...(CORNERS[b] as [number, number, number])),
    );
  }
  const ghost = new THREE.LineSegments(
    new THREE.BufferGeometry().setFromPoints(ghostPts),
    new THREE.LineDashedMaterial({
      color: 0x7d8590,
      dashSize: 0.12,
      gapSize: 0.1,
    }),
  );
  ghost.computeLineDistances();
  scene.add(ghost);

  const geom = new THREE.BufferGeometry();
  geom.setAttribute(
    "position",
    new THREE.BufferAttribute(new Float32Array(EDGES.length * 6), 3),
  );
  scene.add(
    new THREE.LineSegments(
      geom,
      new THREE.LineBasicMaterial({ color: 0x39d3c3 }),
    ),
  );

  // The origin's destination - which is exactly what the fourth column holds.
  const originDot = new THREE.Mesh(
    new THREE.SphereGeometry(0.13, 14, 10),
    new THREE.MeshBasicMaterial({ color: 0xf0883e }),
  );
  scene.add(originDot);

  const show = addReadout(el);
  const tx = addSlider(el, "translate x", -3, 3, 1.5, draw, "", 0.5);
  const ty = addSlider(el, "translate y", -3, 3, 0.5, draw, "", 0.5);
  const spin = addSlider(el, "rotate about y", 0, 360, 30, draw);
  const size = addSlider(el, "scale", 0.5, 2, 1, draw, "\u00D7", 0.1);

  /* The translation column is the interesting one, so it gets its own colour. The bottom
     row never changes for these transforms, and saying so is worth a dimmer shade. */
  const setGrid = addMatrixGrid(el, 4, (row, col) =>
    row === 3 ? "fixed" : col === 3 ? "translate" : "basis",
  );

  function draw() {
    // Scale first, then rotate, then translate. Section 2.3 is about why that order.
    const m: Mat4 = multiplyMat4(
      translation4(tx(), ty(), 0),
      multiplyMat4(rotationY4(spin()), scale4(size(), size(), size())),
    );

    const moved = CORNERS.map((c) => applyMat4(m, point(c[0], c[1], c[2])));
    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),
      );
    }
    geom.setFromPoints(pts);

    originDot.position.set(m.t.x, m.t.y, m.t.z);
    setGrid(rowsOf(m));

    show(
      `the orange dot is where the origin landed: ` +
        `(${m.t.x.toFixed(1)}, ${m.t.y.toFixed(1)}, ${m.t.z.toFixed(1)})`,
    );
    renderer.render(scene, camera);
  }

  draw();

  return () => renderer.dispose();
};

export default mount;

The sixteen numbers under the scene are the matrix, live. The orange column on the right is the translation, and the orange dot in the scene is sitting at exactly those three numbers. The grey bottom row never changes.

Watch what each slider touches. Translate only ever changes the orange column. Rotate and scale only ever change the 3×3 block on the left. The two halves of a transform do not interfere, which is why you can read a matrix by looking at its parts.

Now the part worth the whole detour.

Part 1 spent a long time on the difference between a place and a direction, and ended by admitting that the compiler will not help you - Vector3(3, 2, 0) could be either, and only the variable name says which. With a fourth component, the distinction becomes real:

  • A place has w=1w = 1. It picks up the translation.
  • A direction has w=0w = 0. It does not.

And that follows automatically, because the translation column is multiplied by ww. Set ww to zero and the translation contributes nothing at all.

This is exactly what you want. Move a character 10 meters east and its position changes; the direction it is facing does not. “North” is still north wherever you stand.

Below, the same three numbers (2,0,2)(2, 0, -2) go through the same translation matrix twice - once as a place, once as a direction.

One value, sent through the same matrix twice
The code that draws it src/lib/gamedev/demos/wcomponent.scene.ts
/**
 * The same three numbers, transformed twice: once as a place, once as a direction.
 */
import * as THREE from "three";
import {
  applyMat4,
  translation4,
  point,
  direction,
  type Vec4,
} from "../matrices.ts";
import { makeCanvas, addSlider, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";

// One value, used both ways. Everything in the scene comes from these three numbers.
const VX = 2;
const VY = 0;
const VZ = -2;

const mount: MountFn = (el) => {
  const { renderer, width, height, background } = makeCanvas(el, 320);

  const scene = new THREE.Scene();
  scene.background = background;
  scene.add(new THREE.GridHelper(10, 10, 0x30363d, 0x21262d));

  const camera = new THREE.PerspectiveCamera(42, width / height, 0.1, 100);
  camera.position.set(5.5, 5, 8);
  camera.lookAt(0, 0, 0);

  const ball = (colour: number, opacity = 1) => {
    const m = new THREE.Mesh(
      new THREE.SphereGeometry(0.22, 16, 12),
      new THREE.MeshBasicMaterial({
        color: colour,
        transparent: opacity < 1,
        opacity,
      }),
    );
    scene.add(m);
    return m;
  };

  const arrow = (colour: number, opacity = 1) => {
    const a = new THREE.ArrowHelper(
      new THREE.Vector3(1, 0, 0),
      new THREE.Vector3(),
      1,
      colour,
      0.26,
      0.14,
    );
    (a.line.material as THREE.Material).transparent = opacity < 1;
    (a.line.material as THREE.Material).opacity = opacity;
    (a.cone.material as THREE.Material).transparent = opacity < 1;
    (a.cone.material as THREE.Material).opacity = opacity;
    scene.add(a);
    return a;
  };

  // Faint originals, so you can see what moved and what did not.
  const ghostBall = ball(0xf0883e, 0.25);
  ghostBall.position.set(VX, VY, VZ);
  const ghostArrow = arrow(0x58a6ff, 0.25);

  const movedBall = ball(0xf0883e);
  const movedArrow = arrow(0x58a6ff);

  const show = addReadout(el);
  const tx = addSlider(el, "move along x", -4, 4, 2, draw, "", 0.5);
  const ty = addSlider(el, "move along y", -4, 4, 1, draw, "", 0.5);
  const tz = addSlider(el, "move along z", -4, 4, 0, draw, "", 0.5);

  const setArrow = (a: THREE.ArrowHelper, from: THREE.Vector3, v: Vec4) => {
    const len = Math.hypot(v.x, v.y, v.z);
    a.position.copy(from);
    a.visible = len > 1e-6;
    if (a.visible) {
      a.setDirection(new THREE.Vector3(v.x, v.y, v.z).normalize());
      a.setLength(len, 0.26, 0.14);
    }
  };

  function draw() {
    const T = translation4(tx(), ty(), tz());

    // Identical numbers. The only difference is the fourth one.
    const asPlace = applyMat4(T, point(VX, VY, VZ));
    const asDirection = applyMat4(T, direction(VX, VY, VZ));

    movedBall.position.set(asPlace.x, asPlace.y, asPlace.z);
    setArrow(ghostArrow, new THREE.Vector3(), direction(VX, VY, VZ));
    setArrow(movedArrow, new THREE.Vector3(), asDirection);

    const f = (v: Vec4) =>
      `(${v.x.toFixed(1)}, ${v.y.toFixed(1)}, ${v.z.toFixed(1)})`;
    show(
      `place  w=1 \u2192 ${f(asPlace)} moved     ` +
        `direction  w=0 \u2192 ${f(asDirection)} unchanged`,
    );
    renderer.render(scene, camera);
  }

  draw();

  return () => renderer.dispose();
};

export default mount;

The orange ball is the place. The blue arrow is the direction. Faint copies show where each started.

Drag any slider. The ball moves. The arrow does not budge - it cannot, because its ww is zero and the translation column never gets a chance to apply. Same three numbers, same matrix, different result, and the only difference is the fourth one.

source The code both scenes run on src/lib/gamedev/matrices.ts 324 lines
/**
 * 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] };
}

Look at applyMat4. Every line has four terms, and the last one is multiplied by v.w. That is the entire mechanism - there is no branch, no if, no special case for directions. And the constructors say what they are for: point() sets w to 1, direction() sets it to 0.

Put together, a 4×4 matrix has four readable pieces:

PieceWhereWhat it holds
Columns 1-3, top 3 rowsleft blockwhere the x, y and z axes land - rotation and scale
Column 4, top 3 rowsright columnwhere the origin lands - the translation
Bottom rowlast row(0,0,0,1)(0, 0, 0, 1) for everything in this Part
Bottom-rightcorner1, so that places stay places

So when a transform misbehaves, print the matrix and read the right-hand column first. If the object is in the wrong place, the answer is usually sitting right there in three numbers you can recognize.

Why “Homogeneous”, and What the Bottom Row Is Really For

Section titled “Why “Homogeneous”, and What the Bottom Row Is Really For”

A fair question: if the bottom row is always (0,0,0,1)(0, 0, 0, 1) and ww is always 1 or 0, why keep them around at all?

Because one transform breaks that pattern, and it is the one that makes 3D look 3D. Perspective projection puts numbers in the bottom row on purpose. It sets ww to something that depends on how far away the point is, and then everything gets divided by ww - which is what makes distant objects smaller. That division is the whole reason the fourth component exists as a general idea rather than a padding trick.

Part 5 covers it. For now it is enough to know that the bottom row is not dead weight, and that a transform which quietly modifies it is doing something other than moving an object.

  • Every object’s transform, which is a 4×4 combining where it sits, how it is turned, and how big it is - all in one thing you can multiply.
  • Skinning and animation, where a vertex is transformed by several bone matrices and the results blended. Bones translate, so 3×3 will not do.
  • Normals and directions, which must be transformed with w=0w = 0 or they end up in the wrong place. Section 2.4 shows a second, subtler thing that goes wrong with normals.
  • Importing model files, where a transform stored in the transposed layout produces an object translated along strange axes rather than a clean error.