Game Engine Zero Vol 2 · The Math of Motion
ch 18 / 24
Chapter 18

Matrices in Two Dimensions

Matrices as transforms

This chapter writes two-dimensional transforms as 3 by 3 matrices. The grid lab draws the ship's local grid through a matrix and uses the inverse matrix to test a point against the ship.

A transform with position (40, 20), a quarter turn and scale 2 becomes nine numbers: 0, −2, 40 on the top row; 2, 0, 20 on the middle row; and 0, 0, 1 on the bottom. The first two columns say where the local axes land. The third says where the local origin lands.

Matrix products compose transforms. Read T·R·S from the right: scale first, then rotate, then translate. The worked failure multiplies the same pieces in the wrong order.

Multiplying matrices

∑ Math Interlude — the product, the point, and the undoing

A point (x, y) is carried as three numbers, (x, y, 1), so that a translation can be a multiplication like the others. A translation by (40, 20) is the matrix with 1s down the diagonal and 40, 20 in the last column: the top row dotted with (x, y, 1) is x + 40, the middle row is y + 20, and the bottom row, 0, 0, 1, keeps the 1 a 1. A rotation through ninety degrees puts the rotation formula in the top-left four entries, cos, −sin, sin, cos, which is 0, −1, 1, 0, with the last column zero. A scale of 2 is 2, 0, 0, 2 in the same four places.

The product of two matrices has, at each entry, the dot product of a row of the first with a column of the second. R·S: the top row of R, (0, −1, 0), against the first column of S, (2, 0, 0), is 0; against the second column, (0, 2, 0), is −2; so R·S has top row 0, −2, 0, middle row 2, 0, 0 and the bottom row unchanged. Then T·(R·S): T's top row (1, 0, 40) against the columns of R·S gives 0, −2, 40, and its middle row gives 2, 0, 20. That is the nine numbers, and the nose (10, 0, 1) through them is 0×10 + (−2)×0 + 40 = 40 and 2×10 + 0×0 + 20 = 40: (40, 40), which is where the three steps put it one at a time.

The other product, S·R·T, is the same three matrices in the other order, and it is the transform translate-then-rotate-then-scale, because the matrix nearest the point acts first. Its nine numbers are 0, −2, −40 on top and 2, 0, 80 in the middle, and the nose goes to (−40, 100): forty to the left and a hundred down, where chapter 13's -order trs put it. The determinant of T·R·S is 4, the factor by which it scales area, a scale of 2 each way; a matrix whose determinant is 0 has flattened the plane onto a line and cannot be undone. The inverse undoes the three steps in the opposite order: translate back by (−40, −20), rotate back a quarter turn, scale by one half. The nose's world position (40, 40) goes to (0, 20), then to (20, 0), then to (10, 0), which is where it started; a world point at (44, 30) goes to (4, 10), then (10, −4), then (5, −2), which is inside the hull's extent from −6 to 10 along its length and −6 to 6 across, so the point is on the ship.

(x, y, 1)a point carried as three numbers so that a translation is a multiplication; the third stays 1
T, R, Stranslation, rotation, scaling as 3×3 matrices: 1 0 40 / 0 1 20; 0 −1 / 1 0; 2 0 / 0 2 for the page's transform
A·Beach entry a row of A dotted with a column of B; applied to a point, B acts first: T·R·S is scale, rotate, translate
T·R·S0 −2 40 / 2 0 20 / 0 0 1: the nose (10, 0) lands on (40, 40); the columns are where the axes and the origin land
detthe factor by which area is scaled: 4 here; zero means flattened, and no inverse
M⁻¹the matrix that undoes M: world (40, 40) back to local (10, 0), world (44, 30) back to (5, −2)
The nine numbers of T·R·S, and what each column of them is A three-by-three grid of numbers: 0, -2, 40 on the top row, 2, 0, 20 on the middle, 0, 0, 1 on the bottom. Braces under the first column label it where the ship's x-axis lands, (0, 2); under the second, where its y-axis lands, (-2, 0); under the third, where its origin lands, (40, 20). A note to the right says the bottom row never changes, that a point rides along as (x, y, 1), and that the nose (10, 0, 1) through the matrix is (40, 40). T·R·S FOR POSITION (40, 20), A QUARTER TURN, SCALE 2 0 -2 40 2 0 20 0 0 1 x-axis lands on (0, 2) y-axis lands on (-2, 0) origin lands on (40, 20) the bottom row is always 0 0 1 a point rides along as (x, y, 1): (10, 0, 1) -> (0·10 - 2·0 + 40, 2·10 + 0·0 + 20) = (40, 40) a product reads right to left: in T·R·S the point meets S first det = 0·0 - (-2)·2 = 4, the area scale
Figure 18.1 — the page's transform as nine numbers: the three columns are where the two axes and the origin land, the bottom row is fixed, and a point goes through it as two dot products plus a constant each.
▣ Build · stage 1 — Mat3, the three makers, the product, the point, and the inverse
// internal/vec/mat3.go — create
package vec

