//! 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> = 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, 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, /// 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, } 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) { 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 { 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 { 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( dot(vec3(m00, m01, m02), scene), dot(vec3(m10, m11, m12), scene), dot(vec3(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(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(0.0), vec3(1.0)), push, array(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(gn00, gn01, gn02) + fmt1 * vec3(gn10, gn11, gn12) + fmt2 * vec3(gn20, gn21, gn22) + fmt3 * vec3(gn30, gn31, gn32) + fmt4 * vec3(gn40, gn41, gn42) + fmt5 * vec3(gn50, gn51, gn52); let grained = film_grain(density, source_px, particles, vec3(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(0.0), vec3(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(pb0, pb1, pb2) + pev * 0.30103; let paper_density = film_curve( clamp((paper_log - plog_min) / (plog_max - plog_min), vec3(0.0), vec3(1.0)), u32(rows), ); c = film_lut(clamp(paper_density / pdmax, vec3(0.0), vec3(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, layer: u32) -> vec2 { 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( 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, at: vec2, n: vec3, dmax: vec3, uniformity: f32, ) -> vec3 { 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) -> vec3 { 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, row: u32) -> vec3 { let samples = u32(textureDimensions(film_curves).x); let last = f32(samples - 1u); var out = vec3(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(i), i32(row)), 0); let b = textureLoad(film_curves, vec2(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, push: f32, stations: array, rows: u32) -> vec3 { 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, size: f32, z0: i32) -> vec3 { let n = i32(size); let x = t * (size - 1.0); let base = min(vec3(floor(x)), vec3(n - 2)); let f = x - vec3(base); var out = vec3(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(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 emits the stock in the view // transform's place rather than beside it. If this ever returned // false the picture would be rendered 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); } } }