Radiant / Deep dive / Analog Drift

How to render a CRT oscilloscope

Two sines and a buffer you never clear. Lissajous figures, phosphor persistence, and the velocity-modulated thickness that sells the analog feeling.

· 15 minute read · 5 interactive sandboxes
Scroll

Two sines, plotted against each other on a 2D canvas. Don't clear the canvas between frames. That's the whole effect. Everything else is decoration on top of those two facts.

The shape comes from Lissajous figures, the curve you get when you feed one oscillating waveform into the X deflection of a cathode-ray tube and another into the Y. The glow comes from phosphor persistence, the property that made CRT screens look analog: the screen keeps emitting light for a moment after the beam moves on. We fake both.

A.k.a. Lissajous

Named for Jules Antoine Lissajous, who studied them in the 1850s by reflecting light off mirrors attached to tuning forks. The parametric form is two sines:

x(t) = sin(a·t + δ)
y(t) = sin(b·t)

Three parameters. a is how often the X coordinate cycles per unit of t; b is the same for Y; δ is the phase offset between them. Plug those into a 2D plot and step t from 0 to 2π·max(a, b) to draw a full loop. Drag the knobs below to feel it.

x = sin(3·t + 0.79) · y = sin(2·t)· ratio 3:2
Three sliders, one equation. Integer ratios (1:1, 2:3, 3:5) produce closed curves; non-integer ratios produce dense fills that never close.

The ratio a:b determines the figure's shape. 1:1 with δ = π/2 is a circle; 1:1 with δ = 0 is a diagonal line. 1:2 is a figure-8. 2:3 is a trefoil. The numerator counts X loops per period, the denominator counts Y loops. As the integers grow, the figure gets denser. Irrational ratios fill the box uniformly and never quite close, which is its own visual signature.

The phase δ controls how the two oscillations line up. Slide it from 0 to π/2 on a 1:2 figure and you see the figure-8 dissolve into a parabola, then into a tilted figure-8 on the other side. The shape is alive in three dimensions of parameter space.

js
1// One frame's worth of curve. Two sines, orthogonal axes.
2const N = 3000;
3const coverage = 2 * Math.PI * Math.max(Math.ceil(a), Math.ceil(b));
4const step = coverage / N;
5 
6ctx.beginPath();
7for (let i = 0; i <= N; i++) {
8 const t = i * step;
9 const x = cx + Math.sin(a * t + delta) * size;
10 const y = cy + Math.sin(b * t) * size;
11 if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
12}
13ctx.stroke();
One frame's drawing. 3000 points along the parameter range, one beginPath, one stroke. Canvas 2D handles the rest.

Phosphor persistence

A static Lissajous is a math plot. The CRT look is what you get when you stop clearing the canvas between frames. Instead of ctx.clearRect or fillRect(black) at full opacity, draw black at low alpha. Each old pixel decays toward black a little per frame; new pixels arrive at full brightness. Older parts of the trace fade away while the leading edge stays bright.

The math is straight exponential decay. If you blend a frame against a black overlay with alpha α, the pixel value after n frames is v · (1 − α)^n. At α = 0.025, after 60 frames a pixel is at (0.975)^60 ≈ 0.22 of its initial brightness, so the trail is visible for about a second before fading below perceptible. Crank α to 1 and you get a hard clear (no trail at all). Crank it to 0 and pixels never fade (the screen burns in).

js
1// Instead of clearing the canvas, draw black at low alpha over it.
2// Every old pixel decays exponentially: brightness *= (1 − fadeAlpha)
3// per frame. Smaller alpha = longer phosphor persistence.
4const fadeAlpha = 0.025 * TRAIL_LENGTH;
5ctx.fillStyle = `rgba(10, 10, 10, ${fadeAlpha})`;
6ctx.fillRect(0, 0, W, H);
7 
8// Then draw this frame's curve on top. Each frame's stroke is the
9// brightest layer; older frames recede toward black.
10drawCurve(a, b, delta);
The whole phosphor-persistence trick. Three lines of meaningful code.
Try it
The toggle switches between hard clear (no memory) and phosphor fade (exponential decay). Watch what happens to the same animating curve when you flip it.

There's no real physics here: a real CRT phosphor has a non-exponential decay curve with a fast initial component and a long tail. The exponential we use is close enough that nobody notices. The visual signature lives in two things: that the trail fades smoothly to black, and that the leading edge of the trace is always the brightest pixel on screen. Both come free with one fillRect.

Velocity tells brightness

Watch a real oscilloscope. The trace is brighter where the curve loops back on itself and dimmer where it sweeps across long distances quickly. The reason is physical: the electron beam moves at constant angular speed but the trace it draws goes faster through some regions than others, and pixel brightness depends on how long the beam dwells per pixel. Slow regions accumulate more photons.

