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
+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());
}