MediumFrom 60,000 Scene Nodes to a Steady Frame | by Hemanth R | Nov, 2026 | Medium
Sitemap

From 60,000 Scene Nodes to a Steady Frame

15 min read21 hours ago

--

Building Solo World, Part 3 of 3: performance lessons from a 3D multiplayer world in the browser.

Press enter or click to view image in full size

Part 1 covered Solo World’s architecture, and Part 2 its multiplayer layer. Part 2 ended on a puzzle: the server was barely working, yet the game got laggy as players joined.

A browser game has one main thread. Rendering, input, React, physics bookkeeping and every incoming WebSocket message all queue up on it. At 60 frames a second you get 16.7 ms per frame for everything. At 120 Hz, you get 8.3.

So “the camera feels delayed” is rarely a camera bug. It’s the camera waiting behind something else. Every fix in this post started by finding out what that something was.

I made one rule for myself, and it’s the most useful thing in this series: measure before you change anything, and write down the hypotheses you rejected. Twice, a plausible culprit turned out to be innocent when measured: the server’s broadcast loop (Part 2) and, in an early pass, the name-tag occlusion checks. That second one comes back with a twist later on.

The measuring tools were humble: Chrome’s Performance panel and frame-rendering stats, React’s Profiler, a headless census that mounts the whole world with @react-three/test-renderer and counts what’s in the scene graph, and scripted bots walking circles around the village.

Three clocks, kept apart

A multiplayer 3D game runs on three clocks at once. The network sends at 10 Hz. Physics steps at a fixed 1/60 s. Rendering runs at whatever the display refreshes at. Most of the “it feels laggy” bugs I found were one clock leaking into another.

Press enter or click to view image in full size

The camera stepped at 10 Hz. The chase camera aimed at the player’s position from the Zustand store. But that store was only written every 100 ms, alongside the network send. So the camera chased a target that jumped ten times a second while the avatar moved smoothly. Now the player component publishes its drawn transform after each physics step into a plain module-level holder, and the camera reads that every frame.

Walking speed depended on your monitor. Movement was integrated from body.translation(), which Rapier only advances on its 60 Hz step. On a 120 or 144 Hz display, several frames started from the same stale position and only one small step survived. In other words, players on high-refresh screens would walk slower. (I found this one by reading the code, not by owning a 144 Hz monitor.) The player now owns its simulated position and integrates from that.

Order within a frame was accidental. Depending on mount order, the camera could sample the player before or after physics moved them. React Three Fiber’s useFrame takes a priority, so the order is now pinned: player simulation, then the Rapier step, then publishing the drawn transform, then the camera target, the orbit controls and camera collision, then the rest of the world, and the paint pass last.

None of this needed a faster machine. It needed each value read from the clock it actually belongs to.

Get the network out of React

This was the big one, and the answer to Part 2’s puzzle.

Each client receives roughly 10 × (players − 1) movement messages per second. I measured 9.9/s with two players, 19.7/s with three and 89.2/s with ten. That’s unavoidable. What I did with each message was not.

Every player:move called upsertRemotePlayer on a Zustand store, which built a fresh remotePlayers object and notified every subscriber in the app. The village minimap, mounted for the whole session, subscribed to that whole object. So the entire map SVG re-rendered once per network packet.

Press enter or click to view image in full size

At ten players that’s 100 React commits a second and about 41 ms of React work every second, on the same thread as the camera and input. And it grew with every player who joined, which matched the symptom exactly. (Those React numbers come from a jsdom harness, so they exclude the browser’s style, layout and paint work for an SVG re-rendering 100 times a second. The real cost was higher.)

The fix is a pattern I now use everywhere:

State that changes many times a second doesn’t belong in React state. Every other player’s position, rotation and vehicle now lives in a plain module-scope Map, written directly by the network handler. Readers compare a version counter instead of object identity, so no packet allocates anything. The store is touched only the first time a session is seen, because identity (name, avatar, who’s here) changes on join and leave, not ten times a second.

Then each reader picks the right way to read:

  • Remote avatars read their transform inside useFrame, every frame, through the interpolator from Part 2.
  • The minimap samples on a 200 ms timer and skips the update entirely if nothing moved more than a quarter of a world unit, which is far below a pixel at map scale.
  • The “say hello” proximity prompt polls at 5 Hz. Before, it subscribed to both the local player and every remote player and looped over all of them on every update. That’s O(N) work triggered O(N) times a second: quadratic in the player count.

Results: 100 movement packets now cause zero store writes (it used to be 100). The minimap commits a flat 5 times a second at any player count, 20× fewer at ten players.

