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