Watercolour
Wet paint on wet paper does not stay where the brush put it.
The water spreads into the paper around it and carries the colour with it. Then the paper dries, and the colour stays where the water left it.
None of the brushes so far can do this. A brush decides each pixel once, when the dab lands. Watercolour keeps changing after the brush has gone.
So this is a separate experiment with its own state, and it runs over time:
Click or drag on the paper to drop water and pigment, then watch it spread and dry.
A grid of paper
The paper is cut into cells, 80 across and 50 down. On the 640 by 400 page, each cell is 8 by 8 pixels.
Each cell keeps three numbers:
- water: how wet the paper is, from 0 (dry) to 1 (soaked)
- mobile: pigment still floating in the water, which can move
- deposited: pigment that has settled into the paper, which cannot
pub const Watercolor = struct {
width: u32,
height: u32,
water: []f32,
mobile: []f32,
deposited: []f32,
/// How quickly pigment settles in each cell, from 0.6 to 1.4 times the
/// base rate. Made once, so the paper's grain stays where it is.
grain: []f32,
/// The second buffer for spreading. A step reads one buffer and writes
/// the other, then swaps them.
next: []f32,
/// How fast water and pigment spread. At most 0.25: see `spread`.
spread_rate: f32 = 0.18,
/// How much of its wetness the paper keeps after one second.
retention: f32 = 0.8,
/// How fast floating pigment settles, per second.
settle_rate: f32 = 0.8,
/// The pigment's colour.
color: [3]u8 = .{ 40, 90, 160 },
// ...
};A grid is stored as one long array, row by row. Cell (x, y) is at y * width + x. On a grid 80 wide, cell (3, 2) is number 2 × 80 + 3 = 163.
80 × 50 is 4,000 cells. One f32 field is 16,000 bytes, and all five fields are 80,000. The grid is coarse on purpose: the whole simulation fits in a small cache and runs 30 times a second on a slow laptop.
The picture on the page is these 4,000 cells, drawn smoothly scaled up. The blur from scaling is the soft edge watercolour should have.
Neighbours
Water spreads to the cells next to it: left, right, above and below.
At the edge of the paper, some of those do not exist. The checks happen on the coordinates, before an index is made:
/// Left, right, above and below, or null where that neighbour would be
/// off the paper. Checking the coordinate first stops a cell on the left
/// edge from reaching the right edge of the row above.
pub fn neighbours(w: Watercolor, x: u32, y: u32) [4]?usize {
return .{
if (x > 0) w.index(x - 1, y) else null,
if (x + 1 < w.width) w.index(x + 1, y) else null,
if (y > 0) w.index(x, y - 1) else null,
if (y + 1 < w.height) w.index(x, y + 1) else null,
};
}Checking the index instead would be a bug. The cell to the left of (0, 5) would be index 5 × 80 − 1 = 399, which is the last cell of row 4, on the far side of the paper.
A middle cell has four neighbours and a corner has two. Water at an edge has nowhere to go but back in, so the paper keeps all its water.
Spreading
Each step, every cell moves a little towards each of its neighbours:
next = current + rate × (sum of (neighbour − current))
With rate 0.18, a fully wet centre with four dry neighbours keeps 1 + 0.18 × (−4) = 0.28. Each neighbour gets 0.18. The total is still 1.
/// Spread `field` between neighbouring cells, reading the old values and
/// writing the new ones to a second buffer.
///
/// Each cell moves towards its neighbours by `spread_rate` times each
/// difference. A cell with four neighbours keeps `1 - 4 * rate` of its
/// own value, so `rate` must be at most 0.25 or that weight goes negative
/// and values can overshoot, flip sign and grow without limit.
///
/// `order` only exists to show that it does not matter. Because no cell
/// reads a value written in the same step, scanning backwards gives the
/// same result.
pub fn spread(w: *Watercolor, field: []f32, wet: ?[]const f32, order: Order) void {
const n = field.len;
for (0..n) |k| {
const i = if (order == .forward) k else n - 1 - k;
const x: u32 = @intCast(i % w.width);
const y: u32 = @intCast(i / w.width);
var change: f32 = 0;
for (w.neighbours(x, y)) |maybe| {
const j = maybe orelse continue;
var amount = w.spread_rate;
if (wet) |water| amount *= @min(@min(water[i], water[j]), 1);
change += amount * (field[j] - field[i]);
}
w.next[i] = field[i] + change;
}
@memcpy(field, w.next);
}Read the old grid, write a new one
spread reads from field and writes to next, then copies next back.
That matters. If it wrote into field as it went, the cell after a changed cell would read the new value, not the old one. The result would depend on the order the cells were visited in, and water would drift in the scanning direction.
With two buffers, every cell reads the state from before the step. The order parameter exists only for a test that scans backwards and gets the same answer.
A limit on the rate
A cell with four neighbours keeps 1 − 4 × rate of its own value.
At 0.25 that is 0. Above 0.25 it is negative. A cell would give away more water than it has, end below zero, and the next step would swing it the other way, further each time.
So the rate must stay at or below 0.25 for this rule. A test sets it to 0.4 and watches the centre go negative in one step.
Clamping values to 0 afterwards would hide the problem, and quietly create or destroy water. Keeping the rate in range keeps the model correct instead.
Pigment moves with water
Floating pigment spreads with the same rule, but only as fast as the paper is wet. Between two cells it moves in proportion to the wetness of the drier one:
if (wet) |water| amount *= @min(@min(water[i], water[j]), 1);
Between two dry cells nothing moves. That is why a dried stroke keeps its shape.
The amount moved from one cell to another is the same in both directions, so pigment is never created or lost by spreading.
Drying, with units
The paper should not dry twice as fast because the computer runs twice as many steps.
So the drying rate is stated per second: retention is how much of its water a cell keeps after one second. One step is dt seconds, so one step keeps retention to the power dt.
/// One step of 1/30 of a second.
pub fn step(w: *Watercolor) void {
// Water spreads to drier neighbours. Pigment spreads too, but only as
// far as the paper is wet: between two cells, it moves in proportion
// to the wetness of the drier one.
w.spread(w.water, null, .forward);
w.spread(w.mobile, w.water, .forward);
// Drying. `retention` is what is left after a whole second, so one
// step of `dt` seconds keeps `retention` to the power `dt`. Thirty
// steps then keep exactly `retention`, at any step size.
const keep = std.math.pow(f32, w.retention, dt);
for (w.water) |*v| v.* *= keep;
// Settling. A share of the floating pigment moves into the paper.
// Moved, not removed: the total pigment does not change.
for (w.mobile, w.deposited, w.grain) |*m, *d, g| {
const share = 1.0 - @exp(-w.settle_rate * g * dt);
const moved = m.* * share;
m.* -= moved;
d.* += moved;
}
}With retention 0.8, thirty steps of 1/30 of a second keep 0.8, and sixty keep 0.64. Two half seconds give the same answer as one second, because powers multiply: 0.8^0.5 × 0.8^0.5 = 0.8.
Settling
If the picture showed only water, it would vanish as the paper dried. So pigment has its own state.
Each step, a share of the floating pigment settles into the paper. The share comes from a rate per second, the same way drying does: the part left floating after dt seconds is exp(−rate × dt), so the part that settles is one minus that.
At rate 0.8, one step settles 1 − e^(−0.8/30), about 2.63%. From 0.6 floating, about 0.0158 settles.
The pigment is moved, not removed: mobile goes down by exactly what deposited goes up by. The total never changes.
Each cell multiplies the rate by its own grain, from 0.6 to 1.4, made once from a seed like the paper in Bristles and Paper. Pigment settles faster in some cells than others, which gives the speckled look real watercolour has, called granulation.
The clock belongs to the page
step takes no time argument. It always advances exactly 1/30 of a second.
The page keeps the clock. Each frame it adds the time that has passed to an accumulator, then runs whole steps while at least 1/30 of a second has built up:
while (accumulator >= DT && steps < MAX_STEPS_PER_FRAME) {
e.wc_step();
accumulator -= DT;
steps++;
}
if (steps === MAX_STEPS_PER_FRAME) accumulator = 0;
If 0.05 seconds have built up, one step runs and about 0.0167 seconds carry over to the next frame.
The cap of four steps matters on a slow machine. Without it, a frame that took too long would need more steps, which would make the next frame take longer still. With the cap, the experiment slows down instead and the page stays responsive.
Because the engine never sees a clock, a test can run 600 steps without waiting and check exactly what 20 seconds of drying does.
The same run on every build
The simulation uses pow and exp on f32 values. Those are where two builds of the same program could start to disagree.
So, like the brush demo, the engine carries a fixed run, three drops and three seconds, with a pinned hash of its picture. The native tests and the browser build’s self-test both compare with it, and the site is not published if they differ.
Checks
The cell numbering, and the neighbours at a corner and an edge:
test "a middle cell has four neighbours, a corner two, and an edge does not wrap" {
var w = try lab(80, 50);
defer w.deinit(testing.allocator);
var count: usize = 0;
for (w.neighbours(10, 10)) |n| count += @intFromBool(n != null);
try testing.expectEqual(@as(usize, 4), count);
count = 0;
for (w.neighbours(0, 0)) |n| count += @intFromBool(n != null);
try testing.expectEqual(@as(usize, 2), count);
// The left neighbour of (0, 5) would be index 399, the end of row 4.
try testing.expectEqual(@as(?usize, null), w.neighbours(0, 5)[0]);
}The spreading example, 0.28 and 0.18:
test "a wet centre at rate 0.18 keeps 0.28 and gives each neighbour 0.18" {
var w = try lab(5, 5);
defer w.deinit(testing.allocator);
w.water[w.index(2, 2)] = 1;
w.spread(w.water, null, .forward);
try testing.expectApproxEqAbs(@as(f32, 0.28), w.water[w.index(2, 2)], 1e-6);
try testing.expectApproxEqAbs(@as(f32, 0.18), w.water[w.index(1, 2)], 1e-6);
try testing.expectApproxEqAbs(@as(f32, 0.18), w.water[w.index(2, 3)], 1e-6);
}Scanning backwards changes nothing:
test "reading the old grid makes the scan order irrelevant" {
var a = try lab(20, 12);
defer a.deinit(testing.allocator);
var b = try lab(20, 12);
defer b.deinit(testing.allocator);
a.drop(6, 6, 3, 1, 0);
b.drop(6, 6, 3, 1, 0);
for (0..10) |_| {
a.spread(a.water, null, .forward);
b.spread(b.water, null, .reverse);
}
try testing.expectEqualSlices(f32, a.water, b.water);
}With drying off, all the water is still there after 300 steps, and none of it is negative:
test "spreading keeps the total and never goes negative" {
var w = try lab(30, 20);
defer w.deinit(testing.allocator);
w.retention = 1; // no drying, so all the water must still be there
w.drop(5, 5, 4, 1, 0.5);
const water = Watercolor.total(w.water);
for (0..300) |_| w.step();
try testing.expectApproxEqRel(water, Watercolor.total(w.water), 1e-4);
for (w.water) |v| try testing.expect(v >= 0);
for (w.mobile) |v| try testing.expect(v >= 0);
}Drying, 0.8 then 0.64:
test "drying at 0.8 a second leaves 0.8 after one second and 0.64 after two" {
var w = try lab(3, 3);
defer w.deinit(testing.allocator);
@memset(w.water, 1);
for (0..30) |_| w.step();
try testing.expectApproxEqAbs(@as(f32, 0.8), w.water[0], 1e-4);
for (0..30) |_| w.step();
try testing.expectApproxEqAbs(@as(f32, 0.64), w.water[0], 1e-4);
}And no pigment is lost while it settles:
test "settling moves pigment into the paper and loses none" {
var w = try lab(30, 20);
defer w.deinit(testing.allocator);
w.drop(15, 10, 5, 1, 0.6);
const pigment = Watercolor.total(w.mobile);
for (0..600) |_| w.step();
const now = Watercolor.total(w.mobile) + Watercolor.total(w.deposited);
try testing.expectApproxEqRel(pigment, now, 1e-4);
// After twenty seconds nearly all of it has settled.
try testing.expect(Watercolor.total(w.deposited) > 0.95 * pigment);
}Explain before reading the answer
Does rate 0.18 mean that real paper spreads water at some measured speed?
No. It is a number that makes this grid behave like wet paper, within the limit that keeps the rule stable. A finer grid would need a different number for the same look, because each step would move water a shorter distance. The units that are real here are the ones for drying and settling, which are stated per second.