DEV Community

Cover image for Building a Multi-Pass Phosphor Rendering Pipeline in WebGL
The L? Man
The L? Man

Posted on • Originally published at hubertlim.github.io

Building a Multi-Pass Phosphor Rendering Pipeline in WebGL

Vintage oscilloscopes have a look that's hard to fake. The phosphor coating on the CRT glows where the electron beam hits, fades slowly over time, and bleeds light into surrounding pixels. That persistence and bloom is what gives oscilloscope traces their characteristic warmth.

I wanted to recreate that look in the browser. Not as a post-processing filter on top of a line, but as a physically-inspired rendering pipeline where each frame's energy accumulates, decays, and blooms the way real phosphor does.

The result is Phosphor, a web-based oscilloscope simulator with 5 signal modes, real-time audio visualization, and a 4-pass GLSL shader pipeline.

→ Try the Live Demo | Source Code (MIT)

This post walks through how the rendering works.

The Pipeline

Each frame goes through four shader passes:

Signal Data
    ↓
┌─────────────────┐
│  Pass 1: BEAM   │  Soft gaussian dots, additive blending (HDR)
└────────┬────────┘
         ↓
┌─────────────────┐
│ Pass 2: PHOSPHOR│  Exponential decay persistence (linear HDR)
└────────┬────────┘
         ├──────────────────┐
         ↓                  ↓
┌─────────────────┐  ┌─────────────┐
│  Pass 3: BLOOM  │  │             │
│  (half res)     │  │             │
└────────┬────────┘  │             │
         ↓           ↓             │
┌──────────────────────────────────┐
│       Pass 4: COMPOSITE          │
│  Tone mapping, CRT curvature,    │
│  vignette, scanlines, grid       │
└──────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The key design decision: the phosphor buffer stays in linear HDR space. Tone mapping only happens once, in the composite pass. This prevents the accumulation artifacts you get when you tone-map per frame.

Pass 1: Beam

The beam shader renders each signal point as a soft gaussian dot using gl_PointCoord. Each point has two components: a tight core and a softer glow halo.

vec2 coord = gl_PointCoord - vec2(0.5);
float dist = length(coord);

// Tight core + soft glow
float core = exp(-dist * dist * 28.0);
float glow = exp(-dist * dist * 8.0) * 0.3;
float shape = core + glow;

float brightness = shape * vIntensity * 0.7;
gl_FragColor = vec4(uBeamColor * brightness, brightness);
Enter fullscreen mode Exit fullscreen mode

The core gaussian (sigma ≈ 0.19) gives the sharp bright center. The glow gaussian (sigma ≈ 0.35) adds the softer halo around it. With 4096 points rendered per frame using additive blending, overlapping regions accumulate naturally — dense parts of the trace glow brighter, just like a real CRT.

The output goes to a HalfFloat texture. This is important: standard 8-bit textures would clip at 1.0 and lose the HDR information we need for realistic phosphor behavior.

Pass 2: Phosphor Persistence

This is where the magic happens. The phosphor shader reads the previous frame's buffer, applies exponential decay, and adds the new beam energy:

vec4 current = texture2D(uCurrentFrame, vUv);
vec4 previous = texture2D(uPreviousFrame, vUv);

// Exponential decay
vec4 decayed = previous * uDecay;

// Add new beam energy
vec4 combined = decayed + current;

// Hard clamp at a reasonable HDR ceiling
combined = min(combined, vec4(2.5));

gl_FragColor = combined;
Enter fullscreen mode Exit fullscreen mode

The uDecay uniform (typically 0.85–0.95) controls how long traces persist. At 0.95, a trace takes about 60 frames to fade to near-zero — roughly one second at 60fps, which matches the persistence of P31 phosphor used in many real oscilloscopes.

The min(combined, 2.5) clamp prevents infinite accumulation. Without it, a stationary beam would push values toward infinity. The ceiling of 2.5 is chosen so the composite shader's Reinhard tone mapping still has headroom to work with.

This pass uses a ping-pong buffer: two HalfFloat render targets that swap each frame. The previous frame's output becomes the next frame's input.

