Files
DarkRoom/core/dr-pipeline/src/ops/film_sim.rs
T
dtourolle 657f8f19ff 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.
2026-09-27 16:52:55 -04:00

753 lines
30 KiB
Rust

//! TRACES: FR-DEV-3f
//! Film simulation — the stock renders the picture.
//!
//! # Why this one is the view transform
//!
//! [`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 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
//!
//! 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, push, print exposure and format, which are what
//! a photographer and a printer actually control. `dr-film` turns a stock into
//! [`FilmTables`] that hold none of them; the shader applies all four per
//! pixel, which is what lets a mask layer hold its own (see
//! [`Operation::blends_settings`]). 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 std::sync::{Arc, LazyLock};
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Operation, Stage, 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");
pub const FORMAT: ParamId = ParamId("format");
/// TRACES: FR-DEV-3f
/// The frames a photograph can be simulated on, smallest first.
///
/// A genuinely fixed list, unlike the stocks: nobody invents a film format, so
/// this is a declared `enum` parameter and gets its control, its place in the
/// sidecar and its undo step for free. The *sizes* live in `dr_film::Format`;
/// this crate carries only the names, in the same order.
static FORMATS: [LocalizedKey; 6] = [
LocalizedKey("param.film_sim.format.35mm"),
LocalizedKey("param.film_sim.format.645"),
LocalizedKey("param.film_sim.format.6x6"),
LocalizedKey("param.film_sim.format.6x7"),
LocalizedKey("param.film_sim.format.4x5"),
LocalizedKey("param.film_sim.format.8x10"),
];
/// 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 most development times a stock may measure — a curve row and a push
/// station each. Must agree with `dr_film::bake::MAX_CURVE_ROWS`, for the
/// reason [`CURVE_SAMPLES`] must; the uniform block holds this many stations.
pub const MAX_CURVE_ROWS: usize = 8;
/// How many frames [`FORMATS`] offers, and so how many grain counts a stock
/// carries.
pub const FORMAT_COUNT: usize = 6;
/// 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"],
];
/// Grains per pixel, per format and layer: `gn{format}{layer}`.
static GRAIN_FIELDS: [[&str; 3]; FORMAT_COUNT] = [
["gn00", "gn01", "gn02"],
["gn10", "gn11", "gn12"],
["gn20", "gn21", "gn22"],
["gn30", "gn31", "gn32"],
["gn40", "gn41", "gn42"],
["gn50", "gn51", "gn52"],
];
/// The push each curve row was developed to, padded with the last.
static PUSH_FIELDS: [&str; MAX_CURVE_ROWS] =
["ps0", "ps1", "ps2", "ps3", "ps4", "ps5", "ps6", "ps7"];
/// Which format this is, one-hot. See [`FilmSim::uniforms`] for why a choice
/// reaches the shader as six weights rather than an index.
static FORMAT_FIELDS: [&str; FORMAT_COUNT] = ["fmt0", "fmt1", "fmt2", "fmt3", "fmt4", "fmt5"];
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(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.
// Effect, not tone-and-colour. **This is the descriptor a `rust:` node
// is actually read from** — the `attributes:` line in `ops/*.yaml`
// describes a *declared* node and is inert here, which is how an
// earlier attempt to make this move changed nothing at all.
//
// A stock is `Effect`'s own definition: applied rather than corrected,
// a look and not a fix. Declaring tone and colour put "Kodachrome" in
// the Light group beside exposure and again in Colour beside white
// balance — two places, neither of which is where anyone looks for it.
// That it moves tone and colour is true of every look.
attributes: vec![Attribute::Effect],
id: ID,
label: LocalizedKey("op.film_sim"),
params: vec![
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),
// TRACES: FR-DEV-3f
// Which frame this was taken on — the half of the enlargement a
// photograph cannot supply. A crystal is a fixed size in micrometres,
// so how grainy a picture looks is film size against output size, and
// the same emulsion on 4x5 renders about three times smoother than on
// 35mm at the same print.
ParamDescriptor::choice("format", "param.film_sim.format", FORMATS.to_vec()),
],
})
});
/// 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`, at unit gain: camera exposure is a per-pixel setting.
/// - `curves` — one row of `CURVE_SAMPLES` density triples per
/// `push_stations` entry, uniform over `[curve_log_min, curve_log_max]`,
/// and then, when printed, one more row: the paper's, uniform over
/// `[paper.log_min, paper.log_max]`.
/// - `lut` — `lut_size³` triples uniform over `[0, density_max]` on each
/// axis, with the **red axis varying fastest**: index
/// `(b * size + g) * size + r`. Linear sRGB when the film is viewed
/// directly; the paper's log₁₀ exposure through the negative when it is
/// printed, followed by a second cube, paper density over
/// `[0, paper.density_max]` to linear sRGB. That is the order a 3D texture
/// upload expects with the cubes stacked in depth, 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.
///
/// Everything the sliders move — exposure, push, print exposure, format — is
/// absent. They are per-pixel settings the shader applies against these
/// tables, which is what lets a mask layer hold its own.
#[derive(Debug, Clone, PartialEq)]
pub struct FilmTables {
pub exposure_matrix: [[f32; 3]; 3],
pub curves: Vec<[f32; 3]>,
/// The push each film row was developed to, ascending: one entry for a
/// stock measured at a single process.
pub push_stations: Vec<f32>,
pub curve_log_min: f32,
pub curve_log_max: f32,
pub lut: Vec<[f32; 3]>,
pub density_max: f32,
pub lut_size: usize,
/// The print, for a negative printed on paper.
pub paper: Option<PaperTables>,
/// TRACES: FR-DEV-3f
/// Grains in one pixel's patch of film, per format and then 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]; FORMAT_COUNT],
pub grain_density_max: [f32; 3],
pub grain_uniformity: f32,
}
/// The print half of [`FilmTables`]: where the paper's row and cube are read.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PaperTables {
/// The enlarger's filtration, per layer, in log₁₀ exposure.
pub balance: [f32; 3],
pub log_min: f32,
pub log_max: f32,
pub density_max: f32,
}
impl FilmTables {
/// Film rows, not counting the paper's.
pub fn curve_rows(&self) -> usize {
self.push_stations.len()
}
/// 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 {
let rows = self.curve_rows();
let printed = usize::from(self.paper.is_some());
let paper_ok = self
.paper
.is_none_or(|p| p.density_max > 0.0 && p.log_max > p.log_min);
(1..=MAX_CURVE_ROWS).contains(&rows)
&& self.push_stations.windows(2).all(|w| w[0] < w[1])
&& self.curves.len() == CURVE_SAMPLES * (rows + printed)
&& self.lut_size >= 2
&& self.lut.len() == self.lut_size.pow(3) * (1 + printed)
&& self.density_max > 0.0
&& self.curve_log_max > self.curve_log_min
&& paper_ok
}
}
/// TRACES: FR-DEV-3f
#[derive(Debug, Default, Clone)]
pub struct FilmSim {
exposure: f32,
print_exposure: f32,
push: f32,
/// Index into `FORMATS`. Zero is 35 mm, which is the neutral choice.
format: 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) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
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,
FORMAT => self.format = 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,
FORMAT => self.format,
_ => 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 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());
}
fn film_tables(&self) -> Option<&FilmTables> {
self.tables.as_ref()
}
/// TRACES: FR-DEV-3f
/// A layer's film is its settings, not its own picture blended over the
/// global one.
///
/// Blending outputs would be a photograph developed twice and cross-faded;
/// a region on a pushed film is not that. Every uniform below is linear
/// in what it controls, so the composer can take each layer's weighted
/// average of them and develop the pixel once.
fn blends_settings(&self) -> bool {
true
}
/// Every value here is linear in what the shader does with it, which is
/// what [`Self::blends_settings`] rests on. The format is the one that
/// needs arranging: an index averaged between layers is a format nobody
/// chose, so it goes out one-hot and the shader mixes the six grain
/// counts by it — two layers on 35 mm and 6x7 meet at the average grain.
fn uniforms(&self) -> Vec<Uniform> {
let Some(t) = &self.tables else {
return Vec::new();
};
let mut out = Vec::with_capacity(64);
let mut push = |name: &'static str, value: f32| out.push(Uniform { name, value });
for (l, row) in t.exposure_matrix.iter().enumerate() {
for (c, v) in row.iter().enumerate() {
push(MATRIX_FIELDS[l][c], *v);
}
}
for (f, per_layer) in t.grain_particles.iter().enumerate() {
for (l, v) in per_layer.iter().enumerate() {
push(GRAIN_FIELDS[f][l], *v);
}
}
for (l, name) in ["gd0", "gd1", "gd2"].into_iter().enumerate() {
push(name, t.grain_density_max[l]);
}
push("grain_u", t.grain_uniformity);
push("log_min", t.curve_log_min);
push("log_max", t.curve_log_max);
push("density_max", t.density_max);
push("lut_size", t.lut_size as f32);
let last = *t.push_stations.last().unwrap_or(&0.0);
for (i, name) in PUSH_FIELDS.into_iter().enumerate() {
push(name, t.push_stations.get(i).copied().unwrap_or(last));
}
push("rows", t.curve_rows() as f32);
let paper = t.paper.unwrap_or(PaperTables {
balance: [0.0; 3],
log_min: 0.0,
log_max: 1.0,
density_max: 1.0,
});
push("printed", if t.paper.is_some() { 1.0 } else { 0.0 });
for (l, name) in ["pb0", "pb1", "pb2"].into_iter().enumerate() {
push(name, paper.balance[l]);
}
push("plog_min", paper.log_min);
push("plog_max", paper.log_max);
push("pdmax", paper.density_max);
// The sliders.
push("ev", self.exposure);
push("push", self.push);
push("pev", self.print_exposure);
let chosen = (self.format.max(0.0).round() as usize).min(FORMAT_COUNT - 1);
for (f, name) in FORMAT_FIELDS.into_iter().enumerate() {
push(name, if f == chosen { 1.0 } else { 0.0 });
}
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.
"\
// Working-space colour, which is linear sRGB primaries — what the film's
// exposure matrix is defined against. The composer converted out of camera
// RGB before any scene-stage operation ran (D19).
let scene = 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. The camera's exposure is a gain on it, applied here rather than
// baked in so that a layer can hold its own.
let exposure = exp2(ev) * 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, at this
// pixel's push. 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_pushed(
clamp((log_exposure - log_min) / (log_max - log_min), vec3<f32>(0.0), vec3<f32>(1.0)),
push,
array<f32, 8>(ps0, ps1, ps2, ps3, ps4, ps5, ps6, ps7),
u32(rows),
);
// 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.
//
// The format's grain count, mixed by the one-hot weights: exactly one format's
// on the whole photograph, and the weighted average under overlapping layers.
let particles = fmt0 * vec3<f32>(gn00, gn01, gn02)
+ fmt1 * vec3<f32>(gn10, gn11, gn12)
+ fmt2 * vec3<f32>(gn20, gn21, gn22)
+ fmt3 * vec3<f32>(gn30, gn31, gn32)
+ fmt4 * vec3<f32>(gn40, gn41, gn42)
+ fmt5 * vec3<f32>(gn50, gn51, gn52);
let grained = film_grain(density, source_px, particles,
vec3<f32>(gd0, gd1, gd2),
grain_u);
// Dye absorption through to what comes next — all of it takes exactly three
// numbers in, which is why it fits in one lookup. Viewed directly, that is
// the picture; printed, it is the light the paper receives through the
// negative, in log exposure.
let through = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)),
lut_size, 0);
if (printed > 0.5) {
// The enlarger: its filtration, and then its exposure, the same stops on
// every layer — which is why print exposure is an addition here and not
// a table, and so exact at any setting.
let paper_log = through + vec3<f32>(pb0, pb1, pb2) + pev * 0.30103;
let paper_density = film_curve(
clamp((paper_log - plog_min) / (plog_max - plog_min), vec3<f32>(0.0), vec3<f32>(1.0)),
u32(rows),
);
c = film_lut(clamp(paper_density / pdmax, vec3<f32>(0.0), vec3<f32>(1.0)),
lut_size, i32(lut_size));
} else {
c = through;
}"
.into()
}
fn helpers(&self) -> &'static [crate::operation::Helper] {
&HELPERS
}
}
static HELPERS: [crate::operation::Helper; 6] = [
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, one row of a 256-wide texture, interpolated by
// hand. `t` is already normalised to the curve's domain.
fn film_curve(t: vec3<f32>, row: u32) -> 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), i32(row)), 0);
let b = textureLoad(film_curves, vec2<i32>(i32(i) + 1, i32(row)), 0);
out[ch] = mix(a[ch], b[ch], f);
}
return out;
}",
},
crate::operation::Helper {
name: "film_curve_pushed",
source: "\
// The curves at a push between two measured processes. Development is
// interpolated in log time and push *is* log time, so a straight line between
// the neighbouring rows is the stock's own interpolation, not an estimate of
// it. Clamped to the first and last process, as the stock is.
fn film_curve_pushed(t: vec3<f32>, push: f32, stations: array<f32, 8>, rows: u32) -> vec3<f32> {
if (rows < 2u) {
return film_curve(t, 0u);
}
var at = stations;
var hi = rows - 1u;
for (var i = 1u; i < rows; i = i + 1u) {
if (at[i] >= push) {
hi = i;
break;
}
}
let lo = hi - 1u;
let f = clamp((push - at[lo]) / max(at[hi] - at[lo], 1e-6), 0.0, 1.0);
return mix(film_curve(t, lo), film_curve(t, hi), f);
}",
},
crate::operation::Helper {
name: "film_lut",
source: "\
// Trilinear interpolation of one cube of the lookup, by hand for the same
// reason the curve above is: there is no sampler bound, and the eight loads
// are cache-neighbours. `z0` is where the cube starts in depth: the film's at
// zero, the paper's stacked after it.
fn film_lut(t: vec3<f32>, size: f32, z0: i32) -> 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 + z0);
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]; FORMAT_COUNT],
push_stations: vec![0.0],
paper: None,
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_is_handed_working_space_colour() {
// TRACES: FR-DEV-3f
// D19: the composer leaves camera space before any scene-stage
// operation, so a film converting again would apply the camera
// matrix twice.
let mut op = FilmSim::new();
op.set_tables(Some(tables()));
let wgsl = op.wgsl_body();
assert!(!wgsl.contains("cam_to_srgb"), "{wgsl}");
}
#[test]
fn every_helper_defines_the_function_it_names() {
for h in HELPERS {
assert!(h.source.contains(&format!("fn {}(", h.name)), "{}", h.name);
}
}
}