Paint a mask without ever rasterising one on the CPU

The last line of FR-DEV-3, and the mask ARCH §5.4 was written for. darktable
rasterises drawn masks on the CPU and users call the result unworkable; the
architecture's answer is that a stroke arrives as *parameters* and the device
draws it. This is that, from the model through the sidecar to the pixels — but
not the finger: the canvas is somebody else's change, and this leaves it a
seam rather than reaching into it.

**A stroke is a swept disc along a polyline**, plus erase, radius, hardness and
flow. `MaskSource::Brush` holds an ordered list of them, and the order is the
mask: an erase after an add takes it away and the same pair reversed does not.
Nothing about it is pixels, which is what makes a mask that costs a line of
text, diffs by the gesture, and survives a crop, a straighten and an export at
any size — the properties a stored raster has none of, and the same argument
the region ids were chosen for.

Two things keep the point count honest. While the finger is down, a position
closer to the last than an eighth of the radius is dropped: a touch screen
reports 120 a second, so a finger held still for five seconds is six hundred
points in the same place, and simplification would only remove them once the
gesture had ended — after every frame in between had drawn all of them. When
it ends, Douglas–Peucker at an eighth of the radius removes what a disc that
wide cannot express: a swept circle moved by r/8 moves its own edge by r/8,
which is inside the soft part of any brush. Coordinates snap to a
ten-thousandth of the frame on the way in *and* are written at that precision,
so a round trip is exact rather than nearly exact — a file that drifts in the
sixth decimal every save is a per-field merge conflict a day, over nothing.

**Cost is why the strokes are not drawn by the full-screen triangle the other
masks use.** A swept disc is the minimum distance to any of its segments, so a
stroke over the whole frame costs `pixels × segments` and both terms grow
together — the quadratic that is darktable's problem moved onto the GPU rather
than solved. Each stroke is instead drawn over its own bounding box, grown by
the radius, so the rasteriser never invokes the shader for a pixel the stroke
cannot reach: `area(box) × segments`, which for a dab or a swipe is a small
fraction of the frame. A gesture past 256 points continues as a second stroke
for the same reason, since a shorter stroke has a smaller box.

Add and erase are `dst + a(1 - dst)` and `dst(1 - a)`, which are exactly a
source-over and a one-minus-source blend — so they are blend state, not
arithmetic, and no pass ever reads the slice it is writing. That is what
permits one draw per stroke at all. Within a stroke the coverage is the
*minimum* distance over its segments rather than a sum: a path that crosses
itself must not build up where it did, or every circle and every scribble
would be blotchy wherever consecutive dabs overlap, which is everywhere.

Not a distance field, deliberately. `dr-segment`'s transform documents the two
conditions that make CPU work right there — once per mask edit, over input
already CPU-side — and a stroke fails both: it changes while the finger moves,
and its input is a handful of coordinates that never needed to be pixels. It
also needs no transform, because the distance to a swept disc is closed form.
A stroke is the one mask whose distance field is known without computing one.

An unpainted brush layer is inactive rather than empty, which is not an
optimisation: `invert` turns empty into everything, so a layer created with
invert already set would apply its adjustment to the whole photograph before a
single stroke was made. That is the loud, confident kind of wrong this codebase
refuses everywhere else a mask can go missing, and there is a rendered test for
it.

The tests read pixels back off a device rather than checking that the two
halves agree with each other. What they pin down is what is silent when wrong:
the y flip between mask space and clip space, which a centred stroke would not
notice; a bounding box not grown by the radius, which makes a tap draw nothing
at all; an aspect ratio ignored, which makes a dab an ellipse on any frame that
is not square; a stroke doubling back and building up; and an erase that lost
its place in the order and put back paint the user had taken off.