The shortcut: sample the segment speed periodically and scale the line width inversely. Where the curve moves slowly between consecutive points, the stroke is fat. Where it sprints, thin. One formula:

thickness = base · (0.6 + 1.2 / (1 + speed · 0.25))

Tuning is empirical. The 0.6 floor keeps fast regions from disappearing entirely. The 1.2 multiplier sets how much variation you get. The 0.25 scale on speed sets the speed at which the falloff kicks in. None of these are derivable; you find them by sliding numbers until the trace looks right.

js
1// Pre-compute all (x, y) into typed-array buffers, once per frame.
2for (let i = 0; i <= N; i++) {
3 const t = i * step;
4 ptX[i] = cx + Math.sin(a * t + delta) * size;
5 ptY[i] = cy + Math.sin(b * t) * size;
6}
7 
8// Then stroke in batches of 20 points, with one thickness per batch
9// sampled from the segment speed at the batch's midpoint.
10const batch = 20;
11for (let i = 0; i < N; i += batch) {
12 const end = Math.min(i + batch, N);
13 const mid = Math.min(i + (batch >> 1), N - 1);
14 const dx = ptX[mid + 1] - ptX[mid];
15 const dy = ptY[mid + 1] - ptY[mid];
16 const speed = Math.sqrt(dx * dx + dy * dy);
17 
18 // The inverse-speed formula: thicker where the curve lingers.
19 let mult = 1.0 / (1.0 + speed * 0.25);
20 mult = 0.6 + mult * 1.2;
21 ctx.lineWidth = BASE * mult;
22 
23 ctx.beginPath();
24 ctx.moveTo(ptX[i], ptY[i]);
25 for (let j = i + 1; j <= end; j++) ctx.lineTo(ptX[j], ptY[j]);
26 ctx.stroke();
27}
Compute all points into typed arrays first, then stroke them in batches of 20 with one thickness per batch. Batching keeps Canvas 2D's draw count manageable; without it, calling lineWidth per segment would be ~150× more expensive.
Try it
Same 3:2 figure either way. Toggle modulation on and watch the slow lobes get fat while the fast sweeps stay thin. The strength slider exaggerates or softens the effect.

With modulation off, the trace looks like a CSS border drawn around an SVG shape: technically correct, visually flat. With modulation on, the figure suddenly has weight. The same 3000 points, the same colors, the same phosphor fade. The only difference is that the renderer now respects how long the beam spent at each location.

Drifting through parameter space

A frozen Lissajous figure is a math demo. The full Analog Drift effect comes from slowly animating (a, b, δ) through a sequence of curated waypoints, so the trace morphs between known-nice figures over a span of tens of seconds. The phosphor persistence makes the transitions visible: old waypoints are still glowing while the new one draws itself on top.

Picking waypoints is curation, not math. Ten figures, hand-chosen: a circle, a figure-8, a trefoil, a few rose-like ones, then back to a diagonal line that almost dissolves to nothing before the loop restarts. Random parameter sweeps look chaotic; a curated tour reads as intentional.

The interpolation matters too. Linear interpolation between waypoints feels mechanical: it moves at the same speed through the whole transition, arriving at the new waypoint at the same velocity it left the old one. Ease-in-out cubic is what sells the analog feeling: slow at both ends of the transition, fastest in the middle. The same curve a mechanical control knob would make if you turned it smoothly without overshooting.

js
1// 10 waypoints in (a, b, δ) space. Each one is a closed or
2// near-closed Lissajous figure chosen by eye.
3const waypoints = [
4 { a: 1, b: 1, delta: Math.PI / 2 }, // circle
5 { a: 1, b: 2, delta: 0 }, // figure-8
6 { a: 2, b: 3, delta: Math.PI / 4 }, // trefoil
7 { a: 3, b: 4, delta: Math.PI / 6 }, // rose-like
8 // …six more
9];
10 
11function easeCubic(x) {
12 return x < 0.5 ? 4*x*x*x : 1 - Math.pow(-2*x + 2, 3) / 2;
13}
14 
15function getParams(t) {
16 const n = waypoints.length;
17 const pos = (t % n + n) % n;
18 const idx = Math.floor(pos);
19 let frac = pos - idx;
20 frac = easeCubic(frac); // <-- the analog feel
21 const from = waypoints[idx];
22 const to = waypoints[(idx + 1) % n];
23 return {
24 a: from.a + (to.a - from.a) * frac,
25 b: from.b + (to.b - from.b) * frac,
26 delta: from.delta + (to.delta - from.delta) * frac
27 };
28}
Waypoint storage, easing curve, and interpolation. The ease-in-out cubic is the one place where the choice of math directly affects how 'analog' the result feels.
Try it
The figure drifts through ten waypoints on a loop. Toggle easing off and the transitions feel like a software animation; turn it back on and it feels like a knob being turned by hand. The readout in the top-right shows live (a, b, δ).

