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