Files
DarkRoom/core/dr-gpu/src/mask.rs
T
dtourolle 4574c35236 Let a part be left out of a mask without being taken out of it
A layer built from parts was missing the one control a correction most
often wants: seeing what it did. The question a subtracted gradient
raises is whether it took only the sky, and the question a stroke raises
is whether it filled the shoulder — and the only way to ask either was
to remove the part and look, which answered the question and lost the
part. The layer's own ring answers a different question, about the
adjustment, and hiding eight layers to check one correction is not an
A/B anybody performs.

So a part carries `hidden`. It is an edit and a history step, as the
layer's switch is, and it is folded into the render fingerprint because
hiding a part changes the mask as surely as removing it does. Where the
mask is built the shown parts are walked rather than the parts, which
is what makes a hidden base hand the fold to the first part that is
shown — and a revealed layer whose every part is hidden clears its slice
rather than leaving whatever the last rasterisation put there to be
read back. `covers` asks the same shown parts, so a layer whose only
adding part is hidden costs no slice at all.

In the sidecar the key is `hidden`, in the part's block or, for the
base, in the mask block — under a word that cannot be confused with the
layer's `enabled`, which has always meant the layer. Absent means shown,
so no file written before the switch existed reads any differently.

The row wears the same ring the layer does, one row down, because it is
the same question about a smaller thing.
2026-09-12 01:08:10 +02:00

