Translate, Rotate and Scale
What You’ll Learn
Section titled “What You’ll Learn”Nothing new, at first. You can already translate a point, rotate one, and scale one. This Section puts those three in a single box so they can be combined — which is what makes a sprite on a turret on a tank possible, and what makes “why is my sprite in the wrong place” a question with one answer instead of five.
Then the two things that go wrong: the order you apply them in, and forgetting that a matrix should not move a direction the way it moves a place.
Three Things You Have Already Done
Section titled “Three Things You Have Already Done”| Operation | What it is | Where it came from |
|---|---|---|
| translate | point plus displacement | Section 1.2 |
| rotate | , etc. | Section 2.3 |
| scale | multiply each component | new, and trivial |
Applied one at a time, in order, these are three lines of code and no matrix is needed. So the matrix has to earn its place, and it earns it in exactly two ways.
One transform instead of three. Compose once, then apply the result to every corner of a sprite, or every tile in a chunk, or every particle. A composition costs a handful of multiplications and pays for itself immediately.
Composition becomes multiplication. A turret’s transform relative to its tank, times the tank’s transform relative to the world, is the turret’s transform relative to the world. That single sentence is the whole of Section 3.2, and it is only available because the three operations share a shape.
Rotation and scale are both multiplications, so a matrix holds them comfortably. Translation is an addition, and no matrix can add a constant — put in and any matrix gives back.
The fix is to add a coordinate. Write a point as , and the extra has something to multiply against:
That is all homogeneous coordinates are at this stage: a third coordinate carried along so that translation fits in the same box as the other two. It is worth meeting here, where the matrix is small enough to read every entry, rather than for the first time in a where the same idea is buried in sixteen numbers.
The bottom row stays for everything in this Section, and the build checks that it does for every matrix built here. Section 3.3 is where it finally does something.
Conventions, Stated Once
Section titled “Conventions, Stated Once”Half of all matrix confusion is two sources disagreeing without saying so. This module makes these choices and keeps them:
| Question | This module | The other convention |
|---|---|---|
| points are | columns | rows |
| a point goes on the | right: | left: |
| translation lives in the | right-hand column | bottom row |
| a product reads | right to left | left to right |
| storage | row-major, a row at a time | column-major |
The consequences are worth spelling out, because they are what you actually trip over. In the scale happens first and the translation last, because is nearest the point. Reading a product left to right is the opposite of the order things happen in, which never stops feeling backwards and is forced by putting the point on the right.
If you use the row-vector convention instead, every product in this module reverses and every matrix transposes. Neither is more correct; mixing them silently is what hurts. Sections 3.2 and 3.3 refer back to this table rather than repeating it.
The Columns Are Where the Axes Land
Section titled “The Columns Are Where the Axes Land”This is the one idea that turns a matrix from nine arbitrary numbers into something you can read.
Which is Section 2.3’s observation again: a transform moves the axes and lets the point ride along, with the point’s own components as the weights. A rotation matrix is not a formula to memorise — it is and its quarter turn, written side by side.
src/lib/gamedev/demos/2d/affine.scene.ts /** One sprite under T·R·S, with the composed matrix printed live so you can see which numbers move. */
import {
makeCanvas2D,
arrow,
dot as fillDot,
label,
line,
} from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addMatrixGrid, addReadout, addSlider } from "../controls.ts";
import {
applyToDirection,
determinant,
rows,
translationOf,
} from "../../../gamedev2d/matrix2d.ts";
import {
RANGE,
SHAPE,
fittingScale,
matrixFor,
transformedShape,
} from "./affine-shared.ts";
import type { MountFn } from "../runner.ts";
const BEFORE = "#484f58";
const AFTER = "#7ee787";
const MIRRORED = "#ff7b72";
const AXIS_X = "#58a6ff";
const AXIS_Y = "#d2a8ff";
const GRID = "#252b33";
const TEXT = "#9198a1";
const mount: MountFn = (el) => {
const { ctx, width, height, clear } = makeCanvas2D(el, 320);
const show = addReadout(el);
// The translation column is tagged separately from the linear block, because which cells move when
// you drag which slider is most of what makes a matrix stop feeling arbitrary.
const grid = addMatrixGrid(el, 3, (row, col) =>
row === 2 ? "fixed" : col === 2 ? "translate" : "linear",
);
const note = addReadout(el);
const angle = addSlider(
el,
"rotate",
RANGE.angle.min,
RANGE.angle.max,
30,
draw,
);
const scaleX = addSlider(
el,
"scale x",
RANGE.scale.min,
RANGE.scale.max,
1,
draw,
"\u00D7",
0.05,
);
const scaleY = addSlider(
el,
"scale y",
RANGE.scale.min,
RANGE.scale.max,
1,
draw,
"\u00D7",
0.05,
);
const translateX = addSlider(
el,
"translate x",
RANGE.translate.min,
RANGE.translate.max,
0.8,
draw,
"",
0.1,
);
function outline(
points: Array<{ x: number; y: number }>,
colour: string,
lineWidth = 2,
) {
ctx.save();
ctx.strokeStyle = colour;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (const p of points.slice(1)) ctx.lineTo(p.x, p.y);
ctx.closePath();
ctx.stroke();
ctx.restore();
}
function draw() {
clear();
// Derived from the slider ranges, never chosen: see `fittingScale`.
const unit = fittingScale(width / 2, height / 2);
const ox = width / 2;
const oy = height / 2;
// World Y is up, so drawing negates it. Section 1.1's one conversion, in one place.
const at = (p: { x: number; y: number }) => ({
x: ox + p.x * unit,
y: oy - p.y * unit,
});
line(ctx, { x: 0, y: oy }, { x: width, y: oy }, GRID, { width: 1 });
line(ctx, { x: ox, y: 0 }, { x: ox, y: height }, GRID, { width: 1 });
const params = {
angleDegrees: angle(),
scaleX: scaleX(),
scaleY: scaleY(),
translateX: translateX(),
};
const m = matrixFor("TRS", params);
const det = determinant(m);
const mirrored = det < 0;
outline(SHAPE.map(at), BEFORE);
label(ctx, "before", at(SHAPE[9]).x - 4, at(SHAPE[9]).y - 8, BEFORE);
// The two columns of the linear block are where the axes land. Drawing them makes the matrix
// readable as geometry rather than as nine numbers.
const origin = at({ x: 0, y: 0 });
const xAxis = applyToDirection(m, { x: 1, y: 0 });
const yAxis = applyToDirection(m, { x: 0, y: 1 });
const from = at(translationOf(m));
arrow(ctx, from, at({ x: m[2] + xAxis.x, y: m[5] + xAxis.y }), AXIS_X, 2);
arrow(ctx, from, at({ x: m[2] + yAxis.x, y: m[5] + yAxis.y }), AXIS_Y, 2);
label(
ctx,
"column 1",
at({ x: m[2] + xAxis.x, y: m[5] + xAxis.y }).x + 6,
at({ x: m[2] + xAxis.x, y: m[5] + xAxis.y }).y,
AXIS_X,
);
label(
ctx,
"column 2",
at({ x: m[2] + yAxis.x, y: m[5] + yAxis.y }).x + 6,
at({ x: m[2] + yAxis.x, y: m[5] + yAxis.y }).y,
AXIS_Y,
);
// The translation, which is the third column read straight off the matrix.
line(ctx, origin, from, TEXT, { dashed: true, width: 1 });
fillDot(ctx, from.x, from.y, 4, TEXT);
outline(
transformedShape("TRS", params).map(at),
mirrored ? MIRRORED : AFTER,
);
grid(rows(m));
show(
`T \u00B7 R \u00B7 S \u00B7 the scale happens first and the translation last \u00B7 ` +
`determinant ${det.toFixed(2)}, so area is \u00D7${Math.abs(det).toFixed(2)}` +
`${mirrored ? " and the shape is mirrored" : ""}`,
);
note(
`column 1 is where (1, 0) lands, column 2 is where (0, 1) lands, column 3 is the translation ` +
`(${m[2].toFixed(2)}, ${m[5].toFixed(2)}) \u00B7 the bottom row stays 0 0 1 until Section 3.3`,
);
}
draw();
return () => {};
};
export default mount; Move one slider at a time and watch which cells respond. The rotation slider stirs all four cells of the top-left block; the scale sliders each own a diagonal entry; translate x moves one number in the right-hand column and nothing else. The two arrows are the first two columns, drawn.
That the matrix and Section 2.3’s rotate agree is checked rather than claimed, at every degree from
to across four different vectors.
The Determinant Is an Area, and a Warning
Section titled “The Determinant Is an Area, and a Warning”The top-left block has a determinant, , and Section 2.1 already told you what that number means: it is the cross product of the two columns, so it is the area of the parallelogram the transformed axes span.
| Transform | Determinant | Meaning |
|---|---|---|
| rotation | turning does not change area | |
| pure translation | nor does moving | |
| scale by and | six times the area | |
| scale by and | mirrored, same area |
The negative case is the useful one. A sprite that comes out backwards, a polygon whose normals point inward, a shape that suddenly fails a winding test — check the determinant’s sign before anything else.
There is an exact identity behind this, and it is checked across eight different matrices: the signed polygon area from Section 2.1 is multiplied by the determinant, sign included. So a negative determinant is not merely associated with a flipped winding; it is one.
The Third Coordinate Is the Type Tag Section 1.2 Wanted
Section titled “The Third Coordinate Is the Type Tag Section 1.2 Wanted”Section 1.2 made a distinction and then admitted the type checker could not enforce it: a place and a displacement are both two numbers, so nothing stops you passing one where the other belongs.
Homogeneous coordinates enforce it. A place is and a displacement is , and that third number decides whether translation reaches the thing:
src/lib/gamedev/demos/2d/homogeneous.ts /** A place and a displacement under the same matrix, and the third coordinate that separates them. */
import {
apply,
applyToDirection,
compose,
determinant,
rotation,
scaling,
translation,
} from "../../../gamedev2d/matrix2d.ts";
import type { Demo } from "../runner.ts";
const T = translation(4, 1);
const R = rotation(Math.PI / 2);
const S = scaling(2, 3);
const at = (p: { x: number; y: number }) =>
`(${p.x.toFixed(2)}, ${p.y.toFixed(2)})`;
const PLACE = { x: 3, y: 2 };
const DISPLACEMENT = { x: 3, y: 2 };
const demo: Demo = (log) => {
log(
"a place at (3, 2), translated by (4, 1)",
at(apply(T, PLACE)),
"the third coordinate is 1, so the translation reaches it",
);
log(
"a displacement of (3, 2), through the same matrix",
at(applyToDirection(T, DISPLACEMENT)),
"the third coordinate is 0, so the translation cannot",
);
log(
"the same two, rotated a quarter turn",
`${at(apply(R, PLACE))} and ${at(applyToDirection(R, DISPLACEMENT))}`,
"turning the world does turn your directions, so here they agree",
);
// The identity that gives the w = 0 rule its meaning, rather than making it a rule to remember.
const p = { x: 5, y: -2 };
const q = { x: p.x + DISPLACEMENT.x, y: p.y + DISPLACEMENT.y };
const byPoints = {
x: apply(T, q).x - apply(T, p).x,
y: apply(T, q).y - apply(T, p).y,
};
log(
"transform two places, then subtract them",
at(byPoints),
"which is what transforming the displacement between them has to mean",
);
log(
"determinant of a scale by 2 and 3",
determinant(S),
"the area factor, and it is Section 2.1's cross product of the two columns",
);
log(
"determinant of the rotation",
Number(determinant(R).toFixed(12)),
"turning something does not change its area",
);
log(
"compose(T, R) applied to (3, 2), against applying R then T by hand",
`${at(apply(compose(T, R), PLACE))} and ${at(apply(T, apply(R, PLACE)))}`,
"the right-hand matrix goes first, which is what makes the product read right to left",
);
};
export default demo; Rotation and scale act on both, because turning the world really does turn your directions. Translation acts only on places, because moving the world leaves “four metres east” four metres east.
The fourth row of that panel is the reason this is a fact rather than a rule to remember. Transforming a displacement has to equal transforming two places and subtracting them — that is what a displacement is — and the arithmetic is exactly what makes those agree. The build sweeps that identity over six matrices, three base points and three displacements.
Order Matters, and Only Sometimes
Section titled “Order Matters, and Only Sometimes”and are different transforms. Not subtly — the sprite lands somewhere else.
src/lib/gamedev/demos/2d/orders.scene.ts /** The standard T·R·S beside any of the six orders, so the disagreement is on screen rather than described. */
import { makeCanvas2D, label, line } from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addButtonRow, addReadout, addSlider } from "../controls.ts";
import {
ORDERS,
RANGE,
SHAPE,
distinctOutcomes,
fittingScale,
ordersAgree,
transformedShape,
type Order,
} from "./affine-shared.ts";
import type { MountFn } from "../runner.ts";
const BEFORE = "#484f58";
const STANDARD = "#7ee787";
const OTHER = "#f0883e";
const AGREES = "#39d3c3";
const GRID = "#252b33";
const TEXT = "#9198a1";
const mount: MountFn = (el) => {
const { ctx, width, height, clear } = makeCanvas2D(el, 330);
const show = addReadout(el);
const note = addReadout(el);
let chosen: Order = "SRT";
const pick = addButtonRow(
el,
ORDERS.map((order) => ({
label: order.split("").join("\u00B7"),
apply: () => {
chosen = order;
draw();
},
})),
);
const angle = addSlider(
el,
"rotate",
RANGE.angle.min,
RANGE.angle.max,
30,
draw,
);
const scaleX = addSlider(
el,
"scale x",
RANGE.scale.min,
RANGE.scale.max,
1.4,
draw,
"\u00D7",
0.05,
);
const scaleY = addSlider(
el,
"scale y",
RANGE.scale.min,
RANGE.scale.max,
0.6,
draw,
"\u00D7",
0.05,
);
const translateX = addSlider(
el,
"translate x",
RANGE.translate.min,
RANGE.translate.max,
1.2,
draw,
"",
0.1,
);
function outline(
points: Array<{ x: number; y: number }>,
colour: string,
lineWidth = 2,
) {
ctx.save();
ctx.strokeStyle = colour;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (const p of points.slice(1)) ctx.lineTo(p.x, p.y);
ctx.closePath();
ctx.stroke();
ctx.restore();
}
function draw() {
clear();
const halfWidth = width / 2;
// Two panels side by side, each framed by the same derived scale so the two are comparable.
const unit = fittingScale(halfWidth / 2, height / 2);
const params = {
angleDegrees: angle(),
scaleX: scaleX(),
scaleY: scaleY(),
translateX: translateX(),
};
const agree = ordersAgree("TRS", chosen, params);
const distinct = distinctOutcomes(params);
line(ctx, { x: halfWidth, y: 0 }, { x: halfWidth, y: height }, GRID, {
width: 1,
});
const panels: Array<[Order, number, string]> = [
["TRS", 0, STANDARD],
[chosen, halfWidth, agree ? AGREES : OTHER],
];
for (const [order, left, colour] of panels) {
const ox = left + halfWidth / 2;
const oy = height / 2;
// World Y is up, so drawing negates it. Section 1.1's one conversion, in one place.
const at = (p: { x: number; y: number }) => ({
x: ox + p.x * unit,
y: oy - p.y * unit,
});
line(ctx, { x: left, y: oy }, { x: left + halfWidth, y: oy }, GRID, {
width: 1,
});
line(ctx, { x: ox, y: 0 }, { x: ox, y: height }, GRID, { width: 1 });
outline(SHAPE.map(at), BEFORE, 1);
outline(transformedShape(order, params).map(at), colour);
label(
ctx,
order.split("").join(" \u00B7 "),
left + halfWidth / 2,
20,
colour,
"center",
);
label(
ctx,
order === "TRS" ? "the usual one" : agree ? "same result" : "different",
left + halfWidth / 2,
36,
order === "TRS" ? TEXT : colour,
"center",
);
}
pick(ORDERS.indexOf(chosen));
show(
`T \u00B7 R \u00B7 S against ${chosen.split("").join(" \u00B7 ")} \u2192 ` +
`${agree ? "the same result" : "a different result"} \u00B7 ` +
`at these settings the six orders give ${distinct} distinct outcome${distinct === 1 ? "" : "s"}`,
);
note(
Math.abs(scaleX() - scaleY()) < 1e-9
? "with the two scale factors equal, rotation and scale commute, so several of the six collapse together"
: "set the two scale factors equal and watch the count of distinct outcomes drop \u2014 that is why this bug hides",
);
}
draw();
return () => {};
};
export default mount; The left panel is always , the order nearly every engine uses. Click through the six buttons and compare. At the panel’s default settings all six land somewhere different, and the build asserts that none of the other five matches the standard.
Now set the two scale factors equal and click through again. Because a uniform scale commutes with a rotation, several of the six collapse together:
| Settings | Distinct results | What collapses |
|---|---|---|
| all three doing something | 6 | nothing |
| uniform scale | 4 | and |
| no translation | 2 | only “which of and went first” |
| no rotation | 2 | only “was the translation scaled” |
| translation alone | 1 | nothing is left to order |
Every one of those counts is asserted, and so are the specific groupings — including that a uniform scale does not collapse into , because those two are the orders where a scale gets applied to a translation.
That middle row is why transform-order bugs reach players. Uniform scale is the common case, and with it two of the wrong orders are indistinguishable from the right one. The code looks fine for as long as nobody sets a non-uniform scale. Then an artist squashes one sprite and a bug appears in code that has not been touched in months.
source Three transforms, one shape, and the composition rule
/**
* Translation, rotation and scale, packaged as one 3x3 matrix.
*
* Everything here has already appeared. Rotation is Section 2.3's formula. Scale is multiplying the
* components. Translation is Section 1.2's "point plus vector". The matrix does not add a new idea; it
* gives the three of them **one shape**, so they can be composed once and applied to a thousand points,
* and so a parent's transform can be combined with a child's by multiplying rather than by remembering
* which operations to redo in which order.
*
* A 2D transform needs 3x3 rather than 2x2 because translation is not a multiplication. That is what
* the third row and column are for, and the cost is one extra coordinate on every point - which turns
* out to carry the place-against-displacement distinction from Section 1.2 for free.
*
* **Conventions used throughout this module**, stated once because half of all matrix confusion is two
* sources disagreeing silently:
*
* - **Column vectors.** A point is a column, and it goes on the **right**: $p' = M p$.
* - **Translation lives in the right-hand column**, entries `tx` and `ty`.
* - **Products read right to left.** In $T R S$ the scale happens first and the translation last.
* - **Row-major storage.** The nine numbers are listed a row at a time, which is how they are printed.
*/
import type { Point, Vector } from "./vectors2d.ts";
/**
* Nine numbers, listed a row at a time:
*
* $$\begin{bmatrix} a & b & t_x \\ c & d & t_y \\ 0 & 0 & 1 \end{bmatrix}$$
*
* The top-left 2x2 block does rotation, scale, shear and reflection. The right-hand column translates.
* The bottom row is `0 0 1` for every transform in this Section, and Section 3.3 is where it finally
* earns its keep.
*/
export type Mat3 = readonly [
number,
number,
number,
number,
number,
number,
number,
number,
number,
];
/** Does nothing, which is the transform you start a composition from. */
export function identity(): Mat3 {
return [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
/** Move by a displacement. The one operation a 2x2 matrix cannot express. */
export function translation(tx: number, ty: number): Mat3 {
return [1, 0, tx, 0, 1, ty, 0, 0, 1];
}
/**
* Rotate counter-clockwise about the origin. Section 2.3's formula, in a box.
*
* $$\begin{bmatrix} \cos\theta & -\sin\theta & 0 \\ \sin\theta & \cos\theta & 0 \\ 0 & 0 & 1 \end{bmatrix}$$
*
* Read the **columns** and it stops being something to memorise: the first column is where $(1, 0)$
* lands and the second is where $(0, 1)$ lands. That is exactly what Section 2.3 said a rotation does -
* move the axes and let the point ride along - and a matrix is just those destinations written side by
* side.
*/
export function rotation(radians: number): Mat3 {
const c = Math.cos(radians);
const s = Math.sin(radians);
return [c, -s, 0, s, c, 0, 0, 0, 1];
}
/** Stretch each axis independently. Equal factors is uniform scale; unequal is where order starts to matter. */
export function scaling(sx: number, sy: number): Mat3 {
return [sx, 0, 0, 0, sy, 0, 0, 0, 1];
}
/**
* Multiply two matrices. **The right-hand one happens first.**
*
* That ordering is not a convention you could flip freely; it follows from writing points as columns on
* the right. $(AB)p = A(Bp)$, so `B` is applied to the point before `A` ever sees it. Every "why is my
* sprite in the wrong place" question about transform order comes back to this line.
*/
export function multiply(a: Mat3, b: Mat3): Mat3 {
const out = new Array<number>(9);
for (let row = 0; row < 3; row += 1) {
for (let col = 0; col < 3; col += 1) {
out[row * 3 + col] =
a[row * 3] * b[col] +
a[row * 3 + 1] * b[3 + col] +
a[row * 3 + 2] * b[6 + col];
}
}
return out as unknown as Mat3;
}
/**
* Compose a list of transforms, left to right, so `compose(T, R, S)` is $T R S$ - scale first.
*
* Written this way the argument order matches the way the product is written on paper, which is worth
* more than it sounds: a helper that quietly reversed it would be correct, useful, and impossible to
* reason about alongside any textbook.
*/
export function compose(...matrices: Mat3[]): Mat3 {
return matrices.reduce((acc, m) => multiply(acc, m), identity());
}
/**
* Apply a transform to a **place**. The third coordinate is 1, so translation reaches it.
*
* $$\begin{bmatrix} a & b & t_x \\ c & d & t_y \\ 0 & 0 & 1 \end{bmatrix}
* \begin{bmatrix} x \\ y \\ 1 \end{bmatrix}
* = \begin{bmatrix} ax + by + t_x \\ cx + dy + t_y \\ 1 \end{bmatrix}$$
*
* The 1 is doing real work: it is what multiplies $t_x$ and $t_y$ into the answer.
*/
export function apply(m: Mat3, p: Point): Point {
return {
x: m[0] * p.x + m[1] * p.y + m[2],
y: m[3] * p.x + m[4] * p.y + m[5],
};
}
/**
* Apply a transform to a **displacement**. The third coordinate is 0, so translation cannot reach it.
*
* This is Section 1.2's distinction, finally enforced by the arithmetic rather than by your naming. A
* place is $(x, y, 1)$ and a displacement is $(x, y, 0)$; moving the world moves the places in it and
* leaves every "four metres east" exactly four metres east. Rotation and scale still apply, because
* turning the world does turn your directions.
*
* Getting this wrong is a specific, common bug: a normal or a velocity translated along with its owner,
* which looks fine at the origin and drifts further wrong the further from it you go.
*/
export function applyToDirection(m: Mat3, v: Vector): Vector {
return { x: m[0] * v.x + m[1] * v.y, y: m[3] * v.x + m[4] * v.y };
}
/** Apply one transform to many points. The reason a matrix is worth building at all. */
export function applyAll(m: Mat3, points: readonly Point[]): Point[] {
return points.map((p) => apply(m, p));
}
/**
* The determinant: **how much the transform multiplies area by**, and whether it flips.
*
* For an affine matrix it reduces to $ad - bc$ on the 2x2 block, which is Section 2.1's cross product
* of the two columns - the parallelogram the transformed axes span. So a scale of 2 by 3 has
* determinant 6, a rotation has determinant 1 because turning something does not change its area, and a
* **negative** determinant means the transform mirrored the shape, which is the number to check when a
* sprite comes out backwards.
*/
export function determinant(m: Mat3): number {
return (
m[0] * (m[4] * m[8] - m[5] * m[7]) -
m[1] * (m[3] * m[8] - m[5] * m[6]) +
m[2] * (m[3] * m[7] - m[4] * m[6])
);
}
/**
* The transform that undoes this one, or `null` if there is nothing to undo it with.
*
* Invert the linear block, then invert the translation **through** it: if $p' = Lp + t$ then
* $p = L^{-1}p' - L^{-1}t$. That second term is the part people get wrong by negating the
* translation and stopping there, which is only correct when there is no rotation or scale.
*
* Returns `null` when the determinant is zero, in the same spirit as `normalize` in Section 1.3. A
* zero scale on either axis collapses the plane onto a line and genuinely destroys information -
* every point on that line came from somewhere different, and no matrix can say where. Returning
* `null` forces the caller to decide, rather than handing back `Infinity` for them to propagate.
*/
export function inverse(m: Mat3, epsilon = 1e-12): Mat3 | null {
const det = m[0] * m[4] - m[1] * m[3];
if (Math.abs(det) < epsilon) return null;
const a = m[4] / det;
const b = -m[1] / det;
const c = -m[3] / det;
const d = m[0] / det;
return [a, b, -(a * m[2] + b * m[5]), c, d, -(c * m[2] + d * m[5]), 0, 0, 1];
}
/** The nine numbers as three rows, for printing or for a live grid under a scene. */
export function rows(m: Mat3): number[][] {
return [
[m[0], m[1], m[2]],
[m[3], m[4], m[5]],
[m[6], m[7], m[8]],
];
}
/** The translation a transform carries, which is just its right-hand column read out. */
export function translationOf(m: Mat3): Vector {
return { x: m[2], y: m[5] };
}
/** Do two transforms do the same thing? Compared entry by entry, with a tolerance. */
export function sameMatrix(a: Mat3, b: Mat3, tolerance = 1e-9): boolean {
return a.every((v, i) => Math.abs(v - b[i]) <= tolerance);
} Where This Shows Up
Section titled “Where This Shows Up”- Every sprite that is drawn, whether or not you build the matrix yourself.
- Parent and child hierarchies, where composition is multiplication. Section 3.2.
- Cameras, which are this matrix inverted. Section 3.3.
- Normals and velocities, which need the rule to survive being transformed.
- Mirrored sprites and inverted polygons, diagnosed by the determinant’s sign.
- Section 5.3’s separating axis test, which needs a polygon’s transformed axes — the columns above.