Put the coordinate-domain lens corrections into the graph

`lens.rs` has held a `Warp` trait, a composer and two implementations —
distortion and lateral chromatic aberration — since they were written, and
`compose_warps` was called by nothing outside its own tests. The corrections
existed, were correct, and never touched a photograph.

`EditGraph` now holds them, and `compose_full` emits them between the framing
prologue and the fetch. Distortion first, then CA: each warp receives the
position the previous one produced, and lateral CA is a magnification about
the optical axis of the *undistorted* frame, so measured on a barrel-distorted
one it would be fitted to a radius no profile describes.

They reach the panel the way framing already does — through `capabilities`.
That was the one open question and existing practice answered it: framing is
also not an `Operation`, also has parameters a photographer sets, and also
arrives through that list. Because `Preset::capture` walks the same list, the
sidecar, the clipboard and the undo stack carry a warp's parameters with
nothing registered anywhere, and no file under `ui/` names one (FR-DEV-3a).

`state()` destructures `EditGraph` field by field precisely so that a new
field cannot be forgotten, and it was not.

Chromatic aberration is the only thing that samples per channel, and
`splits_channels` is what keeps everything else from paying for it. Red and
blue are fetched from positions green is not — green is the reference and
never moves, so a wrong correction still leaves one channel sharp rather than
softening all three. With no CA in the chain the single-fetch path is emitted
instead.

The interpolating sampler is now chosen by framing *or* an active warp. Asking
framing alone would have nearest-neighboured a distortion correction on an
unstraightened frame, and that aliasing reads as a bad profile rather than as
a missing filter.

