SDEAI/MLLLM

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

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

Sole engineer and designer

The 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.

Team: Just me. Timeline: September–October 2026.

What changed

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

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.

Decision 1 of 3

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.

Decision 2 of 3

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.

Decision 3 of 3

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/.

Blending formations without branching

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));
}

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.

Questions?

Read the full write-up