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