Learn Zig Series (#156) - Framebuffer Basics

What will I learn?
- What a framebuffer actually is -- why every pixel you have ever seen on a screen ultimately lives in a flat, one-dimensional slice of memory, and how we make that slice pretend to be two-dimensional;
- How to lay out pixels in row-major order, compute the
index(x, y)mapping by hand, and why that one line of arithmetic is the foundation of all 2D graphics; - How to build a
Framebufferfrom scratch in Zig -- a pixel type, an owned buffer, bounds-checkedsetPixel/getPixel, aclear, and a clippedfillRect; - How Zig's type system turns "did I go out of bounds?" from an undefined-behaviour crash into a first-class
?Rgbayou cannot ignore, and howcomptimelets one framebuffer serve grayscale, RGBA, or any pixel format you like; - How to test a renderer even though it draws nothing to a screen -- by reading pixels back out and asserting on them, and by exporting to the dead-simple PPM image format;
- Why row-major access order matters for cache behaviour, and how
@memsetclears a whole buffer far faster than a per-pixel loop; - How the same design shows up in C, Rust and Go, and what each language does about the bounds check that Zig makes explicit.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org) -- the code here is written and tested against Zig 0.16;
- Allocators (the arena and
alloc) from episode 7, slices from episode 5, and structs from episode 6 -- we lean on all three today; comptimefrom episode 9 (and the generic-container pattern from episode 14) for the one section where we make the framebuffer generic over its pixel type;- The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
- Learn Zig Series (#104) - Mini Project: Reverse Proxy - Load Balancing
- Learn Zig Series (#105) - Mini Project: Reverse Proxy - Health Checks
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly
- Learn Zig Series (#107) - Skip Lists
- Learn Zig Series (#108) - B-Trees
- Learn Zig Series (#109) - Red-Black Trees
- Learn Zig Series (#110) - Tries: Prefix Trees
- Learn Zig Series (#111) - Bloom Filters
- Learn Zig Series (#112) - Cuckoo Filters
- Learn Zig Series (#113) - Ring Buffers: Lock-Free
- Learn Zig Series (#114) - Memory Pools
- Learn Zig Series (#115) - Slab Allocators
- Learn Zig Series (#116) - Sorting Algorithms in Zig
- Learn Zig Series (#117) - Binary Search Variations
- Learn Zig Series (#118) - Graph Representation
- Learn Zig Series (#119) - BFS and DFS
- Learn Zig Series (#120) - Dijkstra and A*
- Learn Zig Series (#121) - Topological Sort
- Learn Zig Series (#122) - Union-Find
- Learn Zig Series (#123) - LRU Cache
- Learn Zig Series (#124) - Consistent Hashing
- Learn Zig Series (#125) - Mini Project: Search Engine - Inverted Index
- Learn Zig Series (#126) - Mini Project: Search Engine - TF-IDF
- Learn Zig Series (#127) - Mini Project: Search Engine - Query Parser
- Learn Zig Series (#128) - Mini Project: Database Engine - Page Storage
- Learn Zig Series (#129) - Mini Project: Database Engine - B-Tree Index
- Learn Zig Series (#130) - Mini Project: Database Engine - SQL Parser
- Learn Zig Series (#131) - Lexing a Simple Language
- Learn Zig Series (#132) - Recursive Descent Parsing
- Learn Zig Series (#133) - AST Design and Traversal
- Learn Zig Series (#134) - Type Checking
- Learn Zig Series (#135) - Bytecode Design
- Learn Zig Series (#136) - Stack-Based Virtual Machine
- Learn Zig Series (#137) - Closures and Upvalues
- Learn Zig Series (#138) - Garbage Collection: Mark and Sweep
- Learn Zig Series (#139) - Garbage Collection: Generational
- Learn Zig Series (#140) - JIT Compilation Basics
- Learn Zig Series (#141) - Regex: Thompson NFA
- Learn Zig Series (#142) - Regex: NFA to DFA
- Learn Zig Series (#143) - Regex: Matching Engine
- Learn Zig Series (#144) - Code Generation: AST to Machine Code
- Learn Zig Series (#145) - Register Allocation
- Learn Zig Series (#146) - Mini Project: Calculator - Lexer/Parser
- Learn Zig Series (#147) - Mini Project: Calculator - Interpreter
- Learn Zig Series (#148) - Mini Project: Calculator - Bytecode Compiler
- Learn Zig Series (#149) - Mini Project: Calculator - VM with Debugger
- Learn Zig Series (#150) - Mini Project: Lisp - Reader
- Learn Zig Series (#151) - Mini Project: Lisp - Evaluator
- Learn Zig Series (#152) - Mini Project: Lisp - Special Forms and Macros
- Learn Zig Series (#153) - Mini Project: Lisp - Standard Library
- Learn Zig Series (#154) - Mini Project: Regex Engine - NFA
- Learn Zig Series (#155) - Mini Project: Regex Engine - Matching
- Learn Zig Series (#156) - Framebuffer Basics (this post)
Learn Zig Series (#156) - Framebuffer Basics
For the last two episodes we lived inside a regex engine -- parsing, wiring an NFA, simulating it in linear time. Today we make a hard turn and start something new: we are going to draw. Not with a library, not with a game framework, not with SDL or raylib hiding the details -- we are going to build the thing all of those sit on top of, the humble framebuffer, from a bare slice of bytes. Everything a screen shows you -- this text, that photo, the cursor blinking at you -- is, at the very bottom, a rectangle of numbers in memory that something copies out to a display. Understand that rectangle and you understand computer graphics from the ground up. Here we go!
The core concept: a 1D slice pretending to be 2D
A screen is two-dimensional. Memory is one-dimensional -- a slice is just ptr and len, a straight line of bytes with no notion of "up" or "left". So the first and most important idea in all of graphics is a convention for cramming a 2D grid into a 1D line. The overwhelmingly common one is row-major order: you store row 0 left-to-right, then row 1 right after it, then row 2, and so on. The pixel at column x, row y lives at a single computable offset.
That offset is the one formula you must burn into memory, because literally every drawing routine we write from here on is built on it:
const std = @import("std");
test "row-major index math" {
// A 4-wide, 3-tall image is a flat run of 12 pixels.
// Row 0 occupies slots 0..3, row 1 slots 4..7, row 2 slots 8..11.
// The pixel at (x, y) therefore lives at: y * width + x
const width: usize = 4;
const x: usize = 2;
const y: usize = 1;
const slot = y * width + x; // second row, third column
try std.testing.expectEqual(@as(usize, 6), slot);
}
The width in that formula is what graphics people call the stride (sometimes "pitch") -- the number of pixels you skip to move straight down one row. In our simple case the stride equals the width, but hold on to the distinction: real hardware framebuffers often pad each row up to a nice alignment, so the stride is bigger than the visible width, and mixing them up is a classic bug that makes your image lean diagonally like a drunk. We will keep stride equal to width for now, but I am naming it so the idea is already in your head.
Having said that, let us decide what a single pixel is before we allocate a grid of them.
A pixel is just a small struct
A colour, in the model your monitor speaks, is three intensities -- red, green, blue -- plus optionally an alpha channel that says how opaque the pixel is (we will not blend with it today, but every serious framebuffer carries it, so we will too). Each channel is one byte, 0..255. That is exactly a four-byte value, and Zig lets us say so precisely:
pub const Rgba = packed struct {
r: u8,
g: u8,
b: u8,
a: u8 = 255, // default: fully opaque
};
I made it a packed struct deliberately (recall episode 17). A packed struct has a guaranteed, gap-free memory layout -- these four bytes sit back to back with no padding, so an Rgba is bit-for-bit a u32, and a []Rgba is bit-for-bit the raw pixel bytes a real display or an image file expects. That is the whole point of a framebuffer: the in-memory representation is the wire format. The a: u8 = 255 default is a small ergonomic gift -- you can write .{ .r = 255, .g = 0, .b = 0 } for opaque red and not think about alpha until the day you need to.
Building the Framebuffer
Now the container. A framebuffer owns three things: the slice of pixels, its dimensions, and the allocator it was born from (so it can give the memory back). This is the same ownership discipline we have used since episode 7 -- whoever allocates, deallocates, and the type carries what it needs to clean up after itself:
pub const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator, width: usize, height: usize) !Framebuffer {
const pixels = try allocator.alloc(Rgba, width * height);
return .{
.pixels = pixels,
.width = width,
.height = height,
.allocator = allocator,
};
}
pub fn deinit(self: *Framebuffer) void {
self.allocator.free(self.pixels);
self.* = undefined; // poison the struct -- use-after-free becomes an obvious crash
}
fn index(self: Framebuffer, x: usize, y: usize) usize {
return y * self.width + x;
}
pub fn inBounds(self: Framebuffer, x: usize, y: usize) bool {
return x < self.width and y < self.height;
}
};
Two design choices worth pausing on. First, init returns !Framebuffer -- an error union -- because alloc can fail, and Zig will not let me pretend otherwise. Allocation failure is a real event on constrained targets, and the try propagates it to my caller in stead of papering over it. Second, index is a private helper (no pub), because it is an implementation detail -- callers speak in (x, y), and only the framebuffer knows how those map onto the flat slice. If we later switch to a padded stride, this one function changes and nothing else does. That is encapsulation earning its keep.
Reading and writing pixels, safely
Here is where Zig starts to look genuinely different from C. In C, a framebuffer is a raw pointer and writing a pixel is buf[y*w + x] = color; -- and if x or y is out of range, you have just scribbled over some other poor allocation with zero warning, a bug that surfaces as a mysterious crash three functions away. We are going to make out-of-bounds access impossible to do by accident:
pub fn setPixel(self: *Framebuffer, x: usize, y: usize, color: Rgba) void {
if (!self.inBounds(x, y)) return; // silently clip -- drawing off-canvas is a no-op
self.pixels[self.index(x, y)] = color;
}
pub fn getPixel(self: Framebuffer, x: usize, y: usize) ?Rgba {
if (!self.inBounds(x, y)) return null; // off-canvas has no colour to give
return self.pixels[self.index(x, y)];
}
pub fn clear(self: *Framebuffer, color: Rgba) void {
@memset(self.pixels, color); // paint every pixel one colour in one shot
}
Notice the two different strategies for the boundary. setPixel clips -- writing outside the canvas quietly does nothing. That is exactly the behaviour you want from a drawing primitive: when we draw a line or a sprite that runs off the edge, the parts that fall outside should simply not appear, not crash the program. getPixel, by contrast, returns ?Rgba -- an optional. There is no sensible colour to invent for a pixel that does not exist, so we return null and force the caller to acknowledge the possibility. The reader cannot forget to handle it, because if (fb.getPixel(x, y)) |px| { ... } is the only way to get at the value. This is the type system converting a whole category of C bugs into something the compiler makes you deal with up front.
And clear shows off @memset, a builtin that fills a whole slice with one value. Because Rgba is four bytes, the compiler turns this into a tight, vectorised store loop -- much faster than we could hand-write, and we will come back to why in the performance section.
Filling rectangles, with clipping
A single pixel is nice, but the first genuinely useful primitive is the filled rectangle -- backgrounds, bars, UI panels, the clear-a-region operation. The naive version writes w * h pixels one at a time through setPixel, and that works, but it pays the bounds check on every single pixel. We can do far better by clipping the rectangle to the canvas once, then filling each visible row with a single @memset:
pub fn fillRect(self: *Framebuffer, x0: usize, y0: usize, w: usize, h: usize, color: Rgba) void {
if (x0 >= self.width or y0 >= self.height) return; // wholly off-canvas
const x_end = @min(x0 + w, self.width); // clip right edge
const y_end = @min(y0 + h, self.height); // clip bottom edge
var y = y0;
while (y < y_end) : (y += 1) {
const row_start = self.index(x0, y);
const row_end = self.index(x_end, y);
@memset(self.pixels[row_start..row_end], color); // one row, one memset
}
}
The @min calls are the clipping: they pull the right and bottom edges back inside the canvas so we never compute an index past the end of the slice. Because pixels in a row are contiguous (that is the row-major payoff), each visible row is a single slice self.pixels[row_start..row_end], and one @memset paints the lot. A rectangle 500 pixels wide becomes 500-wide memsets, not 500 individual bounds-checked stores -- the difference is enormous, and it comes straight out of respecting the memory layout in stead of fighting it. This is the recurring lesson of the whole graphics phase: draw in the order memory is laid out, and the machine rewards you.
Making it generic over the pixel type
So far we have hard-coded Rgba. But a framebuffer for a black-and-white e-ink display wants one byte per pixel; a depth buffer wants an f32; a mask wants a bool. The structure -- a flat slice, width, height, the y * width + x mapping, bounds-checked access -- is identical regardless of what a pixel is. This is exactly the situation comptime generics were made for (episode 14), so let us lift the pixel type out into a parameter:
pub fn Image(comptime Pixel: type) type {
return struct {
const Self = @This();
pixels: []Pixel,
width: usize,
height: usize,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator, width: usize, height: usize) !Self {
return .{
.pixels = try allocator.alloc(Pixel, width * height),
.width = width,
.height = height,
.allocator = allocator,
};
}
pub fn deinit(self: *Self) void {
self.allocator.free(self.pixels);
}
pub fn at(self: Self, x: usize, y: usize) ?Pixel {
if (x >= self.width or y >= self.height) return null;
return self.pixels[y * self.width + x];
}
pub fn set(self: *Self, x: usize, y: usize, p: Pixel) void {
if (x >= self.width or y >= self.height) return;
self.pixels[y * self.width + x] = p;
}
pub fn clear(self: *Self, p: Pixel) void {
@memset(self.pixels, p);
}
};
}
Image(Rgba) gives you back essentially the colour framebuffer we just wrote; Image(u8) gives you a grayscale one; Image(f32) gives you a depth buffer. And here is the beautiful part -- there is zero runtime cost for this abstraction. Image(u8) and Image(Rgba) are two entirely separate, fully specialised types generated at compile time, each with its own optimally-laid-out @memset. This is not the "boxed, virtual-dispatch, one-size-fits-all" generics of a managed language; it is monomorphisation with no apology, the same machinery C++ templates and Rust generics use, but spelled out in plain Zig you can read. A part from that, because it is all comptime, an illegal pixel type fails to compile in stead of blowing up at runtime.
Testing a renderer that draws nothing you can see
Here is the awkward truth about graphics code: it produces pictures, and pictures are exactly the kind of output that is miserable to assert on in a unit test. But our framebuffer is not a black box -- it is a slice of numbers we can read straight back. So we test drawing the same way we tested the regex matcher last episode: perform an operation, then read the state back out and assert on it. No screen required.
test "setPixel and getPixel round-trip a colour" {
var fb = try Framebuffer.init(std.testing.allocator, 4, 3);
defer fb.deinit();
fb.clear(.{ .r = 0, .g = 0, .b = 0 });
fb.setPixel(1, 2, .{ .r = 255, .g = 128, .b = 0 }); // opaque orange
const got = fb.getPixel(1, 2).?;
try std.testing.expectEqual(@as(u8, 255), got.r);
try std.testing.expectEqual(@as(u8, 128), got.g);
try std.testing.expectEqual(@as(u8, 0), got.b);
try std.testing.expectEqual(@as(u8, 255), got.a); // the default alpha came through
}
That test pins down the round-trip and the alpha default in one go. Now the boundary behaviour -- the part that would be undefined behaviour in C and is instead a boring, testable no-op here:
test "out-of-bounds writes clip and reads return null" {
var fb = try Framebuffer.init(std.testing.allocator, 2, 2);
defer fb.deinit();
fb.clear(.{ .r = 10, .g = 20, .b = 30 });
fb.setPixel(99, 99, .{ .r = 255, .g = 255, .b = 255 }); // way off canvas: no crash, no effect
try std.testing.expect(fb.getPixel(99, 99) == null);
try std.testing.expectEqual(@as(u8, 10), fb.getPixel(0, 0).?.r); // untouched
}
test "fillRect clips to the framebuffer edge" {
var fb = try Framebuffer.init(std.testing.allocator, 4, 4);
defer fb.deinit();
fb.clear(.{ .r = 0, .g = 0, .b = 0 });
const white = Rgba{ .r = 255, .g = 255, .b = 255 };
fb.fillRect(2, 2, 10, 10, white); // spills far past the bottom-right corner
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(3, 3).?.r); // inside: painted
try std.testing.expectEqual(@as(u8, 0), fb.getPixel(1, 1).?.r); // outside the rect: untouched
}
The fillRect test is the one I care about most, because it exercises the clipping logic on a rectangle that deliberately runs off the edge -- the exact case that a careless index computation would turn into a buffer overflow. If our @min clamps are wrong, this test either crashes or paints a pixel it should not, and zig test tells us immediately. And the generic Image deserves a test too, proving one definition really does serve two pixel formats:
test "generic Image serves both grayscale and rgba" {
var gray = try Image(u8).init(std.testing.allocator, 3, 3);
defer gray.deinit();
gray.clear(0);
gray.set(1, 1, 200);
try std.testing.expectEqual(@as(?u8, 200), gray.at(1, 1));
var color = try Image(Rgba).init(std.testing.allocator, 2, 2);
defer color.deinit();
color.clear(.{ .r = 0, .g = 0, .b = 0 });
color.set(0, 0, .{ .r = 5, .g = 6, .b = 7 });
try std.testing.expectEqual(@as(u8, 5), color.at(0, 0).?.r);
}
Getting the picture out: the PPM format
Reading pixels back in a test is satiesfying, but at some point you want to look at what you drew. The friendliest possible target is PPM (Portable Pixmap), a format so simple you can write an encoder in a dozen lines: a tiny text header -- the magic bytes P6, the width and height, the max channel value 255 -- followed by the raw RGB bytes, three per pixel. No compression, no chunks, no checksums. Every image viewer worth the name opens it, and zig test can verify the bytes exactly:
pub fn toPpm(self: Framebuffer, allocator: std.mem.Allocator) ![]u8 {
const header = try std.fmt.allocPrint(allocator, "P6\n{d} {d}\n255\n", .{ self.width, self.height });
defer allocator.free(header);
var out = try allocator.alloc(u8, header.len + self.pixels.len * 3);
@memcpy(out[0..header.len], header);
var i: usize = header.len;
for (self.pixels) |p| {
out[i] = p.r;
out[i + 1] = p.g;
out[i + 2] = p.b; // note: PPM has no alpha channel, so we drop it
i += 3;
}
return out;
}
test "toPpm emits a correct P6 header and body length" {
var fb = try Framebuffer.init(std.testing.allocator, 2, 1);
defer fb.deinit();
fb.clear(.{ .r = 1, .g = 2, .b = 3 });
const ppm = try fb.toPpm(std.testing.allocator);
defer std.testing.allocator.free(ppm);
try std.testing.expect(std.mem.startsWith(u8, ppm, "P6\n2 1\n255\n"));
// header + exactly 3 bytes per pixel (2 pixels -> 6 body bytes)
try std.testing.expectEqual("P6\n2 1\n255\n".len + 6, ppm.len);
}
Once you have these bytes, writing them to output.ppm with the file I/O from episode 10 is a one-liner, and you can open the result and see your rectangle. I have deliberately returned the bytes rather than writing the file inside the function -- that keeps the encoder pure and trivially testable, and it lets the caller decide whether the pixels go to disk, down a socket, or into another buffer. Separating "compute the bytes" from "perform the I/O" is a habit that pays off far beyond graphics.
Performance: draw with the grain of memory
I keep saying "respect the layout", so let me make it concrete with the single most important performance idea for framebuffers: traverse in row-major order, the same order the pixels are stored. Consider two ways to clear a buffer to one colour. The obvious-looking nested loop, and the one-shot builtin:
// SLOW-ish: correct, but a per-pixel bounds check and a column-outer loop
pub fn clearSlow(self: *Framebuffer, color: Rgba) void {
var x: usize = 0;
while (x < self.width) : (x += 1) {
var y: usize = 0;
while (y < self.height) : (y += 1) {
self.setPixel(x, y, color); // walks DOWN columns -- jumps `width` pixels each step
}
}
}
// FAST: one contiguous fill, no per-pixel branching
pub fn clearFast(self: *Framebuffer, color: Rgba) void {
@memset(self.pixels, color);
}
clearSlow is wrong in two ways that both trace back to memory layout. It calls setPixel per pixel, paying a bounds check width * height times for values we already know are in range. Worse, its outer loop is over x, so it walks down a column -- each step jumps width pixels forward in the slice, thrashing the CPU cache by touching a new cache line almost every time. clearFast streams straight through memory in order, which the prefetcher loves, and hands the whole job to a builtin that the backend turns into wide vector stores. The lesson generalises to every primitive we build after this: put the y loop on the outside and the x loop on the inside, so the innermost work marches along contiguous memory. Get that backwards -- a mistake I have made more than once, to my critisism -- and you can be a factor of ten slower for producing the exact same picture.
The same buffer in C, Rust and Go
Building this from scratch makes the design choices of every graphics stack legible. In C, a framebuffer is a uint32_t *buf plus a manually-tracked width, and a pixel write is buf[y * width + x] = color; -- no bounds check, no optional, all the speed and all the danger, and it is on you to never let x or y escape the range. That raw model is the bedrock every other language dresses up. Rust stores a Vec<u32> (or a [u8] for the raw bytes) and gives you two doors: indexing with buf[y * w + x] panics on overflow, exactly like Zig's default safe build, while buf.get(y * w + x) returns an Option<&u32> -- the direct analogue of our ?Rgba. The image crate wraps all this in an ImageBuffer<P, C> generic over the pixel type P, which is precisely the Image(comptime Pixel: type) move we just made, only with traits doing what our comptime does. Go leans on its standard library: image.RGBA holds a Pix []uint8 byte slice plus a Stride field -- and there is that stride again, first-class in the standard type, because Go's designers knew row padding matters. Go's slice accesses are bounds-checked at runtime and panic out of range, so it lands in the same safety neighbourhood as Zig's safe build, trading a little speed for not corrupting memory.
The through-line: everyone agrees a framebuffer is a flat buffer plus a stride and a pixel format. Where they differ is what happens when you index out of bounds -- silent corruption (C), a panic (Rust/Go by default), or Zig's choice of a checked crash in safe builds that you can drop to raw speed in ReleaseFast once you have proven your indices with tests like the ones above. Zig hands you the C-level buffer and the high-level safety in the same language, and lets you pick per build.
Exercises
Horizontal and vertical lines. Add
hLine(self, x0, x1, y, color)andvLine(self, x, y0, y1, color)methods that draw straight runs, clipping to the canvas. The horizontal one should be a single@memsetper call (contiguous memory), the vertical one awhileloop stepping bywidth. Write tests that draw a line running off each edge and assert only the on-canvas pixels changed.A
blitthat copies one framebuffer into another. Writeblit(dst: *Framebuffer, src: Framebuffer, dst_x: usize, dst_y: usize)that copiessrcintodstat the given offset, clipping any part that would fall outsidedst. Copy row by row with a slice copy (one@memcpyper visible row), and test that a source pasted partly off the edge only writes its visible portion.A checkerboard, exported to PPM. Fill an
Image(u8)grayscale framebuffer with an 8-pixel checkerboard pattern (alternating 0 and 255), add atoPgmencoder (PGM is PPM's grayscale sibling: magicP5, one byte per pixel), write it to disk with the file I/O from episode 10, and open the result in an image viewer to confirm your pattern looks right. Then assert in a test that a known pixel has the value your pattern predicts.
Wat we geleerd hebben
- A framebuffer is a 1D slice pretending to be 2D, and the whole trick is the row-major mapping
index(x, y) = y * width + x, with stride as the more general form of thatwidth; - A pixel is a small
packed structwhose in-memory layout is the wire format, which is why[]Rgbacan be handed straight to an image file or a display; - Zig turns out-of-bounds access from C's silent corruption into either a clipping no-op (
setPixel) or an explicit?Rgbayou cannot ignore (getPixel), catching a whole class of bugs at the type level; fillRectandclearare fast because they@memsetcontiguous rows in stead of poking pixels one at a time -- draw with the grain of memory,yon the outside andxon the inside;comptimelets oneImage(Pixel)definition serve RGBA, grayscale, depth or mask buffers with zero runtime cost, each fully specialised;- We can test a renderer with no screen at all by reading pixels back out, and we can see our output through the dead-simple PPM format;
- C, Rust and Go all agree a framebuffer is a flat buffer + stride + pixel format, and differ only in what they do at the boundary -- Zig gives you the raw buffer and the safety in one language.
We have a canvas now, and a way to clear it, fill it, read it back and save it. What we cannot do yet is draw the one thing every picture is made of -- a line between two arbitrary points, at any angle, without gaps or floating-point drift. That turns out to be a genuinely clever little problem, solved decades ago with nothing but integer addition, and it is where we go next. Keep this framebuffer file handy -- everything from here on draws into exactly this buffer.
Bedankt en tot de volgende keer! ;-)