Generating Film Grain in GLSL Using Hash Noise
This article explains how to procedurally generate dynamic film grain using hash-based pseudo-random noise functions directly within a GLSL fragment shader. By leveraging mathematical hashing rather than static textures, developers can produce infinite, non-repeating noise patterns that animate over time with minimal memory overhead. The following sections detail the mechanics of shader-based hash functions, luminance-dependent modulation, and the complete implementation pipeline for modern post-processing workflows.
The Role of Hash-Based Noise
Procedural film grain simulates the random distribution of silver halide crystals found in analog film stock. Unlike continuous, interpolated noise algorithms like Perlin or Simplex noise—which produce smooth gradients—film grain requires high-frequency, uncorrelated white noise.
In OpenGL and WebGL shaders, allocating memory for large noise
textures introduces cache misses and bandwidth limits. A hash-based
pseudo-random number generator (PRNG) accepts deterministic inputs (such
as screen-space UV coordinates and a time uniform) and outputs a
uniformly distributed float between 0.0 and
1.0 purely through arithmetic operations.
Constructing the Hash Function
The classic approach in GLSL relies on trigonometric functions combined with large scaling factors, such as:
float hash(vec2 p) {
return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
}This function projects a 2D coordinate vector onto an arbitrary
vector, takes the sine, multiplies the result by a large scalar, and
extracts the fractional component with fract(). This
amplifies minute differences in input coordinates into seemingly random
distributions.
However, trigonometric hashes suffer from floating-point precision inconsistencies across different GPU architectures, particularly on mobile hardware. A more stable, modern alternative utilizes integer bitwise operations to create integer hashes (similar to PCG or MurmurHash variants):
float hash(uvec2 p) {
p = p * uvec2(374761393u, 2654435769u);
p = (p.x ^ p.y) * uvec2(2246822519u, 3266489917u);
p = p ^ (p >> 16u);
return float(p.x * 3266489917u) * (1.0 / 4294967295.0);
}This integer permutation creates a robust, uniform dispersion of values without floating-point artifacts.
Animating the Grain Over Time
Film grain must change every frame to mimic a high-frame-rate
exposure. To achieve this, the input coordinate space is augmented with
an animated time uniform (u_time).
By scaling the time variable to step discretely per frame (rather than continuously, which would create an unnatural motion blur), the noise seeds change instantly between frames:
float frame = floor(u_time * 24.0); // 24 fps film cadence
float noise = hash(uvec2(gl_FragCoord.xy) + uvec2(uint(frame) * 1920u, uint(frame) * 1080u));Luminance-Dependent Grain Response
In real photographic film, grain is not uniformly visible across all exposure levels. Physical film grain is typically most apparent in the midtones and darker regions, while bright highlights saturate the emulsion and wash out the individual crystal boundaries.
To simulate physical film response:
- Extract the perceived luminance of the base scene color using
standard Rec. 709 weights
(
vec3(0.2126, 0.7152, 0.0722)). - Weight the noise intensity dynamically based on this luminance curve.
A typical distribution curve dampens grain in pure black and highlights while peaking around mid-gray:
float luminance = dot(baseColor.rgb, vec3(0.2126, 0.7152, 0.0722));
float grainAmount = 4.0 * luminance * (1.0 - luminance); // Bell curve peaking at 0.5Complete Fragment Shader Implementation
The complete process reads the rendered scene texture, computes the hash for the pixel, calculates the luminance mask, and applies the grain using additive or overlay blending:
#version 300 es
precision highp float;
out vec4 fragColor;
in vec2 v_uv;
uniform sampler2D u_sceneTexture;
uniform float u_time;
uniform float u_intensity; // e.g., 0.05
float hash(uvec2 p) {
p = p * uvec2(374761393u, 2654435769u);
p = (p.x ^ p.y) * uvec2(2246822519u, 3266489917u);
p = p ^ (p >> 16u);
return float(p.x * 3266489917u) * (1.0 / 4294967295.0);
}
void main() {
vec4 scene = texture(u_sceneTexture, v_uv);
// Discrete frame time for 24fps motion
uint frame = uint(floor(u_time * 24.0));
uvec2 seed = uvec2(gl_FragCoord.xy) + uvec2(frame * 1024u, frame * 2048u);
// Generate noise centered around 0 (-0.5 to 0.5)
float rawNoise = hash(seed) - 0.5;
// Calculate luminance mask
float luma = dot(scene.rgb, vec3(0.2126, 0.7152, 0.0722));
float mask = 1.0 - 2.0 * abs(luma - 0.5); // Peak at midtones
mask = clamp(mask, 0.0, 1.0);
// Apply final grain
vec3 finalColor = scene.rgb + rawNoise * u_intensity * mask;
fragColor = vec4(clamp(finalColor, 0.0, 1.0), scene.a);
}Performance and Visual Considerations
This hash-based approach runs entirely in registers with zero texture
lookups beyond the initial scene fetch. Because the seed uses screen
coordinates (gl_FragCoord.xy), the grain size remains
uniform at pixel resolution, preventing stretching or scaling artifacts
when dynamic camera projections change.