TRS Order and Composing Transforms
What You’ll Learn
Section titled “What You’ll Learn”That combining transforms is just multiplying their matrices, that the order changes the answer, and that six orderings of scale, rotate and translate give six different results. Then the two rules that pick the right one, each derived rather than announced. And finally why the product is written in the reverse of the order it happens in, which is where the row-versus-column convention finally gets settled.
Order Changes the Answer
Section titled “Order Changes the Answer”Start with the smallest possible case: one rotation, one translation.
Both boxes below get the same turn and the same move. The teal one turns, then moves. The orange one moves, then turns.
src/lib/gamedev/demos/spinorbit.scene.ts /**
* One rotation and one translation, applied in both orders, side by side.
*/
import * as THREE from "three";
import {
applyMat4,
multiplyMat4,
point,
rotationY4,
translation4,
type Mat4,
} from "../matrices.ts";
import {
makeCanvas,
addSlider,
addReadout,
addBoxWire,
type Place,
} from "./ui.ts";
import type { MountFn } from "./runner.ts";
/** Send the unit box's corners through a matrix. */
const via =
(m: Mat4): Place =>
(c) => {
const p = applyMat4(m, point(c[0], c[1], c[2]));
return [p.x, p.y, p.z];
};
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(0.5, 7.5, 8.5);
camera.lookAt(0, 0, 0);
const spinBox = addBoxWire(scene, 0x39d3c3);
const orbitBox = addBoxWire(scene, 0xf0883e);
// The origin, and the circle the orbiting box turns out to be stuck on.
scene.add(
new THREE.Mesh(
new THREE.SphereGeometry(0.1, 12, 8),
new THREE.MeshBasicMaterial({ color: 0x7d8590 }),
),
);
const ring = new THREE.Line(
new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({ color: 0x545d68 }),
);
scene.add(ring);
const show = addReadout(el);
const spin = addSlider(el, "turn about y", 0, 360, 50, draw);
const dist = addSlider(el, "move out along x", 0, 4, 2.5, draw, "", 0.1);
function draw() {
const R = rotationY4(spin());
const T = translation4(dist(), 0, 0);
// Turn first, then move. The box spins where it stands.
const turnThenMove = multiplyMat4(T, R);
// Move first, then turn. The turn now swings the whole offset around the origin.
const moveThenTurn = multiplyMat4(R, T);
spinBox(via(turnThenMove));
orbitBox(via(moveThenTurn));
const pts: THREE.Vector3[] = [];
for (let d = 0; d <= 72; d += 1) {
const a = (d / 72) * Math.PI * 2;
pts.push(
new THREE.Vector3(Math.cos(a) * dist(), 0, Math.sin(a) * dist()),
);
}
ring.geometry.setFromPoints(pts);
const a = applyMat4(turnThenMove, point(0, 0, 0));
const b = applyMat4(moveThenTurn, point(0, 0, 0));
show(
`teal sits at (${a.x.toFixed(1)}, ${a.z.toFixed(1)}) · ` +
`orange sits at (${b.x.toFixed(1)}, ${b.z.toFixed(1)})`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; Drag the turn slider. The teal box spins where it stands. The orange box travels around the grey circle instead, because by the time the rotation happens it is no longer at the origin, and a rotation about the origin swings everything that is away from it.
That is the whole problem in one picture. Neither box is broken. They are answers to two different questions:
- Turn then move is “face this way, and stand over there.” What you want for an object with a position and a facing.
- Move then turn is “stand over there, then swing the whole arrangement around the origin.” What you want for something in orbit, and almost never what you want otherwise.
In algebra, matrix multiplication is not commutative: in general. That is usually stated as a dry fact about matrices. It is really a statement about the two boxes above.
Six Orderings
Section titled “Six Orderings”Add scale and there are three operations, so there are orders to apply them in.
Pick each one and watch. The dashed box is always the standard ordering, so there is something to compare against. The grey dot is the position you asked for and the orange dot is where the box actually ended up.
src/lib/gamedev/demos/trsorder.scene.ts /**
* The same box under all six orderings of scale, rotate and translate.
*/
import * as THREE from "three";
import {
SEQUENCES,
applyMat4,
composeSequence,
point,
type Mat4,
type Sequence,
type TRS,
type Vec4,
} from "../matrices.ts";
import {
makeCanvas,
addSlider,
addReadout,
addButtonRow,
addBoxWire,
type Place,
} from "./ui.ts";
import type { MountFn } from "./runner.ts";
const LETTER = { scale: "S", rotate: "R", translate: "T" } as const;
const labelFor = (seq: Sequence) => seq.map((s) => LETTER[s]).join("\u2192");
const via =
(m: Mat4): Place =>
(c) => {
const p = applyMat4(m, point(c[0], c[1], c[2]));
return [p.x, p.y, p.z];
};
/**
* Whether the box still has right angles at its corners.
*
* Its edges are the matrix's first three columns, so they stay square exactly while those
* columns stay perpendicular. Scaling unevenly *after* a rotation is what breaks it.
*/
function stillSquare(m: Mat4): boolean {
const d = (a: Vec4, b: Vec4) => Math.abs(a.x * b.x + a.y * b.y + a.z * b.z);
return d(m.i, m.j) < 1e-6 && d(m.j, m.k) < 1e-6 && d(m.i, m.k) < 1e-6;
}
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(1, 6.5, 9);
camera.lookAt(0, 0, 0);
// Dashed is always the standard ordering, so there is a reference to compare against.
const ghost = addBoxWire(scene, 0x7d8590, { dashed: true });
const box = addBoxWire(scene, 0x39d3c3);
// Grey is the position you asked for. Orange is where the chosen order actually put it.
const asked = new THREE.Mesh(
new THREE.SphereGeometry(0.11, 12, 8),
new THREE.MeshBasicMaterial({ color: 0x7d8590 }),
);
const landed = new THREE.Mesh(
new THREE.SphereGeometry(0.14, 14, 10),
new THREE.MeshBasicMaterial({ color: 0xf0883e }),
);
scene.add(asked, landed);
let chosen = 0;
const show = addReadout(el);
const setActive = addButtonRow(
el,
SEQUENCES.map((seq, i) => ({
label: labelFor(seq),
apply: () => {
chosen = i;
draw();
},
})),
);
const stretch = addSlider(
el,
"stretch along its own x",
0.4,
2.6,
2.2,
draw,
"\u00D7",
0.1,
);
const spin = addSlider(el, "rotate about y", 0, 360, 40, draw);
const move = addSlider(el, "translate along x", -3, 3, 2, draw, "", 0.5);
function draw() {
const v: TRS = {
scale: { x: stretch(), y: 1, z: 1 },
degrees: spin(),
translate: { x: move(), y: 0, z: 0 },
};
const seq = SEQUENCES[chosen];
const m = composeSequence(v, seq);
ghost(via(composeSequence(v, SEQUENCES[0])));
box(via(m));
const where = applyMat4(m, point(0, 0, 0));
asked.position.set(v.translate.x, 0, 0);
landed.position.set(where.x, where.y, where.z);
setActive(chosen);
show(
`${labelFor(seq)} · asked for x ${v.translate.x.toFixed(1)}, ` +
`landed at (${where.x.toFixed(1)}, ${where.z.toFixed(1)}) · ` +
`${stillSquare(m) ? "corners still square" : "corners sheared"}`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; Six buttons, six different boxes. Only one of them does what a person setting a position, a rotation and a size would expect, and two short rules are enough to find it.
Rule one: translate last
Section titled “Rule one: translate last”Look at the two dots. For four of the six orderings the orange dot is not sitting on the grey one - the object is not where you asked it to be.
The reason is Section 2.2’s fourth column. Translation puts the object’s position into that column, and anything applied afterwards transforms that position along with everything else. Rotate after translating and your position gets rotated. Scale after translating and your position gets scaled, so an object at with a scale of 2 ends up at .
Rotation and scale both leave the origin exactly where it is - that was the limitation the whole of 2.2 was written to escape. So if translation goes last, nothing is left to disturb it, and the number you typed is the position you get. Four orderings eliminated, two left.
Rule two: scale before rotate
Section titled “Rule two: scale before rotate”The two survivors are scale-rotate-translate and rotate-scale-translate. Both put the box in the right place. Only one leaves it a box.
Set the stretch slider away from and compare S→R→T with R→S→T. The first stays
a rectangular box, turned. The second comes out as a slanted parallelepiped, and the readout
says corners sheared.
Here is why, and it is a statement about columns. Section 2.1 established that a matrix’s columns are where the axes land, so the box’s own edges are those columns.
- Scale then rotate. Scaling stretches the axes but keeps them perpendicular. Rotating turns a perpendicular set into another perpendicular set. Right angles survive both, so the box stays a box.
- Rotate then scale. The box turns first, so its edges now point along some diagonal. Scaling only the world’s axis then stretches those diagonal edges by different amounts depending on how much of each one happens to lie along - and unequal stretching of edges that were at right angles leaves them at some other angle. That is a shear.
Scale belongs in the object’s own axes, which means it has to happen while those axes are still the world’s axes - before the rotation turns them. One ordering left.
That is the standard, and it is what every engine builds for you when you set position, rotation and scale on an object.
Why the Product Is Written Backwards
Section titled “Why the Product Is Written Backwards”Composing transforms is multiplying their matrices. The awkward part is which side.
For column vectors, the matrix nearest the vector acts first:
So “scale, then rotate, then translate” is written - right to left, the reverse of how you say it. Nothing deep is going on; it is a consequence of writing the vector on the right. But it is the source of an enormous number of transform bugs, because the code reads as the opposite of the intent.
composeSequence below builds a matrix from a list of steps in the order they happen, and it
has to multiply each new step onto the left to do it.
source The code both scenes run 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] };
} Row Vectors Reverse All of It
Section titled “Row Vectors Reverse All of It”Section 2.2 promised this one, and here it is.
Everything above assumes column vectors: the vector is a tall column and the matrix goes on its left. The other convention writes the vector as a flat row and puts the matrix on its right:
Three things change together, and they are a package - you cannot mix and match:
| Thing | Column convention | Row convention |
|---|---|---|
| Vector goes | on the right of the matrix | on the left |
| Translation lives in | the fourth column | the fourth row |
| Matrices are | the transpose of the row form | the transpose of the column form |
| ”Scale, rotate, move” | , right to left | , left to right |
That last line is the one that matters in practice: in row convention the product reads in the same order as the sentence. Which is arguably nicer, and is why the convention exists.
The two describe the same transform. The build check for this section proves it, by running one value through the column form and the transposed row form and asserting the results match, then asserting that is exactly .
The failure mode is nasty precisely because it is not an error. A transposed matrix is still a perfectly valid matrix, so nothing throws. Your object translates along the wrong axes, or picks up a scale that grows with distance from the origin, and you go looking for a bug in your maths when the bug is in your layout. See the conventions reference table for which tool uses which.
Composing More Than Three
Section titled “Composing More Than Three”Nothing above is special to three transforms. A matrix product can be as long as you like, and it is still built the same way: each new step multiplies onto the left.
This matters because it is how the entire rendering pipeline is expressed. An object’s world matrix, the camera’s view matrix and the projection matrix are three transforms multiplied into one, and a vertex passes through the product in a single multiply rather than three. Section 2.4 walks that chain.
It also matters for undoing a transform. The inverse of a product reverses the order:
Which reads naturally once you think about it physically: to get back, you undo the last thing you did first. The check for this section runs a point out through and back through the reversed inverses, and asserts it lands where it started.
Where This Shows Up
Section titled “Where This Shows Up”- Every object in every scene. Position, rotation and scale fields in any editor are the three ingredients, and the engine composes them as .
- Sheared characters. A model that looks fine at uniform scale and skews when stretched is rule two, almost every time.
- Objects flying off to the wrong place when you scale a parent, which is rule one applied one level up the hierarchy. Section 2.4.
- Pivot points. Rotating around a corner instead of the centre is done by translating the pivot to the origin, rotating, then translating back - three transforms whose order is the entire trick.
- Importing models, where a file written for the row convention arrives transposed and translates along strange axes rather than failing.