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

Flow and Opacity

Updated

A brush has two settings that both sound like “how much paint”.

Flow is how much one dab adds.

Opacity is the most the whole stroke can cover.

They are different, and the difference shows the moment dabs overlap. Every stroke is made of overlapping dabs, so it shows everywhere.

The problem

Say we draw each dab straight onto the layer at 50%.

Two dabs overlap at a pixel. The first covers it 50%. The second covers half of what is left, so the pixel ends up 75% covered.

Ten dabs overlap along a slow stroke, and the pixel is close to 100%.

So a stroke drawn “at 50%” comes out solid in the middle, darker wherever it crosses itself, and darker again where you moved slowly.

That is right for flow. It is how an airbrush builds up paint. It is wrong for opacity, which should be a limit that the stroke as a whole never goes past.

The stroke buffer

The fix is to keep the stroke off the layer until it is finished.

While you draw, dabs go into a separate buffer that holds one number per pixel: how much of that pixel the stroke covers.

examples/paint/src/engine/mask.zigMask fields
pub const Mask = struct {
    width: u32,
    height: u32,
    coverage: []u16,
    /// The colour the stroke puts down at each pixel. For most brushes it is
    /// the brush colour everywhere. A brush that picks up paint changes
    /// colour along the stroke, so the buffer has to remember it per pixel.
    /// Only meaningful where `coverage` is above 0, so it is never cleared.
    color: [][3]u8,
    /// Every pixel outside this rectangle is 0. Keeping it means committing,
    /// clearing and redrawing touch only the part of the image the stroke
    /// reached.
    dirty: Rect = .empty,

    // ...
};

When you let go, the stroke goes onto the layer once, with its coverage multiplied by the opacity.

Overlapping dabs only raise the coverage towards 1. The opacity is applied once on top of that, so where the coverage reaches 1, the stroke covers exactly 50%, no more.

Building up coverage

Each dab adds flow times what is still uncovered. In the stamp loop:

const new = old + ((full - old) * add + full - 1) / full;
m.coverage[i] = @intCast(new);

full - old is what is still uncovered. add is the dab’s coverage at this pixel times the flow.

After n dabs of flow f, the coverage is 1 − (1 − f)ⁿ.

Two dabs at 20% flow give 1 − 0.8² = 0.36, not 0.40.

The coverage gets closer to 1 with every dab and never passes it, however many dabs land.

Why 16 bits

Coverage is a u16: 0 is none and 65535 is full.

Eight bits would not be enough. The Airbrush in the studio has a flow of 4%. In 8 bits, 4% of 255 is 10.2, and that rounds to 10.

Worse, the fading edge of a soft tip is made of small fractions of that 10. They round to 0, 1, 2 and 3, and the edge comes out in visible steps.

In 16 bits, 4% is 2621, and the steps are too small to see.

The rounding bug the tests found

The line above rounds the step up: it adds full - 1 before dividing.

The first version rounded to nearest, and a test caught it.

The test stamps 200 dabs at 30% flow and expects full coverage. Rounded to nearest, coverage stopped at 65534.

At 65534 the uncovered part is 1. One dab adds 30% of 1, which is 0.3, and 0.3 rounds to 0. So every later dab added nothing, and the stroke could never quite finish covering a pixel.

Rounded up, the step is at least 1 whenever anything is uncovered. It still never passes full, because rounding up never makes the step bigger than the gap it is filling.

Putting the stroke onto the layer

endStroke does three things: keeps the old pixels for undo, applies the stroke to every pixel the buffer touched, and clears the buffer.

examples/paint/src/engine/document.zigendStroke
    /// Put the stroke onto its layer, once, and keep the old pixels for undo.
    pub fn endStroke(d: *Document) void {
        const active = d.stroke orelse return;
        const r = d.mask.dirty;
        const layer = d.layers[active.layer];

        d.history_ok = d.history.record(d.gpa, active.layer, layer, d.width, r);

        var y = r.y0;
        while (y < r.y1) : (y += 1) {
            var x = r.x0;
            while (x < r.x1) : (x += 1) {
                const i = @as(usize, y) * d.width + x;
                layer[i] = applyStroke(active, layer[i], d.mask.coverage[i], d.mask.color[i]);
            }
        }
        d.mask.clear();
        d.stroke = null;
        d.stale = d.stale.join(r);
    }

It visits only mask.dirty, the rectangle the stroke’s dabs reached. A short stroke in a corner touches a corner, not the whole layer.

The pixel-by-pixel work is applyStroke:

examples/paint/src/engine/document.zigapplyStroke
    /// One pixel of a layer with the stroke applied. The preview on screen
    /// and the commit both call this, so what you see while drawing is
    /// exactly what you get when you let go.
    fn applyStroke(active: Active, below: Rgba, coverage: u16, color: [3]u8) Rgba {
        const amount = (@as(u32, coverage) * active.opacity + 32767) / 65535;
        return switch (active.tool) {
            .brush => pixel.paint(below, color, amount),
            .eraser => pixel.erase(below, amount),
        };
    }