import (
	"fmt"
	"math"
)

// Mat3 is a three-by-three matrix in row-major order: m[0], m[1], m[2] is
// the top row. A point (x, y) is the column (x, y, 1), and the matrix
// times it is (m[0]x + m[1]y + m[2], m[3]x + m[4]y + m[5], 1): each of the
// two rows is a dot product with the point plus a constant, and the third
// row, 0 0 1, keeps the 1 a 1. The last column is where the origin lands
// and the first two are where the axes land, which is Axes in nine
// numbers with the three that never change written down.
type Mat3 [9]float64

// Identity leaves every point where it is.
func Identity() Mat3 {
	return Mat3{1, 0, 0, 0, 1, 0, 0, 0, 1}
}

// Translation moves every point by (x, y).
func Translation(x, y float64) Mat3 {
	return Mat3{1, 0, x, 0, 1, y, 0, 0, 1}
}

// Rotation turns every point through a about the origin: Rotate as a
// matrix, with the cosines and sines in the places the formula puts them.
func Rotation(a float64) Mat3 {
	c, s := math.Cos(a), math.Sin(a)
	return Mat3{c, -s, 0, s, c, 0, 0, 0, 1}
}

// Scaling stretches every point by sx along x and sy along y.
func Scaling(sx, sy float64) Mat3 {
	return Mat3{sx, 0, 0, 0, sy, 0, 0, 0, 1}
}

// Mul is the matrix product m × n: each entry is a row of m dotted with a
// column of n. Applied to a point, m × n does n first and then m, so a
// product reads right to left as the order things happen in.
func (m Mat3) Mul(n Mat3) Mat3 {
	var p Mat3
	for r := 0; r < 3; r++ {
		for c := 0; c < 3; c++ {
			p[3*r+c] = m[3*r]*n[c] + m[3*r+1]*n[3+c] + m[3*r+2]*n[6+c]
		}
	}
	return p
}

// TransformPoint applies the matrix to a point.
func (m Mat3) TransformPoint(v Vec2) Vec2 {
	return Vec2{
		X: m[0]*v.X + m[1]*v.Y + m[2],
		Y: m[3]*v.X + m[4]*v.Y + m[5],
	}
}

// TRS is a Transform as one matrix: the translation times the rotation
// times the scaling, which applied to a point scales first, rotates
// second and translates last, as Apply does.
func TRS(t Transform) Mat3 {
	return Translation(t.Pos.X, t.Pos.Y).Mul(Rotation(t.Rot)).Mul(Scaling(t.Scale.X, t.Scale.Y))
}

// String prints the matrix as three rows, to four places. Each entry has
// zero added to it first, which turns a negative zero, the sign a product
// like −2 × 0 leaves behind, into a plain zero; the two are equal and only
// print differently.
func (m Mat3) String() string {
	var e [9]float64
	for i := range m {
		e[i] = m[i] + 0
	}
	return fmt.Sprintf("[%.4f %.4f %.4f; %.4f %.4f %.4f; %.4f %.4f %.4f]", e[0], e[1], e[2], e[3], e[4], e[5], e[6], e[7], e[8])
}

// Det is the determinant: the factor by which the matrix scales area, and
// zero exactly when it flattens the plane onto a line or a point, which
// is when it cannot be undone.
func (m Mat3) Det() float64 {
	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])
}

