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
+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()