Layers and the Eraser
A layer is a sheet of clear plastic you can paint on.
Stack two of them over white paper. Paint on the top sheet and the bottom one is untouched. Wipe the top sheet clean, and the bottom one shows through again.
The engine has two of these layers, over white paper.
A layer is an array
A layer is one pixel per position, row by row, width * height of them.
Each pixel has four bytes: red, green, blue, and alpha. Alpha is how much of the pixel is covered. 0 is clear plastic and 255 is solid paint.
/// `extern` fixes the field order in memory. A row of these is the R, G, B, A
/// bytes a browser canvas expects, so the display needs no conversion.
pub const Rgba = extern struct {
r: u8,
g: u8,
b: u8,
a: u8,
pub const transparent: Rgba = .{ .r = 0, .g = 0, .b = 0, .a = 0 };
pub const white: Rgba = .{ .r = 255, .g = 255, .b = 255, .a = 255 };
pub fn opaqueRgb(c: [3]u8) Rgba {
return .{ .r = c[0], .g = c[1], .b = c[2], .a = 255 };
}
};extern fixes the order of the fields in memory: R, G, B, A, one byte each. That is the order a browser canvas expects, so the screen buffer can be copied to a canvas without converting anything. A plain Zig struct would let the compiler reorder the fields.
Two layers at 640 by 400 hold 2 × 640 × 400 × 4 = 2,048,000 bytes. The white paper is not stored anywhere. It is where the drawing starts.
Drawing the stack
To find what one screen pixel shows, start with white paper and lay each visible layer over it, from the bottom up:
/// One screen pixel: white paper, then each visible layer from the
/// bottom up.
fn composite(d: *const Document, x: u32, y: u32) Rgba {
const i = @as(usize, y) * d.width + x;
var out: Rgba = .white;
for (d.layers, d.visible, 0..) |layer, visible, li| {
if (!visible) continue;
var p = layer[i];
if (d.stroke) |active| {
if (active.layer == li) p = applyStroke(active, p, d.mask.coverage[i], d.mask.color[i]);
}
out = pixel.over(p, out);
}
return out;
}The order matters. A solid pixel on the top layer hides the bottom layer. Swap the layers and it is the other way round.
The engine does this only for the rectangle that changed. A dab touches a few hundred pixels, so a few hundred pixels are recomposed.
Premultiplied alpha
There are two ways to store a half-covered red pixel.
Straight alpha stores the colour and the coverage separately: (255, 0, 0, 128). Full red, half covered.
Premultiplied alpha stores the colour already multiplied by the coverage: (128, 0, 0, 128).
The layers here are premultiplied, and the reason is the eraser.
The eraser
Painting white and erasing look the same on white paper.
They are different over another layer. White paint covers what is below it. The eraser removes paint from its own layer, so what is below shows through.
Erasing means “keep less of this pixel”. With premultiplied values that is the same multiply on all four channels:
/// Remove `amount` (0 to 65535) of `dst`. White paint and the eraser look
/// the same on white paper. Over another layer, white covers it and the
/// eraser reveals it.
pub fn erase(dst: Rgba, amount: u32) Rgba {
const keep = full - amount;
return .{
.r = @intCast(scale(dst.r, keep)),
.g = @intCast(scale(dst.g, keep)),
.b = @intCast(scale(dst.b, keep)),
.a = @intCast(scale(dst.a, keep)),
};
}Erase a pixel completely and every channel becomes 0. No colour is left behind.
With straight alpha, a fully erased red pixel would be (255, 0, 0, 0): invisible, but still red. That hidden red is a known source of bugs. Scale or blur the layer, and the invisible red gets averaged with its neighbours and comes back as a red fringe along every erased edge.
Painting and combining
Painting lays a colour over a pixel with a given coverage:
/// Lay `color` over `dst` with coverage `amount` (0 to 65535).
pub fn paint(dst: Rgba, color: [3]u8, amount: u32) Rgba {
const keep = full - amount;
return .{
.r = @intCast(scale(color[0], amount) + scale(dst.r, keep)),
.g = @intCast(scale(color[1], amount) + scale(dst.g, keep)),
.b = @intCast(scale(color[2], amount) + scale(dst.b, keep)),
.a = @intCast(scale(255, amount) + scale(dst.a, keep)),
};
}Combining two layers is the “over” operator from the Alpha Blending chapter, without its first multiply. The colour in top is already multiplied by its alpha:
/// `top` over `below`, both premultiplied. This is the operator from the
/// Alpha Blending chapter, without its multiply: the colour in `top` is
/// already scaled by its alpha.
pub fn over(top: Rgba, below: Rgba) Rgba {
const keep = (255 - @as(u32, top.a)) * 257; // 255 * 257 = 65535
return .{
.r = @intCast(top.r + scale(below.r, keep)),
.g = @intCast(top.g + scale(below.g, keep)),
.b = @intCast(top.b + scale(below.b, keep)),
.a = @intCast(top.a + scale(below.a, keep)),
};
}All of this is integer arithmetic, rounded to the nearest whole number. Rounding twice in one sum can go wrong: two halves that both round up make the total one too big. In a u8 that is 256, which does not fit, and @intCast would stop a safe build.
That cannot happen here, but the reason is not obvious. So a test checks every one of the 65,536 coverage values:
test "paint never overflows a byte, at any coverage" {
// The two rounded parts add up to at most 255: their exact values add up
// to 255, and both can round up only if both end in exactly .5, which
// `amount / 257` never does. Checked here rather than trusted.
var amount: u32 = 0;
while (amount <= full) : (amount += 1) {
const out = paint(.white, .{ 255, 255, 255 }, amount);
try std.testing.expectEqual(@as(u8, 255), out.a);
try std.testing.expectEqual(@as(u8, 255), out.r);
}
}Blending in bytes, not light
The Linear Light chapter shows that mixing stored bytes is not the same as mixing light.
This engine mixes bytes, the way a browser canvas does, and the way most 8-bit paint programs do.
That is a choice. It keeps every layer at 4 bytes a pixel, and the colours behave the way people who have used other paint programs expect. A program that mixed in linear light would need 8 bytes a pixel to keep the dark tones, which doubles every number in this chapter.
Checks
A half-covered red pixel, stored premultiplied:
test "half red on an empty layer is stored premultiplied" {
const p = paint(.transparent, .{ 255, 0, 0 }, full / 2 + 1);
try std.testing.expectEqual(Rgba{ .r = 128, .g = 0, .b = 0, .a = 128 }, p);
}The eraser and white paint, on the top layer over a red bottom layer:
test "the eraser reveals the layer below, white paint covers it" {
var d = try newDoc();
defer d.deinit();
const red: [3]u8 = .{ 220, 0, 0 };
// Bottom layer: solid red everywhere.
_ = try d.fillAt(0, 0, red);
// Top layer: solid blue everywhere.
_ = d.setActiveLayer(1);
_ = try d.fillAt(0, 0, .{ 0, 0, 220 });
const hard: Brush = .{ .radius = 5, .hardness = 1, .flow = 1, .color = .{ 255, 255, 255 } };
d.beginStroke(hard, .eraser, .{ .x = 16, .y = 24 });
d.endStroke();
d.beginStroke(hard, .brush, .{ .x = 48, .y = 24 });
d.endStroke();
_ = d.render();
try testing.expectEqual(Rgba.opaqueRgb(red), d.display[24 * 64 + 16]); // erased: red shows
try testing.expectEqual(Rgba.white, d.display[24 * 64 + 48]); // white paint
}Explain before reading the answer
The top layer has a solid blue pixel, (0, 0, 220, 255). You erase it at 50%.
What does the pixel store, and what does the screen show over a red bottom layer?
It stores (0, 0, 110, 127): half of each channel of (0, 0, 220, 255), rounded.
On screen that is half the blue over the red: (110, 0, 110), a purple.
Painting red at 50% over the blue would show the same purple. The difference comes later. Change the bottom layer to green, and the erased pixel shows blue over green, while the painted one still shows the red you painted.