Files
dtourolle c07f81edcb Run the view transform after the detail stage, in a pass of its own
The fused pass stops at "linear working values" when a sharpener, a
blur or a repair follows, and the detail passes convolve what it hands
on. Until now it handed on the rendering: the base curve, and since the
last commit the view transform, ran before the store. So every kernel
worked on display-referred values while its comments promised the
opposite — D19's second finding.

A fused pass composed for a detail stage now stops before the view
transform, and carries a second shader, `ComposedShader::view`, composed
from the same inputs. It runs the same prologue, for the positions a
fragment reads (a film's grain seeds from `source_px`) and the corners
it blacks out, takes its colour from the detail stage's result bound
where the sample cache would be, and runs the view transform, the
output transform and the mask reveal. `render_detailed` dispatches it
after the last detail pass, in the same encoder.

So no detail pass encodes any more. Every pass writes an intermediate,
the last one included, which retires three things that existed only to
make the last pass encode: `writes_output` and the runner's second
layout, the body-less resolve pass for an active kernel with nothing to
draw at this scale, and capture sharpening's pass-through, which now
emits no pass at all. An empty chain is a whole render: the view pass
reads the fused result directly. The detail stage no longer takes an
output space either, so `compose_detail_for` folds into
`compose_detail` and the space is named once, on the fused half.

The cost is one full-render read and write per frame when a detail
stage exists, and a third intermediate for a one-pass chain.
2026-09-27 16:52:54 -04:00

719 lines
29 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::detail::RenderScale;
use dr_pipeline::ops::local_contrast::Clarity;
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(scale.full_size(), scale.render_size());
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 crossing_the_reduction_threshold_does_not_change_the_picture() {
// TRACES: FR-DSP-3 — `docs/dev/technical-debt.md` TD-4, held in pixels.
//
// Clarity's base is computed on a reduced grid, and how reduced depends on
// the viewport: `LocalContrast::reduction` steps 4 -> 2 -> 1 as sigma
// falls, because a quarter of a small sigma is not a Gaussian any more.
// The whole claim of the optimisation is that this is invisible — that a
// base sampled at a quarter is not an approximation of the full-resolution
// one but the same band-limited function, sampled where it is still fully
// determined.
//
// Every other test in this file measures one form against itself. This is
// the only one that measures the forms against *each other*, and it is the
// one that would fail if the reduction were quietly softening the control,
// shifting it half a reduced pixel, or blocking the base into 4x4 squares.
//
// The two sizes are chosen to sit either side of a step-down: sigma is
// 1.2% of the shorter edge, so 512 gives 6.1 px and reduces by four, while
// 288 gives 3.5 px — under `MIN_REDUCED_SIGMA` once quartered — and
// reduces by two. Asserted rather than assumed, because the whole test is
// vacuous if both sides land on the same reduction.
let Some(ctx) = ctx() else { return };
assert_eq!(
Clarity::with_amount(100.0).reduction(RenderScale::full((512, 512))),
4
);
assert_eq!(
Clarity::with_amount(100.0).reduction(RenderScale::full((288, 288))),
2
);
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
// Peak excursion in stops and the fraction of the frame it covers — the
// same two numbers `a_proxy_and_an_export_agree_about_the_effect` uses,
// and for the same reason: both are scale-free, so they are comparable
// between two renders of different sizes.
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);
let touched = moved.iter().filter(|m| **m > peak * 0.1).count();
(peak, touched as f32 / out as f32)
};
let (quartered_peak, quartered_reach) = measure(512);
let (halved_peak, halved_reach) = measure(288);
// The same tolerances the proxy/export test uses. They are not loose: the
// bound this operation guarantees is 0.35 stops, so 0.03 is under a tenth
// of the full excursion.
assert!(
(quartered_peak - halved_peak).abs() < 0.03,
"a quarter-scale base gives {quartered_peak:.3} stops and a half-scale \
one {halved_peak:.3}; the reduction is supposed to be invisible"
);
assert!(
(quartered_reach - halved_reach).abs() < 0.02,
"the effect covers {quartered_reach:.3} of the frame reduced by four \
and {halved_reach:.3} reduced by two; the base has changed width"
);
assert!(
quartered_peak > 0.1,
"{quartered_peak:.3} stops — two flat images would also agree"
);
}
#[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);
// Four at this size: reduce, the two blur halves on the reduced grid, and
// the combine. σ is 1.2% of 256 px, so `LocalContrast::reduction` lands on
// a half here rather than the quarter a desktop viewport gets — the number
// is the viewport's, and what this test is about is that it does not
// change while the slider moves.
assert_eq!(
pass.detail_dispatches(),
4,
"a reduced separable mask is four 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"
);
// Four renders of a four-pass chain. The counter accumulates, so this is
// the claim that every one of those renders ran the detail stage and only
// the detail stage.
assert_eq!(pass.detail_dispatches(), 16);
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:
// 16, plus clarity's four again, plus texture's two.
graph.set_param(TEXTURE, AMOUNT, 40.0);
render(&mut pass, &graph, &source, 256);
assert_eq!(pass.detail_dispatches(), 22);
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);
// Clarity's four plus texture's two. Texture is never reduced — its band
// is a decade finer than clarity's, so a coarser grid could not hold its
// base — and the two operations keeping different pass counts here is that
// asymmetry showing through.
assert_eq!(pass.detail_dispatches(), 6);
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 at the composition boundary, and closed again,
// more simply, by D19: no detail pass encodes any more, the fused pass's
// view pass performs the output transform whatever the chain holds, and
// so the empty chain is a whole render. That 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(scale.full_size(), scale.render_size());
assert!(
composed.is_empty(),
"texture claimed a kernel it cannot draw"
);
let mut pass = AdjustPass::new(&ctx);
render(&mut pass, &graph, &source, 128);
assert_eq!(pass.view_dispatches(), 1, "the view pass still finishes it");
// 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);
}