Three.js InstancedMesh vs Individual Meshes: A Decision Guide for Rendering Thousands of Objects
Deciding between InstancedMesh and individual Mesh objects in three.js comes down to draw calls, per-instance variation, and culling. This guide compares both options and shows a benchmarkable 50,000-cube implementation.
23 Aug 2025, 13:33 UTC

If your scene needs thousands of identical objects — trees in a forest, particles in a simulation, seats in a stadium — the way you create them decides whether you get 60 FPS or a slideshow. The short answer: use InstancedMesh when objects share geometry and material, and fall back to individual Mesh objects only when each one needs genuinely different rendering state. This guide walks through the constraints, the trade-offs, and a working implementation you can benchmark yourself.
The decision and its constraints
The core problem is draw calls. Every Mesh in your scene graph typically costs at least one draw call per frame, and each draw call has CPU overhead: state changes, uniform uploads, and driver validation. Past a few thousand objects, the CPU becomes the bottleneck long before the GPU runs out of fill rate.
Before choosing, pin down your constraints:
- three.js version: InstancedMesh has been stable for many releases; this article assumes r150 or later. Check
THREE.REVISIONin the console if you are unsure. - WebGL2: three.js r163+ requires WebGL2. On older versions with WebGL1, instancing still works but relies on an extension (
ANGLE_instanced_arrays), which is nearly universal on desktop but worth confirming on old mobile devices. - GPU memory: each instance stores a 4×4 matrix (64 bytes). 100,000 instances is about 6.4 MB of instance data — fine on desktop, worth measuring on low-end phones.
- Per-instance variation: if every object needs a different texture, geometry, or shader branch, instancing stops helping.
Comparing the supported options
| Criteria | Individual Mesh per object | InstancedMesh |
|---|---|---|
| Draw calls | One (or more) per object | One per InstancedMesh |
| CPU overhead per frame | Scales with object count; scene-graph traversal and culling per object | Near-constant; one culling decision for the whole batch |
| Per-instance transform | Full position/rotation/scale via Object3D | 4×4 matrix per instance via setMatrixAt() |
| Per-instance material/texture | Fully supported | Not directly; needs custom shaders or instanceColor for color only |
| Per-instance frustum culling | Automatic | Not automatic — the whole batch is culled as one unit unless you manage it |
| Typical breaking point | Roughly 1,000–5,000 objects before CPU-bound frame drops | Hundreds of thousands of instances on a mid-range GPU |
Trade-offs that matter in practice
Culling. With individual meshes, three.js culls off-screen objects for free. An InstancedMesh has a single bounding volume, so one visible instance can keep all 100,000 in the pipeline. If instances are spread across a huge world, split them into several InstancedMesh chunks (for example, one per grid cell) so chunk-level culling still works.
Per-instance data. InstancedMesh supports instanceColor out of the box for per-instance tinting. Anything richer — per-instance UV offsets, animation phases, selection highlights — requires a custom ShaderMaterial or onBeforeCompile injection with instanced buffer attributes. That is real work; if you need it for most objects, reconsider whether instancing fits.
Updates. Moving an instance means recomputing its matrix and setting instanceMatrix.needsUpdate = true, which re-uploads the buffer. Updating a few matrices per frame is cheap; rewriting all 100,000 every frame can become its own bottleneck. Static or mostly-static scenes are the sweet spot.
Concrete implementation
The following renders 50,000 randomly placed cubes in one draw call. Run it in any page that imports three.js as a module (r150+); no special permissions are needed since it is plain client-side JavaScript.
import * as THREE from 'three';\n\nconst renderer = new THREE.WebGLRenderer({ antialias: true });\nrenderer.setSize(window.innerWidth, window.innerHeight);\ndocument.body.appendChild(renderer.domElement);\n\nconst scene = new THREE.Scene();\nconst camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 2000);\ncamera.position.set(0, 50, 200);\n\nscene.add(new THREE.HemisphereLight(0xffffff, 0x444444));\nconst dir = new THREE.DirectionalLight(0xffffff, 1);\ndir.position.set(1, 1, 1);\nscene.add(dir);\n\nconst COUNT = 50_000;\nconst geometry = new THREE.BoxGeometry(1, 1, 1);\nconst material = new THREE.MeshLambertMaterial();\nconst mesh = new THREE.InstancedMesh(geometry, material, COUNT);\n\nconst dummy = new THREE.Object3D();\nconst color = new THREE.Color();\nfor (let i = 0; i < COUNT; i++) {\n dummy.position.set(\n (Math.random() - 0.5) * 400,\n (Math.random() - 0.5) * 400,\n (Math.random() - 0.5) * 400\n );\n dummy.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);\n dummy.updateMatrix();\n mesh.setMatrixAt(i, dummy.matrix);\n mesh.setColorAt(i, color.setHSL(Math.random(), 0.7, 0.5));\n}\nmesh.instanceMatrix.needsUpdate = true;\nif (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;\nscene.add(mesh);\n\nrenderer.setAnimationLoop(() => renderer.render(scene, camera));Key details: allocate the InstancedMesh with the maximum count up front, write each transform with setMatrixAt() using a reusable Object3D as a matrix builder, and flag the attribute buffers for upload. To render fewer instances than allocated, lower mesh.count — this is the supported way to shrink the batch without reallocating.
Validating the win
Do not trust the theory; measure it on your target hardware. A simple approach:
- Run the scene above and record FPS for 10 seconds using
requestAnimationFrametimestamps or the browser's performance panel. - Replace the InstancedMesh with a loop creating 50,000 individual
Meshobjects sharing the same geometry and material, then measure again. - Compare. On a mid-range desktop GPU you should see a large gap — the individual-mesh version is typically CPU-bound well below 60 FPS, while the instanced version stays smooth. Exact numbers vary by machine, so treat any specific multiplier as hardware-dependent.
Also check renderer.info.render.calls after a frame: the instanced scene should report a handful of calls, while the individual-mesh scene reports tens of thousands. That number is the most direct confirmation that instancing is doing its job.
Limitations to keep in mind
- One bounding volume means no free per-instance culling — chunk large worlds manually.
- Raycasting against InstancedMesh works, but hit-testing 100,000 instances per mouse move is expensive; throttle or use a spatial index.
- Sorting for transparency happens per mesh, not per instance, so transparent instanced objects can show incorrect overlap.
- Very old three.js versions differ in API details; verify against the migration guide for your exact revision rather than assuming the snippets above apply unchanged.
The rule of thumb: identical geometry plus identical material plus a count in the thousands means InstancedMesh. Anything else, reach for individual meshes and accept the draw-call budget.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.