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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user