1 so that translation, rotation and scale all become the same thing -- a matrix multiply;Mat3 affine-transform type written from scratch in Zig, with identity, translation, scaling, rotation, matrix product, and point application;comptime, fixed-size arrays and !void keep the fast path allocation-free while making the batch path's one real failure mode honest;transform, camera/view matrices) and how C, Rust and Go write the identical arithmetic.Framebuffer, Point and the clipping setPixel/getPixel from episode 156, plus the fillPolygon scanline fill we built last episode -- today we feed transformed points into that same fill;f32 math, @floatFromInt/@intFromFloat, and the comptime-duck-typing we have leaned on since episode 13;Learn Zig Series):Last episode ended with a confession: every shape we can now draw -- lines, circles, ellipses, arbitrary filled polygons -- is pinned to hand-typed pixel coordinates. We wrote the triangle's corners as (2,2), (16,4), (8,17) by hand, and the star's ten vertices with a calculator. That is fine for a demo and hopeless for anything that has to move. The moment you want to slide a sprite across the screen, spin it around its centre, or draw the same arrow at ten sizes, you do not want to recompute vertices by hand -- you want one small, composable operation that transforms a whole bag of points at once. That operation is a matrix multiply, and by the end of today you will have written the whole thing from scratch. Here we go!
Three exercises last time, all pushing the scanline fill a little further. Here are my solutions.
Exercise 1 -- fill, then stroke. The task was to combine last episode's fillPolygonAET with episode 157's drawLine, so a filled polygon gets a crisp border in a second colour. Fill first, then walk the vertex loop drawing an edge between each consecutive pair. The test proves the two colours end up where they should -- a vertex pixel takes the stroke colour, a deep-interior pixel keeps the fill:
const std = @import("std");
const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
const Point = struct { x: i64, y: i64 };
const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
fn setPixel(self: *Framebuffer, x: i64, y: i64, color: Rgba) void {
if (x < 0 or y < 0) return;
const ux: usize = @intCast(x);
const uy: usize = @intCast(y);
if (ux >= self.width or uy >= self.height) return;
self.pixels[uy * self.width + ux] = color;
}
fn getPixel(self: Framebuffer, x: i64, y: i64) ?Rgba {
if (x < 0 or y < 0) return null;
const ux: usize = @intCast(x);
const uy: usize = @intCast(y);
if (ux >= self.width or uy >= self.height) return null;
return self.pixels[uy * self.width + ux];
}
};
fn drawLine(fb: *Framebuffer, x0: i64, y0: i64, x1: i64, y1: i64, color: Rgba) void {
var x = x0;
var y = y0;
const dx: i64 = if (x1 > x0) x1 - x0 else x0 - x1;
const dy: i64 = if (y1 > y0) -(y1 - y0) else -(y0 - y1);
const sx: i64 = if (x0 < x1) 1 else -1;
const sy: i64 = if (y0 < y1) 1 else -1;
var err = dx + dy;
while (true) {
fb.setPixel(x, y, color);
if (x == x1 and y == y1) break;
const e2 = 2 * err;
if (e2 >= dy) {
err += dy;
x += sx;
}
if (e2 <= dx) {
err += dx;
y += sy;
}
}
}
fn fillPolygon(fb: *Framebuffer, points: []const Point, color: Rgba) void {
if (points.len < 3) return;
var min_y = points[0].y;
var max_y = points[0].y;
for (points) |p| {
min_y = @min(min_y, p.y);
max_y = @max(max_y, p.y);
}
var xs: [64]i64 = undefined;
var y = min_y;
while (y <= max_y) : (y += 1) {
var n: usize = 0;
var j: usize = points.len - 1;
var i: usize = 0;
while (i < points.len) : (i += 1) {
const a = points[i];
const b = points[j];
j = i;
const lo = @min(a.y, b.y);
const hi = @max(a.y, b.y);
if (y >= lo and y < hi) {
const lower = if (a.y < b.y) a else b;
const upper = if (a.y < b.y) b else a;
const cross_x = lower.x + @divFloor((y - lower.y) * (upper.x - lower.x), upper.y - lower.y);
if (n < xs.len) {
xs[n] = cross_x;
n += 1;
}
}
}
std.mem.sort(i64, xs[0..n], {}, std.sort.asc(i64));
var k: usize = 0;
while (k + 1 < n) : (k += 2) {
var x = xs[k];
while (x <= xs[k + 1]) : (x += 1) fb.setPixel(x, y, color);
}
}
}
fn strokePolygon(fb: *Framebuffer, points: []const Point, color: Rgba) void {
var j: usize = points.len - 1;
var i: usize = 0;
while (i < points.len) : (i += 1) {
drawLine(fb, points[j].x, points[j].y, points[i].x, points[i].y, color);
j = i;
}
}
test "fill then stroke: interior keeps fill, border takes stroke" {
var pixels: [20 * 20]Rgba = undefined;
var fb = Framebuffer{ .pixels = &pixels, .width = 20, .height = 20 };
@memset(fb.pixels, .{ .r = 0, .g = 0, .b = 0 });
const fill = Rgba{ .r = 0, .g = 0, .b = 255 };
const stroke = Rgba{ .r = 255, .g = 0, .b = 0 };
const tri = [_]Point{ .{ .x = 2, .y = 2 }, .{ .x = 16, .y = 4 }, .{ .x = 8, .y = 17 } };
fillPolygon(&fb, &tri, fill);
strokePolygon(&fb, &tri, stroke);
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(2, 2).?.r); // vertex is stroked (red)
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(8, 8).?.b); // deep interior stays fill (blue)
}
Draw order is the whole trick: fill first so the stroke lands on top, and the border is guaranteed crisp even where the fill's half-open bottom edge came out a row short.
Exercise 2 -- a star, to prove concavity works. Ten vertices, alternating an outer and an inner radius around a centre. I build them with a floating-point angle sweep and round each vertex to the integer grid, then hand the lot to fillPolygon. The test is the real point: a spot inside an arm is lit, a spot in a concave notch between two arms is dark, and the centre is lit -- exactly the shape that separates a genuine even-odd fill from a convex-only shortcut:
const std = @import("std");
const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
const Point = struct { x: i64, y: i64 };
const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
fn setPixel(self: *Framebuffer, x: i64, y: i64, color: Rgba) void {
if (x < 0 or y < 0) return;
const ux: usize = @intCast(x);
const uy: usize = @intCast(y);
if (ux >= self.width or uy >= self.height) return;
self.pixels[uy * self.width + ux] = color;
}
fn getPixel(self: Framebuffer, x: i64, y: i64) ?Rgba {
if (x < 0 or y < 0) return null;
const ux: usize = @intCast(x);
const uy: usize = @intCast(y);
if (ux >= self.width or uy >= self.height) return null;
return self.pixels[uy * self.width + ux];
}
};
fn fillPolygon(fb: *Framebuffer, points: []const Point, color: Rgba) void {
if (points.len < 3) return;
var min_y = points[0].y;
var max_y = points[0].y;
for (points) |p| {
min_y = @min(min_y, p.y);
max_y = @max(max_y, p.y);
}
var xs: [64]i64 = undefined;
var y = min_y;
while (y <= max_y) : (y += 1) {
var n: usize = 0;
var j: usize = points.len - 1;
var i: usize = 0;
while (i < points.len) : (i += 1) {
const a = points[i];
const b = points[j];
j = i;
const lo = @min(a.y, b.y);
const hi = @max(a.y, b.y);
if (y >= lo and y < hi) {
const lower = if (a.y < b.y) a else b;
const upper = if (a.y < b.y) b else a;
const cross_x = lower.x + @divFloor((y - lower.y) * (upper.x - lower.x), upper.y - lower.y);
if (n < xs.len) {
xs[n] = cross_x;
n += 1;
}
}
}
std.mem.sort(i64, xs[0..n], {}, std.sort.asc(i64));
var k: usize = 0;
while (k + 1 < n) : (k += 2) {
var x = xs[k];
while (x <= xs[k + 1]) : (x += 1) fb.setPixel(x, y, color);
}
}
}
fn buildStar(cx: f32, cy: f32, outer: f32, inner: f32, out: *[10]Point) void {
var i: usize = 0;
while (i < 10) : (i += 1) {
const radius = if (i % 2 == 0) outer else inner;
// start at the top (-90 degrees), step by 36 degrees (pi/5) each vertex
const angle = -std.math.pi / 2.0 + @as(f32, @floatFromInt(i)) * std.math.pi / 5.0;
const x = cx + radius * @cos(angle);
const y = cy + radius * @sin(angle);
out[i] = .{ .x = @intFromFloat(@round(x)), .y = @intFromFloat(@round(y)) };
}
}
test "filled five-pointed star: arm lit, notch dark, centre lit" {
var pixels: [41 * 41]Rgba = undefined;
var fb = Framebuffer{ .pixels = &pixels, .width = 41, .height = 41 };
@memset(fb.pixels, .{ .r = 0, .g = 0, .b = 0 });
const white = Rgba{ .r = 255, .g = 255, .b = 255 };
var star: [10]Point = undefined;
buildStar(20, 20, 18, 7, &star);
fillPolygon(&fb, &star, white);
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(20, 20).?.r); // centre, always inside
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(20, 6).?.r); // up the top arm, inside
try std.testing.expectEqual(@as(u8, 0), fb.getPixel(27, 10).?.r); // notch between two arms, outside
try std.testing.expectEqual(@as(u8, 0), fb.getPixel(2, 2).?.r); // far corner, outside
}
The notch pixel (27,10) sits outside the star even though it is well inside the bounding box -- an even-odd fill gets that right for free, because the ray from that point crosses the outline an even number of times.
Exercise 3 -- the nonzero winding rule. Even-odd flips inside/outside on every crossing and ignores direction. The winding rule instead gives each edge a sign -- +1 for a downward edge, -1 for an upward one -- and a point is inside when the running sum of signs is not zero. To see the difference you need two contours, so I generalise the fill to take a slice of contours and test with an outer square plus a nested inner square. Wind the inner square the same way as the outer and the winding rule fills the hole (count reaches 2); wind it the opposite way and the counts cancel to 0, leaving the hole empty -- a distinction even-odd simply cannot make:
const std = @import("std");
const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
const Point = struct { x: i64, y: i64 };
const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
fn setPixel(self: *Framebuffer, x: i64, y: i64, color: Rgba) void {
if (x < 0 or y < 0) return;
const ux: usize = @intCast(x);
const uy: usize = @intCast(y);
if (ux >= self.width or uy >= self.height) return;
self.pixels[uy * self.width + ux] = color;
}
fn getPixel(self: Framebuffer, x: i64, y: i64) ?Rgba {
if (x < 0 or y < 0) return null;
const ux: usize = @intCast(x);
const uy: usize = @intCast(y);
if (ux >= self.width or uy >= self.height) return null;
return self.pixels[uy * self.width + ux];
}
};
const Crossing = struct { x: i64, dir: i32 };
fn lessCrossing(_: void, a: Crossing, b: Crossing) bool {
return a.x < b.x;
}
fn fillContoursWinding(fb: *Framebuffer, contours: []const []const Point, color: Rgba) void {
var min_y: i64 = std.math.maxInt(i64);
var max_y: i64 = std.math.minInt(i64);
for (contours) |c| {
for (c) |p| {
min_y = @min(min_y, p.y);
max_y = @max(max_y, p.y);
}
}
var xs: [64]Crossing = undefined;
var y = min_y;
while (y <= max_y) : (y += 1) {
var n: usize = 0;
for (contours) |c| {
if (c.len < 2) continue;
var j: usize = c.len - 1;
var i: usize = 0;
while (i < c.len) : (i += 1) {
const a = c[i];
const b = c[j];
j = i;
const lo = @min(a.y, b.y);
const hi = @max(a.y, b.y);
if (y >= lo and y < hi) {
const lower = if (a.y < b.y) a else b;
const upper = if (a.y < b.y) b else a;
const cross_x = lower.x + @divFloor((y - lower.y) * (upper.x - lower.x), upper.y - lower.y);
const dir: i32 = if (a.y < b.y) 1 else -1; // downward edge counts +1
if (n < xs.len) {
xs[n] = .{ .x = cross_x, .dir = dir };
n += 1;
}
}
}
}
std.mem.sort(Crossing, xs[0..n], {}, lessCrossing);
var winding: i32 = 0;
var k: usize = 0;
while (k + 1 < n) : (k += 1) {
winding += xs[k].dir;
if (winding != 0) { // inside means "wound", not merely "an odd crossing count"
var x = xs[k].x;
while (x <= xs[k + 1].x) : (x += 1) fb.setPixel(x, y, color);
}
}
}
}
test "winding rule: same direction fills the hole, opposite direction empties it" {
const outer = [_]Point{ .{ .x = 2, .y = 2 }, .{ .x = 18, .y = 2 }, .{ .x = 18, .y = 18 }, .{ .x = 2, .y = 18 } };
const inner_same = [_]Point{ .{ .x = 7, .y = 7 }, .{ .x = 13, .y = 7 }, .{ .x = 13, .y = 13 }, .{ .x = 7, .y = 13 } };
const inner_opp = [_]Point{ .{ .x = 7, .y = 7 }, .{ .x = 7, .y = 13 }, .{ .x = 13, .y = 13 }, .{ .x = 13, .y = 7 } };
var pa: [20 * 20]Rgba = undefined;
var a = Framebuffer{ .pixels = &pa, .width = 20, .height = 20 };
@memset(a.pixels, .{ .r = 0, .g = 0, .b = 0 });
const white = Rgba{ .r = 255, .g = 255, .b = 255 };
fillContoursWinding(&a, &.{ &outer, &inner_same }, white);
try std.testing.expectEqual(@as(u8, 255), a.getPixel(10, 10).?.r); // same winding => hole filled
var pb: [20 * 20]Rgba = undefined;
var b = Framebuffer{ .pixels = &pb, .width = 20, .height = 20 };
@memset(b.pixels, .{ .r = 0, .g = 0, .b = 0 });
fillContoursWinding(&b, &.{ &outer, &inner_opp }, white);
try std.testing.expectEqual(@as(u8, 0), b.getPixel(10, 10).?.r); // opposite winding => hole empty
}
That is the whole reason SVG exposes both fill-rule="nonzero" (the default) and fill-rule="evenodd": they disagree exactly when contours nest, and a font's counters -- the hole in an o, the two holes in a B -- rely on getting that disagreement right.
Look at the three things you actually want to do to a shape. Translate it: add (tx, ty) to every point. Scale it: multiply every point by (sx, sy). Rotate it by angle t: the classic x' = x*cos(t) - y*sin(t), y' = x*sin(t) + y*cos(t). Two of those three are matrix multiplications already -- scale and rotate are linear, they can be written as a 2x2 matrix times the point. But translation is stubborn: adding a constant is not a linear operation, so it will not fit in the same 2x2 box. And if translate lives in a different world from rotate and scale, you cannot compose them uniformly, which defeats the whole purpose.
The fix is a genuinely beautiful trick called homogeneous coordinates: pretend every 2D point (x, y) is really the 3D point (x, y, 1). That extra 1 gives translation somewhere to hide. A 3x3 matrix whose bottom row is {0, 0, 1} can now express translate, scale, rotate and any combination of them, all as the same matrix * point multiply. Here is the type, with identity and translation to start -- and apply, which treats the point's third coordinate as an implicit 1:
const std = @import("std");
pub const Vec2 = struct { x: f32, y: f32 };
pub const Mat3 = struct {
// row-major 3x3; for an affine 2D transform the bottom row is always {0, 0, 1}
m: [3][3]f32,
pub fn identity() Mat3 {
return .{ .m = .{
.{ 1, 0, 0 },
.{ 0, 1, 0 },
.{ 0, 0, 1 },
} };
}
pub fn translation(tx: f32, ty: f32) Mat3 {
return .{ .m = .{
.{ 1, 0, tx },
.{ 0, 1, ty },
.{ 0, 0, 1 },
} };
}
// transform a point, treating its third homogeneous coordinate as an implicit 1
pub fn apply(self: Mat3, p: Vec2) Vec2 {
return .{
.x = self.m[0][0] * p.x + self.m[0][1] * p.y + self.m[0][2],
.y = self.m[1][0] * p.x + self.m[1][1] * p.y + self.m[1][2],
};
}
};
test "identity leaves a point alone; translation slides it" {
const p = Vec2{ .x = 3, .y = 4 };
const same = Mat3.identity().apply(p);
try std.testing.expectEqual(@as(f32, 3), same.x);
try std.testing.expectEqual(@as(f32, 4), same.y);
const moved = Mat3.translation(10, -2).apply(p);
try std.testing.expectEqual(@as(f32, 13), moved.x);
try std.testing.expectEqual(@as(f32, 2), moved.y);
}
Why f32 and not the i64 we have used for pixels all series? Because rotation and scale produce fractional coordinates, and forcing them to integers mid-calculation throws away accuracy that compounds badly once you compose transforms. We keep the whole transform pipeline in floating point and round to integers only at the very end, right before setPixel -- the same discipline as computing then rounding, never rounding then computing.
Now the other three constructors and the operation that ties them together: the matrix product. Scaling puts the factors on the diagonal, rotation is the 2x2 rotation block lifted into the top-left corner, and mul is the textbook triple loop -- row i of the left matrix dotted with column j of the right:
const std = @import("std");
pub const Vec2 = struct { x: f32, y: f32 };
pub const Mat3 = struct {
m: [3][3]f32,
pub fn identity() Mat3 {
return .{ .m = .{ .{ 1, 0, 0 }, .{ 0, 1, 0 }, .{ 0, 0, 1 } } };
}
pub fn translation(tx: f32, ty: f32) Mat3 {
return .{ .m = .{ .{ 1, 0, tx }, .{ 0, 1, ty }, .{ 0, 0, 1 } } };
}
pub fn scaling(sx: f32, sy: f32) Mat3 {
return .{ .m = .{ .{ sx, 0, 0 }, .{ 0, sy, 0 }, .{ 0, 0, 1 } } };
}
pub fn rotation(radians: f32) Mat3 {
const c = @cos(radians);
const s = @sin(radians);
return .{ .m = .{ .{ c, -s, 0 }, .{ s, c, 0 }, .{ 0, 0, 1 } } };
}
// matrix product: self * other. Applying (self.mul(other)) means "do other, then self".
pub fn mul(self: Mat3, other: Mat3) Mat3 {
var r: Mat3 = .{ .m = undefined };
for (0..3) |i| {
for (0..3) |j| {
var sum: f32 = 0;
for (0..3) |k| sum += self.m[i][k] * other.m[k][j];
r.m[i][j] = sum;
}
}
return r;
}
pub fn apply(self: Mat3, p: Vec2) Vec2 {
return .{
.x = self.m[0][0] * p.x + self.m[0][1] * p.y + self.m[0][2],
.y = self.m[1][0] * p.x + self.m[1][1] * p.y + self.m[1][2],
};
}
};
test "a quarter turn sends the x-axis unit vector onto the y-axis" {
const rot = Mat3.rotation(std.math.pi / 2.0);
const p = rot.apply(.{ .x = 1, .y = 0 });
try std.testing.expectApproxEqAbs(@as(f32, 0), p.x, 1e-6);
try std.testing.expectApproxEqAbs(@as(f32, 1), p.y, 1e-6);
}
test "scale composes with translate as a single matrix" {
// scale by 2 about the origin, THEN translate by (5, 5)
const t = Mat3.translation(5, 5).mul(Mat3.scaling(2, 2));
const p = t.apply(.{ .x = 3, .y = 4 });
try std.testing.expectApproxEqAbs(@as(f32, 11), p.x, 1e-6); // 3*2 + 5
try std.testing.expectApproxEqAbs(@as(f32, 13), p.y, 1e-6); // 4*2 + 5
}
Read that mul order carefully, because it is the thing people get wrong for years. A.mul(B) produces the matrix that, applied to a point, does B first and A second -- because (A*B)*p = A*(B*p). So "scale then translate" is translation(...).mul(scaling(...)), translate on the left. Matrix multiplication is not commutative, and that is not a mathematical annoyance -- it is the whole reason matrices are useful. Rotating a shape and then sliding it right lands somewhere completely different from sliding it right and then rotating (the second spins it around the origin, dragging it in a big arc). The matrix product lets you bake whichever sequence you mean into one object and reuse it.
Here is where Zig's flavour shows through. Notice what Mat3 is: a struct wrapping a [3][3]f32, nine floats, fixed size, no pointer, no allocation, no hidden state. Every constructor returns one by value, mul returns one by value, and the optimiser is free to keep the whole thing in registers. There is no failure mode in any of this code, so none of it is !something -- and that honesty runs both ways. The instant we do something that can fail, the type has to say so.
Transforming a whole polygon into a fresh buffer is exactly that moment: we ask the allocator for points.len output slots, and allocation can fail. So the function is !void, the caller must acknowledge it, and defer is not needed here because we hand the buffer back to the caller -- but the try on the allocation is mandatory. We round to integer Point only at the boundary, exactly once:
const std = @import("std");
pub const Vec2 = struct { x: f32, y: f32 };
pub const Point = struct { x: i64, y: i64 };
pub const Mat3 = struct {
m: [3][3]f32,
pub fn identity() Mat3 {
return .{ .m = .{ .{ 1, 0, 0 }, .{ 0, 1, 0 }, .{ 0, 0, 1 } } };
}
pub fn rotation(radians: f32) Mat3 {
const c = @cos(radians);
const s = @sin(radians);
return .{ .m = .{ .{ c, -s, 0 }, .{ s, c, 0 }, .{ 0, 0, 1 } } };
}
pub fn apply(self: Mat3, p: Vec2) Vec2 {
return .{
.x = self.m[0][0] * p.x + self.m[0][1] * p.y + self.m[0][2],
.y = self.m[1][0] * p.x + self.m[1][1] * p.y + self.m[1][2],
};
}
};
/// Transform each point and round to the pixel grid. Caller owns the returned slice.
pub fn transformPoints(
allocator: std.mem.Allocator,
t: Mat3,
points: []const Vec2,
) ![]Point {
const out = try allocator.alloc(Point, points.len);
errdefer allocator.free(out); // if a later step failed we would not leak; here it is a safety net
for (points, 0..) |p, i| {
const q = t.apply(p);
out[i] = .{ .x = @intFromFloat(@round(q.x)), .y = @intFromFloat(@round(q.y)) };
}
return out;
}
test "transformPoints rounds at the boundary and the caller frees" {
const square = [_]Vec2{
.{ .x = 0, .y = 0 }, .{ .x = 2, .y = 0 },
.{ .x = 2, .y = 2 }, .{ .x = 0, .y = 2 },
};
const out = try transformPoints(std.testing.allocator, Mat3.identity(), &square);
defer std.testing.allocator.free(out);
try std.testing.expectEqual(@as(i64, 2), out[1].x);
try std.testing.expectEqual(@as(i64, 2), out[2].y);
}
The shape of that signature is the lesson. The pure matrix math cannot fail and does not pretend it might; the buffer step can fail and says so in its return type; the f32-to-i64 conversion is a deliberate @intFromFloat(@round(...)) in exactly one place, not scattered rounding that quietly drifts. That is the same "no hidden control flow, the cost visible in the type" philosophy we met with the scanline fill's !void last episode -- here applied to geometry instead of rasterisation.
Transform code fails in quiet, plausible-looking ways -- a sign flipped in the rotation, mul written in the wrong order, a shape that is almost back where it started. The tests that catch these are the algebraic identities the math promises. Three pull the most weight: identity does nothing, an inverse round-trip returns you home, and order matters (composing in the wrong sequence gives a provably different answer):
const std = @import("std");
pub const Vec2 = struct { x: f32, y: f32 };
pub const Mat3 = struct {
m: [3][3]f32,
pub fn identity() Mat3 {
return .{ .m = .{ .{ 1, 0, 0 }, .{ 0, 1, 0 }, .{ 0, 0, 1 } } };
}
pub fn translation(tx: f32, ty: f32) Mat3 {
return .{ .m = .{ .{ 1, 0, tx }, .{ 0, 1, ty }, .{ 0, 0, 1 } } };
}
pub fn scaling(sx: f32, sy: f32) Mat3 {
return .{ .m = .{ .{ sx, 0, 0 }, .{ 0, sy, 0 }, .{ 0, 0, 1 } } };
}
pub fn rotation(radians: f32) Mat3 {
const c = @cos(radians);
const s = @sin(radians);
return .{ .m = .{ .{ c, -s, 0 }, .{ s, c, 0 }, .{ 0, 0, 1 } } };
}
pub fn mul(self: Mat3, other: Mat3) Mat3 {
var r: Mat3 = .{ .m = undefined };
for (0..3) |i| {
for (0..3) |j| {
var sum: f32 = 0;
for (0..3) |k| sum += self.m[i][k] * other.m[k][j];
r.m[i][j] = sum;
}
}
return r;
}
pub fn apply(self: Mat3, p: Vec2) Vec2 {
return .{
.x = self.m[0][0] * p.x + self.m[0][1] * p.y + self.m[0][2],
.y = self.m[1][0] * p.x + self.m[1][1] * p.y + self.m[1][2],
};
}
};
fn expectClose(a: Vec2, x: f32, y: f32) !void {
try std.testing.expectApproxEqAbs(x, a.x, 1e-5);
try std.testing.expectApproxEqAbs(y, a.y, 1e-5);
}
test "inverse round-trip: rotate then rotate back returns the original point" {
const p = Vec2{ .x = 5, .y = -3 };
const there = Mat3.rotation(0.7);
const back = Mat3.rotation(-0.7);
const round = back.mul(there).apply(p); // apply 'there', then 'back'
try expectClose(round, 5, -3);
}
test "scale-inverse round-trip returns home" {
const p = Vec2{ .x = 8, .y = 12 };
const round = Mat3.scaling(0.25, 0.25).mul(Mat3.scaling(4, 4)).apply(p);
try expectClose(round, 8, 12);
}
test "order matters: rotate-then-translate differs from translate-then-rotate" {
const p = Vec2{ .x = 1, .y = 0 };
const rt = Mat3.translation(10, 0).mul(Mat3.rotation(std.math.pi / 2.0)); // rotate first
const tr = Mat3.rotation(std.math.pi / 2.0).mul(Mat3.translation(10, 0)); // translate first
const a = rt.apply(p);
const b = tr.apply(p);
try expectClose(a, 10, 1); // rotate (1,0)->(0,1), then shift x by 10
try expectClose(b, 0, 11); // shift (1,0)->(11,0), then rotate about origin
try std.testing.expect(a.x != b.x or a.y != b.y);
}
That last test is the one I would fight to keep. It does not just check a number -- it encodes the single most important fact about transforms as an executable assertion, so that if someone "cleans up" mul into the wrong order six months from now, the build goes red and tells them precisely which law they broke. Tests as documentation, exactly as we argued back in episode 12.
The naive way to rotate a hundred-vertex shape is to call @cos and @sin inside the per-point loop. That is wasteful -- the angle does not change per point, so the trig is loop-invariant. The whole point of the matrix is that it bakes the trig in once: build the Mat3 a single time, then every point costs four multiplies and a couple of adds, no transcendental functions at all. And because a Mat3 is nine flat floats with no indirection, a batch transform is a tight, cache-friendly, auto-vectorisable loop:
const std = @import("std");
pub const Vec2 = struct { x: f32, y: f32 };
pub const Mat3 = struct {
m: [3][3]f32,
pub fn rotation(radians: f32) Mat3 {
const c = @cos(radians);
const s = @sin(radians);
return .{ .m = .{ .{ c, -s, 0 }, .{ s, c, 0 }, .{ 0, 0, 1 } } };
}
pub fn apply(self: Mat3, p: Vec2) Vec2 {
return .{
.x = self.m[0][0] * p.x + self.m[0][1] * p.y + self.m[0][2],
.y = self.m[1][0] * p.x + self.m[1][1] * p.y + self.m[1][2],
};
}
};
// build the transform ONCE, then apply it in a flat loop over the batch
pub fn transformInPlace(t: Mat3, points: []Vec2) void {
for (points) |*p| p.* = t.apply(p.*);
}
test "one matrix, whole batch: every point rotated by the same quarter turn" {
var pts = [_]Vec2{
.{ .x = 1, .y = 0 }, .{ .x = 0, .y = 1 },
.{ .x = 2, .y = 0 }, .{ .x = 0, .y = 3 },
};
transformInPlace(Mat3.rotation(std.math.pi / 2.0), &pts);
try std.testing.expectApproxEqAbs(@as(f32, 0), pts[0].x, 1e-6);
try std.testing.expectApproxEqAbs(@as(f32, 1), pts[0].y, 1e-6);
try std.testing.expectApproxEqAbs(@as(f32, -3), pts[2 + 1].x, 1e-6); // (0,3) -> (-3,0)
try std.testing.expectApproxEqAbs(@as(f32, 0), pts[3].y, 1e-6);
}
There is a second, bigger win hiding here. If a shape needs to be rotated and scaled and translated, do not run three passes over the points -- multiply the three matrices into one combined Mat3 first, then make a single pass. Three matrix multiplies (nine floats each, done once) plus one cheap loop beats three loops over thousands of points every time. Having said that -- and you knew this was coming -- do not reach for @Vector SIMD or hand-rolled tricks until a profiler says this loop is hot. Get it correct, keep the matrix product out of the inner loop, and measure before you optimise. That is the exact lesson from episode 34, and it has not changed.
This little Mat3 is the same object sitting under an enormous amount of software. Every scene graph in every game engine is a tree of these: a turret's transform is stored relative to its tank, the tank's relative to the world, and you get the turret's final world position by multiplying transforms up the chain -- exactly world.mul(tank).mul(turret). Every SVG transform="translate(10,20) rotate(45) scale(2)" is parsed into precisely this matrix, composed left to right. Every 2D camera is just an inverse transform: instead of moving the whole world, you move the view, and "scroll the map left" is a translation applied to everything you draw. Canvas, the DOM's CSSMatrix, the sprite batchers in every 2D framework -- all of them are storing and multiplying 3x3 (or, in 3D, 4x4) matrices exactly like the one you just built. Learn this one type and you have learned the spine of 2D and 3D graphics; the 3D version is the same idea with one more coordinate.
The arithmetic is language-neutral, so the interesting difference is, once again, the memory and safety story. In C you get a bare float[3][3], pass it around by pointer or by struct copy, and there is nothing stopping you reading a fourth row that does not exist:
// C: a plain struct of nine floats, no bounds guarantees on the indices
typedef struct { float m[3][3]; } Mat3;
Mat3 mat3_mul(Mat3 a, Mat3 b) {
Mat3 r;
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++) {
float sum = 0.0f;
for (int k = 0; k < 3; k++)
sum += a.m[i][k] * b.m[k][j];
r.m[i][j] = sum;
}
return r;
}
void mat3_apply(Mat3 t, float x, float y, float *ox, float *oy) {
*ox = t.m[0][0]*x + t.m[0][1]*y + t.m[0][2];
*oy = t.m[1][0]*x + t.m[1][1]*y + t.m[1][2];
}
That is our Zig code with the guard rails removed: identical FLOP count, but an out-of-range m[3][j] is undefined behaviour rather than a checked panic. Rust writes almost the same struct, gets bounds-checked indexing that panics instead of corrupting, and in practice pulls in glam, nalgebra or cgmath -- SIMD-accelerated linear-algebra crates that give you Mat3, Affine2 and operator-overloaded * for composition out of the box:
// Rust: with the glam crate this is a one-liner; composition is just `*`
use glam::{Affine2, Vec2};
fn place(angle: f32, tx: f32, ty: f32, p: Vec2) -> Vec2 {
let t = Affine2::from_translation(Vec2::new(tx, ty))
* Affine2::from_angle(angle);
t.transform_point2(p)
}
Go has no operator overloading, so a matrix library exposes methods you chain, much like our .mul(...) -- gonum or a small hand-rolled Mat3 with a Mul method, values copied by default since a [3][3]float64 is a plain array:
// Go: methods instead of operators; array values copy by default
type Mat3 [3][3]float64
func (a Mat3) Mul(b Mat3) Mat3 {
var r Mat3
for i := 0; i < 3; i++ {
for j := 0; j < 3; j++ {
for k := 0; k < 3; k++ {
r[i][j] += a[i][k] * b[k][j]
}
}
}
return r
}
The through-line is the one we keep meeting: everybody computes the identical nine dot products, and they differ only in what happens when an index escapes the matrix, and in how much of a library ecosystem they hand you. Zig gives you C's flat-struct speed with a checked setPixel at the boundary and no surprise allocations anywhere in the transform itself.
A shear constructor. Add pub fn shear(kx: f32, ky: f32) Mat3 to Mat3 -- a shear puts kx and ky in the off-diagonal positions of the linear block (m[0][1] = kx, m[1][0] = ky). Write a test proving that shearing a unit square turns it into a parallelogram (a corner that was at (0,1) moves by kx in x), and that shear(0,0) equals the identity.
Rotate about an arbitrary pivot. A raw rotation spins around the origin, but you almost always want to spin a shape around its own centre (cx, cy). Build that as a composition: translate the pivot to the origin, rotate, translate back -- translation(cx,cy).mul(rotation(t)).mul(translation(-cx,-cy)). Wrap it in pub fn rotationAbout(cx: f32, cy: f32, radians: f32) Mat3 and test that the pivot point itself does not move, while a point beside it swings around.
Draw a spinning shape. Combine today's transformPoints with last episode's fillPolygon: define a triangle in Vec2 model coordinates centred on the origin, then in a loop build rotationAbout(cx, cy, angle) for a handful of angles, transform the triangle into Points, and fill it into a fresh framebuffer each time. Assert that the filled pixel count stays roughly constant across angles (a rotation preserves area, so a correct transform should not make the shape grow or shrink -- a great smoke test for a flipped sign).
1 -- let translation, scaling and rotation all become the same thing: a 3x3 matrix multiply;Mat3 is nine flat f32s with no allocation and no failure mode, so its constructors and mul are plain value-returning functions; the instant we allocate an output buffer the type honestly becomes !something;A.mul(B) applies B first and A second, matrix multiplication does not commute, and composing in the right sequence is the whole reason matrices beat ad-hoc point math;transform, 2D cameras and every sprite batcher are storing and multiplying exactly this matrix; C, Rust and Go compute the identical nine dot products and differ only in safety and ecosystem.We can now describe where a shape goes as cleanly as we describe what it looks like -- geometry and motion, finally decoupled. But notice something our fills and lines still cannot do: every pixel we have written has been fully opaque, stamping right over whatever was underneath. Real graphics are built in layers that see through each other -- a shadow that darkens without hiding, a highlight that glows over the surface below. To draw that, a pixel has to be able to mix with the one already there, and that means finally taking the a in our Rgba seriously. That blending -- how transparency actually works, pixel by pixel -- is where we head next. Keep the Mat3 handy; the things we transform are about to start layering on top of each other.
Thanks for reading, and see you in the next one! ;-)