Measure the distance to the edge, and get four controls for one transform

Feathering, growing, shrinking, closing and opening are the same number
read differently. With the signed distance from the boundary in hand,
dilation is the set where d >= -r, erosion where d >= +r, and a feather of
any shape is a function of d. So the field is computed once and the
controls are arithmetic on it.

The **field** is what reaches the GPU, not a finished alpha, and that is
the point: growing a mask or changing its falloff then costs a uniform
upload and no recomputation, which is what makes them live controls rather
than ones that stall on every drag. Only closing and opening rebuild,
because after the first threshold the shape has changed and the old
distances describe the old one.

Exact Euclidean, via Felzenszwalb's separable transform — not a chamfer
approximation, which leaves a mask visibly octagonal once grown more than
a few pixels. A test asserts the diagonal is √2 rather than 1 or 2.

It runs on the CPU, which ARCH §5.4 forbids for masks. The rule is about
brush lag — a stroke rasterised per frame — and this is a different
operation: once per mask edit, on input the model already produced here,
producing a field the GPU then samples for free. What it buys is exact
determinism, which matters because masks reach the sidecar as indices and a
field that varied by vendor would mean a mask meaning one thing on the
desktop and another on the phone.

The half-pixel in `signed_distance` is not a detail, and a test caught it.
Measuring to the nearest opposite pixel *centre* puts the smallest
magnitude at 1 either side, so the boundary is nowhere and **eroding by
less than a pixel removes nothing**. A control whose first notch does
nothing is a broken control. Half a pixel off each side puts the boundary
where it physically is, and eroding by 1 takes exactly the outermost ring.

Every falloff curve is 0.5 at the boundary by construction, asserted for
all five: changing the curve should change how the transition looks and
never where it sits.
This commit is contained in:
2026-08-22 08:39:17 +02:00
parent ee10097435
commit ec713585a5
7 changed files with 906 additions and 79 deletions
+41 -15
View File
@@ -35,7 +35,9 @@ struct MaskParams {
region_count: u32,
// Softening applied to a region mask, in output pixels.
feather: f32,
_pad0: f32,
// 0 hard, 1 linear, 2 smooth, 3 gaussian, 4 exponential. Kept in step with
// `falloff_code` on the Rust side.
falloff: u32,
// Geometry, in normalised output coordinates. Meaning depends on `mode`.
centre: vec2<f32>,
@@ -57,9 +59,14 @@ struct MaskParams {
// One entry per region: non-zero if the region is in this mask. Small — a few
// thousand bytes — which is what makes changing a selection cheap.
@group(0) @binding(2) var<storage, read> selected: array<u32>;
// One recognised object's coverage, for a subject mask. A 1x1 placeholder
// when the layer is not one — the binding is fixed, and a second pipeline
// differing only in what it ignores would be worse than a wasted texel.
// The **signed distance** from one subject's boundary, in proxy pixels:
// positive inside, negative outside. A 1x1 placeholder when the layer is not a
// subject — the binding is fixed, and a second pipeline differing only in what
// it ignores would be worse than a wasted texel.
//
// A distance field rather than a finished alpha is what makes growing,
// shrinking and feathering free: each is arithmetic on this, so a slider moves
// a uniform instead of rebuilding a mask.
@group(0) @binding(3) var subject: texture_2d<f32>;
// A full-screen triangle rather than a quad: three vertices instead of six,
@@ -142,12 +149,12 @@ fn radial_mask(uv: vec2<f32>) -> f32 {
return 1.0 - smoothstep(1.0 - edge, 1.0, r);
}
// The model's coverage for one object, resampled to the mask's own grid.
// Coverage for one object, from its distance field.
//
// Bilinear, unlike the region lookup above: this is a *quantity*, not a name,
// so the value between two samples is meaningful. The model's own mask is a
// quarter-resolution sigmoid, and interpolating it is what stops the outline
// stair-stepping in blocks of four.
// Bilinear on the *distance*, which is the reason this is a distance field at
// all: distance varies smoothly across the boundary where coverage does not,
// so interpolating it gives a clean sub-pixel edge even though the model's
// own mask was quarter-resolution.
fn subject_mask(uv: vec2<f32>) -> f32 {
let dims = vec2<f32>(textureDimensions(subject));
let last = vec2<i32>(dims) - vec2<i32>(1);
@@ -164,15 +171,34 @@ fn subject_mask(uv: vec2<f32>) -> f32 {
let c = textureLoad(subject, vec2<i32>(p0.x, p1.y), 0).r;
let d = textureLoad(subject, vec2<i32>(p1.x, p1.y), 0).r;
let cov = mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
// `angle` carries the morphology offset in pixels: positive grows the
// mask, negative shrinks it. Adding it before the falloff is what makes
// dilation move the boundary rather than merely brighten the edge.
let dist = mix(mix(a, b, f.x), mix(c, d, f.x), f.y) + p.angle;
// `softness` carries the layer's feather here. Zero gives the model's own
// soft edge untouched, which is a perfectly good mask edge and a better
// default than imposing a ramp on top of one that already exists.
// `softness` is the feather half-width, also in pixels.
if (p.softness <= 0.0) {
return cov;
return select(0.0, 1.0, dist >= 0.0);
}
let t_norm = dist / p.softness;
// Every curve is 0.5 at the boundary, so changing the falloff changes how
// the transition looks and never where it sits.
switch p.falloff {
case 0u: { return select(0.0, 1.0, dist >= 0.0); }
case 1u: { return clamp(t_norm * 0.5 + 0.5, 0.0, 1.0); }
case 3u: { return 1.0 / (1.0 + exp(-3.0 * t_norm)); }
case 4u: {
if (t_norm >= 0.0) {
return 1.0 - 0.5 * exp(-3.0 * t_norm);
}
return 0.5 * exp(3.0 * t_norm);
}
default: {
let x = clamp(t_norm * 0.5 + 0.5, 0.0, 1.0);
return x * x * (3.0 - 2.0 * x);
}
}
return smoothstep(0.5 - p.softness, 0.5 + p.softness, cov);
}
@fragment