Cut draw calls with InstancedMesh

A modular house is many parts, and each part is a draw call. Six copies of the two-storey bay-fronted terrace from the London terraced houses pack is 552 meshes, and the GPU is asked to draw each one separately. When the houses are identical, three.js can draw every copy of a part in one call with InstancedMesh. The code below is the code we ran.

A row of six soot-blackened London terraced houses drawn with InstancedMesh

The code

Unzip the pack next to your script; it uses the pack's assemblies.js loader.

// Tutorial example: a row of identical houses drawn with InstancedMesh.
// Assumes the pack is unzipped next to this file (assemblies/, assemblies.js, jstov-kit.js).
import * as THREE from 'three';
import { loadAssembly } from './assemblies.js';

// url: any one-house assembly, e.g. 'assemblies/london-residential-colourway-soot.json'.

// The usual way: one copy of the house per plot. Every part of every house is its own draw call.
export async function clonedRow(url, houses = 6, level = 'medium') {
  const house = await loadAssembly(url, { level });
  const row = new THREE.Group();
  for (let i = 0; i < houses; i++) { const copy = house.clone(); copy.position.x = i * 5; row.add(copy); }
  return row;
}

// Instanced: load the house once, then make one InstancedMesh per part with a
// matrix per plot. N identical houses cost one house's draw calls.
export async function instancedRow(url, houses = 6, level = 'medium') {
  const house = await loadAssembly(url, { level });
  house.updateMatrixWorld(true);
  const row = new THREE.Group();
  const plot = new THREE.Matrix4();
  house.traverse((mesh) => {
    if (!mesh.isMesh) return;
    const instanced = new THREE.InstancedMesh(mesh.geometry, mesh.material, houses);
    for (let i = 0; i < houses; i++) {
      plot.makeTranslation(i * 5, 0, 0);                       // the kit's house pitch is 5 m
      instanced.setMatrixAt(i, plot.clone().multiply(mesh.matrixWorld)); // plot × the part's place in the house
    }
    instanced.userData = { ...mesh.userData };                 // keep the kit's per-part material data
    instanced.computeBoundingSphere();                         // so culling sees the whole row
    row.add(instanced);
  });
  return row;
}

Or use the helper in the pack

Since pack format 2.1 (2 October 2026) every pack's assemblies.js exports instanceAssembly(root). It does the same thing for any group of loaded assemblies, without knowing the layout: every part used more than once, whether in several copies of a house or several times in one house (the same sash in each window, the same railing panel along a front), becomes one InstancedMesh.

import { loadAssembly, instanceAssembly } from './assemblies.js';

const street = new THREE.Group();
for (let i = 0; i < 6; i++) {
  const house = await loadAssembly('assemblies/london-residential-benchmark.json');
  house.position.x = i * 5;
  street.add(house);
}
scene.add(instanceAssembly(street)); // a new Group; street itself is untouched

On the street builder's default street (four houses, a restaurant and a generated shop) it takes the street from 653 draw calls to 214, and the picture is the same to the pixel (mean difference 0.02 out of 255 per channel). The benchmarks list the instanced count for every scene.

What we measured

Six houses at medium detail, the whole row in view, counted with renderer.info.render.calls in three.js 0.186:

HouseCopiesInstancedMesh
Plain brick (london-residential-benchmark)552 draw calls92 draw calls
Soot-blackened colourway1,050 draw calls175 draw calls
Triangles drawn (plain)62,77262,772

The triangles do not change: instancing saves the per-object work, not the geometry. The soot colourway costs nearly twice the calls because its glass look uses physical transmission, and three.js draws the scene a second time for it; at window scale plain transparency looks the same and removes that pass.

Do the weathered materials survive?

Yes. The kit's layered materials (soot, damp, moss on the slates, chips on the paint) are fitted to each part's own size, which is why merging parts across buildings breaks them. Instancing keeps every part's own geometry and material, so they render as before: we rendered the soot row both ways and the two images differ by an average of 0.03 in 255 per pixel channel, edge antialiasing only.

Limits

See the draw calls for whole scenes, and measure them on your own device, on the benchmarks page.

See the London terraced houses pack