Lutra's film grain
Luminance-dependent, multi-scale, chromatic grain — how silver halide physics became a WGSL compute shader
“Film grain is the very mechanism with which film gives us its image, and is well deserving of some careful crafting.”
Most digital grain is noise. It is uniform, symmetric, and independent of the image it sits on. Real film grain is none of those things: it varies with brightness, clusters at multiple scales, carries subtle color, and is the image itself — not an overlay. Lutra's grain shader models these properties so that the texture you see is shaped by the same physics that shaped analog film for a century. This document walks through the algorithm from the pixel-level math up to the five film-stock profiles, assuming you've read architecture.html and know what a compute pass is.
0 · Why grain is not noise
A photograph on film is formed by silver halide crystals. When photons hit a crystal, it becomes developable — a binary event, on or off. The grain you see is the spatial distribution of these binary events, averaged over many tiny crystals by the human eye into the perception of texture. Three properties of this process make film grain fundamentally different from digital noise:
- Grain is density-dependent. In highlights (maximum silver accumulation on the negative), grain exists but is less visible because the bright background masks it. In midtones, grain peaks perceptually — moderate crystal density against high perceptual contrast. In shadows, grain appears again because the print media's own grain becomes visible through the negative's clear areas. Deep below ~3% luminance, grain vanishes entirely.
- Grain clusters at multiple scales. Individual silver halide crystals are microscopic (0.5–3 μm), but they form conglomerations — tiny grains don't exist alone. The texture you see is a hierarchy: fine crystals grouping into larger visible structures.
- Grain carries color. Color film has separate dye layers (blue, green, red), each with its own grain structure. The red and blue channels have independent noise at a coarser scale than the luminance grain — subtle warm/cool shifts that digital noise, which is correlated across channels, never produces.
1 · The luminance curve
The single most important piece of the grain shader is the luminance weight: how much grain to add at each brightness level. This is an asymmetric curve validated against real film reference scans and the AV1 film grain synthesis specification (Figure 5 of the AOMedia technical report):
fn grainLumaWeight(luma: f32, peak: f32, rolloff: f32) -> f32 {
// Highlight side: tight Gaussian (35% of base rolloff)
// Shadow side: power law ramp with smoothstep cutoff near true black
let r = select(rolloff, rolloff * 0.35, luma > peak);
let d = (luma - peak) / r;
let bell = exp(-0.5 * d * d);
let shadow = min(pow(luma / max(peak, 0.001), 0.18), 1.0)
* smoothstep(0.0, 0.03, luma);
return bell * shadow;
}
Three pieces make up the curve:
-
Bell (Gaussian). Centered at
peak(configurable per profile, typically 0.35–0.42), with different widths on each side. The highlight side usesrolloff × 0.35— a tight Gaussian so grain disappears quickly above the peak. The shadow side uses the fullrolloff— a wider ramp. -
Shadow power law.
pow(luma / peak, 0.18)— a gentle ramp that rises from zero at black toward 1 at the peak. The 0.18 exponent is validated against real film scans: it matches the characteristic curve (Hurter–Driffield) of how grain density grows with exposure in the shadow region. -
Smoothstep cutoff.
smoothstep(0.0, 0.03, luma)— a hard kill below 3% luminance. This prevents grain from lifting true blacks above zero, which would look like a lifted black point rather than film texture.
The curve is asymmetric by design. Real film grain is asymmetric: highlights always
contain grain (maximum silver accumulation on the negative), but it is less visible because
the bright background masks it. Shadows contain grain from the print media's own texture.
The Gaussian/power-law split captures both behaviors with two parameters (peak
and rolloff) that map directly to film stock properties.This is the luminance dependence you see in Fuji cameras' grain effect and in the AV1
film grain synthesis spec. Fuji's exact implementation is undocumented (their patent
describes M-sequence procedural generation, not a static texture), but the perceptual
behavior — grain that breathes with brightness — is the same physical phenomenon.
2 · Multi-scale value noise
Grain is generated as smooth value noise: random values on a coarser grid, interpolated between grid points with quintic easing (C² continuous — no visible grid edges). Two layers run simultaneously:
fn grainSample(pos: vec2<f32>, frame: u32, grainSize: f32, blurAmt: f32) -> f32 {
let fine = grainNoise(pos, frame);
let coarse = grainNoise(pos * 0.667, frame + 17u);
return mix(fine, coarse, blurAmt);
}
-
Fine layer at
grainSize— the primary grain texture, with cell size log-scaled from ~0.3 px (fine) to ~1.5 px (coarse). -
Coarse layer at
grainSize × 1.5(0.67× frequency) — adds the organic clustering of real silver halide conglomerations.
The coarse layer is mixed in by the profile's blur parameter. More blur means
more coarse layer — softer, cloudier grain. Less blur means crisp, fine-grained texture. The
two layers are seeded from the same frame counter but decorrelated (different coordinate
offsets and frame seeds) so they shimmer independently.
The hash function is integer-only — u32 multiplies and xor-shifts, no
transcendentals — per ADR-0025. The quintic easing (t³ · (t · (6t − 15) + 10))
produces smoother interpolation than the cubic alternative, which matters at coarse grain
sizes where grid artifacts would otherwise show.
Why not Perlin noise or FBM?
The previous implementation used 3-octave FBM (fractal Brownian motion) — the standard approach for procedural textures. FBM is designed for terrain: self-similar detail at every scale. Film grain is not self-similar. It has two distinct scales — fine crystals and coarse clusters — with a specific ratio between them. Multi-scale value noise with a controlled mix ratio models this more accurately than an FBM cascade, and the simpler structure is cheaper to evaluate.
3 · Chromatic grain
Real color film has separate dye layers, each with its own grain structure. The red and blue channels are noisier than green (fewer silver halide crystals per unit area in those layers) and the noise is coarser — dye clouds are larger than the silver crystals that formed them. The shader reproduces this:
// Chromatic grain: separate R and B noise at 1.8× grain size
let chromaAmt = l0_chroma * grainAmt;
if (chromaAmt > 0.001) {
let chromaSize = baseSize * 0.56; // 1/1.8 — coarser frequency
let cf = 0.6667 * pow(0.15, chromaSize);
let cr = (grainNoise(vec2<f32>(coord) * cf + vec2<f32>(3.7, 11.3), u_frame + 31u) - 0.5) * 2.0;
let cb = (grainNoise(vec2<f32>(coord) * cf + vec2<f32>(7.1, 15.9), u_frame + 57u) - 0.5) * 2.0;
color.r += cr * chromaAmt;
color.b += cb * chromaAmt;
}
The chroma slider (0–1, default 0.2) controls how much color variation appears
in the grain. At 0, grain is monochrome — the same noise added to all channels equally (like
a scan of black-and-white film). At 1, the red and blue channels get independent coarser
noise, producing the subtle warm/cool color shifts you see in color negative film. Green is
left alone — it carries the luminance signal and should stay clean.
The 1.8× scale factor comes from the physical reality that dye clouds are larger than
silver crystals. On the shader side, it means the chromatic noise frequency is
baseSize × 0.56 (the inverse of 1.8), so color grain is visibly coarser
than luminance grain — exactly as it should be.
4 · Film stock profiles
Different film stocks have different grain character: fine-grained slide film behaves differently from pushed high-ISO negative stock. The shader encodes five profiles, each pre-setting the grain size, luminance peak, rolloff width, and blur (coarse/fine mix):
| Profile | Grain Size | Peak | Rolloff | Blur | Character |
|---|---|---|---|---|---|
| Subtle | 0.30 | 0.40 | 0.35 | 0.60 | Fine 35mm, low intensity, gentle shadow rolloff |
| Medium | 0.50 | 0.38 | 0.40 | 0.55 | Balanced 35mm, peak in midtones (default) |
| Heavy | 0.70 | 0.35 | 0.50 | 0.45 | Pushed film, visible grain in shadows |
| Vintage | 0.80 | 0.42 | 0.55 | 0.35 | Soft, warm chroma, coarse grain, wide rolloff |
| Cinematic | 1.00 | 0.36 | 0.45 | 0.40 | Coarse 16mm, high chroma, tight highlight rolloff |
The profile is selected by the profile uniform (a float 0–4, rounded to int in
the shader). The size slider provides a manual override: when non-zero, it
blends the profile's grain size toward the user's value, so you can tune without switching
profiles. The amount slider controls overall strength independently of
character.
amount and size sliders give
enough manual control for fine-tuning; the profile handles the rest.
5 · Putting it together
The full grain pass, in order of operations:
-
Look up the profile. Round
profileto an integer, fetch[grainSize, peak, rolloff, blur]. -
Resolve grain size. If
size > 0, blend the profile's grain size toward0.2 + size × 1.3. Otherwise use the profile default. -
Compute noise frequency.
f = 0.6667 × pow(0.15, baseSize)— log scale from fine to coarse. - Sample fine + coarse noise. Two value-noise layers at different frequencies, mixed by the profile's blur.
- Apply luminance weight. The asymmetric bell × power law × smoothstep curve, evaluated at the pixel's Rec. 709 luma.
-
Add luma grain.
color += noise × amount × 0.15 × weight— same offset to all channels. -
Add chromatic grain. Separate R and B noise at 1.8× scale, scaled
by
chroma × amount × 0.15 × weight. - Clamp. Prevent negative values from dark-channel clipping.
The 0.15 amplitude constant means full strength adds ±0.15 linear at midtone (≈±14 sRGB levels) — enough to be visible without dominating the image. Everything is in linear light, so the display curve stretches dark values: the same linear amplitude looks larger in shadows on screen, which is physically correct (film grain is more visible in shadows).
6 · What changed from the old grain
| Aspect | Before | After |
|---|---|---|
| Luminance curve | Symmetric triangle: max(1 − |L−0.5| × 1.4, 0.35) |
Asymmetric: Gaussian highlights + power-law shadows + smoothstep at 3% |
| Noise structure | 3-octave FBM, same for all channels | Multi-scale: fine + coarse (0.67×) value noise layers |
| Color grain | None (monochrome) | Separate R/B noise at 1.8× grain size |
| Parameters |
texture (strength), size (cell),
blur (persistence)
|
amount (strength), profile (preset),
size (override), chroma (color)
|
| Film stock presets | None | 5 profiles: Subtle, Medium, Heavy, Vintage, Cinematic |
The old grain's symmetric midtone weight treated shadows and highlights identically — grain faded equally toward both extremes. The new asymmetric curve captures the physical reality that grain persists in shadows (print media grain) while vanishing in highlights (no undeveloped silver at Dmax). This is the difference between "noise added to an image" and "grain that the image is made of."
7 · Reference
| Constant | Value | Notes |
|---|---|---|
| Grain amplitude | ±0.15 linear at amount 1, midtone | ≈±14 sRGB levels |
| Shadow power-law exponent | 0.18 | validated against AV1 spec Figure 5 and real film scans |
| Highlight Gaussian width | rolloff × 0.35 | tight rolloff — grain vanishes quickly above peak |
| Smoothstep kill | 0.0 – 0.03 luma | prevents black-point lift |
| Chromatic grain scale | 1.8× luma grain size | dye clouds are larger than silver crystals |
| Coarse layer frequency | 0.67× fine (1.5× size) | organic clustering ratio |
| Luma coefficients | (0.2126, 0.7152, 0.0722) | Rec. 709, same as all other effects |
| Animation | u_frame (binding 3) |
hash-seeded per frame; grain shimmers naturally |
Sources
- Newson et al. (2017) — A Stochastic Film Grain Model for Resolution-Independent Rendering (PDF)
- Aurélien Pierre (2023) — Stochastic photographic grain synthesis from crystallographic structure simulation (article)
- cinegrain — GLSL film grain shader for mpv (GitHub)
- AOMedia — Technical report on film grain synthesis technology (PDF)
- Dehancer — How does film grain work (blog)