Files
DarkRoom/core/dr-pipeline/src/ops/film_sim.rs
T
dtourolleandClaude Opus 5 ce6458547a Develop longer, from the measurements rather than from a contrast slider
Pushing was not a thing to simulate. It was measured data being thrown
away: Double-X and 2302 each ship five characteristic curves, one per
development time, and this shipped the 6.5-minute column and discarded
four. All five now ship and interpolate.

The axis is real. Double-X runs 4 to 12 minutes, and across it the average
gradient goes 0.472 to 1.034 while Dmax goes 1.19 to 2.56.

The control is in stops, because that is what a photographer means, and one
stop is a factor of about 1.41 in time. That mapping is checked rather than
assumed: against Double-X's own axis it lands within 2% of the 9-minute
column for +1, and near 12 minutes for +2, which are the times the datasheet
gives for exactly that. There is a test.

**Pushing must not recover shadow detail, and this does not.** Across the
whole measured range the speed point moves about a third of a stop while the
gradient doubles; three stops under mid-grey, density goes from 0.008 to
0.035, which is still nothing. Developing longer multiplies what was already
recorded and cannot record what never hit the film. A push built as added
exposure or global contrast brightens those shadows instead and looks
convincing until someone who shoots film sees it, so that property has a
test of its own.

Interpolated in *log* time, because development is multiplicative: 4 to 5
minutes is the same amount of push as 9 to 12, and interpolating linearly
would bunch the control at one end. Clamped at both ends, because past the
published range there is no data and extrapolating a contrast curve invents
an emulsion nobody tested. A stock measured at one process ignores the
control entirely rather than inventing a curve for it -- Portra 800's pushes
are separate *measured* profiles, which is the honest way to offer those.

Costs nothing per pixel and changes no shader. The curves are a per-stock
table, so the interpolation happens on the CPU at bake time, where choosing a
stock and moving its sliders already rebakes. The Vulkan shader is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:35:25 +02:00

562 lines
21 KiB
Rust

