Apply a mask layer's settings as offsets to the global ones

A local adjustment ran as a second chain after every global operation,
then blended by the mask. So global contrast -30 with -20 on a face was
contrast -30, the rest of the chain, and contrast -20 again on the
result, rather than -50 where contrast runs. The two edits compounded in
ways neither slider showed; a flattening applied to an already
flattened picture is how the shadows of a night shot went magenta.

A layer's setting is now an offset from its default, added to the
global setting (clamped to the parameter's range; a moved switch or
choice replaces it) and run at that operation's own place in the chain.
At each operation the global fragment and each touching layer's
combined fragment read the same input colour, and the pixel moves by
each layer's weighted difference: c_g + sum w_i (c_i - c_g). At full
weight that is the combined setting exactly, at zero the global result
exactly, and no setting is applied twice. An offset that brings an
operation back to neutral emits an empty version, which undoes the
global setting inside the mask.

Blending the colours rather than the uniforms is deliberate: the tone
curve and colour mixer emit code only for the channels and bands that
are touched, so the global and combined versions of one operation need
not share a uniform set.

A photograph with no masks compiles to the same shader byte for byte.

Test: global -30 with a whole-frame layer at -20 renders within one
count of global -50.
This commit is contained in:
2026-09-26 20:43:02 -04:00
parent 8392cf772e
commit fc54523093
3 changed files with 489 additions and 119 deletions
+125 -37
View File
@@ -597,10 +597,11 @@ pub fn compose_with_framing(
/// 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".
/// A mask layer's settings are **offsets to the global ones**, applied at each
/// operation's own place in the chain: global contrast −30 and a face at −20
/// is contrast −50 on the face, run where contrast runs. They once ran as a
/// second chain after every global operation, which compounded the two edits
/// in ways neither slider showed — see `mask::offset_onto`.
///
/// The fused-dispatch property survives: three global adjustments and two
/// masked ones are still one shader, one read and one write. The masks
@@ -858,48 +859,76 @@ fn compose_inner(
}
}
for op in &active {
// TRACES: FR-DEV-3
// The local adjustments. Composed first because they are threaded through
// the loop below rather than appended after it: a layer's settings are
// offsets to the global ones, applied at each operation's own place in
// the chain (see `mask::offset_onto` for why). The weights are sampled
// here, once per pixel, ahead of every operation that reads them.
let layers = crate::mask::compose_layers_revealing(masks, reveal, ops);
body.push_str(&layers.weights);
// Every point operation that is active globally *or* in some layer. One
// that only a layer moved still runs here, at its place in the chain,
// with nothing on the global side of its blend.
for op in ops
.iter()
.map(|o| o.as_ref())
.filter(|o| o.detail().is_none())
{
let id = op.descriptor().id.0;
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
if !op.is_active() && local.is_empty() {
continue;
}
let prefix = sanitise(id);
// Each op's uniforms are prefixed, so two operations may both declare
// a field called `amount` without colliding.
let op_uniforms = op.uniforms();
if !op_uniforms.is_empty() {
let _ = writeln!(uniform_fields, " // {id}");
}
for u in &op_uniforms {
let _ = writeln!(uniform_fields, " {prefix}_{}: f32,", u.name);
uniform_values.push(u.value);
}
let mut fragment = String::new();
if op.is_active() {
// Each op's uniforms are prefixed, so two operations may both
// declare a field called `amount` without colliding.
let op_uniforms = op.uniforms();
if !op_uniforms.is_empty() {
let _ = writeln!(uniform_fields, " // {id}");
}
for u in &op_uniforms {
let _ = writeln!(uniform_fields, " {prefix}_{}: f32,", u.name);
uniform_values.push(u.value);
}
for h in op.helpers() {
if !helpers.iter().any(|existing| existing.name == h.name) {
helpers.push(*h);
for h in op.helpers() {
if !helpers.iter().any(|existing| existing.name == h.name) {
helpers.push(*h);
}
}
// Rewrite bare uniform names to their prefixed struct fields, so a
// fragment is written without knowing about any other operation.
fragment = op.wgsl_body();
for u in &op_uniforms {
fragment = rewrite_uniform(&fragment, u.name, &format!("u.{prefix}_{}", u.name));
}
}
// Rewrite bare uniform names to their prefixed struct fields, so a
// fragment is written without knowing about any other operation.
let mut fragment = op.wgsl_body();
for u in &op_uniforms {
fragment = rewrite_uniform(&fragment, u.name, &format!("u.{prefix}_{}", u.name));
}
let _ = writeln!(body, "\n // ---- {id} ----");
let _ = writeln!(body, " {{");
for line in fragment.lines() {
let _ = writeln!(body, " {line}");
}
let _ = writeln!(body, " }}");
body.push_str(&local_block(&fragment, &local));
}
// A layer's operation the global chain does not hold at all. Not a case
// any editor produces — both chains come from `ops::chain` — but a layer
// must not lose an edit because a caller composed a shorter chain.
let mut orphans: Vec<&'static str> = Vec::new();
for l in &layers.ops {
if !orphans.contains(&l.op) && !ops.iter().any(|o| o.descriptor().id.0 == l.op) {
orphans.push(l.op);
}
}
for id in orphans {
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
let _ = writeln!(body, "\n // ---- {id} (local only) ----");
body.push_str(&local_block("", &local));
}
// 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_revealing(masks, reveal);
// TRACES: FR-DEV-19c
// Held apart from the body, because it belongs after the output transform
// rather than among the operations — see `mask::LayerShader::reveal`.
@@ -908,7 +937,6 @@ fn compose_inner(
let reveal_block = layers.reveal.clone();
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);
@@ -1255,6 +1283,66 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
}
}
/// One operation's block: its global fragment, and each layer's version of it
/// blended in by that layer's weight.
///
/// Every version reads the same input — the colour as it arrived at this
/// operation — and the pixel moves from the global result by each layer's
/// difference from it: `c_g + Σ w_i (c_i − c_g)`. At full weight that is the
/// layer's combined setting exactly, at zero it is the global result exactly,
/// and two overlapping layers add their changes rather than one repainting
/// the other.
///
/// With no layer touching the operation this is the block the composer always
/// emitted, byte for byte: a photograph with no masks compiles to the shader
/// it did before layers were offsets.
fn local_block(global: &str, local: &[&crate::mask::LocalOp]) -> String {
let mut out = String::new();
let _ = writeln!(out, " {{");
if local.is_empty() {
for line in global.lines() {
let _ = writeln!(out, " {line}");
}
let _ = writeln!(out, " }}");
return out;
}
let _ = writeln!(out, " let local_in = c;");
let _ = writeln!(out, " {{");
for line in global.lines() {
let _ = writeln!(out, " {line}");
}
let _ = writeln!(out, " }}");
let _ = writeln!(out, " let local_global = c;");
let _ = writeln!(out, " var local_sum = c;");
for l in local {
let w = format!("mask_w{}", l.slot);
// Skipping 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 extra work. Safe as non-uniform control flow — nothing
// inside samples with derivatives or synchronises.
let _ = writeln!(out, " if ({w} > 0.0) {{");
// Fragments write to a `c` they expect to own, so the layer's version
// gets one of its own, shadowing the outer one and starting from what
// this operation was handed.
let _ = writeln!(out, " var c = local_in;");
let _ = writeln!(out, " {{");
for line in l.fragment.lines() {
let _ = writeln!(out, " {line}");
}
let _ = writeln!(out, " }}");
let _ = writeln!(
out,
" local_sum = local_sum + {w} * (c - local_global);"
);
let _ = writeln!(out, " }}");
}
// Two layers pulling the same way can overshoot below zero, and a
// negative component poisons every operation after this one.
let _ = writeln!(out, " c = max(local_sum, vec3<f32>(0.0));");
let _ = writeln!(out, " }}");
out
}
/// The WGSL converting linear sRGB into the output space's primaries.
///
/// A constant matrix rather than a uniform: the space is chosen when the