A layer offered the film's sliders and they moved nothing: its copy of the node was never given the stock, so it stayed inactive. Film now works in a layer the way the other adjustments do, as offsets to the photograph's settings, but blended as settings rather than as results, since a film is a rendering and cross-fading two developments is not what a region on a pushed film looks like. - dr-film bakes no slider. Exposure is a gain in the shader; push interpolates the stock's measured processes, one curve row each; the print is split at the paper's log exposure, so print exposure is an addition between two lookups and exact at any setting. The enlarger stays balanced at the photograph's exposure. - film_sim reads all four settings as uniforms, format one-hot over a grain count per format, so every uniform is linear in what it does. - Operation::blends_settings lets the composer average each overlapping layer's uniforms with the global ones by mask weight, the global setting taking whatever weight the layers leave, and run the fragment once. Three layers at full weight give the mean of their settings. - The stock picker is hidden on a layer. Only the photograph's exposure re-solves the print balance; push, print exposure and format need no rebake at all now.
746 lines
30 KiB
Rust
746 lines
30 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, 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, 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 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 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.
|
|
"\
|
|
// 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. 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_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);
|
|
}
|
|
}
|
|
}
|