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
+73
View File
@@ -1135,3 +1135,76 @@ fn two_shown_masks_are_drawn_each_in_its_own_colour() {
"between them, alpha shows black: ({r}, {g}, {b})"
);
}
/// Render a mid-grey-and-shadows frame through `chain` and `stack`.
fn render_chain(
ctx: &GpuContext,
chain: &[Box<dyn dr_pipeline::operation::Operation>],
stack: &MaskStack,
field: Option<&LabelField>,
) -> Vec<u8> {
// A ramp, so both ends of the tonal range are in the comparison.
let data: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|i| {
let v = ((i % SIZE) * 255 / (SIZE - 1)) as u8;
[v, v / 2, v, 255]
})
.collect();
let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload");
let shader = compose_full(
chain,
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
);
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks
.render(stack, field, None, None, 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
}
fn contrast_chain(v: f32) -> Vec<Box<dyn dr_pipeline::operation::Operation>> {
let mut chain = ops::chain();
chain
.iter_mut()
.find(|o| o.descriptor().id.0 == "contrast")
.expect("contrast")
.set_param(ParamId("contrast"), v);
chain
}
/// A layer's setting is an offset to the global one, applied once: global
/// −30 with a whole-frame layer at −20 is exactly global −50 — not −30 and
/// then −20 again on the result, which is what a layer used to do.
#[test]
fn a_whole_frame_layer_adds_its_setting_to_the_global_one() {
let Some(ctx) = ctx() else {
eprintln!("no GPU adapter; skipping");
return;
};
let mut layer = MaskLayer::new("m1", whole_frame());
layer.set_param("contrast", ParamId("contrast"), -20.0);
let mut stack = MaskStack::new();
stack.push(layer);
let field = split_field(&ctx);
let offset = render_chain(&ctx, &contrast_chain(-30.0), &stack, Some(&field));
let direct = render_chain(&ctx, &contrast_chain(-50.0), &MaskStack::new(), None);
let worst = offset
.iter()
.zip(&direct)
.map(|(a, b)| a.abs_diff(*b))
.max()
.unwrap();
assert!(
worst <= 1,
"layer offset differs from the summed setting by {worst}"
);
}