Film grain
How randomly distributed photosensitive grains become visible density variation after development — and how to approximate that moving, exposure-dependent texture without turning it into RGB sensor noise.
Visible effect
A changing texture in otherwise smooth density
Film grain appears as a fine, irregular texture that changes from frame to frame. In a uniform area, neighbouring patches do not transmit exactly the same amount of light; during projection or scanning, those local density differences become visible brightness variation.
Unlike electronic colour noise, the teaching approximation uses one density field for all three display channels. It therefore changes local exposure while largely preserving colour relationships. Grain is usually easier to notice in smooth regions and underexposed shadows than across detailed, well-exposed texture.
Physics
Discrete photosensitive sites leave statistical density variation
Silver-halide crystals are randomly distributed through an emulsion. Exposure creates a latent image at sensitised sites; development leaves metallic silver in monochrome film or dye clouds around the sites occupied by exposed crystals in colour film. The individual crystals are normally too small to resolve directly, but their statistical grouping produces visible graininess after enlargement.
Objective granularity is measured as density variation with a small microdensitometer aperture. It is not one fixed number across every exposure: stock speed, processing, image density, enlargement, scene detail, and the complete negative-to-print or scan path all affect what the viewer perceives.
Mathematics
Filtered random density at two spatial scales
Let v be smooth value noise, p a destination pixel coordinate, k a frame seed, and s the apparent grain size. The seed is first hashed into independent 2D lattice offsets o₁ and o₂; it is not added as one shared phase inside every random sample. Two scales avoid single-pixel static while remaining cheap enough for an interactive pass:
Amount A sets density variation, shadow biasB raises its amplitude as luminance Yfalls, and mix M blends the result. The exponential treats the random field like a small exposure change, so one fluctuation scales all channels together.
Shader
GLSL spatial density pass
The runtime compiles the exact function below. Its wrapper supplies destination-pixel coordinates, hashes each quantised frame seed into spatial offsets, decodes the source from sRGB, and encodes the result for display.
// WHAT: Add animated, exposure-like monochrome grain to a linear image.
// HOW: Build smooth value noise at two spatial scales, move it to a new lattice
// region per frame, weight it by luminance, then modulate exposure.
// WHY: Shared RGB exposure noise preserves colour ratios and reads as density
// variation rather than independent digital channel noise.
float filmGrainHash(vec2 position) {
return fract(
sin(dot(position, vec2(127.1, 311.7))) * 43758.5453123
);
}
float filmGrainSeedHash(float value) {
float hashed = fract(value * 0.1031);
hashed *= hashed + 33.33;
hashed *= hashed + hashed;
return fract(hashed);
}
vec2 filmGrainFrameOffset(float frameSeed) {
// Whole-cell jumps avoid interpolating or sliding the pattern between frames.
float seed = floor(frameSeed);
return floor(vec2(
filmGrainSeedHash(seed + 17.17),
filmGrainSeedHash(seed + 83.31)
) * 4096.0);
}
float filmGrainNoise(vec2 position) {
vec2 cell = floor(position);
vec2 local = fract(position);
// Smooth interpolation removes hard lattice-cell boundaries.
vec2 weight = local * local * (3.0 - 2.0 * local);
float a = filmGrainHash(cell);
float b = filmGrainHash(cell + vec2(1.0, 0.0));
float c = filmGrainHash(cell + vec2(0.0, 1.0));
float d = filmGrainHash(cell + vec2(1.0));
return mix(mix(a, b, weight.x), mix(c, d, weight.x), weight.y)
* 2.0 - 1.0;
}
vec3 filmGrain(
vec3 sourceLinear,
vec2 pixelCoordinate,
float frameSeed,
float amount,
float grainSize,
float shadowBias,
float effectMix
) {
float safeSize = max(grainSize, 0.5);
vec2 grainCoordinate = pixelCoordinate / safeSize;
vec2 fineOffset = filmGrainFrameOffset(frameSeed);
vec2 coarseOffset = filmGrainFrameOffset(frameSeed + 1024.0);
float fine = filmGrainNoise(grainCoordinate + fineOffset);
float coarse = filmGrainNoise(
grainCoordinate * 0.47 + coarseOffset
);
// The weaker coarse layer breaks up a perfectly uniform fine pattern.
float grain = fine * 0.75 + coarse * 0.25;
float luminance = dot(
sourceLinear,
vec3(0.2126, 0.7152, 0.0722)
);
// Bias can make grain more visible in shadows without a hard tonal boundary.
float shadowWeight = 0.8 + 2.2 * (
1.0 - smoothstep(0.05, 0.55, clamp(luminance, 0.0, 1.0))
);
float amplitude = clamp(amount, 0.0, 1.0) * mix(
1.0,
shadowWeight,
clamp(shadowBias, 0.0, 1.0)
);
// Exponential multiplication treats noise as stops of exposure.
vec3 grained = sourceLinear * exp2(grain * amplitude * 0.7);
// Mix last so disabling the effect returns the exact source.
return mix(
sourceLinear,
clamp(grained, 0.0, 1.0),
clamp(effectMix, 0.0, 1.0)
);
}- Source texturesRGB · one texture read per pixel
- Exposure-grain modulationOne fullscreen render pass
- Aspect-fill sample
- Decode sRGB
- Calculate luminance-based grain amplitude
- Modulate exposure with scalar noise
- Mix and encode to sRGB
- Display output
Why these steps are here
- Hash lattice cells, then interpolate. Raw per-pixel white noise reads as digital static; filtered cells produce a bounded apparent grain size.
- Combine two scales. The coarse contribution breaks up a perfectly regular texture without pretending to resolve literal crystals.
- Use one random value for RGB. Exposure modulation preserves local channel ratios until clipping.
- Hash time into space. Each frame seed selects decorrelated lattice offsets instead of advancing one global sinusoidal phase; pause freezes that arrangement.
- Mix last. Zero mix is a tested identity and does not alter tone or colour.
Notes
- The shader is a display-referred texture model, not a silver-halide crystal, exposure, development, printing, projection, or scanner simulation.
- Apparent pixel size depends on output resolution. Production work should define grain in physical image or reference-resolution units before rescaling.
- Real granularity versus exposure depends on the stock and process. The shadow-bias curve is a teaching control, not a measured profile.
- Compression, sharpening, denoising, scanner noise, and display scaling can all change perceived grain after the film stage.
References
Kodak VISION Color Print Film 2383/3383 — Technical Information — manufacturer data for red, green, and blue characteristic curves, spectral response, and granularity.
Kodak — Glossary of Motion Picture Terms — manufacturer definitions for granularity, graininess, density, and related film-image terms.