Old film
A sepia print with dark corners, flickering exposure and grain that changes 24 times a second.
- 01GoalLens
- 02TechniqueVignette & film grain
- 03ExecutionOld film
Parameters
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.
dotDotProductdotdotdot UVTimeScreen01 Brightness
*Multiply*** In02 Tint
smoothstepSmoothStepsmoothstepsmoothstepsmoothstep In03 Vignette
hashRandomRangehashhashhash In04 Grain
*Multiply*** In05 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-processFull-screen passes run in order over the finished frame, each reading the image the previous one wrote. Bloom, tone mapping and color grading live here. Post-process chain Full-screen passes run in order over the finished frame, each reading the image the previous one wrote. Bloom, tone mapping and color grading live here.
Step by step
- Brightness.
lumais the scene’s luminanceHow bright a color looks, from its linear red, green and blue weighted by the eye's sensitivity to each.FundamentalLuminance
How bright a color looks, from its linear red, green and blue weighted by the eye's sensitivity to each.
Y = 0.2126 R + 0.7152 G + 0.0722 BSources, 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. - Tint.
toned = luma * p_tintstains that brightness with the stock’s color, a warm sepia by default. Sincep_tintarrives in linear light, the tint scales light the way a colored filter would. - Vignette.
centeredmeasures the pixel from the frame’s middle in screen heights, so the darkening stays round on a wide frame instead of following its rectangle.falloffis 1 insidep_radiusand fades to 0 overp_softness, through a smoothstepA soft threshold. It is 0 below the first edge, 1 above the second, and follows an S-shaped curve with flat ends in between.FundamentalSmoothstep
A soft threshold. It is 0 below the first edge, 1 above the second, and follows an S-shaped curve with flat ends in between.
t = clamp((x − e0) / (e1 − e0), 0, 1); s = t² (3 − 2t)Sourcesthat has no visible edge.- smoothstep (opens in a new tab)Reference
- Smoothstep (opens in a new tab)Reference
- Grain.
speckleis a hashA function that scrambles its input into a value that looks random but is always the same for the same input. Shaders use it for per-pixel and per-cell randomness.FundamentalHash
A function that scrambles its input into a value that looks random but is always the same for the same input. Shaders use it for per-pixel and per-cell randomness.
h = fract(sin(dot(p, k)) × big)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 arep_grainSizepixels wide, so the grain can be coarser than the screen. The frame number istime × 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. - Combine.
filmedmultiplies 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 bandingBanding is the stripes a smooth gradient breaks into when stored with too few levels. Dithering adds tiny per-pixel noise before rounding, so the stripes become fine grain. Banding & dithering Banding is the stripes a smooth gradient breaks into when stored with too few levels. Dithering adds tiny per-pixel noise before rounding, so the stripes become fine grain.
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.
- Draw your scene into a framebuffer with a color texture, in linear light. An
RGBA16Ftexture keeps lights above 1 (WebGL2 needsEXT_color_buffer_floatto draw into it);RGBA8works too, since this card clips them anyway. - Bind the canvas and draw the full-screen triangle with this shader and the scene texture bound to
t_screen. - Each frame, set
u_timeto the seconds since the start andu_resolutionto the canvas size in pixels. - Upload
p_tintdecoded to linear (the comment beside it says so), and set the otherp_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.
- Add a
MeshInstance3Das a child of yourCamera3Dand give it aQuadMeshof size 2×2. - Give it a new
ShaderMaterialand paste the shader into a newShader. - Raise the mesh’s Extra Cull Margin to its maximum (in Geometry), so Godot never culls it as off screen.
- In the
WorldEnvironment, set Tonemap > Mode to Linear. - Tune
tint,radius,softness,grain,grainSizeandflickerin 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
#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);}// 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
- Vignetting (opens in a new tab)Reference
- Film grain (opens in a new tab)Reference
- MurmurHash3 (opens in a new tab)Reference
- Relative luminance (opens in a new tab)Reference
- smoothstep (opens in a new tab)Reference
- length (opens in a new tab)Reference