Skip to content
Lens · Vignette & film grainBeginner

Old film

A sepia print with dark corners, flickering exposure and grain that changes 24 times a second.

UVTimeScreenScreen / post
  1. 01GoalLens
  2. 02TechniqueVignette & film grain
  3. 03ExecutionOld film
Test scene

Parameters

#ffdba8
0.35
0.60
0.35
1.5
0.12

Copy for your platform

  • GLSLCopy
  • GodotCopy

Node chain

Built step by step, in GLSL style, with its node names.

Built step by step, in Godot style, with its node names.

No Unreal Engine 5 version of this card yet, so the nodes show the GLSL names.

No Unity version of this card yet, so the nodes show the GLSL names.

No Blender version of this card yet, so the nodes show the GLSL names.

  1. dotDotProductdotdotdot
    UV
    Time
    Screen

    01 Brightness

  2. *Multiply***

    02 Tint

  3. smoothstepSmoothStepsmoothstepsmoothstepsmoothstep

    03 Vignette

  4. hashRandomRangehashhashhash

    04 Grain

  5. *Multiply***

    05 Combine

How it works

The card turns the test scene into a worn projection: one tinted brightness instead of color, corners that fall off into dark, a whole-frame flicker and a fine grain. Everything happens in one post-process pass that reads the finished frame once; the rest is arithmetic on that one color.

Step by step

  1. Brightness. luma is the scene’s luminance, the color dotted with how bright each channel looks to the eye. Old stock records light, not hue, so this single number is all the print keeps.
  2. Tint. toned = luma * p_tint stains that brightness with the stock’s color, a warm sepia by default. Since p_tint arrives in linear light, the tint scales light the way a colored filter would.
  3. Vignette. centered measures the pixel from the frame’s middle in screen heights, so the darkening stays round on a wide frame instead of following its rectangle. falloff is 1 inside p_radius and fades to 0 over p_softness, through a smoothstep that has no visible edge.
  4. Grain. speckle is a hash of the pixel’s cell and the film frame: a value in 0..1 that looks random, different for every cell and every frame. Cells are p_grainSize pixels wide, so the grain can be coarser than the screen. The frame number is time × 24, rounded down: grain and flicker change only 24 times a second, like a projector, which reads as film where per-frame noise reads as video static.
  5. Combine. filmed multiplies everything together: the tinted brightness, the vignette, the flicker (one hash per frame, the same for every pixel) and the grain, centered on 1 so it brightens and darkens equally.

Why the grain multiplies

Added noise lifts black to gray and looks like a dirty lens. Multiplied noise scales with the picture: black stays black, bright areas get the strongest speckle, and the grain follows the image instead of floating over it. Real film grain is strongest in the midtones; a production version can shape p_grain by luma to get there.

The hash uses integers, not the familiar fract(sin(...)) trick. That trick relies on how a GPU rounds a large sine, which differs between vendors and falls into visible patterns at high inputs; an integer hash gives the same bits on every GPU. The mixing function is the finalizer of MurmurHash3, which spreads every input bit across the output.

Production notes

One texture read and about 95 operations per pixel, most of them in the two hashes (three rounds of shifts, XORs and multiplies each). That is far cheaper than any blur, and cheap enough to leave on for a whole game. The flicker hash is the same for every pixel of a frame, so a shipped version computes it once on the CPU and passes it in as a uniform.

Run it last, after tone mapping and color grading, as engines do. The preview’s scene goes straight in, so its brightest lights clip, first to the tint and then to white once even its blue passes 1, which suits a blown-out old print; in a game, tone-map first so the grain isn’t compressed along with the highlights.

The grain also hides banding: a dark gradient quantized to 8 bits shows steps, and a little noise before the final encode breaks them up, the same job dither does. Keep some grain for that reason even when the look calls for none, at an amplitude around one 8-bit step.

Make it in GLSL

One post pass. Render the scene into a texture, then draw a full-screen triangle with this fragment shader to the canvas, the scene bound to t_screen, u_resolution set to the canvas size and u_time to seconds.

The copied code is a complete fragment shader. It runs over a full-screen triangle whose vertex shader writes v_uv from 0 to 1 across the screen, and it writes sRGB, clamped, ready for the canvas.

  1. Draw your scene into a framebuffer with a color texture, in linear light. An RGBA16F texture keeps lights above 1 (WebGL2 needs EXT_color_buffer_float to draw into it); RGBA8 works too, since this card clips them anyway.
  2. Bind the canvas and draw the full-screen triangle with this shader and the scene texture bound to t_screen.
  3. Each frame, set u_time to the seconds since the start and u_resolution to the canvas size in pixels.
  4. Upload p_tint decoded to linear (the comment beside it says so), and set the other p_ uniforms from the card.

