Key answer: Three.js performance for dense scenes comes down to draw calls, and instancing plus LOD is how you cut them. The GPU is rarely the problem — the CPU is, because every separate mesh is a draw call, and thousands of them stall the frame long before the triangles do. An InstancedMesh collapses thousands of identical objects into one geometry upload and one draw call; THREE.LOD drops the triangle count for objects that are far away; frustum culling skips what the camera cannot see; and merging or BatchedMesh batches distinct static meshes that share a material. This guide shows each technique with runnable code, and the live hero above renders thousands of objects while reading its own draw-call count straight from renderer.info.render.calls.
renderer.info.render.calls from the real render loop.Table of contents
- What instancing, LOD and batching do
- Why draw calls are the bottleneck
- InstancedMesh: thousands of objects, one draw call
- Per-instance color and dynamic transforms
- Level of detail with THREE.LOD
- Frustum culling and what instancing costs
- Merging geometry and BatchedMesh
- Measuring it with renderer.info
- Memory: instanced versus separate
- Trade-offs, when to use and when not
- Decision table
- How AETumi approaches instancing and LOD
- Technical proof on GitHub
- FAQ
- Related resources
- Conclusion
What instancing, LOD and batching do
Three.js performance with instancing and LOD is really a set of answers to one question: how do you render a lot of geometry without submitting a lot of work to the CPU? A modern GPU shades millions of triangles happily. What it dislikes is being told to start over thousands of times per frame. Each of the techniques in this guide attacks that from a different angle. Instancing renders many copies of one shape in a single submission. Level of detail (LOD) renders cheaper versions of objects that are far away. Frustum culling skips objects the camera cannot see. And draw call batching — through merging or BatchedMesh — combines distinct static meshes that share a material into one submission.
The hero above is the whole idea in one scene: a field of thousands of objects, the near ones smooth and the far ones faceted, and a heads-up display that reads the live draw-call count from renderer.info.render.calls. Despite the count of objects, the draw-call number stays in the single digits, because two InstancedMesh groups carry the entire field. That is the gap between object count and draw count that every optimization here is trying to widen.
Why draw calls are the bottleneck
A draw call is the command that tells the GPU to render one batch of geometry with one material and one set of state. Setting up each call has fixed CPU cost — binding buffers, validating state, issuing the command — and that cost is paid per call regardless of how many triangles the call actually draws. Ten thousand tiny meshes are ten thousand draw calls; a single mesh with ten thousand triangles is one. This is why the object count and the triangle count matter far less than the draw count for interactivity on real devices.
The practical target, then, is to keep the draw count low while the object count grows. You get there by identifying objects that share geometry and material and rendering them together. Everything below is a concrete way to do that, and the Three.js hub collects the components that ship these patterns by default.
InstancedMesh: thousands of objects, one draw call
Problem: you need to render a large field of identical objects — a crowd, a particle swarm, a grid of product tiles — and a mesh per object would flood the frame with draw calls. InstancedMesh uploads the geometry once and stores a per-instance transform matrix, so the whole field is one draw call.
import * as THREE from 'three';
const geometry = new THREE.IcosahedronGeometry(0.2, 2);
const material = new THREE.MeshStandardMaterial({ color: 0x5ea0ff });
const COUNT = 2116;
const mesh = new THREE.InstancedMesh(geometry, material, COUNT);
const dummy = new THREE.Object3D();
let i = 0;
for (let x = 0; x < 46; x++) {
for (let z = 0; z < 46; z++) {
dummy.position.set(x * 0.62 - 14, 0, z * 0.62 - 14);
dummy.updateMatrix();
mesh.setMatrixAt(i++, dummy.matrix);
}
}
scene.add(mesh); // 2116 objects, one geometry upload, one draw call
Why it matters: the exact pattern powers the hero on this page. Two InstancedMesh groups render the entire field, so the draw-call HUD reads a single-digit number even though the object count is in the thousands — the demonstration of Three.js performance through instancing and LOD that the primary keyword names. Use setMatrixAt to position each instance and, if the field never moves, leave instanceMatrix static so it uploads once.
Per-instance color and dynamic transforms
Problem: instances look identical, and sometimes they must move each frame. InstancedMesh supports a per-instance color buffer, and when transforms animate you mark the matrix buffer dirty so the GPU picks up the change.
// give each instance its own tint (per-instance color, still one draw call)
const color = new THREE.Color();
for (let i = 0; i < COUNT; i++) {
color.setHSL(0.58, 0.6, 0.4 + Math.random() * 0.3); // azure range
mesh.setColorAt(i, color);
}
mesh.instanceColor.needsUpdate = true;
// animate transforms: update matrices, then flag the buffer once per frame
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
function tick(t) {
for (let i = 0; i < COUNT; i++) {
dummy.position.y = Math.sin(t + i) * 0.45;
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true; // upload once, not per instance
}
Why it matters: per-instance color keeps variety without breaking the batch, and DynamicDrawUsage tells the driver the buffer changes often so it optimizes the upload. The cost you take on is CPU work in the loop — updating thousands of matrices in JavaScript every frame is real, so animate only what must move and prefer a shader for purely visual motion. The hero updates its matrices this way, gated so it only runs while the canvas is on screen.
Level of detail with THREE.LOD
Problem: distant objects cover a few pixels but still cost their full triangle budget. THREE.LOD holds several versions of an object and shows the cheapest one adequate for the current distance, swapping automatically as the camera moves.
const lod = new THREE.LOD();
lod.addLevel(new THREE.Mesh(new THREE.IcosahedronGeometry(1, 3), material), 0); // near
lod.addLevel(new THREE.Mesh(new THREE.IcosahedronGeometry(1, 1), material), 15); // mid
lod.addLevel(new THREE.Mesh(new THREE.IcosahedronGeometry(1, 0), material), 40); // far
scene.add(lod);
// LOD picks a level from camera distance during renderer.render()
Why it matters: LOD trades a small amount of memory — you store several meshes — for a large cut in vertex and fragment work on everything in the distance. It pairs naturally with the idea in the hero, where near instances use a smooth high-detail icosahedron and far instances use a faceted low-detail one, so the field spends its triangle budget where the viewer can actually see the difference.
AETumi Expert Note.THREE.LODoperates per object, so it does not combine with a singleInstancedMeshout of the box. For a large field, the production pattern is to split instances into detail tiers — oneInstancedMeshper LOD level — and assign each instance to a tier by distance, exactly as the hero does with its high and low groups. You keep the one-draw-call-per-tier win and still get distance-based simplification.
Frustum culling and what instancing costs
Problem: rendering objects outside the camera view wastes work. Three.js frustum-culls each Object3D by default, but a single InstancedMesh is culled as one unit by its bounding volume — so if any part is on screen, the whole batch is submitted.
// per-object culling is on by default; keep bounding volumes correct
mesh.frustumCulled = true;
instancedMesh.computeBoundingSphere(); // refresh after moving instances
// a giant instanced field is one cull unit — split it into spatial
// chunks so off-screen regions can be culled independently
const chunks = []; // e.g. one InstancedMesh per grid tile
chunks.forEach(c => scene.add(c)); // each culls by its own bounds
Why it matters: culling is free performance for scenes larger than the view, but instancing changes its granularity. One enormous InstancedMesh either draws entirely or not at all. When a field is much larger than the screen, split it into spatial chunks so the renderer can cull the parts behind the camera — a balance between fewer draw calls and finer culling that you tune per scene. Keep bounding spheres current after you move instances, or culling will use stale bounds.
Merging geometry and BatchedMesh
Problem: not everything repeats. Sometimes you have many different static meshes that share one material — modular level pieces, scattered props — and each is its own draw call. Two tools batch them: merging their geometry, or BatchedMesh.
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
// combine many static geometries into one buffer, one draw call
const merged = mergeGeometries([geoA, geoB, geoC], false);
const solid = new THREE.Mesh(merged, sharedMaterial);
scene.add(solid); // trades per-object transforms for a single submission
// BatchedMesh: many distinct geometries, one material, one draw call,
// with per-geometry frustum culling retained
const batched = new THREE.BatchedMesh(64, 4096, 8192, sharedMaterial);
const idA = batched.addGeometry(geoA);
const idB = batched.addGeometry(geoB);
batched.setMatrixAt(idA, matrixA);
batched.setMatrixAt(idB, matrixB);
scene.add(batched);
Why it matters: mergeGeometries is the simplest batch for fully static geometry, but you lose the ability to move pieces independently and you cannot cull them separately. BatchedMesh, added in recent Three.js, keeps per-geometry matrices and culling while still submitting one draw call, which makes it the better fit when distinct meshes need individual transforms. Choose merging for frozen scenery and BatchedMesh for distinct-but-batched objects; choose InstancedMesh when the geometry is identical.
Measuring it with renderer.info
Problem: frame rate alone does not tell you why a scene is slow, and it is easy to optimize the wrong thing. renderer.info exposes the real per-frame counts, so you confirm an optimization instead of guessing.
renderer.render(scene, camera);
console.log(renderer.info.render.calls); // draw calls this frame
console.log(renderer.info.render.triangles); // triangles this frame
console.log(renderer.info.memory.geometries); // geometries on the GPU
console.log(renderer.info.memory.textures); // textures on the GPU
// read AFTER render(); the render counts reset and refill each frame
Why it matters: this is the honest scoreboard. The hero HUD on this page reads renderer.info.render.calls after every render and prints it live, which is why you can watch thousands of instances resolve to a single-digit call count. Before and after any change — instancing a field, merging props, adding LOD — log these numbers and keep the change only if calls or triangles actually fell. Any specific counts you cite should come from this object on your own scene, not from a benchmark you did not run.
Memory: instanced versus separate
Draw calls are the frame-time story; memory is the other half. A thousand separate meshes means a thousand geometry references, a thousand material bindings and a thousand scene-graph nodes the CPU walks each frame. An InstancedMesh holds one geometry, one material and a compact matrix buffer, so it is lighter both on the GPU and in the JavaScript heap.
The exact byte savings depend on your geometry and are worth reading from renderer.info.memory on your own scene rather than estimating. The structural point holds regardless: instancing removes duplication, which is why a dense scene that stutters as separate meshes can become smooth as an instanced field without touching the art.
Trade-offs, when to use and when not
None of this is free, and applying it blindly makes code harder without making frames faster. Instancing requires identical geometry and material; the moment objects need different shapes it stops applying, and animating per-instance transforms moves cost onto the CPU. LOD requires authoring or generating several detail levels and can pop visibly at thresholds if you do not tune distances or fade. Merging geometry forfeits independent movement and per-object culling. BatchedMesh and instancing both add code complexity that a scene with a dozen objects simply does not need.
Use these techniques when you render many repeated objects, dense fields, particle systems, tiled product grids, or large static environments where the draw count climbs into the hundreds or thousands. Do not reach for them when the scene has a handful of unique hero objects, when every object needs a distinct material, or when the real bottleneck is elsewhere — an uncapped devicePixelRatio, an oversized texture, or a render loop that runs when nothing changed. Measure with renderer.info first; optimize the number that is actually high.
Decision table
| Technique | Best for | Draw calls | Main trade-off |
|---|---|---|---|
| InstancedMesh | Many identical objects, per-instance transform/color | One per instanced group | Same geometry + material only; animation is CPU work |
| THREE.LOD | Objects seen at varying distance | Unchanged; fewer triangles | Author several meshes; possible popping |
| Frustum culling | Scenes larger than the view | Skips off-screen batches | Instanced field culls as one unit unless chunked |
| mergeGeometries | Many static meshes, shared material | One for the merged set | No independent movement or per-object culling |
| BatchedMesh | Distinct static meshes, shared material | One for the batch | Newer API; setup and index management |
| renderer.info | Diagnosing what is actually slow | Measurement, not a fix | Read after render(); interpret honestly |
How AETumi approaches instancing and LOD
AETumi is an AI-native 3D web platform for production Three.js and WebGL sites, React and Next.js components, and MCP workflows for assistants such as Claude Code. AETumi Three.js components treat draw-call discipline as a default, not a later optimization: repeated geometry ships as InstancedMesh, distance-sensitive scenes carry LOD tiers, static scenery is merged or batched, and every scene exposes renderer.info during development so the draw count is visible while you build. Because the components are owned source you point at your own content, the same instanced field or LOD grid adapts to a new product through an MCP workflow rather than being rebuilt, and the full library funnels into pricing. The approach mirrors the rest of the platform: keep meaning in crawlable HTML, cap the pixel ratio, render on demand, and let the 3D be a fast, reusable 3D component.
Technical proof on GitHub
The production patterns these techniques build on are runnable in the AETumiApp organization. AETumiApp/threejs-product-viewer and AETumiApp/aetumi-3d-components ship the r160 ES-module importmap, a capped setPixelRatio, explicit dispose on teardown and a prefers-reduced-motion path — the same foundation the hero here uses before any instancing is added. AETumiApp/threejs-scroll-animation shows the scene wired to a single render loop. The instancing, LOD and batching in this article extend that base; the repositories are the explanation-free source, and this article is the explanation.
FAQ
What is the fastest way to improve Three.js performance? Cut draw calls. The GPU can push millions of triangles, but every separate mesh is a CPU-side draw call, and thousands of them stall the frame. Merge or instance repeated objects so the same geometry is submitted once. An InstancedMesh of thousands of copies reports a single draw call in renderer.info.render.calls, which is the number to watch.
When should I use InstancedMesh instead of separate meshes? Use it when you render many copies of the same geometry and material that differ only by transform and optionally color — trees, crowds, particles, tiles, product grids. It collapses them into one draw call and one geometry upload. Use separate meshes when each object needs a different geometry or material, or when there are only a handful of them.
Does LOD actually help if the GPU is fast? Yes, because LOD reduces the triangles and shading work for objects that are far away and cover few pixels. THREE.LOD swaps a high-detail mesh for simpler ones past distance thresholds, so a crowd of distant objects costs a fraction of the vertex work. It matters most on mobile GPUs and in dense scenes where far geometry would otherwise be shaded at full cost.
How do I measure draw calls in Three.js? Read renderer.info after a render. renderer.info.render.calls is the draw-call count for the last frame, renderer.info.render.triangles is the triangle count, and renderer.info.memory reports GPU allocations. Log these before and after an optimization to confirm instancing or merging actually reduced the numbers rather than guessing from frame rate alone.
What is the difference between InstancedMesh and BatchedMesh? InstancedMesh renders many copies of one geometry with per-instance transforms in a single draw call. BatchedMesh renders many different geometries that share one material in a single draw call, with per-geometry culling. Use InstancedMesh for identical repeated objects and BatchedMesh for a set of distinct static meshes you want batched together.
Related resources
Continue with Three.js, WebGL, reusable 3D components, React Three Fiber, 3D websites, the MCP workflow, pricing, and options for agencies.
Conclusion
Three.js performance through instancing and LOD is a discipline of draw calls: render repeated objects with InstancedMesh, simplify distant ones with THREE.LOD, cull what the camera cannot see, batch distinct static meshes with merging or BatchedMesh, and verify every change against renderer.info. The live field on this page renders thousands of objects in a handful of draw calls because it applies exactly that. Reach for these techniques when the object count is high and the scene is dense; measure first, keep meaning in HTML, and let the numbers — not the frame-rate hunch — decide which optimization stays.
More from the AETumi library
Real, production-ready assets — preview the motion, grab the source.
#threejs #instancing #lod #drawcalls #performance #webgl #InstancedMesh #BatchedMesh #frustumculling #AETumi