Pass 3: Bloom

The bloom pass creates the characteristic CRT glow by blurring the phosphor buffer. It uses a separable 13-tap Gaussian blur in two passes (horizontal, then vertical) at half resolution:

float weights[7];
weights[0] = 0.1964825501511404;
weights[1] = 0.2969069646728344;
weights[2] = 0.2195956136;
// ... (symmetric kernel)

for (int i = -6; i <= 6; i++) {
    float weight = weights[abs(i)];
    vec2 offset = uDirection * texelSize * float(i);
    sum += texture2D(uTexture, vUv + offset) * weight;
    totalWeight += weight;
}
Enter fullscreen mode Exit fullscreen mode

Running at half resolution is a deliberate choice: it makes the blur wider (each texel covers 2×2 pixels) while using the same number of taps, and it's cheaper to compute. The slight softness from the downscale actually helps — real CRT bloom isn't sharp.

Pass 4: Composite

The composite shader is where everything comes together. It samples both the phosphor buffer and the bloom texture, combines them in linear HDR space, then applies tone mapping and CRT effects:

vec3 hdr = phosphor + bloom * uBloomIntensity;

// Reinhard tone mapping — the ONLY place this happens
float exposure = 1.5;
vec3 color = hdr * exposure;
color = color / (1.0 + color);
Enter fullscreen mode Exit fullscreen mode

Reinhard tone mapping (x / (1 + x)) compresses HDR values into displayable range while preserving relative brightness. Bright areas stay bright, dim areas stay dim, and nothing clips.

After tone mapping, the shader applies CRT effects:

  • Barrel distortion — simulates the curved glass of a CRT
  • Scanlines — horizontal brightness modulation at display resolution
  • Grid overlay — the 10×10 graticule with major axis lines
  • Vignette — darkening toward the edges
  • Ambient glass glow — a subtle green tint (vec3(0.0, 0.003, 0.0)) that simulates light scattering in the glass

Why HDR Matters

The single most important decision in this pipeline is keeping everything in linear HDR space until the final composite pass.

Here's what happens if you tone-map per frame instead:

Frame Per-frame tone mapping HDR accumulation
1 beam 1.5 → mapped to 0.6 beam 1.5 → stored as 1.5
2 decayed 0.54 + beam 1.5 = 2.04 → mapped to 0.67 decayed 1.35 + beam 1.5 = 2.85 → clamped to 2.5
3 decayed 0.60 + beam 1.5 = 2.10 → mapped to 0.68 composite: tone map 2.5 → 0.79

With per-frame tone mapping, values converge to a fixed point. The phosphor persistence looks flat and lifeless.

With HDR accumulation, dense persistent traces genuinely glow brighter than transient ones. The tone mapping at the end preserves the dynamic range while keeping everything displayable.

Audio Visualization

Phosphor includes four audio display modes that feed signal data into the same rendering pipeline:

  • Waveform — time-domain display using AnalyserNode.getByteTimeDomainData()
  • Spectrum — FFT with logarithmic frequency scale
  • X-Y — stereo oscilloscope (left channel → X, right channel → Y)
  • Radial — circular spectrum with beat detection

You can drag-and-drop audio files or use your microphone. Auto-gain scales weak signals to fill the screen.

Performance

The pipeline runs comfortably at 60fps on integrated GPUs:

Pass Cost Why
Beam Cheap 4096 point sprites with additive blending
Phosphor Cheap Full-screen quad, two texture reads
Bloom Moderate Two half-res blur passes, 13 taps each
Composite Cheap Full-screen quad with math

The HalfFloat textures are the biggest memory cost (4 at full resolution), but modern GPUs handle this without issue.

Try It

The whole thing runs in the browser. No install needed:

→ Live Demo

Or run it locally with Docker:

git clone https://github.com/hubertlim/oscilloscope_playground.git
cd oscilloscope_playground
docker compose up --build
Enter fullscreen mode Exit fullscreen mode

The source is MIT licensed. If you're interested in the shader code, start with the shaders directory.


Originally published on my blog.

Top comments (0)