//! 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`]. It measures 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. A //! stock's grain character therefore needs no new model and no new code — it //! is one number in the profile. //! //! # Getting that number is the hard part //! //! It is **not** simply "what the datasheet says", and assuming so is a trap //! worth naming: //! //! - **Kodak colour negatives publish Print Grain Index, not RMS.** PGI is a //! perceptual scale from viewer surveys — 25 is roughly the threshold of //! visibility and four units is one just-noticeable difference — and Kodak //! state it *cannot* be compared to RMS granularity. There is no published //! conversion, so a PGI figure cannot be dropped into this field. //! - **Ilford publish no granularity figure at all.** Their technical sheets //! describe grain only in words ("fine grain", "finest grain"); the word //! granularity does not appear in them. Spectral sensitivity and the //! characteristic curves are there, but as graphs. //! - RMS granularity proper is published mostly for black-and-white, reversal //! and motion-picture stocks. //! //! So the shipped defaults are plausible rather than measured, and every stock //! currently carries the same ones — which means **grain does not yet tell one //! stock from another**. Fixing that is per-stock data, not code, and for the //! films whose figures are unpublished it needs a defensible estimate rather //! than a number copied off a page. 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); } }