diff --git a/core/dr-gpu/src/adjust.rs b/core/dr-gpu/src/adjust.rs index ce16c7d..9b2e842 100644 --- a/core/dr-gpu/src/adjust.rs +++ b/core/dr-gpu/src/adjust.rs @@ -60,6 +60,8 @@ pub struct AdjustPass { targets: [Option; 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()), + ), + }, ], }); diff --git a/core/dr-gpu/src/error.rs b/core/dr-gpu/src/error.rs index 5fdd8b7..b430d75 100644 --- a/core/dr-gpu/src/error.rs +++ b/core/dr-gpu/src/error.rs @@ -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), } diff --git a/core/dr-gpu/src/lib.rs b/core/dr-gpu/src/lib.rs index 458b65e..ee5d298 100644 --- a/core/dr-gpu/src/lib.rs +++ b/core/dr-gpu/src/lib.rs @@ -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. diff --git a/core/dr-gpu/src/mask.rs b/core/dr-gpu/src/mask.rs new file mode 100644 index 0000000..b06e8f5 --- /dev/null +++ b/core/dr-gpu/src/mask.rs @@ -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 { + 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, + /// 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 { + 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, ¶ms, 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 { + 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) { + (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"); + } +} diff --git a/core/dr-gpu/src/shaders/mask.wgsl b/core/dr-gpu/src/shaders/mask.wgsl new file mode 100644 index 0000000..5f33c47 --- /dev/null +++ b/core/dr-gpu/src/shaders/mask.wgsl @@ -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, + // Linear: (cos, sin) of the ramp direction. Radial: semi-axes. + axis: vec2, + // Linear: ramp width. Radial: edge falloff as a fraction of the radius. + softness: f32, + // Radial only: rotation of the ellipse. + angle: f32, + _pad1: vec2, +} + +@group(0) @binding(0) var 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 labels: array; +// 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 selected: array; + +// 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 { + let x = f32(i32(i) / 2) * 4.0 - 1.0; + let y = f32(i32(i) & 1) * 4.0 - 1.0; + return vec4(x, y, 0.0, 1.0); +} + +fn region_at(px: vec2) -> 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) -> 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) -> 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(dx, dy), + vec2(0, 0), + vec2(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 { + // 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 { + 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(d.x * ca - d.y * sa, d.x * sa + d.y * ca); + let r = length(local / max(p.axis, vec2(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) -> @location(0) vec4 { + let px = vec2(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(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(clamp(m, 0.0, 1.0), 0.0, 0.0, 1.0); +} diff --git a/core/dr-gpu/tests/local_adjustments.rs b/core/dr-gpu/tests/local_adjustments.rs new file mode 100644 index 0000000..52db997 --- /dev/null +++ b/core/dr-gpu/tests/local_adjustments.rs @@ -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 { + 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 = (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 = (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 { + 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"); +} diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index 1e22af6..c522aa4 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -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; diff --git a/core/dr-pipeline/src/mask.rs b/core/dr-pipeline/src/mask.rs new file mode 100644 index 0000000..a5d6cf1 --- /dev/null +++ b/core/dr-pipeline/src/mask.rs @@ -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, + }, + + /// 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>, +} + +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, 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 { + 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 + '_ { + 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, +} + +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 { + 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 { + 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, + pub body: String, + pub helpers: Vec, +} + +/// 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(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(gid.xy), 0, 0)")); + assert!(shader.body.contains("textureLoad(masks, vec2(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"]); + } +} diff --git a/core/dr-pipeline/src/operation.rs b/core/dr-pipeline/src/operation.rs index 15dc2f4..165de80 100644 --- a/core/dr-pipeline/src/operation.rs +++ b/core/dr-pipeline/src/operation.rs @@ -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], 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], + 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; @group(0) @binding(1) var u: Params; @group(0) @binding(2) var output: texture_storage_2d; +// 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; {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()