Files
DarkRoom/core/dr-gpu/src/mask.rs
T
dtourolleandClaude Opus 5 c75849040c Format the tree the way the gate asks for it
`cargo fmt --check` is a required step and had drifted across 45 files. Most of
it arrived this week: several operations were written in parallel worktrees and
merged by hand, and a hand-merge resolves conflicts without ever running the
formatter over the result.

No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its
own commit so the next reader can skip it wholesale rather than search it for
one that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:16:34 +02:00

1284 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Rasterising local-adjustment masks (ARCH §5.4).
//!
//! Turns a [`MaskStack`]'s rules into an r8unorm texture array, one slice per
//! active layer, which the composed adjust shader samples. Nothing here reads
//! back, and no mask ever exists in CPU memory.
//!
//! # What runs when
//!
//! 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 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
//!
//! Region masks index a compacted label field uploaded once per segmentation.
//! Compacted, rather than the watershed's raw basin roots, because a root is a
//! sparse index into pixel space: indexing a per-region array by one would
//! need a table the size of the image, where compacted ids index an array of
//! `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, Stroke, MAX_LAYERS};
use wgpu::util::DeviceExt;
use crate::{GpuContext, GpuError};
/// Modes understood by `mask.wgsl`. Kept beside the shader's `switch`.
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)]
struct MaskParams {
width: u32,
height: u32,
label_width: u32,
label_height: u32,
mode: u32,
region_count: u32,
feather: f32,
/// Which falloff curve a subject layer uses. Kept in step with the
/// `switch` in `mask.wgsl` by `falloff_code`.
falloff: u32,
centre: [f32; 2],
axis: [f32; 2],
softness: f32,
angle: f32,
_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 —
/// the hierarchy that produced the ids stays on the CPU, where the interactive
/// operations (walk up a level, add a region) are cheap graph work.
pub struct LabelField {
buffer: wgpu::Buffer,
width: u32,
height: u32,
region_count: u32,
}
impl LabelField {
/// Upload a compacted label field.
///
/// `labels` is one region id per pixel, every value below `region_count` —
/// exactly [`dr_segment::RegionField::labels`].
pub fn upload(
ctx: &GpuContext,
labels: &[u32],
width: u32,
height: u32,
region_count: u32,
) -> Result<Self, GpuError> {
if labels.len() != (width * height) as usize {
return Err(GpuError::InvalidMask(format!(
"label field is {} entries, expected {}x{}",
labels.len(),
width,
height
)));
}
let buffer = ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask-labels"),
contents: bytemuck::cast_slice(labels),
usage: wgpu::BufferUsages::STORAGE,
});
Ok(Self {
buffer,
width,
height,
region_count,
})
}
pub fn region_count(&self) -> u32 {
self.region_count
}
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
}
/// Signed distance fields for the subject layers, resident on the GPU.
///
/// **One per active layer, in that order** — not one per detected object. Two
/// layers can mask the same subject with different morphology, and closing or
/// opening rebuilds the field rather than offsetting it, so the field belongs
/// to the layer that shaped it.
///
/// `R32Float`, because the values are signed distances in pixels and the
/// controls read them at sub-pixel precision. That is four bytes a pixel:
/// ~7 MB per layer at a 1600 px proxy, which is the price of making grow,
/// shrink and feather cost nothing per frame.
pub struct SubjectMasks {
views: Vec<wgpu::TextureView>,
width: u32,
height: u32,
}
impl SubjectMasks {
/// Upload one distance field per active subject layer.
pub fn upload(
ctx: &GpuContext,
fields: &[&[f32]],
width: u32,
height: u32,
) -> Result<Self, GpuError> {
let expected = (width * height) as usize;
let mut views = Vec::with_capacity(fields.len());
for (i, field) in fields.iter().enumerate() {
if field.len() != expected {
return Err(GpuError::InvalidMask(format!(
"subject field {i} is {} values, expected {width}x{height}",
field.len()
)));
}
let texture = ctx.device.create_texture_with_data(
&ctx.queue,
&wgpu::TextureDescriptor {
label: Some("subject-distance"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
bytemuck::cast_slice(field),
);
views.push(texture.create_view(&wgpu::TextureViewDescriptor::default()));
}
Ok(Self {
views,
width,
height,
})
}
pub fn len(&self) -> usize {
self.views.len()
}
pub fn is_empty(&self) -> bool {
self.views.is_empty()
}
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
fn view(&self, index: usize) -> Option<&wgpu::TextureView> {
self.views.get(index)
}
}
/// The rasterised masks for one edit./// The rasterised masks for one edit.
pub struct MaskArray {
texture: wgpu::Texture,
view: wgpu::TextureView,
width: u32,
height: u32,
layers: u32,
}
impl MaskArray {
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
/// The view the adjust shader binds at `@binding(3)`.
pub fn view(&self) -> &wgpu::TextureView {
&self.view
}
pub fn layers(&self) -> u32 {
self.layers
}
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
fn matches(&self, width: u32, height: u32, layers: u32) -> bool {
self.width == width && self.height == height && self.layers == layers
}
}
/// Rasterises mask layers.
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.
///
/// Exists to be asserted on. Reallocating per frame instead of per resize
/// is the kind of regression that costs a lot of bandwidth and shows up
/// nowhere in the output, so the cheap reuse path is worth a test that
/// can actually see it.
allocations: usize,
/// Bound at the subject slot for any layer that is not a subject.
empty_subject: SubjectMasks,
/// A one-region, always-unselected field, for a stack with no region mask.
///
/// The shader's bindings are fixed, so *something* must be bound at the
/// label slots even when rasterising a gradient. A placeholder is cheaper
/// and far simpler than two pipelines differing only in what they ignore.
placeholder: LabelField,
}
impl MaskPass {
pub fn new(ctx: &GpuContext) -> Result<Self, GpuError> {
let scope = ctx.device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("mask"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/mask.wgsl").into()),
});
let layout = ctx
.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("mask-bgl"),
entries: &[
uniform_entry(0),
storage_entry(1),
storage_entry(2),
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
// `filterable: false`: R32Float cannot be filtered
// without an optional feature, and the shader loads
// texels and interpolates them itself anyway.
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
});
let pipeline_layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("mask-layout"),
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
let pipeline = ctx
.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("mask-pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(MaskArray::FORMAT.into())],
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
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()));
}
let placeholder = LabelField::upload(ctx, &[0], 1, 1, 0)?;
// Everywhere outside, so a layer that somehow reaches this masks
// nothing rather than everything.
let empty_subject = SubjectMasks::upload(ctx, &[&[-1.0f32][..]], 1, 1)?;
Ok(Self {
ctx: ctx.clone(),
layout,
pipeline,
brush_layout,
brush_add,
brush_erase,
array: None,
allocations: 0,
placeholder,
empty_subject,
})
}
/// Rasterise every active layer, returning the array to bind.
///
/// `labels` may be `None` when no layer is a region mask; a region layer
/// without one is skipped rather than drawn wrong, since a mask that
/// silently covers the whole frame would apply an edit everywhere.
pub fn render(
&mut self,
stack: &MaskStack,
labels: Option<&LabelField>,
subjects: Option<&SubjectMasks>,
width: u32,
height: u32,
) -> Result<&MaskArray, GpuError> {
// At least one layer, because a zero-layer texture array is invalid
// and the shader binds this slot unconditionally.
let active = stack.active_count().clamp(1, MAX_LAYERS) as u32;
self.ensure_array(width, height, active)?;
let mut encoder = self
.ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("mask-encoder"),
});
for (slot, layer) in stack.active().enumerate().take(MAX_LAYERS) {
let field = match (&layer.source, labels) {
(MaskSource::Regions { .. }, None) => {
log::warn!(
"mask layer {} is a region mask with no segmentation loaded; skipping",
layer.id
);
continue;
}
(MaskSource::Regions { .. }, Some(f)) => f,
(_, _) => &self.placeholder,
};
// A subject layer whose instance is missing is skipped for the
// same reason a region layer without a segmentation is: an absent
// mask that defaults to "everything" would apply the adjustment to
// the whole photograph, which is a much louder failure than none.
// Indexed by *slot*, not by the instance the layer names: the
// fields are built per layer, in this same order, because two
// layers over one subject can carry different morphology.
let subject = match &layer.source {
MaskSource::Subject { .. } => match subjects.filter(|s| slot < s.len()) {
Some(s) => (s, slot),
None => {
log::warn!("mask layer {} has no distance field; skipping", layer.id);
continue;
}
},
_ => (&self.empty_subject, 0),
};
let params = self.params(layer, field, width, height);
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()]);
Ok(self.array.as_ref().expect("array was just ensured"))
}
/// The currently rasterised array, if any.
pub fn array(&self) -> Option<&MaskArray> {
self.array.as_ref()
}
/// How many times the array texture has been allocated. For tests.
pub fn allocations(&self) -> usize {
self.allocations
}
fn params(
&self,
layer: &dr_pipeline::mask::MaskLayer,
field: &LabelField,
width: u32,
height: u32,
) -> MaskParams {
let base = MaskParams {
width,
height,
label_width: field.width,
label_height: field.height,
mode: MODE_REGIONS,
region_count: field.region_count,
feather: 0.0,
falloff: 0,
centre: [0.5, 0.5],
axis: [1.0, 0.0],
softness: 0.0,
angle: 0.0,
_pad1: [0.0, 0.0],
};
match &layer.source {
// `softness` carries the layer's feather. The model's coverage is
// already a soft sigmoid, so zero means "use the edge the model
// drew" rather than "hard edge" — the one place in this shader
// where zero softness is not a step.
// Feather and morphology are in fractions of the frame's shorter
// edge; the field is in proxy pixels. Converting here keeps the
// stored edit resolution-independent while the shader works in the
// units its texture is actually measured in.
MaskSource::Subject { .. } => {
let short = field_short_edge(width, height);
MaskParams {
mode: MODE_SUBJECT,
// `softness` is the feather half-width in pixels.
softness: (layer.feather * short).max(0.0),
// `angle` carries the morphology offset — reused rather
// than padded, since a subject layer has no ellipse to
// rotate.
angle: morph_offset(layer) * short,
falloff: falloff_code(layer.falloff),
..base
}
}
MaskSource::Regions { .. } => MaskParams {
// A pixel of softening at the proxy-to-output ratio, so the
// edge is equally soft whatever size the render is.
feather: (width as f32 / field.width.max(1) as f32).clamp(0.0, 4.0),
..base
},
MaskSource::Linear {
centre,
angle,
width: ramp,
} => MaskParams {
mode: MODE_LINEAR,
centre: [centre.0, centre.1],
axis: [angle.cos(), angle.sin()],
softness: *ramp,
..base
},
MaskSource::Radial {
centre,
radii,
angle,
feather,
} => MaskParams {
mode: MODE_RADIAL,
centre: [centre.0, centre.1],
axis: [radii.0.max(1e-6), radii.1.max(1e-6)],
softness: *feather,
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;
}
}
/// One byte-flag per region, or a single zero for a non-region layer.
fn selection_buffer(
&self,
layer: &dr_pipeline::mask::MaskLayer,
field: &LabelField,
) -> wgpu::Buffer {
let mut flags = vec![0u32; field.region_count.max(1) as usize];
if let MaskSource::Regions { ids, .. } = &layer.source {
for &id in ids {
if let Some(slot) = flags.get_mut(id as usize) {
*slot = 1;
}
}
}
self.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask-selection"),
contents: bytemuck::cast_slice(&flags),
usage: wgpu::BufferUsages::STORAGE,
})
}
#[allow(clippy::too_many_arguments)]
fn draw(
&self,
encoder: &mut wgpu::CommandEncoder,
slot: u32,
params: &MaskParams,
field: &LabelField,
selected: &wgpu::Buffer,
subject: (&SubjectMasks, usize),
) {
let params_buf = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask-params"),
contents: bytemuck::bytes_of(params),
usage: wgpu::BufferUsages::UNIFORM,
});
let bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("mask-bind"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: params_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: field.buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: selected.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(
subject.0.view(subject.1).unwrap_or_else(|| {
self.empty_subject.view(0).expect("placeholder exists")
}),
),
},
],
});
// The array slice is selected by the attachment rather than by a
// uniform the shader reads — one fewer value that can disagree with
// where the pass actually writes.
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-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
// Cleared rather than loaded: every pixel is written by the
// triangle below, and declaring that lets a tiler skip
// reading the previous contents in.
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,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.draw(0..3, 0..1);
}
fn ensure_array(&mut self, width: u32, height: u32, layers: u32) -> Result<(), GpuError> {
if self
.array
.as_ref()
.is_some_and(|a| a.matches(width, height, layers))
{
return Ok(());
}
let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("mask-array"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: layers,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: MaskArray::FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor {
label: Some("mask-array-view"),
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
self.allocations += 1;
self.array = Some(MaskArray {
texture,
view,
width,
height,
layers,
});
Ok(())
}
}
/// The shorter edge of the space the mask is rasterised in.
///
/// Feather and morphology are stored as fractions of it, so the same edit is
/// the same edge whether it renders to a viewport or to a 24 MP export.
fn field_short_edge(width: u32, height: u32) -> f32 {
width.min(height).max(1) as f32
}
/// How far the boundary moves, in fractions of the shorter edge.
///
/// Zero for closing and opening: those are folded into the field itself when
/// it is built, because their second half acts on a shape the original field
/// does not describe.
fn morph_offset(layer: &dr_pipeline::mask::MaskLayer) -> f32 {
use dr_pipeline::mask::Morphology;
match layer.morphology {
Morphology::Dilate => layer.morph_radius,
Morphology::Erode => -layer.morph_radius,
Morphology::None | Morphology::Close | Morphology::Open => 0.0,
}
}
/// Kept in step with the `switch` in `mask.wgsl`.
fn falloff_code(falloff: dr_pipeline::mask::Falloff) -> u32 {
use dr_pipeline::mask::Falloff;
match falloff {
Falloff::Hard => 0,
Falloff::Linear => 1,
Falloff::Smooth => 2,
Falloff::Gaussian => 3,
Falloff::Exponential => 4,
}
}
/// `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,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
fn storage_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::MaskLayer;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A 4x2 label field: regions 0 and 1 left, 2 and 3 right.
fn labels() -> (Vec<u32>, u32, u32, u32) {
(vec![0, 0, 2, 2, 1, 1, 3, 3], 4, 2, 4)
}
fn lit(source: MaskSource) -> MaskLayer {
let mut layer = MaskLayer::new("m1", source);
layer.set_param("exposure", ParamId("exposure"), 1.0);
layer
}
#[test]
fn a_label_field_of_the_wrong_size_is_rejected() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
assert!(LabelField::upload(&ctx, &[0, 1, 2], 4, 2, 4).is_err());
}
#[test]
fn region_masks_rasterise_to_the_selected_regions() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let (data, w, h, n) = labels();
let field = LabelField::upload(&ctx, &data, w, h, n).expect("upload");
let mut stack = MaskStack::new();
stack.push(lit(MaskSource::Regions {
signature: 1,
level: 4,
ids: vec![0, 1],
}));
let mut pass = MaskPass::new(&ctx).expect("mask pass");
let array = pass
.render(&stack, Some(&field), None, w, h)
.expect("render");
assert_eq!(array.size(), (w, h));
assert_eq!(array.layers(), 1);
}
/// A region layer with no segmentation must produce nothing rather than
/// an all-covering mask, which would apply the edit to the whole frame.
#[test]
fn a_region_layer_without_labels_is_skipped() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(lit(MaskSource::Regions {
signature: 1,
level: 4,
ids: vec![0],
}));
let mut pass = MaskPass::new(&ctx).expect("mask pass");
assert!(pass.render(&stack, None, None, 8, 8).is_ok());
}
#[test]
fn gradients_need_no_segmentation() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(lit(MaskSource::Linear {
centre: (0.5, 0.5),
angle: 0.0,
width: 0.2,
}));
stack.push(lit(MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.3, 0.2),
angle: 0.0,
feather: 0.5,
}));
let mut pass = MaskPass::new(&ctx).expect("mask pass");
let array = pass.render(&stack, None, None, 16, 16).expect("render");
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 {
eprintln!("no adapter; skipping");
return;
};
let mut pass = MaskPass::new(&ctx).expect("mask pass");
let array = pass
.render(&MaskStack::new(), None, None, 8, 8)
.expect("render");
assert_eq!(
array.layers(),
1,
"the adjust shader binds this slot whether or not it reads it"
);
}
#[test]
fn the_array_is_reused_when_nothing_changed() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(lit(MaskSource::Linear {
centre: (0.5, 0.5),
angle: 0.0,
width: 0.2,
}));
let mut pass = MaskPass::new(&ctx).expect("mask pass");
pass.render(&stack, None, None, 32, 32).expect("render");
assert_eq!(pass.allocations(), 1);
pass.render(&stack, None, None, 32, 32).expect("render");
assert_eq!(
pass.allocations(),
1,
"same size and layer count should not reallocate"
);
pass.render(&stack, None, None, 64, 64).expect("render");
assert_eq!(pass.allocations(), 2, "a resize must reallocate");
}
}