Make the view transform an operation the photographer can set

FR-DEV-3j gives the view transform two controls, contrast and the white
point in stops above middle grey, persisted and held per mask layer like
any other setting. A scene-referred pipeline whose white point cannot be
moved hands the photographer a shoulder they cannot place.

`view_transform` is a hand-written node in `Stage::View`, a new stage
the composer emits last and emits whatever the node's state: a neutral
operation is otherwise left out of the shader, but a photograph with no
view transform is a scan. "Active" keeps meaning "moved from the
defaults", so an untouched photograph writes nothing for it and
`every_node_starts_neutral` still holds. A caller whose chain holds no
view operation gets a default one.

The composer's loop becomes an ordered list of steps — camera nodes, the
matrix, scene nodes, layer-only nodes, the view — so each is emitted in
exactly one place. The base curve could not be a node because it
belonged to the camera; the ops README records why that argument went
with it (D19). The panel shows it in the Light group as "Tone Mapping".

Tests that counted the blocks of a neutral graph now count one, the
view transform, and the two chain-wide tests that load a film expect the
view transform to be absent, since a stock replaces it.
This commit is contained in:
2026-09-27 16:52:54 -04:00
parent 37a6d99dc4
commit 92afaebd34
11 changed files with 386 additions and 99 deletions
+77 -63
View File
@@ -468,6 +468,16 @@ pub enum Stage {
/// Working-space colour: linear Rec.709 primaries, scene-referred,
/// unbounded. Nothing here clamps above 1.0 or encodes (ARCH §6.14).
Scene,
/// The view transform (FR-DEV-3j): after every scene operation, and the
/// one stage allowed to map the scene to a display range.
///
/// Composed **whatever its state**. A neutral operation is otherwise left
/// out, but a photograph with no view transform is a scan rather than a
/// picture, so a view operation at its defaults still renders — with its
/// defaults. "Active" keeps its meaning of "moved from the defaults",
/// which is what the sidecar and the panel read. When the chain holds no
/// view operation at all the composer supplies a default one.
View,
}
/// A named WGSL helper function, deduplicated across operations.
@@ -849,16 +859,9 @@ fn compose_inner(
uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0);
// TRACES: FR-DEV-3j
// The view transform's function, whenever the composer emits the view
// transform — which is every render but the camera-space tap and one a
// rendering operation has taken over. See `rendering_tail` below.
// Whether the composer emits a view transform — which is every render but
// the camera-space tap and one a rendering operation has taken over.
let views = !op_renders && output_mode != OutputMode::CameraLinear;
if views {
helpers.push(Helper {
name: "view_sigmoid",
source: crate::view::VIEW_SIGMOID_WGSL,
});
}
// Framing's block follows the base one at a fixed offset, for the same
// reason: the prologue is emitted whether or not any operation is active,
@@ -910,22 +913,71 @@ fn compose_inner(
.map(|o| o.as_ref())
.filter(move |o| o.detail().is_none() && o.stage() == stage)
};
let mut in_working_space = false;
for op in point(Stage::Camera).chain(point(Stage::Scene)) {
if !in_working_space && op.stage() == Stage::Scene {
body.push_str(CAMERA_MATRIX);
in_working_space = true;
}
// The view operation, or a default one when the caller's chain holds
// none — `compose(&[])` in a test, or a probe. Absent altogether where
// nothing is to be rendered: see `views`.
let default_view = crate::ops::ViewTransform::new();
let view: Option<&dyn Operation> = views.then(|| {
point(Stage::View)
.next()
.unwrap_or(&default_view as &dyn Operation)
});
// What is emitted, in order: camera RGB, the matrix out of it, the scene,
// any layer-only operations, and the view transform last (D19).
enum Step<'a> {
Op(&'a dyn Operation),
Matrix,
Orphans,
}
let steps = point(Stage::Camera)
.map(Step::Op)
.chain(std::iter::once(Step::Matrix))
.chain(point(Stage::Scene).map(Step::Op))
.chain(std::iter::once(Step::Orphans))
.chain(view.map(Step::Op));
for step in steps {
let op = match step {
Step::Op(op) => op,
Step::Matrix => {
body.push_str(CAMERA_MATRIX);
continue;
}
Step::Orphans => {
// A layer's operation the global chain does not hold at
// all. Not a case any editor produces — both chains come from
// `ops::chain` — but a layer must not lose an edit because a
// caller composed a shorter chain.
let mut orphans: Vec<&'static str> = Vec::new();
for l in &layers.ops {
if !orphans.contains(&l.op) && !ops.iter().any(|o| o.descriptor().id.0 == l.op)
{
orphans.push(l.op);
}
}
for id in orphans {
let local: Vec<&crate::mask::LocalOp> =
layers.ops.iter().filter(|l| l.op == id).collect();
let _ = writeln!(body, "\n // ---- {id} (local only) ----");
body.push_str(&local_block("", &local));
}
continue;
}
};
let id = op.descriptor().id.0;
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
if !op.is_active() && local.is_empty() {
// The global side of the blend. A view operation always has one; see
// `Stage::View`.
let global = op.is_active() || op.stage() == Stage::View;
if !global && local.is_empty() {
continue;
}
let prefix = sanitise(id);
let mut fragment = String::new();
let mut op_uniforms = Vec::new();
if op.is_active() {
if global {
// Each op's uniforms are prefixed, so two operations may both
// declare a field called `amount` without colliding.
op_uniforms = op.uniforms();
@@ -964,27 +1016,6 @@ fn compose_inner(
body.push_str(&local_block(&fragment, &local));
}
// Emitted here when no scene operation was listed at all, which is what
// makes an empty chain still convert out of camera space.
if !in_working_space {
body.push_str(CAMERA_MATRIX);
}
// A layer's operation the global chain does not hold at all. Not a case
// any editor produces — both chains come from `ops::chain` — but a layer
// must not lose an edit because a caller composed a shorter chain.
let mut orphans: Vec<&'static str> = Vec::new();
for l in &layers.ops {
if !orphans.contains(&l.op) && !ops.iter().any(|o| o.descriptor().id.0 == l.op) {
orphans.push(l.op);
}
}
for id in orphans {
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
let _ = writeln!(body, "\n // ---- {id} (local only) ----");
body.push_str(&local_block("", &local));
}
// TRACES: FR-DEV-19c
// Held apart from the body, because it belongs after the output transform
// rather than among the operations — see `mask::LayerShader::reveal`.
@@ -1114,32 +1145,13 @@ fn compose_inner(
),
};
// The view transform, which an operation may have taken over.
let rendering_tail = if !views {
// Where the view transform is not, say why, for whoever reads the
// generated source. Where it is, it is the last operation block above.
let rendering_tail = if views {
String::new()
} else {
" // No view transform: an operation declaring `Operation::renders` has\n // mapped the scene to a display range itself, or this is the\n // camera-space tap, which stores the sensor's own numbers.\n"
.to_string()
} else {
let curve = crate::view::Sigmoid::default_curve();
format!(
" // ==== the view transform (FR-DEV-3j) ====
//
// Marked with `====` and not the `----` an operation block carries: this
// is not one, and the difference is what several tests count on to tell
// an edit apart from the rendering of one.
//
// The one stage allowed to map scene-linear colour to a display range
// (D19, ARCH §6.14), after every operation. Everything above it is
// unbounded; everything the shoulder has not brought under 1.0 is
// clipped by the output transform, at the last moment.
//
// Skipped for an already-rendered source — a JPEG is a display rendering
// already, and rendering it again would compress it twice.
if (!non_linear) {{
c = view_sigmoid(c, {:?}, {:?}, {:?});
}}
",
curve.n, curve.inv_k, curve.w
)
};
// Formatted with Rust's `Display` so the shader reads the same threshold
@@ -1833,6 +1845,8 @@ mod tests {
fn an_inactive_operation_contributes_nothing() {
// The point of composing rather than branching: an op at neutral
// must not appear in the source at all.
// Measured against an empty chain rather than the preamble alone,
// since every render carries the view transform's uniforms too.
let ops = vec![fake(DESC_A.clone(), 0.0, false)];
let shader = compose(&ops);
assert!(
@@ -1841,7 +1855,7 @@ mod tests {
);
assert_eq!(
shader.uniforms.len(),
PREAMBLE_FIELDS,
compose(&[]).uniforms.len(),
"it must contribute no uniforms either"
);
}