Brighten her face without touching the sky behind her

A mask layer is an ordinary develop chain plus a rule about where it
applies. Nothing in the chain knows it is being masked, so every operation
that works globally now works locally and a newly declared op in `ops/`
arrives with local support already done.

The composer emits each layer after the global chain and before the
conversion out of camera space, which is what a photographer means by "and
*then* lift the shadows on her face". Op fragments write to a `c` they
expect to own, so a layer block shadows it and copies the result back out
through a carrier — assigning the outer one from inside is impossible
precisely because it is shadowed. The fused dispatch survives: three global
adjustments and two masked ones remain one shader, one read, one write.

Masks rasterise on the GPU and never exist in CPU memory (ARCH §5.4). That
is the whole reason darktable's brush masks lag, and it is architectural
rather than tuning, so it is not a thing to inherit and fix later.

The rasteriser is a render pass rather than the compute shader it obviously
wants to be, and the format is why: R8Unorm is not a core storage format,
so a compute path has to widen masks to four bytes per pixel — 768 MB
across eight layers of a 24 MP export, against 192 MB at one byte. A colour
attachment takes R8Unorm happily. The array slice comes from the attached
view, so no slot uniform exists to disagree with where the pass writes.

Region masks index a compacted label field rather than the watershed's raw
basin roots, because a root is a sparse index into pixel space and
indexing a per-region array by one would need a table the size of the
image. Changing a selection then costs a few kilobytes, not a re-upload.

Stored as region ids, not as pixels: diffable, mergeable per-field under
FR-NC-9, and cheap in a sidecar. The ids only mean anything alongside the
segmentation that produced them, so each layer carries that signature and
is treated as stale rather than applied when it does not match — a
confidently wrong mask being much worse than an absent one.

