Time-Varying Film Grain
by GameDev.net · GLSL ES 3.00 (WebGL2) · 30 Aug 2026
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.
Shader inputs
void mainImage(out vec4 fragColor, in vec2 fragCoord)
Called once per pixel. Write the colour to fragColor.
-
iResolutionvec3 - Viewport size in pixels (z is the pixel aspect ratio).
-
iTimefloat - Seconds since the shader started.
-
iTimeDeltafloat - Seconds since the previous frame.
-
iFrameRatefloat - Frames per second, smoothed.
-
iFrameint - Frames rendered since the start.
-
iMousevec4 - Mouse position: xy while held, zw of the last click.
-
iDatevec4 - Year, month, day, and seconds within the day.
-
iChannel0sampler2D - Texture bound to channel 0.
-
iChannel1sampler2D - Texture bound to channel 1.
-
iChannel2sampler2D - Texture bound to channel 2.
-
iChannel3sampler2D - Texture bound to channel 3.
-
iChannelResolutionvec3[4] - Pixel size of each bound channel texture.
-
iChannelTimefloat[4] - Playback time of each channel, in seconds.
-
iSampleRatefloat - 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
uniform float uAmount; // @param 0.0..0.3 = 0.08 "Grain amount"
uniform float uGrainSize; // @param 1.0..4.0 = 1.5 "Grain size"
uniform float uRate; // @param 4.0..60.0 = 24.0 "Update rate"
uniform float uColorAmount; // @param 0.0..1.0 = 0.15 "Color amount"
float grainHash(vec3 p) {
p = fract(p * 0.1031);
p += dot(p, p.yzx + 33.33);
return fract((p.x + p.y) * p.z);
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec3 scene = texture(iChannel0, uv).rgb;
vec2 grainCell = floor(fragCoord / uGrainSize);
float frame = floor(iTime * uRate);
frame = mod(frame, 65521.0);
float n0 = grainHash(vec3(grainCell, frame));
float n1 = grainHash(vec3(grainCell + 19.7, frame + 7.0));
float n2 = grainHash(vec3(grainCell + 47.3, frame + 17.0));
vec3 noise = vec3(n0, mix(n0, n1, uColorAmount), mix(n0, n2, uColorAmount)) - 0.5;
float luma = dot(scene, vec3(0.2126, 0.7152, 0.0722));
float response = smoothstep(0.02, 0.28, luma) * (1.0 - smoothstep(0.72, 1.0, luma));
vec3 color = max(scene + noise * uAmount * mix(0.35, 1.0, response), 0.0);
fragColor = vec4(color, 1.0);
}
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.
Discussion