Engineering Journal

← all posts

Part of Building the 3D Club Scene

Driving a 60 fps WebGL scene from React panels without re-rendering it

Performance3D & motionSoftware & tooling

The Scene Controls panel — Looks, Cues, Palette, and a live pole-color gradient editor — open as ordinary React DOM over the same canvas it's driving, mid-frame, at 60 fps.

Our club scene has ~15 control panels — levels, color grade, look presets, camera, sequencer — all ordinary React DOM. The scene they drive holds 60 fps on the machines we build on, and the scene itself never re-renders when a slider moves; the panel you are dragging does, and that split is the design. The trick is a ~40-line store pattern we now use everywhere: mutable fields for the frame loop, subscribe/snapshot for the panels, and a one-way "seam" object wherever the DOM and the canvas have to talk.


The problem

react-three-fiber puts your scene in the React tree, which is wonderful right up until you wire a slider to it. Put the slider's value in React state and every drag re-renders whatever subtree consumes it — at 60 events per second, through components that own GPU resources. Context makes it worse by fanning the invalidation wider. You can memo your way around some of it, but you're fighting the framework on every panel you add, and we add panels constantly.

The observation that fixes it: the scene doesn't need to re-render when a value changes. It's already rendering, sixty times a second, inside useFrame. It just needs to read the current value each frame. Reads are free; re-renders are not.

The store

Every tunable subsystem gets the same tiny external store. Here's the whole shape of our Levels store (eleven per-element brightness multipliers):

const state = { values: initial(), version: 0, listeners: new Set() }

export const LEVELS = {
  // hot path: called inside useFrame every frame
  get: (id) => {
    const v = state.values[id]
    return typeof v === 'number' ? v : 1
  },
  set(id, value) {
    const v = Number(value)
    if (!isFinite(v) || state.values[id] === v) return
    state.values[id] = v
    persist()          // localStorage, inside the store, not a component
    emit()             // version++ and notify listeners
  },
  snapshot: () => state.version,
  subscribe(l) { state.listeners.add(l); return () => state.listeners.delete(l) },
}

The two halves have different consumers and different rules.

The frame loop reads plain fields. An element multiplies LEVELS.get('lasers') inside its own useFrame and sees a slider drag on the next frame. No React involved. The contract on get matters more than it looks: unknown id returns 1, a no-op, so an element without a slider is simply left at its shipped brightness and adding a channel never requires touching consumers.

The live stage with the React Scene Controls panel open over the WebGL canvas, including look presets, cue controls, and palette editors.

The two worlds in one frame: ordinary DOM controls on the right, a continuously rendering WebGL scene on the left. The store is the seam between them.

The panel subscribes. The DOM side is one line, useSyncExternalStore(LEVELS.subscribe, LEVELS.snapshot), and the version counter as snapshot means the panel re-renders when anything changes, and nothing inside the canvas does. The panel is cheap; it's a column of sliders. (useSyncExternalStore rather than a hand-rolled subscribe-and-setState because it's the API that exists specifically so an external store can't tear under a concurrent render.)

persist() sitting inside set means a drag writes localStorage on every step, synchronously, on the thread that renders the canvas. We never thought about that, and the only reason it doesn't hurt is scale: an eleven-key object at slider-event rate. If the store held a large object, or a panel fired at pointermove rate, that write wants debouncing out of the setter.

The discipline that keeps the hot path hot: fields the frame loop reads are plain mutable data, written once per frame by a single driver, and no getters doing work. Our look director computes sequence gains into a plain object each frame; elements call LOOK.gain(zone), which is a property lookup and a typeof check. The eased tint is a THREE.Color the driver lerps in place. Nothing allocates.

Seams: when the DOM has to steer the scene

Stores cover values. Commands are uglier — "fly the camera to this preset" crosses from a click handler into a system that lives inside the render loop. Our answer is a seam object: a module-level object with a want field the DOM writes and the scene consumes, and sometimes an active field flowing back the other way.

export const CAM_PRESET = {
  want: null,    // preset id the panel asked for
  active: null,  // preset currently flying/holding; null once the user grabs
  go(id) { this.want = id },
}

A driver component inside the canvas checks want each frame, flies the camera, keeps active current, and clears it the moment the user grabs the view. The panel polls the seam on a 350 ms interval to paint its highlight. Yes, polls. It's a highlight on a button; 350 ms is imperceptible there, and the alternative is event plumbing between two worlds that otherwise share nothing. We use the same shape for the cinematic transport and the moving-head cue bus. The rule underneath both halves: the frame loop never waits on a subscription, and a command never travels through React state.

One slot means two clicks inside one frame coalesce to the second, with no acknowledgement back. For "go to this camera preset" that's the behavior you want: the last thing the operator asked for is the thing to do. It is exactly the wrong shape for a command that must not be dropped, and a seam like this would need a queue or a generation counter before you put one through it.

Ordering, or: one driver to rule the frame

With many systems reading shared state per frame, when it's written matters. Our beat clock and look director tick first, from the app root, before any element's useFrame runs. Every element in a given frame then sees the same palette, the same tint, the same gains. Before we enforced that, elements sampled mid-update and shimmered in ways that never reproduced twice.

"Enforced" is generous. What actually holds the order is that the ticker component is the first child in the canvas tree, so it subscribes to useFrame first and equal-priority callbacks run in subscription order. We declare no useFrame priorities anywhere. Move that component down the tree and the guarantee quietly leaves with it, which is a rake to leave lying around for a shimmer that took us a while to diagnose the first time.

What this buys us

Every panel in the scene — levels, grade, FX toggles, looks, sequencer, camera — is built on this one pattern now, and the recent per-look editing feature fell out of it almost embarrassingly easily: to make edits stick to the active look, the look store just subscribes to the other stores like any panel would, debounces 250 ms, and diffs. The stores didn't change.

Which is also where the honest accounting on the headline lives. A drag re-renders the panel you're dragging, and 250 ms later the look store's diff lands and re-renders whatever look-aware components are mounted (the chips, the panel frame, the cue list, the look editor) because one of them now has to paint a "modified" dot. So: a handful of DOM components, not one. What never re-renders is anything inside the canvas, which is the part that owns GPU resources and the part the whole pattern exists to protect.

One limit worth naming before you copy it: these stores are module singletons. That's fine for one canvas on one page, and it would need rethinking for two canvases, server rendering, or a test that wants a fresh room per case.

If you'd rather not hand-roll any of it, zustand's subscribeWithSelector + getState gets you the same split and is the standard answer in r3f land. We keep ours because it's forty lines we fully understand, the useFrame read contract is explicit instead of a convention, and the day something stutters we'd rather read our own store than someone else's middleware. Every tunable subsystem in the scene runs on one now.