Skip to content

CRT post-process

Source: src/gpu/pipelines/crt.ts

The final full-screen pass stamps a CRT character onto the composited scene before it reaches the swap chain: chromatic aberration → tone-map → scanlines. When crt={false}, this is swapped for a plain passthrough blit.

Real CRTs (and cheap lenses) don’t focus all wavelengths to the same point. We fake it by sampling the red and blue channels with a small horizontal offset while green stays centered:

offset=aberrationresolutionx,r=tex(uv+offset)r,  g=tex(uv)g,  b=tex(uvoffset)b\text{offset} = \frac{\text{aberration}}{\text{resolution}_x}, \quad r = \text{tex}(uv + \text{offset})_r,\; g = \text{tex}(uv)_g,\; b = \text{tex}(uv - \text{offset})_b

aberration is in pixels (default 1.0), converted to uv space by dividing by width. At 0 there’s no fringing.

The composite is HDR (bloom can push values past 1.0). Mapping it to the displayable 0..1 range is tone-mapping. A common operator is Reinhard, x/(x+1)x/(x+1) — but that’s for true HDR (values ≫ 1). Our signal is mostly in [0,1] (heads clamp at 1.0; bloom adds a modest overshoot), so Reinhard would crush midtones — a 1.0 head would map to 0.5, darkening everything. Instead we clamp:

color=saturate(r,g,b)=clamp(,0,1)\text{color} = \operatorname{saturate}(r, g, b) = \operatorname{clamp}(\cdot,\,0,\,1)

Clamping lets bloom’s >1.0 highlights blow out to white — the desirable glow — and is identical to the implicit clamp the swap chain would apply anyway. (If heads ever emit genuine HDR, revisit with a real operator.)

A horizontal brightness ripple — a sine in screen-space yy:

scan=1scanlineStrength(0.5+0.5sin(uvyf))\text{scan} = 1 - \text{scanlineStrength}\cdot\big(0.5 + 0.5\sin(uv_y \cdot f)\big)

The frequency ff is tied to the canvas height so band spacing stays constant in device pixels at any size:

f=resolutiony2πSCANLINE_PERIOD_PX,SCANLINE_PERIOD_PX=4f = \text{resolution}_y \cdot \frac{2\pi}{\text{SCANLINE\_PERIOD\_PX}}, \qquad \text{SCANLINE\_PERIOD\_PX} = 4

i.e. one full bright→bright period every 4 device pixels. scanlineStrength (default 0.3) controls only the depth of the ripple, not its spacing — multiply the color by scan.

It reads the fully-composited scene (reusing the single-texture blit binding) and writes the swap chain directly — so it’s where HDR finally becomes the 8-bit image you see. Everything upstream stayed in HDR offscreen targets precisely so this step has real range to clamp and glow from. See the Pipeline overview.