One more of the same kind: the local player rebuilt its list of nearby obstacles every frame from every remote player, with no distance cutoff. Anyone further than 40 units is now skipped before the list is built, so the per-frame cost stays small and constant however many people are in the room.

Stop building the world you can’t see

Phones and tablets were far slower than desktop, so I ran the headless census over the whole world. Everything was instantiated at startup, wherever you happened to stand:

  • about 5,200 draw calls
  • about 950,000 triangles
  • about 60,000 scene-graph nodes
  • about 5,200 unique geometries and 5,200 unique materials

The surprise wasn’t the GPU. 48,666 of those 60,000 nodes were ground tiles. The ground was drawn with drei’s <Instances> and <Instance>, which is one draw call, but also one React element and one Object3D per tile. three.js updates the world matrix and frustum-tests the bounds of every one of those, every frame. That’s main-thread work no phone can afford, spent on ground you couldn’t even see.

The ground became one InstancedMesh per chunk, with matrices written once: 48,666 nodes became two per chunk, drawing exactly the same thing. Then the chunks were streamed like everything else.

A registry, not a scene graph

Streaming is built around a tiny wrapper component. A piece of the world says where it is and how big it is, and a throttled driver tells it when to exist:

// Illustrative usage: anchor, extent and load priority, nothing else.
<Streamed x={540} z={-210} radius={35} priority={STREAM_PRIORITY.structure}>
<Airfield />
</Streamed>
Press enter or click to view image in full size

The details are where it either works or feels awful:

  • Unmount, never hide. Setting visible = false still costs memory and traversal. Unmounting lets React Three Fiber dispose the geometries, materials and physics bodies, and three.js stops walking that subtree.
  • Hysteresis. Build at 260 units, drop only past 340. Standing on a boundary never thrashes.
  • Distances chosen against the fog, not the map. The scene is fully fogged out by 180 to 260 units, so content arriving at 260 is never a visible pop-in.
  • Priority and a per-tick budget. Structure first (terrain, buildings, anything gameplay reads), then scenery, then decoration, nearest first, a few per tick. Walking into a dense region spreads its construction over several frames instead of freezing one.
  • Look-ahead. The decision point is pushed about two seconds along your current velocity, so a region is already built when you arrive.
  • Physics is never streamed. The only world collider is one ground box covering everything. Everything else you collide with is resolved against layout data, not meshes. Nobody falls through unloaded terrain.
  • Phones get a smaller world. Shorter distances, a smaller build budget, thinner vegetation and fog pulled in to match.
Press enter or click to view image in full size

Scene nodes, the per-frame main-thread cost that was the worst of it, fell by 83 to 97% depending on where you stand. Triangles fell 30% in the middle of the village and 87 to 92% out in the wider world.

The sea got its own rewrite. It used to be six ripple planes whose vertex positions and normals were rewritten on the CPU 30 times a second. It’s now two meshes and a signed distance field, built once at load, and its entire animation is a single uniform addition per frame.

Instancing done properly, and a pool of eight lights

Streaming can’t help in the middle of the village, because that content genuinely is in view. Making each visible thing cheaper is a different job.

Repeated scenery as parts

About 400 trees were about 1,600 separate meshes, each with its own geometry and material, all walked by three.js every frame. The jungle and two estates added another thousand or so. Now each tree type is described once as a set of parts (a handful of primitive shapes) and drawn through instanced meshes:

  • village trees: 1,580 objects → 33
  • jungle: 1,074 objects → 21

Same shapes, colours, materials and shadow flags. I compared every instance’s world matrix against the original components in a headless render, and they match to float32 precision.

Drei’s <Instances> was rebuilding itself every frame

The trap in that ground-tile story turned out to be everywhere. Drei’s <Instance> is a real scene-graph node, and <Instances> rebuilds its whole instance buffer from those nodes every frame, then re-uploads it to the GPU. With about 13,000 of them at spawn, that was most of the per-frame JavaScript and more than half of three.js’s matrix pass.

Get Hemanth R’s stories in your inbox

Join Medium for free to get updates from this writer.

The replacement, BakedInstances, keeps the exact same JSX, so no call site had to change. The difference is that an <Instance> is now just data, baked into the InstancedMesh only when it changes. Same 53,783 instances, same transforms and colours. Per-frame useFrame work at spawn went from about 3.7 to 4.0 ms down to 0.4 ms.

Eight lights, lent out

This one surprised me. In three.js, every point light is evaluated for every lit pixel whatever its distance is set to, and the light count is compiled into every shader. So when streaming brought a building with lamps into range, every material in the scene recompiled.

