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:

  1. Extract the perceived luminance of the base scene color using standard Rec. 709 weights (vec3(0.2126, 0.7152, 0.0722)).
  2. 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.5

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