Learn Zig Series (#157) - Line Drawing: Bresenham
What will I learn?
- Why drawing a straight line between two arbitrary points is a genuinely hard problem, and how the pixel grid quietly fights you every step of the way;
- The naive floating-point DDA approach first -- so you feel exactly what is wrong with it before we fix it;
- Bresenham's line algorithm from first principles: an integer-only decision variable that picks the next pixel with nothing but addition and comparison, no division, no floats, no drift;
- How to grow the single-octant version into an all-directions routine that draws any line at any angle, clipping cleanly to the canvas;
- How Zig's signed integers,
?Rgbaoptionals andanytypelet us decouple the algorithm from the surface it draws on, so the same Bresenham serves a framebuffer, a counter, or an SVG writer; - How to test a line routine with no screen at all, and why the "hits both endpoints and is symmetric" property is the test that catches the most bugs;
- Where this shows up in the real world (wireframes, UI, plotting) and how C, Rust and Go draw the exact same line.
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;
- The
Framebuffer,Rgbapixel andsetPixel/getPixelfrom episode 156 -- we draw straight into that buffer today; - Signed integers and control flow from the early episodes, and the
anytypecomptime-duck-typing trick we have leaned on since episode 13; - 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
- Learn Zig Series (#157) - Line Drawing: Bresenham (this post)
Learn Zig Series (#157) - Line Drawing: Bresenham
Last episode we built a framebuffer -- a flat slice of pixels pretending to be a 2D grid, with setPixel, clear and fillRect. We can paint rectangles all day, but a picture is not made of rectangles. It is made of lines -- the edges of shapes, the strokes of a font, the wires of a 3D model projected flat. And drawing a line between two arbitrary points turns out to be one of those problems that looks trivial for about ten seconds and then bites you. Today we solve it properly, the way it has been solved since 1962, with an algorithm so lean it runs on a microcontroller with no floating-point unit at all. Here we go!
Solutions to Episode 156 Exercises
Before we draw a single line, here are the solutions to last episode's three framebuffer exercises.
Exercise 1 -- horizontal and vertical lines. The horizontal one is one @memset over a contiguous row; the vertical one steps by width. Both clip to the canvas. Note the + 1 that makes the endpoint inclusive:
const std = @import("std");
const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
fn hLine(self: *Framebuffer, x0: usize, x1: usize, y: usize, color: Rgba) void {
if (y >= self.height) return; // whole row off-canvas
const lo = @min(x0, x1);
if (lo >= self.width) return;
const hi = @min(@max(x0, x1) + 1, self.width); // clip right edge, inclusive endpoint
const start = y * self.width + lo;
const end = y * self.width + hi;
@memset(self.pixels[start..end], color); // one contiguous fill
}
fn vLine(self: *Framebuffer, x: usize, y0: usize, y1: usize, color: Rgba) void {
if (x >= self.width) return;
const lo = @min(y0, y1);
const hi = @min(@max(y0, y1) + 1, self.height);
var y = lo;
while (y < hi) : (y += 1) {
self.pixels[y * self.width + x] = color; // step down by a whole row each time
}
}
};
test "hLine and vLine clip to the canvas" {
var pixels: [16]Rgba = undefined;
var fb = Framebuffer{ .pixels = &pixels, .width = 4, .height = 4 };
@memset(fb.pixels, .{ .r = 0, .g = 0, .b = 0 });
const white = Rgba{ .r = 255, .g = 255, .b = 255 };
fb.hLine(1, 99, 2, white); // runs off the right edge
try std.testing.expectEqual(@as(u8, 255), fb.pixels[2 * 4 + 3].r); // last on-canvas pixel painted
try std.testing.expectEqual(@as(u8, 0), fb.pixels[2 * 4 + 0].r); // x=0 left untouched
fb.vLine(0, 0, 99, white); // runs off the bottom
try std.testing.expectEqual(@as(u8, 255), fb.pixels[3 * 4 + 0].r);
}
The key insight: because a horizontal line is contiguous in memory, it collapses to a single @memset, while a vertical line -- the same number of pixels -- costs a whole loop with a stride jump on every step. Same picture, very different memory access. Hold that thought; it is the whole reason Bresenham is written the way it is.
Exercise 2 -- a blit that copies one framebuffer into another, clipping. Copy row by row with one @memcpy per visible row, and stop when you run off any edge of the destination:
const std = @import("std");
const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
};
fn blit(dst: *Framebuffer, src: Framebuffer, dst_x: usize, dst_y: usize) void {
var row: usize = 0;
while (row < src.height) : (row += 1) {
const dy = dst_y + row;
if (dy >= dst.height) break; // below the canvas: no rows left
const copy_w = if (dst_x >= dst.width) 0 else @min(src.width, dst.width - dst_x);
if (copy_w == 0) continue; // this row lands entirely off the right edge
const s_start = row * src.width;
const d_start = dy * dst.width + dst_x;
@memcpy(dst.pixels[d_start .. d_start + copy_w], src.pixels[s_start .. s_start + copy_w]);
}
}
test "blit clips the part that falls off the destination" {
var dpix: [16]Rgba = undefined;
var dst = Framebuffer{ .pixels = &dpix, .width = 4, .height = 4 };
@memset(dst.pixels, .{ .r = 0, .g = 0, .b = 0 });
var spix: [4]Rgba = undefined;
const src = Framebuffer{ .pixels = &spix, .width = 2, .height = 2 };
@memset(spix[0..], .{ .r = 9, .g = 9, .b = 9 });
blit(&dst, src, 3, 3); // only its top-left pixel fits in the corner
try std.testing.expectEqual(@as(u8, 9), dst.pixels[3 * 4 + 3].r); // corner copied
try std.testing.expectEqual(@as(u8, 0), dst.pixels[0].r); // rest untouched
}
Exercise 3 -- a checkerboard exported to PGM. PGM is PPM's grayscale sibling: the magic bytes P5, then width, height, max value, then one byte per pixel. The pattern is a parity check on which cell each pixel falls into:
const std = @import("std");
fn checkerboard(pixels: []u8, width: usize, height: usize, cell: usize) void {
var y: usize = 0;
while (y < height) : (y += 1) {
var x: usize = 0;
while (x < width) : (x += 1) {
const on = ((x / cell) + (y / cell)) % 2 == 0;
pixels[y * width + x] = if (on) 255 else 0;
}
}
}
fn toPgm(allocator: std.mem.Allocator, pixels: []const u8, width: usize, height: usize) ![]u8 {
const header = try std.fmt.allocPrint(allocator, "P5\n{d} {d}\n255\n", .{ width, height });
defer allocator.free(header);
const out = try allocator.alloc(u8, header.len + pixels.len);
@memcpy(out[0..header.len], header);
@memcpy(out[header.len..], pixels);
return out;
}
test "checkerboard pattern and PGM export" {
const w: usize = 16;
const h: usize = 16;
const buf = try std.testing.allocator.alloc(u8, w * h);
defer std.testing.allocator.free(buf);
checkerboard(buf, w, h, 8);
try std.testing.expectEqual(@as(u8, 255), buf[0]); // (0,0) is an "on" cell -> white
try std.testing.expectEqual(@as(u8, 0), buf[8]); // (8,0) flips -> black
const pgm = try toPgm(std.testing.allocator, buf, w, h);
defer std.testing.allocator.free(pgm);
try std.testing.expect(std.mem.startsWith(u8, pgm, "P5\n16 16\n255\n"));
try std.testing.expectEqual("P5\n16 16\n255\n".len + w * h, pgm.len);
}
Right -- framebuffer refreshed, exercises squared away. Now to the real subject.
Why a straight line is not straightforward
Here is the trap. You have two points, (x0, y0) and (x1, y1), and you want to light up the pixels between them. Your first instinct, and mine too the first time, is high-school algebra: the line is y = m*x + b where m is the slope. So loop x from x0 to x1, compute y = round(m*x + b), plot. Done, right?
It works -- for gentle slopes. But it has three real problems. First, it uses floating-point multiplication and rounding per pixel, which is slow and, on a chip with no FPU, not even available. Second, if the line is steeper than 45 degrees, stepping x by one skips whole rows of y, leaving a dashed, gappy line -- you would have to detect that case and loop over y instead. Third, floating-point rounding drifts: accumulate enough tiny errors and the line wobbles off its true path. We can do better with only integers, and that is Bresenham's gift.
The naive version first, so you feel the problem
Before the elegant solution, let us write the honest, naive one -- the DDA (Digital Differential Analyzer). It fixes the gap problem by stepping along whichever axis is longer, but it still leans on floats. We draw into the episode-156 framebuffer, which I have widened to accept signed coordinates -- crucial, because a line can start off the left edge at a negative x and we want to clip, not crash:
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,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator, width: usize, height: usize) !Framebuffer {
return .{
.pixels = try allocator.alloc(Rgba, width * height),
.width = width,
.height = height,
.allocator = allocator,
};
}
pub fn deinit(self: *Framebuffer) void {
self.allocator.free(self.pixels);
}
pub fn clear(self: *Framebuffer, color: Rgba) void {
@memset(self.pixels, color);
}
// signed coordinates: anything off-canvas is silently clipped, never a crash
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];
}
};
Widening the coordinate to i64 is a small decision with big consequences. In episode 156 our pixels were addressed with usize, which is unsigned -- perfect for a buffer index that can never be negative. But a line lives in a coordinate space where negatives are completely normal (a shape half off the screen), so the drawing API speaks i64, and setPixel is the one place that converts, clips and guarantees the final index is a valid usize. The boundary between "geometry, which can be negative" and "memory, which cannot" lives in exactly one function. That is the same encapsulation discipline as last episode's private index helper.
Now the DDA itself:
pub fn drawLineFloat(fb: *Framebuffer, x0: i64, y0: i64, x1: i64, y1: i64, color: Rgba) void {
const dx: f64 = @floatFromInt(x1 - x0);
const dy: f64 = @floatFromInt(y1 - y0);
const steps: usize = @intFromFloat(@max(@abs(dx), @abs(dy))); // walk the LONGER axis
if (steps == 0) {
fb.setPixel(x0, y0, color); // start == end: a single dot
return;
}
const x_inc = dx / @as(f64, @floatFromInt(steps));
const y_inc = dy / @as(f64, @floatFromInt(steps));
var x: f64 = @floatFromInt(x0);
var y: f64 = @floatFromInt(y0);
var i: usize = 0;
while (i <= steps) : (i += 1) {
fb.setPixel(@intFromFloat(@round(x)), @intFromFloat(@round(y)), color);
x += x_inc;
y += y_inc;
}
}
This draws correct lines. Stepping along the longer axis kills the gap problem. But look at the loop body: a float add, then a @round, then a float-to-int conversion, on every single pixel. And the accumulating x += x_inc is where drift creeps in over a very long line. It offends me a little that we are doing analog arithmetic to place discrete dots. Bresenham asks: what if we never left the integers at all?
Bresenham: the integer decision variable
Here is the idea that makes it click. Take a shallow line, going right and slightly up, slope between 0 and 1. We step x by one every iteration -- that is forced, since x is the long axis. The only question at each step is: does y stay the same, or tick up by one? The true line passes somewhere through the column, and we want the pixel row closest to it.
Bresenham tracks a single integer, the error term, which measures how far the true line has drifted from the pixel row we last chose. Each x step adds the slope's rise to the error; when the error crosses the halfway mark, we bump y and subtract back. Multiply everything through by 2*dx and every fraction vanishes -- it is all integer add and compare. Here is the clean single-octant version, for a line heading right and down with slope in [0, 1], which shows the decision variable naked:
// single octant only (x1 >= x0, and 0 <= slope <= 1) -- for teaching the error idea
pub fn drawLineShallow(fb: *Framebuffer, x0: i64, y0: i64, x1: i64, y1: i64, color: Rgba) void {
const dx = x1 - x0;
const dy = y1 - y0;
var d = 2 * dy - dx; // the decision variable, pre-scaled by 2*dx so it stays integer
var y = y0;
var x = x0;
while (x <= x1) : (x += 1) {
fb.setPixel(x, y, color);
if (d > 0) { // the true line has crossed into the next row
y += 1;
d -= 2 * dx;
}
d += 2 * dy; // every x step adds the rise
}
}
Read the loop as a running tally. d starts at 2*dy - dx. Every column we add 2*dy (the doubled rise). The moment d goes positive, the ideal line has drifted more than half a pixel above our current row, so we step y up and pay back 2*dx. No division, no floats, no rounding -- just three integer operations per pixel, and the error can never drift because it is exact. That is the whole trick, and it is genuinely beautiful once it lands.
The all-octant version you will actually use
The shallow version only handles one of the eight directions a line can go. The production form handles them all with two sign variables (sx, sy) for the step direction and a symmetric error term that lets either axis be the long one. This is the version to memorise -- I have typed it from memory more times than any other graphics routine:
pub fn drawLine(fb: *Framebuffer, x0: i64, y0: i64, x1: i64, y1: i64, color: Rgba) void {
var x = x0;
var y = y0;
const dx: i64 = @intCast(@abs(x1 - x0));
const dy: i64 = -@as(i64, @intCast(@abs(y1 - y0))); // note: dy is negative here
const sx: i64 = if (x0 < x1) 1 else -1; // step left or right
const sy: i64 = if (y0 < y1) 1 else -1; // step up or down
var err = dx + dy;
while (true) {
fb.setPixel(x, y, color);
if (x == x1 and y == y1) break; // reached the far endpoint
const e2 = 2 * err;
if (e2 >= dy) { // time to step along x
err += dy;
x += sx;
}
if (e2 <= dx) { // time to step along y
err += dx;
y += sy;
}
}
}
What makes this version so pleasant is its total generality. sx and sy absorb direction, so a line going up-left draws exactly as happily as one going down-right. The error starts at dx + dy (remember dy is stored negative), and the two if blocks decide the step: e2 >= dy advances x, e2 <= dx advances y. They are two separate ifs, not else if -- on a 45-degree diagonal both fire in the same iteration, stepping both axes at once. That symmetry is the whole reason one loop covers all eight octants with no special-casing. Read it a few times; the negative dy and the doubled error feel like a magic trick until they suddenly do not.
Decoupling the algorithm from the surface with anytype
Notice that Bresenham does not actually care that it is drawing into a framebuffer. It produces a sequence of integer points; what you do with each point is your business. In episode 13 we met interfaces via type erasure, and in episode 14 comptime generics. Here the lightest possible tool fits: take an anytype context with a plot method and call it. The same line generator can fill pixels, count them, or stream them to an SVG file -- no allocation, no vtable, resolved at compile time:
pub const Point = struct { x: i64, y: i64 };
pub fn bresenham(a: Point, b: Point, ctx: anytype) void {
var x = a.x;
var y = a.y;
const dx: i64 = @intCast(@abs(b.x - a.x));
const dy: i64 = -@as(i64, @intCast(@abs(b.y - a.y)));
const sx: i64 = if (a.x < b.x) 1 else -1;
const sy: i64 = if (a.y < b.y) 1 else -1;
var err = dx + dy;
while (true) {
ctx.plot(x, y); // whatever the caller wants done with this pixel
if (x == b.x and y == b.y) break;
const e2 = 2 * err;
if (e2 >= dy) {
err += dy;
x += sx;
}
if (e2 <= dx) {
err += dx; // the CORRECT form: dx here
y += sy;
}
}
}
const Counter = struct {
n: usize = 0,
fn plot(self: *Counter, x: i64, y: i64) void {
_ = x;
_ = y;
self.n += 1;
}
};
test "bresenham plots the expected number of pixels on a diagonal" {
var c = Counter{};
bresenham(.{ .x = 0, .y = 0 }, .{ .x = 5, .y = 5 }, &c);
try std.testing.expectEqual(@as(usize, 6), c.n); // 45-degree line touches 6 pixels
}
This is the same "pass a thing that quacks the right way" pattern Zig uses everywhere, and because ctx is anytype, Counter.plot is inlined with zero indirection -- the counting version compiles down to essentially a loop incrementing a register. A FramebufferSink with a plot that calls setPixel would be just as cheap. Separating generate the points from consume the points is exactly the "encoder returns bytes, caller does the I/O" separation from last episode, applied to geometry.
Testing a line with no screen
How do you test a drawing routine? The same way we tested the framebuffer and the regex matcher before it: perform the operation, read the pixels back, assert. The two properties that catch almost every Bresenham bug are (1) both endpoints are always lit, and (2) the line clips cleanly when it runs off the canvas. Let us pin both down:
test "drawLine lights both endpoints" {
var fb = try Framebuffer.init(std.testing.allocator, 8, 8);
defer fb.deinit();
fb.clear(.{ .r = 0, .g = 0, .b = 0 });
const c = Rgba{ .r = 255, .g = 255, .b = 255 };
drawLine(&fb, 1, 1, 6, 4, c);
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(1, 1).?.r); // start
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(6, 4).?.r); // end
}
test "drawLine clips coordinates that leave the canvas" {
var fb = try Framebuffer.init(std.testing.allocator, 4, 4);
defer fb.deinit();
fb.clear(.{ .r = 0, .g = 0, .b = 0 });
const c = Rgba{ .r = 1, .g = 2, .b = 3 };
drawLine(&fb, -5, 2, 20, 2, c); // spans far past both edges on row y=2
try std.testing.expectEqual(@as(u8, 1), fb.getPixel(0, 2).?.r); // left edge painted
try std.testing.expectEqual(@as(u8, 1), fb.getPixel(3, 2).?.r); // right edge painted
try std.testing.expectEqual(@as(u8, 0), fb.getPixel(0, 0).?.r); // other rows clean
}
test "single-octant version hits its endpoints too" {
var fb = try Framebuffer.init(std.testing.allocator, 10, 10);
defer fb.deinit();
fb.clear(.{ .r = 0, .g = 0, .b = 0 });
const c = Rgba{ .r = 255, .g = 255, .b = 255 };
drawLineShallow(&fb, 0, 0, 6, 2, c);
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(0, 0).?.r);
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(6, 2).?.r);
}
The clip test is the one I lean on hardest, because negative and out-of-range coordinates are precisely where a careless line routine turns into a buffer overflow. Because clipping lives inside setPixel, the line loop stays gloriously simple and still cannot scribble outside the buffer -- the test proves it by drawing a line that runs five pixels off the left edge and twelve off the right, then checking the corners of the canvas are untouched.
Performance: why the integers matter
The DDA and Bresenham draw the identical set of pixels, so why prefer Bresenham? Count the work per pixel. The DDA does two float adds, two @round calls and two float-to-int casts. Bresenham does one comparison and one or two integer adds. On a modern desktop with a fat FPU the gap is smaller than it used to be, but it is never zero, and on the embedded targets Zig loves -- a microcontroller driving a little OLED -- there may be no hardware floats at all, so the DDA either traps into slow software emulation or simply will not run. Bresenham needs nothing but an adder.
There is a second, subtler win, and it is the same lesson as last episode: draw with the grain of memory. A near-horizontal line walks along contiguous pixels, one cache line feeding many writes; a near-vertical line jumps width pixels every step, touching a fresh cache line each time. You cannot change a vertical line's access pattern -- the geometry demands it -- but you can make sure your per-pixel cost is a single integer op so the cache stall is the only thing you pay. And if you are hammering a hot inner loop, the setPixel bounds check becomes the bottleneck: once your tests prove the endpoints are in range, a specialised unclipped inner routine that writes self.pixels[y * width + x] directly (skipping the four comparisons) is a legitimate optimisation for ReleaseFast builds -- but only after the clipped version has proven correct.
Where you meet Bresenham in the wild
This tiny routine is everywhere. Every wireframe 3D renderer projects triangles to 2D and draws their edges with exactly this. Every charting library plots a data series as Bresenham segments. UI toolkits draw borders, underlines and focus rings with it. And the moment you have drawLine, whole shapes fall out for free -- a triangle outline is just three lines:
pub fn drawTriangle(fb: *Framebuffer, a: Point, b: Point, c: Point, color: Rgba) void {
drawLine(fb, a.x, a.y, b.x, b.y, color);
drawLine(fb, b.x, b.y, c.x, c.y, color);
drawLine(fb, c.x, c.y, a.x, a.y, color);
}
test "triangle outline paints its three corners" {
var fb = try Framebuffer.init(std.testing.allocator, 16, 16);
defer fb.deinit();
fb.clear(.{ .r = 0, .g = 0, .b = 0 });
const c = Rgba{ .r = 200, .g = 100, .b = 50 };
drawTriangle(&fb, .{ .x = 2, .y = 2 }, .{ .x = 12, .y = 4 }, .{ .x = 6, .y = 13 }, c);
try std.testing.expectEqual(@as(u8, 200), fb.getPixel(2, 2).?.r);
try std.testing.expectEqual(@as(u8, 200), fb.getPixel(12, 4).?.r);
try std.testing.expectEqual(@as(u8, 200), fb.getPixel(6, 13).?.r);
}
When Bresenham is not the right tool: when you want smooth, anti-aliased edges. Bresenham gives a hard, aliased "staircase" line -- every pixel is fully on or fully off. For soft edges you reach for Xiaolin Wu's algorithm, which shades boundary pixels by fractional coverage (and, ironically, brings the floats back). Bresenham is the choice when you want crisp, fast and exact; Wu when you want pretty. Most of the time, for shape edges and UI, crisp and fast wins.
The same line in C, Rust and Go
Because the algorithm is pure integer arithmetic, it ports almost character-for-character across languages -- what changes is the safety story around the pixel write. In C, it is a raw pointer and the bounds check is your problem, exactly the danger Zig's setPixel closes:
// C: the classic all-octant Bresenham, raw pointer, manual clipping
void draw_line(uint32_t *buf, int w, int h, int x0, int y0, int x1, int y1, uint32_t color) {
int dx = abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
int dy = -abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
int err = dx + dy;
for (;;) {
if (x0 >= 0 && x0 < w && y0 >= 0 && y0 < h) // you clip by hand, or you corrupt memory
buf[y0 * w + x0] = color;
if (x0 == x1 && y0 == y1) break;
int e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
Notice it is line-for-line our Zig, because the algorithm is language-neutral -- only the pixel store differs. Rust would write the loop identically over i32, but the pixel write goes through buf.get_mut(y*w + x) returning an Option<&mut u32> (our ?Rgba again) or a bounds-checked buf[i] that panics -- the same safe-by-default posture as Zig. The image and tiny-skia crates give you draw_line out of the box. Go leans on its standard image package plus golang.org/x/image/vector for anything fancy, with slice accesses bounds-checked at runtime and a panic (not corruption) if you overrun -- again landing in Zig's safety neighbourhood, trading a little speed for not trashing memory. The through-line: everyone runs the identical 1962 integer loop; they differ only in what happens the instant your index escapes the buffer. Zig hands you the raw-C speed and the clip-or-crash safety in one language, and lets you drop the check per build once you have earned it with tests.
Exercises
Break it on purpose, then prove the fix. Copy
drawLineand change the y-branch to adddyinstead of the correctdx. Now write a test that draws a steep line -- say(0,0)to(2,8), whereyis the long axis -- and asserts a midpoint pixel like(1,4)is lit. Watch the broken version fail (or loop), then restore thedxand watch it pass. The lesson: the "hits both endpoints" test alone is not enough, because a shallow line barely exercises the y-branch -- you need a steep case to catch this class of bug.Thick lines. Write
drawThickLine(fb, x0, y0, x1, y1, width, color)that draws a linewidthpixels thick. The simplest correct approach: run Bresenham, and at each plotted point stamp a small filled square (or, better, a short perpendicular run) of the given width. Test that a horizontal thick line of width 3 lights three rows, and that the thickness clips at the canvas edge.Extend the error term to a curve. Bresenham's decision-variable idea is not limited to lines. Using nothing but integer add and compare, implement
drawCircle(fb, cx, cy, radius, color)for a circle centred at(cx, cy): track an integer error term as you step one octant from the top, and mirror each plotted point into the other seven octants by symmetry. Test that the four cardinal points (cx +/- radius,cy +/- radius) are lit. This is the exact same "integer decision variable" machinery, pointed at a rounder target.
What we learned
- Drawing a straight line between arbitrary pixels is deceptively hard: the slope-intercept approach is slow, gappy on steep slopes, and drifts;
- The DDA fixes the gaps by walking the longer axis, but still pays floating-point add, round and cast on every pixel -- unavailable on an FPU-less chip;
- Bresenham replaces all of that with a single integer error term: add the rise each step, and when it crosses the halfway mark, bump the minor axis and pay it back -- no floats, no division, no drift;
- The all-octant form uses
sx/sysign steps and two (non-exclusive)ifbranches so one loop draws every direction, including exact 45-degree diagonals; - Making the coordinate API
i64and clipping insidesetPixelputs the "geometry can be negative, memory cannot" boundary in exactly one place, so the line loop stays simple and still cannot overflow the buffer; - An
anytypeplotcontext decouples generating the pixels from consuming them, with zero runtime cost -- the same line drives a framebuffer, a counter, or a file; - We test drawing with no screen by asserting on read-back pixels, and the "both endpoints lit" plus "clips cleanly off-canvas" properties catch the overwhelming majority of bugs;
- C, Rust and Go all run the identical integer loop -- they differ only in what happens when your index leaves the buffer, and Zig gives you the raw speed and the safety in the same language.
We can draw any line now, at any angle, without a single floating-point operation -- and the error-term trick we just learned is not really about lines at all. It is about walking a discrete grid to approximate a continuous shape using only integers, and that idea generalises to rounder things than lines, as exercise 3 hints. That is exactly where we go next. Keep this drawLine handy -- like the framebuffer before it, everything from here builds on top of it.
Bedankt en tot de volgende keer! ;-)