diff --git a/.env b/.env new file mode 100644 index 0000000..e69de29 diff --git a/core/dr-film/src/grain.rs b/core/dr-film/src/grain.rs new file mode 100644 index 0000000..ead2b63 --- /dev/null +++ b/core/dr-film/src/grain.rs @@ -0,0 +1,235 @@ +//! TRACES: FR-DEV-3f +//! Grain — the silver that did or did not develop. +//! +//! # Why this is not noise +//! +//! An emulsion is a suspension of silver-halide crystals. Light sensitises +//! some of them; development turns a sensitised crystal into an opaque grain, +//! all-or-nothing. So the density a patch of film reaches is a *count* of +//! developed grains, and a count of independent yes/no events has a variance +//! whether or not anybody wanted texture. +//! +//! Writing it down gives the look for free, rather than as a slider: +//! +//! ```text +//! p = D / Dmax the chance one grain develops +//! N grains in this pixel's patch of film +//! mean = D +//! variance = D · (Dmax − u · D) / N +//! ``` +//! +//! The variance peaks near the middle of the density range and vanishes at +//! both ends — clear film has nothing to develop, fully black film has nothing +//! left undeveloped. That is why grain lives in the midtones, and it is a +//! consequence here rather than a "midtone bias" control. +//! +//! # Why the zoom problem is not a problem +//! +//! `N` is grains *per pixel*, so it scales with how much film a pixel covers. +//! Zoomed out, each pixel averages more grains and the variance falls, which +//! is exactly what happens when you look at a print from further away. Nothing +//! has to be super-sampled and nothing has to be filtered: the model is already +//! a function of scale, and [`Grain::for_pixel_size`] is where the scale +//! enters. +//! +//! # Where crystal habit lives +//! +//! In [`crate::profile::Profile::rms_granularity`], the number every datasheet +//! publishes. It is a measurement of exactly the thing that differs between a +//! traditional cubic emulsion and a tabular one: for the same speed, tabular +//! crystals present more area per unit of silver, so fewer, flatter grains +//! cover the frame and the film reads finer. Ilford Delta 100 is quoted around +//! 9 where HP5 Plus is around 12, and that gap *is* the crystal habit. +//! +//! So a stock's grain character needs no new model and no new code — it is one +//! number in the profile, taken from the manufacturer's own measurement. + +use crate::profile::Profile; + +/// The aperture RMS granularity is defined against: a 48 µm circle. +/// +/// Fixed by the measurement, not by us. Every published granularity figure is +/// a standard deviation of density read through this aperture, so inverting it +/// for a particle area needs the same one. +const RMS_APERTURE_AREA_UM2: f32 = std::f32::consts::PI * 24.0 * 24.0; + +/// The net density the granularity figure is quoted at. +const RMS_REFERENCE_NET_DENSITY: f32 = 1.0; + +/// A 35 mm frame's width, in micrometres. +/// +/// What turns a pixel count into a grain size. A photograph has no inherent +/// film format, so simulating one means choosing what the frame *would have +/// been*; 35 mm is the choice that makes the numbers mean what a photographer +/// expects, since published granularity and every intuition about how grainy a +/// stock looks come from 35 mm. +pub const FRAME_WIDTH_UM: f32 = 36_000.0; + +/// TRACES: FR-DEV-3f +/// What the shader needs to add grain to a density. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Grain { + /// Grains in one pixel's patch of film, per layer. The whole scale + /// dependence is in here. + pub particles: [f32; 3], + /// Each layer's maximum density — the ceiling `p = D/Dmax` is taken + /// against. + pub density_max: [f32; 3], + /// How uniformly grains develop. Just below 1: a real emulsion's grains + /// are not identical, and at exactly 1 the variance expression collapses + /// at the top of the curve. + pub uniformity: f32, +} + +impl Grain { + /// Derive the parameters for a stock at a given sampling scale. + /// + /// `pixel_size_um` is the film distance one rendered pixel covers. It is + /// the only argument that changes with zoom, and it is what makes a + /// preview show less grain than a 100% view without anything being + /// filtered. + pub fn for_pixel_size(profile: &Profile, pixel_size_um: f32) -> Self { + let density_max = profile.max_density_per_layer(); + let density_min = profile.min_density_per_layer(); + let uniformity = profile.grain_uniformity; + + let pixel_area = (pixel_size_um * pixel_size_um).max(1e-6); + let mut particles = [0.0f32; 3]; + for layer in 0..3 { + // Invert the granularity definition for the area of one grain: + // + // sigma48^2 = D_ref (Dmax - u D_ref) a / A48 + // + // then count how many of those fit in a pixel. A stock with a + // *lower* published granularity has smaller grains, so more of + // them per pixel, so less variance -- which is the whole content + // of the number. + let sigma48 = profile.rms_granularity[layer] / 1000.0; + let d_ref = RMS_REFERENCE_NET_DENSITY + density_min[layer]; + let denom = (d_ref * (density_max[layer] - uniformity * d_ref)).max(1e-6); + let area = (sigma48 * sigma48 * RMS_APERTURE_AREA_UM2 / denom).max(1e-9); + + // At least one grain per pixel. Below that the model is describing + // a pixel smaller than a single crystal, where "how many developed" + // stops being a useful question and the variance would run away. + particles[layer] = (pixel_area / area).max(1.0); + } + + Self { + particles, + density_max, + uniformity, + } + } + + /// The standard deviation of the developed density at density `d`. + /// + /// The shader evaluates this per pixel; it is here so the property can be + /// asserted without a device. + pub fn sigma(&self, layer: usize, d: f32) -> f32 { + let dmax = self.density_max[layer]; + let d = d.clamp(0.0, dmax); + let var = d * (dmax - self.uniformity * d) / self.particles[layer]; + var.max(0.0).sqrt() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn portra() -> Profile { + Profile::parse(include_str!("../profiles/kodak_portra_400.yaml")).unwrap() + } + + fn trix() -> Profile { + Profile::parse(include_str!("../profiles/kodak_trix.yaml")).unwrap() + } + + #[test] + fn grain_vanishes_at_both_ends_and_peaks_between() { + // The property that makes this a model rather than a texture: clear + // film has nothing developed to vary, and fully developed film has + // nothing left to develop. Everything interesting is in the middle. + let g = Grain::for_pixel_size(&portra(), 6.6); + let dmax = g.density_max[1]; + let clear = g.sigma(1, 0.0); + let mid = g.sigma(1, dmax * 0.5); + let full = g.sigma(1, dmax); + assert!(clear < mid && full < mid, "{clear} {mid} {full}"); + assert!(clear < 1e-6, "clear film is not grainless: {clear}"); + } + + #[test] + fn a_smaller_pixel_sees_more_grain() { + // The scale dependence, and the reason nothing needs super-sampling: a + // pixel covering less film averages fewer grains, so it is noisier. A + // model that got this backwards would show *more* grain in a zoomed-out + // preview than in the export. + let p = portra(); + let close = Grain::for_pixel_size(&p, 3.0); + let far = Grain::for_pixel_size(&p, 12.0); + let d = close.density_max[1] * 0.5; + assert!( + close.sigma(1, d) > far.sigma(1, d), + "close {} far {}", + close.sigma(1, d), + far.sigma(1, d) + ); + } + + #[test] + fn halving_the_pixel_halves_the_grain() { + // Not merely monotone: variance goes as 1/N and N goes as area, so + // sigma goes as 1/pixel_size. Asserted because the exponent is what + // decides whether a preview at half scale looks like the export. + let p = portra(); + let a = Grain::for_pixel_size(&p, 4.0); + let b = Grain::for_pixel_size(&p, 8.0); + let d = a.density_max[1] * 0.5; + let ratio = a.sigma(1, d) / b.sigma(1, d); + assert!( + (ratio - 2.0).abs() < 0.05, + "sigma ratio {ratio}, expected 2" + ); + } + + #[test] + fn a_coarser_stock_is_grainier_at_the_same_scale() { + // What the granularity number is for, and what will carry crystal + // habit when a tabular stock arrives: the same pixel, the same + // density, a coarser emulsion, more noise. + let mut fine = portra(); + let mut coarse = portra(); + fine.rms_granularity = [4.0; 3]; + coarse.rms_granularity = [16.0; 3]; + + let d = fine.max_density_per_layer()[1] * 0.5; + let f = Grain::for_pixel_size(&fine, 6.6).sigma(1, d); + let c = Grain::for_pixel_size(&coarse, 6.6).sigma(1, d); + assert!(c > f * 2.0, "fine {f} coarse {c}"); + } + + #[test] + fn a_monochrome_stock_grains_every_layer_alike() { + // Its three layers are one emulsion spread three ways, so a difference + // between them would be grain the film does not have — and it would + // show as colour speckle on a black and white photograph, which is the + // most obvious way this could look wrong. + let g = Grain::for_pixel_size(&trix(), 6.6); + let d = g.density_max[0] * 0.5; + let s: Vec = (0..3).map(|l| g.sigma(l, d)).collect(); + assert!( + (s[0] - s[1]).abs() < 1e-5 && (s[1] - s[2]).abs() < 1e-5, + "monochrome grain differs by layer: {s:?}" + ); + } + + #[test] + fn a_pixel_never_holds_less_than_one_grain() { + // Past this the model describes a pixel smaller than a crystal, where + // the variance runs away and the picture fills with salt and pepper. + let g = Grain::for_pixel_size(&portra(), 0.001); + assert!(g.particles.iter().all(|n| *n >= 1.0), "{:?}", g.particles); + } +} diff --git a/core/dr-film/src/lib.rs b/core/dr-film/src/lib.rs index ea130d7..25335ab 100644 --- a/core/dr-film/src/lib.rs +++ b/core/dr-film/src/lib.rs @@ -39,11 +39,13 @@ pub mod bake; mod built_in; +pub mod grain; pub mod profile; pub mod spectrum; pub mod tables; pub use bake::{bake, Baked, Recipe}; +pub use grain::Grain; pub use profile::{Kind, Profile, Stage, Support}; use built_in::BUILT_IN; diff --git a/core/dr-film/src/profile.rs b/core/dr-film/src/profile.rs index cfd481a..fccfbcf 100644 --- a/core/dr-film/src/profile.rs +++ b/core/dr-film/src/profile.rs @@ -97,6 +97,28 @@ pub struct Profile { /// so a picker can say "black and white" without inspecting the numbers. #[serde(default)] pub monochrome: bool, + /// TRACES: FR-DEV-3f + /// RMS granularity per layer, as the datasheet publishes it. + /// + /// The standard deviation of density read through a 48 µm aperture, times + /// a thousand. It is the one number that carries a stock's grain + /// character, and **it is where crystal habit lives**: a tabular emulsion + /// presents more area per unit of silver than a cubic one, so at equal + /// speed it reads finer and its published figure is lower. Adding a stock + /// whose grain is its whole reputation is therefore editing one line. + /// + /// Defaults are a middling colour negative's, blue coarsest — the top + /// layer of the stack is, in every film. A stock that has not been given + /// its own figure grains plausibly rather than not at all. + #[serde(default = "default_granularity")] + pub rms_granularity: [f32; 3], + /// How uniformly grains develop. + /// + /// Just under one. At exactly one the variance expression collapses at the + /// top of the curve, and a real emulsion's crystals are not identical + /// anyway. + #[serde(default = "default_uniformity")] + pub grain_uniformity: f32, /// The light the sensitivity data was measured under. pub reference_illuminant: String, /// The light the developed result is meant to be looked at under. @@ -128,11 +150,34 @@ fn filming() -> Stage { Stage::Filming } +fn default_granularity() -> [f32; 3] { + [6.0, 8.0, 10.0] +} + +fn default_uniformity() -> f32 { + 0.97 +} + impl Profile { /// Read a profile from YAML. pub fn parse(yaml: &str) -> Result { - let profile: Profile = serde_norway::from_str(yaml).map_err(|e| e.to_string())?; + let mut profile: Profile = serde_norway::from_str(yaml).map_err(|e| e.to_string())?; profile.validate()?; + + // TRACES: FR-DEV-3f + // A monochrome stock has one emulsion, so its three layers must agree + // about grain. The default granularity does not — it is a colour + // negative's, blue coarsest — and left alone it would put *colour* + // speckle on a black and white photograph, which is both wrong and + // the most visible way this could be wrong. + // + // Collapsed here rather than asked of whoever writes the profile: + // every other per-layer table is already replicated from one measured + // channel at conversion, and this is the same fact arriving by a + // different door. + if profile.monochrome { + profile.rms_granularity = [profile.rms_granularity[0]; 3]; + } Ok(profile) } @@ -196,6 +241,34 @@ impl Profile { out } + /// The largest density each layer reaches, separately. + /// + /// Per layer rather than pooled, because grain is a per-layer count and + /// pooling would give the shadow layers the ceiling of the deepest one — + /// which reads as too little grain in exactly the channel carrying most of + /// it. + pub fn max_density_per_layer(&self) -> [f32; 3] { + let mut out = [0.0f32; 3]; + for row in &self.density_curves { + for (c, slot) in out.iter_mut().enumerate() { + *slot = slot.max(row[c]); + } + } + // A degenerate curve would otherwise divide by zero downstream. + out.map(|v| v.max(1e-3)) + } + + /// The smallest density each layer reaches — the stock's own fog. + pub fn min_density_per_layer(&self) -> [f32; 3] { + let mut out = [f32::MAX; 3]; + for row in &self.density_curves { + for (c, slot) in out.iter_mut().enumerate() { + *slot = slot.min(row[c]); + } + } + out.map(|v| v.max(0.0)) + } + /// The largest density any layer reaches. The 3D LUT's upper bound. pub fn max_density(&self) -> f32 { self.density_curves diff --git a/core/dr-gpu/src/adjust.rs b/core/dr-gpu/src/adjust.rs index 69734ec..1e3bac7 100644 --- a/core/dr-gpu/src/adjust.rs +++ b/core/dr-gpu/src/adjust.rs @@ -1716,6 +1716,9 @@ mod tests { lut: vec![[0.5, 0.5, 0.5]; N * N * N], density_max: 3.0, lut_size: N, + grain_particles: [0.0; 3], + grain_density_max: [3.0; 3], + grain_uniformity: 0.97, } } diff --git a/core/dr-gpu/tests/film_sim.rs b/core/dr-gpu/tests/film_sim.rs index 1c0fb21..49fda1a 100644 --- a/core/dr-gpu/tests/film_sim.rs +++ b/core/dr-gpu/tests/film_sim.rs @@ -62,6 +62,17 @@ fn flat_raw(level: u16) -> RawImage { /// purpose, and a field pasted into the wrong slot here is invisible until /// pixels come back wrong. fn tables(baked: &dr_film::Baked) -> FilmTables { + tables_with_grain(baked, [0.0; 3]) +} + +/// The same, with grain switched on at a chosen particle count. +/// +/// Grain is *stochastic*, so a grained render cannot be compared against the +/// CPU model pixel for pixel — the comparison below therefore runs with it off, +/// and `grain_reaches_the_shader` is what says it is wired at all. Without that +/// split a grain that never left the CPU would look exactly like a passing +/// test suite. +fn tables_with_grain(baked: &dr_film::Baked, particles: [f32; 3]) -> FilmTables { FilmTables { exposure_matrix: baked.exposure_matrix, curves: baked.curves.clone(), @@ -70,23 +81,21 @@ fn tables(baked: &dr_film::Baked) -> FilmTables { lut: baked.lut.clone(), density_max: baked.density_max, lut_size: baked.lut_size, + grain_particles: particles, + grain_density_max: [baked.density_max; 3], + grain_uniformity: 0.97, } } -/// A name for the baked stock. -/// -/// The id is not carried on `Baked` — it is the *recipe's*, and a bake is a -/// pile of numbers. The tests here only need the graph to hold something, and -/// what it holds is checked by the sidecar's own tests rather than by pixels. -fn film_stock_of(_baked: &dr_film::Baked) -> &'static str { - "under_test" -} - /// Render a flat frame through a stock and return the centre pixel, 0..1. /// /// The centre rather than a corner: a demosaic invents its edges, and the /// border of a 16x16 frame is not where anyone should read a tone off. fn rendered(ctx: &GpuContext, level: u16, baked: &dr_film::Baked) -> [f32; 3] { + rendered_with(ctx, level, tables(baked)) +} + +fn rendered_with(ctx: &GpuContext, level: u16, tables: FilmTables) -> [f32; 3] { let source = Demosaicer::new(ctx) .expect("demosaicer") .run(&flat_raw(level)) @@ -94,14 +103,14 @@ fn rendered(ctx: &GpuContext, level: u16, baked: &dr_film::Baked) -> [f32; 3] { let mut graph = EditGraph::default_chain(); graph.set_film(Some(dr_pipeline::graph::Film { - stock: film_stock_of(baked).to_string(), + stock: "under_test".to_string(), print: None, - tables: tables(baked), + tables: tables.clone(), })); let shader = graph.compose(); let mut adjust = AdjustPass::new(ctx); - adjust.set_film(Some(&tables(baked))); + adjust.set_film(Some(&tables)); adjust.render(&source, &shader, SIZE, SIZE).expect("render"); let (pixels, _, _) = adjust.export_pixels().expect("readback"); @@ -176,3 +185,55 @@ fn a_negative_and_its_print_are_not_the_same_picture() { "the print of a neutral is not neutral: {printed:?}" ); } + +#[test] +fn grain_reaches_the_shader_and_scales_with_the_pixel() { + // TRACES: FR-DEV-3f + // Two claims the CPU tests cannot make, because both are about the shader: + // that grain is applied at all, and that fewer grains per pixel means more + // of it. A flat frame is the right probe — every pixel is handed the same + // density, so anything that differs between them is grain and nothing else. + let Some(ctx) = ctx() else { + eprintln!("no GPU adapter; skipping"); + return; + }; + let film = dr_film::find("kodak_kodachrome_64").expect("stock"); + let baked = bake(&Recipe::new(film, None)); + + let spread = |particles: [f32; 3]| { + let t = tables_with_grain(&baked, particles); + let mut lo = f32::MAX; + let mut hi = f32::MIN; + // Several pixels of one flat render, not several renders: the hash is + // seeded by position, so this reads the variation across the frame. + for level in [12_000u16, 12_000, 12_000] { + let px = rendered_with(&ctx, level, t.clone()); + lo = lo.min(px[1]); + hi = hi.max(px[1]); + } + (lo, hi) + }; + + let none = spread([0.0; 3]); + assert!( + (none.1 - none.0).abs() < 1e-6, + "grain is being applied when it was switched off: {none:?}" + ); + + // A single grain per pixel is the noisiest the model goes; ten thousand is + // effectively smooth. If the uniform never arrived, these would agree. + let coarse = rendered_with(&ctx, 12_000, tables_with_grain(&baked, [1.0; 3])); + let fine = rendered_with(&ctx, 12_000, tables_with_grain(&baked, [10_000.0; 3])); + let ungrained = rendered_with(&ctx, 12_000, tables_with_grain(&baked, [0.0; 3])); + + let coarse_err = (coarse[1] - ungrained[1]).abs(); + let fine_err = (fine[1] - ungrained[1]).abs(); + assert!( + coarse_err > fine_err, + "grain did not scale with the particle count: coarse {coarse_err}, fine {fine_err}" + ); + assert!( + coarse_err > 1e-4, + "grain never reached the shader: the coarsest setting moved the pixel by {coarse_err}" + ); +} diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index 960fe69..38f7ea4 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -119,6 +119,9 @@ mod tests { lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32], density_max: 3.0, lut_size: 32, + grain_particles: [0.0; 3], + grain_density_max: [3.0; 3], + grain_uniformity: 0.97, }, })); g diff --git a/core/dr-pipeline/src/operation.rs b/core/dr-pipeline/src/operation.rs index 69a652c..1ba1764 100644 --- a/core/dr-pipeline/src/operation.rs +++ b/core/dr-pipeline/src/operation.rs @@ -1011,6 +1011,16 @@ pub(crate) fn sample_source(interpolate: bool) -> &'static str { return; } + // TRACES: FR-DEV-3f + // Where this pixel sits on the *source*, in source pixels. Published for + // fragments that need a position and not only a colour. + // + // The source and not the output, and that is the whole point: a pattern + // seeded from the render swims as the photograph is zoomed, and grain is a + // property of the film rather than of the view. Seeded from here it stays + // put, and its *amount* is handled separately by how much film a pixel + // covers -- see `dr_film::Grain`. + let source_px = uv_src * vec2(src_dims); // A free angle puts output pixels between source pixels. Nearest-neighbour // here is what makes a straightened horizon stair-step, so interpolate. var c = sample_bilinear(uv_src, src_dims); @@ -1030,6 +1040,16 @@ pub(crate) fn sample_source(interpolate: bool) -> &'static str { // Every output pixel lands on a source pixel, so load it directly: exact, // and with no interpolation to soften detail. let coord = min(vec2(uv_src * vec2(src_dims)), vec2(src_dims) - vec2(1)); + // TRACES: FR-DEV-3f + // Where this pixel sits on the *source*, in source pixels. Published for + // fragments that need a position and not only a colour. + // + // The source and not the output, and that is the whole point: a pattern + // seeded from the render swims as the photograph is zoomed, and grain is a + // property of the film rather than of the view. Seeded from here it stays + // put, and its *amount* is handled separately by how much film a pixel + // covers -- see `dr_film::Grain`. + let source_px = uv_src * vec2(src_dims); var c = textureLoad(source, coord, 0).rgb; " } @@ -1403,6 +1423,9 @@ mod tests { lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32], density_max: 3.0, lut_size: 32, + grain_particles: [0.0; 3], + grain_density_max: [3.0; 3], + grain_uniformity: 0.97, })); assert!(film.is_active(), "the fixture did not load"); diff --git a/core/dr-pipeline/src/ops/film_sim.rs b/core/dr-pipeline/src/ops/film_sim.rs index 3700dc5..fff33e8 100644 --- a/core/dr-pipeline/src/ops/film_sim.rs +++ b/core/dr-pipeline/src/ops/film_sim.rs @@ -93,6 +93,13 @@ pub struct FilmTables { pub lut: Vec<[f32; 3]>, pub density_max: f32, pub lut_size: usize, + /// TRACES: FR-DEV-3f + /// Grains in one pixel's patch of film, per layer, with the density + /// ceiling and uniformity the variance is taken against. Zero particles + /// means no grain, which is how the control is turned off. + pub grain_particles: [f32; 3], + pub grain_density_max: [f32; 3], + pub grain_uniformity: f32, } impl FilmTables { @@ -207,6 +214,22 @@ impl Operation for FilmSim { }); } } + for (l, name) in ["gn0", "gn1", "gn2"].into_iter().enumerate() { + out.push(Uniform { + name, + value: t.grain_particles[l], + }); + } + for (l, name) in ["gd0", "gd1", "gd2"].into_iter().enumerate() { + out.push(Uniform { + name, + value: t.grain_density_max[l], + }); + } + out.push(Uniform { + name: "grain_u", + value: t.grain_uniformity, + }); out.push(Uniform { name: "log_min", value: t.curve_log_min, @@ -265,10 +288,23 @@ let log_exposure = log10(max(exposure, vec3(0.0)) + 1e-10); let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min), vec3(0.0), vec3(1.0))); +// TRACES: FR-DEV-3f +// Grain, on the density and before the dye. +// +// That order is the physical one and it is not cosmetic: grain is silver that +// did or did not develop, so it perturbs *density*, and the dye absorbs +// through whatever density resulted. Adding noise to the finished colour -- +// which is what an effect does -- tints the highlights wrong, because that +// noise never passes through the dye at all. +let grained = film_grain(density, source_px, + vec3(gn0, gn1, gn2), + vec3(gd0, gd1, gd2), + grain_u); + // Dye absorption, the print through the negative, the paper, the viewing // illuminant and the chromatic adaptation — all of which take exactly three // numbers in, which is why they fit in one lookup. -c = film_lut(clamp(density / density_max, vec3(0.0), vec3(1.0)), lut_size);" +c = film_lut(clamp(grained / density_max, vec3(0.0), vec3(1.0)), lut_size);" .into() } @@ -277,7 +313,85 @@ c = film_lut(clamp(density / density_max, vec3(0.0), vec3(1.0)), lut_s } } -static HELPERS: [crate::operation::Helper; 3] = [ +static HELPERS: [crate::operation::Helper; 5] = [ + crate::operation::Helper { + name: "film_hash", + source: "\ +// A hash, not a random number generator: the same pixel of the same frame has +// to grain the same way every time it is drawn, or the picture would crawl +// while nobody was editing it. Seeded from a position, so it is reproducible +// by construction rather than by holding state between frames. +// +// Two decorrelated uniforms come out, which is what a Gaussian needs. +fn film_hash(p: vec2, 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: "\ @@ -349,6 +463,9 @@ mod tests { lut: vec![[0.5, 0.5, 0.5]; 32 * 32 * 32], density_max: 3.0, lut_size: 32, + grain_particles: [0.0; 3], + grain_density_max: [3.0; 3], + grain_uniformity: 0.97, } } diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 4c7ade5..df765fd 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -2385,6 +2385,19 @@ impl DevelopSession { } else { None }; + // TRACES: FR-DEV-3f + // Grain, at the scale this photograph is being sampled at. + // + // A digital frame has no film format, so simulating one means choosing + // what it *would have been* — 35 mm, because that is the format every + // published granularity figure and every intuition about how grainy a + // stock looks comes from. The sensor's width in pixels then says how + // much film one pixel covers, and the grain model needs nothing else + // to be correct at any zoom. + let (source_width, _) = self.demosaiced.size(); + let pixel_size_um = dr_film::grain::FRAME_WIDTH_UM / source_width.max(1) as f32; + let grain = dr_film::Grain::for_pixel_size(profile, pixel_size_um); + let baked = dr_film::bake(&dr_film::Recipe { film: profile, print: paper, @@ -2415,6 +2428,9 @@ impl DevelopSession { lut: baked.lut, density_max: baked.density_max, lut_size: baked.lut_size, + grain_particles: grain.particles, + grain_density_max: grain.density_max, + grain_uniformity: grain.uniformity, }, })); }