Smoothing, Pressure and Scatter
The last chapter turned samples into evenly spaced dabs.
This one changes the dabs themselves. Three changes, each a few lines:
- Smoothing moves the path, to steady a shaky hand.
- Pressure changes the size of each dab.
- Jitter and scatter change the size and position of each dab at random, for brushes like a spray can.
The third one has a condition. The randomness must repeat exactly, or no test could ever compare two pictures.
Smoothing
A hand is not steady. A mouse is worse.
Smoothing keeps a second position, the filtered position, and moves it only part of the way towards each new sample:
/// 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;
}The first line of move is all of it. follow is the fraction of the way to move.
A worked example
The filtered x is 100. A sample arrives at x = 120. follow is 0.25.
The filtered x moves a quarter of the way: 100 + (120 − 100) × 0.25 = 105.
Another sample arrives at 120 again. It moves a quarter of the remaining way: 105 + 15 × 0.25 = 108.75.
A wobble of a few pixels gets the same treatment, so it shrinks to a quarter of its size.
At follow = 1 the filtered point jumps straight to the sample, so there is no smoothing.
The cost is a lag. The stroke trails behind the pointer, and when you let go it ends short of where the pointer was. A lower follow means a steadier line and a longer lag. That is why the Ink brush in the studio uses 0.35 and the Spray brush uses 1.
Pressure
A pen reports how hard it presses, from 0 to 1. A mouse has no pressure, and browsers report 0.5 while the button is down.
The brush’s pressure setting says how much the pen should matter, from 0 (not at all) to 1 (fully). Here is where each dab gets its size:
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,
});
}The size at full effect is 0.25 + 0.75 × p², where p is the pen’s pressure.
At full pressure that is 1, the whole radius. At no pressure it is 0.25, a quarter of it. The square makes light pressure count for less, which feels closer to a real pen than a straight line does.
The brush setting then chooses between that and full size with a lerp. At setting 0, the dab is always full size, so the pen is ignored.
A radius of 20, at setting 1:
- pressure 1 gives 20 × (0.25 + 0.75) = 20 pixels
- pressure 0.5 gives 20 × (0.25 + 0.1875) = 8.75 pixels
- pressure 0 gives 20 × 0.25 = 5 pixels
Random numbers that repeat
The Spray brush changes each dab’s size and position at random.
“At random” usually means asking the operating system, and getting different numbers every time the program runs.
That would break two things. A test could not draw the spray brush and compare the result with a number. And a recorded stroke would draw differently each time it was replayed.
So the engine makes its own random numbers, starting from a seed. The same seed gives the same numbers in the same order, on every machine:
pub const Random = struct {
/// Always between 1 and 2^31 - 2. Zero would stay zero for ever.
state: u32,
const modulus: u64 = 2147483647; // 2^31 - 1, a prime
const multiplier: u64 = 16807; // 7^5
pub fn init(seed: u32) Random {
const s: u32 = @intCast(seed % modulus);
return .{ .state = if (s == 0) 1 else s };
}
/// The next value, from 0.0 to 1.0.
pub fn next(r: *Random) f32 {
// The product needs 46 bits, so it is done in u64. In u32 it would
// overflow, and Zig would stop the program in a safe build.
r.state = @intCast(@as(u64, r.state) * multiplier % modulus);
return @as(f32, @floatFromInt(r.state - 1)) / 2147483646.0;
}
};This is Park and Miller’s “minimal standard” generator from 1988. Each step multiplies the state by 16807 and keeps the remainder after dividing by 2³¹ − 1.
The product can need 46 bits, so it is computed in a u64. In a u32 it would overflow. Zig checks integer overflow in safe builds, so that mistake would stop the program with a message rather than produce wrong numbers quietly.
Each brush carries a seed. The studio adds one to it after every stroke, so two spray strokes look different, but a replayed stroke looks the same.
Jitter and scatter
Back in emit, three random numbers are drawn for every dab.
Jitter shrinks the dab by a random amount. At jitter 0.6, a dab is between 40% and 100% of its size.
Scatter moves the dab to a random point in a disc around its place on the path. The disc’s radius is scatter times the brush radius.
The random point is an angle and a distance. The angle is 2π × u. The distance is R × √v.
The square root matters. Without it, half of all dabs would land within half the radius, but that inner circle is only a quarter of the disc’s area. The dabs would bunch at the centre.
With the square root, half the dabs land within R × √0.5, about 71% of the radius, which is exactly half the area. The dabs spread evenly.
The three numbers are drawn whether jitter and scatter are on or not. If they were drawn only when used, turning scatter on would shift the sequence, and every later dab’s jitter would change as well.
Checks
The smoothing example from above:
test "smoothing moves a quarter of the way" {
var rec: Recorder = .{};
var s = Stroke.begin(.{ .follow = 0.25 }, .{ .x = 100, .y = 0 }, &rec);
s.move(.{ .x = 120, .y = 0 }, &rec);
try std.testing.expectEqual(@as(f32, 105), s.filtered.x);
s.move(.{ .x = 120, .y = 0 }, &rec);
try std.testing.expectEqual(@as(f32, 108.75), s.filtered.x);
}Pressure at its two settings:
test "pressure: ignored at 0, a quarter size at full effect and no pressure" {
var rec: Recorder = .{};
_ = Stroke.begin(.{ .radius = 20, .pressure = 0 }, .{ .x = 0, .y = 0, .pressure = 0 }, &rec);
_ = Stroke.begin(.{ .radius = 20, .pressure = 1 }, .{ .x = 0, .y = 0, .pressure = 0 }, &rec);
_ = Stroke.begin(.{ .radius = 20, .pressure = 1 }, .{ .x = 0, .y = 0, .pressure = 1 }, &rec);
try std.testing.expectEqual(@as(f32, 20), rec.dabs[0].radius);
try std.testing.expectEqual(@as(f32, 5), rec.dabs[1].radius);
try std.testing.expectEqual(@as(f32, 20), rec.dabs[2].radius);
}The published values of the generator. Park and Miller list the states that follow seed 1, so a wrong multiplier or a wrong modulus shows up here:
test "the first values from seed 1 are the published ones" {
// Park and Miller give 16807, 282475249 and 1622650073 as the first three
// states from seed 1, so a wrong multiplication shows up here.
var r = Random.init(1);
_ = r.next();
try std.testing.expectEqual(@as(u32, 16807), r.state);
_ = r.next();
try std.testing.expectEqual(@as(u32, 282475249), r.state);
_ = r.next();
try std.testing.expectEqual(@as(u32, 1622650073), r.state);
}And scatter: every dab within the disc, and the same seed giving the same dabs:
test "scatter stays inside the disc, and the same seed repeats it" {
const brush: Brush = .{ .radius = 10, .scatter = 2, .seed = 7 };
var a: Recorder = .{};
var b: Recorder = .{};
var sa = Stroke.begin(brush, .{ .x = 0, .y = 0 }, &a);
var sb = Stroke.begin(brush, .{ .x = 0, .y = 0 }, &b);
sa.move(.{ .x = 200, .y = 0 }, &a);
sb.move(.{ .x = 200, .y = 0 }, &b);
try std.testing.expectEqual(a.len, b.len);
for (a.dabs[0..a.len], b.dabs[0..b.len], 0..) |da, db, i| {
try std.testing.expectEqual(da, db);
// The dab's place on the path, before scatter, is i * gap.
const along: f32 = @as(f32, @floatFromInt(i)) * 2.5;
const off = std.math.hypot(da.x - along, da.y);
try std.testing.expect(off <= 20.001);
}
}Explain before reading the answer
A random number u is 0.25 and another v is 0.25. The scatter radius R is 20 pixels.
Where does the dab move?
The angle is 2π × 0.25 = π/2, a quarter turn. The distance is 20 × √0.25 = 10 pixels.
A quarter turn from the x axis points along the y axis, and on a screen y grows downwards. So the dab moves 10 pixels straight down.