Appearance
A CRT curvature shader, explained without scary maths
A convincing CRT shader is the difference between "pixel game running on an LCD" and "pixel game that feels like it grew up in 1992". There are dozens of free CRT shaders floating around — the CRT-Royale shader from libretro is probably the best-known — but most of them are dense, monolithic files that are hard to learn from.
I wrote a minimal version for Quiet Frame that I can fit on a single screen and explain to anyone in fifteen minutes. This post is that explanation.
The whole thing decomposes into five tiny functions. We will build them one at a time, then assemble them into a fragment() body.
1. Barrel distortion
A CRT screen is not flat. The glass bulges outward, which makes the image at the edges a touch larger than the image at the centre, and slightly bent inward at the corners. We simulate this by warping UV coordinates before we sample the texture.
glsl
vec2 barrel(vec2 uv, float amount) {
vec2 c = uv - 0.5; // recentre to 0
float r2 = dot(c, c); // squared distance from centre
c *= 1.0 + r2 * amount; // push outward proportional to r^2
return c + 0.5; // recentre back
}The amount is normally between 0.05 (gentle) and 0.2 (Trinitron levels of curve). At higher values you start to see black corners outside the warped area; we handle those in step 2.
2. Vignette mask
Real CRT phosphors get less bright at the edges of the tube, and the warped UV leaves us with black corners anyway. One function does both jobs:
glsl
float vignette(vec2 uv, float strength) {
vec2 d = uv - 0.5;
float r = length(d) * strength;
return smoothstep(0.85, 0.4, r);
}smoothstep here returns 1.0 at the centre and falls to 0.0 at the edges, with a nice curve in between. We multiply the final colour by this value.
3. Scanlines
Every other pixel row on a CRT is darker. The brain reads this as "I am looking at a tube screen". The cheapest way to fake it is to multiply by a sinusoid based on the screen Y coordinate:
glsl
float scanline(vec2 frag, float intensity) {
float s = sin(frag.y * 3.14159); // -1 to 1, repeats every 2 pixels
return 1.0 - intensity * 0.5 * (1.0 - s); // dimmer on the troughs
}intensity of 0 leaves the image flat. intensity of 1 carves deep black rows between bright rows. Anywhere from 0.3 to 0.7 looks right depending on the brightness of your art.
4. Slot-mask tint
The other half of "this looks like a CRT" is the slot mask: the trios of red, green and blue phosphor stripes that run vertically. We approximate this by darkening one of R, G, B per pixel column.
glsl
vec3 slot_mask(vec2 frag, vec3 colour) {
int col = int(mod(frag.x, 3.0));
vec3 mask = vec3(1.0);
if (col == 0) mask = vec3(1.0, 0.7, 0.7);
if (col == 1) mask = vec3(0.7, 1.0, 0.7);
if (col == 2) mask = vec3(0.7, 0.7, 1.0);
return colour * mask;
}The tint values (0.7) control how strong the mask is. Stronger looks more like an aperture-grille Trinitron; weaker looks more like a domestic shadow-mask TV.
5. Phosphor bloom
A CRT's bright areas spill light into their surroundings — the bloom of an explosion is fuzzy at the edges. A real bloom shader is a multi-pass affair, but for pixel art we can fake it with two extra samples:
glsl
vec3 cheap_bloom(sampler2D tex, vec2 uv, float strength) {
vec3 c = texture(tex, uv).rgb;
vec3 cx = texture(tex, uv + vec2(0.0015, 0.0)).rgb;
vec3 cy = texture(tex, uv + vec2(0.0, 0.0015)).rgb;
vec3 surrounding = (cx + cy) * 0.5;
return c + max(surrounding - 0.4, 0.0) * strength;
}We sample the texture two more times, very close to the central sample, and add the over-bright portion of those samples back into the main colour. This is mathematically primitive but visually it works because pixel art has hard edges; the bloom only ever appears around bright pixels next to dark ones.
The fragment body
Glue all five together:
glsl
shader_type canvas_item;
uniform sampler2D source : source_color;
void fragment() {
vec2 uv = barrel(UV, 0.10);
// Bail if we warped off the screen
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
COLOR = vec4(0.0, 0.0, 0.0, 1.0);
return;
}
vec3 colour = cheap_bloom(source, uv, 1.4);
colour = slot_mask(FRAGCOORD.xy, colour);
colour *= scanline(FRAGCOORD.xy, 0.5);
colour *= vignette(uv, 1.0);
COLOR = vec4(colour, 1.0);
}Drop this onto a Godot 4 ColorRect covering the screen and assign a source viewport, and your game gets dressed up like a 1992 RCA. The whole shader is about sixty lines including the function bodies above.
The numbers worth tweaking
barrel(uv, 0.10)— the curve. Push to 0.2 for arcade cabinet. Drop to 0.04 for "modern flat-screen with character".scanline(..., 0.5)— how dark the rows between scanlines get. 0.5 is a sweet spot.slot_mask— if your art is already saturated, tone the mask values up (0.85instead of0.7) so the colours don't muddy.cheap_bloom(..., 1.4)— bloom strength. Above 2.0 you'll get visible halos around isolated bright pixels, which is fun for explosions and bad for text.
What this shader is not
This is not a faithful CRT simulator. CRT-Royale, Mattias Gustavsson's CRT-Lottes port, and the various Reshade preset packs all do dramatically more accurate work, including beam-shape simulation, phosphor decay timing, NTSC colour bleed and proper interlacing.
What this shader is is a teaching object. Every function is small enough to understand on its own. Once you understand the five pieces you can read a "real" CRT shader and recognise what each block of it is doing — and at that point you can either start swapping in better versions of each piece, or decide that the cheap version is good enough for your game (it usually is).
Where to go next
If you want to go deeper on shaders generally, The Book of Shaders by Patricio Gonzalez Vivo is still the best introduction. For CRT specifically, Pixel-Perfect's article on CRT shader components is dated but still excellent. And Shadertoy is full of people doing extraordinary work that you can read line by line for free.