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.
This commit is contained in:
2026-09-27 16:52:54 -04:00
parent 92afaebd34
commit c07f81edcb
19 changed files with 618 additions and 566 deletions
+18 -58
View File
@@ -347,8 +347,15 @@ impl Operation for CaptureSharpen {
impl DetailStage for CaptureSharpen {
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
// A radius finer than one pixel of this render: the detail it would
// act on is not in this texture — it was lost to the downscale before
// this stage ran (FR-DSP-1). Guessing at it would put sharpening on
// screen that the exported file will not contain, so there is no
// pass, and the interface is free to say `zoom to 1:1`. An empty
// chain is a whole render since D19: the fused pass's view pass
// performs the output transform whatever the chain holds.
if !self.resolves(scale) {
return vec![nothing_to_sharpen()];
return Vec::new();
}
let extent = self.kernel(scale);
@@ -396,41 +403,6 @@ impl DetailStage for CaptureSharpen {
}
}
/// The pass emitted when the radius is finer than a render pixel.
///
/// One dispatch that changes nothing, rather than an empty chain, and the
/// difference is not stylistic. [`crate::operation::compose_full`] decides
/// from the *operations* — before any resolution is known — that an active
/// detail operation means the fused pass hands on unclipped linear values
/// instead of encoding its own output. If this returned no passes at all,
/// that decision would still stand and nothing downstream would ever perform
/// the output transform: `dr-gpu` would be handed a linear-working shader
/// with an empty chain and refuse it.
///
/// So the honest "nothing survives at this scale" still has to carry the
/// encode, and one pass that does only that is exactly the resolve step the
/// stage would otherwise need. It costs a single copy of a proxy-sized
/// texture, which is a rounding error against the dispatches around it.
fn nothing_to_sharpen() -> DetailPass {
DetailPass {
output_scale: 1,
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
// before this stage ran (FR-DSP-1). Guessing at it would put sharpening on
// screen that the exported file will not contain, so this pass passes the
// colour through unchanged and the interface is free to say `zoom to 1:1`.
//
// `c` already holds this pixel; leaving it alone is the whole body."
.to_string(),
}
}
/// One axis of the separable unsharp mask.
///
/// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the
@@ -528,7 +500,6 @@ c = select(c, scaled, centre > 1e-5);"#;
mod tests {
use super::*;
use crate::EditGraph;
use dr_types::ColourSpace;
/// The develop chain with the sharpener turned up.
///
@@ -549,7 +520,7 @@ mod tests {
// The scale is what these tests vary, so it is rebuilt into the two
// sizes it stands for rather than handed over: a render of the full
// frame at `render_size`, from a source of `full_size`.
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb)
graph.compose_detail(scale.full_size(), scale.render_size())
}
#[test]
@@ -591,13 +562,11 @@ mod tests {
assert_eq!(first.label, "capture_sharpen/horizontal");
assert_eq!(last.label, "capture_sharpen/vertical");
assert!(!first.writes_output);
assert!(first.source.contains("texture_storage_2d<rgba16float"));
assert!(!first.source.contains("fn encode_output"));
assert!(last.writes_output);
assert!(last.source.contains("texture_storage_2d<rgba8unorm"));
assert!(last.source.contains("fn encode_output"));
// Neither encodes: the view pass after the detail stage does (D19).
for pass in [first, last] {
assert!(pass.source.contains("texture_storage_2d<rgba16float"));
assert!(!pass.source.contains("fn encode_output"));
}
// Two shaders, so two pipeline-cache entries. Sharing one would run
// the horizontal pass's uniforms through the vertical pass's slots.
@@ -723,20 +692,11 @@ mod tests {
let proxy = RenderScale::new((1000, 1000), (4000, 4000));
assert!(!proxy.resolves(1.0));
// Empty: since D19 nothing in the detail stage encodes, so a chain
// with nothing to draw is a whole render — the fused pass's view pass
// finishes it.
let composed = chain_at(&graph, proxy);
// Not empty, though. See `nothing_to_sharpen`: the fused pass has
// already been composed to hand on linear values, so *something* must
// still perform the output transform.
assert_eq!(composed.len(), 1);
assert_eq!(composed.radius(), 0, "it reads no neighbours");
let pass = &composed.passes[0];
assert_eq!(pass.label, "capture_sharpen/unresolved");
assert!(pass.writes_output);
assert!(pass.source.contains("fn encode_output"));
assert!(
!pass.source.contains("for (var i ="),
"the pass-through must not walk a kernel it has decided not to run"
);
assert!(composed.is_empty());
// Zooming to 1:1 is what brings it back — the view rect shrinks while
// the render target keeps its size — so there is no separate
+1 -7
View File
@@ -541,14 +541,13 @@ c = (c - lifted) / t;";
mod tests {
use super::*;
use crate::detail::compose_detail;
use dr_types::ColourSpace;
fn ops(amount: f32) -> Vec<Box<dyn Operation>> {
vec![Box::new(Dehaze::with_amount(amount))]
}
fn composed(amount: f32, scale: RenderScale) -> crate::ComposedDetail {
compose_detail(&ops(amount), scale, ColourSpace::Srgb)
compose_detail(&ops(amount), scale)
}
#[test]
@@ -658,11 +657,6 @@ mod tests {
let split = Split::of(Dehaze::with_amount(60.0).patch(RenderScale::full((2000, 1500))));
assert!(composed.passes.iter().all(|p| p.radius == split.extent()));
// Only the last writes the display texture, so the output transform
// happens exactly once (FR-DEV-2).
assert!(!composed.passes[0].writes_output);
assert!(composed.passes[1].writes_output);
// Nothing here uses the reduced chain — see the module documentation
// for why a second operation cannot pick its own `output_scale` while
// the runner holds one reduced buffer.
+1 -2
View File
@@ -878,7 +878,6 @@ c = c * exp2(stops);"
mod tests {
use super::*;
use crate::detail::compose_detail;
use dr_types::ColourSpace;
/// The two controls, as the graph would hold them.
fn ops(clarity: f32, texture: f32) -> Vec<Box<dyn Operation>> {
@@ -889,7 +888,7 @@ mod tests {
}
fn composed(clarity: f32, texture: f32, scale: RenderScale) -> crate::ComposedDetail {
compose_detail(&ops(clarity, texture), scale, ColourSpace::Srgb)
compose_detail(&ops(clarity, texture), scale)
}
#[test]
+1 -7
View File
@@ -615,7 +615,6 @@ c = vec3<f32>(y0) + chroma_sum / weight_sum;";
#[cfg(test)]
mod tests {
use super::*;
use dr_types::ColourSpace;
/// A 24 MP frame, and the panel a develop view might show it in.
const FULL: (u32, u32) = (6000, 4000);
@@ -629,7 +628,7 @@ mod tests {
}
fn compose(op: NoiseReduction, scale: RenderScale) -> crate::detail::ComposedDetail {
crate::detail::compose_detail(&chain_with(op), scale, ColourSpace::Srgb)
crate::detail::compose_detail(&chain_with(op), scale)
}
#[test]
@@ -674,11 +673,6 @@ mod tests {
// The luminance pass runs first, so the chroma guide is the denoised
// luminance rather than the raw one.
assert_eq!(both.passes[0].label, "noise_reduction/luminance");
// And only the last pass in the whole chain performs the output
// transform, whichever pass that happens to be.
assert!(!both.passes[0].writes_output);
assert!(!both.passes[1].writes_output);
assert!(both.passes[2].writes_output);
}
#[test]