How it runs so smoothly
Ooh Bubbles simulates up to a couple of hundred soap bubbles, each with its own film thickness, wobble, colour and pop, and draws them at your screen’s full sharpness, aiming for 60 frames a second, in an ordinary browser tab. Not every device gets there; when one can’t, it shows fewer bubbles rather than a blurrier picture. Here is how, and how we got there.
The shape of the system
Physics in Rust, compiled to WebAssembly
Why
The simulation started in TypeScript. As it grew (films, contacts, wind, smoke, rupture) the hot loops started eating the frame. Moving them into a Rust crate compiled to WebAssembly with SIMD gave large, measured wins: the wind field went from 26–28% of a busy main thread to 6–7% at 300 bubbles; bubble wobble from 11–15% to 2–3.5%; one field-filling kernel from 4.3 ms to 0.98 ms. Keeping physics free of renderer imports also lets it run off the page thread, and a deterministic thread pool means scheduling changes which helper computes a chunk, never the order or arithmetic within it.
How
- One crate (
wasm/) holds the kernels: fluid and wind, film thickness and soap chemistry, wobble, contacts (broad phase, a small QP solver, warm starts), and the event engine that runs rupture, rim, spray and air fronts. Its only dependency islibm. Release builds use full optimisation, link-time optimisation and one codegen unit. - Pinned, reproducible builds. Nothing compiled is committed.
scripts/build-wasm.mjspins rustc 1.95.0; on Cloudflare Pages, whose build image has no Rust, it downloads a pinned rustup (checked against a SHA-256) and installs the toolchain. It clears environment overrides so the crate’s own settings always apply, refuses to build without SIMD128, and then runs a pinned Binaryenwasm-opt -O2. - Two flavours: a serial kernel and a threaded one (atomics, shared memory, 128 MiB cap). Only the one your browser can use downloads at startup.
- A worker with shared memory. Every response carries cross-origin isolation headers (COOP
same-origin, COEPrequire-corp), which browsers require before they hand outSharedArrayBufferand WebAssembly threads. Worker files are named with a-coep-marker, and the release check fails the build if any worker lacks it. Commands go to the worker through a shared ring; compact snapshots come back (rows shrank from 1.29 MB to 181 KB). - A thin TypeScript shell handles input, UI and drawing, and talks to the kernels through small adapters.
- Checkpoints are versioned snapshots of the world state, used to capture a busy scene once and replay it exactly when benchmarking a change. They are a developer tool, not a save feature.
Raw WebGPU, no WebGL
Until mid-October the site drew with WebGL2 through three.js. In one large change (PR #279) the renderer moved to raw WebGPU with hand-written WGSL shaders on a single shared GPU device, with compute passes for film colour tables and reductions, and per-tile event batching. three.js and every WebGL path were removed; there is no fallback renderer. The bar for merging was “at least as good as the live WebGL site” on load and frame time; on a 3840×2160 Studio frame with 200 bubbles the median frame went from 28.2 ms to 21.4 ms.
Each frame runs, in order: the studio background (with tile-binned bokeh), the bubbles (thin-film optics sampled from a colour atlas computed on the GPU), reflections and particles (sharing one render pass on tile-based phone GPUs), depth of field at half size, multi-level bloom, and a final composite for tone mapping, vignette and dither.
Measuring the GPU honestly
- Timestamp queries bracket each pass. Each marker is its own tiny compute dispatch, because on iPhone Safari an empty timestamp pass recorded nothing.
- Distrusting the timer. GPU timers can lie: on some stacks (ANGLE on Metal, in the WebGL days) they read 2–10× the real cost. So the engine checks: if 90% of frames arrive on time but the timer says the work exceeds a frame for two 120-frame windows running, it marks the timer untrusted (
timerTrusted = false) for the session and steers by the wall clock instead. - Headed runs on real displays. Headless browsers distort timing, and other apps can keep a GPU 0–63% busy, so measurements run in a visible browser on real 60 Hz screens, and variants are compared by alternating them in one page rather than across runs.
scripts/measure-page.mjsattaches to a running browser and samples submissions, GPU time, trust, CPU time, bubble count and quality level, and fails if the pixel ratio or canvas size ever changes.
Testing shaders
Unit and GPU tests check optics against references: the film colour lookup must stay within 0.005 per colour channel of a reference calculation for a single film (0.02 where films cross), and within 2/255 on screen after the final post-processing; rim films are checked against a pixel-averaged response, and whole sRGB frames against refined references. CI runs typechecks, a sharded fast suite and a full release build on a self-hosted runner, plus Playwright runs on WebKit (iPhone and iPad) and Android Chromium.
The governor: shed bubbles, never pixels
When a device can’t keep up, many apps quietly lower the resolution. Ooh Bubbles never does: it always draws at your screen’s native pixel ratio, with no upscaling. Instead a governor watches for sustained lateness (medians over at least a second, a frame counting as late past 1.25 frame budgets) and gives things up in this order:
- local soap-flow detail on the most prominent bubbles,
- effects, step by step,
- bubbles that are off screen,
- visible bubbles, down to a floor for your device.
It recovers in reverse, slowly and in small steps. A single long stall counts as one observation, so a one-off hitch isn’t mistaken for a slow device. It assumes a 60 Hz display and only treats a screen as 120 Hz after actually seeing frames at that rate.
Phones and desktops
| Device class | Starts with | Up to | Sheds down to |
|---|---|---|---|
| Phone | 55 | 120 | 55 |
| Tablet | 120 | 180 | 100 |
| Desktop | 180 | 225 | 55 |
| Strong desktop GPU | 200 | 250 | 55 |
Ceilings shrink on very large screens. A phone is a device with a coarse (touch) pointer and a short screen side; the engine deliberately doesn’t guess from GPU features, because iPhone Safari exposes the same timer features as a desktop. On a 5K desktop display, 225 bubbles held 87–98% of frames on time where 300 managed only 29–88%, which set the desktop ceiling.
On an iPhone at native 1206×2613, the first version ran at 10–15 fps; a dedicated phone tier brought it to 35–47 fps with about 40 bubbles, and later work (smaller film tiles, size-based detail, capped spray) kept improving it. If a phone is still struggling at its floor with all effects gone, a one-way lite tier keeps native resolution and crisp edges but uses a faster film pipeline, updates films less often and drops bloom.
Starting fast, and Safari’s freezes
WebGPU pipelines must be compiled before they draw, and the full bubble optics are big. The startup path:
- The first HTML already requests the engine’s script, the WebAssembly and the physics worker, and the worker spawns alongside the download.
- A wand loader paints from the very first frame and stays until real bubbles are on screen.
- A small, fast pipeline shows bubbles at full resolution first; each kind of bubble switches to full optics once its own pipelines are ready. A cold desktop Chrome start went from 1.85 s to 0.84 s for the first live frame.
- Optional pipelines (bloom, depth of field) compile after the first frame and fade in.
The Safari freeze. On a real iPhone, the first 11–13 seconds were smooth at 60 Hz, then the page collapsed to 0.2 fps with multi-second frames. Safari blocks the page for every pipeline compile, even the “async” one, at around 4 s each for the full optics. After the reveal the renderer compiled more variants; the governor read each freeze as load and stepped down, which triggered more compiles. The fix is compile fencing: on engines that compile one pipeline at a time, startup compiles exactly one optics tier (at one inter-reflection level on phones), lower tiers reuse it, and nothing new compiles after the reveal. Compiles are paced one per presented frame. Chromium keeps its parallel path.
The iPhone cold start. On a first visit nothing is cached, and cold WebKit took 7–10 s to compile each full-optics pipeline. The first reveal waited on 2 fast pipelines plus 9 full ones, about 25 s on a real iPhone before bubbles appeared. Three changes cut that down: startup diagnostics now tell time spent inside graphics calls apart from compiles still pending (#1398); physics starts before the first-light GPU fence instead of after it (#1400); and the full-optics shader is pruned to only the code each pipeline uses, with one general full-optics program shared by every kind of bubble, so Safari compiles two full pipelines instead of nine (#1407). The shared program is the full optics, not a cut-down version. On the same iPhone, a cold first visit now shows bubbles at about 5.7 s instead of 25 s, and settles at 60 fps.
Other quirks we met: WebKit’s first job per worker took about 50 ms while it optimised the kernel, so workers warm up first; and opening a page with ?bootReport=1 records a startup timeline from a real device, which is how the iPhone problems were found.
Keeping it small
Every build ends with a release check on the output. These checks fail the build: content-hashed names for every cached asset, the -coep- marker on every worker, WebAssembly only as .wasm files, and no large payload duplicated across chunks. Sizes are reported against Brotli budgets but only warn, because performance and correctness come first:
| Budget (Brotli) | Limit |
|---|---|
| Player page initial JS | 57 KiB |
| Player page initial JS, CSS and fonts | 210 KiB |
| Engine initial JS | 685 KiB |
| Engine initial transfer | 690 KiB |
| Startup WebAssembly | 410 KiB |
These pages are plain static HTML with no script, so reading them costs the app nothing.
How we got here
- 22 Sep First commit: a physically based soap bubble simulator, ray-traced in WebGL.
- 1–2 Oct Wind and wobble move to WebAssembly; the Pages build starts compiling Rust from source (#28).
- 6–7 Oct Batched SIMD kernels, a dedicated physics worker, a deterministic thread pool (−17.7% frame time with 4 threads).
- 12 Oct Adaptive quality fixed to need real late frames (5K on-time frames from 51–77% to 100%, #233); thin-film colour lookup table (−26% frame time, #237).
- 13 Oct Raw WebGPU and WGSL replace WebGL (#279); one startup kernel download (#333); Binaryen optimisation (#352); governor works on sustained medians (#368).
- 15 Oct Native pixels always: no resolution scaling (#1029).
- 16 Oct GPU work bounded to two frames ahead (#1097), early preloads (#1111), wand loader (#1121), fast first pipeline (#1128), device boot reports (#1132).
- 17 Oct Compile fencing for Safari (#1153).
- 18 Oct Phone tier (#1169) and lite tier (#1185).
- 20–21 Oct Smaller phone film tiles (#1195), capped phone spray (#1208).
- 22 Oct Spray captured by neighbouring bubbles (#1393); iPhone cold start from 25 s to 5.7 s (#1398, #1400, #1407).