// Inverse is the matrix that undoes m: m times its inverse is the
// identity. A matrix with a zero determinant has no inverse, and the
// second result says so instead of dividing by zero.
func (m Mat3) Inverse() (Mat3, bool) {
	d := m.Det()
	if d == 0 {
		return Mat3{}, false
	}
	return Mat3{
		(m[4]*m[8] - m[5]*m[7]) / d, (m[2]*m[7] - m[1]*m[8]) / d, (m[1]*m[5] - m[2]*m[4]) / d,
		(m[5]*m[6] - m[3]*m[8]) / d, (m[0]*m[8] - m[2]*m[6]) / d, (m[2]*m[3] - m[0]*m[5]) / d,
		(m[3]*m[7] - m[4]*m[6]) / d, (m[1]*m[6] - m[0]*m[7]) / d, (m[0]*m[4] - m[1]*m[3]) / d,
	}, true
}
go vet ./...

Mat3 is an array of nine floats. That lets Mul find an entry from its row and column: 3*r+c. Each product in the loop is one row of m dotted with one column of n.

TRS builds translation times rotation times scaling. Written left to right, that product acts right to left on a point. Inverse returns the matrix that undoes a matrix, and returns false when the determinant is zero instead of dividing by zero.

Drawing through a matrix

Extend cmd/motion with the grid lab. The same keys drive a transform, and the lab rebuilds its matrix every tick. Ebitengine draws the hull and the local grid after each point passes through TransformPoint.

A probe point stays fixed in the world at (44, 30). The inverse matrix takes it back into the ship's local space. There, a box test against the hull extent says whether the probe is inside the ship.

▣ Build · stage 2 — the grid lab, and its name in newLab
// cmd/motion/grid.go — create
package main

import (
	"fmt"
	"image/color"
	"math"

	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/vector"

	"gez/internal/vec"
)

// The grid lab is the ship lab with the transform made a matrix: the
// ship's own grid is drawn through the matrix, so it turns and stretches
// with the hull, and a fixed probe point in the world is taken back into
// the ship's own space through the inverse, where the hit test is a box
// test against the hull's extent.
var (
	gridProbe  = vec.Vec2{X: 44, Y: 30} // a world point, marked with a cross
	hullExtent = vec.AABB{Min: vec.Vec2{X: -6, Y: -6}, Max: vec.Vec2{X: 10, Y: 6}}
)

// gridLab keeps the transform, and the heading in whole degrees so that
// forty-five turns of two degrees are exactly ninety: the rotation is
// derived from the degrees every tick, never accumulated in radians.
type gridLab struct {
	tr  vec.Transform
	deg float64
}

func newGridLab() *gridLab {
	return &gridLab{tr: shipStart}
}

// matrix is the transform as one matrix: translation times rotation
// times scaling.
func (l *gridLab) matrix() vec.Mat3 {
	return vec.TRS(l.tr)
}

// probe takes the fixed world point back into the ship's own space, and
// says whether it is within the hull's extent there.
func (l *gridLab) probe() (local vec.Vec2, inside, ok bool) {
	inv, ok := l.matrix().Inverse()
	if !ok {
		return vec.Vec2{}, false, false
	}
	local = inv.TransformPoint(gridProbe)
	_, inside = vec.OverlapAABB(vec.AABB{Min: local, Max: local.Add(vec.Vec2{X: 1e-9, Y: 1e-9})}, hullExtent)
	return local, inside, true
}