What you see while drawing

The stroke is not on the layer until you let go. But you still need to see it while you draw.

So the screen is built with the stroke applied on the fly. When it builds the active layer’s pixel, it runs the same applyStroke first:

examples/paint/src/engine/document.zigcomposite
    /// 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 preview and the commit call the same function with the same numbers. So the stroke cannot change when you let go. A test checks it: it hashes the screen just before endStroke and just after, and the two must match.

Cancelling

A stroke can be interrupted. The browser can take the pointer away when a dialog opens, or the window loses focus halfway through.

Committing half a stroke would leave a mark the person never finished. Leaving the buffer as it was would leave a ghost on screen.

Because the stroke is still only in the buffer, cancelling is easy: clear the buffer and redraw that rectangle.

examples/paint/src/engine/document.zigcancelStroke
    /// Drop the stroke in progress without changing any layer. Used when the
    /// pointer is lost halfway through a stroke.
    pub fn cancelStroke(d: *Document) void {
        if (d.stroke == null) return;
        d.stale = d.stale.join(d.mask.dirty);
        d.mask.clear();
        d.stroke = null;
    }

Checks

The worked example:

examples/paint/src/engine/mask.zigtwo dabs at 20% flow give 36%, not 40%
test "two dabs at 20% flow give 36%, not 40%" {
    var m = try Mask.init(std.testing.allocator, 32, 32);
    defer m.deinit(std.testing.allocator);
    const dab: Dab = .{ .x = 16, .y = 16, .radius = 10, .angle = 0, .direction = 0, .color = .{ 0, 0, 0 }, .load = 1 };
    m.stamp(dab, 1, 1, 0.2);
    m.stamp(dab, 1, 1, 0.2);
    const centre = @as(f32, @floatFromInt(m.at(16, 16))) / full;
    try std.testing.expectApproxEqAbs(@as(f32, 0.36), centre, 0.0001);
}

The test that found the rounding bug:

examples/paint/src/engine/mask.zigmany dabs approach full coverage and never pass it
test "many dabs approach full coverage and never pass it" {
    var m = try Mask.init(std.testing.allocator, 16, 16);
    defer m.deinit(std.testing.allocator);
    for (0..200) |_| m.stamp(.{ .x = 8, .y = 8, .radius = 6, .angle = 0, .direction = 0, .color = .{ 0, 0, 0 }, .load = 1 }, 1, 1, 0.3);
    try std.testing.expectEqual(@as(u16, 65535), m.at(8, 8));
}

A stroke at 50% opacity that goes back over itself, and stays at 50%:

examples/paint/src/engine/document.ziga stroke crossing itself does not get darker
test "a stroke crossing itself does not get darker" {
    var d = try newDoc();
    defer d.deinit();
    // Half opacity, full flow: the stroke itself becomes fully covered, and
    // the opacity is applied once on top.
    const brush: Brush = .{ .radius = 4, .flow = 1, .opacity = 0.5, .color = .{ 0, 0, 0 } };
    d.beginStroke(brush, .brush, .{ .x = 5, .y = 20 });
    d.moveStroke(.{ .x = 60, .y = 20 });
    d.moveStroke(.{ .x = 5, .y = 20 }); // back over the same line
    d.endStroke();
    try testing.expectEqual(@as(u8, 128), d.layers[0][20 * 64 + 30].a);
}

Opacity 0 must change nothing at all:

examples/paint/src/engine/document.zigopacity 0 leaves the layer unchanged
test "opacity 0 leaves the layer unchanged" {
    var d = try newDoc();
    defer d.deinit();
    d.beginStroke(.{ .opacity = 0 }, .brush, .{ .x = 10, .y = 10 });
    d.moveStroke(.{ .x = 50, .y = 30 });
    d.endStroke();
    for (d.layers[0]) |p| try testing.expectEqual(Rgba.transparent, p);
}

And a cancelled stroke leaves nothing behind, on screen or in the history:

examples/paint/src/engine/document.zigcancelling a stroke leaves no ghost
test "cancelling a stroke leaves no ghost" {
    var d = try newDoc();
    defer d.deinit();
    _ = d.render();
    const blank = d.displayHash();
    d.beginStroke(.{}, .brush, .{ .x = 10, .y = 10 });
    d.moveStroke(.{ .x = 40, .y = 40 });
    d.cancelStroke();
    _ = d.render();
    try testing.expectEqual(blank, d.displayHash());
    try testing.expect(!d.history.canUndo());
}

Explain before reading the answer

You draw one stroke at 50% opacity. Then you draw a second stroke at 50% across the first.

Where they cross, is the colour 50% or 75%?

75%. Each stroke is limited to 50% on its own, because each stroke has its own buffer. But the second stroke is applied over the result of the first, like a second coat of paint: 50% of the 50% that was still uncovered, plus the first 50%.