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:
@@ -335,6 +335,7 @@ fn render_stack(
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
adjust
|
||||
.render_masked(source, &shader, ow, oh, Some(array))
|
||||
|
||||
@@ -1845,7 +1845,21 @@ mod tests {
|
||||
fused_blocks += usize::from(point);
|
||||
}
|
||||
|
||||
// The count the loop above accumulated, plus framing — which emits a
|
||||
// The lens corrections, which are the third kind of block. They are
|
||||
// not in `descriptors` — they rewrite coordinates rather than
|
||||
// transform a colour, so they are not operations — and they run ahead
|
||||
// of the fetch rather than in either stage the loop above sorts into.
|
||||
let mut warp_blocks = 0;
|
||||
for desc in g.warp_descriptors() {
|
||||
let id = desc.id.0;
|
||||
assert!(
|
||||
shader.source.contains(&format!("---- warp: {id} ----")),
|
||||
"{id} was armed above and did not reach the shader"
|
||||
);
|
||||
warp_blocks += 1;
|
||||
}
|
||||
|
||||
// The counts the loops above accumulated, plus framing — which emits a
|
||||
// stage of its own rather than an operation block and is not in
|
||||
// `descriptors`. Asserted as well as the per-operation exclusive-or
|
||||
// because the two catch different faults: the XOR catches an operation
|
||||
@@ -1853,7 +1867,7 @@ mod tests {
|
||||
// in the chain asked for.
|
||||
assert_eq!(
|
||||
shader.source.matches("---- ").count(),
|
||||
fused_blocks + 1,
|
||||
fused_blocks + warp_blocks + 1,
|
||||
"the fused shader carries a block nothing in the chain asked for"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -72,6 +72,7 @@ fn render_at(
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
|
||||
@@ -90,6 +90,7 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
@@ -108,6 +109,7 @@ fn render_unmasked(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust.render(&source, &shader, out, out).expect("render");
|
||||
@@ -230,6 +232,7 @@ fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
|
||||
@@ -989,6 +989,7 @@ mod tests {
|
||||
dr_types::ColourSpace::Srgb,
|
||||
&crate::mask::MaskStack::new(),
|
||||
&crate::spot::SpotSet::new(),
|
||||
&[],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2319,7 +2319,7 @@ mod tests {
|
||||
// And the sampler's last step, which lives in `operation.rs` and is
|
||||
// the half of the map this file does not emit.
|
||||
assert!(
|
||||
crate::operation::sample_source(false).contains("p / aspect + vec2<f32>(0.5)"),
|
||||
crate::operation::sample_source(false, false).contains("p / aspect + vec2<f32>(0.5)"),
|
||||
"the sampler's return to texture coordinates moved"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,6 +120,20 @@ pub struct EditGraph {
|
||||
/// *where* an adjustment applies, and a spot says where a piece of the
|
||||
/// photograph comes from.
|
||||
spots: SpotSet,
|
||||
/// The lens corrections that rewrite coordinates: distortion and lateral
|
||||
/// chromatic aberration (`docs/architecture.md` §5.2).
|
||||
///
|
||||
/// Apart from `ops` for the fourth time, and this one is not about shape
|
||||
/// but about direction. Every [`Operation`] is a function from colour to
|
||||
/// colour, and these run *before* a colour exists: they decide which
|
||||
/// source pixel is read, and CA decides it three times over. See
|
||||
/// [`crate::lens`] for why that cannot be expressed as an operation.
|
||||
///
|
||||
/// Beside [`Self::framing`] in every way that matters — the two compose
|
||||
/// into one coordinate map, and neither can be applied without the other's
|
||||
/// result — but held separately because framing also changes the output's
|
||||
/// dimensions, which a warp never does.
|
||||
warps: Vec<Box<dyn crate::lens::Warp>>,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
@@ -164,6 +178,16 @@ impl EditGraph {
|
||||
masks: Arc::new(MaskStack::new()),
|
||||
film: None,
|
||||
spots: SpotSet::new(),
|
||||
// Distortion first, then CA, and the order is the correction's
|
||||
// rather than a preference. Each warp receives the position the
|
||||
// previous one produced, and lateral CA is a magnification about
|
||||
// the optical axis of the *undistorted* frame — measured on a
|
||||
// barrel-distorted one it would be fitted to a radius the lens
|
||||
// profile does not describe.
|
||||
warps: vec![
|
||||
Box::new(crate::ops::Distortion::new()),
|
||||
Box::new(crate::ops::Aberration::new()),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +272,18 @@ impl EditGraph {
|
||||
self.ops.iter().map(|o| o.descriptor()).collect()
|
||||
}
|
||||
|
||||
/// Descriptors for the coordinate-domain lens corrections, in order.
|
||||
///
|
||||
/// The counterpart to [`Self::descriptors`] and split from it for the same
|
||||
/// reason framing is absent there: a warp emits its own block in the
|
||||
/// generated shader rather than a colour fragment, so the codegen tests
|
||||
/// that count `---- ` markers have to know which kind they are counting.
|
||||
/// A UI wanting everything still reads [`Self::capabilities`], where all
|
||||
/// three kinds arrive together and indistinguishably (FR-DEV-3a).
|
||||
pub fn warp_descriptors(&self) -> Vec<Arc<OpDescriptor>> {
|
||||
self.warps.iter().map(|w| w.descriptor()).collect()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3a | FR-DEV-3c
|
||||
/// Everything a UI needs to build its controls.
|
||||
///
|
||||
@@ -286,6 +322,39 @@ impl EditGraph {
|
||||
}
|
||||
});
|
||||
|
||||
// The lens corrections first, matching where they sit in the shader:
|
||||
// they rewrite the coordinate before any colour is fetched, so nothing
|
||||
// below them can be judged until they are right. It also puts the
|
||||
// three optical corrections together in the panel — these two and the
|
||||
// vignetting node, which is an ordinary operation and arrives above
|
||||
// through `ops`.
|
||||
let warps = self.warps.iter().map(|w| {
|
||||
let desc = w.descriptor();
|
||||
OpCapability {
|
||||
id: desc.id,
|
||||
label: desc.label,
|
||||
active: w.is_active(),
|
||||
params: desc
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| ParamCapability {
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
kind: p.kind.clone(),
|
||||
default: p.default,
|
||||
value: w.param(p.id),
|
||||
facet: p.facet,
|
||||
})
|
||||
.collect(),
|
||||
// No preferred widget. A distortion amount and the two CA
|
||||
// scales are ordinary scalars, and plain sliders — the
|
||||
// fallback every frontend implements — are the right control
|
||||
// for them.
|
||||
presentation: None,
|
||||
attributes: desc.attributes.clone(),
|
||||
}
|
||||
});
|
||||
|
||||
// Framing last, matching where it sits in the pipeline: the crop is
|
||||
// decided after the image looks right, not before.
|
||||
let desc = self.framing.descriptor();
|
||||
@@ -314,7 +383,7 @@ impl EditGraph {
|
||||
attributes: desc.attributes.clone(),
|
||||
};
|
||||
|
||||
ops.chain(std::iter::once(framing)).collect()
|
||||
warps.chain(ops).chain(std::iter::once(framing)).collect()
|
||||
}
|
||||
|
||||
/// Set a parameter, clamping to the descriptor's declared range.
|
||||
@@ -362,6 +431,11 @@ impl EditGraph {
|
||||
// nothing to register (FR-DEV-3c).
|
||||
ops: _,
|
||||
framing: _,
|
||||
// Reached through `capabilities` with the other two. A warp's
|
||||
// parameters are ordinary scalars once they are in that list, so
|
||||
// the sidecar, the clipboard and the undo stack carry them with
|
||||
// nothing registered anywhere (FR-DEV-3c).
|
||||
warps: _,
|
||||
masks,
|
||||
film,
|
||||
spots,
|
||||
@@ -440,6 +514,20 @@ impl EditGraph {
|
||||
return;
|
||||
}
|
||||
|
||||
// The warps, before the operations. Their ids cannot collide with an
|
||||
// operation's — `ops/` and the warp list are disjoint by construction,
|
||||
// and `declared_parity` asserts the chain is exactly what `ops/`
|
||||
// declares — so the order is for readability rather than precedence.
|
||||
if let Some(warp) = self.warps.iter_mut().find(|w| w.descriptor().id == op) {
|
||||
let descriptor = warp.descriptor();
|
||||
let Some(desc) = descriptor.param(param) else {
|
||||
log::warn!("unknown parameter {param} on {op}; ignoring");
|
||||
return;
|
||||
};
|
||||
warp.set_param(param, desc.clamp(value));
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(operation) = self.ops.iter_mut().find(|o| o.descriptor().id == op) else {
|
||||
// A sidecar naming an operation this build does not have. The
|
||||
// rest of the edit must still apply.
|
||||
@@ -465,6 +553,9 @@ impl EditGraph {
|
||||
.param(param)
|
||||
.map(|_| self.framing.param(param));
|
||||
}
|
||||
if let Some(warp) = self.warps.iter().find(|w| w.descriptor().id == op) {
|
||||
return warp.descriptor().param(param).map(|_| warp.param(param));
|
||||
}
|
||||
self.ops
|
||||
.iter()
|
||||
.find(|o| o.descriptor().id == op)
|
||||
@@ -478,6 +569,11 @@ impl EditGraph {
|
||||
op.set_param(p.id, p.default);
|
||||
}
|
||||
}
|
||||
for warp in &mut self.warps {
|
||||
for p in &warp.descriptor().params {
|
||||
warp.set_param(p.id, p.default);
|
||||
}
|
||||
}
|
||||
self.framing.reset();
|
||||
// Masks go too, and this is why `apply` can be a replacement rather
|
||||
// than an overlay: a sidecar with no mask blocks means an edit with no
|
||||
@@ -542,7 +638,14 @@ impl EditGraph {
|
||||
/// graph renders to the screen and to a file in the same breath, and the
|
||||
/// two want different answers.
|
||||
pub fn compose_for(&self, output: dr_types::ColourSpace) -> ComposedShader {
|
||||
compose_full(&self.ops, &self.framing, output, &self.masks, &self.spots)
|
||||
compose_full(
|
||||
&self.ops,
|
||||
&self.framing,
|
||||
output,
|
||||
&self.masks,
|
||||
&self.spots,
|
||||
&self.warps,
|
||||
)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-1
|
||||
@@ -637,6 +740,22 @@ impl EditGraph {
|
||||
u64::from(crate::operation::canonical_bits(self.framing.param(p.id))),
|
||||
);
|
||||
}
|
||||
// The warps belong to the geometry key, not the colour one: they decide
|
||||
// which source pixel a colour is read from, so a cached *result* of
|
||||
// this stage is wrong the moment one moves. Hashed by id as well as by
|
||||
// value, so two warps swapping their amounts is not the same edit.
|
||||
for warp in &self.warps {
|
||||
let descriptor = warp.descriptor();
|
||||
geometry = hash_bytes(geometry, descriptor.id.0.as_bytes());
|
||||
for p in &descriptor.params {
|
||||
geometry = hash_bytes(geometry, p.id.0.as_bytes());
|
||||
geometry = mix(
|
||||
geometry,
|
||||
u64::from(crate::operation::canonical_bits(warp.param(p.id))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The view rect is not a parameter and not in the structure key — it
|
||||
// is not an edit (see `Framing::view`). It is still an input to every
|
||||
// rendered pixel, so a cache that ignored it would show the wrong part
|
||||
@@ -823,20 +942,106 @@ mod tests {
|
||||
assert_ne!(first.uniforms, second.uniforms);
|
||||
}
|
||||
|
||||
/// The whole point of putting the warps in `capabilities`: everything that
|
||||
/// walks that list carries them, with nothing registered anywhere.
|
||||
#[test]
|
||||
fn a_warp_is_carried_by_the_machinery_it_never_told_about_itself() {
|
||||
use crate::ops::{aberration, distortion};
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
|
||||
g.set_param(aberration::ID, aberration::RED, 25.0);
|
||||
|
||||
assert_eq!(g.param(distortion::ID, distortion::AMOUNT), Some(40.0));
|
||||
assert_eq!(g.param(aberration::ID, aberration::RED), Some(25.0));
|
||||
|
||||
// Through the same capture/apply the sidecar, the clipboard and the
|
||||
// undo stack all use.
|
||||
let state = g.state();
|
||||
let mut restored = EditGraph::default_chain();
|
||||
// No film in this graph, so nothing is owed; the result is asserted
|
||||
// rather than dropped because ignoring it elsewhere would leave a
|
||||
// photograph rendering without its stock.
|
||||
assert_eq!(restored.set_state(&state), FilmRebake::NotNeeded);
|
||||
|
||||
assert_eq!(
|
||||
restored.param(distortion::ID, distortion::AMOUNT),
|
||||
Some(40.0),
|
||||
"a distortion correction did not survive a state round trip, so \
|
||||
reopening the photograph would silently drop it"
|
||||
);
|
||||
assert_eq!(restored.param(aberration::ID, aberration::RED), Some(25.0));
|
||||
}
|
||||
|
||||
/// A warp is an edit, so `reset` has to reach it. It did not until the
|
||||
/// loop was added: a reset that left the lens corrections standing would
|
||||
/// mean "back to the file as it is" quietly did not mean that.
|
||||
#[test]
|
||||
fn resetting_the_graph_neutralises_the_warps() {
|
||||
use crate::ops::distortion;
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
|
||||
g.reset();
|
||||
|
||||
assert_eq!(g.param(distortion::ID, distortion::AMOUNT), Some(0.0));
|
||||
}
|
||||
|
||||
/// The warps belong to the geometry key, not the colour one.
|
||||
///
|
||||
/// A tile cache keyed on geometry holds the *result* of the coordinate
|
||||
/// stage. Moving a distortion slider changes which source pixel every
|
||||
/// output pixel reads, so a cache that did not notice would keep drawing
|
||||
/// the previous correction — visibly, and only where it had already
|
||||
/// cached.
|
||||
#[test]
|
||||
fn a_warp_moves_the_geometry_key_and_leaves_the_others_alone() {
|
||||
use crate::operation::Affects;
|
||||
use crate::ops::distortion;
|
||||
|
||||
let mut g = EditGraph::default_chain();
|
||||
let before = g.invalidation();
|
||||
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
|
||||
let after = g.invalidation();
|
||||
|
||||
assert_ne!(
|
||||
before.of(Affects::Geometry),
|
||||
after.of(Affects::Geometry),
|
||||
"a distortion change must invalidate the geometry stage"
|
||||
);
|
||||
assert_eq!(
|
||||
before.of(Affects::Colour),
|
||||
after.of(Affects::Colour),
|
||||
"and must not invalidate the colour stage, which it does not touch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_describe_every_operation_and_parameter() {
|
||||
// The UI builds its whole panel from this. Anything missing here is
|
||||
// something the UI would have to hardcode.
|
||||
let g = EditGraph::default_chain();
|
||||
let caps = g.capabilities();
|
||||
// Every operation, plus framing — which is not an operation and so
|
||||
// is absent from `descriptors`, but must still reach the panel.
|
||||
assert_eq!(caps.len(), g.descriptors().len() + 1);
|
||||
// Every operation, plus everything that is *not* an operation and so
|
||||
// is absent from `descriptors`: the framing, and the coordinate-domain
|
||||
// lens corrections. All of them have parameters a photographer sets,
|
||||
// so all of them have to reach the panel — and the panel is forbidden
|
||||
// from naming any of them (FR-DEV-3a), which leaves this list as the
|
||||
// only way they can arrive.
|
||||
assert_eq!(caps.len(), g.descriptors().len() + g.warps.len() + 1);
|
||||
assert!(
|
||||
caps.iter().any(|c| c.id == crate::framing::ID),
|
||||
"framing must appear in the capability list, or the UI cannot \
|
||||
build a crop control without naming it"
|
||||
);
|
||||
for warp in &g.warps {
|
||||
let id = warp.descriptor().id;
|
||||
assert!(
|
||||
caps.iter().any(|c| c.id == id),
|
||||
"{id} must appear in the capability list, or its correction is \
|
||||
in the shader with no control anywhere that can reach it"
|
||||
);
|
||||
}
|
||||
|
||||
for cap in &caps {
|
||||
assert!(!cap.params.is_empty(), "{} exposes no parameters", cap.id);
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-14
File diff suppressed because one or more lines are too long
@@ -154,6 +154,12 @@ fn catalogued(key: &str) -> Option<&'static str> {
|
||||
// one for what it corrects means the two can sit in the same panel
|
||||
// without either having to be renamed to make room.
|
||||
"op.vignetting" => "Lens Vignetting",
|
||||
// "Chromatic Aberration", not the "Aberration" `derive` would give.
|
||||
// The bare word names a whole family of lens defects — coma, spherical,
|
||||
// astigmatism — and this operation corrects exactly one of them. Under
|
||||
// it sit sliders called "Red" and "Blue", which only make sense once
|
||||
// the heading has said what is being separated.
|
||||
"op.aberration" => "Chromatic Aberration",
|
||||
|
||||
// Parameters
|
||||
"param.temperature" => "Temperature",
|
||||
|
||||
Reference in New Issue
Block a user