// step drives the transform as the ship lab does, with the heading kept
// in degrees. Space prints the matrix, the nose through it and through
// Apply, the inverse, and the probe in the ship's own space.
func (l *gridLab) step(k keys) {
	if k.r {
		l.tr, l.deg = shipStart, 0
	}
	if k.left {
		l.deg = math.Mod(l.deg-shipTurn+360, 360)
	}
	if k.right {
		l.deg = math.Mod(l.deg+shipTurn, 360)
	}
	l.tr.Rot = vec.Radians(l.deg)
	if k.up {
		l.tr.Pos = l.tr.Pos.Add(vec.FromAngle(l.tr.Rot).Scale(shipSpeed))
	}
	if k.down {
		l.tr.Pos = l.tr.Pos.Sub(vec.FromAngle(l.tr.Rot).Scale(shipSpeed))
	}
	if k.w {
		l.tr.Scale = l.tr.Scale.Add(vec.Vec2{X: shipGrow, Y: shipGrow})
	}
	if k.s {
		l.tr.Scale = l.tr.Scale.Sub(vec.Vec2{X: shipGrow, Y: shipGrow})
	}
	if k.space {
		m := l.matrix()
		nose := shipHull[0]
		fmt.Printf("m %v\n", m)
		fmt.Printf("the matrix puts the nose at %v; Apply puts it at %v\n", m.TransformPoint(nose), l.tr.Apply(nose))
		inv, ok := m.Inverse()
		if !ok {
			fmt.Printf("det %.4f: no inverse\n", m.Det())
			return
		}
		local, inside, _ := l.probe()
		fmt.Printf("det %.4f  inverse %v\n", m.Det(), inv)
		fmt.Printf("probe world %v -> local %v  inside %t\n", gridProbe, local, inside)
	}
}

func (l *gridLab) draw(screen *ebiten.Image, h *hud) {
	m := l.matrix()
	line := func(a, b vec.Vec2, c color.Color) {
		pa, pb := toScreen(m.TransformPoint(a)), toScreen(m.TransformPoint(b))
		vector.StrokeLine(screen, float32(pa.X), float32(pa.Y), float32(pb.X), float32(pb.Y), 1, c, false)
	}
	// The ship's own grid, sixty units each way, drawn through the matrix.
	for i := -3; i <= 3; i++ {
		k := float64(i) * gridStep
		line(vec.Vec2{X: k, Y: -60}, vec.Vec2{X: k, Y: 60}, dimColor)
		line(vec.Vec2{X: -60, Y: k}, vec.Vec2{X: 60, Y: k}, dimColor)
	}
	_, inside, _ := l.probe()
	hull := lineColor
	if inside {
		hull = yColor
	}
	for i := range shipHull {
		line(shipHull[i], shipHull[(i+1)%len(shipHull)], hull)
	}
	p := toScreen(gridProbe)
	vector.StrokeLine(screen, float32(p.X-4), float32(p.Y), float32(p.X+4), float32(p.Y), 1, xColor, false)
	vector.StrokeLine(screen, float32(p.X), float32(p.Y-4), float32(p.X), float32(p.Y+4), 1, xColor, false)
}

func (l *gridLab) lines() (top, bottom string) {
	m := l.matrix()
	local, inside, ok := l.probe()
	if !ok {
		return fmt.Sprintf("m [%.4f %.4f %.4f; %.4f %.4f %.4f]", m[0], m[1], m[2], m[3], m[4], m[5]),
			fmt.Sprintf("det %.4f: no inverse", m.Det())
	}
	return fmt.Sprintf("m [%.4f %.4f %.4f; %.4f %.4f %.4f]", m[0], m[1], m[2], m[3], m[4], m[5]),
		fmt.Sprintf("probe local %v  inside %t  det %.4f", local, inside, m.Det())
}
// cmd/motion/main.go — extend
// newLab builds the lab named, from the seed: the one place a name
// becomes a lab. Labs that draw no random numbers ignore the seed.
func newLab(name string, seed uint64) (lab, error) {
	switch name {
	case "circle":
		return newCircleLab(), nil
	case "arrows":
		return newArrowsLab(), nil
	case "ship":
		return newShipLab(), nil
	case "ball":
		return newBallLab(), nil
	case "ease":
		return newEaseLab(), nil
	case "dots":
		return newDotsLab(seed), nil
	case "shapes":
		return newShapesLab(), nil
	case "grid":
		return newGridLab(), nil
	}
	return nil, fmt.Errorf("no lab called %q", name)
}
go vet ./...
go run ./cmd/motion -lab grid
A grid of dim twenty-pixel squares centred on the picture, with the small white outline of the ship at its centre pointing right, and a small blue cross to the lower right of the ship. The top line reads m [1.0000 0.0000 0.0000; 0.0000 1.0000 0.0000] and the bottom probe local (44.0000, 30.0000) inside false det 1.0000.
The window when the lab opens: the identity matrix, the ship's own grid lying on the world's, and the probe at (44, 30) in both spaces because nothing has moved.

