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 ----"));
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
.passes
.iter()
@@ -2503,8 +2510,15 @@ mod tests {
// 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 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!(
shader.source.matches("---- ").count(),
shader.source.matches("---- ").count() + view_blocks,
fused_blocks + warp_blocks + 1,
"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.
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
measurements rather than from a curve somebody drew. Running both renders the
scene twice — the camera's rendering, and then a film's rendering of *that* —
which looks like neither and reads as a colour-management bug with no
colour-management bug to find.
characteristic curve does the view transform's job, from measurements rather
than from a curve somebody chose. Running both renders the scene twice — the
default rendering, and then a film's rendering of *that* — which looks like
neither and reads as a colour-management bug with no colour-management bug to
find.
So a node declaring `renders` hands back display-referred linear sRGB, and in
exchange the composer does not emit the base curve. It is handed working-space
colour like every other node: the conversion out of camera space is no longer
the rendering's to take over, because since D19 it runs before every node but
white balance (see "Stages" below). `distortion` and
So `film_sim` is in `Stage::View` beside `view_transform`, and while a stock
is loaded the composer emits it in the view transform's place, last, after the
detail stage, and not the sigmoid (D19). It is handed working-space colour and
hands back display-referred linear sRGB for the output transform. `distortion` and
`aberration` are `Warp`s rather than operations: they rewrite coordinates
before sampling rather than transforming a colour after it.
+10 -8
View File
@@ -1,5 +1,5 @@
id: film_sim
order: 25
order: 190
# 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,
# 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.
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
the amount of light that reached it — and they are only meaningful on
scene-linear values, which is what a film has to be handed. Everything below
is a decision about the picture, and a decision about the picture belongs
after the film has rendered it, exactly as it does when you scan a frame and
then work on the scan.
Before D19 it sat at order 25, after white balance and exposure, and every
decision below it acted on the film's output, as though the frame had been
scanned and then worked on. That put a display-referred rendering in the
middle of the chain, which is what D19 removes: every operation is now handed
the scene, and the film is the last thing that happens to the picture — an
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 ----"));
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
.passes
.iter()
@@ -189,8 +196,12 @@ mod tests {
);
fused_blocks += usize::from(point);
}
let view_blocks = shader
.view
.as_ref()
.map_or(0, |v| v.source.matches("---- ").count());
assert_eq!(
shader.source.matches("---- ").count(),
shader.source.matches("---- ").count() + view_blocks,
fused_blocks,
"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
/// one.
///
/// Almost everything returns `false`. An operation that returns `true`
/// hands back display-referred linear sRGB, and in exchange the composer
/// does not emit the camera profile's base curve — because this operation
/// has done its job. It is handed working-space colour like any other
/// scene-stage operation (D19).
/// Almost everything returns `false`. A [`Stage::View`] operation that
/// returns `true` is a complete rendering: while it is active the composer
/// emits it in the view transform's place and not the default view
/// transform beside it (FR-DEV-3j).
///
/// 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
/// 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 —
/// a stock's characteristic curve does the base curve's job, from
/// measurements, and running both is the camera's rendering of the scene
/// a stock's characteristic curve does the view transform's job, from
/// measurements, and running both is the default rendering of the scene
/// followed by a film's rendering of *that*.
fn renders(&self) -> bool {
false
@@ -469,7 +468,10 @@ pub enum Stage {
/// 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.
/// 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
/// 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_values: Vec<f32> = Vec::new();
let mut body = String::new();
@@ -882,8 +879,8 @@ fn compose_inner(
// TRACES: FR-DEV-3j
// 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;
// the camera-space tap, whose job is the sensor's own numbers.
let views = output_mode != OutputMode::CameraLinear;
// 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
// 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())
.filter(move |o| o.detail().is_none() && o.stage() == stage)
};
// 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`.
// The view operation: an active rendering — a loaded film stock — if
// there is one, otherwise the chain's view transform, or a default one
// 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 view: Option<&dyn Operation> = views_here.then(|| {
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)
});
@@ -1190,7 +1192,7 @@ fn compose_inner(
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"
" // No view transform: this is the camera-space tap, which stores the\n // sensor's own numbers.\n"
.to_string()
};
@@ -2319,10 +2321,22 @@ mod tests {
}));
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
.find("---- film_sim ----")
.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!(
!source.contains("view_sigmoid"),
"the view transform is still being applied on top of the film"
+24 -15
View File
@@ -1,22 +1,25 @@
//! TRACES: FR-DEV-3f
//! 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
//! curve exists because sensor data is scene-referred and nothing anybody looks
//! at is (FR-DEV-3e); a film stock's characteristic curve does the same job,
//! from measurements, with a toe and a shoulder that were coated onto acetate
//! rather than drawn. Running both renders the image twice — the camera's
//! JPEG-ish rendering, and then a film's rendering of that — which is not what
//! [`crate::ops`]' other nodes adjust a picture. This one *makes* it. The view
//! transform exists because sensor data is scene-referred and nothing anybody
//! looks at is (FR-DEV-3j); a film stock's characteristic curve does the same
//! job, from measurements, with a toe and a shoulder that were coated onto
//! acetate rather than drawn. Running both renders the image twice — the
//! default rendering, and then a film's rendering of that — which is not what
//! either is for and looks like neither.
//!
//! So this node declares [`Operation::renders`], and the composer answers by
//! emitting neither the base curve nor the camera matrix. Both jobs move here:
//! the fragment takes camera RGB, converts it to linear sRGB itself with the
//! matrix already in the uniform block, and returns linear sRGB. That is a
//! contract worth stating plainly, because a node that got half of it wrong
//! would produce a picture that renders perfectly and is wrong everywhere.
//! So this node is in [`Stage::View`] and declares [`Operation::renders`]: when
//! a stock is loaded the composer puts it at the end of the chain in place of
//! the default sigmoid (D19). It is handed working-space colour — linear sRGB
//! primaries, scene-referred, after every other operation and after the detail
//! stage — and returns display-referred linear sRGB for the output transform.
//! 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
//!
@@ -34,7 +37,7 @@
use std::sync::{Arc, LazyLock};
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 EXPOSURE: ParamId = ParamId("exposure");
@@ -304,11 +307,17 @@ impl Operation for FilmSim {
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 {
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>) {
self.set_tables(tables.cloned());
}