The 60fps war: allocations, culling, and quality that degrades politely
A node-graph visualiser has a performance problem by construction: the user is the one deciding, live, how much work each frame does — and they will always decide "more". You can't optimise their patch for them. What you can do is make the engine waste nothing, skip everything invisible, and fail gracefully when ambition outruns the GPU. Three weeks of commits later, that's roughly the campaign map.
Front one: stop making garbage
Sixty times a second, small allocations become a GC metronome — the app
runs smooth, then hiccups on a schedule. The offenders read like a
confession booth roster: the executor rebuilding its node map and edge
index every frame instead of once per graph change; the FFT node
allocating fresh spectrum arrays per frame; the particle node calling
THREE.Color.set per particle per frame (now a pre-parsed hex→RGB
cache); the LAN caster copying ~8MB per frame that a view over the
existing buffer eliminated; a CPU film-grain effect touching every pixel
in JavaScript, replaced by a pre-generated noise canvas; the perf-stats
overlay itself using Array.push/shift — the instrument was causing
what it measured — swapped for a Float64Array ring buffer.
The inspect-node fix is the representative one: inline texture previews were creating ~360 short-lived objects per second, producing a visible GC pause every few seconds. Now a shared preview object is acquired and reused, and the GPU readback writes into a persistent buffer. None of these changes is clever. All of them are the same change: decide at graph-edit time, reuse at frame time.
Front two: don't render what nobody sees
The executor learned to prove work unnecessary. Opacity culling runs lazily, just before the first texture-producing node — crucially after all the number nodes (LFOs, math, phase) have evaluated, so opacity and mix values are actual values, not guesses. A stack layer at opacity zero gets its entire upstream texture tree traced and skipped; a mix node at the extremes culls the loser's branch. The tracing is deliberately conservative — a node shared between a live path and a dead one stays alive — and culled nodes emit nulls so downstream code doesn't need a theory about why an input vanished.
Routing-aware culling extended this from "invisible" to "unreachable": a backward trace from the output node through switches, gates, and compositors, including parameter ports — the LFO driving a live layer's opacity is contributing even though no texture passes through it. The same traversal feeds the UI (contributing nodes get highlighted, so the graph shows you what's actually alive), and it's throttled to every fourth frame because the answer rarely changes faster than that.
Front three: React is in the render loop, whether invited or not
The ugliest fights weren't in WebGL. Dragging a parameter slider tanked the frame rate — not because the graph got slower, but because every slider tick re-rendered ReactFlow's node components. The animation loop was even being recreated on state changes because it sat in a component with dependencies. The fixes are a React performance greatest-hits: state the render loop reads lives in refs, not state; parameter controls are memoised with stable callbacks; components subscribe to narrow store selectors instead of whole objects. The playhead got the same treatment — the store value updates at ~10Hz for the UI, while the render loop reads a raw ref at full rate, so nothing React re-renders sixty times a second. The boundary rule that emerged: React owns structure, refs own time.
Front four: when it's still too slow, degrade politely
This week the safety net landed: adaptive quality. The design choices matter more than the mechanism —
- It measures delivered FPS from rAF timestamps, not executor CPU time — a GPU stall shows up in frame delivery even when your CPU timers swear everything's fine. Measure the symptom the user sees.
- It degrades the resolution preset (4K → 1440p → 1080p → …), a thing the user understands, not an invisible scale factor silently making their work blurrier.
- Sustained sub-24fps for forty frames proposes one step down via a banner; the user confirms. Dismissal is respected for ten seconds. It never auto-upscales — recovering is a human decision.
Alongside it, the freeze-protection thresholds got honest: per-node auto-bypass at 50ms, frame budget cut from a comedic 150ms to 32ms — and source nodes exempted from auto-bypass, because "protectively" bypassing the audio input takes the whole show down with it.
Field notes
Nothing here is novel; every technique is in some GDC talk. What feels worth writing down is the shape of the campaign: allocation discipline bought consistency, culling bought headroom, React containment bought back the UI, and adaptive quality bought a floor under the worst case. Four different failure modes, four different tools — and the diagnosis mattered more than the cure every single time. The perf-stats overlay, the thing that made all of it visible, remains the highest-ROI feature: you can't win a war you can't see.