Skip to content

Performance · GPU · core

Draw Calls

draw a thousand things in one request

Each mesh costs the CPU a draw call plus state changes. Instancing and batching turn thousands of identical objects into one call; track renderer.info.

View .md
Interactive visual

A slow-motion view of the CPU submitting one frame. As separate meshes, each cube turns orange when its own draw call is sent; as one InstancedMesh, the whole block turns cyan in a single call. The yellow bar is CPU time spent submitting. The default alternates between the two; raise the cube count to widen the gap.

What it is

A draw call is one request from the CPU to the GPU: "draw this geometry with this shader and these settings". Before each one the engine and driver bind buffers, upload uniforms (the object's matrix, material values) and maybe switch shaders or textures. The GPU can draw a thousand triangles almost as fast as ten, so with many small objects the frame is limited by this per-call CPU overhead, not by triangle count.

In Three.js every visible Mesh is at least one draw call (more with multi-material geometry or shadows, since each shadow-casting light renders casters again). Read renderer.info.render.calls after rendering to see the count. Under WebGL a few hundred calls per frame is comfortable on a laptop; several thousand will eat most of the frame budget.

Instancing (THREE.InstancedMesh) draws N copies of one geometry and material in a single call, with a per-instance matrix and optional colour. Batching (THREE.BatchedMesh) draws many *different* geometries that share a material in one call. Static scenery can also be merged into one geometry at load time. Sharing materials and packing textures into a texture atlas is what makes batching possible.

Fewer calls usually also means fewer state changes. When you can't merge, sort by shader and then material so consecutive calls reuse state, which is what Three.js does for opaque objects.

Key formulas

CPU submission cost

With c_call around 5–20 µs in WebGL, 1,000 separate meshes can cost several ms.

Instancing

Rules of thumb

  • Check renderer.info.render.calls (and .triangles) every time a scene gets slower.
  • Aim for under ~200–500 draw calls per frame on web for mid-range hardware.
  • Use InstancedMesh for 50+ copies of the same mesh (trees, rocks, bullets, crowd props).
  • Use BatchedMesh or merged geometry when objects differ in shape but share a material.
  • Set instanceMatrix.needsUpdate = true only on frames where instances moved, and call computeBoundingSphere() after large moves so culling stays correct.

Implementation

1,000 cubes in one draw call with THREE.InstancedMesh:
const COUNT = 1000
const geometry = new THREE.BoxGeometry(0.4, 0.4, 0.4)
const material = new THREE.MeshStandardMaterial()
const cubes = new THREE.InstancedMesh(geometry, material, COUNT)
const m = new THREE.Matrix4()
const color = new THREE.Color()

for (let i = 0; i < COUNT; i++) {
  m.setPosition((i % 32) - 16, 0, Math.floor(i / 32) - 16)
  cubes.setMatrixAt(i, m)
  cubes.setColorAt(i, color.setHSL(i / COUNT, 0.6, 0.5))
}
cubes.instanceMatrix.needsUpdate = true
cubes.instanceColor!.needsUpdate = true
scene.add(cubes)

renderer.render(scene, camera)
console.log(renderer.info.render.calls) // 1 for the cubes, not 1000

Common mistakes

  • Creating a new material per object (even with identical settings), which defeats batching and multiplies state changes.
  • Measuring triangles while ignoring call count, then simplifying meshes that were never the bottleneck.
  • Forgetting needsUpdate after setMatrixAt, so instances don't move.
  • Leaving the instanced mesh's bounding sphere at its initial value, so the whole batch gets frustum-culled when it shouldn't.
  • Merging everything into one giant mesh, which then can't be culled or updated in parts. Group by region instead.

Prompt

Refactor my TypeScript + Three.js scene to cut draw calls. Find meshes that share geometry and material and replace each group with a THREE.InstancedMesh (copy world matrices with setMatrixAt, per-instance colour with setColorAt). Group meshes that share a material but differ in geometry into a THREE.BatchedMesh. Keep a map from the old object ids to instance ids so gameplay code can still move or hide one item (hide by scaling its matrix to zero). Recompute bounding spheres after edits. Log renderer.info.render.calls before and after, and write a test that builds 500 identical meshes and asserts the converted scene renders them with one call.

Further reading

Updated 2026-11-01