Bristles and Paper
A real brush is not a solid disc. It is a bundle of hairs.
When it is loaded with paint, the hairs press together and leave one solid mark. When it is nearly dry, each hair leaves its own thin track, and the tracks break wherever the paper’s surface dips away from the brush.
The Dry brush in the studio does both. It needs two new brush settings:
- bristles: how many hairs, from 0 (a solid tip) to 16
- contact: how much of the paper the brush reaches, from 0 to 1
Hairs across the stroke
A bristle brush stamps one small round dab for each hair, instead of one big dab.
The hairs sit in a row across the stroke, at right angles to the way it is moving. If the stroke moves in direction θ, the direction straight across it is:
(−sin θ, cos θ)
Moving right, θ is 0, and that gives (0, 1): straight down the page. Moving down, θ is a quarter turn, and it gives (−1, 0): to the left. Either way it is across the stroke.
Each hair has an offset from −1 (one edge of the tip) to 1 (the other). Its position is the dab’s centre, plus the direction across times the offset times the radius.
With radius 20 and offset −0.5, a stroke moving right puts that hair 10 pixels above the centre.
pub fn dab(p: Painter, d: Dab) void {
const b = p.brush;
const doc = p.doc;
if (b.bristles == 0) {
doc.mask.stampOn(&doc.paper, b.contact, d, b.hardness, b.aspect, b.flow);
doc.stale = doc.stale.join(Rect.around(d.x, d.y, d.radius, doc.width, doc.height));
return;
}
// A bristle brush is a row of thin hairs across the stroke. Each
// hair is a small round dab, placed along the line at right
// angles to the direction of travel.
const across_x = -@sin(d.direction);
const across_y = @cos(d.direction);
const count: f32 = @floatFromInt(b.bristles);
// Each hair is half as wide as the space it has, so neighbouring
// tracks stay apart even after the small shifts in `Hairs`.
const hair_radius = @max(d.radius / count * 0.5, 0.75);
for (p.hairs.offset[0..b.bristles], p.hairs.flow[0..b.bristles]) |offset, flow| {
var hair = d;
hair.x += across_x * offset * d.radius;
hair.y += across_y * offset * d.radius;
hair.radius = hair_radius;
doc.mask.stampOn(&doc.paper, b.contact, hair, b.hardness, 1, b.flow * flow);
}
doc.stale = doc.stale.join(Rect.around(d.x, d.y, d.radius + hair_radius, doc.width, doc.height));
}The direction comes from the stroke. Every dab now carries the direction it was travelling, as well as the tip’s angle, because a tip can be turned to any angle while the hairs must always spread across the path.
Where the hairs sit
Perfectly even hairs look like a comb. So each hair is moved by a small random amount, and given its own share of the flow, so some tracks are fainter than others:
/// Where each hair of a bristle brush sits across the tip, and how much
/// paint it carries. Made from the brush's seed, so the same brush always
/// has the same hairs, and a painter can make them again on every call
/// instead of storing them.
const Hairs = struct {
/// From -1 (one edge of the tip) to 1 (the other).
offset: [max_bristles]f32,
/// A share of the brush's flow, from 0.55 to 1.
flow: [max_bristles]f32,
fn layOut(b: Brush) Hairs {
var h: Hairs = undefined;
var random: Random = .init(b.seed +% 0x9e37);
const n: f32 = @floatFromInt(@max(b.bristles, 1));
for (0..max_bristles) |i| {
const even = -1.0 + (2.0 * @as(f32, @floatFromInt(i)) + 1.0) / n;
// Evenly spaced, then moved by up to a third of the gap, so
// the tracks are not a perfect comb.
h.offset[i] = even + (random.next() - 0.5) * (2.0 / n) / 3.0;
h.flow[i] = 0.55 + 0.45 * random.next();
}
return h;
}
};The layout is made from the brush’s seed. The same brush always has the same hairs, so the layout does not need to be stored anywhere. The painter makes it again on each call.
That choice also avoids a bug this file already had once. The first dab of a stroke is placed before the stroke is stored in the document, so nothing the painter needs can live in the stored stroke.
Each hair is half as wide as the space it has. A test found that at 90% the hairs ran into each other after the random shifts, and five hairs left three tracks.
Paper
Real paper has a surface of tiny hills and dips.
The engine stores it as a grid of heights, one per cell of 4 by 4 pixels. The heights are random, from a seed, and they are made once, when the document is made:
pub const Paper = struct {
/// Width and height of one cell, in pixels.
cell: u32,
columns: u32,
rows: u32,
/// One height per cell, row by row: 0 is the bottom of a dip, 255 the
/// top of a hill.
heights: []u8,
pub fn init(gpa: std.mem.Allocator, width: u32, height: u32, cell: u32, seed: u32) !Paper {
const columns = (width + cell - 1) / cell;
const rows = (height + cell - 1) / cell;
const heights = try gpa.alloc(u8, @as(usize, columns) * rows);
var random: Random = .init(seed);
for (heights) |*h| h.* = @intFromFloat(@round(random.next() * 255.0));
return .{ .cell = cell, .columns = columns, .rows = rows, .heights = heights };
}
pub fn deinit(p: *Paper, gpa: std.mem.Allocator) void {
gpa.free(p.heights);
}
/// The height under pixel (`x`, `y`). Every pixel in a cell shares one
/// height, so the grain is `cell` pixels across.
pub fn at(p: Paper, x: u32, y: u32) u8 {
const column = @min(x / p.cell, p.columns - 1);
const row = @min(y / p.cell, p.rows - 1);
return p.heights[@as(usize, row) * p.columns + column];
}
/// Whether a brush with this `contact` (0 to 1) reaches the paper at
/// (`x`, `y`). Full contact reaches every height. Less contact reaches
/// only the hills: at 0.3, only heights in the top 30%.
pub fn touches(p: Paper, x: u32, y: u32, contact: f32) bool {
const threshold = (1.0 - contact) * 255.0;
return @as(f32, @floatFromInt(p.at(x, y))) >= threshold;
}
};The paper stays where it is. A stroke drawn twice across the same spot meets the same hills both times, which is how real grain behaves.
To find the height under a pixel, divide by the cell size:
column = x / 4
row = y / 4
index = row × columns + column
The pixel at (22, 10) is in column 5 and row 2. With 160 columns across a 640-pixel document, its cell is number 2 × 160 + 5 = 325.
Contact
A wet brush pushes into the dips. A dry one touches only the tops of the hills.
Contact says how far down the brush reaches. A pixel gets paint only if its paper is high enough:
/// Whether a brush with this `contact` (0 to 1) reaches the paper at
/// (`x`, `y`). Full contact reaches every height. Less contact reaches
/// only the hills: at 0.3, only heights in the top 30%.
pub fn touches(p: Paper, x: u32, y: u32, contact: f32) bool {
const threshold = (1.0 - contact) * 255.0;
return @as(f32, @floatFromInt(p.at(x, y))) >= threshold;
}At contact 0.3, the brush reaches only heights in the top 30%: a height of 0.8 gets paint and 0.4 does not. At contact 0.9, both get paint. At 1, every height does, which is why every other brush behaves exactly as before.
The check sits inside the tip’s loop, just after the distance test:
if (paper) |pp| if (!pp.touches(x, y, contact)) continue;
A pixel the brush does not reach gets no coverage from this dab. Another dab of the same stroke cannot help, because it meets the same paper.
Checks
The grid lookup from the example:
test "the cell under a pixel: (22, 10) with cells of 4 is column 5, row 2" {
var p = try Paper.init(std.testing.allocator, 640, 400, 4, 1);
defer p.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(u32, 160), p.columns);
try std.testing.expectEqual(p.heights[2 * 160 + 5], p.at(22, 10));
try std.testing.expectEqual(p.at(20, 8), p.at(23, 11)); // same cell
}Contact at 30% and at 100%:
test "contact: 30% reaches a high cell but not a low one, 100% reaches both" {
var heights = [_]u8{ 204, 102 }; // 0.8 and 0.4 of the way up
const p: Paper = .{ .cell = 1, .columns = 2, .rows = 1, .heights = &heights };
try std.testing.expect(p.touches(0, 0, 0.3));
try std.testing.expect(!p.touches(1, 0, 0.3));
try std.testing.expect(p.touches(0, 0, 1));
try std.testing.expect(p.touches(1, 0, 1));
}A dry stroke paints only high cells:
test "a dry brush skips the paper's dips, the same ones every time" {
var d = try newDoc();
defer d.deinit();
const dry: Brush = .{ .radius = 20, .hardness = 1, .flow = 1, .contact = 0.3 };
d.beginStroke(dry, .brush, .{ .x = 0, .y = 24 });
d.moveStroke(.{ .x = 64, .y = 24 });
d.endStroke();
// Every painted pixel is on a high cell, and every high cell the stroke
// crossed is painted.
var painted: usize = 0;
for (0..48) |y| for (0..64) |x| {
const covered = d.layers[0][y * 64 + x].a > 0;
const high = d.paper.touches(@intCast(x), @intCast(y), 0.3);
if (covered) {
painted += 1;
try testing.expect(high);
}
};
try testing.expect(painted > 0);
try testing.expect(painted < 64 * 41 / 2); // well under half the band
}And five hairs leave five tracks. The test reads one column of pixels across the stroke and counts the separate runs of paint:
test "a bristle brush leaves separate tracks" {
var d = try newDoc();
defer d.deinit();
const brush: Brush = .{ .radius = 20, .bristles = 5, .hardness = 1, .flow = 1, .spacing = 0.05 };
d.beginStroke(brush, .brush, .{ .x = 0, .y = 24 });
d.moveStroke(.{ .x = 64, .y = 24 });
d.endStroke();
// Read down one column across the stroke and count the runs of paint.
var runs: usize = 0;
var inside = false;
for (0..48) |y| {
const covered = d.layers[0][y * 64 + 32].a > 0;
if (covered and !inside) runs += 1;
inside = covered;
}
try testing.expectEqual(@as(usize, 5), runs);
}Explain before reading the answer
A bristle brush moves along a hair’s own track and lands on a pixel whose paper is too low for its contact.
Does the hair’s track continue after the gap?
Yes. The gap is in the paint, not in the stroke. The stroke keeps walking the path and placing dabs, and the next dab on higher paper paints again. That is the broken line a dry brush leaves.