⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1516+8a4b5424dWhat's newOn an older Zig?

Transforms

The scene graph stored two integers per node and admitted the limit in passing: two integers only let a node move. A real one carries a matrix, so a node can turn and resize as well, and composing a parent with a child becomes a multiply. This is that matrix.

The program

const std = @import("std");

const width: usize = 58;
const height: usize = 17;

var framebuffer: [width * height]u8 = undefined;

/// A 2D affine transform: the top two rows of a 3x3 matrix.
///
///     | a c e |     x' = a*x + c*y + e
///     | b d f |     y' = b*x + d*y + f
///     | 0 0 1 |
///
/// The bottom row is always `0 0 1` for an affine transform, so storing it
/// would be storing a constant. Six numbers hold every combination of moving,
/// turning, scaling and shearing there is.
const Transform = struct {
    a: f32 = 1,
    b: f32 = 0,
    c: f32 = 0,
    d: f32 = 1,
    e: f32 = 0,
    f: f32 = 0,

    const identity: Transform = .{};

    fn translate(dx: f32, dy: f32) Transform {
        return .{ .e = dx, .f = dy };
    }

    fn scale(sx: f32, sy: f32) Transform {
        return .{ .a = sx, .d = sy };
    }

    fn rotate(radians: f32) Transform {
        const cos = @cos(radians);
        const sin = @sin(radians);
        return .{ .a = cos, .b = sin, .c = -sin, .d = cos };
    }

    /// `self.then(next)` is the transform that applies `self` first. Written
    /// as matrices that is `next * self`, because a point is a column on the
    /// right and the matrix nearest it acts first. Reading a chain left to
    /// right in the order things happen is the reason for the name.
    fn then(self: Transform, next: Transform) Transform {
        return .{
            .a = next.a * self.a + next.c * self.b,
            .b = next.b * self.a + next.d * self.b,
            .c = next.a * self.c + next.c * self.d,
            .d = next.b * self.c + next.d * self.d,
            .e = next.a * self.e + next.c * self.f + next.e,
            .f = next.b * self.e + next.d * self.f + next.f,
        };
    }

    fn apply(self: Transform, x: f32, y: f32) struct { x: f32, y: f32 } {
        return .{
            .x = self.a * x + self.c * y + self.e,
            .y = self.b * x + self.d * y + self.f,
        };
    }
};

/// The shape, in its own coordinates. An arrow, because it has no symmetry:
/// a rotated square looks like a square, which proves nothing.
const shape = [_][2]f32{
    .{ 0, -3 },
    .{ 2, 0 },
    .{ 1, 0 },
    .{ 1, 3 },
    .{ -1, 3 },
    .{ -1, 0 },
    .{ -2, 0 },
};

fn clear() void {
    @memset(&framebuffer, ' ');
}

fn plot(x: i32, y: i32, ink: u8) void {
    if (x < 0 or y < 0) return;
    const ux: usize = @intCast(x);
    const uy: usize = @intCast(y);
    if (ux >= width or uy >= height) return;
    framebuffer[uy * width + ux] = ink;
}

/// Bresenham, so an edge of the shape is drawn rather than sampled.
fn line(x0: i32, y0: i32, x1: i32, y1: i32, ink: u8) void {
    var x = x0;
    var y = y0;
    const dx: i32 = @intCast(@abs(x1 - x0));
    const dy: i32 = -@as(i32, @intCast(@abs(y1 - y0)));
    const sx: i32 = if (x0 < x1) 1 else -1;
    const sy: i32 = if (y0 < y1) 1 else -1;
    var err = dx + dy;

    while (true) {
        plot(x, y, ink);
        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;
        }
    }
}

/// A character cell is about twice as tall as it is wide, so the last thing
/// done to every point is a non-uniform scale that undoes it. Composing it here
/// rather than multiplying by two at the call site is the practical reason to
/// have `then` at all: the transforms above stay in square units, where a
/// rotation is a rotation, and the display's odd aspect is one matrix applied
/// after everything else.
const aspect = Transform.scale(2, 1);

/// Transform every point, then draw the outline through the results. The shape
/// itself is never modified: it is one array, drawn through different matrices.
fn draw(t: Transform, ink: u8) void {
    const view = t.then(aspect);
    for (shape, 0..) |point, i| {
        const next = shape[(i + 1) % shape.len];
        const p = view.apply(point[0], point[1]);
        const q = view.apply(next[0], next[1]);
        line(
            @intFromFloat(@round(p.x)),
            @intFromFloat(@round(p.y)),
            @intFromFloat(@round(q.x)),
            @intFromFloat(@round(q.y)),
            ink,
        );
    }
}

fn dump(out: *std.Io.Writer) !void {
    for (0..height) |y| {
        const row = framebuffer[y * width ..][0..width];
        try out.writeAll(std.mem.trimEnd(u8, row, " "));
        try out.writeByte('\n');
    }
}

const quarter_turn: f32 = std.math.pi / 2.0;

