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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user