How to Reduce Draw Calls in Three.js

In WebGL-based applications, high CPU-to-GPU overhead caused by excessive draw calls is often the primary reason for stuttering render loops and degraded framerates. This article details practical, industry-standard strategies development teams can deploy to dramatically lower draw call counts in Three.js, including geometric instancing, geometry merging, texture atlasing, BatchedMesh usage, and effective spatial culling.

Leverage InstancedMesh for Duplicate Geometries

When rendering dozens, hundreds, or thousands of identical objects (such as foliage, debris, or particles) that share the same geometry and material, regular THREE.Mesh instances submit an individual draw call for each item.

Replacing individual meshes with THREE.InstancedMesh collapses all instances of that geometry into a single draw call. You allocate a single buffer and pass transformation matrices (Matrix4) and optional instance colors (InstancedBufferAttribute) to the GPU:

const count = 1000;
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial();
const instancedMesh = new THREE.InstancedMesh(geometry, material, count);

const dummy = new THREE.Object3D();
for (let i = 0; i < count; i++) {
  dummy.position.set(Math.random() * 100, 0, Math.random() * 100);
  dummy.updateMatrix();
  instancedMesh.setMatrixAt(i, dummy.matrix);
}
instancedMesh.instanceMatrix.needsUpdate = true;
scene.add(instancedMesh);

Merge Static Geometries with BufferGeometryUtils

For non-identical geometries that remain static relative to one another (such as architectural elements, furniture, or stationary terrain), merging individual geometries into a single BufferGeometry removes redundant draw overhead.

Three.js provides mergeGeometries via the BufferGeometryUtils add-on. Merged meshes should share the same material so that they can be drawn simultaneously:

import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';

const geometries = [geo1, geo2, geo3];
const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries, false);
const singleMesh = new THREE.Mesh(mergedGeometry, sharedMaterial);
scene.add(singleMesh);

Ensure attributes (normals, UVs, tangents) match across all source geometries before merging to avoid dropped attributes or corrupted buffers.

Deploy BatchedMesh for Diverse Dynamic Objects

Introduced in modern Three.js releases, THREE.BatchedMesh solves the limitation where InstancedMesh requires identical geometries and mergeGeometries requires static transformations.

BatchedMesh accepts different geometries and allows independent translation, rotation, and scaling of each sub-mesh while keeping them inside a shared set of draw calls:

const maxGeometryCount = 100;
const maxVertexCount = 100000;
const maxIndexCount = 200000;

const batchedMesh = new THREE.BatchedMesh(
  maxGeometryCount,
  maxVertexCount,
  maxIndexCount,
  sharedMaterial
);

const geomId = batchedMesh.addGeometry(boxGeometry);
const instanceId = batchedMesh.addInstance(geomId);

batchedMesh.setMatrixAt(instanceId, matrix);
scene.add(batchedMesh);

Consolidate Materials with Texture Atlases

A unique material inherently triggers a separate draw call, even if the geometry is simple. Having dozens of distinct materials prevents batching and instancing.

To circumvent this:

  • Combine separate texture maps (albedo, roughness, normal) into a single large texture image known as a texture atlas.
  • Remap geometry UV coordinates to point to the correct coordinate region within the atlas.
  • Avoid assigning arrays of materials to individual faces of a mesh (mesh.material = [mat1, mat2]), as Three.js splits multi-material meshes into separate draw calls internally.

Optimize Culling and Object Visibility

A draw call is avoided entirely if an object is not sent to the pipeline.

  • Frustum Culling: Ensure mesh.frustumCulled = true (default) is enabled so objects outside the camera frustum are not rendered. Accurate bounding spheres (geometry.computeBoundingSphere()) are required for frustum culling to work correctly.
  • Distance Culling via LOD: Implement THREE.LOD (Level of Detail) to replace complex meshes with lower-polygon variants at greater distances, or unmount geometries completely past a visibility horizon.
  • Explicit Visibility Toggles: Set object.visible = false on hidden meshes. Reducing opacity to 0 via material transparency still executes draw calls and worsens alpha-blending overhead; toggling visible removes the object from the render queue completely.

Profile Draw Calls with the WebGLRenderer Info API

Regularly monitor draw calls during runtime using the built-in renderer statistics:

console.log(renderer.info.render.calls);
console.log(renderer.info.render.triangles);

Keeping renderer.info.render.calls within a target threshold (typically under 100 to 200 calls for mobile devices, and under 500 to 1000 for modern desktop targets) ensures CPU draw-call submission does not choke the render loop, keeping frame delivery at a steady 60 or 120 FPS.