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.
- 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.
- 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 star and five sparks fly along fixed orbits. Their positions are sums
of sines and cosines of the time, so any frame's positions can be computed
directly.
- Smoke particles leave the star, one per stream each frame, aimed at the
stream's spark. Each frame, every spark pulls every particle with
inverse-square gravity, its own stream's spark 8 times as hard. A particle
lives 2.5 s, widening and dimming as it ages. At 50 fps, about 625 are
alive at once.
- Each particle is drawn as a quad of animated smoke texture, stretched
along its motion that frame, and added to the screen (
GL_SRC_ALPHA, GL_ONE).
- The previous frame isn't cleared. Flurry darkens it with a black
rectangle at alpha 5 × frame time, capped at 0.2: 0.1 at its intended 50 fps.
Old light drops below one 8-bit level after about 53 frames, about 1.1 s.
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 #
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:
- 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.
- 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:
- The particles: 3,600 slots, each with a position, velocity, color and
age, integrated under the sparks' gravity every 20 ms.
- The framebuffer: 640×400 RGB floats. Each step multiplies it by 0.9, as
Flurry's black rectangle at alpha 0.1 does. Then it rasterizes about 625
textured smoke quads into it with additive blending, using Flurry's 8×8
atlas of noisy smoke 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:
- Steady gravity: Flurry scales gravity by
42.5 / (frames / fTime).
Its fTime starts at a random 0–300 s, so for the first minutes the ratio
is far below the frame rate and gravity is many times stronger. The port
uses the value that ratio settles towards at 50 fps, 0.85.
- A float framebuffer: GL's 8-bit framebuffer rounds each fade, which can
leave faint smoke that never quite fades. The port's float framebuffer
decays smoothly.
- Fixed steps: the port always steps 20 ms. Flurry uses the real frame
time, and scales its brightness and fade to match.
- Its own random numbers: the texture speckle and particle jitter use a
fixed xorshift seed, so each load plays the same.
Looped vs persistent #
| Property | Looped WGSL | <qip-play> |
| State between frames | None: a frame is a function of time | Particles and the last frame |
| Smoke paths | Fitted formula | Flurry's simulation |
| Trails | Redrawn from 2.5 s of history | Kept in the framebuffer, faded 10% per frame |
| Work per frame | 200 segments at every pixel | About 625 smoke quads, plus a fade of every pixel |
| Scrub to any time | Yes | No, it must simulate up to it |
| Runs on | WebGPU, or WebAssembly via wgsl-to-wasm | WebAssembly |