Make these moves: Up forty ticks, Right forty-five ticks, Up twenty ticks, W thirty-two ticks, then press Space:

$ go run ./cmd/motion -lab grid
m [0.0000 -2.0000 40.0000; 2.0000 0.0000 20.0000; 0.0000 0.0000 1.0000]
the matrix puts the nose at (40.0000, 40.0000); Apply puts it at (40.0000, 40.0000)
det 4.0000  inverse [0.0000 0.5000 -10.0000; -0.5000 0.0000 20.0000; 0.0000 0.0000 1.0000]
probe world (44.0000, 30.0000) -> local (5.0000, -2.0000)  inside true
The grid now of forty-pixel squares, shifted so its lines pass through the ship's position; the ship, twice its earlier size and pointing down, is drawn in gold, with the blue cross inside its outline. The top line reads m [0.0000 -2.0000 40.0000; 2.0000 0.0000 20.0000], the bottom probe local (5.0000, -2.0000) inside true det 4.0000, and the tick count reads 137.
The page's transform on the screen: the ship's own grid doubled and turned with the hull, the determinant 4, and the probe found inside the hull through the inverse, so the hull is gold.

The first line is the interlude's nine numbers. The second line checks the matrix against Transform.Apply: both put the nose at (40, 40).

The third line prints the inverse matrix. It contains the three undoing steps in one product: move back, rotate back and scale by one half. The fourth line takes the world probe (44, 30) back to local (5, −2), which is inside the hull extent.

The grid lab keeps its heading in whole degrees and derives radians each tick. Forty-five additions of 2 are exactly 90, so the cosine of the quarter turn prints as zero.

probe asks the inverse for a local point and then uses OverlapAABB as a point-in-box test. A screen click works the same way: convert the point into a thing's local space, then test the unchanging local bounds.

⚙ Tool — GeoM is a Mat3 with the bottom row left out

Ebitengine's GeoM stores six numbers: the top two rows of this matrix. Its Scale, Rotate and Translate methods build the same kind of product, and DrawImage sends image pixels through it.

The lab draws the grid line by line through TransformPoint so the arithmetic stays visible. A sprite drawn with a matching GeoM would land on the same pixels.

Reversing product order

Add a flag that multiplies the three matrices in the wrong order. The default product is T·R·S. The flag builds S·R·T.

▣ Build · stage 3 — the reversed product, behind a flag, extend cmd/motion/grid.go
// cmd/motion/grid.go — extend
import (
	"flag"
	"fmt"
	"image/color"
	"math"

	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/vector"

	"gez/internal/vec"
)

// matrix is the transform as one matrix: translation times rotation
// times scaling, or, under -reversed, the same three multiplied the other
// way round, which is the mistake this chapter shows.
func (l *gridLab) matrix() vec.Mat3 {
	if *reversed {
		return vec.Scaling(l.tr.Scale.X, l.tr.Scale.Y).Mul(vec.Rotation(l.tr.Rot)).Mul(vec.Translation(l.tr.Pos.X, l.tr.Pos.Y))
	}
	return vec.TRS(l.tr)
}

var reversed = flag.Bool("reversed", false, "multiply the grid lab's matrices scaling times rotation times translation: the mistake, kept so it can be seen")
go vet ./...
go run ./cmd/motion -lab grid

Without the flag, nothing changes. With it, every point the lab draws goes through S·R·T instead of T·R·S. The inverse also belongs to that wrong product.

⚠ Worked failure — S·R·T, and a nose at (−40, 100)

Run the lab with -reversed, make the same moves, and press Space:

