`cargo fmt --check` is a required step and had drifted across 45 files. Most of it arrived this week: several operations were written in parallel worktrees and merged by hand, and a hand-merge resolves conflicts without ever running the formatter over the result. No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its own commit so the next reader can skip it wholesale rather than search it for one that matters. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
630 lines
24 KiB
Rust
630 lines
24 KiB
Rust
//! Clarity and texture, end to end on a real device.
|
||
//!
|
||
//! `dr-pipeline`'s tests assert what the composer *generates* — the kernel
|
||
//! width, the uniforms, which lines of WGSL each node emits. None of that can
|
||
//! tell whether the two passes compose into an unsharp mask, whether the
|
||
//! original colour really survives the hand-off from the blur pass to the
|
||
//! combining one, or whether the halo the soft limit is supposed to bound is
|
||
//! actually bounded in pixels. Those are questions only a GPU answers.
|
||
//!
|
||
//! # Why every measurement is in stops
|
||
//!
|
||
//! The controls work on log luminance, and their guarantees are stated in
|
||
//! stops: an overshoot of at most `gain * threshold`, an effect that is
|
||
//! symmetric about neutral, a strength that does not depend on how bright the
|
||
//! subject is. Asserting on 8-bit code values would restate all of that in a
|
||
//! unit where none of it is true, and would need a fresh magic number for
|
||
//! every brightness tested. So the pixels are decoded back to linear and
|
||
//! compared as ratios.
|
||
//!
|
||
//! # The test image
|
||
//!
|
||
//! A vertical step between two **midtones** rather than between black and
|
||
//! white. Clarity is tapered to nothing at both ends of the range on purpose
|
||
//! (see `midtone_weight`), so a 0–255 step is the one edge in the world it is
|
||
//! designed to leave alone, and a test built on it would measure the taper
|
||
//! working and call it the feature not working.
|
||
|
||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||
use dr_pipeline::descriptor::{OpId, ParamId};
|
||
use dr_pipeline::ops::local_contrast::{Clarity, Texture};
|
||
use dr_pipeline::{Affects, EditGraph, OutputMode};
|
||
use dr_types::ColourSpace;
|
||
|
||
const CLARITY: OpId = OpId("clarity");
|
||
const TEXTURE: OpId = OpId("texture");
|
||
const AMOUNT: ParamId = ParamId("amount");
|
||
|
||
/// Large enough that texture's kernel — a tenth of clarity's — is still more
|
||
/// than one pixel wide. At 1024 its sigma is 1.2 px; at 256 it would round to
|
||
/// a delta and the control would honestly do nothing, which is the behaviour
|
||
/// `texture_stops_rather_than_lying_when_the_render_is_too_small` covers and
|
||
/// not the behaviour under test here.
|
||
const SIZE: u32 = 1024;
|
||
|
||
/// The two sides of the step, as sRGB code values.
|
||
///
|
||
/// Both well inside the range, and roughly two stops apart — a real edge, of
|
||
/// the kind that produces the halo this file exists to bound.
|
||
const DARK: u8 = 90;
|
||
const BRIGHT: u8 = 175;
|
||
|
||
fn ctx() -> Option<GpuContext> {
|
||
// CI runners and headless machines may have no usable adapter. Skip rather
|
||
// than fail, exactly as the rest of this crate's device tests do.
|
||
match pollster::block_on(GpuContext::new_headless()) {
|
||
Ok(c) => Some(c),
|
||
Err(e) => {
|
||
eprintln!("skipping: no GPU adapter ({e})");
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
fn srgb_decode(v: u8) -> f32 {
|
||
let e = v as f32 / 255.0;
|
||
if e <= 0.040_45 {
|
||
e / 12.92
|
||
} else {
|
||
((e + 0.055) / 1.055).powf(2.4)
|
||
}
|
||
}
|
||
|
||
/// A vertical step from `DARK` to `BRIGHT` at the half-way column.
|
||
fn step_edge(ctx: &GpuContext, size: u32, tint: [f32; 3]) -> DemosaicedImage {
|
||
let data: Vec<u8> = (0..size * size)
|
||
.flat_map(|i| {
|
||
let x = i % size;
|
||
let v = if x < size / 2 { DARK } else { BRIGHT } as f32;
|
||
[
|
||
(v * tint[0]).round() as u8,
|
||
(v * tint[1]).round() as u8,
|
||
(v * tint[2]).round() as u8,
|
||
255,
|
||
]
|
||
})
|
||
.collect();
|
||
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
|
||
}
|
||
|
||
/// One row of the rendered image, as linear luminance-ish red values.
|
||
fn row(pixels: &[u8], size: u32, y: u32) -> Vec<u8> {
|
||
(0..size)
|
||
.map(|x| pixels[((y * size + x) * 4) as usize])
|
||
.collect()
|
||
}
|
||
|
||
/// One row as full RGB triples.
|
||
fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> {
|
||
(0..size)
|
||
.map(|x| {
|
||
let i = ((y * size + x) * 4) as usize;
|
||
[pixels[i], pixels[i + 1], pixels[i + 2]]
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Render one graph with its detail stage and read the pixels back.
|
||
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||
let scale = graph.render_scale(source.size(), (out, out));
|
||
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||
let key = graph.invalidation().through(Affects::Colour);
|
||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||
.expect("render");
|
||
pass.export_pixels().expect("readback").0
|
||
}
|
||
|
||
/// A graph with one of the two controls set and everything else neutral.
|
||
fn graph_with(op: OpId, amount: f32) -> EditGraph {
|
||
let mut g = EditGraph::default_chain();
|
||
g.set_param(op, AMOUNT, amount);
|
||
g
|
||
}
|
||
|
||
/// How far a pixel moved, in stops, against the same pixel unedited.
|
||
fn stops(edited: u8, plain: u8) -> f32 {
|
||
(srgb_decode(edited).max(1e-6) / srgb_decode(plain).max(1e-6)).log2()
|
||
}
|
||
|
||
#[test]
|
||
fn clarity_lifts_local_contrast_and_leaves_the_flat_regions_alone() {
|
||
// The definition of a local contrast control, as pixels: it must do
|
||
// something at the edge and *nothing* a long way from it. An operation
|
||
// that brightened the whole bright plateau would be an exposure slider
|
||
// with extra steps, and it is the failure a sign error in the base
|
||
// produces.
|
||
let Some(ctx) = ctx() else { return };
|
||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||
|
||
let mut plain_pass = AdjustPass::new(&ctx);
|
||
let plain = row(
|
||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||
SIZE,
|
||
SIZE / 2,
|
||
);
|
||
|
||
let mut pass = AdjustPass::new(&ctx);
|
||
let edited = row(
|
||
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE),
|
||
SIZE,
|
||
SIZE / 2,
|
||
);
|
||
|
||
let edge = (SIZE / 2) as usize;
|
||
let reach = Clarity::with_amount(100.0)
|
||
.kernel(EditGraph::default_chain().render_scale((SIZE, SIZE), (SIZE, SIZE)))
|
||
as usize;
|
||
|
||
// Far outside the kernel's reach the base equals the pixel, the detail
|
||
// signal is zero, and the output must be the input to the last code value.
|
||
for x in [
|
||
0,
|
||
reach / 2,
|
||
SIZE as usize - 1 - reach / 2,
|
||
SIZE as usize - 1,
|
||
] {
|
||
assert!(
|
||
edited[x].abs_diff(plain[x]) <= 1,
|
||
"column {x} moved by {} away from any edge",
|
||
edited[x].abs_diff(plain[x])
|
||
);
|
||
}
|
||
|
||
// And at the edge it must do the thing it is for: the bright side lifts,
|
||
// the dark side drops, which is what "more local contrast" means.
|
||
assert!(
|
||
edited[edge] > plain[edge] + 4,
|
||
"the bright side of the edge did not lift: {} vs {}",
|
||
edited[edge],
|
||
plain[edge]
|
||
);
|
||
assert!(
|
||
edited[edge - 1] + 4 < plain[edge - 1],
|
||
"the dark side of the edge did not drop: {} vs {}",
|
||
edited[edge - 1],
|
||
plain[edge - 1]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_soft_limit_bounds_the_halo_at_a_hard_edge() {
|
||
// The single most common way clarity is got wrong, held to a number.
|
||
//
|
||
// `t * tanh(d / t)` saturates at `t`, so no pixel may move further than
|
||
// `gain * threshold` stops however violent the edge — a bound that holds
|
||
// by construction rather than by tuning, and one this test takes from the
|
||
// operation itself rather than restating.
|
||
//
|
||
// The comparison that gives it meaning is the second assertion: an
|
||
// unlimited unsharp mask over this edge would move the bright side by
|
||
// about half the step, which is more than twice as far. That is the
|
||
// difference between a control and a white glow along the skyline.
|
||
let Some(ctx) = ctx() else { return };
|
||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||
|
||
let mut plain_pass = AdjustPass::new(&ctx);
|
||
let plain = row(
|
||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||
SIZE,
|
||
SIZE / 2,
|
||
);
|
||
let mut pass = AdjustPass::new(&ctx);
|
||
let edited = row(
|
||
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE),
|
||
SIZE,
|
||
SIZE / 2,
|
||
);
|
||
|
||
let worst = (0..SIZE as usize)
|
||
.map(|x| stops(edited[x], plain[x]).abs())
|
||
.fold(0.0f32, f32::max);
|
||
let bound = Clarity::with_amount(100.0).overshoot_bound();
|
||
|
||
// Where the two comparisons below sit, derived rather than observed:
|
||
//
|
||
// srgb_decode(175) = 0.4287, srgb_decode(90) = 0.1022
|
||
// the step is log2(0.4287 / 0.1022) = 2.069 stops
|
||
// an unlimited mask peaks at half of it = 1.034 stops
|
||
// the soft limit saturates at = 0.350 stops (`bound`)
|
||
// the midtone taper then takes about 13% off at 175, so the peak this
|
||
// test should actually see is near = 0.30 stops
|
||
//
|
||
// So 0.30 has to clear the 0.1 floor with room, and fall well under both
|
||
// 0.35 + slack and 0.6 × 1.034 = 0.62. Every one of those is a bound with
|
||
// a reason, not a tolerance widened until the test passed.
|
||
//
|
||
// A code value's worth of slack: the readback is 8-bit, and a pixel
|
||
// sitting exactly on the bound quantises either side of it.
|
||
assert!(
|
||
worst <= bound + 0.02,
|
||
"a pixel moved {worst:.3} stops, past the {bound:.3} the soft limit \
|
||
promises"
|
||
);
|
||
|
||
// Half the step is what an unlimited mask would have produced at the very
|
||
// edge, since the base there is the mean of the two plateaus.
|
||
let unlimited = (srgb_decode(BRIGHT) / srgb_decode(DARK)).log2() / 2.0;
|
||
assert!(
|
||
worst < unlimited * 0.6,
|
||
"the limit is not biting: {worst:.3} stops against the {unlimited:.3} \
|
||
an unlimited unsharp mask would give"
|
||
);
|
||
// But it is still a real effect, not a control that does nothing.
|
||
assert!(
|
||
worst > 0.1,
|
||
"clarity moved almost nothing: {worst:.3} stops"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_proxy_and_an_export_agree_about_the_effect() {
|
||
// TRACES: FR-DSP-1 — the decision the radius unit rests on, proved in
|
||
// pixels rather than in kernel widths.
|
||
//
|
||
// Clarity's radius is a fraction of the frame because the control is
|
||
// compositional: "separate the subject from its background" is a statement
|
||
// about how much of the picture the subject occupies. If that is right,
|
||
// the *same edit* rendered at two resolutions must produce an effect of
|
||
// the same strength covering the same proportion of the frame — which is
|
||
// exactly what a photographer tuning on screen and exporting at full size
|
||
// is relying on.
|
||
//
|
||
// Had the radius been stated in source pixels, the proxy here would show
|
||
// half the reach and the export would be a different photograph.
|
||
let Some(ctx) = ctx() else { return };
|
||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||
|
||
// Peak excursion in stops, and how far the effect reaches, as a fraction
|
||
// of the frame.
|
||
let measure = |out: u32| -> (f32, f32) {
|
||
let mut plain_pass = AdjustPass::new(&ctx);
|
||
let plain = row(
|
||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, out),
|
||
out,
|
||
out / 2,
|
||
);
|
||
let mut pass = AdjustPass::new(&ctx);
|
||
let edited = row(
|
||
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, out),
|
||
out,
|
||
out / 2,
|
||
);
|
||
|
||
let moved: Vec<f32> = (0..out as usize)
|
||
.map(|x| stops(edited[x], plain[x]).abs())
|
||
.collect();
|
||
let peak = moved.iter().cloned().fold(0.0f32, f32::max);
|
||
// The width of the band that moved by more than a tenth of the peak —
|
||
// a threshold relative to the effect, so it means the same thing at
|
||
// both sizes.
|
||
let touched = moved.iter().filter(|m| **m > peak * 0.1).count();
|
||
(peak, touched as f32 / out as f32)
|
||
};
|
||
|
||
let (proxy_peak, proxy_reach) = measure(SIZE / 2);
|
||
let (export_peak, export_reach) = measure(SIZE);
|
||
|
||
assert!(
|
||
(proxy_peak - export_peak).abs() < 0.03,
|
||
"the same edit is {proxy_peak:.3} stops on the proxy and \
|
||
{export_peak:.3} in the export"
|
||
);
|
||
assert!(
|
||
(proxy_reach - export_reach).abs() < 0.02,
|
||
"the effect covers {proxy_reach:.3} of the proxy and {export_reach:.3} \
|
||
of the export; a radius tuned on screen must land in the file"
|
||
);
|
||
// And it is a real effect at both sizes, not two flat images agreeing.
|
||
assert!(
|
||
proxy_peak > 0.1 && proxy_reach > 0.02,
|
||
"{proxy_peak:.3} stops over {proxy_reach:.3} of the proxy"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn texture_acts_at_a_finer_scale_than_clarity() {
|
||
// The whole reason there are two nodes. If the two controls ever reach the
|
||
// same distance from an edge, the second slider has become a duplicate of
|
||
// the first and a photographer setting both is setting one thing twice.
|
||
let Some(ctx) = ctx() else { return };
|
||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||
|
||
let mut plain_pass = AdjustPass::new(&ctx);
|
||
let plain = row(
|
||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||
SIZE,
|
||
SIZE / 2,
|
||
);
|
||
|
||
let reach = |op: OpId| -> usize {
|
||
let mut pass = AdjustPass::new(&ctx);
|
||
let edited = row(
|
||
&render(&mut pass, &graph_with(op, 100.0), &source, SIZE),
|
||
SIZE,
|
||
SIZE / 2,
|
||
);
|
||
// How many columns moved by more than a code value — the honest
|
||
// measure of "how far from the edge does this control reach".
|
||
(0..SIZE as usize)
|
||
.filter(|&x| edited[x].abs_diff(plain[x]) > 1)
|
||
.count()
|
||
};
|
||
|
||
let coarse = reach(CLARITY);
|
||
let fine = reach(TEXTURE);
|
||
assert!(fine > 0, "texture did nothing at all");
|
||
assert!(
|
||
coarse > fine * 4,
|
||
"clarity reaches {coarse} columns and texture {fine}; these are not \
|
||
separable scales"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn clarity_moves_luminance_without_moving_hue() {
|
||
// The third halo decision, in pixels. The gain is applied as a scale on
|
||
// the whole triple, so chromaticity is untouched; boosting the channels
|
||
// independently would put a *coloured* fringe along every edge, arriving
|
||
// from a control the photographer reads as contrast.
|
||
let Some(ctx) = ctx() else { return };
|
||
// A strongly tinted step, so a per-channel mask would show plainly.
|
||
let source = step_edge(&ctx, SIZE, [1.0, 0.55, 0.25]);
|
||
|
||
let mut plain_pass = AdjustPass::new(&ctx);
|
||
let plain = row_rgb(
|
||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||
SIZE,
|
||
SIZE / 2,
|
||
);
|
||
let mut pass = AdjustPass::new(&ctx);
|
||
let edited = row_rgb(
|
||
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE),
|
||
SIZE,
|
||
SIZE / 2,
|
||
);
|
||
|
||
// Compare in linear light, where a scale is a scale. The two channel
|
||
// ratios together fix the chromaticity, so holding both fixes the colour.
|
||
let edge = (SIZE / 2) as usize;
|
||
for x in [edge, edge + 1, edge + 4, edge - 1, edge - 4] {
|
||
let ratio = |p: [u8; 3], i: usize| srgb_decode(p[i]) / srgb_decode(p[0]).max(1e-6);
|
||
for channel in [1, 2] {
|
||
let before = ratio(plain[x], channel);
|
||
let after = ratio(edited[x], channel);
|
||
assert!(
|
||
(after - before).abs() < 0.02,
|
||
"column {x} channel {channel}: chromaticity moved from \
|
||
{before:.4} to {after:.4} — that is a coloured fringe"
|
||
);
|
||
}
|
||
}
|
||
// And the effect was actually applied here, or the assertion above is
|
||
// vacuous.
|
||
assert!(edited[edge][0].abs_diff(plain[edge][0]) > 3);
|
||
}
|
||
|
||
#[test]
|
||
fn negative_clarity_softens_the_surface_without_dissolving_the_edge() {
|
||
// The soft limit earns its keep in both directions. An unlimited mask at
|
||
// −100 subtracts the whole detail signal and turns every edge to mud;
|
||
// limited, it removes at most the threshold, so modelling softens and real
|
||
// edges stand.
|
||
let Some(ctx) = ctx() else { return };
|
||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||
|
||
let mut plain_pass = AdjustPass::new(&ctx);
|
||
let plain = row(
|
||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||
SIZE,
|
||
SIZE / 2,
|
||
);
|
||
let mut pass = AdjustPass::new(&ctx);
|
||
let softened = row(
|
||
&render(&mut pass, &graph_with(CLARITY, -100.0), &source, SIZE),
|
||
SIZE,
|
||
SIZE / 2,
|
||
);
|
||
|
||
let edge = (SIZE / 2) as usize;
|
||
// The sign is the other way round from the positive case: the bright side
|
||
// of the edge comes down and the dark side comes up.
|
||
assert!(
|
||
softened[edge] + 3 < plain[edge],
|
||
"negative clarity did not soften: {} vs {}",
|
||
softened[edge],
|
||
plain[edge]
|
||
);
|
||
|
||
// But the step itself survives. Measured in stops across the edge, so the
|
||
// claim is about contrast and not about code values.
|
||
let step_of = |r: &[u8]| (srgb_decode(r[edge]) / srgb_decode(r[edge - 1])).log2();
|
||
let before = step_of(&plain);
|
||
let after = step_of(&softened);
|
||
assert!(
|
||
after > before * 0.55,
|
||
"the edge dissolved: {after:.3} stops left of {before:.3}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn neutral_controls_cost_the_edit_nothing() {
|
||
// Both nodes are in the default chain, and both are the widest kernels in
|
||
// the pipeline. An unedited photograph must render through the single
|
||
// fused dispatch it always did — no detail pass, no intermediate texture,
|
||
// and byte-identical pixels.
|
||
let Some(ctx) = ctx() else { return };
|
||
let source = step_edge(&ctx, 128, [1.0, 1.0, 1.0]);
|
||
let graph = EditGraph::default_chain();
|
||
|
||
assert_eq!(
|
||
graph.compose_for(ColourSpace::Srgb).output_mode,
|
||
OutputMode::Encoded,
|
||
"a neutral detail operation must not change how the fused pass ends"
|
||
);
|
||
|
||
let mut pass = AdjustPass::new(&ctx);
|
||
render(&mut pass, &graph, &source, 128);
|
||
assert_eq!(pass.colour_dispatches(), 1);
|
||
assert_eq!(pass.detail_dispatches(), 0);
|
||
assert_eq!(pass.detail_allocations(), 0, "nothing was allocated");
|
||
}
|
||
|
||
#[test]
|
||
fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() {
|
||
// TRACES: FR-DEV-3d. Clarity is `Affects::Detail`, so the fused colour
|
||
// pass's result is still valid while the slider moves — which for a
|
||
// hundred-tap kernel is the difference between an interactive control and
|
||
// a slideshow. Invisible in the output by construction, so a dispatch
|
||
// counter is the only thing that can see it.
|
||
let Some(ctx) = ctx() else { return };
|
||
let source = step_edge(&ctx, 256, [1.0, 1.0, 1.0]);
|
||
let mut pass = AdjustPass::new(&ctx);
|
||
|
||
let mut graph = graph_with(CLARITY, 40.0);
|
||
render(&mut pass, &graph, &source, 256);
|
||
assert_eq!(pass.colour_dispatches(), 1);
|
||
assert_eq!(
|
||
pass.detail_dispatches(),
|
||
2,
|
||
"a separable mask is two passes"
|
||
);
|
||
let pipelines = pass.cached_detail_pipelines();
|
||
|
||
for amount in [50.0, 60.0, 70.0] {
|
||
graph.set_param(CLARITY, AMOUNT, amount);
|
||
render(&mut pass, &graph, &source, 256);
|
||
}
|
||
assert_eq!(
|
||
pass.colour_dispatches(),
|
||
1,
|
||
"the fused colour pass re-ran for a change it does not depend on"
|
||
);
|
||
assert_eq!(pass.detail_dispatches(), 8);
|
||
assert_eq!(
|
||
pass.cached_detail_pipelines(),
|
||
pipelines,
|
||
"an amount is a uniform, not a shader"
|
||
);
|
||
|
||
// Turning on the other control adds its own pair, and only its own pair.
|
||
graph.set_param(TEXTURE, AMOUNT, 40.0);
|
||
render(&mut pass, &graph, &source, 256);
|
||
assert_eq!(pass.detail_dispatches(), 12);
|
||
assert_eq!(pass.colour_dispatches(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn the_two_controls_stack_without_overwriting_each_other() {
|
||
// Four passes through one ping-pong, with the scratch lane changing hands
|
||
// half way. If clarity's combining pass left the colour where its blur
|
||
// pass had put it — or if texture's blur overwrote the colour rather than
|
||
// the lane — the result would be a blurred image rather than a sharpened
|
||
// one, which is loud rather than subtle.
|
||
let Some(ctx) = ctx() else { return };
|
||
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
|
||
|
||
let mut plain_pass = AdjustPass::new(&ctx);
|
||
let plain = row(
|
||
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
|
||
SIZE,
|
||
SIZE / 2,
|
||
);
|
||
|
||
let mut graph = graph_with(CLARITY, 80.0);
|
||
graph.set_param(TEXTURE, AMOUNT, 80.0);
|
||
let mut pass = AdjustPass::new(&ctx);
|
||
let both = row(&render(&mut pass, &graph, &source, SIZE), SIZE, SIZE / 2);
|
||
assert_eq!(pass.detail_dispatches(), 4);
|
||
|
||
let edge = (SIZE / 2) as usize;
|
||
// Both sides of the edge move the way local contrast moves them...
|
||
assert!(both[edge] > plain[edge] + 4);
|
||
assert!(both[edge - 1] + 4 < plain[edge - 1]);
|
||
// ...and the plateaus are untouched, which a stray blur would not leave.
|
||
assert!(both[0].abs_diff(plain[0]) <= 1);
|
||
assert!(both[SIZE as usize - 1].abs_diff(plain[SIZE as usize - 1]) <= 1);
|
||
|
||
// Stacked, they must reach further than either alone — the coarse control
|
||
// still working at its own scale rather than being overwritten by the fine
|
||
// one running after it.
|
||
let mut clarity_only = AdjustPass::new(&ctx);
|
||
let coarse = row(
|
||
&render(&mut clarity_only, &graph_with(CLARITY, 80.0), &source, SIZE),
|
||
SIZE,
|
||
SIZE / 2,
|
||
);
|
||
assert!(
|
||
both[edge] >= coarse[edge],
|
||
"adding texture undid clarity: {} against {}",
|
||
both[edge],
|
||
coarse[edge]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn texture_contributes_nothing_where_its_scale_does_not_exist() {
|
||
// Unlike the acutance family this is not an approximation being hidden. A
|
||
// two-pixel surface structure is not present in a 128-pixel rendering of
|
||
// the frame, so the honest answer is no pass at all — and clarity, a
|
||
// hundred times wider, still runs, which is what a thumbnail should show.
|
||
let Some(ctx) = ctx() else { return };
|
||
let source = step_edge(&ctx, 512, [1.0, 1.0, 1.0]);
|
||
|
||
let mut graph = graph_with(TEXTURE, 100.0);
|
||
|
||
// Asserted on the composed chain rather than on a dispatch counter,
|
||
// because what is interesting here is not how many dispatches ran but
|
||
// that texture contributed no *kernel* to them.
|
||
//
|
||
// This assertion used to require an empty chain, and recorded the empty
|
||
// chain as a gap in the seam: `compose_full` decides whether the fused
|
||
// pass hands on linear working values from `is_active()`, which has no
|
||
// `RenderScale` to consult, while `compose_detail` decides what to
|
||
// dispatch from the kernel it can actually draw at this scale. When a
|
||
// detail operation was active and its kernel rounded away, the two
|
||
// disagreed, `render_detailed` found nothing to run, fell through to
|
||
// `render_masked`, and was rejected for handing a linear-working shader
|
||
// to the plain path — so texture alone on a thumbnail did not render.
|
||
//
|
||
// The seam was closed where that note said it would have to be, at the
|
||
// composition boundary: `compose_detail` now emits a bodyless
|
||
// `detail/resolve` pass in exactly this case, which reads only the pixel
|
||
// it writes and performs the output transform the fused pass declined to
|
||
// do. So the chain is no longer empty — it carries precisely the one pass
|
||
// that finishes the render and no kernel at all, which is the honest
|
||
// description of "a two-pixel surface structure is not present in a
|
||
// 128-pixel rendering".
|
||
let scale = graph.render_scale(source.size(), (128, 128));
|
||
let composed = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||
assert_eq!(
|
||
composed.len(),
|
||
1,
|
||
"the chain must carry the resolve pass and nothing else"
|
||
);
|
||
assert_eq!(composed.passes[0].label, "detail/resolve");
|
||
assert_eq!(
|
||
composed.radius(),
|
||
0,
|
||
"texture claimed a kernel it cannot draw"
|
||
);
|
||
|
||
// With clarity on as well the edit is renderable again, and the dispatch
|
||
// count says what the assertion above says: two passes, not four. Texture
|
||
// is active, and contributes nothing.
|
||
graph.set_param(CLARITY, AMOUNT, 100.0);
|
||
let mut pass = AdjustPass::new(&ctx);
|
||
render(&mut pass, &graph, &source, 128);
|
||
assert_eq!(
|
||
pass.detail_dispatches(),
|
||
2,
|
||
"clarity survives a thumbnail, and texture added nothing beside it"
|
||
);
|
||
|
||
// And texture comes back, exactly, as soon as the view is large enough to
|
||
// hold it — no separate path, no fade, just the kernel resolving again.
|
||
let mut zoomed = AdjustPass::new(&ctx);
|
||
render(&mut zoomed, &graph_with(TEXTURE, 100.0), &source, 1024);
|
||
assert_eq!(zoomed.detail_dispatches(), 2);
|
||
}
|