A Paint Program You Can Test
Press Start painting and draw.
The engine this section builds, about 41 KB of WebAssembly. It loads when you press the button.
A mouse works, and so does a finger. A pen with pressure draws thicker when you press harder.
Try the Calligraphy brush. Lines in one direction come out thin, and lines across them come out wide.
Draw on the bottom layer, switch to the top layer, draw again, and erase there. The bottom layer shows through where you erase.
One rule
The program is in two parts.
The engine decides what every pixel becomes. It is about 1,600 lines of Zig, and about 750 more of tests.
The front end turns mouse and pen events into calls to the engine, and copies the engine’s pixels onto the canvas.
The rule is that the engine never sees a mouse, a window or a clock.
Points go in. Pixels come out.
The engine’s main type is a document. It holds the layers, the stroke being drawn, the history, and what the screen should show:
pub const Document = struct {
gpa: std.mem.Allocator,
width: u32,
height: u32,
/// Bottom first. Each one is `width * height` premultiplied pixels.
layers: [][]Rgba,
visible: []bool,
active_layer: u32 = 0,
/// Coverage of the stroke in progress.
mask: Mask,
/// The grain under the whole picture, for brushes with less than full
/// contact. Made once, so every stroke meets the same hills.
paper: Paper,
/// What the screen shows: every visible layer over white paper, with the
/// stroke in progress applied. Always opaque.
display: []Rgba,
/// The part of `display` that is out of date.
stale: Rect = .empty,
history: History,
stroke: ?Active = null,
/// False after an action whose undo could not be kept. A front end can
/// say so instead of letting Undo silently skip it.
history_ok: bool = true,
// ...
};A front end calls a few methods on it.
A stroke is beginStroke, some moveStroke calls, and endStroke. Each call takes a point: an x, a y and a pressure.
After that, render brings display up to date and says which rectangle changed.
The front end copies that rectangle to the screen. It does nothing else.
What the rule buys
Because the engine needs no window, a test can draw.
A test can start a stroke, move it, end it, and then check the pixels.
This one checks that a stroke looks the same while you draw it as it does after you let go:
test "a stroke shows while drawing, and looks the same after it is committed" {
var d = try newDoc();
defer d.deinit();
_ = d.render();
const blank = d.displayHash();
d.beginStroke(.{ .radius = 6, .color = .{ 200, 30, 30 } }, .brush, .{ .x = 10, .y = 10 });
d.moveStroke(.{ .x = 50, .y = 30 });
_ = d.render();
const preview = d.displayHash();
try testing.expect(preview != blank);
d.endStroke();
_ = d.render();
try testing.expectEqual(preview, d.displayHash());
}There are 60 tests like this, and none of them opens a window.
The same pixels everywhere
The engine runs in three places: in your browser, in a native window, and in the tests.
A test only proves something about the browser if the browser build draws the same pixels as the test build.
So the engine carries a fixed drawing. It uses every tool: a soft wash, a calligraphy nib, a ribbon, a dry bristle brush, an oil brush, spray, a fill, an eraser, and an undo and redo.
/// The display hash after `draw` on a fresh 640 by 400, two-layer document.
/// If a change to the engine changes the picture on purpose, run the tests,
/// look at the new picture, and update this.
pub const hash: u32 = 0x3870db57;A native test draws the picture and compares a hash of its pixels with that number.
Before the site is built, the browser build draws the same picture and compares with the same number.
If one pixel differs between the two builds, the build fails and this page is not published.
The browser build
The engine is plain Zig with no dependencies.
So the browser build needs no extra tools. It is the same compiler, pointed at wasm32-freestanding:
/// The browser build. Public so the guide's build.zig can call it with its own
/// path to `src/`, and both builds stay one definition.
pub fn addWeb(b: *std.Build, src: std.Build.LazyPath, optimize: std.lang.Optimize) *std.Build.Step.Compile {
const wasm_target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .freestanding });
const engine = b.createModule(.{
.root_source_file = src.path(b, "engine.zig"),
.target = wasm_target,
.optimize = optimize,
});
const exe = b.addExecutable(.{
.name = "paint",
.root_module = b.createModule(.{
.root_source_file = src.path(b, "web.zig"),
.target = wasm_target,
.optimize = optimize,
.imports = &.{.{ .name = "engine", .module = engine }},
}),
});
// A library of exported functions, not a program with a main: the page
// calls into it.
exe.entry = .disabled;
exe.rdynamic = true;
return exe;
}freestanding means there is no operating system at all. The engine does not need one, because it never opens a file or reads a clock.
The result is about 41 KB.
The chapters
The chapters follow the engine from the pointer to the PNG file.
- Strokes: turning uneven pointer samples into evenly spaced dabs.
- Smoothing, pressure and scatter: making a stroke look drawn by hand, and repeatable.
- The tip: soft edges, flat tips, and tips that turn.
- Bristles and paper: separate hair tracks, and a dry brush on paper grain.
- Oil: a brush that picks up the paint it is dragged through, and runs dry.
- Flow and opacity: why a stroke is built in its own buffer first.
- Layers and the eraser: premultiplied alpha, and why the eraser needs it.
- Undo: keeping only what changed, inside a memory budget.
- Fill: a flood fill with a queue instead of recursion.
- Saving: the picture as a PNG.
- Watercolour: water and pigment spreading and drying on a grid, a separate experiment you can run.
- A native window: the same engine with raylib, and cross-compiling it for Windows.
Each chapter shows the tests that check it.
Explain before reading the answer
The front end sends the engine every pointer sample the browser reports.
Some browsers report a fast movement as a few samples far apart. Others report the same movement as many samples close together.
Will the same movement draw the same stroke in both?
It will. The engine does not put one mark per sample. It walks along the path and puts a mark every few pixels, whatever the samples were. The next chapter shows how.