To use only the vignette, set p_grain and p_flicker to 0 and a white p_tint; the color still turns to gray, so for a color vignette replace toned with scene.

Make it in Godot

A spatial shader for a full-screen quad in front of the camera, reading the frame through hint_screen_texture. It runs before Godot's tone mapping, so set the Environment's tone mapper to Linear for the card's exact look.

The copied shader is ready to use; Godot draws a post pass as a mesh in front of the camera, and this shader’s vertex function stretches it over the whole screen.

  1. Add a MeshInstance3D as a child of your Camera3D and give it a QuadMesh of size 2×2.
  2. Give it a new ShaderMaterial and paste the shader into a new Shader.
  3. Raise the mesh’s Extra Cull Margin to its maximum (in Geometry), so Godot never culls it as off screen.
  4. In the WorldEnvironment, set Tonemap > Mode to Linear.
  5. Tune tint, radius, softness, grain, grainSize and flicker in the material’s Shader Parameters.

The shader reads TIME, which keeps running in the editor, so the grain and the flicker animate in the viewport too.

No Unreal Engine 5 tutorial for this card yet.

No Unity tutorial for this card yet.

No Blender tutorial for this card yet.

Code

old-film.fragGLSL
#version 300 es// Vignette & film grain → Old film, web GLSL (GLSL ES 3.00). From Creative Center, MIT license.precision highp float;uniform float u_time;uniform vec2 u_resolution; // this pass's output size in pixelsuniform vec3 p_tint; // Tint: vec3(1.0, 0.86, 0.66), sRGB; upload it decoded to linearuniform float p_radius; // Vignette radius: 0.35 (0.0 to 1.0)uniform float p_softness; // Vignette softness: 0.6 (0.05 to 1.5)uniform float p_grain; // Grain: 0.35 (0.0 to 1.0)uniform float p_grainSize; // Grain size (px): 1.5 (1.0 to 4.0)uniform float p_flicker; // Flicker: 0.12 (0.0 to 0.5)uniform sampler2D t_screen; // the scene's color: linear HDR (RGBA16F), linear filter, clamp to edgein vec2 v_uv;in vec3 v_position;in vec3 v_normal;in vec3 v_view;out vec4 fragColor;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};vec3 u_toSrgb(vec3 c) {	return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));}// MurmurHash3's finalizer (public domain): every input bit ends up flipping about half of the output bits. highp, because// a fragment shader's integers default to mediump, which some mobile GPUs keep in 16 bitshighp uint mix32(highp uint x) {	x ^= x >> 16u;	x *= 0x85ebca6bu;	x ^= x >> 13u;	x *= 0xc2b2ae35u;	x ^= x >> 16u;	return x;}// A value in 0..1 that looks random, one per cell and film frame; an integer hash gives the same bits on every GPUfloat hash(highp ivec2 cell, highp int frame) {	highp uint h = mix32(uint(cell.x) ^ mix32(uint(cell.y) ^ mix32(uint(frame))));	return float(h >> 8u) / 16777216.0;}vec4 effect(Inputs i) {	vec3 scene = texture(t_screen, i.uv).rgb;	float luma = dot(scene, vec3(0.2126, 0.7152, 0.0722));	vec3 toned = luma * p_tint;	// Measured in screen heights from the center, so the darkening stays round on a wide frame	vec2 centered = (i.uv - 0.5) * vec2(i.resolution.x / i.resolution.y, 1.0);	float falloff = 1.0 - smoothstep(p_radius, p_radius + p_softness, length(centered));	// A projector shows 24 frames a second: grain and flicker change only that often, so they read as film, not static	highp int frame = int(i.time * 24.0);	float speckle = hash(ivec2(i.fragCoord / p_grainSize), frame);	float flash = 1.0 + (hash(ivec2(0), frame) - 0.5) * p_flicker;	// Grain scales the color rather than adding to it, so black stays black and the grain follows the picture	vec3 filmed = toned * falloff * flash * (1.0 + (speckle - 0.5) * p_grain);	return vec4(filmed, 1.0);}void main() {	Inputs i;	i.uv = v_uv;	i.position = v_position;	// Interpolation shortens the vectors between vertices	i.normal = normalize(v_normal);	i.view = normalize(v_view);	i.time = u_time;	i.fragCoord = gl_FragCoord.xy;	i.resolution = u_resolution;	vec4 frag_out = effect(i);	// The output is sRGB: clamped and encoded once, here, with no tone mapping; opaque, so alpha is 1	fragColor = vec4(u_toSrgb(clamp(frag_out.rgb, 0.0, 1.0)), 1.0);}
old-film.gdshaderGodot shader
// Vignette & film grain → Old film, Godot 4. From Creative Center, MIT license.shader_type spatial;// A full-screen quad over the finished frame: no lighting, no culling, never hidden by depthrender_mode unshaded, cull_disabled, depth_test_disabled;uniform vec3 tint : source_color = vec3(1.0, 0.86, 0.66);uniform float radius : hint_range(0.0, 1.0, 0.01) = 0.35;uniform float softness : hint_range(0.05, 1.5, 0.01) = 0.6;uniform float grain : hint_range(0.0, 1.0, 0.01) = 0.35;uniform float grainSize : hint_range(1.0, 4.0, 0.1) = 1.5;uniform float flicker : hint_range(0.0, 0.5, 0.01) = 0.12;uniform sampler2D t_screen : hint_screen_texture, filter_linear, repeat_disable;struct Inputs {	vec2 uv;	vec3 position;	vec3 normal;	vec3 view;	float time;	vec2 fragCoord;	vec2 resolution;};// Godot's textures start at the top-left and the effect's uv at the bottom-left: each sample flips v backvec2 u_flipUv(vec2 uv) {	return vec2(uv.x, 1.0 - uv.y);}// MurmurHash3's finalizer (public domain): every input bit ends up flipping about half of the output bits. highp, because// a fragment shader's integers default to mediump, which some mobile GPUs keep in 16 bitshighp uint mix32(highp uint x) {	x ^= x >> 16u;	x *= 0x85ebca6bu;	x ^= x >> 13u;	x *= 0xc2b2ae35u;	x ^= x >> 16u;	return x;}// A value in 0..1 that looks random, one per cell and film frame; an integer hash gives the same bits on every GPUfloat hash(highp ivec2 cell, highp int frame) {	highp uint h = mix32(uint(cell.x) ^ mix32(uint(cell.y) ^ mix32(uint(frame))));	return float(h >> 8u) / 16777216.0;}vec4 effect(Inputs i) {	vec3 scene = texture(t_screen, u_flipUv(i.uv)).rgb;	float luma = dot(scene, vec3(0.2126, 0.7152, 0.0722));	vec3 toned = luma * tint;	// Measured in screen heights from the center, so the darkening stays round on a wide frame	vec2 centered = (i.uv - 0.5) * vec2(i.resolution.x / i.resolution.y, 1.0);	float falloff = 1.0 - smoothstep(radius, radius + softness, length(centered));	// A projector shows 24 frames a second: grain and flicker change only that often, so they read as film, not static	highp int frame = int(i.time * 24.0);	float speckle = hash(ivec2(i.fragCoord / grainSize), frame);	float flash = 1.0 + (hash(ivec2(0), frame) - 0.5) * flicker;	// Grain scales the color rather than adding to it, so black stays black and the grain follows the picture	vec3 filmed = toned * falloff * flash * (1.0 + (speckle - 0.5) * grain);	return vec4(filmed, 1.0);}void vertex() {	// Put the shader on a MeshInstance3D with a 2x2 QuadMesh in front of the camera; it covers the screen wherever it is	POSITION = vec4(VERTEX.xy, 1.0, 1.0);}void fragment() {	Inputs i;	// SCREEN_UV starts at the top-left; the effect expects GLSL's bottom-left	i.uv = vec2(SCREEN_UV.x, 1.0 - SCREEN_UV.y);	// A flat quad facing the viewer, the same as the site's 2D preview	i.position = vec3(i.uv * 2.0 - 1.0, 0.0);	i.normal = vec3(0.0, 0.0, 1.0);	i.view = vec3(0.0, 0.0, 1.0);	i.time = TIME;	// FRAGCOORD's origin is the top-left too	i.resolution = VIEWPORT_SIZE;	i.fragCoord = vec2(FRAGCOORD.x, i.resolution.y - FRAGCOORD.y);	vec4 frag_out = effect(i);	ALBEDO = frag_out.rgb;}

No Unreal Engine 5 code for this card yet.

No Unity code for this card yet.

No Blender code for this card yet.

Further reading