Count the silver instead of adding noise

An emulsion is a suspension of crystals. Light sensitises some; development
turns a sensitised one opaque, all or nothing. So a patch of film's density
is a *count* of developed grains, and a count of independent yes/no events
has a variance whether or not anyone wanted texture:

    mean     = D
    variance = D * (Dmax - u * D) / N

That expression is the whole feature. It peaks in the middle of the density
range and vanishes at both ends -- clear film has nothing developed to vary,
black film has nothing left to develop -- so grain lives in the midtones as a
consequence rather than as a "midtone bias" slider.

I was wrong earlier that this needs the detail stage. Nothing in it reads a
neighbouring pixel; the only reason to move it was that grain must be fixed in
film space rather than screen space, and that solves itself: N is grains *per
pixel*, so it scales with the film a pixel covers. Zoom out, each pixel
averages more grains, less variance -- correct, with nothing super-sampled and
nothing filtered. It stays in the fused pass.

Grain goes on the density and *before* the dye, which is the physical order
and not cosmetic. Perturbing the finished colour -- what an effect does --
tints highlights wrong, because that noise never passes through the dye.

Crystal habit lives in `rms_granularity`, the number every datasheet
publishes, now a profile field. It measures exactly what differs between a
cubic emulsion and a tabular one: at equal speed, tabular crystals present
more area per unit silver, so the film reads finer. Delta 100 is quoted near 9
where HP5 is near 12, and that gap *is* the habit. Adding a stock whose grain
is its whole reputation is therefore editing one line, not writing a model.

Three things this cost, all of them worth writing down:

  - The default granularity is a colour negative's, blue coarsest. Applied to
    Tri-X it put *colour* speckle on a black and white photograph. Monochrome
    stocks collapse it at parse, where every other per-layer table is already
    replicated from the one measured channel.
  - Helpers cannot read uniforms. The composer prefixes a uniform with its
    operation's id and rewrites references inside a fragment body only;
    helpers are shared and deduplicated, so a bare `gn0` names nothing.
    `film_lut` already took its size as an argument for this reason, and now
    says so.
  - The end-to-end test compares the shader against the CPU model, and grain
    is stochastic, so that comparison now runs with grain off. Which means a
    grain that never left the CPU would look exactly like a passing suite --
    hence a second test that grain off is bit-identical, one grain per pixel
    moves it, and ten thousand move it less.

Not here, deliberately: no grain slider. The parameters are physical and
`rms_granularity` is the honest place to scale one from, but its range wants
choosing rather than guessing. Nor a film format -- 35 mm is assumed, and
medium format at the same stock is far less grainy per unit of picture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 10:08:51 +02:00
co-authored by Claude Opus 5
parent 1d38015a7b
commit 4b2ee0ac50
10 changed files with 548 additions and 15 deletions
View File
+235
View File
@@ -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<f32> = (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);
}
}
+2
View File
@@ -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;
+74 -1
View File
@@ -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<Self, String> {
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
+3
View File
@@ -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,
}
}
+73 -12
View File
@@ -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}"
);
}
+3
View File
@@ -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
+23
View File
@@ -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<f32>(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<i32>(uv_src * vec2<f32>(src_dims)), vec2<i32>(src_dims) - vec2<i32>(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<f32>(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");
+119 -2
View File
@@ -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<f32>(0.0)) + 1e-10);
let density = film_curve(clamp((log_exposure - log_min) / (log_max - log_min),
vec3<f32>(0.0), vec3<f32>(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<f32>(gn0, gn1, gn2),
vec3<f32>(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<f32>(0.0), vec3<f32>(1.0)), lut_size);"
c = film_lut(clamp(grained / density_max, vec3<f32>(0.0), vec3<f32>(1.0)), lut_size);"
.into()
}
@@ -277,7 +313,85 @@ c = film_lut(clamp(density / density_max, vec3<f32>(0.0), vec3<f32>(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<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: "\
@@ -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,
}
}
+16
View File
@@ -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,
},
}));
}