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:
@@ -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()),
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user