Skip to content

Performance

ArdaDDemir edited this page Jul 16, 2026 · 1 revision

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.

Measure it

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 window

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

The cost is fill rate, not simulation

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.

Measured

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.

Adaptive quality

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.

The rest of the budget

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

Adding something?

  • Covers a lot of screen? Its fragment shader is the whole story. Fade or discard early.
  • 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.

Clone this wiki locally