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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user