The camera-space tap: OutputMode::CameraLinear, composed with no operations

compose_camera_linear composes the fused pass with an empty operation
list, the file's orientation as the baseline, a view rect for the tile,
and a store of rgba32float. On the GPU, render_camera_linear is the only
entry that accepts it: it fills the profile uniforms neutral — unit white
balance, identity matrix, curve off — so what lands in the texture is the
sensor's numbers after the lens warp and nothing else (FR-MRG-2). A third
bind-group layout carries the format, as the linear one does, and the
readback is generalised to any pixel width for the f32 copy.

Thirty-two bits because the composite is written back at the sensor's
scale: a 14-bit sensor has 16 384 steps to white and f16 keeps 2 048 of
them in the top octave.
This commit is contained in:
2026-09-19 15:24:12 +02:00
parent 54290b9540
commit 9b6b4942cf
4 changed files with 389 additions and 76 deletions
+8
View File
@@ -849,6 +849,14 @@ impl EditGraph {
)
}
/// TRACES: FR-MRG-2
/// The camera-space tap for a merge: this edit's lens corrections and
/// nothing else of it, stored at full precision. See
/// [`crate::operation::compose_camera_linear`].
pub fn compose_camera_linear(&self, view: crate::framing::CropRect) -> ComposedShader {
crate::operation::compose_camera_linear(&self.warps, self.framing.baseline(), view)
}
/// TRACES: FR-DEV-19c
/// [`Self::compose_for`], with one layer's mask drawn over the picture.
///
+81 -2
View File
@@ -424,6 +424,21 @@ pub enum OutputMode {
/// clamping before a sharpener sees it would draw a hard edge at precisely
/// the luminance a sharpener is most visible at.
LinearWorking,
/// TRACES: FR-MRG-2
/// `rgba32float`, **camera space**: after the lens warp and nothing else.
///
/// What a merge stitches (FR-MRG-2). The shader is the linear tail with
/// no operations, and the caller fills the reserved uniforms neutral —
/// unit white balance, identity matrix, base curve off — so what is
/// stored is the sensor's own numbers, demosaiced and undistorted. Only
/// [`compose_camera_linear`] produces it, and only
/// `AdjustPass::render_camera_linear` accepts it, so the neutral
/// uniforms cannot be forgotten by a caller that composed it by mistake.
///
/// Thirty-two bits rather than sixteen because the composite is written
/// back as a RAW at the sensor's scale (FR-MRG-3): a 14-bit sensor has
/// 16 384 steps to white and `f16` keeps 2 048 of them in the top octave.
CameraLinear,
}
/// The result of composing a set of operations into one shader.
@@ -573,6 +588,54 @@ pub fn compose_full_revealing(
spots: &crate::spot::SpotSet,
warps: &[Box<dyn crate::lens::Warp>],
reveal: Option<&crate::mask::Reveal>,
) -> ComposedShader {
compose_inner(ops, framing, output, masks, spots, warps, reveal, None)
}
/// TRACES: FR-MRG-2
/// The camera-space tap: the fused pass with no operations, stopping after
/// the lens warp and storing `rgba32float` ([`OutputMode::CameraLinear`]).
///
/// Takes the warps and a view, because that is all the tap uses of an edit:
/// no crop (a merge wants the whole frame, and crops the composite), no
/// masks, no spots, no operations. `view` is the tile — the fraction of the
/// undistorted frame to render, in `Framing::set_view`'s terms — so that a
/// merge pulls source tiles on demand (FR-MRG-11) rather than a frame that
/// may not fit. The camera profile's uniforms are still declared — the
/// prologue is the same — and the GPU side fills them neutral.
pub fn compose_camera_linear(
warps: &[Box<dyn crate::lens::Warp>],
baseline: dr_types::Orientation,
view: crate::framing::CropRect,
) -> ComposedShader {
let mut framing = Framing::default();
// The file's orientation and nothing of the user's: a merge aligned
// its frames upright (`dr_pano::Gray::oriented`), so its tiles must be
// upright too, and `view` is a fraction of the upright frame.
framing.set_baseline(baseline);
framing.set_view(view);
compose_inner(
&[],
&framing,
ColourSpace::Srgb,
&MaskStack::new(),
&crate::spot::SpotSet::new(),
warps,
None,
Some(OutputMode::CameraLinear),
)
}
#[allow(clippy::too_many_arguments)]
fn compose_inner(
ops: &[Box<dyn Operation>],
framing: &Framing,
output: ColourSpace,
masks: &MaskStack,
spots: &crate::spot::SpotSet,
warps: &[Box<dyn crate::lens::Warp>],
reveal: Option<&crate::mask::Reveal>,
forced: Option<OutputMode>,
) -> ComposedShader {
// The lens corrections, composed into one coordinate transform. Beside
// `framing` because they are the other half of the same stage: framing
@@ -603,12 +666,18 @@ pub fn compose_full_revealing(
// all: a photograph with a spot on it and no sharpening still has a detail
// stage, and a fused pass that encoded its own output there would quantise
// twice and be bound to a texture of the wrong format.
let output_mode =
//
// `forced` is the one exception, and it is not a caller flag in the
// sense above: `compose_camera_linear` is the only function that passes
// it, with an empty operation list, and the mode it forces has its own
// storage format and its own render entry on the GPU side.
let output_mode = forced.unwrap_or(
if ops.iter().any(|o| o.is_active() && o.detail().is_some()) || !spots.is_neutral() {
OutputMode::LinearWorking
} else {
OutputMode::Encoded
};
},
);
// Whether an operation has taken over the rendering. Decided from the
// operations for the same reason `output_mode` is: a caller that got it
@@ -827,6 +896,16 @@ pub fn compose_full_revealing(
// would put a hard edge into the very neighbourhood the next pass is
// about to convolve, which is how sharpeners come to draw dark rings
// around specular highlights.
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(c, 1.0));"
.to_string(),
),
OutputMode::CameraLinear => (
"rgba32float",
String::new(),
String::new(),
" // Camera space, for a merge (FR-MRG-2): the sensor's numbers after the
// lens warp, with the profile uniforms filled neutral by the caller so the
// prologue above changed nothing. Not clamped, not encoded, full precision.
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(c, 1.0));"
.to_string(),
),