Vibes DIY
Vibes DIY / Blog
From the build log

A quarter of a million towers

The tutorial said three.js "just runs in the page" and moved on. This post is for the readers who stopped there and asked how, and for the ones who hit sixty frames a second with five hundred pillars and want fifty thousand.

three-stress — open the live vibe, switch the way, raise the count

How three.js gets into a vibe

There is no build step in a vibe, so a bare import has to resolve somewhere. Write this at the top of App.jsx:

jsimport * as THREE from "three";

and at deploy time it is rewritten to a versioned CDN URL, with the library's own dependency graph resolved and React marked external so you never ship a second copy of it. The page carries an import map; the browser does the rest. Your source stays readable and the resolution is the platform's problem.

The city does something else. It names the version itself:

jsimport * as THREE from "https://esm.sh/[email protected]";
import { OrbitControls } from "https://esm.sh/[email protected]/examples/jsm/controls/OrbitControls.js";

That is a lever, and a generated world is the case where you want it. Most software can take a library upgrade as a free improvement — the tests still pass, the buttons still work. A seeded city cannot, because the seed only reproduces the city while every function between the seed and the pixels stays the same. The random generator is ours, so that half is safe. The other half is three.js, and a release that changes how a material responds to a light, or what a default tone mapping does to a colour, is a release that changes what somebody kept. The pin makes the island on the sea the same island next year.

The second line is the one that catches people. Three.js ships its controls, loaders and exporters as addons under examples/jsm/, and each of them imports three itself. If the addon's copy and your copy come from different places, you have two three.js modules in one page, and the first symptom is a controls object that silently fails an instanceof check deep inside the library. The fix is to pin both lines to the same version on the same host; the CDN then resolves the addon's internal import to the module you already loaded. We checked — controls instanceof THREE.EventDispatcher is true, which it would not be with two copies — because the way to know there is one three.js in the page is to ask, not to assume.

Mount once, clean up once, and why React leaves you no choice

The tutorial's snippets looked like every three.js tutorial on the internet: a scene, a loop, and nothing after. In a page that is also a React app, the "nothing after" is the part that breaks. React mounts your component, and later — a route change, a parent re-render that swaps a key, a hot reload, the user closing a panel — it unmounts it, and if the scene you built is still running, it keeps running. The animation loop keeps asking the GPU for frames nobody can see. The canvas is gone from the page but not from the browser's list of WebGL contexts. The next mount builds a second one.

So every demo in this series is one useEffect that builds the scene and returns the function that takes it apart:

jsReact.useEffect(() => {
  const host = ref.current;
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  host.appendChild(renderer.domElement);
  // …scene, camera, controls, the loop…
  return () => {
    renderer.setAnimationLoop(null);   // stop asking for frames
    controls.dispose();                // remove the pointer listeners
    geometry.dispose();                // free the GPU buffers
    material.dispose();                // and the shader program
    renderer.dispose();                // and the context's own resources
    renderer.domElement.remove();      // and the canvas
  };
}, []);

The empty dependency list is deliberate: the renderer is built once per mount, full stop. Sliders do not go in that list. When a number changes, a second effect asks the scene to rebuild the city; the renderer, the camera and the controls survive as objects, and the only thing the rebuild does to them is reframe the camera so a denser grid still fits the view:

jsReact.useEffect(() => {
  api.current?.set(params);  // remove the old city group, build the new one
}, [params]);

We measured what skipping the cleanup costs, because "it leaks" is the kind of claim that deserves a number. Five mounts that each built a geometry and never disposed it left five geometries resident in the renderer's memory counters; the same five with dispose() left zero. Then we built eighteen renderers in one page without disposing any, which is roughly what a leaky component does across a few route changes. The browser's answer was a console warning — too many active WebGL contexts, oldest context will be lost — and the context it lost first was the one for the scene that was still on screen. A leaky 3D component does not just waste memory. Given enough remounts, it kills itself.

Get posts like this in your inbox

One email field. Real updates. No algorithm required.

Two details from the cleanup that are easy to get backwards. renderer.dispose() releases the renderer's own state, not your geometries and materials — those are yours to dispose, which is why the demos share one box geometry, one ground disc and a handful of cached materials, and dispose each exactly once in the cleanup rather than walking the scene and calling dispose() on the same shared box five hundred times. (Nor does renderer.dispose() release the WebGL context itself; that is forceContextLoss(), a separate call, and the browser reclaims contexts on its own schedule.) And the city builder returns a group, so swapping cities is scene.remove(old) and scene.add(new), with nothing to dispose in between, because every tower in both cities points at the same shared geometry and the same cached materials. Sharing is what makes rebuilding free.

What a frame costs

The field in the tutorial was 576 pillars, each its own Mesh. It ran fine. Here is what happens as you push that shape up, measured in a headless Chromium with a software renderer — so the absolute times are slower than your laptop, and the point is the shape of the two columns, not the digits.

How to read the numbers: each time is the elapsed time inside one renderer.render() call, averaged over a few frames after a warm-up frame, in headless Chromium on SwiftShader at 960×540 with three.js r186. That is the CPU side of a frame — scene traversal, frustum culling, sorting, state changes and draw submission — and it is not the whole frame: GPU completion is not in it, and neither is anything the animation loop does besides render. Draw calls and triangles are renderer.info after the same call. Ordinary meshes are frustum-culled one by one, which is why the Mesh column's call count falls below the tower count at the larger sizes; an InstancedMesh is culled as a whole, so every instance is always submitted.

