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.

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 untouchedOn 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:
| House | Copies | InstancedMesh |
|---|---|---|
Plain brick (london-residential-benchmark) | 552 draw calls | 92 draw calls |
| Soot-blackened colourway | 1,050 draw calls | 175 draw calls |
| Triangles drawn (plain) | 62,772 | 62,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
- Instances are identical. Every house in the row has the same colourway and the same weathering pattern. For a mixed street, make one set of instanced meshes per house type or colourway: four colourways still cost four houses' draw calls, not forty.
- One bounding sphere per part. The row is culled as a whole; very long streets are better split into blocks, each with its own instanced set.
- Editing one house means updating its matrix with
setMatrixAt(andinstanceMatrix.needsUpdate = true), or taking it out of the instanced set.
See the draw calls for whole scenes, and measure them on your own device, on the benchmarks page.