The warps go in the geometry invalidation key rather than the colour one: they
decide which source pixel a colour is read from, so a tile cached across a
distortion change would keep drawing the previous correction. The pipeline
cache needs nothing new — `hash_source` already covers the generated body, and
uniform values never enter it, so arming a warp recompiles and dragging it
does not. Both are asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-05 15:03:16 +02:00
co-authored by Claude Opus 5
parent e17b909d41
commit a1165ef182
10 changed files with 477 additions and 30 deletions
+224 -8
View File
@@ -510,6 +510,11 @@ pub fn compose_with_framing(
output,
&MaskStack::new(),
&crate::spot::SpotSet::new(),
// No lens corrections. This entry point exists for callers that have
// an operation chain and nothing else — the codegen tests, and the
// export path before it grew a graph — and a warp is not something a
// caller can hold without one.
&[],
)
}
@@ -531,7 +536,13 @@ pub fn compose_full(
output: ColourSpace,
masks: &MaskStack,
spots: &crate::spot::SpotSet,
warps: &[Box<dyn crate::lens::Warp>],
) -> ComposedShader {
// The lens corrections, composed into one coordinate transform. Beside
// `framing` because they are the other half of the same stage: framing
// says which part of the source this output pixel comes from, and a warp
// says where the lens put it once it got there.
let warp = crate::lens::compose_warps(warps);
// Active *point* operations. A neighbourhood operation is filtered out
// here rather than asked for a fragment it cannot write: it reads pixels
// it is not writing, so it belongs to the detail stage that runs after
@@ -636,6 +647,19 @@ pub fn compose_full(
);
uniform_values.extend_from_slice(&framing.uniforms());
// The warps, immediately after framing and before any operation, matching
// where they sit in the shader. Slot order is emission order and nothing
// addresses a slot by number, so this only has to be consistent with
// itself — but keeping it in pipeline order is what makes the generated
// struct readable next to the generated body.
uniform_fields.push_str(&warp.uniform_fields);
uniform_values.extend_from_slice(&warp.uniforms);
for h in &warp.helpers {
if !helpers.iter().any(|existing| existing.name == h.name) {
helpers.push(*h);
}
}
for op in &active {
let id = op.descriptor().id.0;
let prefix = sanitise(id);
@@ -702,17 +726,35 @@ pub fn compose_full(
// The coordinate stage: output pixel -> source position -> colour. Emitted
// ahead of the operation fragments, which receive the sampled `c`.
let prologue = format!(
"{}\n{}",
framing.wgsl_prologue(),
sample_source(framing.needs_interpolation())
);
let sampler_helper = if framing.needs_interpolation() {
BILINEAR_HELPER
// A warp puts an output pixel between source pixels exactly as a free
// angle does, so either one forces the interpolating sampler. Asking
// framing alone — which is what this did before the warps existed — would
// have nearest-neighboured a distortion correction on an unstraightened
// frame, and the aliasing would have looked like a bad profile.
let interpolate = framing.needs_interpolation() || warp.is_active();
// Declared ahead of the warp block, which assigns to them. They enter
// equal to `p` so that a chain mixing a splitting warp with a
// non-splitting one still carries every earlier correction into the red
// and blue paths — a distortion correction must move all three channels,
// and only the CA that follows it may move them apart.
let channel_positions = if warp.splits_channels {
"\n // Per-channel source positions, for the lateral CA correction.\n\
\x20 var p_r = p;\n\
\x20 var p_b = p;\n"
} else {
""
};
let prologue = format!(
"{}{}{}\n{}",
framing.wgsl_prologue(),
channel_positions,
warp.body,
sample_source(interpolate, warp.splits_channels)
);
let sampler_helper = if interpolate { BILINEAR_HELPER } else { "" };
// The tail, and it is the whole of the difference between the two output
// modes. Everything above — the prologue, the fragments, the mask layers,
// the camera matrix — is emitted identically either way, so an operation
@@ -1049,7 +1091,51 @@ fn encode_output(c: vec3<f32>) -> vec3<f32> {{
/// Split out because it is the join between the coordinate stage and the
/// colour stage, and because the choice it makes — an exact integer load, or
/// a filtered sample — is the one thing the free-angle case changes.
pub(crate) fn sample_source(interpolate: bool) -> &'static str {
pub(crate) fn sample_source(interpolate: bool, splits_channels: bool) -> &'static str {
if splits_channels {
// Lateral chromatic aberration is a per-channel radial magnification,
// so red and blue are fetched from positions green is not — which is
// the whole reason a warp is not an `Operation`. By the time a colour
// reaches an operation the three channels have been sampled together
// and the divergence is gone.
//
// Green never moves. It is the reference the other two are scaled
// about, so a correction that is wrong still leaves one channel sharp
// rather than softening all three.
return " // Back to texture coordinates, once per channel.
let uv_src = p / aspect + vec2<f32>(0.5);
let uv_r = p_r / aspect + vec2<f32>(0.5);
let uv_b = p_b / aspect + vec2<f32>(0.5);
// Tested on green alone, not on all three.
//
// The three positions differ by a fraction of a pixel at any correction a
// real lens needs, so testing each would only let the outermost row of the
// frame disagree with itself about whether it exists — which draws a
// coloured fringe along the edge, the exact artefact this is here to
// remove. `sample_bilinear` clamps its own texel indices, so red and blue
// land on the edge pixel rather than out of bounds.
if (any(uv_src < vec2<f32>(0.0)) || any(uv_src >= vec2<f32>(1.0))) {
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(0.0, 0.0, 0.0, 1.0));
return;
}
// TRACES: FR-DEV-3f
// Where this pixel sits on the *source*, in source pixels. Green's
// position, since that is the one that did not move.
let source_px = uv_src * vec2<f32>(src_dims);
let radius = length(p) / (0.5 * length(aspect));
// Three fetches, one channel kept from each. Two thirds of the work is
// discarded, which is why `Warp::splits_channels` exists: with no CA in
// the chain the single-sample path below is emitted instead.
var c = vec3<f32>(
sample_bilinear(uv_r, src_dims).r,
sample_bilinear(uv_src, src_dims).g,
sample_bilinear(uv_b, src_dims).b,
);
";
}
if interpolate {
" // Back to texture coordinates.
let uv_src = p / aspect + vec2<f32>(0.5);
@@ -1427,6 +1513,136 @@ mod tests {
}
}
use crate::lens::Warp as _;
/// A neutral warp list must leave the shader exactly as it was.
///
/// The property the whole `is_active` filter exists for: an unedited
/// photograph keeps the integer `textureLoad` path, and pays nothing —
/// not a bilinear fetch, not a uniform slot, not a recompile — for
/// corrections nobody has asked for.
#[test]
fn warps_at_neutral_change_nothing_at_all() {
let ops = crate::ops::chain();
let framing = Framing::new();
let without = compose_full(
&ops,
&framing,
ColourSpace::Srgb,
&MaskStack::new(),
&crate::spot::SpotSet::new(),
&[],
);
let with_neutral = compose_full(
&ops,
&framing,
ColourSpace::Srgb,
&MaskStack::new(),
&crate::spot::SpotSet::new(),
&[
Box::new(crate::ops::Distortion::new()) as Box<dyn crate::lens::Warp>,
Box::new(crate::ops::Aberration::new()),
],
);
assert_eq!(without.source, with_neutral.source);
assert_eq!(without.structure_hash, with_neutral.structure_hash);
assert_eq!(without.uniforms, with_neutral.uniforms);
assert!(
!without.source.contains("sample_bilinear"),
"an unwarped, unstraightened frame must keep the integer load path"
);
}
/// Distortion alone samples once; chromatic aberration samples three times.
///
/// `splits_channels` is the whole reason for this test. Lateral CA fetches
/// red and blue from positions green is not, and paying that everywhere
/// would triple the texture bandwidth of the common case — a distortion
/// correction with no CA, which is most lens profiles.
#[test]
fn only_chromatic_aberration_splits_the_channels() {
let compose_with = |warps: Vec<Box<dyn crate::lens::Warp>>| {
compose_full(
&crate::ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
&MaskStack::new(),
&crate::spot::SpotSet::new(),
&warps,
)
.source
};
let mut distortion = crate::ops::Distortion::new();
distortion.set_param(crate::ops::distortion::AMOUNT, 40.0);
let only_distortion = compose_with(vec![Box::new(distortion)]);
assert!(
only_distortion.contains("---- warp: distortion ----"),
"an active distortion must reach the shader"
);
assert!(
only_distortion.contains("sample_bilinear"),
"a warp puts output pixels between source pixels, so it forces \
the interpolating sampler even on an unstraightened frame"
);
assert!(
!only_distortion.contains("var p_r"),
"distortion moves all three channels together and must not pay \
for the per-channel path"
);
let mut ca = crate::ops::Aberration::new();
ca.set_param(crate::ops::aberration::RED, 25.0);
let with_ca = compose_with(vec![Box::new(ca)]);
assert!(with_ca.contains("var p_r"), "CA needs per-channel positions");
assert_eq!(
with_ca.matches("sample_bilinear(").count(),
// Three fetches in the body, plus the helper's own definition.
4,
"CA must fetch each channel from its own position"
);
}
/// An active warp is a different shader and must not reuse the cached one.
///
/// Covered by `hash_source` rather than by anything warp-specific — the
/// body is written into the source — but asserted because the alternative
/// failure is silent: the correction would simply never appear, exactly as
/// a zoom did before `Framing::structure_key` gained its last bit.
#[test]
fn arming_a_warp_recompiles_but_moving_its_slider_does_not() {
let compose_at = |amount: f32| {
let mut d = crate::ops::Distortion::new();
d.set_param(crate::ops::distortion::AMOUNT, amount);
compose_full(
&crate::ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
&MaskStack::new(),
&crate::spot::SpotSet::new(),
&[Box::new(d) as Box<dyn crate::lens::Warp>],
)
};
let neutral = compose_at(0.0);
let armed = compose_at(40.0);
let further = compose_at(70.0);
assert_ne!(
neutral.structure_hash, armed.structure_hash,
"arming a warp adds a block to the shader and must recompile"
);
assert_eq!(
armed.structure_hash, further.structure_hash,
"its magnitude is a uniform, so a drag must not recompile"
);
assert_ne!(armed.uniforms, further.uniforms);
}
#[test]
fn structure_hash_ignores_values_but_tracks_the_op_set() {
// The property the shader cache depends on: moving a slider must not