Develop the film last, in the view transform's place

A film stock ran at order 25, after white balance and exposure, and
everything after it — contrast, the curves, the colour mixer, the
grading, every mask layer's tone — acted on the film's display-referred
output, as though the frame had been scanned and then worked on. That
was a display-referred rendering in the middle of the chain, which D19
removes (FR-DEV-3f, FR-DEV-3j).

`film_sim` is now in `Stage::View` beside `view_transform`. While a
stock is loaded the composer emits it in the view transform's place and
not the sigmoid; otherwise the sigmoid. So every edit is a decision
about the exposure the negative receives, and the film is the last
thing that happens to the picture — after the detail stage too, in the
view pass, which already binds the film's tables, the mask array and
the grain's source position. Its per-layer settings blend there as they
did in the fused pass.

`op_renders` goes: a rendering is chosen, not suppressed, and the only
render with no view transform is the camera-space tap. The YAML order
moves to 190 so the panel reads in pipeline order; the stage, not the
number, is what places it.

Existing edits that combine a stock with tone or colour operations now
render differently: those operations used to act on the print, and now
act on the scene.
This commit is contained in:
2026-09-27 16:52:55 -04:00
parent c07f81edcb
commit 657f8f19ff
7 changed files with 123 additions and 74 deletions
+16 -2
View File
@@ -2469,7 +2469,14 @@ mod tests {
assert!(!shader.source.contains("---- view_transform ----")); assert!(!shader.source.contains("---- view_transform ----"));
continue; continue;
} }
let point = shader.source.contains(&format!("---- {id} ----")); // A view operation is in the view pass when a detail stage
// follows, which it does here (D19).
let block = format!("---- {id} ----");
let point = shader.source.contains(&block)
|| shader
.view
.as_ref()
.is_some_and(|v| v.source.contains(&block));
let neighbourhood = detail let neighbourhood = detail
.passes .passes
.iter() .iter()
@@ -2503,8 +2510,15 @@ mod tests {
// because the two catch different faults: the XOR catches an operation // because the two catch different faults: the XOR catches an operation
// in the wrong stage, this catches a block in the shader that nothing // in the wrong stage, this catches a block in the shader that nothing
// in the chain asked for. // in the chain asked for.
//
// The view pass repeats the prologue — framing and the warps — for
// the positions it publishes, so only its operation blocks count.
let view_blocks = shader
.view
.as_ref()
.map_or(0, |v| v.source.matches("---- ").count() - (warp_blocks + 1));
assert_eq!( assert_eq!(
shader.source.matches("---- ").count(), shader.source.matches("---- ").count() + view_blocks,
fused_blocks + warp_blocks + 1, fused_blocks + warp_blocks + 1,
"the fused shader carries a block nothing in the chain asked for" "the fused shader carries a block nothing in the chain asked for"
); );
+9 -10
View File
@@ -240,17 +240,16 @@ coefficients that are not parameters.
`Operation::renders`, and it is worth knowing why before writing a second one. `Operation::renders`, and it is worth knowing why before writing a second one.
Every other node *adjusts* a picture. That one *makes* it: a film stock's Every other node *adjusts* a picture. That one *makes* it: a film stock's
characteristic curve does the camera profile's base curve's job, from characteristic curve does the view transform's job, from measurements rather
measurements rather than from a curve somebody drew. Running both renders the than from a curve somebody chose. Running both renders the scene twice — the
scene twice — the camera's rendering, and then a film's rendering of *that* — default rendering, and then a film's rendering of *that* — which looks like
which looks like neither and reads as a colour-management bug with no neither and reads as a colour-management bug with no colour-management bug to
colour-management bug to find. find.
So a node declaring `renders` hands back display-referred linear sRGB, and in So `film_sim` is in `Stage::View` beside `view_transform`, and while a stock
exchange the composer does not emit the base curve. It is handed working-space is loaded the composer emits it in the view transform's place, last, after the
colour like every other node: the conversion out of camera space is no longer detail stage, and not the sigmoid (D19). It is handed working-space colour and
the rendering's to take over, because since D19 it runs before every node but hands back display-referred linear sRGB for the output transform. `distortion` and
white balance (see "Stages" below). `distortion` and
`aberration` are `Warp`s rather than operations: they rewrite coordinates `aberration` are `Warp`s rather than operations: they rewrite coordinates
before sampling rather than transforming a colour after it. before sampling rather than transforming a colour after it.
+10 -8
View File
@@ -1,5 +1,5 @@
id: film_sim id: film_sim
order: 25 order: 190
# What this node is *about* is not written here, and cannot be: a `rust:` node # What this node is *about* is not written here, and cannot be: a `rust:` node
# publishes its own descriptor, so `attributes:` in this file would be read, # publishes its own descriptor, so `attributes:` in this file would be read,
# validated and then ignored. See `Attribute::Effect` on `FilmSim`'s descriptor # validated and then ignored. See `Attribute::Effect` on `FilmSim`'s descriptor
@@ -15,11 +15,13 @@ why_rust: |
and the conversion out of camera space on its behalf. and the conversion out of camera space on its behalf.
placement: | placement: |
After white balance and exposure, and before everything else. Last, in the view transform's place (D19, FR-DEV-3j), after every other
operation and after the detail stage.
Those two are what the camera did — interpreting the sensor, and correcting Before D19 it sat at order 25, after white balance and exposure, and every
the amount of light that reached it — and they are only meaningful on decision below it acted on the film's output, as though the frame had been
scene-linear values, which is what a film has to be handed. Everything below scanned and then worked on. That put a display-referred rendering in the
is a decision about the picture, and a decision about the picture belongs middle of the chain, which is what D19 removes: every operation is now handed
after the film has rendered it, exactly as it does when you scan a frame and the scene, and the film is the last thing that happens to the picture — an
then work on the scan. edit is a decision about the exposure the negative receives. `Stage::View`
is what puts it there; this number only places it in the panel's order.
+13 -2
View File
@@ -176,7 +176,14 @@ mod tests {
assert!(!shader.source.contains("---- view_transform ----")); assert!(!shader.source.contains("---- view_transform ----"));
continue; continue;
} }
let point = shader.source.contains(&format!("---- {id} ----")); // A view operation is in the view pass when a detail stage
// follows, which it does here (D19).
let block = format!("---- {id} ----");
let point = shader.source.contains(&block)
|| shader
.view
.as_ref()
.is_some_and(|v| v.source.contains(&block));
let neighbourhood = detail let neighbourhood = detail
.passes .passes
.iter() .iter()
@@ -189,8 +196,12 @@ mod tests {
); );
fused_blocks += usize::from(point); fused_blocks += usize::from(point);
} }
let view_blocks = shader
.view
.as_ref()
.map_or(0, |v| v.source.matches("---- ").count());
assert_eq!( assert_eq!(
shader.source.matches("---- ").count(), shader.source.matches("---- ").count() + view_blocks,
fused_blocks, fused_blocks,
"the fused shader carries a block nothing in the chain asked for" "the fused shader carries a block nothing in the chain asked for"
); );
+35 -21
View File
@@ -337,18 +337,17 @@ pub trait Operation: Send + Sync {
/// Whether this operation *is* the rendering, rather than an adjustment to /// Whether this operation *is* the rendering, rather than an adjustment to
/// one. /// one.
/// ///
/// Almost everything returns `false`. An operation that returns `true` /// Almost everything returns `false`. A [`Stage::View`] operation that
/// hands back display-referred linear sRGB, and in exchange the composer /// returns `true` is a complete rendering: while it is active the composer
/// does not emit the camera profile's base curve — because this operation /// emits it in the view transform's place and not the default view
/// has done its job. It is handed working-space colour like any other /// transform beside it (FR-DEV-3j).
/// scene-stage operation (D19).
/// ///
/// The reason it is a trait method and not a flag the caller sets is the /// The reason it is a trait method and not a flag the caller sets is the
/// one [`compose_full`] gives for deciding the output mode the same way: a /// one [`compose_full`] gives for deciding the output mode the same way: a
/// caller that got it wrong would produce a shader that compiles, runs, and /// caller that got it wrong would produce a shader that compiles, runs, and
/// renders the picture twice. `film_sim` is the operation this exists for — /// renders the picture twice. `film_sim` is the operation this exists for —
/// a stock's characteristic curve does the base curve's job, from /// a stock's characteristic curve does the view transform's job, from
/// measurements, and running both is the camera's rendering of the scene /// measurements, and running both is the default rendering of the scene
/// followed by a film's rendering of *that*. /// followed by a film's rendering of *that*.
fn renders(&self) -> bool { fn renders(&self) -> bool {
false false
@@ -469,7 +468,10 @@ pub enum Stage {
/// unbounded. Nothing here clamps above 1.0 or encodes (ARCH §6.14). /// unbounded. Nothing here clamps above 1.0 or encodes (ARCH §6.14).
Scene, Scene,
/// The view transform (FR-DEV-3j): after every scene operation, and the /// The view transform (FR-DEV-3j): after every scene operation, and the
/// one stage allowed to map the scene to a display range. /// one stage allowed to map the scene to a display range. `view_transform`
/// and `film_sim` are its two operations, and one of them runs: the film
/// while a stock is loaded (see [`Operation::renders`]), the sigmoid
/// otherwise.
/// ///
/// Composed **whatever its state**. A neutral operation is otherwise left /// Composed **whatever its state**. A neutral operation is otherwise left
/// out, but a photograph with no view transform is a scan rather than a /// out, but a photograph with no view transform is a scan rather than a
@@ -849,11 +851,6 @@ fn compose_inner(
) )
}; };
// Whether an operation has taken over the rendering. Decided from the
// operations for the same reason `output_mode` is: a caller that got it
// wrong would produce a shader that compiles and renders the picture twice.
let op_renders = ops.iter().any(|o| o.is_active() && o.renders());
let mut uniform_fields = String::new(); let mut uniform_fields = String::new();
let mut uniform_values: Vec<f32> = Vec::new(); let mut uniform_values: Vec<f32> = Vec::new();
let mut body = String::new(); let mut body = String::new();
@@ -882,8 +879,8 @@ fn compose_inner(
// TRACES: FR-DEV-3j // TRACES: FR-DEV-3j
// Whether the composer emits a view transform — which is every render but // Whether the composer emits a view transform — which is every render but
// the camera-space tap and one a rendering operation has taken over. // the camera-space tap, whose job is the sensor's own numbers.
let views = !op_renders && output_mode != OutputMode::CameraLinear; let views = output_mode != OutputMode::CameraLinear;
// And whether *this* shader is where it goes. Not the fused pass when a // And whether *this* shader is where it goes. Not the fused pass when a
// detail stage follows: the view transform maps into a display range, and // detail stage follows: the view transform maps into a display range, and
// a sharpener handed a display range is what D19 set out to stop. The // a sharpener handed a display range is what D19 set out to stop. The
@@ -940,13 +937,18 @@ fn compose_inner(
.map(|o| o.as_ref()) .map(|o| o.as_ref())
.filter(move |o| o.detail().is_none() && o.stage() == stage) .filter(move |o| o.detail().is_none() && o.stage() == stage)
}; };
// The view operation, or a default one when the caller's chain holds // The view operation: an active rendering — a loaded film stock — if
// none — `compose(&[])` in a test, or a probe. Absent altogether where // there is one, otherwise the chain's view transform, or a default one
// nothing is to be rendered: see `views`. // when the caller's chain holds none (`compose(&[])` in a test, or a
// probe). Decided from the operations for the same reason `output_mode`
// is: a caller that got it wrong would produce a shader that compiles and
// renders the picture twice. Absent altogether where nothing is to be
// rendered: see `views`.
let default_view = crate::ops::ViewTransform::new(); let default_view = crate::ops::ViewTransform::new();
let view: Option<&dyn Operation> = views_here.then(|| { let view: Option<&dyn Operation> = views_here.then(|| {
point(Stage::View) point(Stage::View)
.next() .find(|o| o.is_active() && o.renders())
.or_else(|| point(Stage::View).find(|o| !o.renders()))
.unwrap_or(&default_view as &dyn Operation) .unwrap_or(&default_view as &dyn Operation)
}); });
@@ -1190,7 +1192,7 @@ fn compose_inner(
let rendering_tail = if views { let rendering_tail = if views {
String::new() String::new()
} else { } 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" " // No view transform: this is the camera-space tap, which stores the\n // sensor's own numbers.\n"
.to_string() .to_string()
}; };
@@ -2319,10 +2321,22 @@ mod tests {
})); }));
assert!(film.is_active(), "the fixture did not load"); assert!(film.is_active(), "the fixture did not load");
let source = compose(&[Box::new(film) as Box<dyn Operation>]).source; // Listed first, and emitted last anyway: a stock is the view
// transform (D19), so graph order does not put it among the scene
// operations the way order 25 once did.
let source = compose(&[
Box::new(film) as Box<dyn Operation>,
fake(DESC_A.clone(), 2.0, false),
])
.source;
let film_at = source let film_at = source
.find("---- film_sim ----") .find("---- film_sim ----")
.expect("the operation itself must still be emitted"); .expect("the operation itself must still be emitted");
let scene_op = source.find("---- op_a ----").expect("op present");
assert!(
scene_op < film_at,
"the film must see every scene operation's result"
);
assert!( assert!(
!source.contains("view_sigmoid"), !source.contains("view_sigmoid"),
"the view transform is still being applied on top of the film" "the view transform is still being applied on top of the film"
+24 -15
View File
@@ -1,22 +1,25 @@
//! TRACES: FR-DEV-3f //! TRACES: FR-DEV-3f
//! Film simulation — the stock renders the picture. //! Film simulation — the stock renders the picture.
//! //!
//! # Why this one replaces the base curve //! # Why this one is the view transform
//! //!
//! [`crate::ops`]' other nodes adjust a picture. This one *makes* it. The base //! [`crate::ops`]' other nodes adjust a picture. This one *makes* it. The view
//! curve exists because sensor data is scene-referred and nothing anybody looks //! transform exists because sensor data is scene-referred and nothing anybody
//! at is (FR-DEV-3e); a film stock's characteristic curve does the same job, //! looks at is (FR-DEV-3j); a film stock's characteristic curve does the same
//! from measurements, with a toe and a shoulder that were coated onto acetate //! job, from measurements, with a toe and a shoulder that were coated onto
//! rather than drawn. Running both renders the image twice — the camera's //! acetate rather than drawn. Running both renders the image twice — the
//! JPEG-ish rendering, and then a film's rendering of that — which is not what //! default rendering, and then a film's rendering of that — which is not what
//! either is for and looks like neither. //! either is for and looks like neither.
//! //!
//! So this node declares [`Operation::renders`], and the composer answers by //! So this node is in [`Stage::View`] and declares [`Operation::renders`]: when
//! emitting neither the base curve nor the camera matrix. Both jobs move here: //! a stock is loaded the composer puts it at the end of the chain in place of
//! the fragment takes camera RGB, converts it to linear sRGB itself with the //! the default sigmoid (D19). It is handed working-space colour — linear sRGB
//! matrix already in the uniform block, and returns linear sRGB. That is a //! primaries, scene-referred, after every other operation and after the detail
//! contract worth stating plainly, because a node that got half of it wrong //! stage — and returns display-referred linear sRGB for the output transform.
//! would produce a picture that renders perfectly and is wrong everywhere. //! Before D19 it ran at order 25, after exposure and before everything else,
//! and the operations below it acted on its output. They now act on the scene
//! it is shown: an edit is a decision about the exposure the negative
//! receives, and the film is the last thing that happens to the picture.
//! //!
//! # Why the tables are not parameters //! # Why the tables are not parameters
//! //!
@@ -34,7 +37,7 @@
use std::sync::{Arc, LazyLock}; use std::sync::{Arc, LazyLock};
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId}; use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Operation, Uniform}; use crate::operation::{Operation, Stage, Uniform};
pub const ID: OpId = OpId("film_sim"); pub const ID: OpId = OpId("film_sim");
pub const EXPOSURE: ParamId = ParamId("exposure"); pub const EXPOSURE: ParamId = ParamId("exposure");
@@ -304,11 +307,17 @@ impl Operation for FilmSim {
self.tables.is_some() self.tables.is_some()
} }
/// This node renders; the camera's own rendering must not also run. /// This node renders; the default view transform must not also run.
fn renders(&self) -> bool { fn renders(&self) -> bool {
true true
} }
/// TRACES: FR-DEV-3f | FR-DEV-3j
/// The view transform's place, at the end of the chain (D19).
fn stage(&self) -> Stage {
Stage::View
}
fn set_film_tables(&mut self, tables: Option<&FilmTables>) { fn set_film_tables(&mut self, tables: Option<&FilmTables>) {
self.set_tables(tables.cloned()); self.set_tables(tables.cloned());
} }
File diff suppressed because one or more lines are too long