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
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.
// 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.
// 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
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 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.
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.
// 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.
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
-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
- 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.
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.