Skip to main content
GameDev.net gamedev.net

Time-Varying Film Grain

by GameDev.net · GLSL ES 3.00 (WebGL2) · 30 Aug 2026

Use in your engine

Run the shader to adjust these controls.

What it demonstrates

Film grain is animated high-frequency variation shaped by image brightness, not a static noise texture laid over the frame. This demo renders a smooth procedural night scene first, providing gradients, shadows, saturated signs, and highlights that reveal where grain helps and where it becomes distracting.

Source Revision 1

Author notes are linked to specific lines.

Shader inputs

void mainImage(out vec4 fragColor, in vec2 fragCoord)

Called once per pixel. Write the colour to fragColor.

iResolution vec3
Viewport size in pixels (z is the pixel aspect ratio).
iTime float
Seconds since the shader started.
iTimeDelta float
Seconds since the previous frame.
iFrameRate float
Frames per second, smoothed.
iFrame int
Frames rendered since the start.
iMouse vec4
Mouse position: xy while held, zw of the last click.
iDate vec4
Year, month, day, and seconds within the day.
iChannel0 sampler2D
Texture bound to channel 0.
iChannel1 sampler2D
Texture bound to channel 1.
iChannel2 sampler2D
Texture bound to channel 2.
iChannel3 sampler2D
Texture bound to channel 3.
iChannelResolution vec3[4]
Pixel size of each bound channel texture.
iChannelTime float[4]
Playback time of each channel, in seconds.
iSampleRate float
Audio sample rate, always 44100.
Common
float sourceHash(float n) {
    return fract(sin(n * 73.13) * 43758.5453);
}

vec3 renderSource(vec2 p, float time) {
    vec3 color = mix(vec3(0.012, 0.018, 0.04), vec3(0.30, 0.16, 0.20), p.y + 0.5);
    float moon = 1.0 - smoothstep(0.07, 0.08, length(p - vec2(0.28, 0.24)));
    color += vec3(0.72, 0.78, 0.92) * moon;
    float ridge = -0.12 + 0.04 * sin(p.x * 8.0) + 0.02 * sin(p.x * 19.0);
    if (p.y < ridge) {
        color = vec3(0.025, 0.03, 0.055);
    }
    float column = floor((p.x + 0.7) * 11.0);
    float building = -0.34 + sourceHash(column) * 0.20;
    if (p.y < building) {
        color = vec3(0.018, 0.022, 0.04);
        vec2 windows = fract(vec2((p.x + 0.7) * 33.0, (p.y + 0.5) * 28.0));
        float lit = step(0.62, sourceHash(column * 9.0 + floor((p.y + 0.5) * 28.0)));
        color += vec3(0.85, 0.48, 0.16) * lit * step(0.25, windows.x) * step(windows.x, 0.70) * step(0.25, windows.y) * step(windows.y, 0.72);
    }
    float signShape = max(abs(p.x + 0.28) - 0.12, abs(p.y + 0.02) - 0.045);
    color += vec3(0.85, 0.06, 0.40) * (1.0 - smoothstep(0.0, 0.012, abs(signShape)));
    return color;
}

vec2 sourcePoint(vec2 fragCoord) {
    return (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
}
Buffer A
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    fragColor = vec4(renderSource(sourcePoint(fragCoord), iTime), 1.0);
}
Main Image4
1 uniform float uAmount; // @param 0.0..0.3 = 0.08 "Grain amount"
2 uniform float uGrainSize; // @param 1.0..4.0 = 1.5 "Grain size"
3 uniform float uRate; // @param 4.0..60.0 = 24.0 "Update rate"
4 uniform float uColorAmount; // @param 0.0..1.0 = 0.15 "Color amount"
5
6 float grainHash(vec3 p) {
7 p = fract(p * 0.1031);
8 p += dot(p, p.yzx + 33.33);
9 return fract((p.x + p.y) * p.z);
10 }
11
12 void mainImage(out vec4 fragColor, in vec2 fragCoord) {
13 vec2 uv = fragCoord / iResolution.xy;
14 vec3 scene = texture(iChannel0, uv).rgb;
15 vec2 grainCell = floor(fragCoord / uGrainSize);
16 float frame = floor(iTime * uRate);
17 frame = mod(frame, 65521.0);
18 float n0 = grainHash(vec3(grainCell, frame));
19 float n1 = grainHash(vec3(grainCell + 19.7, frame + 7.0));
20 float n2 = grainHash(vec3(grainCell + 47.3, frame + 17.0));
21 vec3 noise = vec3(n0, mix(n0, n1, uColorAmount), mix(n0, n2, uColorAmount)) - 0.5;
22 float luma = dot(scene, vec3(0.2126, 0.7152, 0.0722));
23 float response = smoothstep(0.02, 0.28, luma) * (1.0 - smoothstep(0.72, 1.0, luma));
24 vec3 color = max(scene + noise * uAmount * mix(0.35, 1.0, response), 0.0);
25 fragColor = vec4(color, 1.0);
26 }
27

Inputs for this pass

  • iChannel0 Buffer A
  • iChannel1 Empty
  • iChannel2 Empty
  • iChannel3 Empty

Learn from this shader

How it works

The image pass groups fragments into pixel-sized grain cells and hashes each cell together with a discrete time frame. The frame index wraps over a prime-length cycle before hashing, keeping its magnitude stable during long sessions. Updating the seed at a fixed cadence makes the pattern jump between exposures instead of sliding across the screen. Three decorrelated hashes can produce coloured noise, while Color amount blends back to a single monochrome value. A luminance response reduces grain near deep black and full white, leaving the strongest texture in midtones. Noise is zero-centred before addition, so average image brightness remains stable. The pass requires one scene sample and a few inexpensive hash evaluations.

Try changing

Increase Amount while watching the smooth sky, then compare it with the dark silhouettes. Raise Grain size for a rough vintage stock appearance. Lower Rate to see individual noise arrangements hold between updates. Set Color amount to zero for neutral silver-grain texture or increase it carefully for a digital sensor-noise character.

Using it in a game

Connect the final scene colour where Buffer A is bound and apply grain near the end of post-processing, before interface rendering. Pixel coordinates keep its scale stable across camera movement, but dynamic resolution may require scaling Grain size to the output resolution. Grain can hide colour banding and make temporally stable renders feel less synthetic. Keep the amount subtle during normal play; stronger animated grain suits flashbacks, surveillance feeds, low-light vision, or damage states without requiring authored assets.

Explore the techniques

Continue with curated explanations and progressively related examples.

LicenseMIT
Views0
Forks0

Discussion

Loading comments...