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
+7 -16
View File
@@ -139,17 +139,15 @@ impl DevelopSession {
/// almost every edit, so this costs nothing to the frames that do not
/// need it.
///
/// `space` has to be the space `shader` was composed for. It is the last
/// pass of the detail chain that performs the output transform when there
/// is one, so the two would otherwise be free to disagree about which
/// primaries the file is in — and the result would be a correctly
/// labelled file with the wrong colours in it (FR-EXP-2).
/// The output space is `shader`'s alone: since D19 no detail pass encodes,
/// and the view pass `shader` carries performs the output transform, so
/// the two halves cannot disagree about which primaries the file is in
/// (FR-EXP-2).
pub(super) fn render_with_masks(
&mut self,
shader: &dr_pipeline::operation::ComposedShader,
w: u32,
h: u32,
space: dr_types::ColourSpace,
) -> Result<(), String> {
let ctx = self.ctx.clone();
self.ensure_subject_fields(&ctx);
@@ -173,9 +171,7 @@ impl DevelopSession {
// Empty for every edit with no active neighbourhood operation — which
// is almost all of them — and `render_detailed` then falls straight
// through to the single masked dispatch this used to call.
let detail = self
.graph
.compose_detail_for(self.demosaiced.size(), (w, h), space);
let detail = self.graph.compose_detail(self.demosaiced.size(), (w, h));
// Detail passes read what the colour pass wrote, so the key they are
// cached against is the colour key: moving a sharpening slider re-runs
// this stage and not the fused one (FR-DEV-3d).
@@ -266,12 +262,7 @@ impl DevelopSession {
// Rasterise the masks first: the shader addresses array slices by
// index, so the array has to describe *this* stack before it is bound.
//
// The same `space` to both, necessarily: where a detail stage exists
// it is the *last* pass that performs the output transform, and two
// halves composed for different spaces would encode the frame twice
// or not at all.
self.render_with_masks(&shader, w, h, space)?;
self.render_with_masks(&shader, w, h)?;
let texture = self.adjust.output().ok_or("nothing was rendered")?;
// The import is fallible on format and usage only, and both are fixed
@@ -658,7 +649,7 @@ impl DevelopSession {
// uniform baked at composition, so composing before this point would
// restore the framing and export the zoom anyway.
let shader = self.graph.compose_for(space);
let rendered = self.render_with_masks(&shader, w, h, space);
let rendered = self.render_with_masks(&shader, w, h);
self.graph.framing_mut().set_view(saved_view);
rendered?;