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