1838 lines
69 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 photograph, bound as an input
//!
//! A range mask (FR-DEV-10) selects by what a pixel *is*, so this pass reads
//! the demosaiced source as well as writing masks. It is bound for every draw
//! and looked at by two modes; everything else gets a 1x1 placeholder, for the
//! reason the label field below does — the bindings are fixed, and a second
//! pipeline differing only in what it ignores costs more than a texel.
//!
//! Nothing is read back and nothing is rasterised on this side. What crosses
//! into CPU memory for a range layer is five floats and a matrix.
//!
//! # 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::{Join, MaskSource, MaskStack, Stroke, MAX_LAYERS};
use wgpu::util::DeviceExt;
use crate::{DemosaicedImage, 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;
/// TRACES: FR-DEV-10
const MODE_LUMINANCE: u32 = 5;
/// TRACES: FR-DEV-10
const MODE_COLOUR: u32 = 6;
/// 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,
/// TRACES: FR-DEV-10
/// Source texels per mask texel, per axis. See `image_value` in the
/// shader for why a range averages its footprint rather than sampling it.
source_step: [f32; 2],
/// Camera RGB → linear sRGB, one row per `vec4` because that is the
/// alignment a uniform gives a three-component vector anyway. Only a
/// range mask reads them.
cam_to_srgb: [[f32; 4]; 3],
/// `rgb`: as-shot white balance. `w`: non-zero for a gamma-encoded source.
/// The same packing the generated adjust shader uses, so the two agree by
/// construction rather than by inspection.
as_shot_wb: [f32; 4],
/// Whether this part is turned over before it joins the mask. Read by the
/// combine pass and by nothing else — see `fs_combine`.
invert: u32,
/// A uniform buffer is a multiple of sixteen bytes, and the flag above
/// takes four of them.
_pad: [u32; 3],
}
/// 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,
/// Reads a part back out of [`Self::scratch`] and blends it into the
/// layer's slice. The set operation is the blend state, so these two are
/// one shader as well.
combine_layout: wgpu::BindGroupLayout,
combine_union: wgpu::RenderPipeline,
combine_subtract: wgpu::RenderPipeline,
/// Where a part is drawn before it is joined.
///
/// One texture for the whole stack rather than one per layer, because
/// layers rasterise in sequence and a part is read back immediately after
/// it is drawn. Allocated the first time a layer has more than one part,
/// so a library of unedited masks never pays for it.
scratch: Option<Scratch>,
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,
/// TRACES: FR-DEV-10
/// Bound at the image slot for every mask that is not a range.
///
/// Never sampled by those modes, so its contents do not matter — but it is
/// cleared rather than left undefined, because a placeholder whose value
/// is arbitrary is one that makes a binding mistake look like a mask that
/// nearly works.
empty_image: wgpu::TextureView,
}
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,
},
// TRACES: FR-DEV-10
// The photograph, for a range mask. Unfilterable for the
// same reason the field above is: every read is a
// `textureLoad`, and this pipeline binds no sampler.
wgpu::BindGroupLayoutEntry {
binding: 6,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
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 pipelines that join one part to the mask so far. The blend
// state is the set operation and the shader is the same three
// vertices either way — which is why adding a way to combine masks
// cost no shader arithmetic at all.
let combine_layout =
ctx.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("mask-combine-bgl"),
entries: &[
uniform_entry(0),
wgpu::BindGroupLayoutEntry {
binding: 7,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
// Loaded texel by texel at matching size, so
// there is nothing to filter and no sampler.
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
],
});
let combine_pipeline_layout =
ctx.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("mask-combine-layout"),
bind_group_layouts: &[Some(&combine_layout)],
immediate_size: 0,
});
let combine = |label, blend| {
ctx.device
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&combine_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_combine"),
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,
})
};
// `max`, not source-over: a union must not build up where two parts
// overlap. Two selections that both half-cover a pixel select it half
// — adding them would make the overlap of two soft edges harder than
// either, which is a seam exactly where a photographer joined two
// things to avoid one.
let combine_union = combine(
"mask-combine-union",
wgpu::BlendState {
color: MAX_BLEND,
alpha: MAX_BLEND,
},
);
// `dst * (1 - src)`, which is the erase blend one level up: what the
// mask had, minus what this part covers, in proportion to how much of
// it the part covers.
let combine_subtract = combine(
"mask-combine-subtract",
blend_state(wgpu::BlendFactor::Zero, 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)?;
let empty_image = empty_image(ctx);
// 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,
combine_layout,
combine_union,
combine_subtract,
scratch: None,
array: None,
allocations: 0,
placeholder,
empty_subject,
empty_image,
})
}
/// 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.
///
/// `source` is the photograph a range layer measures (FR-DEV-10), and it
/// is skipped on the same rule for the same reason: without it the shader
/// would read a blank placeholder, and a band that happens to contain
/// black would then cover the whole frame.
pub fn render(
&mut self,
stack: &MaskStack,
labels: Option<&LabelField>,
subjects: Option<&SubjectMasks>,
source: Option<&DemosaicedImage>,
width: u32,
height: u32,
) -> Result<&MaskArray, GpuError> {
self.render_revealing(stack, labels, subjects, source, width, height, None)
}
/// TRACES: FR-DEV-19c
/// [`Self::render`], also drawing the layer being looked at.
///
/// A selection with no adjustment on it changes no pixel, so it is not
/// active and has no slice — which is right until somebody asks to *see*
/// it, and that is the state a photographer is in from choosing a subject
/// until deciding what to do to it.
///
/// `reveal` has to be the same one the shader was composed with and the
/// same one the distance fields were built for: all three index this array
/// by position in [`MaskStack::rendered`], and two of them disagreeing
/// shows as an adjustment applied through another layer's mask.
#[allow(clippy::too_many_arguments)]
pub fn render_revealing(
&mut self,
stack: &MaskStack,
labels: Option<&LabelField>,
subjects: Option<&SubjectMasks>,
source: Option<&DemosaicedImage>,
width: u32,
height: u32,
reveal: Option<&dr_pipeline::mask::Reveal>,
) -> 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.rendered_count(reveal).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.rendered(reveal).enumerate().take(MAX_LAYERS) {
// **The path a mask with one part takes is the path every mask
// took before parts existed**: drawn straight into the layer's
// slice, cleared by the draw itself. Nothing about an unedited
// library's rendering changes, and the scratch texture is never
// allocated for it.
//
// An inverted base is the exception, because turning a part over
// is done where it is read back rather than where it is drawn —
// a brush deposits dabs and cannot know what the rest of the
// frame is. See `fs_combine`.
// TRACES: FR-DEV-19a
// The shown parts, not the parts: a hidden one is skipped here
// and nowhere else, and the first *shown* part is the one that
// opens the fold. Which can leave nothing — a revealed layer with
// every part hidden — and that clears the slice rather than
// leaving whatever the last rasterisation put there to be read
// back as this mask.
let shown: Vec<&dr_pipeline::mask::MaskPart> = layer.shown_parts().collect();
if shown.is_empty() {
self.clear_slice(&mut encoder, slot as u32);
continue;
}
let direct = shown.len() == 1 && !shown[0].invert;
if !direct {
self.ensure_scratch(width, height)?;
}
for (index, part) in shown.iter().copied().enumerate() {
let base = index == 0;
let field = match (&part.source, labels) {
(MaskSource::Regions { .. }, None) => {
log::warn!(
"mask layer {} is a region mask with no segmentation loaded; skipping",
layer.id
);
if base {
break;
}
continue;
}
(MaskSource::Regions { .. }, Some(f)) => f,
(_, _) => &self.placeholder,
};
// A subject part whose instance is missing is skipped for the
// same reason a region part 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 part names: the
// fields are built per layer, in this same order, because two
// layers over one subject can carry different morphology.
// Which is also why only a base part can have one — a model
// part joined to a mask has no field built for it yet, and it
// is skipped rather than drawn against a placeholder that
// would cover the frame.
let subject = match &part.source {
// Category alongside Subject: both are model coverage
// turned into a distance field, both are built per layer
// in this same order, and leaving a category out of here
// is precisely the failure the comment above warns about —
// it binds the 1x1 placeholder, so the mask covers
// everything and the adjustment silently goes global.
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
match subjects.filter(|s| base && slot < s.len()) {
Some(s) => (s, slot),
None => {
log::warn!(
"part {} of mask layer {} has no distance field; skipping",
part.id,
layer.id
);
if base {
break;
}
continue;
}
}
}
_ => (&self.empty_subject, 0),
};
// TRACES: FR-DEV-10
// A range part with no photograph bound is skipped rather than
// drawn against the placeholder, on exactly the rule the two
// cases above follow: an absent mask that defaults to
// "everything" takes a local adjustment global, which is a far
// quieter failure than a part that visibly did not render.
let image = match (&part.source, source) {
(s, None) if s.is_range() => {
log::warn!(
"mask layer {} selects a range with no image loaded; skipping",
layer.id
);
if base {
break;
}
continue;
}
(_, image) => image,
};
let params = self.params(part, field, image, width, height);
let target = if direct {
self.slice_view(slot as u32)
} else {
self.scratch_view()
};
match &part.source {
MaskSource::Brush { strokes } => {
self.draw_brush(&mut encoder, &target, &params, strokes, width, height)
}
_ => {
let selected = self.selection_buffer(part, field);
self.draw(
&mut encoder,
&target,
&params,
field,
&selected,
subject,
image,
);
}
}
if !direct {
// The first part joins a cleared slice, so it lands
// exactly as it was drawn whichever way it says it joins —
// there is nothing yet for a subtraction to take away
// from, and a mask that began by subtracting from nothing
// would render as empty however it was painted afterwards.
let join = if base { Join::Union } else { part.join };
self.combine(&mut encoder, slot as u32, join, base, &params);
}
}
}
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,
part: &dr_pipeline::mask::MaskPart,
field: &LabelField,
source: Option<&DemosaicedImage>,
width: u32,
height: u32,
) -> MaskParams {
// TRACES: FR-DEV-10
// How much of the photograph one mask texel covers. One when there is
// no image bound, which is a value nothing reads — the range modes are
// the only readers and they are skipped in that case.
let source_step = match source {
Some(image) => {
let (sw, sh) = image.size();
[
sw as f32 / width.max(1) as f32,
sh as f32 / height.max(1) as f32,
]
}
None => [1.0, 1.0],
};
// Row-major nine, widened to three `vec4`s. Identity where there is no
// image, so a range that somehow reached the shader without one would
// read camera values rather than nothing — the same defensive choice
// the demosaicer makes for an uncalibrated body.
let m = source.map_or([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], |i| {
i.color_matrix()
});
let wb = source.map_or([1.0, 1.0, 1.0], |i| i.as_shot_wb());
let non_linear = source.is_some_and(|i| i.is_non_linear());
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,
source_step,
cam_to_srgb: [
[m[0], m[1], m[2], 0.0],
[m[3], m[4], m[5], 0.0],
[m[6], m[7], m[8], 0.0],
],
as_shot_wb: [wb[0], wb[1], wb[2], if non_linear { 1.0 } else { 0.0 }],
invert: u32::from(part.invert),
_pad: [0; 3],
};
match &part.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.
// A category shares the subject's mode, and that is not a
// shortcut: both arrive as a soft coverage buffer at proxy
// resolution and both are turned into a distance field before they
// reach here. The shader has no way to tell them apart and no
// reason to want one — what differs is only which model produced
// the coverage.
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
let short = field_short_edge(width, height);
MaskParams {
mode: MODE_SUBJECT,
// `softness` is the feather half-width in pixels.
softness: (part.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(part) * short,
falloff: falloff_code(part.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
},
// TRACES: FR-DEV-10
// A band, carried in the fields the gradients measure geometry
// in. Reused rather than given their own, and it is not a
// shortcut: `centre` and `axis` are two pairs of floats whose
// meaning has always been the mode's to decide, and a range that
// added four more would grow the uniform every other mask pays
// for. What matters is that nothing here is a *coordinate* — a
// range is not a function of position at all.
MaskSource::Luminance { lo, hi, softness } => MaskParams {
mode: MODE_LUMINANCE,
axis: [*lo, *hi],
softness: *softness,
..base
},
MaskSource::Colour {
hue,
hue_width,
chroma_lo,
chroma_hi,
softness,
} => MaskParams {
mode: MODE_COLOUR,
centre: [*hue, *hue_width],
axis: [*chroma_lo, *chroma_hi],
softness: *softness,
..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,
target: &wgpu::TextureView,
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 mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mask-brush-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
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,
part: &dr_pipeline::mask::MaskPart,
field: &LabelField,
) -> wgpu::Buffer {
let mut flags = vec![0u32; field.region_count.max(1) as usize];
if let MaskSource::Regions { ids, .. } = &part.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,
target: &wgpu::TextureView,
params: &MaskParams,
field: &LabelField,
selected: &wgpu::Buffer,
subject: (&SubjectMasks, usize),
source: Option<&DemosaicedImage>,
) {
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")
}),
),
},
// TRACES: FR-DEV-10
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(
source.map_or(&self.empty_image, |i| i.view()),
),
},
],
});
// 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 mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mask-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
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);
}
/// A view of one layer's slice of the array.
fn slice_view(&self, slot: u32) -> wgpu::TextureView {
// 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");
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()
})
}
fn scratch_view(&self) -> wgpu::TextureView {
self.scratch
.as_ref()
.expect("scratch ensured by caller")
.texture
.create_view(&wgpu::TextureViewDescriptor {
label: Some("mask-part"),
..Default::default()
})
}
/// Blend the part sitting in [`Self::scratch`] into a layer's slice.
///
/// `first` clears the slice instead of loading it, which is both cheaper
/// on a tiler and the only thing that makes the fold start from nothing
/// covered rather than from whatever the last rasterisation left.
fn combine(
&self,
encoder: &mut wgpu::CommandEncoder,
slot: u32,
join: Join,
first: bool,
params: &MaskParams,
) {
let params_buf = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask-combine-params"),
contents: bytemuck::bytes_of(params),
usage: wgpu::BufferUsages::UNIFORM,
});
let bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("mask-combine-bind"),
layout: &self.combine_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: params_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&self.scratch_view()),
},
],
});
let target = self.slice_view(slot);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mask-combine-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: if first {
wgpu::LoadOp::Clear(wgpu::Color::BLACK)
} else {
wgpu::LoadOp::Load
},
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(match join {
Join::Union => &self.combine_union,
Join::Subtract => &self.combine_subtract,
});
pass.set_bind_group(0, &bind_group, &[]);
pass.draw(0..3, 0..1);
}
/// Leave a layer's slice covering nothing.
///
/// A pass that clears and draws nothing, for the one case where a layer
/// reaches the array with no part to draw: every part hidden while the
/// layer is being revealed. The slice has to be written, because the
/// shader reads it whatever this function did.
fn clear_slice(&self, encoder: &mut wgpu::CommandEncoder, slot: u32) {
let target = self.slice_view(slot);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mask-clear-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
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,
});
}
/// The texture a part is drawn in before it is joined.
///
/// Allocated on the first mask that has more than one part and kept at the
/// rasterisation size, which is the same size the array is: a part and the
/// slice it joins are compared texel for texel, so there is nothing to
/// scale and nothing to sample between.
fn ensure_scratch(&mut self, width: u32, height: u32) -> Result<(), GpuError> {
if self
.scratch
.as_ref()
.is_some_and(|s| s.width == width && s.height == height)
{
return Ok(());
}
let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("mask-scratch"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
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: &[],
});
self.scratch = Some(Scratch {
texture,
width,
height,
});
Ok(())
}
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 texture one part is drawn into on its way into a layer's slice.
struct Scratch {
texture: wgpu::Texture,
width: u32,
height: u32,
}
/// `max(dst, src)` — the union of two parts.
///
/// Not source-over, which would build up: two parts that each half-cover a
/// pixel select it half, and adding them would make the overlap of two soft
/// edges harder than either of them, drawing a seam exactly where a
/// photographer joined two selections to avoid one.
const MAX_BLEND: wgpu::BlendComponent = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Max,
};
/// TRACES: FR-DEV-10
/// A single black texel, bound at the image slot for a mask that is not a
/// range.
///
/// Written rather than merely allocated. Undefined contents would be read by
/// nothing today, but a binding mistake in a range mask would then produce
/// whatever the driver left in memory — a mask that flickers between builds
/// and machines, which is the hardest shape of bug this pass could have.
fn empty_image(ctx: &GpuContext) -> wgpu::TextureView {
let texture = ctx.device.create_texture_with_data(
&ctx.queue,
&wgpu::TextureDescriptor {
label: Some("mask-empty-image"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DemosaicedImage::FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
// Four half-floats of zero. Rgba16Float, so eight bytes.
&[0u8; 8],
);
texture.create_view(&wgpu::TextureViewDescriptor::default())
}
/// 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(part: &dr_pipeline::mask::MaskPart) -> f32 {
use dr_pipeline::mask::Morphology;
match part.morphology {
Morphology::Dilate => part.morph_radius,
Morphology::Erode => -part.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, 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, 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, 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(0, *erase, *radius, 0.5, 1.0);
for &(x, y) in path {
layer.extend_stroke(0, x, y);
}
layer.end_stroke(0);
}
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, 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, 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, None, 32, 32)
.expect("render");
assert_eq!(pass.allocations(), 1);
pass.render(&stack, None, None, None, 32, 32)
.expect("render");
assert_eq!(
pass.allocations(),
1,
"same size and layer count should not reallocate"
);
pass.render(&stack, None, None, None, 64, 64)
.expect("render");
assert_eq!(pass.allocations(), 2, "a resize must reallocate");
}
}