Flurry: looped WGSL vs persistent state

Flurry is Calum Robinson's 2002 screensaver, which Apple shipped with Mac OS X. Glowing streams of smoke whip around a dark screen. This page ports its Classic preset (five streams in tie-dye colors) two ways, from the Mac source and the version in xscreensaver (hacks/glx/flurry*.c). Flurry is © 2002 Calum Robinson and licensed under the BSD 3-Clause License.

  1. Looped WGSL: a stateless shader draws each frame from the time alone. The page runs it with WebGPU and as WebAssembly compiled by wgsl-to-wasm.wasm, and compares the two.
  2. Persistent state: a <qip-play> component keeps Flurry's particles and its last frame in memory, and draws each new frame on top of the previous one, as Flurry does.

How Flurry draws a frame #

A particle's path depends on every frame before it, so a shader can't compute it from the time. The looped version approximates it; the <qip-play> version runs the simulation itself.

1. Looped WGSL: every frame from scratch #

WebGPU
WebAssembly
Absolute difference ×255: black → red → yellow, so yellow is a whole 8-bit level or more

Loading…

The looped version replaces the particle simulation with a formula. A particle born age seconds ago is placed partway along the line from where the star was at its birth towards where its spark was a quarter of the way through its flight, 0.28·age + 0.03·age² of the way along. We fitted that curve and the quarter to the positions in a run of Flurry's own simulation. The real particles scatter about 140 units around the fitted line, so the shader widens older smoke to match.

It runs as two fragment shaders, each the same WGSL on both sides:

  1. Points computes 41 points along each of the 5 ribbons, and each segment's color, into a 41×10 corner of an RGBA32F texture. The rest of the texture stays black, and only its size is used.
  2. Ribbons draws every pixel by looping over all 200 segments, adding a soft capsule for each one that's near. It reads the points with textureLoad.

The split keeps the star and spark trigonometry out of the per-pixel loop. Each frame still redraws 2.5 s of history at every pixel, so the work grows with how much history it reconstructs. The ribbons don't have Flurry's smoke texture, and the shader doesn't add the 1.1 s motion blur from the fade.

The star and sparks take sin and cos of angles up to about 140 radians. WGSL doesn't bound the GPU's error outside [−π, π], while the Wasm build reduces the angle by π/2 first. So the two sides' points can drift apart, and a point that moves moves the edge of a ribbon. The summary reports how far the points moved and how many pixels differ by a whole 8-bit level. In headless Chrome on a Mac at the default time, the points moved by about 0.015 px and no pixel differed by a level.

2. Persistent state: a <qip-play> component #

flurry.wasm is a port of Flurry's update and draw code to Zig (gui/flurry.zig in the qip repository). It keeps two things in its memory between frames:

<qip-play> asks for a wake every 20 ms. A frame costs only that step's work, whatever the length of the trails, because the trails are already in the framebuffer. If the page falls behind, the component runs up to 4 steps per wake and drops the rest.

It differs from Flurry in a few ways:

Looped vs persistent #

PropertyLooped WGSL<qip-play>
State between framesNone: a frame is a function of timeParticles and the last frame
Smoke pathsFitted formulaFlurry's simulation
TrailsRedrawn from 2.5 s of historyKept in the framebuffer, faded 10% per frame
Work per frame200 segments at every pixelAbout 625 smoke quads, plus a fade of every pixel
Scrub to any timeYesNo, it must simulate up to it
Runs onWebGPU, or WebAssembly via wgsl-to-wasmWebAssembly