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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user