⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.18.0-dev.246+05671ced9What's newOn an older Zig?Blog

Strokes

Updated

A brush stroke is a row of marks called dabs.

Each dab is one stamp of the brush tip. Put enough of them close together and they look like one continuous line, if they are spaced evenly.

The obvious answer is wrong

The pointer reports where it is, again and again, while the button is held. Each report is a sample.

The obvious answer is one dab per sample.

That fails for a simple reason. Samples do not arrive at even distances.

The browser sends them when it gets round to it, about once per screen frame. A slow movement gives samples one or two pixels apart. A fast movement gives samples 40 pixels apart, with nothing in between.

So one dab per sample draws a solid line when you move slowly, and a row of separate dots when you move fast.

Distance and lerp

We need two small tools first.

The distance between two points is the length of the straight line from one to the other:

examples/paint/src/engine/geometry.zigdistance
/// Straight-line distance, in pixels. Moving backwards gives the same distance.
pub fn distance(a: Point, b: Point) f32 {
    return std.math.hypot(b.x - a.x, b.y - a.y);
}

From (10, 20) to (40, 60), the change in x is 30 and the change in y is 40. The distance is √(900 + 1600) = 50 pixels.

Moving backwards gives negative changes, but the same distance.

lerp finds the value a fraction t of the way from a to b:

examples/paint/src/engine/geometry.ziglerp
/// The value a fraction `t` of the way from `a` to `b`. `t` is not clamped:
/// `t = 1.5` goes past `b`.
pub fn lerp(a: f32, b: f32, t: f32) f32 {
    return a + (b - a) * t;
}

From 20 to 100, t = 0.25 gives 40. t = 0 gives 20 and t = 1 gives 100.

t = 1.5 gives 140, past the end. lerp does not clamp, and the stroke code never needs it to.

Walking the path

The fix is to stop caring where the samples are.

Between two samples the pointer moved along a line. We walk along that line and put a dab every gap pixels.

The gap comes from the brush. It is a fraction of the radius, so a big brush puts its dabs further apart than a small one:

examples/paint/src/engine/stroke.ziggap
    /// The gap between dabs, in pixels. Never below half a pixel, or a tiny
    /// brush would put thousands of dabs on one short segment.
    fn gap(s: Stroke) f32 {
        return @max(s.brush.spacing * s.brush.radius, 0.5);
    }

A spacing of 0.25 on a brush of radius 40 gives a gap of 10 pixels.

The leftover distance

Walking each segment on its own still has a problem.

Say the gap is 10 and the pointer moves 6 pixels. No dab fits in 6 pixels.

Then it moves another 6. If each segment starts counting from zero, no dab fits in that one either. The pointer has moved 12 pixels and the stroke has no dab after the first.

So we remember how far we have walked since the last dab. That distance is called the carry.

After the first 6-pixel move, the carry is 6.

The next segment does not need a whole gap before its first dab. It needs only the 4 pixels that complete the interval: 10 − 6 = 4.

Here is move, the function each new sample goes through:

examples/paint/src/engine/stroke.zigmove
    /// The pointer moved to `p`. Place every dab that falls on the way.
    pub fn move(s: *Stroke, p: Point, sink: anytype) void {
        // Smoothing: move part of the way towards the real point.
        s.filtered = geometry.lerpPoint(s.filtered, p, s.brush.follow);

        const a = s.last;
        const b = s.filtered;
        const d = geometry.distance(a, b);
        // Two samples at the same place: nothing to walk, and dividing by
        // `d` below would give NaN.
        if (d == 0) return;
        s.direction = std.math.atan2(b.y - a.y, b.x - a.x);

        const step = s.gap();
        // The first dab on this segment completes the interval left over from
        // the last one.
        var at = step - s.carry;
        while (at <= d) : (at += step) {
            // The load drains by distance, not by dab or by sample: one gap
            // further along the path uses `drain * step / 100` of a load.
            s.load = @max(s.load - s.brush.drain * step / 100.0, 0);
            s.emit(geometry.lerpPoint(a, b, at / d), sink);
        }
        s.carry = d - (at - step);
        s.last = b;
    }

at is the distance along this segment where the next dab goes. It starts at step - s.carry, which is the 4 pixels in our example.

The loop places a dab, adds a gap, and repeats while at still falls inside the segment.

When the loop ends, at has gone one gap past the last dab. So at - step is where the last dab was, and d - (at - step) is how far the segment ran on after it. That is the new carry.

After the loop, the carry is always less than one gap.

Two smaller details

Two samples can arrive at exactly the same position. The distance is then 0, and at / d would be a division by zero, giving NaN. NaN in a position puts a dab nowhere and poisons every calculation after it. So move returns early when d == 0.

A stroke must also leave a mark when you click without moving. So begin places the first dab at the starting point, and resets the carry to 0:

