-
Notifications
You must be signed in to change notification settings - Fork 0
Performance
The canonical, always-current version of this page lives in the repository: docs/PERFORMANCE.md. This page is the short version.
LuminaReef targets a steady 60 fps on integrated graphics. Nothing breaks calm like a dropped frame.
npm run dev # terminal 1
npm run perf # terminal 2
W=2560 H=1440 npm run perf # fill-rate stress
HEADED=1 npm run perf # in a real windowIt reports mean fps, 95th percentile and worst frame, names the renderer, and fails loudly on shader compile errors — which otherwise fail quietly and just make the scene look subtly wrong.
requestAnimationFrame is vsync-locked. A scene holding 60 reports ~60 no
matter how much headroom it has. To compare two versions of a shader you must
push past the refresh rate with W/H, or both runs report 60 and you learn
nothing.
Twelve fish and sixty corals are nothing. Two screen-filling planes are everything.
The seabed and the water ceiling both cover most of the frame and both run
animated caustics per pixel. Those caustics used to hash their noise lattice
with fract(sin(dot(p, k)) * c) — four hashes per octave, four octaves per
fbm(), and the seabed called fbm() twice. Roughly 32 transcendental
hashes per pixel per frame, across nearly the whole screen.
Now the lattice is baked once into a tileable 256×256 texture and sampled with
LinearFilter — which is the bilinear interpolation the hand-written value
noise was doing. An octave costs one texture fetch instead of four hashes,
and it still animates, because fbm() still gets the same time-drifted
coordinate.
AMD Radeon RX Vega 10 (integrated), 2560×1440:
| Tier | Drawing buffer | Mean | Worst frame | |
|---|---|---|---|---|
| Before | lite (never earned promotion) | 2176×1224 | 33.6 fps | 250 ms |
| After | full (shadows + bloom) | 2560×1440 | 45.9 fps | 33 ms |
The rows aren't doing equal work — that's the point. After the change the scene qualified for full quality, so the faster row also renders 38% more pixels and pays for post-processing and shadows the other row never attempted.
Boots in lite, promotes only after drei's PerformanceMonitor watches
stable frames, so loading never hitches on a weak device.
| lite | full | |
|---|---|---|
| Device pixel ratio | 0.85 | 0.85–1.2 |
| Shadows | off | on (static) |
| Post-processing | off | bloom + vignette |
flipflops={2} caps the thrash, and onFallback pins a bouncing device to lite
permanently — otherwise it settles on whichever tier it happened to flip to
last, often the one it just proved it couldn't sustain.
- Static shadow maps — drawn for 8 frames after a garden wakes, then frozen.
- Instancing — corals and fish; the swim cycle runs in the vertex shader.
- An allocation-free frame loop — 60 allocations a second are 60 GC pauses, and GC pauses read as stutter.
-
Event-driven state — nothing gamified touches
useFrame. - Five lights, counted deliberately — every light taxes every lit pixel.
- No downloads — procedural shaders, runtime-synthesized audio, three ~270 KB fish GLBs.
-
Covers a lot of screen? Its fragment shader is the whole story. Fade or
discardearly. - Runs per frame per entity? Check allocations, then ask if the GPU could do it from an attribute.
- Adds a light? It taxes the whole scene, not just its own glow.
-
Only changes on an event? It belongs in an action, not
useFrame.
Playing
Building
Repository