pub fn main(init: std.process.Init) !void {
    var buf: [8192]u8 = undefined;
    var file_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &file_writer.interface;

    // Rotate then move, against move then rotate. Same quarter turn, same
    // offset, composed in the two possible orders. Both are then shifted by the
    // same amount so each lands on the canvas, which changes where the pair
    // sits but not how far apart they are.
    const view = Transform.translate(13, 6);
    const spin_then_move = Transform.rotate(quarter_turn)
        .then(Transform.translate(7, 0))
        .then(view);
    const move_then_spin = Transform.translate(7, 0)
        .then(Transform.rotate(quarter_turn))
        .then(view);

    clear();
    draw(spin_then_move, '#');
    draw(move_then_spin, 'o');
    try out.writeAll("# rotate then move, o move then rotate\n");
    try dump(out);

    // Turning a shape about a point that is not the origin is three transforms:
    // bring the point to the origin, turn, put it back. There is no rotate
    // that takes a centre, and this is the reason none is needed.
    const pivot_x: f32 = 16;
    const pivot_y: f32 = 7;
    clear();
    for ([_]f32{ 0, 1, 2 }, "123") |steps, ink| {
        const about_pivot = Transform.translate(-pivot_x, -pivot_y)
            .then(Transform.rotate(quarter_turn * steps))
            .then(Transform.translate(pivot_x, pivot_y));
        // Placed five units to the right of the pivot, then turned about it,
        // so the arrow orbits rather than spinning where it stands.
        draw(Transform.translate(pivot_x + 5, pivot_y).then(about_pivot), ink);
    }
    plot(@intFromFloat(pivot_x * 2), @intFromFloat(pivot_y), '+');
    try out.writeAll("\n+ is the pivot; 1, 2 and 3 are quarter turns about it\n");
    try dump(out);

    // Scale lives in the same six numbers and composes the same way.
    clear();
    draw(Transform.scale(1, 1).then(Transform.translate(5, 8)), '.');
    draw(Transform.scale(1.6, 1.6).then(Transform.translate(13, 8)), '+');
    draw(Transform.scale(2.2, 2.2).then(Transform.translate(23, 8)), '#');
    try out.writeAll("\nthe same seven points at three scales\n");
    try dump(out);

    try out.flush();
}

What just happened

Six numbers cover every case. Moving, turning, scaling and shearing are not four features. They are four ways of filling in the same six slots, and a routine that applies a transform does not know which of them it was handed.

Order changes the answer. The two arrows in the first picture come from the same quarter turn and the same offset, composed both ways round. Rotation is about the origin, so moving first means turning the offset too, and the shape travels a quarter circle to somewhere else entirely. Matrix multiplication is not commutative, and this is what that sentence looks like.

Turning about a point is three transforms. There is no rotate that takes a centre. Move the pivot to the origin, turn, move it back, and the composition of those three is a single matrix costing exactly what one rotation costs. The arrows numbered 1, 2 and 3 orbit the + without any of them containing an orbit.

The shape is never modified. shape is one array of seven points, const, drawn eleven times across the three pictures. Every difference is in the matrix.

Reading a composition

The awkward part of transforms in practice is that the conventional way to write them runs backwards from the order things happen. As matrices, applying A and then B to a column vector is B * A * p: the one nearest the point acts first, so the chain reads right to left.

then exists to hide that. a.then(b) means a first, and internally it computes b * a. The reversal happens once, in one function, rather than in the head of everyone reading the call:

const about_pivot = Transform.translate(-pivot_x, -pivot_y)
    .then(Transform.rotate(quarter_turn * steps))
    .then(Transform.translate(pivot_x, pivot_y));

That is three operations in the order they occur, and it is the same matrix a graphics text would write as T * R * T⁻¹.

The display’s aspect ratio is composed the same way. A character cell is about twice as tall as it is wide, so every point gets a final non-uniform scale of (2, 1). Keeping it out of the transforms above is why a rotation in this program is a rotation: the shape is reasoned about in square units and squashed once, at the end, by a matrix like any other.

What the bottom row is for

The matrix is 3x3 but only six numbers are stored. The missing row is always 0 0 1, and storing a constant would be storing a constant.

It exists because translation is not a linear operation. A 2x2 matrix multiply can rotate, scale and shear, and every one of those leaves the origin where it is. Nothing built from a, b, c and d alone can move a point away from the origin. Adding a third coordinate fixed at 1 gives translation a column to live in, and e and f are that column: they are multiplied by the 1 and added, which is exactly what apply does.

That is the whole trick behind homogeneous coordinates. The payoff is not elegance, it is that translation joins the others as a multiply, so a chain of moves, turns and scales collapses into one matrix that is applied once per point instead of a pipeline walked per point.

Perspective is the same idea taken one step further: let the bottom row hold something other than 0 0 1, divide by the third coordinate afterwards, and the division is where the far end of a wall gets smaller. That division is also exactly what the trapezoid in texture mapping was missing.

Check yourself

then builds a new matrix by multiplying. Why not skip it and apply each transform to the points in turn?

For seven points it makes no difference. The reason is what happens when there are seven thousand, or when the chain is deep: composing costs one multiply per transform, applying costs one per point. Fold the chain first and every point pays for one matrix regardless of how many operations went into it, which is the same reason the scene graph accumulates an offset on the way down the tree instead of re-walking from the root at every node.

The second reason is accuracy. Each application rounds, so applying five transforms in turn rounds five times per point. Composing rounds once while building the matrix, and the points are transformed once.

If you have written C

The struct is the six-float affine matrix from every 2D graphics API you have met: cairo_matrix_t, Direct2D’s D2D1_MATRIX_3X2_F, the six arguments to the web canvas setTransform, and Java’s AffineTransform. Field order and naming differ, and it is always these six.

Two differences in the Zig. @cos and @sin are builtins rather than libc calls, so this snippet has no dependency to link and runs unchanged in a browser, which is why the program above is running in this page.

And the default field values in the struct declaration are doing real work. identity is .{}, and each constructor sets only the fields it cares about: translate writes e and f and the other four come back as the identity’s 1, 0, 0, 1. In C those are four more assignments per constructor, and forgetting one is the classic bug where a translate quietly zeroes the scale.

Next: the same buffer, handed to a display server rather than to a file.