examples/paint/src/engine/stroke.zigbegin
    /// Start a stroke at `p` and put the first dab there, so a click with no
    /// movement still leaves a mark.
    pub fn begin(brush: Brush, p: Point, sink: anytype) Stroke {
        const b = brush.sanitized();
        var s: Stroke = .{
            .brush = b,
            .random = .init(b.seed),
            .filtered = p,
            .last = p,
            .carry = 0,
            .direction = 0,
            .loaded = .{ @floatFromInt(b.color[0]), @floatFromInt(b.color[1]), @floatFromInt(b.color[2]) },
            .load = 1,
        };
        s.emit(p, sink);
        return s;
    }

Where the dabs go

move does not draw anything. It calls sink.dab(...) and lets the caller decide.

sink is anytype, so any value with a dab method works. Zig checks this at compile time, separately for each type that is passed in.

The document passes a painter that stamps each dab into the stroke buffer. A test passes a recorder that only keeps a list:

examples/paint/src/engine/stroke.zigRecorder
/// A sink for tests: keeps the dabs it is given.
const Recorder = struct {
    dabs: [256]Dab = undefined,
    len: usize = 0,
    /// What `sample` reports as the paint under every dab.
    under: ?Sample = null,

    fn dab(r: *Recorder, d: Dab) void {
        r.dabs[r.len] = d;
        r.len += 1;
    }

    fn sample(r: *Recorder, x: f32, y: f32) ?Sample {
        _ = x;
        _ = y;
        return r.under;
    }
};

The stroke code is the same in both. Only what happens to the dabs changes.

Checks

The worked example from above, as a test. Three moves of 6 pixels with a gap of 10:

examples/paint/src/engine/stroke.zigcarry after three moves of 6 pixels with a gap of 10 is 8
test "carry after three moves of 6 pixels with a gap of 10 is 8" {
    // Radius 40 at spacing 0.25 is a gap of 10 pixels.
    const brush: Brush = .{ .radius = 40, .spacing = 0.25 };
    var rec: Recorder = .{};
    var s = Stroke.begin(brush, .{ .x = 0, .y = 0 }, &rec);
    s.move(.{ .x = 6, .y = 0 }, &rec);
    try std.testing.expectEqual(@as(f32, 6), s.carry);
    s.move(.{ .x = 12, .y = 0 }, &rec);
    s.move(.{ .x = 18, .y = 0 }, &rec);
    try std.testing.expectEqual(@as(f32, 8), s.carry);

    // A dab at the start, and one at x = 10. Not one per sample.
    try std.testing.expectEqual(@as(usize, 2), rec.len);
    try std.testing.expectEqual(@as(f32, 0), rec.dabs[0].x);
    try std.testing.expectApproxEqAbs(@as(f32, 10), rec.dabs[1].x, 1e-4);
}

The first dab is at 0 and the second at 10, one gap apart. The second move, from 6 to 12, places it 4 pixels in.

The next test is the reason for the carry. It draws one 100-pixel move, then the same 100 pixels cut into moves of 3. Both must give the same dabs:

examples/paint/src/engine/stroke.zigthe spacing does not depend on how the movement was cut up
test "the spacing does not depend on how the movement was cut up" {
    const brush: Brush = .{ .radius = 20, .spacing = 0.25 };
    var one: Recorder = .{};
    var s1 = Stroke.begin(brush, .{ .x = 0, .y = 0 }, &one);
    s1.move(.{ .x = 100, .y = 0 }, &one);

    var many: Recorder = .{};
    var s2 = Stroke.begin(brush, .{ .x = 0, .y = 0 }, &many);
    var x: f32 = 0;
    while (x < 100) {
        x = @min(x + 3, 100);
        s2.move(.{ .x = x, .y = 0 }, &many);
    }

    try std.testing.expectEqual(one.len, many.len);
    for (one.dabs[0..one.len], many.dabs[0..many.len]) |a, b| {
        try std.testing.expectApproxEqAbs(a.x, b.x, 1e-3);
    }
}

Without the carry, the second stroke would put a dab at the end of every 3-pixel move, or at none of them.

And the repeated sample:

examples/paint/src/engine/stroke.ziga repeated sample adds no dab and does not divide by zero
test "a repeated sample adds no dab and does not divide by zero" {
    var rec: Recorder = .{};
    var s = Stroke.begin(.{}, .{ .x = 5, .y = 5 }, &rec);
    s.move(.{ .x = 5, .y = 5 }, &rec);
    try std.testing.expectEqual(@as(usize, 1), rec.len);
    try std.testing.expect(!std.math.isNan(s.carry));
}

Recording and replaying

A stroke is just its list of samples.

Keep the list, and you can draw the stroke again. With the same brush it draws the same dabs. With a different brush it draws the same path in a new style.

The demo picture from the first chapter is a replay of this kind. Its samples are written into the code, so a test can draw it and compare the pixels.

Explain before reading the answer

You finish a stroke with a carry of 7, lift the pen, and start a new stroke somewhere else.

Should the new stroke start with a carry of 7?

No. The carry measures the distance since the last dab of this stroke. A new stroke has no last dab. It starts its own spacing, with a dab where the pen touches down, which is why begin sets carry = 0.