Oil: Pickup and Paint Load
Every brush so far puts down one colour, the same at the end of a stroke as at the start.
A brush loaded with oil paint does not. Two things change as it moves:
- It picks up some of the paint it is dragged through, so its colour drifts towards what is under it.
- It runs dry. There is less paint on it with every centimetre, and the stroke fades out.
Choose the Oil brush in the studio, paint a few colours, and drag it across them.
The brush carries paint
For these two effects the stroke has to remember something from one dab to the next: the colour on the brush, and how much paint is left.
So the stroke gains two fields:
pub const Stroke = struct {
brush: Brush,
random: Random,
/// The smoothed pointer position.
filtered: Point,
/// Where the last segment ended. Dabs are placed from here.
last: Point,
/// Distance walked since the last dab, in pixels. Always less than `gap`.
carry: f32,
/// Direction of travel, in radians. 0 (to the right) until the first move.
direction: f32,
/// The colour on the brush, kept in `f32` so that small pickups add up
/// instead of rounding away. Starts as the brush colour.
loaded: [3]f32,
/// The paint left on the brush. Every stroke starts with a full brush.
load: f32,
/// Start a stroke at `p` and put the first dab there, so a click with no
/// movement still leaves a mark.
// ...
};loaded starts as the brush colour. load starts at 1, a full brush. Every stroke starts with a freshly loaded brush.
Pickup
Before each dab goes down, the brush looks at the paint under it and moves its colour part of the way there:
loaded = loaded + (under − loaded) × pickup
That is lerp again, once for each of red, green and blue.
Loaded red is 200. The paint under it has red 100. pickup is 0.2. The new loaded red is 200 + (100 − 200) × 0.2 = 180.
Over the same paint again: 180 + (100 − 180) × 0.2 = 164.
The colour is carried to the next dab, so the paint from one place gets dragged a little way along the stroke. That is the smear.
Here is where it happens in emit:
fn emit(s: *Stroke, p: Point, sink: anytype) void {
const b = s.brush;
// Pressure: at `pressure = 0` the size ignores the pen.
// At 1, a light touch draws at a quarter of the size.
const p2 = p.pressure * p.pressure;
var radius = b.radius * geometry.lerp(1.0, 0.25 + 0.75 * p2, b.pressure);
// Three random numbers per dab, used or not, so that turning on
// scatter does not change the size of every later dab.
const u_size = s.random.next();
const u_angle = s.random.next();
const u_reach = s.random.next();
radius *= 1.0 - b.jitter * u_size;
// Scatter across a disc. The square root spreads the
// offsets evenly over the area instead of bunching them at the centre.
const theta = 2.0 * std.math.pi * u_angle;
const reach = b.scatter * b.radius * @sqrt(u_reach);
const x = p.x + @cos(theta) * reach;
const y = p.y + @sin(theta) * reach;
// Pickup: before the dab goes down, the brush takes on part of the
// paint already under it. The colour is carried to the next dab, so
// paint gets dragged along the stroke. Thin paint gives less, and
// bare paper gives nothing: a brush cannot pick up the paper.
if (b.pickup > 0) {
if (sink.sample(x, y)) |under| {
const t = b.pickup * under.cover;
for (&s.loaded, under.color) |*c, u| c.* = geometry.lerp(c.*, @floatFromInt(u), t);
}
}
sink.dab(.{
.x = x,
.y = y,
.radius = radius,
// Following the direction, the brush angle is an offset from it:
// 0 lays the tip along the stroke, 90 lays it across.
.angle = (if (b.follow_direction) s.direction else 0) + b.angle * std.math.pi / 180.0,
.direction = s.direction,
.color = .{
@intFromFloat(@round(s.loaded[0])),
@intFromFloat(@round(s.loaded[1])),
@intFromFloat(@round(s.loaded[2])),
},
.load = s.load,
});
}loaded is kept in f32, not in bytes. A small pickup moves each channel by less than 1. In bytes that would round away to nothing, and a brush with a low pickup would never change colour at all.
The brush cannot pick up paper
The first version of this sampled the picture as it looks on screen: the layer over white paper.
The demo’s oil stroke is red, and it starts on blank paper. Within a few dabs it was white, and then it painted white over the dark stroke it crossed. It was picking up the paper.
Paper is not paint. So the sample comes from the layer itself, and reports how much paint is there as well as its colour:
/// The paint already on the active layer at (`x`, `y`): its colour,
/// and how much of the pixel it covers. Read from the layer, not from
/// the stroke buffer, so a brush picks up the paint it is dragged
/// through and never the stroke it is in the middle of laying down.
/// Bare paper is not paint, so it gives nothing.
pub fn sample(p: Painter, x: f32, y: f32) ?Sample {
const doc = p.doc;
if (!(x >= 0 and y >= 0)) return null;
const ux: u32 = @intFromFloat(@min(x, @as(f32, @floatFromInt(doc.width - 1))));
const uy: u32 = @intFromFloat(@min(y, @as(f32, @floatFromInt(doc.height - 1))));
const px = doc.layers[doc.active_layer][@as(usize, uy) * doc.width + ux];
if (px.a == 0) return null;
// Layers are premultiplied, so the colour of the paint itself is
// each channel divided by the alpha.
const a: u32 = px.a;
return .{
.color = .{
@intCast((@as(u32, px.r) * 255 + a / 2) / a),
@intCast((@as(u32, px.g) * 255 + a / 2) / a),
@intCast((@as(u32, px.b) * 255 + a / 2) / a),
},
.cover = @as(f32, @floatFromInt(a)) / 255.0,
};
}Bare paper returns null, so there is nothing to pick up. Paint that half covers a pixel counts half as much: the pickup is multiplied by cover.
Layers are premultiplied, as the Layers chapter explains. A pixel half covered with red 200 is stored as red 100. To get the colour of the paint itself, sample divides by the alpha.
The sample also comes from the layer, never from the stroke buffer. So the brush picks up paint that was there before the stroke, and never the stroke it is in the middle of laying down.
A stroke of many colours
The stroke buffer from Flow and Opacity held one number per pixel, the coverage. The colour was the brush colour, the same everywhere.
With pickup, two dabs of one stroke can have different colours. So the buffer now keeps a colour per pixel too:
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 a dab adds coverage to a pixel, the pixel’s colour becomes the average of the old colour and the dab’s, weighted by how much coverage each one supplied.
A red dab at 50% flow covers half a pixel. A blue dab at 50% covers half of the rest, a quarter. Red supplied twice as much as blue, so the colour is two thirds red and one third blue.
That weighted average is what painting the blue dab over the red one gives. And when the two colours are the same, the average is exactly that colour, with no rounding error. So every brush that does not pick up paint draws the same pixels as before this change.
That is not only an argument. The demo picture’s hash was pinned before the change. The change went in and every test ran, with the hash unchanged. Only then was an oil stroke added to the demo, and the hash pinned again.
Running dry
The load goes down with distance, not with time and not with pointer samples. The Strokes chapter shows why samples are a bad measure of anything.
The stroke already walks the path one gap at a time. So each gap uses a fixed share of the load:
s.load = @max(s.load - s.brush.drain * step / 100.0, 0);
drain is the share of a full load used per 100 pixels. With drain 0.2, 150 pixels of stroke use 0.3, and a full brush is left at 0.7.
The load never goes below 0. Each dab’s flow is multiplied by the load, so a stroke fades out smoothly as the brush empties.
Checks
The pickup example, 200 then 180 then 164:
test "pickup: the loaded red goes 200, 180, 164 over a red of 100 at 0.2" {
var rec: Recorder = .{ .under = .{ .color = .{ 100, 0, 0 } } };
var s = Stroke.begin(.{ .radius = 40, .spacing = 0.25, .pickup = 0.2, .color = .{ 200, 0, 0 } }, .{ .x = 0, .y = 0 }, &rec);
try std.testing.expectEqual(@as(u8, 180), rec.dabs[0].color[0]);
s.move(.{ .x = 10, .y = 0 }, &rec);
try std.testing.expectEqual(@as(u8, 164), rec.dabs[1].color[0]);
}Thin paint gives less:
test "pickup from paint that half covers the paper is half as strong" {
var rec: Recorder = .{ .under = .{ .color = .{ 100, 0, 0 }, .cover = 0.5 } };
_ = Stroke.begin(.{ .pickup = 0.2, .color = .{ 200, 0, 0 } }, .{ .x = 0, .y = 0 }, &rec);
try std.testing.expectEqual(@as(u8, 190), rec.dabs[0].color[0]);
}The drain example, cut into samples two ways, which must agree:
test "drain 0.2 over 150 pixels uses 0.3 of a load, whatever the samples" {
// A gap of 10 pixels. 150 pixels of stroke is 15 gaps.
const brush: Brush = .{ .radius = 40, .spacing = 0.25, .drain = 0.2 };
var one: Recorder = .{};
var s1 = Stroke.begin(brush, .{ .x = 0, .y = 0 }, &one);
s1.move(.{ .x = 150, .y = 0 }, &one);
var many: Recorder = .{};
var s2 = Stroke.begin(brush, .{ .x = 0, .y = 0 }, &many);
var x: f32 = 0;
while (x < 150) {
x = @min(x + 7, 150);
s2.move(.{ .x = x, .y = 0 }, &many);
}
try std.testing.expectApproxEqAbs(@as(f32, 0.7), s1.load, 1e-5);
try std.testing.expectApproxEqAbs(s1.load, s2.load, 1e-5);
// The load never goes below empty.
s1.move(.{ .x = 2000, .y = 0 }, &one);
try std.testing.expectEqual(@as(f32, 0), s1.load);
}And the colour mixing in the stroke buffer:
test "two colours mix by the coverage each one added" {
var m = try Mask.init(std.testing.allocator, 16, 16);
defer m.deinit(std.testing.allocator);
const at: Dab = .{ .x = 8, .y = 8, .radius = 6, .angle = 0, .direction = 0, .color = .{ 200, 0, 0 }, .load = 1 };
m.stamp(at, 1, 1, 0.5);
var blue = at;
blue.color = .{ 0, 0, 200 };
m.stamp(blue, 1, 1, 0.5);
// Red covered half, then blue covered half of the rest: red supplied
// twice as much as blue.
const c = m.color[8 * 16 + 8];
try std.testing.expectEqual([3]u8{ 133, 0, 67 }, c);
}Explain before reading the answer
Set pickup to 1, and drag the brush from a blue area out onto bare paper.
What does it paint?
Over the blue, each dab takes the blue under it completely, so it paints blue on blue and nothing seems to change. Out on the paper there is nothing to pick up, so the brush keeps the last blue it took and paints it there. The stroke drags the blue out of its area, like a finger through wet paint.