WIP: clarity and texture

Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
This commit is contained in:
2026-08-22 19:01:18 +02:00
parent c963dafd09
commit 97d4bd9061
6 changed files with 1542 additions and 1 deletions
+84 -1
View File
@@ -315,6 +315,37 @@ pub struct DetailPass {
/// them in the shader. Prefer computing lengths on the CPU in
/// [`DetailStage::passes`], where the units are named methods rather
/// than an untyped float.
/// - `aux: f32` and `tap_aux(coord, offset) -> f32` — **one scalar per
/// pixel that survives to the next pass**, pre-loaded with what the
/// previous pass left there and written back out unless the body
/// assigns it.
///
/// # Why `aux` exists
///
/// The ping-pong hands each pass exactly one texture: what the pass before
/// it wrote. That is enough for a chain of filters — a separable blur is
/// two of them — and it is *not* enough for an unsharp mask, which is the
/// shape of sharpening, clarity, texture and dehaze alike. An unsharp mask
/// needs the blur **and** the original in the same place at the same time,
/// and once the first pass has written its blur the original is gone.
///
/// Three channels cannot carry both. Even restricted to the case where the
/// operation only moves luminance — so the colour is a luminance and two
/// chromaticity degrees of freedom — the combining pass needs four
/// numbers: the original luminance, two of chromaticity, and the blurred
/// luminance. Four does not fit in three, and no encoding makes it fit.
///
/// The intermediate is `rgba16float` and its alpha was being written as a
/// constant `1.0` and read by nobody, so the fourth number goes there. A
/// blur pass leaves `c` alone and puts its result in `aux`; the pass after
/// it therefore receives the untouched original *and* the blur, and can
/// subtract one from the other. An operation with no use for the lane says
/// nothing and hands on what it was given.
///
/// The last pass in the chain writes the display texture, whose alpha is
/// opacity rather than scratch space, so `aux` is readable there and not
/// written. That is exactly the right way round: the combining pass is the
/// one that reads it.
///
/// Uniforms are addressed by the bare names declared in [`Self::uniforms`],
/// exactly as a fused fragment addresses its own; the composer rewrites
@@ -536,7 +567,11 @@ fn compose_one(
"rgba16float",
" // Another linear intermediate: no clip and no encode, because\n\
\x20 // the pass after this one still has to read real values.\n\
\x20 textureStore(output, coord, vec4<f32>(c, 1.0));"
\x20 //\n\
\x20 // `aux` rides in alpha. A pass that never touches it hands on\n\
\x20 // whatever it was given, so the lane costs an operation that\n\
\x20 // does not want it exactly one copy of a value it already read.\n\
\x20 textureStore(output, coord, vec4<f32>(c, aux));"
.to_string(),
)
};
@@ -582,6 +617,12 @@ fn tap(coord: vec2<i32>, offset: vec2<i32>) -> vec3<f32> {{
return textureLoad(source, clamp(coord + offset, vec2<i32>(0), last), 0).rgb;
}}
// The same neighbour's scratch lane — see `aux` in the body below.
fn tap_aux(coord: vec2<i32>, offset: vec2<i32>) -> f32 {{
let last = vec2<i32>(textureDimensions(source)) - vec2<i32>(1);
return textureLoad(source, clamp(coord + offset, vec2<i32>(0), last), 0).a;
}}
{helper_src}{encode_fn}
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
@@ -596,6 +637,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
let render_scale = u.detail_base.z;
var c = tap(coord, vec2<i32>(0));
// One scalar per pixel that survives the hand-off from one pass to the
// next, alongside the colour. See `DetailPass::wgsl` for what it is for
// and why three channels were not enough.
var aux = tap_aux(coord, vec2<i32>(0));
{{
{indented}
@@ -880,6 +925,44 @@ mod tests {
}
}
#[test]
fn a_pass_can_hand_a_scalar_to_the_next_one_alongside_the_colour() {
// What makes an unsharp mask — sharpening, clarity, texture, dehaze —
// expressible at all in a chain that hands each pass exactly one
// texture. The blur goes in `aux` and the colour rides through
// untouched, so the pass that combines them receives both; without the
// lane, four numbers would have to fit in three channels and the
// operation could only ever be a blur.
//
// A pass that says nothing about `aux` hands on what it was given,
// which is why the box blur below needs no knowledge of it.
let ops = with_blur(0.05);
let composed = compose_detail(
&ops,
RenderScale::full((512, 512)),
dr_types::ColourSpace::Srgb,
);
for pass in &composed.passes {
assert!(
pass.source.contains("fn tap_aux(") && pass.source.contains("var aux = tap_aux("),
"{} cannot read the scratch lane",
pass.label
);
}
assert!(
composed.passes[0]
.source
.contains("textureStore(output, coord, vec4<f32>(c, aux));"),
"an intermediate must carry the lane to the pass after it"
);
// The last pass writes the display texture, whose alpha is opacity and
// not scratch space. Readable there, not written — which is the right
// way round, because the combining pass is the one that reads it.
assert!(composed.passes[1].writes_output);
assert!(!composed.passes[1].source.contains("vec4<f32>(c, aux)"));
}
#[test]
fn an_edit_with_no_detail_operation_composes_no_passes() {
// The property that keeps the cost of this stage at zero for the