DoubleBuffer type written from scratch in Zig, index-based (no self-referential pointers), with an O(1) swap that costs nothing per pixel;Pacer;errdefer and the "functional core, imperative shell" split keep the timing logic testable without ever sleeping in a test;SwapBuffers, the compositor) and how C, Rust and Go write the same swap.Framebuffer, Rgba and clipping setPixel/getPixel from episode 156, plus the fillPolygon scanline fill and the Mat3 transforms we built over the last few episodes -- today we animate the shapes those produced;errdefer and defer (episodes 7 and 26), and the testing habits from episode 12;Learn Zig Series):The last exercise of episode 160 asked you to draw a spinning triangle: build a rotationAbout matrix for a handful of angles, transform the shape, and fill it into a fresh framebuffer each time. Do that, then imagine actually showing each of those frames on a real screen one after another, and you would meet a very old, very rude surprise -- the shape would flicker like a broken fluorescent tube, and depending on your timing it would also tear, showing you the top half of one frame stitched onto the bottom half of the next. Every shape we have drawn so far has sat still, so we never noticed. The instant things move, how a finished frame reaches the display becomes its own problem, and it is the problem we solve today. Here we go!
Three exercises last time, all about extending the Mat3 transform type. Here are my solutions.
Exercise 1 -- a shear constructor. A shear puts its factors in the off-diagonal positions of the linear block, so m[0][1] = kx and m[1][0] = ky. The test proves that shearing the corner (0,1) slides it by kx in x (a square becomes a parallelogram), and that shear(0,0) is just the identity:
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 shear(kx: f32, ky: f32) Mat3 {
return .{ .m = .{ .{ 1, kx, 0 }, .{ ky, 1, 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],
};
}
};
test "shear slides a square corner into a parallelogram; shear(0,0) is identity" {
const s = Mat3.shear(2, 0);
const corner = s.apply(.{ .x = 0, .y = 1 }); // top corner slides by kx*y = 2 in x
try std.testing.expectApproxEqAbs(@as(f32, 2), corner.x, 1e-6);
try std.testing.expectApproxEqAbs(@as(f32, 1), corner.y, 1e-6);
const p = Vec2{ .x = 3, .y = -4 };
const same = Mat3.shear(0, 0).apply(p);
try std.testing.expectApproxEqAbs(p.x, same.x, 1e-6);
try std.testing.expectApproxEqAbs(p.y, same.y, 1e-6);
}
A shear is the transform that turns an upright rectangle into the slanted one you see in italic fonts -- same area, same base, just pushed sideways in proportion to height.
Exercise 2 -- rotate about an arbitrary pivot. A raw rotation spins around the origin, but you almost always want a shape to spin around its own centre. The recipe is a three-matrix composition: translate the pivot to the origin, rotate there, translate back. Because A.mul(B) applies B first, you read the composition right-to-left:
const std = @import("std");
pub const Vec2 = struct { x: f32, y: f32 };
pub const Mat3 = struct {
m: [3][3]f32,
pub fn translation(tx: f32, ty: f32) Mat3 {
return .{ .m = .{ .{ 1, 0, tx }, .{ 0, 1, ty }, .{ 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 rotationAbout(cx: f32, cy: f32, radians: f32) Mat3 {
return translation(cx, cy).mul(rotation(radians)).mul(translation(-cx, -cy));
}
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 "rotationAbout keeps the pivot fixed and swings a neighbour around it" {
const t = Mat3.rotationAbout(10, 10, std.math.pi / 2.0);
const pivot = t.apply(.{ .x = 10, .y = 10 });
try std.testing.expectApproxEqAbs(@as(f32, 10), pivot.x, 1e-5);
try std.testing.expectApproxEqAbs(@as(f32, 10), pivot.y, 1e-5);
// one unit to the right of the pivot swings to one unit above it
const neighbour = t.apply(.{ .x = 11, .y = 10 });
try std.testing.expectApproxEqAbs(@as(f32, 10), neighbour.x, 1e-5);
try std.testing.expectApproxEqAbs(@as(f32, 11), neighbour.y, 1e-5);
}
The pivot is the one point a rotation-about leaves untouched -- that is the whole definition of a pivot -- so testing that it does not move is the sharpest possible check.
Exercise 3 -- draw a spinning shape. Combine rotation and translation with last episode's transformPoints-style rounding and fillPolygon. The clever bit is the assertion: a rotation preserves area, so if I fill the same triangle at four different angles, the number of lit pixels should stay roughly constant. A flipped sign in the rotation would visibly grow or shrink the shape and blow this test up -- a lovely, cheap smoke test:
const std = @import("std");
pub const Vec2 = struct { x: f32, y: f32 };
pub const Point = struct { x: i64, y: i64 };
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
pub 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;
}
};
pub const Mat3 = struct {
m: [3][3]f32,
pub fn translation(tx: f32, ty: f32) Mat3 {
return .{ .m = .{ .{ 1, 0, tx }, .{ 0, 1, ty }, .{ 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 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 litCount(fb: Framebuffer) usize {
var count: usize = 0;
for (fb.pixels) |px| {
if (px.r != 0 or px.g != 0 or px.b != 0) count += 1;
}
return count;
}
test "a rotating triangle keeps roughly constant area across angles" {
// model triangle centred on the origin
const model = [_]Vec2{
.{ .x = -8, .y = -8 }, .{ .x = 8, .y = -8 }, .{ .x = 0, .y = 10 },
};
const angles = [_]f32{ 0.0, 0.4, 0.9, 1.3 };
var counts: [4]usize = undefined;
for (angles, 0..) |angle, idx| {
var pixels: [61 * 61]Rgba = undefined;
var fb = Framebuffer{ .pixels = &pixels, .width = 61, .height = 61 };
@memset(fb.pixels, .{ .r = 0, .g = 0, .b = 0 });
// rotate about the origin, then drop the shape at the screen centre (30, 30)
const t = Mat3.translation(30, 30).mul(Mat3.rotation(angle));
var poly: [3]Point = undefined;
for (model, 0..) |mp, i| {
const q = t.apply(mp);
poly[i] = .{ .x = @intFromFloat(@round(q.x)), .y = @intFromFloat(@round(q.y)) };
}
fillPolygon(&fb, &poly, .{ .r = 255, .g = 255, .b = 255 });
counts[idx] = litCount(fb);
}
// rotation preserves area, so no angle should differ from the first by more than ~20%
for (counts) |c| {
const diff = if (c > counts[0]) c - counts[0] else counts[0] - c;
try std.testing.expect(diff * 100 <= counts[0] * 20);
}
}
Notice I compose translation(30,30).mul(rotation(angle)) -- rotate the model at the origin first, then slide it to the screen centre. That is the natural way to place a rotating object: model coordinates centred on nothing, world placement bolted on last.
Everything up to now has used exactly one framebuffer, and it worked because our shapes never moved. But think carefully about what a real display does: it reads your pixel memory, top to bottom, sixty-or-so times a second, and paints whatever it finds there onto the glass. It does not wait for you. It does not ask if you are done. It just reads.
Now put those two facts together. To animate, you clear the screen and redraw it every frame. So your per-frame code looks like this:
const std = @import("std");
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
pub const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
pub fn clear(self: *Framebuffer, color: Rgba) void {
@memset(self.pixels, color);
}
pub 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;
}
};
// SINGLE BUFFER: the display reads this same memory while we scribble in it.
fn renderFrameSingle(fb: *Framebuffer, frame: usize) void {
fb.clear(.{ .r = 0, .g = 0, .b = 0 }); // (1) whole screen wiped to black...
const x: i64 = @intCast(frame % fb.width);
fb.setPixel(x, 0, .{ .r = 255, .g = 255, .b = 255 }); // (2) ...then the dot re-appears
}
test "mid-render, a single buffer is caught fully black -- that is the flicker" {
var pixels: [8]Rgba = undefined;
var fb = Framebuffer{ .pixels = &pixels, .width = 8, .height = 1 };
@memset(fb.pixels, .{ .r = 0, .g = 0, .b = 0 });
// pretend the display samples the memory *between* step (1) and step (2)
fb.clear(.{ .r = 0, .g = 0, .b = 0 });
var any_lit = false;
for (fb.pixels) |px| {
if (px.r != 0) any_lit = true;
}
try std.testing.expect(!any_lit); // caught with NOTHING drawn: the frame the viewer saw is empty
}
Between step (1) and step (2) there is a window -- often milliseconds wide -- where the framebuffer holds an empty frame. If the display happens to scan the screen during that window, the viewer sees black. Do that sixty times a second and the moving dot spends half its life invisible: that is flicker. And even once the dot is drawn, if the display scans while you are only halfway through redrawing a big scene, it shows the top rows from the new frame and the bottom rows from the old one, with a visible horizontal seam where they meet. That seam is tearing. Drawing faster does not help -- it just moves the seam around. The real fix is architectural: never let the display read a frame you are still drawing.
The idea is almost embarrassingly simple, which is usually the sign of a good one. Keep two buffers. The front buffer is the one the display reads. The back buffer is the one you draw into, in private, at your leisure. When -- and only when -- the back buffer holds a complete frame, you present: you make the back buffer the new front. The display now reads a finished frame, and you start drawing the next one into what used to be the front.
The one design decision that matters is how you present. The naive way is to copy the back buffer's pixels over the front (a @memcpy of the whole screen, every frame). That works but wastes megabytes of copying per frame. The good way, called page flipping, is to just swap which buffer is "front" -- a pointer or index flip that costs the same whether your screen is 8 pixels or 8 million. I will build the index version, because it neatly dodges a classic Zig footgun: if you store front: *Framebuffer pointing into the same struct, moving or copying that struct leaves the pointer dangling. An index into an array of buffers has no such problem.
const std = @import("std");
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
pub const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
pub fn clear(self: *Framebuffer, color: Rgba) void {
@memset(self.pixels, color);
}
pub 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;
}
pub 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];
}
};
pub const DoubleBuffer = struct {
allocator: std.mem.Allocator,
buffers: [2][]Rgba,
width: usize,
height: usize,
back_index: u1, // which of the two buffers we currently draw into
pub fn init(allocator: std.mem.Allocator, width: usize, height: usize) !DoubleBuffer {
const a = try allocator.alloc(Rgba, width * height);
errdefer allocator.free(a); // if the SECOND alloc fails, we must not leak the first
const b = try allocator.alloc(Rgba, width * height);
errdefer allocator.free(b);
const black = Rgba{ .r = 0, .g = 0, .b = 0 };
@memset(a, black);
@memset(b, black);
return .{ .allocator = allocator, .buffers = .{ a, b }, .width = width, .height = height, .back_index = 0 };
}
pub fn deinit(self: *DoubleBuffer) void {
self.allocator.free(self.buffers[0]);
self.allocator.free(self.buffers[1]);
self.* = undefined;
}
// the buffer you draw into (private, not shown yet)
pub fn back(self: *DoubleBuffer) Framebuffer {
return .{ .pixels = self.buffers[self.back_index], .width = self.width, .height = self.height };
}
// the buffer the display shows (finished, do not draw here)
pub fn front(self: *DoubleBuffer) Framebuffer {
return .{ .pixels = self.buffers[self.back_index ^ 1], .width = self.width, .height = self.height };
}
// present: reveal the back buffer. O(1) -- no pixels move, just the index flips.
pub fn swap(self: *DoubleBuffer) void {
self.back_index ^= 1;
}
};
test "swap exchanges front and back without copying any pixels" {
var db = try DoubleBuffer.init(std.testing.allocator, 4, 4);
defer db.deinit();
var b = db.back();
b.setPixel(1, 1, .{ .r = 255, .g = 0, .b = 0 }); // draw into the hidden back buffer
try std.testing.expectEqual(@as(u8, 0), db.front().getPixel(1, 1).?.r); // not visible yet
db.swap(); // present
try std.testing.expectEqual(@as(u8, 255), db.front().getPixel(1, 1).?.r); // now on screen
}
Two things are worth pausing on. First, back() and front() return a Framebuffer by value, but that is fine: the pixels field is a slice (a pointer plus a length), so the copy still points at the same backing memory -- writing through it writes the real buffer. Second, that errdefer on the first allocation is not decoration. If the second alloc fails, Zig unwinds and frees a for us; without it, a failed init would leak an entire screen's worth of memory. This is the exact errdefer discipline we drilled in episode 26's allocator work, and here it earns its keep.
There is a subtlety that bites everyone once. After a swap, the buffer you now draw into is the buffer that was on screen two frames ago -- it still holds stale pixels. So you must fully redraw (or clear) the back buffer every frame. If you assume it is blank and only draw what changed, you get the notorious "flickering between two frames" bug, where old and new content alternate. With two buffers, always paint the whole frame.
Look at where the failure modes live in that code, because Zig makes you put them exactly where they belong. init allocates, so it is !DoubleBuffer -- the caller must try it and cannot forget. deinit frees and then sets self.* = undefined, so any accidental use-after-free trips the safety checks in debug builds instead of silently reading freed memory. And swap? It flips a single bit. It cannot fail, it does not allocate, so its return type is a plain void. No try, no error set, no ceremony -- because there is genuinely nothing that can go wrong.
That honesty is the whole Zig ethos in miniature: the cost and the risk of an operation are visible in its signature. The expensive, fallible setup says ! and forces acknowledgement; the cheap, infallible per-frame present says nothing because there is nothing to say. Compare that to a garbage-collected language where allocating two buffers, swapping them and tearing them down all look identical at the call site -- the costs are real but invisible. Here they are spelled out in the types, and you draw your architecture around them: allocate once at startup, swap freely in the hot loop, free once at shutdown.
Double buffering kills tearing only if you present at the right moment. A display refreshes on a fixed heartbeat -- 60 times a second on most panels, every 16.67 milliseconds -- and between finishing one refresh and starting the next there is a tiny pause called the vertical blank, or vblank. If you flip your buffers during the vblank, the display never catches a swap mid-scan, and tearing is gone for good. Synchronising your present to that vblank is what vsync means, and it has a second effect: it paces your loop to the refresh rate, so you render exactly as many frames as the screen can show and not a wasteful one more.
On real hardware the vblank arrives as a signal from the GPU, and a call like SwapBuffers blocks until it comes. We are drawing into memory in a tutorial, with no GPU raising interrupts, so I will model the vblank with a clock: a Pacer that tells you how long to wait before the next frame is due. The trick that keeps it testable is to make the core logic a pure function of timestamps -- it never sleeps, it just does arithmetic -- and leave the actual waiting to a thin outer layer. That is the "functional core, imperative shell" split, and it means the tricky timing logic can be tested with a fake clock at full speed:
const std = @import("std");
pub const Pacer = struct {
frame_ns: u64, // target nanoseconds per frame
next_deadline_ns: u64, // when the next present is due
armed: bool, // false until the first present schedules the cadence
pub fn init(target_fps: u32) Pacer {
std.debug.assert(target_fps > 0);
return .{ .frame_ns = std.time.ns_per_s / target_fps, .next_deadline_ns = 0, .armed = false };
}
// Pure: given the current time, how long to wait before presenting? 0 = present now.
pub fn waitNs(self: Pacer, now_ns: u64) u64 {
if (!self.armed) return 0; // very first frame goes out immediately
return if (now_ns >= self.next_deadline_ns) 0 else self.next_deadline_ns - now_ns;
}
// Record that a present happened; advance the schedule by a FIXED step so cadence never drifts.
pub fn markPresented(self: *Pacer, now_ns: u64) void {
if (!self.armed) {
self.next_deadline_ns = now_ns + self.frame_ns;
self.armed = true;
} else {
self.next_deadline_ns += self.frame_ns;
}
}
};
test "pacer holds a driftless 60 Hz cadence under a fake clock" {
var pacer = Pacer.init(60);
const frame_ns = std.time.ns_per_s / 60; // 16_666_666 ns
// first present is immediate, and it arms the schedule
try std.testing.expectEqual(@as(u64, 0), pacer.waitNs(1_000));
pacer.markPresented(1_000);
// a frame ready 1 ns later must wait almost a whole frame
try std.testing.expectEqual(frame_ns - 1, pacer.waitNs(1_001));
// now present 500 ns LATE. The next deadline advances by a fixed frame from the previous
// deadline (not from this late timestamp), so the lateness does not accumulate.
pacer.markPresented(1_000 + frame_ns + 500);
try std.testing.expectEqual(frame_ns - 500, pacer.waitNs(1_000 + frame_ns + 500));
}
The design decision hiding in markPresented is the one that separates a smooth animation from a slowly-drifting one. I advance next_deadline_ns by exactly frame_ns from the previous deadline, not from the actual present time. If I reset the deadline to "now + frame_ns" instead, every frame that ran a hair late would push the next deadline a hair later, and over a few thousand frames your "60 fps" quietly becomes 58. Advancing by a fixed step pins the cadence to an absolute grid, so a frame that runs slightly long is followed by one that waits slightly less, and the average stays exactly on target. This is the same fixed-step-versus-drift trade-off we met with timers back in episode 70.
The imperative shell that puts it all together is short, and this is the only part that touches the real clock and really sleeps -- which is exactly why it stays out of our fast unit tests:
const std = @import("std");
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
pub const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
pub fn clear(self: *Framebuffer, color: Rgba) void {
@memset(self.pixels, color);
}
};
pub const DoubleBuffer = struct {
allocator: std.mem.Allocator,
buffers: [2][]Rgba,
width: usize,
height: usize,
back_index: u1,
pub fn init(allocator: std.mem.Allocator, width: usize, height: usize) !DoubleBuffer {
const a = try allocator.alloc(Rgba, width * height);
errdefer allocator.free(a);
const b = try allocator.alloc(Rgba, width * height);
errdefer allocator.free(b);
@memset(a, .{ .r = 0, .g = 0, .b = 0 });
@memset(b, .{ .r = 0, .g = 0, .b = 0 });
return .{ .allocator = allocator, .buffers = .{ a, b }, .width = width, .height = height, .back_index = 0 };
}
pub fn deinit(self: *DoubleBuffer) void {
self.allocator.free(self.buffers[0]);
self.allocator.free(self.buffers[1]);
self.* = undefined;
}
pub fn back(self: *DoubleBuffer) Framebuffer {
return .{ .pixels = self.buffers[self.back_index], .width = self.width, .height = self.height };
}
pub fn swap(self: *DoubleBuffer) void {
self.back_index ^= 1;
}
};
pub const Pacer = struct {
frame_ns: u64,
next_deadline_ns: u64,
armed: bool,
pub fn init(target_fps: u32) Pacer {
std.debug.assert(target_fps > 0);
return .{ .frame_ns = std.time.ns_per_s / target_fps, .next_deadline_ns = 0, .armed = false };
}
pub fn waitNs(self: Pacer, now_ns: u64) u64 {
if (!self.armed) return 0;
return if (now_ns >= self.next_deadline_ns) 0 else self.next_deadline_ns - now_ns;
}
pub fn markPresented(self: *Pacer, now_ns: u64) void {
if (!self.armed) {
self.next_deadline_ns = now_ns + self.frame_ns;
self.armed = true;
} else {
self.next_deadline_ns += self.frame_ns;
}
}
};
// The imperative shell, parameterised over its clock+sleep so it stays testable.
// In production you pass a context whose now() reads a monotonic timer and whose
// sleep() blocks the thread; in a test you pass a fake clock that never really waits.
pub fn runLoop(db: *DoubleBuffer, pacer: *Pacer, ctx: anytype, frames: u32) void {
var i: u32 = 0;
while (i < frames) : (i += 1) {
var canvas = db.back();
canvas.clear(.{ .r = 0, .g = 0, .b = 0 });
// ... draw this frame's shapes into `canvas` here ...
const wait = pacer.waitNs(ctx.now());
if (wait > 0) ctx.sleep(wait); // block until the (modelled) vblank
db.swap(); // present at the boundary -- no tearing
pacer.markPresented(ctx.now());
}
}
// A fake clock for the test: virtual time that only advances when we "sleep".
const FakeClock = struct {
t: u64 = 0,
fn now(self: *FakeClock) u64 {
return self.t;
}
fn sleep(self: *FakeClock, ns: u64) void {
self.t += ns; // no real waiting -- just move virtual time forward
}
};
test "runLoop presents each frame and paces via an injected fake clock" {
var db = try DoubleBuffer.init(std.testing.allocator, 2, 2);
defer db.deinit();
var pacer = Pacer.init(60);
var clock = FakeClock{};
runLoop(&db, &pacer, &clock, 3); // three frames, instant test -- no real sleeping
// frame 0 presents immediately; frames 1 and 2 each wait one full 60 Hz gap
try std.testing.expectEqual(@as(u64, 2 * (std.time.ns_per_s / 60)), clock.t);
}
Read the loop body as a sentence: draw the whole frame into the back buffer, wait until the frame is due, present it, and record the present so the next deadline is scheduled. The clear at the top is the "always redraw everything" rule from earlier, made into code. Injecting the clock through ctx is what lets the same loop run against a real monotonic timer in production and a fake clock in the test -- the fake advances virtual time instead of really waiting, so the test finishes instantly. In production, ctx.sleep would call your platform's real sleep, which is the courteous way to wait: it hands the CPU back to the operating system instead of spinning in a busy loop burning battery, which matters enormously on a laptop.
The bug double buffering is supposed to prevent is a mixed frame -- half old, half new -- on screen. So the test that matters most asserts the opposite: at every moment, the front buffer is entirely one frame or entirely the next, never a blend. Because our swap is a single index flip, that atomicity is structural, and we can pin it down with an assertion:
const std = @import("std");
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
pub const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
pub fn clear(self: *Framebuffer, color: Rgba) void {
@memset(self.pixels, color);
}
pub 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];
}
};
pub const DoubleBuffer = struct {
allocator: std.mem.Allocator,
buffers: [2][]Rgba,
width: usize,
height: usize,
back_index: u1,
pub fn init(allocator: std.mem.Allocator, width: usize, height: usize) !DoubleBuffer {
const a = try allocator.alloc(Rgba, width * height);
errdefer allocator.free(a);
const b = try allocator.alloc(Rgba, width * height);
errdefer allocator.free(b);
@memset(a, .{ .r = 0, .g = 0, .b = 0 });
@memset(b, .{ .r = 0, .g = 0, .b = 0 });
return .{ .allocator = allocator, .buffers = .{ a, b }, .width = width, .height = height, .back_index = 0 };
}
pub fn deinit(self: *DoubleBuffer) void {
self.allocator.free(self.buffers[0]);
self.allocator.free(self.buffers[1]);
self.* = undefined;
}
pub fn back(self: *DoubleBuffer) Framebuffer {
return .{ .pixels = self.buffers[self.back_index], .width = self.width, .height = self.height };
}
pub fn front(self: *DoubleBuffer) Framebuffer {
return .{ .pixels = self.buffers[self.back_index ^ 1], .width = self.width, .height = self.height };
}
pub fn swap(self: *DoubleBuffer) void {
self.back_index ^= 1;
}
};
fn frontIsUniform(db: *DoubleBuffer, channel_g: u8) bool {
const f = db.front();
for (0..f.width) |x| {
if (f.getPixel(@intCast(x), 0).?.g != channel_g) return false;
}
return true;
}
test "present is atomic: the front is never a mix of two frames" {
var db = try DoubleBuffer.init(std.testing.allocator, 8, 1);
defer db.deinit();
// frame 1: fill the back entirely red, then present
var f1 = db.back();
f1.clear(.{ .r = 255, .g = 0, .b = 0 });
db.swap();
try std.testing.expect(frontIsUniform(&db, 0)); // uniformly red (green channel 0 everywhere)
// frame 2: fill the NEW back entirely green
var f2 = db.back();
f2.clear(.{ .r = 0, .g = 255, .b = 0 });
// crucial: BEFORE the swap, the display still shows a complete red frame -- not a half-drawn one
try std.testing.expect(frontIsUniform(&db, 0));
db.swap();
try std.testing.expect(frontIsUniform(&db, 255)); // now uniformly green
}
The middle assertion is the point of the whole episode expressed as one line: while frame two is being painted into the back buffer, the front buffer is still a complete frame one, green channel zero across every column. There is no instant at which someone reading the front sees a seam. Between that atomicity test and the pacer's cadence test, we have covered the two ways this system fails -- a torn frame and a drifting clock -- with assertions that stay green only while the design stays correct. Tests as executable documentation, exactly as we argued in episode 12.
The reason page-flipping wins is arithmetic you can do in your head. Copying a 1920x1080 screen of 4-byte pixels is about 8 megabytes moved per frame, 60 times a second -- roughly half a gigabyte a second of pure memory traffic doing nothing but shuffling finished pixels around. Flipping an index moves one bit. On any resolution above a postage stamp, the flip is free and the copy is a tax you pay forever. That is why every serious renderer flips.
Double buffering does cost you one thing: latency. A frame you finish drawing waits until the next vblank to appear, so on a 60 Hz screen a present can sit idle for up to 16.7 ms. For most software that is invisible. For a twitchy competitive game it is enough that people reach for triple buffering -- three buffers, so you can start a third frame while one is displayed and one waits, decoupling render rate from refresh without tearing. It trades a little memory for lower latency, but the core idea is identical: the display only ever reads a completed buffer. Having said that -- and you knew a "measure first" was coming -- do not add a third buffer until a profiler or your own eyes say the latency is a real problem. That is the same discipline from episode 34, and it has not changed.
One more honest caveat: our Pacer sleeps to hit its deadline, and a plain OS sleep guarantees only a minimum wait, not an exact one -- the system may hand control back a little late. Real engines pair a coarse sleep with a short final spin, or lean on the GPU's genuine vblank interrupt for precision. For learning the structure, a plain sleep is quit fine.
This little DoubleBuffer is the beating heart of every graphical program you have ever used. When you call SDL's SDL_RenderPresent, or OpenGL's SwapBuffers, or when a Vulkan app presents from a swapchain (just double or triple buffering with a fancier name), you are calling exactly this swap, and the "vsync on/off" toggle in every game's settings is precisely the choice of whether that present blocks for the vblank. Your desktop's compositor does the same dance on your behalf: each app draws into its own back buffer, and the compositor flips them onto the screen together at the refresh boundary -- which is why modern desktops do not tear even when you drag windows around. Learn this one pattern -- draw hidden, reveal atomically, pace to the refresh -- and you have the spine of how anything animates on a screen.
The pattern is language-neutral; what changes is how each language expresses "swap these two owners". In C you swap two pointers by hand, and nothing stops you from getting the temporary wrong:
// C: swap two framebuffer pointers -- O(1), no pixels copied
typedef struct { uint32_t *pixels; int w, h; } Framebuffer;
void present(Framebuffer **front, Framebuffer **back) {
Framebuffer *tmp = *front;
*front = *back;
*back = tmp;
}
Rust gives you the swap as a safe standard-library call -- std::mem::swap -- with no unsafe and no chance of leaving a dangling half-swap:
// Rust: mem::swap exchanges two owners safely, no manual temporary
use std::mem;
struct Framebuffer { pixels: Vec<u32>, w: usize, h: usize }
fn present(front: &mut Framebuffer, back: &mut Framebuffer) {
mem::swap(front, back);
}
Go has no operator overloading, but slices are reference types and tuple assignment swaps in one line, so exchanging two framebuffer headers is trivial and copies no pixels:
// Go: slices are reference types; tuple assignment swaps the headers, not the pixels
type Framebuffer struct {
Pixels []uint32
W, H int
}
func present(front, back *Framebuffer) {
*front, *back = *back, *front
}
The through-line is the one we keep meeting: everyone performs the identical O(1) swap, and they differ only in how much the language protects you. Zig sits where it always sits -- C's flat, allocation-free layout and exact costs, but with std.mem.swap, checked indexing at the boundary, and an errdefer that refuses to let a failed setup leak. Our index-flip version sidesteps even the pointer swap.
A swapCopy alternative. Add pub fn swapCopy(self: *DoubleBuffer) void that presents by @memcpy-ing the back buffer over the front instead of flipping the index. Write a test showing it produces the same visible result as swap for one frame, then explain in a comment why the index flip is preferred for a full-screen buffer (hint: count the bytes moved).
Frame statistics. Extend Pacer with a running count of frames presented and a fn averageFrameNs(self: Pacer, total_elapsed_ns: u64) u64 that returns the mean time per presented frame. Test it under a fake clock by presenting several frames at known timestamps and asserting the average lands on your target frame time.
Detect a dropped frame. A frame is "dropped" when rendering took longer than one frame interval, so waitNs returns 0 and you are already past the deadline. Add a boolean return (or an out-parameter) to a small wrapper around markPresented that reports whether the just-finished frame missed its deadline, and test it by feeding one on-time present and one late present under a fake clock.
@memcpy-ing the whole screen every frame; an index (not a self-referential pointer) keeps the struct safe to move;Pacer that advances by a fixed step from the previous deadline;init is ! and forces a try, errdefer guarantees a failed setup frees cleanly, and the infallible swap says nothing because nothing can go wrong;SwapBuffers, Vulkan swapchains and your desktop compositor are all doing exactly this; C, Rust and Go perform the identical O(1) swap and differ only in how much they protect you.We can now animate without flicker and without tearing, paced to the screen's heartbeat -- motion, finally, done properly. But every frame is still assembled from scratch, pixel by pixel and shape by shape. Real programs lean on prepared building blocks: little images you stamp down wholesale, laid out in grids to build a whole world from small repeated tiles. Teaching our renderer to place those prefabricated pictures -- fast, and in the right spot -- is where we head next. Keep the DoubleBuffer handy; the things we stamp are about to start moving across it.
Bedankt en tot de volgende keer! ;-)