Glyph8x8 type, a drawGlyph routine that reads those bits, and why an unlit bit is exactly the color-key transparency we built last episode;Font type that maps an ASCII byte to a glyph, with a clean ?Glyph return so a space or an unknown character draws nothing in stead of crashing;drawText that walks a string, advances a cursor, and handles newlines -- the whole of "put words on the screen" in a dozen lines;Framebuffer, Rgba and clipping setPixel/getPixel from episode 156, and the sprite blit from episode 162 -- a glyph is just a very small sprite, so today builds straight on top of that;if (x) |y| capture (episode 4), and the testing habits from episode 12;Learn Zig Series):Last episode ended on a small confession. We can stamp sprites all over a scrolling world, cheaply and safely, but every sprite so far has been pixels we conjured by hand into a test array -- a red square, a blue tile. Perfectly fine for a bush, useless for the single most common thing any program needs to put on a screen: words. A game shows a score, a debugger prints registers, a tiny embedded gadget flashes an error code. And the punchline I dropped at the end is the whole idea of today: a letter is just a very small sprite with a very particular shape. Draw the shape of an 'A' once, keep it in a table, and rendering text becomes nothing more than stamping the right little sprites in a row. That table of letter-shapes is a bitmap font, and by the end of this episode we will have built one, drawn strings with it, made it fast, and tested every corner. Here we go!
Three exercises last time, all extending the sprite blit and the tile map. Here are my solutions.
Exercise 1 -- a flip flag. The task was to add a flip_h: bool to blit so a sprite can be mirrored left-to-right, reading column sprite.width - 1 - sx instead of sx. The change is one line in the read; everything else, transparency and clipping, is untouched. The test blits an asymmetric strip both ways and asserts the mirror is the column-reversed original:
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 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 Sprite = struct {
pixels: []const Rgba,
width: usize,
height: usize,
pub fn at(self: Sprite, x: usize, y: usize) Rgba {
return self.pixels[y * self.width + x];
}
};
// flip_h mirrors the sprite left-to-right by reading column (width-1-sx) instead of sx.
fn blit(fb: *Framebuffer, sprite: Sprite, dst_x: i64, dst_y: i64, flip_h: bool) void {
var sy: usize = 0;
while (sy < sprite.height) : (sy += 1) {
var sx: usize = 0;
while (sx < sprite.width) : (sx += 1) {
const read_x = if (flip_h) sprite.width - 1 - sx else sx;
const px = sprite.at(read_x, sy);
if (px.a == 0) continue;
fb.setPixel(dst_x + @as(i64, @intCast(sx)), dst_y + @as(i64, @intCast(sy)), px);
}
}
}
test "flip_h draws the column-reversed sprite" {
// an asymmetric 3x1 sprite: red, green, blue
const data = [_]Rgba{
.{ .r = 255, .g = 0, .b = 0 },
.{ .r = 0, .g = 255, .b = 0 },
.{ .r = 0, .g = 0, .b = 255 },
};
const sprite = Sprite{ .pixels = &data, .width = 3, .height = 1 };
var normal: [3]Rgba = undefined;
var flipped: [3]Rgba = undefined;
var fn_ = Framebuffer{ .pixels = &normal, .width = 3, .height = 1 };
var ff = Framebuffer{ .pixels = &flipped, .width = 3, .height = 1 };
@memset(fn_.pixels, .{ .r = 0, .g = 0, .b = 0 });
@memset(ff.pixels, .{ .r = 0, .g = 0, .b = 0 });
blit(&fn_, sprite, 0, 0, false);
blit(&ff, sprite, 0, 0, true);
// flipped column i must equal normal column (2 - i)
try std.testing.expectEqual(fn_.getPixel(0, 0).?.r, ff.getPixel(2, 0).?.r);
try std.testing.expectEqual(fn_.getPixel(2, 0).?.b, ff.getPixel(0, 0).?.b);
}
The key insight is that mirroring is a read-side transform: you write the destination pixels in the same left-to-right order, but you read the source in reverse. That keeps clipping and transparency exactly as they were.
Exercise 2 -- animated tiles. The task was to let a tile index map to a short sequence of sprites cycled by a frame counter (flowing water, a flickering torch). The cleanest shape is a small AnimTile that owns its frames and hands back the right one for a given frame number, wrapping with a modulo so the animation loops forever:
const std = @import("std");
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
pub const Sprite = struct {
pixels: []const Rgba,
width: usize,
height: usize,
pub fn at(self: Sprite, x: usize, y: usize) Rgba {
return self.pixels[y * self.width + x];
}
};
// An animated tile is just a short sequence of sprites cycled by a frame counter.
pub const AnimTile = struct {
frames: []const Sprite,
pub fn frameFor(self: AnimTile, frame: u64) Sprite {
return self.frames[@intCast(frame % self.frames.len)];
}
};
test "an animated tile shows different frames as the counter advances, and wraps" {
const off = [_]Rgba{.{ .r = 0, .g = 0, .b = 0 }};
const on = [_]Rgba{.{ .r = 0, .g = 0, .b = 255 }};
const water = AnimTile{ .frames = &[_]Sprite{
.{ .pixels = &off, .width = 1, .height = 1 },
.{ .pixels = &on, .width = 1, .height = 1 },
} };
try std.testing.expectEqual(@as(u8, 0), water.frameFor(0).at(0, 0).b);
try std.testing.expectEqual(@as(u8, 255), water.frameFor(1).at(0, 0).b);
try std.testing.expectEqual(@as(u8, 0), water.frameFor(2).at(0, 0).b); // wrapped back to frame 0
}
The modulo is the whole trick: a monotonically increasing frame counter driving frame % frames.len gives you a loop of any length without ever storing "which frame am I on" per tile. Your draw routine just passes the global frame number in.
Exercise 3 -- a multi-layer map. Real levels stack a background, the solid world, and a foreground drawn over the player. Represent the level as an array of TileMaps sharing one TileSet, and draw them back-to-front. Because each cell's empty_tile is skipped, a hole in the foreground lets the layer below show through -- and because later layers draw last, they win where they overlap:
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 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 Sprite = struct {
pixels: []const Rgba,
width: usize,
height: usize,
pub fn at(self: Sprite, x: usize, y: usize) Rgba {
return self.pixels[y * self.width + x];
}
};
fn blit(fb: *Framebuffer, sprite: Sprite, dst_x: i64, dst_y: i64) void {
var sy: usize = 0;
while (sy < sprite.height) : (sy += 1) {
var sx: usize = 0;
while (sx < sprite.width) : (sx += 1) {
const px = sprite.at(sx, sy);
if (px.a == 0) continue;
fb.setPixel(dst_x + @as(i64, @intCast(sx)), dst_y + @as(i64, @intCast(sy)), px);
}
}
}
pub const TileSet = struct { tiles: []const Sprite, tile_w: usize, tile_h: usize };
pub const TileMap = struct {
indices: []const u16,
cols: usize,
rows: usize,
pub const empty_tile: u16 = std.math.maxInt(u16);
pub fn at(self: TileMap, col: usize, row: usize) u16 {
return self.indices[row * self.cols + col];
}
};
fn drawTileMap(fb: *Framebuffer, map: TileMap, set: TileSet, cam_x: i64, cam_y: i64) void {
var row: usize = 0;
while (row < map.rows) : (row += 1) {
var col: usize = 0;
while (col < map.cols) : (col += 1) {
const idx = map.at(col, row);
if (idx == TileMap.empty_tile) continue;
blit(fb, set.tiles[idx], @as(i64, @intCast(col * set.tile_w)) - cam_x, @as(i64, @intCast(row * set.tile_h)) - cam_y);
}
}
}
// Layers share one TileSet and are drawn back-to-front: later layers paint over earlier ones.
fn drawLayers(fb: *Framebuffer, layers: []const TileMap, set: TileSet, cam_x: i64, cam_y: i64) void {
for (layers) |layer| drawTileMap(fb, layer, set, cam_x, cam_y);
}
test "a foreground tile overwrites the background, and an empty foreground cell lets it show through" {
const red = [_]Rgba{.{ .r = 255, .g = 0, .b = 0 }};
const blue = [_]Rgba{.{ .r = 0, .g = 0, .b = 255 }};
const tiles = [_]Sprite{
.{ .pixels = &red, .width = 1, .height = 1 },
.{ .pixels = &blue, .width = 1, .height = 1 },
};
const set = TileSet{ .tiles = &tiles, .tile_w = 1, .tile_h = 1 };
const e = TileMap.empty_tile;
// two cells wide, one row. background: red in both cells.
const bg_cells = [_]u16{ 0, 0 };
// foreground: blue over cell 0, empty over cell 1.
const fg_cells = [_]u16{ 1, e };
const background = TileMap{ .indices = &bg_cells, .cols = 2, .rows = 1 };
const foreground = TileMap{ .indices = &fg_cells, .cols = 2, .rows = 1 };
var pixels: [2]Rgba = undefined;
var fb = Framebuffer{ .pixels = &pixels, .width = 2, .height = 1 };
@memset(fb.pixels, .{ .r = 0, .g = 0, .b = 0 });
drawLayers(&fb, &[_]TileMap{ background, foreground }, set, 0, 0);
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(0, 0).?.b); // foreground blue won cell 0
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(1, 0).?.r); // background red shows in cell 1
}
Draw order is the compositing rule here, and it is worth internalising because we lean on the exact same idea for text: draw the background, then stamp the ink on top. Right, exercises done. On to the letters.
Here is the leap that makes fonts cheap. Last episode a sprite was []const Rgba -- four bytes per pixel. An 8x8 letter that way would be 256 bytes, and a full 95-character printable ASCII set would be nearly 25 KB of pixel data, most of it identical black. But a letter is really a two-color thing: a pixel is either ink or it is not. So do not store colors -- store one bit per pixel. An 8x8 glyph collapses to eight bytes, one byte per row, and the whole printable ASCII set fits in under 800 bytes. That is a bitmap font: a table where each character is a handful of bytes whose bits spell out the shape.
const std = @import("std");
// One glyph in an 8x8 bitmap font: eight bytes, one per row.
// In each byte the most-significant bit is the LEFTMOST pixel of that row.
// A set bit means "ink", a clear bit means "leave the background alone".
pub const Glyph8x8 = [8]u8;
// The capital letter 'H'. Read the 1s and you can see it:
// .#....#.
// .#....#.
// .#....#.
// .######.
// .#....#.
// .#....#.
// .#....#.
// ........
pub const glyph_H: Glyph8x8 = .{
0b01000010,
0b01000010,
0b01000010,
0b01111110,
0b01000010,
0b01000010,
0b01000010,
0b00000000,
};
test "a glyph is 8 bytes and its bits describe the shape" {
// 8 rows, and the crossbar row has six ink pixels (columns 1..6).
try std.testing.expectEqual(@as(usize, 8), glyph_H.len);
try std.testing.expectEqual(@as(u8, 6), @popCount(glyph_H[3]));
// the top row has exactly the two verticals lit
try std.testing.expectEqual(@as(u8, 2), @popCount(glyph_H[0]));
}
The reason I write the rows as binary literals rather than hex is honesty: 0b01000010 literally is the picture, ink where the ones are. This is exactly how the old fonts were authored -- the classic IBM PC ROM font is a table of bytes just like this, and you can still find .h files where someone hand-drew each letter in binary. Nota bene the choice that the most-significant bit is the leftmost pixel; that convention is arbitrary but must be consistent, because the drawing code and the glyph data have to agree on which way a row reads. I use @popCount in the test as a cheap sanity check -- counting the set bits of a row tells me how many ink pixels it has without caring where they are, which is a nice way to pin the shape without transcribing all 64 bits.
Drawing is the mirror of the storage. Walk the eight rows, walk the eight columns, and for every set bit write the ink color -- and for every clear bit do nothing, which is precisely the color-key transparency from last episode. An unlit bit is a transparent pixel, so text lands cleanly on any background. Around the single-glyph routine we wrap a Font: a slice of glyphs plus the ASCII code of the first entry, so a byte maps to a glyph by simple subtraction, and a clean ?Glyph8x8 return that says "I have no drawing for this" in stead of reaching past the table.
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 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 Glyph8x8 = [8]u8;
// Stamp one glyph at (x, y). We touch the framebuffer only for set bits, so the
// unlit pixels of the glyph act as transparency -- the background shows through.
pub fn drawGlyph(fb: *Framebuffer, glyph: Glyph8x8, x: i64, y: i64, ink: Rgba) void {
var row: usize = 0;
while (row < 8) : (row += 1) {
const bits = glyph[row];
var col: usize = 0;
while (col < 8) : (col += 1) {
const shift: u3 = @intCast(7 - col);
if (((bits >> shift) & 1) == 1) {
fb.setPixel(x + @as(i64, @intCast(col)), y + @as(i64, @intCast(row)), ink);
}
}
}
}
// A font is a table of glyphs plus the ASCII code of its first entry, and how far
// the cursor moves per character and per line.
pub const Font = struct {
glyphs: []const Glyph8x8,
first: u8,
advance: i64 = 8,
line_height: i64 = 8,
// Return the glyph for a character, or null if this font has no drawing for it
// (a space, or anything outside the covered range). Null means "draw nothing".
pub fn glyphFor(self: Font, c: u8) ?Glyph8x8 {
if (c < self.first) return null;
const idx: usize = c - self.first;
if (idx >= self.glyphs.len) return null;
return self.glyphs[idx];
}
};
const glyph_H: Glyph8x8 = .{
0b01000010, 0b01000010, 0b01000010, 0b01111110,
0b01000010, 0b01000010, 0b01000010, 0b00000000,
};
const glyph_I: Glyph8x8 = .{
0b00111100, 0b00011000, 0b00011000, 0b00011000,
0b00011000, 0b00011000, 0b00111100, 0b00000000,
};
test "drawGlyph lights exactly the ink pixels and leaves the rest as background" {
var pixels: [8 * 8]Rgba = undefined;
var fb = Framebuffer{ .pixels = &pixels, .width = 8, .height = 8 };
@memset(fb.pixels, .{ .r = 0, .g = 0, .b = 0 });
const white = Rgba{ .r = 255, .g = 255, .b = 255 };
drawGlyph(&fb, glyph_H, 0, 0, white);
// the two verticals of the H are lit on the top row (columns 1 and 6)
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(1, 0).?.r);
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(6, 0).?.r);
// the gap between them is untouched background
try std.testing.expectEqual(@as(u8, 0), fb.getPixel(3, 0).?.r);
// the crossbar (row 3) fills the middle
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(3, 3).?.r);
}
test "glyphFor returns null for a space and for characters outside the font's range" {
const font = Font{ .glyphs = &[_]Glyph8x8{ glyph_H, glyph_I }, .first = 'H' };
try std.testing.expect(font.glyphFor('H') != null);
try std.testing.expect(font.glyphFor('I') != null);
try std.testing.expect(font.glyphFor(' ') == null); // below 'first'
try std.testing.expect(font.glyphFor('Z') == null); // past the end of the table
}
The bit test is the heart of it. (bits >> shift) & 1 isolates one bit, and shift = 7 - col walks from the leftmost pixel to the rightmost, honoring the convention we baked into the data. That u3 on the shift amount is not decoration -- Zig will not let you shift a u8 by a value that could exceed its bit width, so the shift amount must be a type that can only hold 0..7. The compiler is quietly proving, at the type level, that this shift can never be undefined behaviour. In C that same shift is a footgun you have to keep in your head.
The Font.glyphFor returning ?Glyph8x8 is the other design decision worth dwelling on. A real font never covers every byte -- there is no glyph for a space (it is defined by drawing nothing), nor for control characters, nor for anything past its range. Returning an optional forces the caller to decide what "no glyph" means at the exact call site, and Zig will not let you forget: you cannot use the result without unwrapping it. Compare that to a C font routine returning a raw pointer that might be null, where forgetting the check is a segfault waiting for the first unusual character.
One glyph is a party trick; a line of text is the useful thing. drawText keeps a cursor, stamps each glyph the font knows, and advances the cursor by a fixed step per character. A newline sends the cursor back to the starting column and down one line. Characters the font does not cover simply advance the cursor -- a space is exactly "advance, draw nothing", which falls out for free from the ?Glyph8x8.
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 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 Glyph8x8 = [8]u8;
pub fn drawGlyph(fb: *Framebuffer, glyph: Glyph8x8, x: i64, y: i64, ink: Rgba) void {
var row: usize = 0;
while (row < 8) : (row += 1) {
const bits = glyph[row];
var col: usize = 0;
while (col < 8) : (col += 1) {
const shift: u3 = @intCast(7 - col);
if (((bits >> shift) & 1) == 1) {
fb.setPixel(x + @as(i64, @intCast(col)), y + @as(i64, @intCast(row)), ink);
}
}
}
}
pub const Font = struct {
glyphs: []const Glyph8x8,
first: u8,
advance: i64 = 8,
line_height: i64 = 8,
pub fn glyphFor(self: Font, c: u8) ?Glyph8x8 {
if (c < self.first) return null;
const idx: usize = c - self.first;
if (idx >= self.glyphs.len) return null;
return self.glyphs[idx];
}
};
// Walk the bytes of a string, stamping each glyph and advancing the cursor.
// A newline returns the cursor to the starting column and drops down one line.
// Characters the font does not cover simply advance the cursor (a blank space).
pub fn drawText(fb: *Framebuffer, font: Font, text: []const u8, x: i64, y: i64, ink: Rgba) void {
var cx = x;
var cy = y;
for (text) |c| {
if (c == '\n') {
cx = x;
cy += font.line_height;
continue;
}
if (font.glyphFor(c)) |g| drawGlyph(fb, g, cx, cy, ink);
cx += font.advance;
}
}
const glyph_H: Glyph8x8 = .{
0b01000010, 0b01000010, 0b01000010, 0b01111110,
0b01000010, 0b01000010, 0b01000010, 0b00000000,
};
const glyph_I: Glyph8x8 = .{
0b00111100, 0b00011000, 0b00011000, 0b00011000,
0b00011000, 0b00011000, 0b00111100, 0b00000000,
};
test "drawText lays glyphs left to right and wraps to a new line on newline" {
const font = Font{ .glyphs = &[_]Glyph8x8{ glyph_H, glyph_I }, .first = 'H' };
var pixels: [16 * 16]Rgba = undefined;
var fb = Framebuffer{ .pixels = &pixels, .width = 16, .height = 16 };
@memset(fb.pixels, .{ .r = 0, .g = 0, .b = 0 });
const white = Rgba{ .r = 255, .g = 255, .b = 255 };
drawText(&fb, font, "HI\nH", 0, 0, white);
// first glyph 'H': vertical at column 1, top row
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(1, 0).?.r);
// second glyph 'I' sits 8 pixels to the right: its top row spans columns 8+2..8+5
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(8 + 2, 0).?.r);
// after the newline the third glyph 'H' starts back at column 0, on the next line (y = 8)
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(1, 8).?.r);
// nothing was drawn on the second line to the right of that single 'H'
try std.testing.expectEqual(@as(u8, 0), fb.getPixel(8 + 1, 8).?.r);
}
That is the entire text engine most programs ever need. This is a fixed-width (or monospace) font: every character advances the cursor by the same eight pixels, which is why terminals and code editors line up so tidily into columns. A proportional font, where an 'i' is narrower than an 'm', needs one extra number per glyph -- a per-character advance -- and the loop changes by exactly one line: cx += font.advanceFor(c) in stead of a constant. Everything else, the stamping and the newline handling, stays identical. I leave the proportional version as an exercise, because the interesting decisions are all right here in the fixed-width case.
Graphics code is deceptively easy to get "looks right, is wrong". A glyph shifted one pixel, a row read upside-down, a transparent pixel that should have been ink -- none of it throws, it just looks slightly off, and slightly-off is exactly what your eye stops noticing after the tenth glance. So I do not test that text "looks like HI". I test specific pixels against the known bit pattern. You have already seen three flavours of it above, and they are worth naming because they generalise to any raster code:
The first is a known-glyph test: draw one glyph and assert that the pixels the bits say should be ink are ink, and the gap between them is not. That pins the bit-reading convention -- if I ever flip the most-significant-bit rule by accident, getPixel(1, 0) and getPixel(6, 0) stop being lit and the test goes red immediately. The second is a transparency test, folded into the same case: the background under a clear bit must survive, so I assert getPixel(3, 0) is still the background color. The third is a layout test: drawText("HI\nH", ...) checks that the second glyph landed eight pixels right, that the newline reset the column and dropped a line, and -- easy to forget -- that nothing spilled where nothing should be. That last negative assertion is the one that catches an off-by-one advance, the single most common bug in text layout. NB: testing what should be absent is at least as valuable as testing what should be present.
Text is drawn a lot -- a full screen of an 80x25 terminal is two thousand glyphs, redrawn many times a second, and each glyph is 64 bit-tests in the naive routine. Two easy wins pay off without changing a single visible pixel. First, most glyph rows are mostly empty and some are entirely empty (the blank row 8 of every letter, the gaps inside), so a single bits == 0 test skips a whole row of work. Second, when text runs off the edge of the screen we can compute the visible rectangle once and drop the per-pixel bounds check inside the loop -- the same pre-clipping trick we used for blitClipped last episode. And, as always, an optimization I cannot prove equal to the obvious version is just a new bug, so I keep the naive drawGlyph around as an oracle and pin the fast one against it at five positions including three that hang off the edges:
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 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 Glyph8x8 = [8]u8;
// The naive version, kept as an oracle to prove the fast one against.
fn drawGlyph(fb: *Framebuffer, glyph: Glyph8x8, x: i64, y: i64, ink: Rgba) void {
var row: usize = 0;
while (row < 8) : (row += 1) {
const bits = glyph[row];
var col: usize = 0;
while (col < 8) : (col += 1) {
const shift: u3 = @intCast(7 - col);
if (((bits >> shift) & 1) == 1) {
fb.setPixel(x + @as(i64, @intCast(col)), y + @as(i64, @intCast(row)), ink);
}
}
}
}
// Pre-clip the visible rectangle once, then skip whole blank rows with a single
// zero test, and write straight into the buffer with no per-pixel bounds check.
fn drawGlyphClipped(fb: *Framebuffer, glyph: Glyph8x8, x: i64, y: i64, ink: Rgba) void {
const fb_w: i64 = @intCast(fb.width);
const fb_h: i64 = @intCast(fb.height);
const start_col: usize = if (x < 0) @intCast(-x) else 0;
const start_row: usize = if (y < 0) @intCast(-y) else 0;
if (start_col >= 8 or start_row >= 8) return; // fully off the left or top
var end_col: usize = 8;
var end_row: usize = 8;
if (x + 8 > fb_w) {
const visible = fb_w - x;
if (visible <= 0) return; // fully off the right
end_col = @intCast(visible);
}
if (y + 8 > fb_h) {
const visible = fb_h - y;
if (visible <= 0) return; // fully off the bottom
end_row = @intCast(visible);
}
var row = start_row;
while (row < end_row) : (row += 1) {
const bits = glyph[row];
if (bits == 0) continue; // an entirely blank row: nothing to do
const dy: usize = @intCast(y + @as(i64, @intCast(row)));
var col = start_col;
while (col < end_col) : (col += 1) {
const shift: u3 = @intCast(7 - col);
if (((bits >> shift) & 1) == 1) {
const dx: usize = @intCast(x + @as(i64, @intCast(col)));
fb.pixels[dy * fb.width + dx] = ink; // safe: the rectangle was already clipped
}
}
}
}
const glyph_A: Glyph8x8 = .{
0b00011000, 0b00100100, 0b01000010, 0b01000010,
0b01111110, 0b01000010, 0b01000010, 0b00000000,
};
test "drawGlyphClipped writes exactly the same pixels as the naive glyph draw" {
const ink = Rgba{ .r = 200, .g = 100, .b = 50 };
const positions = [_][2]i64{ .{ 0, 0 }, .{ -3, -3 }, .{ 5, 2 }, .{ -2, 4 }, .{ 9, 9 } };
for (positions) |pos| {
var a: [12 * 12]Rgba = undefined;
var b: [12 * 12]Rgba = undefined;
var fa = Framebuffer{ .pixels = &a, .width = 12, .height = 12 };
var fb = Framebuffer{ .pixels = &b, .width = 12, .height = 12 };
@memset(fa.pixels, .{ .r = 0, .g = 0, .b = 0 });
@memset(fb.pixels, .{ .r = 0, .g = 0, .b = 0 });
drawGlyph(&fa, glyph_A, pos[0], pos[1], ink);
drawGlyphClipped(&fb, glyph_A, pos[0], pos[1], ink);
for (fa.pixels, fb.pixels) |pa, pb| {
try std.testing.expectEqual(pa.r, pb.r);
try std.testing.expectEqual(pa.g, pb.g);
}
}
}
The bits == 0 fast-out is a lovely little win precisely because bitmap fonts are so sparse: on a typical glyph a third of the rows are blank, and skipping them costs one comparison in stead of eight bit-tests. Having said that, I want to be careful not to oversell the micro-optimizations -- the honest performance story of a bitmap font is that it is already fast, because the data is tiny and cache-friendly and there is no floating point anywhere. The real reason to write drawGlyphClipped is not raw speed, it is that pre-clipping lets the innermost store be an unchecked array write that the compiler and I have both proven safe, in stead of leaning on setPixel's guard a few thousand times a frame. Correct first, fast second, proven equal always -- the same discipline from the profiling episode, pointed at glyphs.
This little engine is not a toy -- it is how an enormous amount of real text gets drawn. The text you see before an operating system finishes booting is a bitmap font baked into firmware; the classic 8x16 VGA ROM font has been drawing boot messages for forty years. The Linux console uses PSF (PC Screen Font) files, which are, at heart, exactly our table of glyph bytes plus a tiny header. The X11 world has BDF, a human-readable text format where each glyph is spelled out in hex rows -- open one in an editor and you will recognise our 0b01000010 idea immediately, just written in hex. Tiny embedded displays -- the little OLED and e-paper panels on hobbyist boards -- almost always drive text with a bitmap font, because they have no room for anything cleverer and no need for it. And every retro-styled game, every pixel-art UI, uses bitmap fonts on purpose, because at small sizes a hand-tuned bitmap is crisper than any scaled outline.
That last point is the crux, and it is also the wall we are about to hit. A bitmap font is locked to the size it was drawn at. Ask our 8x8 'A' to render at 32 pixels tall and your only options are to draw a separate 32-pixel table (real fonts ship several fixed sizes for just this reason) or to scale the 8x8 up by integer multiples, which gives you the chunky, stair-stepped look that is either charming retro or an ugly mess depending on what you were going for. There is no smooth in-between. To get a letter that is sharp at any size, you cannot store pixels at all -- you have to store the letter's outline, the curves and straight edges of its shape, and rasterize it fresh for whatever size you ask. That is a completely different, and much richer, way to think about type, and it is exactly where we go next.
The bit test is language-neutral; what changes, as ever, is who guarantees the shift is legal and who bounds-checks the write. In C you must remember that a shift by an out-of-range amount is undefined behaviour, and that indexing the framebuffer is unchecked:
// C: nothing stops you shifting by a bad amount or writing out of bounds
typedef struct { uint8_t r, g, b, a; } Rgba;
typedef struct { Rgba *pixels; int w, h; } Framebuffer;
void draw_glyph(Framebuffer *fb, const uint8_t glyph[8], int x, int y, Rgba ink) {
for (int row = 0; row < 8; row++) {
uint8_t bits = glyph[row];
for (int col = 0; col < 8; col++) {
if ((bits >> (7 - col)) & 1) {
int px = x + col, py = y + row;
if (px < 0 || py < 0 || px >= fb->w || py >= fb->h) continue; // YOU must remember
fb->pixels[py * fb->w + px] = ink;
}
}
}
}
Rust bounds-checks indexing for you, and like Zig it wants the shift amount kept sane -- an over-wide shift panics in debug in stead of being silent UB:
// Rust: indexing is bounds-checked; an out-of-range shift panics rather than being UB
struct Rgba { r: u8, g: u8, b: u8, a: u8 }
struct Framebuffer<'a> { pixels: &'a mut [Rgba], w: i64, h: i64 }
fn draw_glyph(fb: &mut Framebuffer, glyph: &[u8; 8], x: i64, y: i64, ink: Rgba) {
for row in 0..8i64 {
let bits = glyph[row as usize];
for col in 0..8i64 {
if (bits >> (7 - col)) & 1 == 1 {
let (px, py) = (x + col, y + row);
if px < 0 || py < 0 || px >= fb.w || py >= fb.h { continue; }
fb.pixels[(py * fb.w + px) as usize] = Rgba { r: ink.r, g: ink.g, b: ink.b, a: ink.a };
}
}
}
}
Go makes the slice a reference type and bounds-checks every write at runtime, so the loop reads almost like the Zig one:
// Go: slices are reference types; the runtime bounds-checks every write
type Rgba struct{ R, G, B, A uint8 }
type Framebuffer struct { Pixels []Rgba; W, H int }
func DrawGlyph(fb *Framebuffer, glyph [8]uint8, x, y int, ink Rgba) {
for row := 0; row < 8; row++ {
bits := glyph[row]
for col := 0; col < 8; col++ {
if (bits>>(7-col))&1 == 1 {
px, py := x+col, y+row
if px < 0 || py < 0 || px >= fb.W || py >= fb.H {
continue
}
fb.Pixels[py*fb.W+px] = ink
}
}
}
}
The through-line is the one we keep meeting. Everyone runs the identical bit test; they differ in how much the language protects you and what that costs. C is fastest and least forgiving -- one forgotten check and you are shifting into undefined behaviour or scribbling past the buffer. Go pays a runtime bounds check on every write. Rust and Zig both check in safe builds but let you drop the check where you have proven it redundant -- our drawGlyphClipped pre-clips exactly so the inner store needs no guard. And Zig adds the one thing none of the others do at the type level: the u3 shift amount makes an illegal shift a compile error, not a runtime surprise. Having said that, the shape of the code is the same everywhere, which is rather the point -- the algorithm is simple, and simple is what you want in the routine that draws every character on the screen.
A proportional font. Give each glyph its own width by adding a parallel advances: []const u8 slice to Font and an advanceFor(c) method. Change drawText so it advances the cursor by the per-glyph width in stead of the constant advance. Write a test that lays out two glyphs of different widths and asserts the second one starts at the first one's width, not at a fixed step.
Word wrapping. Write a drawTextWrapped that takes a maximum pixel width and, when the cursor would run past it, breaks to a new line at the last space in stead of mid-word. Test that a string longer than the limit wraps at the space, and that a single word longer than the limit still makes progress (does not loop forever).
A background box. Add an optional background color so each glyph cell is filled first and then the ink is stamped on top (readable text over a busy image). Give drawGlyph an extra ?Rgba parameter: when it is non-null, fill the whole 8x8 cell with it before drawing the bits. Write a test proving that a cleared bit shows the background color when a box is set, and shows the untouched framebuffer when it is null.
drawGlyph walks the rows and columns, writing the ink color only where a bit is set, so an unlit bit is exactly the color-key transparency from episode 162 -- text lands cleanly on any background;Font maps an ASCII byte to a glyph by subtraction from first, and returns ?Glyph8x8 so a space or an unknown character is "draw nothing" that the compiler forces you to handle;drawText is just a cursor that advances per character and resets on a newline; a fixed-width font uses a constant step, a proportional font uses one number per glyph and changes exactly one line;u3 shift amount turns an illegal shift into a compile error -- the one guarantee C, Rust and Go do not give you at compile time -- while the drawing loop itself is the same everywhere;Our renderer can finally put words on the screen, crisp and cheap, at the one size we drew them. But zoom in and the illusion breaks: pixels are pixels, and there is no smooth way to make an 8x8 'A' fill a headline. The fix is to stop storing pixels entirely and start storing the outline of each letter -- the lines and curves of its shape -- so we can rasterize it fresh at any size we like. Reading those outlines means opening up a real, on-disk font file and parsing its tables, which is a proper little binary-format adventure. And once we are drawing smooth edges, we will need to think a good deal harder about how colors actually mix on a screen. Plenty to build.
Thanks for reading, and tot de volgende keer! ;-)