//! TRACES: FR-DEV-3f
//! Film simulation — the stock renders the picture.
//!
//! # Why this one replaces the base curve
//!
//! [`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
//! 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.
//!
//! # Why the tables are not parameters
//!
//! For the same reason [`crate::ops::vignetting`]'s coefficients are not: they
//! are measurements of a physical thing, not something a slider moves. The
//! sliders here are exposure and print exposure, which are what a photographer
//! and a printer actually control. `dr-film` turns a stock plus those two
//! numbers into [`FilmTables`]; this node knows only the layout.
//!
//! Declared as a plain struct here rather than imported, so that dr-pipeline
//! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does
//! with `Pa`.
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Operation, Uniform};
pub const ID: OpId = OpId("film_sim");
pub const EXPOSURE: ParamId = ParamId("exposure");
pub const PRINT_EXPOSURE: ParamId = ParamId("print_exposure");
pub const PUSH: ParamId = ParamId("push");
/// How many samples a characteristic curve carries.
///
/// Must agree with `dr_film::profile::CURVE_SAMPLES`. Restated rather than
/// imported because importing it is exactly the dependency this crate does not
/// take; [`FilmTables::is_well_formed`] is what stops the two drifting.
pub const CURVE_SAMPLES: usize = 256;
/// The uniform field names the fragment reads the exposure matrix from.
///
/// A table rather than a formatted string, because a `Uniform`'s name is
/// `&'static str`: building one per composition would mean leaking a string
/// every time a slider moved.
static MATRIX_FIELDS: [[&str; 3]; 3] = [
["m00", "m01", "m02"],
["m10", "m11", "m12"],
["m20", "m21", "m22"],
];
static DESCRIPTOR: OpDescriptor = OpDescriptor {
// Tone and colour both, and not `Effect`: a stock is not something applied
// on top of a photograph, it is what the photograph was made on.
attributes: &[Attribute::Tone, Attribute::Colour],
id: ID,
label: LocalizedKey("op.film_sim"),
params: &[
ParamDescriptor::stops("exposure", "param.film_sim.exposure", -3.0, 3.0),
ParamDescriptor::stops("print_exposure", "param.film_sim.print_exposure", -3.0, 3.0),
// TRACES: FR-DEV-3f
// Development, in stops of push. Bounded by what the manufacturers
// actually published: Double-X's measured axis spans about -1 to +2,
// and beyond a range like that a curve would have to be invented.
ParamDescriptor::stops("push", "param.film_sim.push", -1.0, 3.0),
],
};
/// A stock reduced to what a shader runs, as `dr-film` bakes it.
///
/// Layout is the contract between the two crates, so it is written down here
/// and checked rather than assumed:
///
/// - `exposure_matrix[l][c]` — layer `l`'s response to linear sRGB channel `c`.
/// - `curves` — `CURVE_SAMPLES` density triples, uniform over
/// `[curve_log_min, curve_log_max]`.
/// - `lut` — `lut_size³` linear sRGB triples, uniform over `[0, density_max]`
/// on each axis, with the **red axis varying fastest**: index
/// `(b * size + g) * size + r`. That is the order a 3D texture upload
/// expects, so the consumer hands the slice straight to the driver. Filling
/// it the other way round transposes red and blue in the finished picture —
/// which is a plausible photograph of the wrong colour, and which the unit
/// tests on both sides of this seam happily pass, because each side is
/// internally consistent. `dr-film` pins it; `dr-gpu`'s `film_sim` test
/// catches it end to end.
#[derive(Debug, Clone, PartialEq)]
pub struct FilmTables {
pub exposure_matrix: [[f32; 3]; 3],
pub curves: Vec<[f32; 3]>,
pub curve_log_min: f32,
pub curve_log_max: f32,
pub lut: Vec<[f32; 3]>,
pub density_max: f32,
pub lut_size: usize,
/// TRACES: FR-DEV-3f
/// Grains in one pixel's patch of film, per layer, with the density
/// ceiling and uniformity the variance is taken against. Zero particles
/// means no grain, which is how the control is turned off.
pub grain_particles: [f32; 3],
pub grain_density_max: [f32; 3],
pub grain_uniformity: f32,
}
impl FilmTables {
/// Whether these tables are the shape the shader will index them at.
///
/// Checked on the way in, because the failure otherwise is a shader
/// sampling past the end of a texture: undefined, silent, and different on
/// every driver.
pub fn is_well_formed(&self) -> bool {
self.curves.len() == CURVE_SAMPLES
&& self.lut_size >= 2
&& self.lut.len() == self.lut_size.pow(3)
&& self.density_max > 0.0
&& self.curve_log_max > self.curve_log_min
}
}
/// TRACES: FR-DEV-3f
#[derive(Debug, Default, Clone)]
pub struct FilmSim {
exposure: f32,
print_exposure: f32,
push: f32,
tables: Option<FilmTables>,
}
impl FilmSim {
pub fn new() -> Self {
Self::default()
}
/// Load a baked stock, or clear it.
///
/// Malformed tables are refused rather than stored: an operation that is
/// active but cannot be indexed is worse than one that is off, because the
/// first renders garbage and the second renders the photograph.
pub fn set_tables(&mut self, tables: Option<FilmTables>) {
match tables {
Some(t) if !t.is_well_formed() => {
log::error!(
"film_sim: refusing malformed tables ({} curve samples, {} lut entries at size {})",
t.curves.len(),
t.lut.len(),
t.lut_size
);
self.tables = None;
}
other => self.tables = other,
}
}
/// The loaded stock's tables, for whoever has to upload them.
pub fn tables(&self) -> Option<&FilmTables> {
self.tables.as_ref()
}
}
impl Operation for FilmSim {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
EXPOSURE => self.exposure = value,
PRINT_EXPOSURE => self.print_exposure = value,
PUSH => self.push = value,
_ => log::warn!("film_sim: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
EXPOSURE => self.exposure,
PRINT_EXPOSURE => self.print_exposure,
PUSH => self.push,
_ => 0.0,
}
}
/// Active exactly when a stock is loaded.
///
/// Not "when a slider has moved", which is the rule everywhere else and
/// would be wrong here: a stock at zero exposure compensation is the whole
/// point of choosing it, and a node that went quiet at its defaults would
/// mean picking a film did nothing until you also nudged something.
fn is_active(&self) -> bool {
self.tables.is_some()
}
/// This node renders; the camera's own rendering must not also run.
fn renders(&self) -> bool {
true
}
fn set_film_tables(&mut self, tables: Option<&FilmTables>) {
self.set_tables(tables.cloned());
}
fn uniforms(&self) -> Vec<Uniform> {
let Some(t) = &self.tables else {
return Vec::new();
};
let m = t.exposure_matrix;
// Exposure rides in the matrix on the CPU when the stock is baked, so
// what is left here is the *shader's* copy of the same nine numbers.
// Spelled out one at a time because a uniform is a named `f32` in this
// pipeline and a matrix would be a second kind of thing for one caller.
let mut out = Vec::with_capacity(MATRIX_FIELDS.len() + 5);
for (l, row) in m.iter().enumerate() {
for (c, v) in row.iter().enumerate() {
out.push(Uniform {
name: MATRIX_FIELDS[l][c],
value: *v,
});
}
}
for (l, name) in ["gn0", "gn1", "gn2"].into_iter().enumerate() {
out.push(Uniform {
name,
value: t.grain_particles[l],
});
}
for (l, name) in ["gd0", "gd1", "gd2"].into_iter().enumerate() {
out.push(Uniform {
name,
value: t.grain_density_max[l],
});
}
out.push(Uniform {
name: "grain_u",
value: t.grain_uniformity,
});
out.push(Uniform {
name: "log_min",
value: t.curve_log_min,
});
out.push(Uniform {
name: "log_max",
value: t.curve_log_max,
});
out.push(Uniform {
name: "density_max",
value: t.density_max,
});
out.push(Uniform {
name: "lut_size",
value: t.lut_size as f32,
});
out.push(Uniform {
name: "print_exposure",
value: self.print_exposure,
});
out
}
fn wgsl_body(&self) -> String {
// Filtered by hand rather than through a sampler, which is what the
// framing prologue already does for the source: this pipeline has no
// sampler binding, and adding one to interpolate two lookups would
// cost a binding in every shader whether or not a film is loaded.
"\
// Camera RGB to linear sRGB. The film's exposure matrix is defined against
// sRGB primaries, and this node has taken over the conversion the composer
// would otherwise have emitted at the end — see `Operation::renders`.
let scene = vec3<f32>(
dot(u.cam_to_srgb_0.rgb, c),
dot(u.cam_to_srgb_1.rgb, c),
dot(u.cam_to_srgb_2.rgb, c),
);
// What each emulsion layer was exposed to. A matrix, exactly: the scene
// spectrum reconstructed from an sRGB triple is linear in that triple, so the
// integral over wavelength collapsed into these nine numbers when the stock
// was baked.
let exposure = vec3<f32>(
dot(vec3<f32>(m00, m01, m02), scene),
dot(vec3<f32>(m10, m11, m12), scene),
dot(vec3<f32>(m20, m21, m22), scene),
);
// 1e-10 rather than a clamp to zero: a black pixel has to land somewhere on
// the curve, and the toe is where it belongs.
let log_exposure = log10(max(exposure, vec3<f32>(0.0)) + 1e-10);
// The characteristic curve: what density each layer develops to. Clamped, not
// extrapolated — past the shoulder a real emulsion stops responding, and
// extrapolating would turn a blown highlight into a colour cast that grows the
// more it is overexposed.
let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min),
vec3<f32>(0.0), vec3<f32>(1.0)));
// TRACES: FR-DEV-3f
// Grain, on the density and before the dye.
//
// That order is the physical one and it is not cosmetic: grain is silver that
// did or did not develop, so it perturbs *density*, and the dye absorbs
// through whatever density resulted. Adding noise to the finished colour --
// which is what an effect does -- tints the highlights wrong, because that
// noise never passes through the dye at all.
let grained = film_grain(density, source_px,
vec3<f32>(gn0, gn1, gn2),
vec3<f32>(gd0, gd1, gd2),
grain_u);
// Dye absorption, the print through the negative, the paper, the viewing
// illuminant and the chromatic adaptation — all of which take exactly three
// numbers in, which is why they fit in one lookup.
c = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)), lut_size);"
.into()
}
fn helpers(&self) -> &'static [crate::operation::Helper] {
&HELPERS
}
}
static HELPERS: [crate::operation::Helper; 5] = [
crate::operation::Helper {
name: "film_hash",
source: "\
// A hash, not a random number generator: the same pixel of the same frame has
// to grain the same way every time it is drawn, or the picture would crawl
// while nobody was editing it. Seeded from a position, so it is reproducible
// by construction rather than by holding state between frames.
//
// Two decorrelated uniforms come out, which is what a Gaussian needs.
fn film_hash(p: vec2<f32>, layer: u32) -> vec2<f32> {
var h = u32(i32(floor(p.x))) * 73856093u
^ u32(i32(floor(p.y))) * 19349663u
^ (layer + 1u) * 83492791u;
h = h ^ (h >> 16u);
h = h * 2246822519u;
h = h ^ (h >> 13u);
h = h * 3266489917u;
let a = h ^ (h >> 16u);
var g = a * 747796405u + 2891336453u;
g = ((g >> ((g >> 28u) + 4u)) ^ g) * 277803737u;
let b = g ^ (g >> 22u);
// Open interval: a zero would send the logarithm below to infinity.
return vec2<f32>(
max(f32(a) * 2.3283064e-10, 1e-7),
max(f32(b) * 2.3283064e-10, 1e-7)
);
}",
},
crate::operation::Helper {
name: "film_grain",
source: "\
// Developed density, with the variance a count of silver grains actually has.
//
// mean = D
// variance = D * (Dmax - u * D) / N
//
// N is grains *per pixel*, so the entire scale dependence sits in that uniform
// and none of it is here: a zoomed-out pixel covers more film, averages more
// grains, and comes out smoother with nothing filtered.
//
// A Gaussian with the exact first two moments, rather than the exact compound
// Poisson-Binomial the silver actually follows. The two agree wherever grain
// is visible; the real one is skewed only in the deep toe, where the density
// is near zero and so is its variance. Sampling it properly would cost tens of
// draws per layer per pixel to change nothing anyone can see.
// Takes its parameters rather than reading uniforms, and must: the composer
// prefixes a uniform with its operation's id and rewrites the references
// *inside a fragment body only*. Helpers are shared between operations and
// deduplicated by name, so a bare `gn0` here is an identifier that exists in
// no shader. `film_lut` below takes its size for the same reason.
fn film_grain(
density: vec3<f32>,
at: vec2<f32>,
n: vec3<f32>,
dmax: vec3<f32>,
uniformity: f32,
) -> vec3<f32> {
var out = density;
for (var l = 0u; l < 3u; l = l + 1u) {
if (n[l] <= 0.0) {
continue;
}
let d = clamp(density[l], 0.0, dmax[l]);
let variance = d * (dmax[l] - uniformity * d) / n[l];
if (variance <= 0.0) {
continue;
}
let u = film_hash(at, l);
// Box-Muller. Half the pair is discarded rather than carried: the next
// layer wants a seed of its own, not this one's leftover.
let z = sqrt(-2.0 * log(u.x)) * cos(6.2831853 * u.y);
// Clamped, not wrapped: a negative density is not a colour, and the
// ceiling is the most silver this emulsion has to develop.
out[l] = clamp(d + z * sqrt(variance), 0.0, dmax[l]);
}
return out;
}",
},
crate::operation::Helper {
name: "log10",
source: "\
// WGSL has no log10, and `log2(x) * log10(2)` is the cheap identity for it.
fn log10(v: vec3<f32>) -> vec3<f32> {
return log2(v) * 0.30103;
}",
},
crate::operation::Helper {
name: "film_curve",
source: "\
// Three characteristic curves, sampled from a 256-wide texture and
// interpolated by hand. `t` is already normalised to the curve's domain.
fn film_curve(t: vec3<f32>) -> vec3<f32> {
let samples = u32(textureDimensions(film_curves).x);
let last = f32(samples - 1u);
var out = vec3<f32>(0.0);
for (var ch = 0u; ch < 3u; ch = ch + 1u) {
let x = t[ch] * last;
let i = min(u32(floor(x)), samples - 2u);
let f = x - f32(i);
let a = textureLoad(film_curves, vec2<i32>(i32(i), 0), 0);
let b = textureLoad(film_curves, vec2<i32>(i32(i) + 1, 0), 0);
out[ch] = mix(a[ch], b[ch], f);
}
return out;
}",
},
crate::operation::Helper {
name: "film_lut",
source: "\
// Trilinear interpolation of the density lookup, by hand for the same reason
// the curve above is: there is no sampler bound, and the eight loads are
// cache-neighbours.
fn film_lut(t: vec3<f32>, size: f32) -> vec3<f32> {
let n = i32(size);
let x = t * (size - 1.0);
let base = min(vec3<i32>(floor(x)), vec3<i32>(n - 2));
let f = x - vec3<f32>(base);
var out = vec3<f32>(0.0);
for (var dx = 0; dx < 2; dx = dx + 1) {
let wx = select(1.0 - f.x, f.x, dx == 1);
for (var dy = 0; dy < 2; dy = dy + 1) {
let wy = select(1.0 - f.y, f.y, dy == 1);
for (var dz = 0; dz < 2; dz = dz + 1) {
let wz = select(1.0 - f.z, f.z, dz == 1);
let p = base + vec3<i32>(dx, dy, dz);
out = out + wx * wy * wz
* textureLoad(film_lut_texture, p, 0).rgb;
}
}
}
return out;
}",
},
];
#[cfg(test)]
mod tests {
use super::*;
fn tables() -> FilmTables {
FilmTables {
exposure_matrix: [[5.0, 0.5, 0.2], [0.1, 5.0, 0.3], [0.2, 0.5, 4.0]],
curves: vec![[0.0, 0.0, 0.0]; CURVE_SAMPLES],
curve_log_min: -3.0,
curve_log_max: 4.0,
lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32],
density_max: 3.0,
lut_size: 32,
grain_particles: [0.0; 3],
grain_density_max: [3.0; 3],
grain_uniformity: 0.97,
}
}
#[test]
fn it_starts_inactive() {
assert!(!FilmSim::new().is_active());
}
#[test]
fn loading_a_stock_is_what_turns_it_on() {
// Not a moved slider, which is the rule for every other node. Choosing
// a film has to do something on its own, or picking one would appear
// to be broken until you also nudged the exposure.
let mut op = FilmSim::new();
op.set_tables(Some(tables()));
assert!(op.is_active());
op.set_tables(None);
assert!(!op.is_active());
}
#[test]
fn malformed_tables_are_refused_rather_than_stored() {
// The alternative is a shader indexing past the end of a texture,
// which is undefined, silent, and different on every driver.
let mut op = FilmSim::new();
let mut bad = tables();
bad.lut.truncate(10);
op.set_tables(Some(bad));
assert!(!op.is_active(), "malformed tables were accepted");
}
#[test]
fn a_short_curve_is_refused_too() {
let mut op = FilmSim::new();
let mut bad = tables();
bad.curves.truncate(CURVE_SAMPLES - 1);
op.set_tables(Some(bad));
assert!(!op.is_active());
}
#[test]
fn it_declares_itself_a_rendering_transform() {
// The whole reason the composer skips the base curve and the camera
// matrix. If this ever returned false the picture would be rendered
// twice and converted twice, which looks like a colour management bug
// a long way from here.
assert!(FilmSim::new().renders());
}
#[test]
fn the_matrix_reaches_the_shader_in_the_order_the_fragment_reads_it() {
// `m01` must be layer 0's response to sRGB green. A transposed matrix
// compiles, runs, and swaps the picture's colours.
let mut op = FilmSim::new();
op.set_tables(Some(tables()));
let uniforms = op.uniforms();
let named = |n: &str| uniforms.iter().find(|u| u.name == n).unwrap().value;
assert_eq!(named("m01"), 0.5);
assert_eq!(named("m10"), 0.1);
assert_eq!(named("m22"), 4.0);
}
#[test]
fn an_inactive_node_publishes_no_uniforms() {
assert!(FilmSim::new().uniforms().is_empty());
}
#[test]
fn the_fragment_converts_out_of_camera_space_itself() {
// It has to: it has taken over the conversion the composer would
// otherwise emit at the end.
let mut op = FilmSim::new();
op.set_tables(Some(tables()));
let wgsl = op.wgsl_body();
assert!(wgsl.contains("cam_to_srgb_0"), "{wgsl}");
}
#[test]
fn every_helper_defines_the_function_it_names() {
for h in HELPERS {
assert!(h.source.contains(&format!("fn {}(", h.name)), "{}", h.name);
}
}
}