Seven device tests render actual frames and read them back. The unit tests
either side check halves that would both pass if the two agreed with each
other and were both wrong; a mask sampled with x and y swapped satisfies
them and fails these.
This commit is contained in:
2026-08-22 08:39:16 +02:00
parent 0da8271836
commit c6a846a1f9
9 changed files with 1853 additions and 1 deletions
+65
View File
@@ -60,6 +60,8 @@ pub struct AdjustPass {
targets: [Option<Target>; 2],
/// Which of [`Self::targets`] the last render wrote.
current: usize,
/// Bound at `@binding(3)` when the edit carries no mask layers.
empty_masks: wgpu::TextureView,
}
struct Target {
@@ -109,6 +111,20 @@ impl AdjustPass {
},
count: None,
},
// The local-adjustment masks. Present in every layout
// whether or not the edit has any, because the layout
// is built once here and the generated shader declares
// the binding unconditionally for exactly that reason.
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
},
],
});
@@ -120,6 +136,29 @@ impl AdjustPass {
immediate_size: 0,
});
// A 1x1 single-layer mask, bound when the edit has no local
// adjustments. The generated shader never samples it — no layer block
// is emitted — but a bind group must still satisfy the layout.
let empty = ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("adjust-empty-masks"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: crate::MaskArray::FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let empty_masks = empty.create_view(&wgpu::TextureViewDescriptor {
label: Some("adjust-empty-masks-view"),
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
Self {
ctx: ctx.clone(),
bind_group_layout,
@@ -127,6 +166,7 @@ impl AdjustPass {
cache: HashMap::new(),
targets: [None, None],
current: 0,
empty_masks,
}
}
@@ -250,6 +290,25 @@ impl AdjustPass {
shader: &ComposedShader,
width: u32,
height: u32,
) -> Result<&wgpu::Texture, GpuError> {
self.render_masked(source, shader, width, height, None)
}
/// TRACES: FR-DEV-3
/// Render one frame with local adjustments applied.
///
/// `masks` must be the array [`crate::MaskPass`] rasterised for *this*
/// edit: the generated shader addresses slices by index, and an array
/// built from a different stack applies each layer's adjustment through
/// another layer's mask. Passing `None` is correct only for an edit with
/// no active mask layers.
pub fn render_masked(
&mut self,
source: &DemosaicedImage,
shader: &ComposedShader,
width: u32,
height: u32,
masks: Option<&crate::MaskArray>,
) -> Result<&wgpu::Texture, GpuError> {
let (width, height) = (width.max(1), height.max(1));
self.ensure_target(width, height);
@@ -310,6 +369,12 @@ impl AdjustPass {
binding: 2,
resource: wgpu::BindingResource::TextureView(&target.view),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(
masks.map_or(&self.empty_masks, |m| m.view()),
),
},
],
});
+10
View File
@@ -31,4 +31,14 @@ pub enum GpuError {
#[error("image too large for this device: {0}")]
TooLarge(String),
/// A mask input that cannot describe the image it claims to — a label
/// field whose length disagrees with its own dimensions, most often.
///
/// Its own variant rather than a panic because the caller assembles this
/// from a segmentation and a render size that are computed in different
/// places, and a mismatch between them is a bug worth reporting with its
/// numbers rather than an abort.
#[error("invalid mask input: {0}")]
InvalidMask(String),
}
+2
View File
@@ -21,6 +21,7 @@ mod adjust;
mod demosaic;
mod error;
mod histogram;
mod mask;
mod readback;
mod segment;
pub use adjust::AdjustPass;
@@ -29,6 +30,7 @@ pub use error::GpuError;
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
// at all at a crate root shared with demosaic and segmentation.
pub use histogram::{Histogram, HistogramPass, BINS as HISTOGRAM_BINS};
pub use mask::{LabelField, MaskArray, MaskPass};
pub use segment::{SegmentOptions, SegmentPass, Segmentation};
/// Owns the wgpu device and queue.
+645
View File
@@ -0,0 +1,645 @@
//! 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 resized output — which is what keeps a local adjustment as
//! responsive as a global one.
//!
//! # 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, 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;
#[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,
_pad0: f32,
centre: [f32; 2],
axis: [f32; 2],
softness: f32,
angle: f32,
_pad1: [f32; 2],
}
/// 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)
}
}
/// 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,
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,
/// 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)],
});
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,
});
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)?;
Ok(Self {
ctx: ctx.clone(),
layout,
pipeline,
array: None,
allocations: 0,
placeholder,
})
}
/// 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>,
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,
};
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);
}
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,
_pad0: 0.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 {
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
},
}
}
/// 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,
) {
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(),
},
],
});
// 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(())
}
}
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), 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, 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, 16, 16).expect("render");
assert_eq!(array.layers(), 2, "one slice per active layer");
}
#[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, 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, 32, 32).expect("render");
assert_eq!(pass.allocations(), 1);
pass.render(&stack, None, 32, 32).expect("render");
assert_eq!(
pass.allocations(),
1,
"same size and layer count should not reallocate"
);
pass.render(&stack, None, 64, 64).expect("render");
assert_eq!(pass.allocations(), 2, "a resize must reallocate");
}
}
+158
View File
@@ -0,0 +1,158 @@
// Rasterise one local-adjustment mask into a layer of the mask array.
//
// ARCH §5.4: every mask becomes pixels here and never in CPU memory. One draw
// per layer, each targeting its own array slice, run only when a mask's
// *shape* changes — moving a slider on a masked layer re-runs the adjust
// shader and not this one.
//
// # Why this is a render pass and not a compute one
//
// The natural shape for this is a compute shader writing a storage texture,
// and the format is what rules that out: **R8Unorm is not a core storage
// format**, so a compute path has to widen the mask to R32Float or RGBA8 —
// four bytes per pixel per layer. At eight layers over a 24 MP export that is
// 768 MB of masks, against 192 MB at one byte. A colour attachment takes
// R8Unorm happily, so the mask stays one byte and the pass becomes a
// full-screen triangle.
//
// The array slice is chosen by the *view* the caller attaches, so there is no
// slot uniform here — one less thing that can disagree with the shader.
struct MaskParams {
// Output size, which is the render size rather than the segmentation's.
width: u32,
height: u32,
// Label field size. Different from the above: the watershed runs at a
// proxy resolution, and the mask is drawn at whatever the display or the
// export asked for.
label_width: u32,
label_height: u32,
// 0 = regions, 1 = linear, 2 = radial.
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`.
region_count: u32,
// Softening applied to a region mask, in output pixels.
feather: f32,
_pad0: f32,
// Geometry, in normalised output coordinates. Meaning depends on `mode`.
centre: vec2<f32>,
// Linear: (cos, sin) of the ramp direction. Radial: semi-axes.
axis: vec2<f32>,
// Linear: ramp width. Radial: edge falloff as a fraction of the radius.
softness: f32,
// Radial only: rotation of the ellipse.
angle: f32,
_pad1: vec2<f32>,
}
@group(0) @binding(0) var<uniform> p: MaskParams;
// Compacted region id per pixel of the label field. Compacted rather than the
// watershed's raw basin roots: the roots are sparse indices into pixel space,
// so indexing a per-region array by one would need a table as large as the
// image. The compaction happens once, when the segmentation is built.
@group(0) @binding(1) var<storage, read> labels: array<u32>;
// One entry per region: non-zero if the region is in this mask. Small — a few
// thousand bytes — which is what makes changing a selection cheap.
@group(0) @binding(2) var<storage, read> selected: array<u32>;
// A full-screen triangle rather than a quad: three vertices instead of six,
// no shared edge for the rasteriser to crack along, and no vertex buffer.
@vertex
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let x = f32(i32(i) / 2) * 4.0 - 1.0;
let y = f32(i32(i) & 1) * 4.0 - 1.0;
return vec4<f32>(x, y, 0.0, 1.0);
}
fn region_at(px: vec2<i32>) -> u32 {
// Nearest-neighbour from output space into the label field. Deliberately
// not bilinear: region ids are *names*, and the average of region 4 and
// region 9 is not region 6.
let fx = (f32(px.x) + 0.5) / f32(p.width);
let fy = (f32(px.y) + 0.5) / f32(p.height);
let lx = clamp(i32(fx * f32(p.label_width)), 0, i32(p.label_width) - 1);
let ly = clamp(i32(fy * f32(p.label_height)), 0, i32(p.label_height) - 1);
return labels[u32(ly) * p.label_width + u32(lx)];
}
fn in_selection(px: vec2<i32>) -> f32 {
let r = region_at(px);
if (r >= p.region_count) {
return 0.0;
}
return select(0.0, 1.0, selected[r] != 0u);
}
fn region_mask(px: vec2<i32>) -> f32 {
let hard = in_selection(px);
if (p.feather <= 0.0) {
return hard;
}
// Box-average the binary selection over the feather radius. Cheap, and it
// is the whole reason a region mask does not look cut out with scissors:
// the watershed boundary is pixel-exact, which is correct and also harsher
// than any edit wants at a subject's edge.
let r = i32(ceil(p.feather));
var total = 0.0;
var n = 0.0;
for (var dy = -r; dy <= r; dy = dy + 1) {
for (var dx = -r; dx <= r; dx = dx + 1) {
let q = clamp(
px + vec2<i32>(dx, dy),
vec2<i32>(0, 0),
vec2<i32>(i32(p.width) - 1, i32(p.height) - 1),
);
total = total + in_selection(q);
n = n + 1.0;
}
}
return total / n;
}
fn linear_mask(uv: vec2<f32>) -> f32 {
// Signed distance along the ramp direction, from the centre.
let d = dot(uv - p.centre, p.axis);
if (p.softness <= 0.0) {
return select(0.0, 1.0, d >= 0.0);
}
return smoothstep(-p.softness * 0.5, p.softness * 0.5, d);
}
fn radial_mask(uv: vec2<f32>) -> f32 {
let ca = cos(-p.angle);
let sa = sin(-p.angle);
let d = uv - p.centre;
// Into the ellipse's own frame, then normalised by its semi-axes so the
// problem becomes a unit circle.
let local = vec2<f32>(d.x * ca - d.y * sa, d.x * sa + d.y * ca);
let r = length(local / max(p.axis, vec2<f32>(1e-6)));
let edge = clamp(p.softness, 0.0, 1.0);
if (edge <= 0.0) {
return select(0.0, 1.0, r <= 1.0);
}
return 1.0 - smoothstep(1.0 - edge, 1.0, r);
}
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let px = vec2<i32>(i32(pos.x), i32(pos.y));
// Normalised, so a gradient's geometry survives a crop or an export at
// another size — the mask is defined on the frame, not on a pixel count.
let uv = vec2<f32>(pos.x / f32(p.width), pos.y / f32(p.height));
var m = 0.0;
switch p.mode {
case 0u: { m = region_mask(px); }
case 1u: { m = linear_mask(uv); }
case 2u: { m = radial_mask(uv); }
default: { m = 0.0; }
}
return vec4<f32>(clamp(m, 0.0, 1.0), 0.0, 0.0, 1.0);
}
+269
View File
@@ -0,0 +1,269 @@
//! Local adjustments, end to end on a device.
//!
//! The unit tests either side of this one check halves: `dr-pipeline` asserts
//! the generated WGSL says the right thing, and `dr-gpu`'s mask tests assert
//! an array of the right shape comes out. Neither would notice if the two
//! agreed with each other and both were wrong — a mask sampled with x and y
//! swapped satisfies both.
//!
//! So this renders a real frame and reads the pixels back: the masked region
//! must change, the rest must not, and the boundary must fall where the label
//! field says it does.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
use dr_pipeline::operation::compose_full;
use dr_pipeline::{ops, EditGraph, Framing};
use dr_types::ColourSpace;
const SIZE: u32 = 32;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// A flat mid-grey JPEG-path image, so any change is the adjustment's.
fn grey(ctx: &GpuContext) -> DemosaicedImage {
let data: Vec<u8> = (0..SIZE * SIZE).flat_map(|_| [128, 128, 128, 255]).collect();
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
}
/// Two regions: 0 is the left half, 1 the right.
fn split_field(ctx: &GpuContext) -> LabelField {
let labels: Vec<u32> = (0..SIZE * SIZE)
.map(|i| u32::from(i % SIZE >= SIZE / 2))
.collect();
LabelField::upload(ctx, &labels, SIZE, SIZE, 2).expect("label upload")
}
/// A layer brightening whatever it covers, by a lot, so it cannot be missed.
fn brighten(source: MaskSource) -> MaskLayer {
let mut layer = MaskLayer::new("m1", source);
layer.set_param("exposure", ParamId("exposure"), 2.0);
layer
}
fn luma_at(pixels: &[u8], x: u32, y: u32) -> u8 {
pixels[((y * SIZE + x) * 4) as usize]
}
/// Render `stack` over flat grey and hand back the RGBA8 result.
fn render(ctx: &GpuContext, stack: &MaskStack, field: Option<&LabelField>) -> Vec<u8> {
let source = grey(ctx);
let shader = compose_full(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
);
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, field, SIZE, SIZE).expect("rasterise");
let mut adjust = AdjustPass::new(ctx);
adjust
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
.expect("render");
adjust.export_pixels().expect("readback").0
}
#[test]
fn a_region_mask_changes_only_the_regions_it_names() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let mut stack = MaskStack::new();
stack.push(brighten(MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
}));
let pixels = render(&ctx, &stack, Some(&field));
// Sampled well inside each half, clear of the feathered boundary.
let inside = luma_at(&pixels, 4, SIZE / 2);
let outside = luma_at(&pixels, SIZE - 5, SIZE / 2);
assert!(
inside > outside + 40,
"the masked half should be much brighter: {inside} vs {outside}"
);
assert!(
(120..=136).contains(&outside),
"the unmasked half must be untouched mid-grey, got {outside}"
);
}
/// The failure a swapped axis or an inverted comparison would produce, and
/// which the "inside is brighter" assertion alone would not catch.
#[test]
fn inverting_a_region_mask_swaps_which_half_moves() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let mut layer = brighten(MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
});
layer.invert = true;
let mut stack = MaskStack::new();
stack.push(layer);
let pixels = render(&ctx, &stack, Some(&field));
let left = luma_at(&pixels, 4, SIZE / 2);
let right = luma_at(&pixels, SIZE - 5, SIZE / 2);
assert!(
right > left + 40,
"inverted, the *other* half should brighten: left {left}, right {right}"
);
}
#[test]
fn opacity_scales_the_effect() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let source = MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
};
let mut full = MaskStack::new();
full.push(brighten(source.clone()));
let mut half = MaskStack::new();
let mut layer = brighten(source);
layer.opacity = 0.5;
half.push(layer);
let at_full = luma_at(&render(&ctx, &full, Some(&field)), 4, SIZE / 2);
let at_half = luma_at(&render(&ctx, &half, Some(&field)), 4, SIZE / 2);
let untouched = 128;
assert!(
at_half > untouched && at_half < at_full,
"half opacity should land between neutral and full: {untouched} < {at_half} < {at_full}"
);
}
#[test]
fn a_linear_gradient_ramps_across_the_frame() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(brighten(MaskSource::Linear {
centre: (0.5, 0.5),
angle: 0.0,
width: 1.0,
}));
let pixels = render(&ctx, &stack, None);
let left = luma_at(&pixels, 1, SIZE / 2);
let middle = luma_at(&pixels, SIZE / 2, SIZE / 2);
let right = luma_at(&pixels, SIZE - 2, SIZE / 2);
assert!(
left < middle && middle < right,
"a horizontal ramp should increase left to right: {left}, {middle}, {right}"
);
}
#[test]
fn a_radial_mask_is_strongest_at_its_centre() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(brighten(MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.3, 0.3),
angle: 0.0,
feather: 0.5,
}));
let pixels = render(&ctx, &stack, None);
let centre = luma_at(&pixels, SIZE / 2, SIZE / 2);
let corner = luma_at(&pixels, 1, 1);
assert!(
centre > corner + 40,
"the centre should carry the effect: {centre} vs corner {corner}"
);
assert!(
(120..=136).contains(&corner),
"outside the radius must be untouched, got {corner}"
);
}
/// Two layers must not read each other's slice.
#[test]
fn stacked_layers_use_their_own_masks() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let field = split_field(&ctx);
let mut stack = MaskStack::new();
// Left half up.
stack.push(brighten(MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![0],
}));
// Right half down.
let mut darken = MaskLayer::new("m2", MaskSource::Regions {
signature: 1,
level: 2,
ids: vec![1],
});
darken.set_param("exposure", ParamId("exposure"), -2.0);
stack.push(darken);
let pixels = render(&ctx, &stack, Some(&field));
let left = luma_at(&pixels, 4, SIZE / 2);
let right = luma_at(&pixels, SIZE - 5, SIZE / 2);
assert!(left > 150, "left should have brightened, got {left}");
assert!(right < 100, "right should have darkened, got {right}");
}
/// A neutral edit must render identically whether or not masks are bound —
/// otherwise merely *having* the feature would alter every unedited image.
#[test]
fn an_empty_stack_renders_exactly_as_the_unmasked_path() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let plain = {
let source = grey(&ctx);
let mut adjust = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
adjust.export_pixels().expect("readback").0
};
let masked = render(&ctx, &MaskStack::new(), None);
assert_eq!(plain, masked, "an empty mask stack must be a no-op");
}
+1
View File
@@ -36,6 +36,7 @@ pub mod framing;
pub mod graph;
pub mod history;
pub mod lens;
pub mod mask;
pub mod operation;
pub mod ops;
pub mod preset;
+659
View File
@@ -0,0 +1,659 @@
//! Local adjustments — a stack of masked edits over the global chain.
//!
//! FR-DEV-3's last line: "linear gradient, radial gradient, and brush masks".
//! A mask layer is an ordinary develop chain plus a rule saying *where* it
//! applies, and the two halves are deliberately independent — every operation
//! that works globally works locally, with no per-operation support needed and
//! nothing to add when a new one is declared in `ops/`.
//!
//! # Where a mask actually exists
//!
//! **Not here, and not on the CPU at all.** A layer stores the *rule* — some
//! region ids, or a gradient's geometry — and a compute pass rasterises it
//! into a texture (ARCH §5.4). This module's job is to describe the rule and
//! to emit the WGSL that blends by the result.
//!
//! That split is the direct response to darktable, where CPU-rasterised brush
//! masks make painting lag badly enough that users call it unworkable. The
//! problem there is architectural rather than a tuning failure, and the only
//! way not to inherit it is to never put a mask in CPU memory.
//!
//! # Why region ids rather than a raster
//!
//! [`MaskSource::Regions`] stores integers naming regions in the segmentation
//! hierarchy (`dr-segment`). That choice is what makes a mask diffable, cheap
//! in a sidecar, and mergeable per-field under FR-NC-9 — three properties a
//! stored raster has none of (docs/segmentation.md §1). Two devices that
//! select the same subject produce the same small sorted list, and a sync
//! conflict between them is resolvable rather than a binary blob fight.
//!
//! The cost is that the ids only mean anything alongside the segmentation that
//! produced them, so [`MaskSource::Regions::signature`] records which one —
//! see there for what happens when it does not match.
use std::fmt::Write as _;
use crate::descriptor::{OpDescriptor, ParamId};
use crate::operation::Operation;
use crate::ops;
/// Per-layer uniforms the generated shader reads: `invert`, then `opacity`.
pub const LAYER_UNIFORM_FIELDS: usize = 2;
/// The most layers one image may carry.
///
/// A limit exists because the masks are bound as one texture array and every
/// layer costs a full-resolution channel of VRAM — at 24 MP that is ~24 MB
/// each, so an unbounded stack is an out-of-memory waiting for a patient user.
/// Eight is comfortably past what an edit uses in practice and still bounded.
pub const MAX_LAYERS: usize = 8;
/// Where a mask layer applies.
#[derive(Debug, Clone, PartialEq)]
pub enum MaskSource {
/// A set of segmentation regions — the click-to-select mask.
///
/// This is what the watershed and the semantic model exist to produce.
/// Selecting a subject means "the regions the model's instance covers",
/// and the resulting edge is the watershed's, which is to say the image's
/// own (docs/segmentation.md §5).
Regions {
/// Which segmentation these ids index into.
///
/// Region numbering is a property of one particular segmentation of
/// one particular image at one particular proxy size. Store ids
/// without recording that, and a later build with a retuned watershed
/// silently reinterprets the mask as a different shape — the failure
/// mode being *a wrong mask*, which is far worse than *no mask*,
/// because nothing announces it.
///
/// When this does not match the current segmentation the layer is
/// treated as stale rather than applied: see [`MaskLayer::is_stale`].
signature: u64,
/// Granularity: how far up the merge hierarchy the ids were taken.
level: u32,
/// Sorted and deduplicated, so the same selection is byte-identical
/// however it was arrived at — which is what lets it be a cache key.
ids: Vec<u32>,
},
/// A linear gradient — the graduated-filter mask.
///
/// Geometry is in **normalised output coordinates**, so it survives a crop
/// or an export at another size. Storing pixels would make a mask that
/// silently moves when the frame changes.
Linear {
/// Midpoint of the ramp, `0.0..=1.0` in each axis.
centre: (f32, f32),
/// Radians, measured from the +x axis.
angle: f32,
/// Distance from full effect to none, in normalised units. Zero is a
/// hard edge.
width: f32,
},
/// A radial gradient — the classic vignette-shaped local adjustment.
Radial {
centre: (f32, f32),
/// Semi-axes, normalised. Two of them, because a face is an ellipse
/// and forcing a circle makes the user compensate with a crop.
radii: (f32, f32),
angle: f32,
/// Fraction of the radius over which the edge falls off.
feather: f32,
},
}
impl MaskSource {
/// A short stable name for the UI and for debugging.
pub fn kind(&self) -> &'static str {
match self {
Self::Regions { .. } => "regions",
Self::Linear { .. } => "linear",
Self::Radial { .. } => "radial",
}
}
}
/// One local adjustment: a rule about *where*, plus a chain saying *what*.
pub struct MaskLayer {
/// Stable identity, for the sidecar and for merge (FR-NC-9).
pub id: String,
/// What the user called it. Empty means "name me after my source".
pub name: String,
pub source: MaskSource,
/// Swap inside for outside.
pub invert: bool,
/// Global strength of the layer, `0.0..=1.0`.
pub opacity: f32,
/// Off without being deleted — the A/B a local edit is always wanting.
pub enabled: bool,
/// This layer's adjustments.
///
/// A full chain, the same one [`crate::EditGraph`] holds. That is the
/// whole reason local adjustments need no per-operation support: the
/// composer already knows how to turn a chain into WGSL, and a mask layer
/// is a chain that happens to be multiplied by a mask afterwards.
pub ops: Vec<Box<dyn Operation>>,
}
impl Clone for MaskLayer {
/// Cloned by *value*, not by handle: the ops are trait objects, so this
/// rebuilds a fresh chain and copies the parameters across. Needed because
/// the UI edits a layer speculatively and the history stores snapshots.
fn clone(&self) -> Self {
let mut ops = ops::chain();
for (dst, src) in ops.iter_mut().zip(&self.ops) {
for p in src.descriptor().params {
dst.set_param(p.id, src.param(p.id));
}
}
Self {
id: self.id.clone(),
name: self.name.clone(),
source: self.source.clone(),
invert: self.invert,
opacity: self.opacity,
enabled: self.enabled,
ops,
}
}
}
impl std::fmt::Debug for MaskLayer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MaskLayer")
.field("id", &self.id)
.field("name", &self.name)
.field("source", &self.source)
.field("invert", &self.invert)
.field("opacity", &self.opacity)
.field("enabled", &self.enabled)
.field("active_ops", &self.active_ops().count())
.finish()
}
}
impl PartialEq for MaskLayer {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
&& self.name == other.name
&& self.source == other.source
&& self.invert == other.invert
&& self.opacity == other.opacity
&& self.enabled == other.enabled
&& self.params().eq(other.params())
}
}
impl MaskLayer {
/// A new layer over `source`, with every adjustment at neutral.
pub fn new(id: impl Into<String>, source: MaskSource) -> Self {
Self {
id: id.into(),
name: String::new(),
source,
invert: false,
opacity: 1.0,
enabled: true,
ops: ops::chain(),
}
}
/// The name to show, falling back to the source kind.
pub fn display_name(&self) -> &str {
if self.name.is_empty() {
self.source.kind()
} else {
&self.name
}
}
/// Whether this layer would change any pixel.
///
/// A layer with a mask but no adjustment is not inactive in the UI — it is
/// a selection the user is still working on — but it contributes nothing
/// to the shader and is omitted from it.
pub fn is_active(&self) -> bool {
self.enabled && self.opacity > 0.0 && self.active_ops().next().is_some()
}
pub fn active_ops(&self) -> impl Iterator<Item = &dyn Operation> {
self.ops.iter().map(|o| o.as_ref()).filter(|o| o.is_active())
}
/// Whether this layer's region ids belong to a different segmentation.
///
/// Applying it anyway would produce a confidently wrong mask, so callers
/// should offer to recompute rather than render it.
pub fn is_stale(&self, current: u64) -> bool {
matches!(self.source, MaskSource::Regions { signature, .. } if signature != current)
}
pub fn descriptors(&self) -> Vec<&'static OpDescriptor> {
self.ops.iter().map(|o| o.descriptor()).collect()
}
pub fn set_param(&mut self, op: &str, param: ParamId, value: f32) {
if let Some(o) = self.ops.iter_mut().find(|o| o.descriptor().id.0 == op) {
let clamped = o
.descriptor()
.param(param)
.map_or(value, |d| d.clamp(value));
o.set_param(param, clamped);
}
}
/// Every non-default parameter, for the sidecar.
pub fn params(&self) -> impl Iterator<Item = (&'static str, &'static str, f32)> + '_ {
self.ops.iter().flat_map(|o| {
let id = o.descriptor().id.0;
o.descriptor().params.iter().filter_map(move |p| {
let v = o.param(p.id);
(v != p.default).then_some((id, p.id.0, v))
})
})
}
/// The two uniforms the generated shader reads for this layer.
fn uniforms(&self) -> [f32; LAYER_UNIFORM_FIELDS] {
[if self.invert { 1.0 } else { 0.0 }, self.opacity]
}
}
/// The ordered stack of local adjustments.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct MaskStack {
layers: Vec<MaskLayer>,
}
impl MaskStack {
pub fn new() -> Self {
Self::default()
}
pub fn layers(&self) -> &[MaskLayer] {
&self.layers
}
pub fn layers_mut(&mut self) -> &mut [MaskLayer] {
&mut self.layers
}
pub fn is_empty(&self) -> bool {
self.layers.is_empty()
}
pub fn len(&self) -> usize {
self.layers.len()
}
pub fn get(&self, id: &str) -> Option<&MaskLayer> {
self.layers.iter().find(|l| l.id == id)
}
pub fn get_mut(&mut self, id: &str) -> Option<&mut MaskLayer> {
self.layers.iter_mut().find(|l| l.id == id)
}
/// Add a layer, returning whether there was room for it.
///
/// Refuses past [`MAX_LAYERS`] rather than dropping the oldest: a stack at
/// its limit is a thing to tell the user about, and silently discarding
/// work they can see on screen is the wrong way to handle it.
pub fn push(&mut self, layer: MaskLayer) -> bool {
if self.layers.len() >= MAX_LAYERS {
log::warn!("mask stack is full ({MAX_LAYERS} layers); refusing to add another");
return false;
}
self.layers.push(layer);
true
}
pub fn remove(&mut self, id: &str) -> Option<MaskLayer> {
let i = self.layers.iter().position(|l| l.id == id)?;
Some(self.layers.remove(i))
}
/// Reorder, since later layers composite over earlier ones.
pub fn move_to(&mut self, id: &str, index: usize) {
let Some(from) = self.layers.iter().position(|l| l.id == id) else {
return;
};
let layer = self.layers.remove(from);
self.layers.insert(index.min(self.layers.len()), layer);
}
/// The layers that will appear in the shader, in composite order.
///
/// The index within *this* sequence is the texture-array layer the
/// rasteriser must write, which is why both sides call this rather than
/// indexing `layers` — an inactive layer occupies no mask slot, and the
/// two halves disagreeing about that shows as an edit applied through the
/// wrong mask.
pub fn active(&self) -> impl Iterator<Item = &MaskLayer> {
self.layers.iter().filter(|l| l.is_active())
}
pub fn active_count(&self) -> usize {
self.active().count()
}
/// Whether any layer changes any pixel.
pub fn is_neutral(&self) -> bool {
self.active_count() == 0
}
/// Generate a fresh layer id that does not collide with an existing one.
pub fn next_id(&self) -> String {
(1..).map(|n| format!("m{n}")).find(|id| self.get(id).is_none()).expect("infinite range")
}
}
/// One layer's contribution to the generated shader.
pub(crate) struct LayerShader {
pub uniform_fields: String,
pub uniform_values: Vec<f32>,
pub body: String,
pub helpers: Vec<crate::operation::Helper>,
}
/// Emit the WGSL for every active layer.
///
/// `slot` is the layer's index in the mask texture array, matching
/// [`MaskStack::active`].
pub(crate) fn compose_layers(stack: &MaskStack) -> LayerShader {
let mut out = LayerShader {
uniform_fields: String::new(),
uniform_values: Vec::new(),
body: String::new(),
helpers: Vec::new(),
};
for (slot, layer) in stack.active().enumerate() {
let prefix = format!("mask{slot}");
let _ = writeln!(
out.uniform_fields,
" // mask {slot}: {}\n {prefix}_invert: f32,\n {prefix}_opacity: f32,",
layer.display_name()
);
out.uniform_values.extend_from_slice(&layer.uniforms());
let _ = writeln!(
out.body,
"\n // ======== mask {slot}: {} ({}) ========",
layer.display_name(),
layer.source.kind()
);
let _ = writeln!(out.body, " {{");
let _ = writeln!(
out.body,
" var m = textureLoad(masks, vec2<i32>(gid.xy), {slot}, 0).r;"
);
let _ = writeln!(
out.body,
" m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);"
);
let _ = writeln!(
out.body,
" m = clamp(m * u.{prefix}_opacity, 0.0, 1.0);"
);
// Skipping the work where the mask is empty is most of the point of a
// local adjustment: a mask covering a tenth of the frame should cost
// about a tenth of the shader. Safe as non-uniform control flow —
// nothing inside samples with derivatives or synchronises.
let _ = writeln!(out.body, " if (m > 0.0) {{");
// `masked` is the outer-scope carrier: op fragments write to a `c`
// they expect to own, so the inner block shadows `c` and copies the
// result back out. Assigning the outer `c` from inside is not possible
// precisely because it is shadowed.
let _ = writeln!(out.body, " var masked = c;");
let _ = writeln!(out.body, " {{");
let _ = writeln!(out.body, " var c = masked;");
for op in layer.active_ops() {
let id = op.descriptor().id.0;
let op_prefix = format!("{prefix}_{}", crate::operation::sanitise(id));
let op_uniforms = op.uniforms();
if !op_uniforms.is_empty() {
let _ = writeln!(out.uniform_fields, " // mask {slot}: {id}");
}
for u in &op_uniforms {
let _ = writeln!(out.uniform_fields, " {op_prefix}_{}: f32,", u.name);
out.uniform_values.push(u.value);
}
for h in op.helpers() {
if !out.helpers.iter().any(|e| e.name == h.name) {
out.helpers.push(*h);
}
}
let mut fragment = op.wgsl_body();
for u in &op_uniforms {
fragment = crate::operation::rewrite_uniform(
&fragment,
u.name,
&format!("u.{op_prefix}_{}", u.name),
);
}
let _ = writeln!(out.body, " // ---- {id} ----");
let _ = writeln!(out.body, " {{");
for line in fragment.lines() {
let _ = writeln!(out.body, " {line}");
}
let _ = writeln!(out.body, " }}");
}
let _ = writeln!(out.body, " masked = c;");
let _ = writeln!(out.body, " }}");
let _ = writeln!(out.body, " c = mix(c, masked, m);");
let _ = writeln!(out.body, " }}");
let _ = writeln!(out.body, " }}");
}
out
}
/// A stable fingerprint of a segmentation, for [`MaskSource::Regions`].
///
/// Built from the things that change what a region id *means* — the proxy
/// size, the region count, and the options the watershed ran with. Deliberately
/// **not** a hash of the label field: that would be a readback on a path that
/// must not have one (ARCH §6.1), and would also make the signature depend on
/// float arithmetic whose cross-vendor determinism is exactly the open
/// question (docs/segmentation.md §6, M5).
pub fn segmentation_signature(width: u32, height: u32, regions: u32, tuning: u64) -> u64 {
// FNV-1a over the four fields. Small, dependency-free, and adequate: this
// guards against accidental mismatch, not against a forged sidecar.
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for word in [width as u64, height as u64, regions as u64, tuning] {
for byte in word.to_le_bytes() {
h ^= byte as u64;
h = h.wrapping_mul(0x1000_0000_01b3);
}
}
h
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::ParamId;
fn regions(ids: &[u32]) -> MaskSource {
MaskSource::Regions {
signature: 7,
level: 300,
ids: ids.to_vec(),
}
}
fn lit_layer(id: &str, ev: f32) -> MaskLayer {
let mut layer = MaskLayer::new(id, regions(&[1, 2]));
layer.set_param("exposure", ParamId("exposure"), ev);
layer
}
#[test]
fn a_layer_with_no_adjustment_is_not_in_the_shader() {
let layer = MaskLayer::new("m1", regions(&[1]));
assert!(!layer.is_active(), "a bare selection changes no pixel");
let mut stack = MaskStack::new();
stack.push(layer);
assert!(stack.is_neutral());
assert_eq!(compose_layers(&stack).body, "");
}
#[test]
fn a_disabled_layer_is_omitted_but_kept() {
let mut stack = MaskStack::new();
let mut layer = lit_layer("m1", 1.0);
layer.enabled = false;
stack.push(layer);
assert_eq!(stack.active_count(), 0, "disabled layers do not render");
assert_eq!(stack.len(), 1, "but they are not deleted");
}
#[test]
fn zero_opacity_is_inactive() {
let mut layer = lit_layer("m1", 1.0);
layer.opacity = 0.0;
assert!(!layer.is_active());
}
#[test]
fn the_generated_block_reads_its_own_mask_slot() {
let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0));
stack.push(lit_layer("m2", -1.0));
let shader = compose_layers(&stack);
assert!(shader.body.contains("textureLoad(masks, vec2<i32>(gid.xy), 0, 0)"));
assert!(shader.body.contains("textureLoad(masks, vec2<i32>(gid.xy), 1, 0)"));
assert!(shader.body.contains("u.mask0_opacity"));
assert!(shader.body.contains("u.mask1_opacity"));
}
/// The slot a layer renders through must follow `active()`, not the raw
/// index — otherwise disabling layer 0 silently shifts every mask.
#[test]
fn slots_follow_active_order_not_stack_order() {
let mut stack = MaskStack::new();
let mut off = lit_layer("m1", 1.0);
off.enabled = false;
stack.push(off);
stack.push(lit_layer("m2", -1.0));
let shader = compose_layers(&stack);
assert!(
shader.body.contains("gid.xy), 0, 0"),
"the one active layer must use slot 0, not slot 1"
);
assert!(!shader.body.contains("gid.xy), 1, 0"));
}
#[test]
fn each_layer_gets_its_own_uniforms() {
let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0));
stack.push(lit_layer("m2", -1.0));
let shader = compose_layers(&stack);
assert!(shader.uniform_fields.contains("mask0_exposure_"));
assert!(shader.uniform_fields.contains("mask1_exposure_"));
assert_eq!(
shader.uniform_values.len(),
shader.uniform_fields.lines().filter(|l| l.trim_start().starts_with("mask")).count(),
"one value per emitted field"
);
}
#[test]
fn the_inner_block_shadows_c_and_copies_back() {
let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0));
let body = compose_layers(&stack).body;
assert!(body.contains("var masked = c;"));
assert!(body.contains("var c = masked;"));
assert!(body.contains("masked = c;"));
assert!(body.contains("c = mix(c, masked, m);"));
}
#[test]
fn a_full_stack_refuses_rather_than_dropping_work() {
let mut stack = MaskStack::new();
for i in 0..MAX_LAYERS {
assert!(stack.push(lit_layer(&format!("m{i}"), 1.0)));
}
assert!(!stack.push(lit_layer("overflow", 1.0)));
assert_eq!(stack.len(), MAX_LAYERS);
assert!(stack.get("overflow").is_none());
}
#[test]
fn ids_do_not_collide() {
let mut stack = MaskStack::new();
assert_eq!(stack.next_id(), "m1");
stack.push(MaskLayer::new("m1", regions(&[1])));
assert_eq!(stack.next_id(), "m2");
}
#[test]
fn a_layer_from_another_segmentation_is_stale() {
let layer = MaskLayer::new("m1", regions(&[1]));
assert!(!layer.is_stale(7), "same signature is fine");
assert!(layer.is_stale(8), "a retuned segmentation invalidates ids");
// A gradient has no region ids, so nothing can go stale about it.
let grad = MaskLayer::new(
"m2",
MaskSource::Linear { centre: (0.5, 0.5), angle: 0.0, width: 0.2 },
);
assert!(!grad.is_stale(999));
}
#[test]
fn signatures_separate_what_changes_a_region_id() {
let base = segmentation_signature(1600, 1067, 6730, 2);
assert_eq!(base, segmentation_signature(1600, 1067, 6730, 2));
assert_ne!(base, segmentation_signature(1600, 1067, 6730, 5), "tuning");
assert_ne!(base, segmentation_signature(800, 1067, 6730, 2), "proxy size");
assert_ne!(base, segmentation_signature(1600, 1067, 42, 2), "region count");
}
#[test]
fn cloning_copies_parameters_not_handles() {
let layer = lit_layer("m1", 1.5);
let mut copy = layer.clone();
assert_eq!(copy, layer);
copy.set_param("exposure", ParamId("exposure"), -1.0);
assert_ne!(copy, layer, "the clone edits independently");
}
#[test]
fn params_reports_only_what_moved() {
let layer = lit_layer("m1", 1.25);
let moved: Vec<_> = layer.params().collect();
assert_eq!(moved, vec![("exposure", "exposure", 1.25)]);
}
#[test]
fn reordering_moves_a_layer_within_the_stack() {
let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0));
stack.push(lit_layer("m2", 1.0));
stack.push(lit_layer("m3", 1.0));
stack.move_to("m3", 0);
let order: Vec<&str> = stack.layers().iter().map(|l| l.id.as_str()).collect();
assert_eq!(order, ["m3", "m1", "m2"]);
}
}
+44 -1
View File
@@ -26,6 +26,7 @@ use dr_types::{ColourSpace, Transfer};
use crate::descriptor::{OpDescriptor, ParamId, Presentation};
use crate::framing::{Framing, FRAMING_UNIFORM_FIELDS};
use crate::mask::MaskStack;
/// What an operation's parameters affect, for cache invalidation scoping.
///
@@ -188,6 +189,27 @@ pub fn compose_with_framing(
ops: &[Box<dyn Operation>],
framing: &Framing,
output: ColourSpace,
) -> ComposedShader {
compose_full(ops, framing, output, &MaskStack::new())
}
/// TRACES: FR-DEV-3
/// Compose the global chain, the framing, and the local adjustments.
///
/// Mask layers are emitted **after** every global operation and before the
/// conversion out of camera space, so a local exposure acts on the tones the
/// global chain settled on — which is what a photographer means by "and then
/// lift the shadows on her face".
///
/// The fused-dispatch property survives: three global adjustments and two
/// masked ones are still one shader, one read and one write. The masks
/// themselves arrive as a pre-rasterised texture array (ARCH §5.4), so a
/// slider drag over a mask recompiles a shader but re-rasterises nothing.
pub fn compose_full(
ops: &[Box<dyn Operation>],
framing: &Framing,
output: ColourSpace,
masks: &MaskStack,
) -> ComposedShader {
let active: Vec<&dyn Operation> = ops
.iter()
@@ -265,6 +287,21 @@ pub fn compose_with_framing(
let _ = writeln!(body, " }}");
}
// The local adjustments, after every global one: a masked exposure should
// act on the tones the global chain arrived at, not on the ones it started
// from. Their uniforms follow the global ops' in the block for the same
// reason those follow framing's — slot order is emission order, and
// nothing addresses a slot by number.
let layers = crate::mask::compose_layers(masks);
uniform_fields.push_str(&layers.uniform_fields);
uniform_values.extend_from_slice(&layers.uniform_values);
body.push_str(&layers.body);
for h in &layers.helpers {
if !helpers.iter().any(|existing| existing.name == h.name) {
helpers.push(*h);
}
}
// Pad the uniform block to a 16-byte boundary. A struct whose size is not
// a multiple of 16 is rejected by the WGSL uniform address space rules.
let pad = (4 - (uniform_values.len() % 4)) % 4;
@@ -308,6 +345,12 @@ struct Params {{
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var<uniform> u: Params;
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>;
// The local adjustment masks, one array layer each, rasterised by a separate
// pass (ARCH §5.4). Declared unconditionally even when no layer is active, so
// that every generated shader shares one bind group layout — a layout that
// changed with the edit would mean rebuilding the pipeline layout, and the
// cost of the unused declaration is a 1x1 placeholder texture.
@group(0) @binding(3) var masks: texture_2d_array<f32>;
{sampler_helper}{helper_src}{encode_output}
// Display-encoded sRGB back to linear, for sources that arrive that way.
@@ -662,7 +705,7 @@ fn is_ident_byte(b: u8) -> bool {
}
/// Make an operation id safe to embed in a WGSL identifier.
fn sanitise(id: &str) -> String {
pub(crate) fn sanitise(id: &str) -> String {
id.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()