Now static lamps are declared with a <PooledPointLight> that takes the same props as <pointLight>. A fixed pool of eight real lights is lent to the lamps nearest the camera, re-ranked four times a second with a short fade on hand-off. Height differences count triple in the ranking, so inside the apartment block the lamps along your own corridor beat the ones on the floors above and below. The count never changes, so nothing recompiles. The scene went from 51 point lights (and a number that varied) to a fixed 8.

Small leaks add up

A few textures, such as signs and hint cards, were created each time a streamed component mounted and never disposed. Walking in and out of range leaked GPU memory every time. A shooting star allocated four Vector3s per frame. An avatar thumbnail in the HUD re-rendered 60 times a second in its own WebGL context, for a picture that never moves. Each is a one-line fix, and none is visible until you go looking.

The name tags that ate half the main thread

Remember the hypothesis I rejected early on? In one pass I measured name-tag occlusion at 0.08 ms per check against the real scene graph, already throttled and staggered. Cheap. Innocent.

Then reports came in of severe lag on some devices, Windows PCs in particular, once several players shared the village. So I measured again, in Chrome this time, with 20 scripted players walking circles around the spawn point, and DevTools CPU throttling at 4× to stand in for a budget laptop.

Press enter or click to view image in full size

The biggest single cost was name-tag occlusion raycasts: about 48% of main-thread time. Each floating name tag cast a ray to check whether a wall was hiding its player. The ray was unbounded, and it tested the whole scene: about 17,000 objects, including every triangle of the open-ocean mesh and every foliage instance. With 20 tags, the cost multiplied. The garbage those raycasts produced is also why the heap kept ballooning.

The earlier measurement wasn’t wrong. But it measured a single check, not twenty tags checking at once. Cost that scales with player count only shows up when you test with the player count.

The fix keeps the feature and bounds the cost:

  • The ray stops at the tag (far), instead of going on to the horizon.
  • Tags that are off-screen don’t check at all.
  • One shared occluder list (visible meshes and their world bounding spheres, refreshed every 0.5 s, world-scale meshes like the ocean excluded) is pre-tested sphere by sphere before any triangle test.
  • At most two tags check per frame, room-wide. The cost no longer grows with the number of players.

With 20 players: 24 fps went to 38 on a normal CPU. Under 4× throttling, 2.2 fps (frames of around 450 ms) went to between 8.6 and 10 fps. Long tasks went from about six a second, around 100 ms each, to none.

The trade-off is honest: a moving mesh can take up to half a second to start hiding a tag. It’s a trade I’d make again.

Adapting to the device

There’s no single right quality setting for a world that has to run on a gaming PC and a three-year-old phone. So the game starts at full quality on desktop and only steps down when the measured frame rate says it must: pixel ratio 2 → 1.5 → 1.25 → 1, then the sun’s shadow map from 2048 to 1024, then shadow updates every second or third frame. It never touches gameplay.

The governor is deliberately conservative, because a quality setting that flickers is worse than one that’s slightly too low:

  • Nothing is judged for the first 6 seconds.
  • Stepping down needs three slow one-second windows in a row.
  • Stepping back up needs 25 healthy windows, and stops for good if a step-up ever causes another drop within a minute.
  • “Slow” is relative to the display’s own refresh rate, measured from the shortest frame interval seen. A 30 Hz power-saving display isn’t mistaken for a weak GPU.

Touch devices skip the step where they prove they can’t hold the desktop setting: a coarse pointer plus the reported core count and memory pick a sensible starting rung, shorter streaming distances and thinner vegetation. A browser rendering in software (SwiftShader, Microsoft Basic Render Driver, llvmpipe, which means hardware acceleration is off) starts at the lowest tier. For people who want control, Settings → Graphics offers Auto, High, Medium and Low.

One React Three Fiber gotcha worth knowing: the pixel ratio must go through the Canvas’s dpr prop. R3F re-applies that prop after every Canvas render, so anything you set directly with gl.setPixelRatio is silently reset.

Shipping less JavaScript

The app used to ship as a single 5.4 MB chunk. To show the landing page, a visitor downloaded three.js, Rapier and the entire game world first.

Now /game, /chats and every Settings section are lazy routes. The 3D stack lives in long-lived vendor chunks that rarely change, so returning players keep them cached across deploys:

manualChunks(id) {
// React first, or Rollup hoists it into vendor-three
// and the landing page has to download the 3D stack after all.
if (id.includes('vite/preload-helper') ||
/\/node_modules\/(react|react-dom|scheduler|zustand)\//.test(id)) return 'vendor-react';
if (id.includes('/node_modules/@dimforge/') ||
id.includes('/@react-three/rapier/')) return 'vendor-rapier';
if (/\/node_modules\/(three|three-stdlib|@react-three\/(fiber|drei))\//.test(id)) return 'vendor-three';
if (/\/node_modules\/(@mui|@emotion)\//.test(id)) return 'vendor-mui';
}

That first rule is the non-obvious one. Without it, Rollup hoisted React and the preload helper into the three.js chunk, because the 3D code imports them too, and the entry chunk had to download all of three.js anyway.

Two small touches make it feel instant. The game chunk is prefetched two seconds after the landing page renders, so clicking “Explore” is near-instant without slowing the first paint. And if a lazy chunk fails to load, usually because a deploy replaced the hashed files an open tab was built against, the page reloads once per session instead of showing a broken screen.

Press enter or click to view image in full size

See it live: the ?diag=1 overlay

Every number in this series came from a tool I could run again. The most useful one ships with the game, switched off.

Open the game with ?diag=1 and a small overlay appears: frame rate and worst frame time, draw calls, scene nodes, GPU geometry and texture counts, how many streamed pieces are built out of the total, WebSocket messages and bytes per second each way, round-trip time, and the server’s own event-loop lag and per-move cost, delivered on the diagnostic pong from Part 2. It’s never part of the normal UI. A production build skips the network instrumentation entirely unless that flag is set, and the overlay touches React state only twice a second.

Press enter or click to view image in full size

The overlay, top left, at the village spawn with a second player in view.

While writing this post, I opened the live game with the flag on and used the map’s quick-travel buttons to hop between three places in the same session:

Press enter or click to view image in full size

That one screen tells most of this post’s story. In the village, 178 of the 397 streamed pieces are built and there are about 16,500 scene nodes. At the airfield, 25 pieces are built, and the frame rate hits the display’s cap. Standing far from the only other player, interest management and idle suppression bring incoming traffic down from 20 messages a second to two, then to zero. The server, meanwhile, spends about a tenth of a millisecond per move wherever you are.

(Those frame rates are from one desktop browser, not a benchmark. The point is the ratio between places, on the same machine, in the same session.)

What’s still slow, honestly

A performance post that ends with “and now everything is fast” is lying. Here’s what’s left.

  • Draw calls in the middle of the village. Roughly 3,400 a frame from about 3,000 meshes, with nearly as many unique geometries and materials. Streaming can’t help, because that content really is in view. This is the bill for the “everything is code” decision in Part 1: every hut declares its own geometry and material inline, so nothing is shared. The fix, a shared geometry and material cache or merging static buildings into a few meshes per material, touches every scenery component and needs careful visual checking. It’s the next big job.
  • Windows pays more per draw call. Chrome on Windows draws through ANGLE and Direct3D 11, where each call costs the CPU noticeably more than on macOS. Weak Windows laptops feel the draw-call problem first.
  • No mesh LOD. Procedural primitives don’t come with reduced-detail versions. Distant things are built in full or not at all.
  • Rapier’s WASM is 773.6 kB gzipped, because it’s bundled as base64. It’s lazy-loaded with the game, but it’s still the largest single chunk.
  • Lab numbers, not field numbers. Much of the measuring was headless or on one machine with CPU throttling. Real phones, real GPUs and thermal throttling deserve their own pass.

Lessons from the whole series

  1. Measure first, and keep the losers. Two of my confident hypotheses were wrong. Writing down what you rejected stops you “fixing” it later.
  2. Fast-changing state stays out of React. Ten updates a second per player is fine. Ten React re-renders per player per second is not. Module-scope holders plus useFrame or a slow poll beat a store subscription every time.
  3. Respect the three clocks. Network, physics and render each have their own rate. Read every value from the clock it belongs to.
  4. Don’t build what nobody can see, and when you tear it down, really tear it down. Hysteresis and priority make streaming invisible.
  5. Be suspicious of convenient abstractions in hot paths. <Instance> as a scene node, light counts compiled into shaders, an unbounded raycast: each was fine until it ran 13,000 times, or 20 times per frame.
  6. Test at the scale you care about. A cost that grows with player count only shows up with players. Scripted bots are cheap; use them.
  7. Ship the diagnostics. A hidden ?diag=1 overlay turned “it feels laggy” into numbers I could compare across deploys and devices.

Solo World started as a question: how far can a browser tab go? Further than I expected, with one process, one database and a lot of measuring. If you build something similar, I hope a few of these save you an evening or two.

Thanks for reading all three parts. Come and walk around at soloworld.in, and if you spot a frame drop, open it with ?diag=1 and tell me what you see.

--

--

Hemanth R
Hemanth R

Written by Hemanth R

Consultant Developer at Thoughtworks