towersone Mesh eachone InstancedMesh
1,0001,000 draw calls · 33 ms1 draw call · 0.04 ms
4,0004,000 draw calls · 330 ms1 draw call · 2 ms
16,00015,668 draw calls · 1,386 ms1 draw call · 0.02 ms
64,00037,692 draw calls · 1,692 ms1 draw call · 0.02 ms
250,000—1 draw call · 0.04 ms

The left column is the draw-call tax. Every Mesh is a separate instruction to the GPU — bind this geometry, this material, this transform, draw — and the instruction is issued from JavaScript, one at a time, every frame. At a thousand towers that is thirty milliseconds of CPU work inside the render call before the GPU has finished a pixel, which on this machine is already the whole frame budget. At sixteen thousand the camera has culled a couple of percent of the city and the render call still takes over a second; at sixty-four thousand it has culled four towers in ten and the call takes longer still. The triangles are not the problem. Sixteen thousand boxes is under two hundred thousand triangles, which a phone draws without noticing. The problem is that we asked for them sixteen thousand times.

The right column asks once. An InstancedMesh is one geometry, one material, and an array of transforms — one matrix per tower — uploaded to the GPU as a single buffer:

jsconst towers = new THREE.InstancedMesh(geometry, material, count);
const dummy = new THREE.Object3D();
for (let i = 0; i < count; i++) {
  dummy.position.set(x[i], 0, z[i]);
  dummy.scale.set(w[i], h[i], w[i]);
  dummy.updateMatrix();
  towers.setMatrixAt(i, dummy.matrix);
}
towers.instanceMatrix.needsUpdate = true;
scene.add(towers);

The dummy Object3D is the idiom: it exists so you can say position and scale in words and let it compute the matrix. Then the renderer issues one draw call that says "this box, this material, two hundred and fifty thousand times, here are the matrices," and the GPU does the rest at a cost that does not show up in the render call at all. Three million triangles, one call, and a render call that returns in under a tenth of a millisecond; how long the GPU then takes to draw them is a question for the GPU, which is the right place for it. That is the stress demo at the top of this post with the count on its last setting.

What you give up is per-object identity. There is no towers[i] to add a child to or to hang a click handler on — a raycast still hits an instanced mesh and tells you the instanceId, so clicking works, but "this tower is an object" is gone. The tutorial's field kept a Mesh per pillar because five hundred objects you can reason about one at a time is a better teaching shape than one object you reason about as an array. The number where that trade flips is somewhere around a few thousand, and the demo's two buttons let you find it on your own hardware.

A note for the voxel-world crowd, who hit this wall first and hardest: the same arithmetic is why a block world built as one Mesh per block stalls at a few thousand blocks, and why every fast one you have played draws its blocks a chunk at a time, one instanced mesh or one merged geometry per chunk. We looked at three community Minecraft-style vibes on this platform recently; the slow one had two hundred and thirty thousand draw calls, the fast ones had a handful. Same engine, same blocks, same trick the stress demo shows — the difference was whether the trick was used.

The stress test, properly

The demo at the top is the honest version of the table: it runs on your GPU, and its counter reads renderer.info every half second — frames per second, draw calls, triangles — so you can watch the two numbers that matter move independently. Set it to a Mesh each and climb the counts; the frame rate falls with the draw calls long before the triangle count is anything a GPU would notice. Switch to one InstancedMesh and climb again; the draw calls stay at one, the triangles climb to three million, and the frame rate is now a question for your GPU, which is the question it should have been all along.

One number the counter cannot show you is the first frame. Building a quarter of a million matrices is a loop of a quarter of a million iterations, and on the measurement machine it cost fifty-seven milliseconds once — a blink, paid when you press the button, not every frame. The Mesh-each version paid two seconds for its first frame at sixty-four thousand towers, and then paid it again, forever. That is the shape worth remembering: instancing moves the work from every frame to one frame.

Lineage, told properly

three.js is sixteen years old this year. Ricardo Cabello — mr.doob — first released it in April 2010, after years of 3D engine experiments in his ActionScript days, in the months when WebGL was arriving in browsers, and it has been the way most people meet 3D on the web ever since. The scene graph you used in the tutorial — a Scene holding Meshes, each a geometry and a material, a Camera looking at them — is the same shape it had at version 1, and the shape it borrowed from the tools that came before it: the scene graphs of VRML and Open Inventor, the material model of the offline renderers, the idea from the games industry that a draw call is the unit you optimise.

InstancedMesh is newer, from 2019, and it is three.js wrapping a hardware feature the GPU has had since the mid-2000s: instanced drawing, the ability to say "this geometry, N times, with these per-copy attributes" in one instruction. It was originally a trick for grass and crowds and particle systems in games. In a browser it is the difference between a demo and a world.

The release line we pinned, r186, comes from a project that now ships a second renderer alongside the WebGL one — WebGPURenderer, with a shading language, TSL, that you write in JavaScript and that compiles to the backend's own shader code, falling back to WebGL 2 where WebGPU is absent. That is where compute shaders and the next generation of GPU access live. The city in this series does not need it. The city your reader is about to build might.

Remix it

The stress demo is public, and it is the smallest useful benchmark harness you will find: one layout function, two ways to hand it to the GPU, a counter. Remix it and swap the box for your own geometry — a tree, a chair, a glyph — and you have a scalability test for whatever world you are planning, before you plan it. The city builder and the archipelago are public too, and all three pin the same release of the same library, so anything you learn in one carries to the others.

Build something with a quarter of a million of anything

Remix the stress test, or describe the world you wish existed.

Start building →

Enjoyed this? Get the next post by email

One email field. Real updates. No algorithm required.

Prefer a feed? RSS · Atom