$ go run ./cmd/motion -lab grid -reversed
m [0.0000 -2.0000 -40.0000; 2.0000 0.0000 80.0000; 0.0000 0.0000 1.0000]
the matrix puts the nose at (-40.0000, 100.0000); Apply puts it at (40.0000, 40.0000)
det 4.0000  inverse [0.0000 0.5000 -40.0000; -0.5000 0.0000 -20.0000; 0.0000 0.0000 1.0000]
probe world (44.0000, 30.0000) -> local (-25.0000, -42.0000)  inside false
The forty-pixel grid has slid up and to the left so that its lines no longer pass through the middle of the picture, the blue cross sits on empty grid, and the ship's outline is at the bottom edge of the picture, white, with its nose cut off below the bottom line. The top line reads m [0.0000 -2.0000 -40.0000; 2.0000 0.0000 80.0000] and the bottom probe local (-25.0000, -42.0000) inside false det 4.0000.
The lab under -reversed after the same keys: the same three fields, a different product, and the hull at the bottom of the picture with its nose below the edge.

The top-left four entries are the same in both matrices: 0, −2, 2, 0. The scale is the same on both axes, so the rotation and scale commute. The last column changes.

T·R·S has (40, 20) in the last column, the position as typed. S·R·T has (−40, 80), because translation acted first and was then rotated and scaled. The nose lands on (−40, 100), and the probe comes back to local (−25, −42), outside the hull.

The fix is TRS. The matrix on the right acts first.

Composing transforms

A matrix says where the axes and the origin land. Multiplying two matrices produces another matrix with the same kind of answer. That is why a chain of transforms can become one matrix.

A turret on a ship on the screen can be one product: Tscreen·Tship·Tturret. A turret point meets the turret transform first, then the ship transform, then the screen transform. The inverse product sends a screen point back the other way for picking.

The determinant gives the area scale. A determinant of 4 means areas become four times as large. A determinant of 0 means the matrix flattened the plane, so it cannot be undone. Inverse returns false for that case, and the caller can skip picking instead of dividing by zero.

Checkpoint

✓ Checkpoint — what you can now do
  • Write translation, rotation and scaling as three-by-three matrices, and read the columns of their product as where the axes and the origin land.
  • Multiply T·R·S for position (40, 20), a quarter turn and scale 2 by hand, to 0, −2, 40 over 2, 0, 20, and put the nose through it to (40, 40).
  • Say why a product acts right to left, and predict the last column of S·R·T before running it.
  • Undo the transform as three undoings in reverse order and as one inverse, and take (44, 30) back to (5, −2).
  • Pick a thing on the screen by taking a point through the inverse into its own space and testing a box that never changes.
  • Say what a determinant of 4 means and what a determinant of 0 refuses.
⚡ Exercises — try first, then reveal
Exercise 1 — the probe on the mouse. Replace the fixed probe with the cursor, taken from the screen into the world by subtracting the world origin, and watch the hull light up under the pointer whatever the ship's transform.

Add the cursor to keys as chapter 3 did, set gridProbe from it each tick with vec.Vec2{X: float64(mx), Y: float64(my)}.Sub(worldOrigin), and nothing else changes: the inverse takes the pointer into the ship's space and the box test answers. Scale the ship to three and turn it, and the gold still follows the pointer exactly over the hull, because the extent in local space is the same numbers it always was.

Exercise 2 — a turret matrix. Draw a second, smaller hull at local (−3, 0) on the ship, turned by its own angle under Q and E, with one product for the two transforms.

turret := vec.TRS(vec.Transform{Pos: vec.Vec2{X: -3}, Rot: l.turretRot, Scale: vec.Vec2{X: 1, Y: 1}}) and m2 := l.matrix().Mul(turret): the ship's matrix on the left, the turret's on the right, because a turret point meets the turret's transform first. Every turret corner goes through m2.TransformPoint. Print m2's last column and it is where local (−3, 0) lands in the world, which is the ship's Apply of (−3, 0), the turret's pivot.

Exercise 3 — flatten it. Hold S until the scale reaches zero and read the two lines. Then let the scale go negative.

At a scale of exactly zero the determinant prints 0.0000, the bottom line says no inverse, and the hull is a point; probe returned its third result false and the lab printed the other message, which is the boolean doing its job. Past zero the determinant is positive again, since a negative scale on both axes is a half turn, and the ship is drawn upside down and backwards, growing as the scale grows more negative: a reflection through the origin, which is the one transform of the three the keys can reach that the interlude did not name.