Fit a WebGPU particle world into 304 KB, loaded after first paint
This portfolio, built as a production project with budgets, tests and CI
Summary
- Problem
- A portfolio that shows graphics skill usually costs load time and accessibility. This one had to be striking and still pass a strict engineering review.
- My role
- Sole engineer and designer
- Team
- Just me
- Status
- Shipped
- Timeline
- September–October 2026
- Stack
- Astro, TypeScript, three.js (WebGPU and TSL), GSAP, Playwright, Vitest, Lighthouse CI
- Next
- Capture the cover still from the high tier on a machine with a GPU.
What shipped
- Kept the 3D engine to 304.6 KB after first paint, within a 320 KB budget, by running every formation through one branch-free compute kernel on WebGPU and WebGL2.
- Scored 99–100 on Lighthouse mobile performance across six page types (poster path, no GPU) by choosing the fallback before the engine downloads.
- Matched the GPU kernels to the CPU specification within 0.2% by comparing GPU read-back with the CPU simulation in CI.
- Graphics engine
- 304.6 KB
- Baseline: 320 KB budget
- Gzipped JavaScript the engine adds after first paint, measured on the built site by scripts/budgets.ts in CI
- JavaScript before idle
- 10.9 KB
- Baseline: 50 KB budget
- Gzipped initial JavaScript on project pages (40.3 KB on Home, which adds the one-time intro), same script
- GPU kernel against CPU spec
- < 0.2% error
- Playwright steps the WebGPU and WebGL2 kernels once and compares the buffers with the CPU simulation, in CI on Chrome 153
- Lighthouse performance (mobile)
- 99–100
- Three Lighthouse CI runs per page (Home, projects, a deep dive, a presentation, the Lab and the résumé) on the built site. CI has no GPU, so this measures the poster path; frame rates on real devices are still to be measured
- Code available on request
- Present this project
On this page
Context and constraints
The brief set hard limits: 50 KB of JavaScript before idle, strict Lighthouse scores, WCAG 2.2 AA, and graphics that are extraordinary yet always mean something. Every number on this site had to come from a script, not an estimate.
Architecture
Pages are static Astro. One #world element persists across navigation, and pages describe the formation they want with data attributes. After first paint, boot code decides between the poster and the engine. The engine builds all five formations once, simulates them in one compute kernel, and renders them as one instanced sprite draw. A camera rig and GSAP scroll progress blend between them. The architecture explorer and the three Lab demos load only when opened; the explorer rewrites one formation’s data so the same particles become the project’s architecture.
Request flows
- Full world: Browser → Static pages (Astro) → Boot code (poster check) → World engine → Compute kernel (WebGPU or WebGL2)
- Poster: Browser → Static pages (Astro) → Boot code (poster check) → Poster stills
- Open the explorer: Browser → Static pages (Astro) → Lab demos and explorer → World engine → Compute kernel (WebGPU or WebGL2)
Engineering decisions
How do GPU and CPU simulations stay in step?
- Options
- Write them separately and eyeball it
- Generate both from one source
- Treat the CPU version as the specification and test the GPU against it
- Chose
- The CPU version is the specification, and a test compares GPU read-back with it
- Trade-off
- The same logic exists twice, so every change touches both files.
- Evidence
- Changing one constant in the GPU kernel only makes the test fail with a 7.7% error against a 0.2% tolerance.
What happens on a browser without a usable GPU?
- Options
- Run the low tier anyway
- Show nothing
- Show a poster chosen before downloading the engine
- Chose
- A poster, chosen by about 1 KB of boot code
- Trade-off
- Visitors with a software renderer never see the moving world.
- Evidence
- Headless Chrome with software rendering blocked the main thread for 2.4 s per frame; with the check, blocking time is 0 ms.
How does a Three.js bug stay out of the kernel?
- Options
- Pin an older version
- Work around it per call site
- Write the kernel with no branches around buffer reads
- Chose
- No branches around buffer reads
- Trade-off
- The kernel evaluates all five formations for every particle.
- Evidence
- A 40-line reproduction shows WebGL2 reading wrong elements inside a second sibling If() block; the branch-free kernel matches the CPU specification on both backends. The reproduction is in docs/upstream/.
Evaluation
CI runs lint, type checking, 142 unit tests, 103 end-to-end tests with axe accessibility checks in both themes, the size budgets and Lighthouse CI on every push.
| Check | Result |
|---|---|
| Graphics engine after first paint | 304.6 KB of 320 KB |
| JavaScript before idle | 9.7–10.9 KB (Home 40.3 KB) of 50 KB |
| Architecture explorer, loaded on demand | 3.7 KB of 25 KB |
| Lab demos, loaded on demand | 5.4–7.6 KB each, of 25–35 KB |
| GPU particle kernel against CPU spec (WebGL2 and WebGPU) | under 0.2% error |
| GPU training kernels against CPU spec (40 steps) | largest difference 0.00000012 |
| High tier, MacBook Air 13-inch (Apple M5, 16 GB), Chrome | 61 fps, 16.6 ms frames (the display’s 60 Hz), 1.44 ms of GPU time (light theme); 65,536 particles in 1 draw call and 1 compute pass |
| Low tier CPU simulation, 4,096 particles | 4.0 ms a step, CPU only |
| Lighthouse mobile, six page types | performance 99–100, 0–23 ms blocking |
| Home largest paint (hero text), 12 runs | median 1.66 s, worst 1.81 s, of 2.0 s |
Deployment and operations
Vercel builds production strictly: the build fails while any placeholder remains. Preview deploys show placeholders as badges. The world picks a quality tier on each device, steps down when frames are slow or the GPU fails, and mirrors its state onto the page for tests.
Results
The site meets every budget in CI, and the hero text is always the largest paint. On a MacBook Air 13-inch (Apple M5, 16 GB) in Chrome, the high tier runs its 65,536 particles in one compute pass and one draw call at 61 frames per second. The 16.6 ms frame time is the display’s 60 Hz refresh, not the work: the GPU needs 1.44 ms a frame in the light theme, under a tenth of the budget, so the world leaves the machine nearly idle. A machine without a GPU renders in software at 2 to 7 frames per second, which says nothing about real hardware. On a CPU the low tier’s simulation step takes 4.0 ms for 4,096 particles, which leaves room inside a 33 ms frame on a phone a few times slower. A mid-range phone on the low tier is the next measurement to take.
Code highlight
Blending formations without branching
Every formation is evaluated and weighted, so moving between shapes is one continuous blend, GPU threads never diverge, and the WebGL2 bug has nothing to trigger on.
const base = i.mul(STRIDE);
for (let k = 0; k < K; k++) {
const w = U.weights[k];
const A = formBuf.element(base.add(k * 2)).toVar();
const B = formBuf.element(base.add(k * 2 + 1)).toVar();
const f = formationTarget(k, A, B, h, U);
tgt.addAssign(f.pos.mul(w));
sig.addAssign(f.sig.mul(w));
vis.addAssign(f.vis.mul(w));
hue.addAssign(f.hue.mul(w));
rigid.addAssign(f.rigid.mul(w));
}What I'd do next
Move rendering into a worker with OffscreenCanvas, so even a slow GPU never touches the main thread.
Talk about this project
I'm happy to walk through any decision here in more depth.