Fit a WebGPU particle world into 304 KB, loaded after first paint

This portfolio, built as a production project with budgets, tests and CI

SDEAI/MLLLM

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
On this page
A still of the world in its AI/ML formation, captured from the high tier on a machine with a GPU (POSTER_TIER=high npm run capture-poster)

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.

Architecture: This siteBrowser, Static pages (Astro), Boot code (poster check), Lab demos and explorer, Poster stills, World engine, Compute kernel (WebGPU or WebGL2). Connections: Browser to Static pages (Astro); Static pages (Astro) to Boot code (poster check); Boot code (poster check) to World engine; Boot code (poster check) to Poster stills; World engine to Compute kernel (WebGPU or WebGL2); Static pages (Astro) to Lab demos and explorer; Lab demos and explorer to World engine.GPU availableno usable GPUon demandBrowserclientStatic pages (Astro)serviceBoot code (poster check)gatewayLab demos and explorerservicePoster stillsdata storeWorld engineserviceCompute kernel (WebGPU or WebGL2)model

Request flows

  1. Full world: Browser → Static pages (Astro) → Boot code (poster check) → World engine → Compute kernel (WebGPU or WebGL2)
  2. Poster: Browser → Static pages (Astro) → Boot code (poster check) → Poster stills
  3. Open the explorer: Browser → Static pages (Astro) → Lab demos and explorer → World engine → Compute kernel (WebGPU or WebGL2)

Architecture: This site

Components

Request flows

Choose a component to focus it, or a flow to watch its requests. In the view, drag or use the arrow keys to turn it and + or − to zoom. Escape closes.

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.

pssaikumardangeti@gmail.com