The hero of this site has a field of dots that ripples away from your cursor. I wanted the 3D version of that feeling: real rigid bodies you can shove around, with actual mass and bounce. The result is Signal Pit — about 130 spheres and cubes in a caged arena, where the cursor is a force field, a click is a shockwave, and gravity is a button.
The catch: this portfolio has a hard performance rule. No perpetual animation, nothing that drains a battery while you read. A physics simulation is the most "perpetual" thing there is — so most of the interesting decisions were about when the simulation is allowed to exist.
The stack
Three.js renders; Rapier simulates. Rapier is a Rust physics engine compiled to WASM, and the @dimforge/rapier3d-compat build inlines the WASM so it works in any bundler without ceremony. Both are imported dynamically insidethe component's effect:
const THREE = await import('three')
const RAPIER = await import('@dimforge/rapier3d-compat')
await RAPIER.init()That single decision keeps the ~1.5 MB physics/3D chunk off every page that doesn't mount the pit. The homepage embed goes further: an IntersectionObserver with a 600px margin only mounts the component when you scroll toward it, and only on machines my perf provider rates as capable. Everyone else gets a CSS teaser that links to the lab.
Two draw calls for 130 bodies
Naively, every body is a mesh — 130 draw calls before lighting. Instead the pit uses two InstancedMeshes (one sphere, one box) and writes each body's transform into the instance matrix every frame:
const t = body.translation()
const r = body.rotation()
matrix.compose(p.set(t.x, t.y, t.z), q.set(r.x, r.y, r.z, r.w), scale)
mesh.setMatrixAt(i, matrix)Per-instance colors come from setColorAt, so the whole pit — every sphere, every cube, all five accent colors — is two draw calls.
The part I care about: it sleeps
Rapier already puts individual rigid bodies to sleep when they stop moving. The pit extends that to the whole system: every frame, if the pointer has been idle and every body reports isSleeping(), the render loop cancels its own requestAnimationFrame and simply stops:
if (!pointerActive && bodies.every((b) => b.isSleeping())) {
running = false
return // no rAF scheduled — the page goes back to costing nothing
}
raf = requestAnimationFrame(loop)A HUD chip flips from ● LIVE to ○ IDLE when that happens, mostly because I like seeing it work. Any pointer movement, tap, or button press calls wake() and the loop resumes. The same logic hard-pauses when the canvas scrolls off-screen or the tab hides. Settle time after a shockwave is a few seconds — after that, the simulation literally does not exist as far as your CPU is concerned.
Forces, not positions
The cursor never moves bodies directly — it applies impulses, so everything stays physical. The pointer is raycast onto a plane just above the floor, and bodies inside a radius get pushed away with a falloff curve, scaled by their own mass so a big cube and a small sphere react believably:
const f = (1 - d / radius) ** 1.6 * force * body.mass()
body.applyImpulse({ x: (dx / d) * f, y: f * upBias, z: (dz / d) * f }, true)A click is the same function with a bigger radius and an upward bias — a shockwave that pops the pile into the air. Flipping gravity is one line (world.gravity.y *= -1) plus waking everyone up; the arena has a lid specifically so the bodies have something to rain onto.
Two mobile lessons, learned the honest way
The first phone test found two real bugs. One: the camera was tuned for widescreen, so portrait phones cropped the arena's sides. The fix computes the horizontal field of view from the actual aspect ratio and pulls the camera back until the arena fits, scaling the fog distances with it. Two: touch-action: nonemade the canvas swallow every swipe — you couldn't scroll past the section. touch-action: pan-y is the correct answer: vertical swipes scroll the page, horizontal drags push bodies, taps still detonate.
Numbers
- ~130 dynamic bodies (80 on lower-tier machines), two instanced draw calls
- 60–120 FPS during interaction, depending on the machine
- 0% CPU when settled — no rAF, no stepping, nothing
- Physics/3D chunk loads only on
/labor near the homepage embed, never up front
Try it in the lab — flip gravity, then watch the status chip go idle.