Harmonic overtones

One more layer. Stack three additional Lissajous curves behind the main one, each at a multiple of the base frequencies. The 2× harmonic (2a, 2b) is denser and tighter, drawn at about 30% alpha. The 3× harmonic (3a, 2b) is denser still, dimmer. A sub-harmonic (a/2, b/2) goes the other way: larger, slower, even dimmer, drifting behind everything.

All four curves share the same drift waypoints, so they morph together. The harmonics multiply or halve the frequencies, but they're locked to the same δ progression. The visual effect is that the figure looks fuller without looking busy, the same way an audio signal with harmonic overtones sounds richer than a pure sine wave at the same fundamental.

js
1// Sub-harmonic (drawn first, behind everything else)
2drawCurve(a * 0.5, b * 0.5, delta * 0.7 - time * 0.05, 0.42,
3 'rgba(180, 130, 90, 1)', 0.18, 0.6);
4 
5// 3rd harmonic
6drawCurve(a * 3, b * 2, delta * 1.5 + time * 0.08, 0.32,
7 'rgba(200, 149, 108, 1)', 0.20, 0.7);
8 
9// 2nd harmonic
10drawCurve(a * 2, b * 2, delta + time * 0.15, 0.34,
11 'rgba(200, 149, 108, 1)', 0.28, 0.9);
12 
13// Main trace (drawn last, brightest, on top)
14drawCurve(a, b, delta, 0.38, 'rgba(220, 180, 130, 1)', 0.85, 1.5);
Layer order matters: draw back-to-front, so the brightest layer ends up on top. Each harmonic gets its own alpha, thickness, and time-shift, so they don't all phase-lock into one congealed shape.
Try it
A held 2:3 figure with the three harmonic layers toggleable. Turn them off one at a time. The 2nd is the most visually impactful; the sub-harmonic is the most felt-but-not-seen.

The harmonics aren't a single toggle in the production shader; each is configurable independently. Together they account for maybe 5% of the visible image but 30% of the impression that the signal is rich, not synthetic.

Everything together

Add a faint oscilloscope grid behind the trace (subtle, breathing). Add a Gaussian-bloom layer under the main curve using shadowBlur for CRT phosphor glow. Add an offset cyan shimmer trace at δ + 0.01 over the amber base for a hint of chromatic aberration. Add 40 brightly-cored phosphor dots distributed along the curve, pulsing independently of the trace. None of those is essential. All of them are easy.

Try it
The full effect: waypoint drift, phosphor fade, velocity thickness, harmonics, bloom, cyan accent, dot phosphors, grid. Drift speed and trail length are exposed; everything else is hard-coded to taste.

Why this is fast

Four curves of 3000 points each, redrawn 60 times per second, is 720K points per second through Canvas 2D. None of it is intuitively cheap.

Pre-allocated typed arrays. Point coordinates go into two Float64Array(4001) buffers allocated once at startup. No per-frame garbage. The browser's GC never sees this hot loop.

Batched strokes per thickness. Naive line-width-per-segment would call beginPath, stroke, and a state-change 3000 times per curve. Sampling one thickness per 20-point batch is 150 strokes per curve, with the path itself still drawn as one continuous moveTo/lineTo sequence per batch.

Bloom only where it pays. shadowBlur is one of Canvas 2D's most expensive operations: it forces a software pass for the entire stroke. We apply it only to the main trace and the cyan accent (the two layers the eye lingers on), and skip it entirely on the three harmonics.

The whole shader holds 60fps on a 2019 laptop with the dev tools open. The bottleneck isn't math or memory; it's how many times we ask Canvas 2D to flush its state.

Where to go from here

Lissajous on Wikipedia covers the math thoroughly, including the closed-curve conditions and the rational/irrational distinction. MathWorld has a more compact treatment with the parametric forms.

If you want a deeper rabbit hole, the underlying technique generalizes to any 2D parametric curve. Replace the sines with rose curves r = cos(k·θ), with epicycloids, with audio waveforms sampled in real time. The phosphor-fade-plus-velocity-thickness rendering layer is independent of what's being plotted. Feed it any function; it'll look like that function as seen through an oscilloscope.

The whole analog-drift shader is 434 lines of HTML. Roughly half is the curve math and harmonics. A third is the rendering setup. The rest is parameter wiring and the bit of oscilloscope chrome that sells the illusion. Drop it on any page; it has no dependencies.