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:
@@ -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"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-16
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user