Not done here: the interaction. The canvas needs to begin, extend and end a
stroke on the active layer, and `DevelopSession::rasterise_masks` still returns
early without a segmentation — it takes the proxy size from one, and a brush
needs no model to have run over the photograph first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 12:37:42 +02:00
co-authored by Claude Opus 5
parent 586698db00
commit c396a22dfd
6 changed files with 1787 additions and 20 deletions
+434 -5
View File
@@ -9,8 +9,17 @@
//! Rasterising is **not** on the slider path. Dragging exposure on a masked
//! layer changes uniforms only; the mask array is reused untouched. This pass
//! runs when a mask's *shape* changes — a different selection, a moved
//! gradient, a resized output — which is what keeps a local adjustment as
//! responsive as a global one.
//! gradient, a new stroke, a resized output — which is what keeps a local
//! adjustment as responsive as a global one.
//!
//! # The two shapes of pass
//!
//! A parametric mask is a function of the whole frame, so it is one full-screen
//! triangle. A brush is not: a stroke reaches a bounded part of the picture,
//! and drawing it over the whole frame would cost `pixels × segments` for a
//! mark the size of a thumb. So strokes are drawn over their own bounding
//! boxes, one draw each, compositing onto the slice with blend state — see the
//! second half of `mask.wgsl`.
//!
//! # The label field
//!
@@ -21,7 +30,7 @@
//! `region_count`. The compaction is CPU-side and once per image, which is the
//! same place and cadence the region adjacency graph is already built at.
use dr_pipeline::mask::{MaskSource, MaskStack, MAX_LAYERS};
use dr_pipeline::mask::{MaskSource, MaskStack, Stroke, MAX_LAYERS};
use wgpu::util::DeviceExt;
use crate::{GpuContext, GpuError};
@@ -31,6 +40,12 @@ const MODE_REGIONS: u32 = 0;
const MODE_LINEAR: u32 = 1;
const MODE_RADIAL: u32 = 2;
const MODE_SUBJECT: u32 = 3;
/// Brush layers go through their own entry points rather than the `switch`, so
/// this is only ever read by a person looking at a captured frame.
const MODE_BRUSH: u32 = 4;
/// Six vertices — two triangles — per stroke. See `vs_brush`.
const VERTICES_PER_STROKE: u32 = 6;
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
@@ -54,6 +69,105 @@ struct MaskParams {
_pad1: [f32; 2],
}
/// One stroke, as `mask.wgsl`'s `StrokeHeader` expects it.
///
/// The bounding box is computed here rather than in the shader because the
/// vertex stage needs it before there is anything to compute it from — that is
/// the whole trick: the box is what stops the fragment shader running over
/// pixels the stroke cannot reach. Finding it is a pass over a few hundred
/// coordinates, which is not rasterising a mask on the CPU by any reading of
/// ARCH §5.4: no pixel is produced, and the output is four floats.
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct StrokeHeader {
lo: [f32; 2],
hi: [f32; 2],
radius: f32,
hardness: f32,
flow: f32,
first: u32,
count: u32,
_pad: u32,
}
/// The strokes of one layer, packed for the shader.
///
/// Empty when the layer has nothing to draw, which is not the same as an error:
/// a brush layer with no strokes is a mask covering nothing, and a mask
/// covering nothing is what an unpainted layer should be.
struct StrokeBatch {
headers: Vec<StrokeHeader>,
points: Vec<[f32; 2]>,
/// Whether each header erases, in step with `headers`. Not in the header
/// itself because it selects a *pipeline* rather than a value the shader
/// reads: add and erase are two blend states over one fragment shader.
erases: Vec<bool>,
}
impl StrokeBatch {
/// Pack `strokes` for a mask of `width`×`height`.
fn pack(strokes: &[Stroke], width: u32, height: u32) -> Self {
let short = field_short_edge(width, height);
// Back out of shorter-edge units into normalised ones, per axis. The
// radius is a fraction of the shorter edge, so on a landscape frame it
// is a smaller fraction of the width than of the height, and growing
// the box by the same amount in both would clip the ends of a stroke
// along the long axis.
let margin = |extent: u32| short / extent.max(1) as f32;
let (mx, my) = (margin(width), margin(height));
let texel = (1.0 / width.max(1) as f32).max(1.0 / height.max(1) as f32);
let mut out = Self {
headers: Vec::with_capacity(strokes.len()),
points: Vec::new(),
erases: Vec::with_capacity(strokes.len()),
};
for stroke in strokes {
if stroke.points.is_empty() {
continue;
}
let mut lo = [f32::MAX, f32::MAX];
let mut hi = [f32::MIN, f32::MIN];
for &(x, y) in &stroke.points {
lo = [lo[0].min(x), lo[1].min(y)];
hi = [hi[0].max(x), hi[1].max(y)];
}
// Grown by the radius, or a stroke would be drawn only where its
// centre line ran — and a tap, whose box has no area at all, would
// draw nothing whatever.
let grow = [stroke.radius * mx + texel, stroke.radius * my + texel];
out.headers.push(StrokeHeader {
lo: [
(lo[0] - grow[0]).clamp(0.0, 1.0),
(lo[1] - grow[1]).clamp(0.0, 1.0),
],
hi: [
(hi[0] + grow[0]).clamp(0.0, 1.0),
(hi[1] + grow[1]).clamp(0.0, 1.0),
],
radius: stroke.radius,
hardness: stroke.hardness,
flow: stroke.flow,
first: out.points.len() as u32,
count: stroke.points.len() as u32,
_pad: 0,
});
out.erases.push(stroke.erase);
out.points
.extend(stroke.points.iter().map(|&(x, y)| [x, y]));
}
out
}
fn is_empty(&self) -> bool {
self.headers.is_empty()
}
}
/// The segmentation a region mask indexes into, resident on the GPU.
///
/// Uploaded once per image. Holds the compacted label field and nothing else —
@@ -229,6 +343,18 @@ pub struct MaskPass {
ctx: GpuContext,
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
/// The brush's own bindings: the parameters, plus the stroke buffers.
///
/// A second layout rather than two more entries on the first, because a
/// brush reads neither the label field nor a distance field and the
/// parametric masks read no strokes. Sharing one layout would mean binding
/// a placeholder in every draw for something that pass provably cannot
/// touch.
brush_layout: wgpu::BindGroupLayout,
/// One fragment shader, two blend states: `dst + a(1 - dst)` to paint and
/// `dst(1 - a)` to erase.
brush_add: wgpu::RenderPipeline,
brush_erase: wgpu::RenderPipeline,
array: Option<MaskArray>,
/// How many times the array texture has been (re)allocated.
///
@@ -314,6 +440,76 @@ impl MaskPass {
cache: None,
});
let brush_layout = ctx
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("mask-brush-bgl"),
entries: &[
uniform_entry(0),
// Visible to the vertex stage too: the stroke headers are
// where the bounding box comes from, and the box is what
// the vertex shader draws.
wgpu::BindGroupLayoutEntry {
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
..storage_entry(4)
},
storage_entry(5),
],
});
let brush_pipeline_layout =
ctx.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("mask-brush-layout"),
bind_group_layouts: &[Some(&brush_layout)],
immediate_size: 0,
});
let brush = |label, blend| {
ctx.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&brush_pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_brush"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_brush"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: MaskArray::FORMAT,
blend: Some(blend),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
})
};
// Source-over: what the stroke deposits, plus what it did not cover of
// whatever was already there. Two strokes at half flow reach three
// quarters rather than one, which is what "build up" means.
let brush_add = brush(
"mask-brush-add",
blend_state(wgpu::BlendFactor::One, wgpu::BlendFactor::OneMinusSrc),
);
// The same, with the deposit thrown away: coverage is only ever taken
// off what earlier strokes on this layer put down. There is no negative
// coverage to accumulate, so erasing an unpainted layer is a no-op
// rather than a mask that comes back inverted.
let brush_erase = brush(
"mask-brush-erase",
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc),
);
if let Some(err) = pollster::block_on(scope.pop()) {
return Err(GpuError::ShaderCompilation(err.to_string()));
}
@@ -327,6 +523,9 @@ impl MaskPass {
ctx: ctx.clone(),
layout,
pipeline,
brush_layout,
brush_add,
brush_erase,
array: None,
allocations: 0,
placeholder,
@@ -394,8 +593,15 @@ impl MaskPass {
};
let params = self.params(layer, field, width, height);
let selected = self.selection_buffer(layer, field);
self.draw(&mut encoder, slot as u32, &params, field, &selected, subject);
match &layer.source {
MaskSource::Brush { strokes } => {
self.draw_brush(&mut encoder, slot as u32, &params, strokes, width, height)
}
_ => {
let selected = self.selection_buffer(layer, field);
self.draw(&mut encoder, slot as u32, &params, field, &selected, subject);
}
}
}
self.ctx.queue.submit([encoder.finish()]);
@@ -488,6 +694,145 @@ impl MaskPass {
angle: *angle,
..base
},
// A brush carries everything else per stroke, so the only fields it
// reads here are the output dimensions — which it needs for the
// aspect ratio, not for a coordinate.
MaskSource::Brush { .. } => MaskParams {
mode: MODE_BRUSH,
..base
},
}
}
/// Paint one brush layer's slice.
///
/// The slice is cleared and then the strokes are blended onto it in the
/// order they were painted, which is why this is a pass of its own rather
/// than a variation on [`Self::draw`]: the accumulating mask *is* the
/// attachment, so an erase can take away what an add put down without
/// either of them reading the texture.
///
/// Consecutive strokes that composite the same way go out as one draw,
/// since the only thing that changes between them is the pipeline. A layer
/// painted and never erased is therefore one draw call however many strokes
/// it holds.
fn draw_brush(
&self,
encoder: &mut wgpu::CommandEncoder,
slot: u32,
params: &MaskParams,
strokes: &[Stroke],
width: u32,
height: u32,
) {
let batch = StrokeBatch::pack(strokes, width, height);
// Still worth beginning the pass: the slice has to be cleared, or an
// unpainted layer would show whatever the last edit left in it.
let bind_group = (!batch.is_empty()).then(|| {
let params_buf = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask-brush-params"),
contents: bytemuck::bytes_of(params),
usage: wgpu::BufferUsages::UNIFORM,
});
// Rebuilt per rasterisation rather than kept and patched. This runs
// when a mask's shape changes, not per frame, and a few kilobytes
// of stroke geometry is cheaper to upload than a residency scheme
// is to get wrong.
let headers = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask-strokes"),
contents: bytemuck::cast_slice(&batch.headers),
usage: wgpu::BufferUsages::STORAGE,
});
let points = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask-stroke-points"),
contents: bytemuck::cast_slice(&batch.points),
usage: wgpu::BufferUsages::STORAGE,
});
self.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("mask-brush-bind"),
layout: &self.brush_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: params_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: headers.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 5,
resource: points.as_entire_binding(),
},
],
})
});
let array = self.array.as_ref().expect("array ensured by caller");
let view = array.texture.create_view(&wgpu::TextureViewDescriptor {
label: Some("mask-slice"),
dimension: Some(wgpu::TextureViewDimension::D2),
base_array_layer: slot,
array_layer_count: Some(1),
..Default::default()
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mask-brush-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
// Nothing at all until a stroke covers it, which is what
// makes an unpainted brush layer mask nothing rather than
// everything.
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
let Some(bind_group) = bind_group else {
return;
};
pass.set_bind_group(0, &bind_group, &[]);
let mut run = 0;
while run < batch.erases.len() {
let erases = batch.erases[run];
let mut end = run + 1;
while end < batch.erases.len() && batch.erases[end] == erases {
end += 1;
}
pass.set_pipeline(if erases {
&self.brush_erase
} else {
&self.brush_add
});
pass.draw(
run as u32 * VERTICES_PER_STROKE..end as u32 * VERTICES_PER_STROKE,
0..1,
);
run = end;
}
}
@@ -675,6 +1020,24 @@ fn falloff_code(falloff: dr_pipeline::mask::Falloff) -> u32 {
}
}
/// `src * src_factor + dst * dst_factor`, on both components.
///
/// The mask is a single channel, so the alpha component is never written — but
/// a target still has to declare one, and declaring something different there
/// would be a difference nothing could observe and everything could be confused
/// by.
fn blend_state(src: wgpu::BlendFactor, dst: wgpu::BlendFactor) -> wgpu::BlendState {
let component = wgpu::BlendComponent {
src_factor: src,
dst_factor: dst,
operation: wgpu::BlendOperation::Add,
};
wgpu::BlendState {
color: component,
alpha: component,
}
}
fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
@@ -796,6 +1159,72 @@ mod tests {
assert_eq!(array.layers(), 2, "one slice per active layer");
}
/// One gesture: whether it erases, its radius, and its path.
type Gesture = (bool, f32, Vec<(f32, f32)>);
fn painted(gestures: &[Gesture]) -> MaskLayer {
let mut layer = lit(MaskSource::brush());
for (erase, radius, path) in gestures {
layer.begin_stroke(*erase, *radius, 0.5, 1.0);
for &(x, y) in path {
layer.extend_stroke(x, y);
}
layer.end_stroke();
}
layer
}
/// A brush is the one mask that needs nothing uploaded first — no
/// segmentation, no distance field, no label. Requiring one would mean a
/// photograph could not be painted on until a model had run over it.
#[test]
fn a_brush_needs_no_segmentation() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(painted(&[(false, 0.1, vec![(0.2, 0.2), (0.8, 0.8)])]));
let mut pass = MaskPass::new(&ctx).expect("mask pass");
let array = pass.render(&stack, None, None, 32, 32).expect("render");
assert_eq!(array.layers(), 1);
}
/// The box a stroke is drawn over has to be grown by its radius. Packed
/// from the points alone, a tap's box has no area at all and the stroke
/// would be silently missing from the mask.
#[test]
fn a_taps_box_has_room_for_its_dab() {
let layer = painted(&[(false, 0.25, vec![(0.5, 0.5)])]);
let batch = StrokeBatch::pack(layer.strokes(), 64, 32);
assert_eq!(batch.headers.len(), 1);
let h = &batch.headers[0];
assert!(h.hi[0] - h.lo[0] > 0.2, "wide enough for the dab: {h:?}", h = (h.lo, h.hi));
assert!(
h.hi[1] - h.lo[1] > h.hi[0] - h.lo[0],
"and taller than it is wide in normalised units, since the radius \
is a fraction of the shorter edge"
);
}
/// The pipeline is chosen per stroke, so the packed order has to be the
/// painted order — an erase that ended up before its add would put paint
/// back that the user removed.
#[test]
fn packing_keeps_the_painted_order() {
let layer = painted(&[
(false, 0.1, vec![(0.2, 0.5), (0.4, 0.5)]),
(true, 0.1, vec![(0.3, 0.5)]),
(false, 0.1, vec![(0.8, 0.5)]),
]);
let batch = StrokeBatch::pack(layer.strokes(), 32, 32);
assert_eq!(batch.erases, [false, true, false]);
assert_eq!(batch.headers[0].first, 0);
assert_eq!(batch.headers[1].first, batch.headers[0].count);
}
#[test]
fn an_empty_stack_still_yields_a_bindable_array() {
let Some(ctx) = ctx() else {
+152 -1
View File
@@ -28,7 +28,11 @@ struct MaskParams {
label_width: u32,
label_height: u32,
// 0 = regions, 1 = linear, 2 = radial, 3 = subject.
// 0 = regions, 1 = linear, 2 = radial, 3 = subject, 4 = brush.
//
// A brush does not read this — it has its own entry points, because it is
// the one mask that is not a function of the whole frame — but it is set
// anyway so a captured frame says which kind of mask a pass was drawing.
mode: u32,
// How many regions the label field holds, so an out-of-range label is
// caught rather than read past the end of `selected`.
@@ -220,3 +224,150 @@ fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
return vec4<f32>(clamp(m, 0.0, 1.0), 0.0, 0.0, 1.0);
}
// ---------------------------------------------------------------------------
// Brush strokes (ARCH §5.4)
// ---------------------------------------------------------------------------
//
// The mask the architecture was written for. What arrives is a list of
// positions, a radius, a hardness and a flow; what leaves is pixels. Nothing
// between the two ever exists in CPU memory, which is the whole difference from
// darktable, where the same strokes are rasterised on the CPU and the lag makes
// painting unusable.
//
// # Why the strokes are not drawn by the full-screen triangle above
//
// Cost. A swept disc is the minimum distance to any segment of its polyline, so
// evaluating one stroke costs a distance per segment *per pixel*. Over the
// whole frame that is `pixels × segments`, and a stroke that wandered across
// the photograph has both terms large at once.
//
// So each stroke is drawn over its own bounding box instead, expanded by the
// radius. The rasteriser then never invokes the fragment shader for a pixel the
// stroke cannot reach, and the cost becomes `area(box) × segments` — for the
// ordinary case, a dab or a swipe, a small fraction of the frame. The model
// splits a long gesture into strokes of bounded length for the same reason:
// both terms of that product grow with how far one stroke travelled.
//
// # Why the strokes composite with fixed-function blending
//
// Add is `dst + a(1 - dst)` and erase is `dst(1 - a)`, which are exactly a
// source-over and a one-minus-source blend. Expressing them as blend state
// rather than as arithmetic in the shader is what allows one draw per stroke:
// the accumulating mask is the attachment, and no pass ever has to read the
// slice it is writing.
struct StrokeHeader {
// Bounding box in normalised coordinates, already grown by the radius and
// a texel — the vertex shader trusts it and draws nothing outside it.
lo: vec2<f32>,
hi: vec2<f32>,
// Radius in units of the frame's shorter edge, so a dab is round on a frame
// that is not square.
radius: f32,
// Fraction of the radius that is fully covered.
hardness: f32,
// Coverage deposited where the stroke is solid.
flow: f32,
// Window into `stroke_points`.
first: u32,
count: u32,
_pad: u32,
}
@group(0) @binding(4) var<storage, read> strokes: array<StrokeHeader>;
@group(0) @binding(5) var<storage, read> stroke_points: array<vec2<f32>>;
struct BrushVertex {
@builtin(position) pos: vec4<f32>,
// Flat: a stroke index interpolated across its own quad would name a
// different stroke in the middle of it.
@location(0) @interpolate(flat) stroke: u32,
}
// Six vertices per stroke, non-instanced.
//
// Deliberately not one instance per stroke: `@builtin(instance_index)` with a
// non-zero first instance needs base-instance support, which the GL backend
// this has to run on under Android cannot promise. Dividing the vertex index
// costs one integer operation and works everywhere.
@vertex
fn vs_brush(@builtin(vertex_index) v: u32) -> BrushVertex {
var quad = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
);
let i = v / 6u;
let s = strokes[i];
let uv = mix(s.lo, s.hi, quad[v % 6u]);
var out: BrushVertex;
// y is flipped because normalised mask coordinates run downwards, the way
// the fragment shader above reads them, and clip space runs upwards. A
// stroke drawn without this lands mirrored about the horizon, which is
// plausible enough on a symmetric test image to survive a careless check.
out.pos = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0);
out.stroke = i;
return out;
}
// Into units of the frame's shorter edge.
//
// Without this the brush would be a circle in normalised coordinates, which on
// a 3:2 frame is an ellipse half again as wide as it is tall. A brush whose dab
// is not round is not a brush.
fn to_square(uv: vec2<f32>) -> vec2<f32> {
let dims = vec2<f32>(f32(p.width), f32(p.height));
return uv * dims / min(dims.x, dims.y);
}
fn segment_distance(q: vec2<f32>, a: vec2<f32>, b: vec2<f32>) -> f32 {
let ab = b - a;
let len2 = dot(ab, ab);
// A finger that stopped and went back leaves a zero-length segment, and
// dividing by its length is a NaN — which propagates through the min()
// below and takes the whole stroke with it.
if (len2 <= 1e-12) {
return length(q - a);
}
let t = clamp(dot(q - a, ab) / len2, 0.0, 1.0);
return length(q - (a + ab * t));
}
@fragment
fn fs_brush(in: BrushVertex) -> @location(0) vec4<f32> {
let s = strokes[in.stroke];
let q = to_square(vec2<f32>(in.pos.x / f32(p.width), in.pos.y / f32(p.height)));
// The *minimum* over the segments, which is the maximum of their coverage.
// Accumulating the segments instead would make a stroke that crosses itself
// — every circle, every scribble — build up a bright patch where it did,
// and a soft brush would go blotchy along any curve tight enough for
// consecutive dabs to overlap, which is all of them.
var d = 1e30;
if (s.count == 1u) {
// A tap. One point is a legitimate stroke, and it paints one dab.
d = length(q - to_square(stroke_points[s.first]));
} else {
for (var k = 0u; k + 1u < s.count; k = k + 1u) {
d = min(
d,
segment_distance(
q,
to_square(stroke_points[s.first + k]),
to_square(stroke_points[s.first + k + 1u]),
),
);
}
}
// Even at full hardness the edge keeps a one-pixel ramp. A true step would
// alias into a staircase, and the mask is sampled bilinearly at whatever
// zoom the user is inspecting it at — which is where an edge is judged.
let texel = 1.0 / f32(min(p.width, p.height));
let inner = min(s.radius * clamp(s.hardness, 0.0, 1.0), max(s.radius - texel, 0.0));
let coverage = 1.0 - smoothstep(inner, s.radius, d);
return vec4<f32>(clamp(coverage * s.flow, 0.0, 1.0), 0.0, 0.0, 1.0);
}