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.”

— Denis Pătruț, Simple Physically-Based Film Grain Simulation

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:

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:

0 0.4 1.0 1 luminance peak shadows midtones highlights <3%
The luminance weight curve. Grain peaks in the midtones, fades sharply in the highlights (tight Gaussian), ramps gently in the shadows (power law γ = 0.18), and vanishes below 3% luminance (smoothstep kill).

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);
}

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.

Why profiles instead of more sliders. The luminance curve's parameters (peak, rolloff, power-law exponent, smoothstep threshold) are physically meaningful but not intuitively tunable by a photographer. A profile bundles them into a named character — "Heavy" or "Vintage" — that maps to a film stock's behavior without exposing the underlying math. The 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:

  1. Look up the profile. Round profile to an integer, fetch [grainSize, peak, rolloff, blur].
  2. Resolve grain size. If size > 0, blend the profile's grain size toward 0.2 + size × 1.3. Otherwise use the profile default.
  3. Compute noise frequency. f = 0.6667 × pow(0.15, baseSize) — log scale from fine to coarse.
  4. Sample fine + coarse noise. Two value-noise layers at different frequencies, mixed by the profile's blur.
  5. Apply luminance weight. The asymmetric bell × power law × smoothstep curve, evaluated at the pixel's Rec. 709 luma.
  6. Add luma grain. color += noise × amount × 0.15 × weight — same offset to all channels.
  7. Add chromatic grain. Separate R and B noise at 1.8× scale, scaled by chroma × amount × 0.15 × weight.
  8. 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