CRT (composite)
Purpose
A complex, recognizable look like a CRT is not a new primitive — it's a stack of five or six simple operations from earlier lessons, each living in an identifiable layer. The only new skill is composing them in the right order with the right intensities.
Key insight
Nothing in a CRT shader is mathematically new after the lessons above. Scanlines are L3's sin used as an intensity mask. The phosphor dot mask is L5's coordinate quantization with a color per cell. Barrel distortion is a radial UV remap (coordinate layer). Chromatic aberration is L7. The glow is a vignette in reverse (bright-at-center mask added instead of subtracted).
The lesson is the ordering and the layered masks-times-image recipe: coordinate changes happen first (so you can't sample the same pixel twice), signal-layer masks are computed from UVs, then everything is multiplied and added onto the scene in the color layer. This is the first lesson where composition itself is the skill being taught; a CRT that has its parts in the wrong order is unmistakably broken.
Break it
Zero every slider, then bring them up one at a time. scanlines alone looks like bad Netflix; scanlines + mask looks like an arcade; scanlines + mask + curve looks like a real television; adding fringe + glow gives the full nostalgia hit. Teaches: a CRT is not one effect — it's five effects stacked, each in its own layer, each contributing one aspect of the look. Remove any one and it's still "retro" in some way but loses a specific quality. You can now name what each quality is.
Then turn scanlines and mask both to 1, glow to 0, and look closely. The screen becomes a grid of RGB phosphor triads with dark scanlines between them — the actual physical structure of a real CRT, now visible as data. Teaches: subtle retro looks are hiding this exact structure at small amplitude. The look works because your eye integrates the grid at normal viewing distance — the same mechanism that makes Lesson 11's dithering work.
Direct Claude
uniform float u_curve;
uniform float u_scanlines;
uniform float u_fringe;
uniform float u_mask;
uniform float u_glow;
// Source scene — a sunset vaporwave with animated grid.
vec3 scene(vec2 uv) {
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return vec3(0.0);
vec3 col = mix(vec3(0.18, 0.10, 0.28), vec3(0.95, 0.48, 0.32), uv.y);
col = mix(col, vec3(0.25, 0.60, 0.85), smoothstep(0.55, 0.98, uv.y));
float sun = 1.0 - smoothstep(0.09, 0.11, length(uv - vec2(0.5, 0.55)));
col = mix(col, vec3(1.0, 0.93, 0.68), sun);
float mntn = 0.40 + 0.05 * sin(uv.x * 18.0) + 0.08 * sin(uv.x * 5.0 + 2.0);
col = mix(col, vec3(0.10, 0.06, 0.20), step(uv.y, mntn) * step(uv.y, 0.5));
if (uv.y < 0.5) {
float yy = (0.5 - uv.y);
float hz = smoothstep(0.004, 0.0, abs(fract(pow(yy, 0.7) * 8.0 - u_time * 0.25) - 0.5));
float vl = smoothstep(0.004, 0.0, abs(fract((uv.x - 0.5) / max(yy, 0.02) * 4.0) - 0.5));
col = mix(col, vec3(0.95, 0.30, 0.75), max(hz, vl) * 0.9);
}
return col;
}
float hash11(float x) {
return fract(sin(x * 127.1) * 43758.5453);
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
// COORDINATE LAYER (1) — barrel distortion. Bows UV outward from center.
vec2 cuv = uv - 0.5;
cuv *= 1.0 + dot(cuv, cuv) * u_curve;
vec2 warped = cuv + 0.5;
// Black border outside the bowed rectangle.
if (warped.x < 0.0 || warped.x > 1.0 || warped.y < 0.0 || warped.y > 1.0) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
return;
}
// COORDINATE LAYER (2) — radial chromatic aberration. L7.
vec2 dir = warped - 0.5;
float r = scene(warped + dir * u_fringe).r;
float g = scene(warped).g;
float b = scene(warped - dir * u_fringe).b;
vec3 col = vec3(r, g, b);
// SIGNAL LAYER (1) — scanlines as a sine intensity mask.
// 220 horizontal bands regardless of canvas resolution.
float scan = 0.5 + 0.5 * sin(uv.y * 220.0 * 3.14159);
col *= mix(1.0, scan, u_scanlines);
// SIGNAL LAYER (2) — phosphor RGB triad mask (L5's quantization, one color per cell).
float triads = 160.0; // how many RGB triads across the screen
float cell = mod(uv.x * triads * 3.0, 3.0);
vec3 phosphor = vec3(1.0);
if (cell < 1.0) phosphor = vec3(1.25, 0.55, 0.55);
else if (cell < 2.0) phosphor = vec3(0.55, 1.25, 0.55);
else phosphor = vec3(0.55, 0.55, 1.25);
col *= mix(vec3(1.0), phosphor, u_mask);
// COLOR LAYER — glow (vignette inverted — bright at center, added back onto col).
float glowMask = 1.0 - smoothstep(0.0, 0.8, length(uv - 0.5));
col += u_glow * glowMask * 0.35;
// COLOR LAYER — subtle edge darkening (built-in vignette).
float vig = 1.0 - smoothstep(0.45, 0.85, length(uv - 0.5));
col *= mix(1.0, vig, 0.5);
// SIGNAL LAYER (3) — tiny per-frame brightness flicker (L8's noise, low amplitude).
float flicker = 1.0 + 0.025 * (hash11(floor(u_time * 30.0)) - 0.5);
col *= flicker;
gl_FragColor = vec4(col, 1.0);
}