Let a detail pass carry a list, not only a kernel

Every neighbourhood pass so far has been a convolution, whose whole
description fits in the uniform block because its structure fixes how many
numbers it needs. Spot removal is not that shape: sixty-four repairs and
one repair are the same shader with a different buffer behind it.

So a pass may declare `storage`, which arrives at binding 3 as
`array<vec4<f32>>` with `arrayLength` in scope. The alternative — packing
the list into uniforms — needs a fixed maximum paid for on every frame, a
composer that can emit vec4 fields because a uniform array's stride is 16
whatever it holds, and it gives the next operation that wants a table
nothing to build on.

The property worth having is what stays out of the generated source: the
count is in the buffer, so placing the tenth spot uploads 512 bytes and
reuses the compiled pipeline, exactly as moving a slider does for the
fused pass. `changing_the_list_does_not_recompile` is that, asserted.

One bind group entry rather than two more layouts, and one placeholder
buffer allocated in `new` rather than sixteen bytes per pass per frame —
a zero-length storage buffer cannot be bound, and per-frame allocation is
what this module's documentation exists to refuse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 20:00:24 +02:00
co-authored by Claude Opus 5
parent 44444f7768
commit 6997c0f7ac
8 changed files with 287 additions and 5 deletions
@@ -358,6 +358,8 @@ impl DetailStage for CaptureSharpen {
.map(|label| DetailPass {
label,
radius: extent,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: vec![
Uniform {
// −100…100 as a gain around zero. A hundred percent is
@@ -410,6 +412,8 @@ fn nothing_to_sharpen() -> DetailPass {
label: "unresolved",
// Reads only the pixel it writes, so a tile needs no halo at all.
radius: 0,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: Vec::new(),
wgsl: "// The chosen radius is finer than one pixel of this render, so the detail
// it would act on is not in this texture — it was lost to the downscale
@@ -475,12 +475,16 @@ impl<B: Band> DetailStage for LocalContrast<B> {
DetailPass {
label: "base",
radius,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: shape,
wgsl: BASE_X.to_string(),
},
DetailPass {
label: "combine",
radius,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: combine,
wgsl: combine_body(B::RECIPE.midtone_taper),
},
@@ -436,6 +436,8 @@ impl DetailStage for NoiseReduction {
passes.push(DetailPass {
label: "luminance",
radius: luma,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: vec![
Uniform {
name: "radius",
@@ -474,6 +476,8 @@ impl DetailStage for NoiseReduction {
"chroma-vertical"
},
radius: chroma,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: vec![
Uniform {
name: "radius",