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.
753 lines
30 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|