Learn Creative Coding (#70) - Instancing: A Million Objects

in StemSocial4 days ago (edited)

Learn Creative Coding (#70) - Instancing: A Million Objects

cc-banner

Last episode we ran the rendered image through a stack of fullscreen effects -- bloom for emissive glow, SSAO for contact shadows, depth of field, color grading, vignette, film grain. The post-processing pipeline turned raw renders into polished cinematic pieces. But in every scene we built, the object count was modest. Ten columns. Five orbs. A handful of boxes. What if you wanted ten thousand columns? A hundred thousand crystals? A million grass blades?

Individual Mesh objects won't cut it. Each mesh is a separate draw call -- the CPU tells the GPU "here's a thing, draw it" once per mesh per frame. At 60fps, drawing 10,000 individual meshes means 600,000 CPU-to-GPU commands per second. The GPU itself is fast enough to handle the triangles, but the communication overhead between CPU and GPU is the bottleneck. It's like sending 10,000 individual letters instead of one package with 10,000 items in it.

We briefly touched on InstancedMesh in episode 65 (3D particle systems) where we used it for shaped particles -- tiny tetrahedra drifting through noise fields. That was a taste. This episode is the full meal. We're going deep into instancing: how it works, per-instance transforms with setMatrixAt, per-instance colors, custom per-instance data via InstancedBufferAttribute, animated instances, and creative applications -- forests, crystal caves, cities, grass fields. The technique that makes massive 3D scenes possible in real-time.

The draw call problem

Before we get into the solution, let's really understand the problem. Here's the naive approach -- creating 10,000 individual cubes:

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080810);

const camera = new THREE.PerspectiveCamera(
  60, window.innerWidth / window.innerHeight, 0.1, 200
);
camera.position.set(0, 20, 40);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;

scene.add(new THREE.AmbientLight(0x222244, 1.0));
const sun = new THREE.DirectionalLight(0xffeedd, 2.0);
sun.position.set(10, 20, 10);
scene.add(sun);

// DON'T do this for large counts
const geometry = new THREE.BoxGeometry(0.3, 0.3, 0.3);
const material = new THREE.MeshStandardMaterial({ color: 0x4488aa });

for (let i = 0; i < 10000; i++) {
  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.set(
    (Math.random() - 0.5) * 40,
    (Math.random() - 0.5) * 40,
    (Math.random() - 0.5) * 40
  );
  mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
  scene.add(mesh);
}

This creates 10,000 Mesh objects in the scene graph. Each one gets its own draw call. On a beefy GPU you might still hit 30fps but on anything mid-range, this crawls. The scene graph itself becomes heavy -- 10,000 Object3D instances each with their own matrix, layers, callbacks, event handlers, and metadata. Most of that is waste when all you want is "draw the same cube in 10,000 different positions."

Check renderer.info.render.calls after a frame and you'll see the number: 10,000+ draw calls. That's the problem.

InstancedMesh: one geometry, one material, N transforms

InstancedMesh solves this by sending one geometry and one material to the GPU, along with a list of 4x4 matrices -- one per instance. The GPU draws the geometry N times, each time applying a different matrix. One draw call. Same visual result as 10,000 individual meshes but the CPU-GPU communication drops from 10,000 messages to 1.

const count = 10000;
const geometry = new THREE.BoxGeometry(0.3, 0.3, 0.3);
const material = new THREE.MeshStandardMaterial({ color: 0x4488aa });

const mesh = new THREE.InstancedMesh(geometry, material, count);

const dummy = new THREE.Object3D();

for (let i = 0; i < count; i++) {
  dummy.position.set(
    (Math.random() - 0.5) * 40,
    (Math.random() - 0.5) * 40,
    (Math.random() - 0.5) * 40
  );
  dummy.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0);
  dummy.scale.setScalar(0.5 + Math.random() * 1.0);
  dummy.updateMatrix();
  mesh.setMatrixAt(i, dummy.matrix);
}

mesh.instanceMatrix.needsUpdate = true;
scene.add(mesh);

Same visual result. One draw call. The dummy Object3D trick (same one from ep065) lets you set position, rotation, and scale with the familiar Three.js API, then updateMatrix() bakes those transforms into a 4x4 matrix. setMatrixAt(index, matrix) copies that matrix into the instance buffer at the given index. After setting all instances, instanceMatrix.needsUpdate = true tells Three.js to upload the buffer to the GPU.

Check renderer.info.render.calls now -- it'll show ~2-3 (one for the instanced mesh, one or two for the lights). That's it.

The performace difference is massive. On my machine the individual meshes version struggles to hold 25fps. The instanced version runs at smooth 60fps. Same geometry, same look, completely different performance characteristics. Makes sense right?

Per-instance color

All 10,000 cubes being the same blue is boring. InstancedMesh supports per-instance color out of the box:

const color = new THREE.Color();

for (let i = 0; i < count; i++) {
  // ... position and rotation setup ...

  // height-based color
  const y = dummy.position.y;
  const t = (y + 20) / 40;  // normalize to 0..1
  color.setHSL(0.55 + t * 0.15, 0.6, 0.3 + t * 0.2);

  mesh.setColorAt(i, color);
}

mesh.instanceColor.needsUpdate = true;

setColorAt(index, color) works just like setMatrixAt -- it stores a per-instance color in a buffer attribute. The shader automatically reads it and multiplies with the base material color. So if your material is white (0xffffff), the per-instance color IS the final color. If the material has its own color, the per-instance color tints it.

You can drive the color from anything -- position, index, noise, data. The same idea as vertex colors from ep063 and ep065, but applied per-instance instead of per-vertex. Every instance gets one color for the whole mesh. If you need per-vertex-per-instance color variation, you'd need a custom shader (we'll get to that).

Procedural placement: grids, scatter, and patterns

Random scatter is fine for particle clouds but for scenes with structure -- forests, cityscapes, crystal formations -- you want deliberate placement algorithms. The instancing API doesn't care HOW you compute the matrices, just that you fill the buffer. So any placement algorithm works.

Grid with noise displacement:

const gridSize = 50;
const spacing = 1.2;
const count = gridSize * gridSize;
const mesh = new THREE.InstancedMesh(geometry, material, count);

let idx = 0;
for (let gx = 0; gx < gridSize; gx++) {
  for (let gz = 0; gz < gridSize; gz++) {
    const x = (gx - gridSize / 2) * spacing;
    const z = (gz - gridSize / 2) * spacing;

    // noise displacement breaks the perfect grid
    const nx = noise2D(x * 0.1, z * 0.1) * 0.4;
    const nz = noise2D(x * 0.1 + 100, z * 0.1) * 0.4;
    const ny = noise2D(x * 0.05, z * 0.05) * 2.0;

    dummy.position.set(x + nx, ny, z + nz);
    dummy.rotation.y = Math.random() * Math.PI * 2;
    dummy.scale.setScalar(0.3 + noise2D(x * 0.2, z * 0.2) * 0.3);
    dummy.updateMatrix();
    mesh.setMatrixAt(idx, dummy.matrix);

    idx++;
  }
}

mesh.instanceMatrix.needsUpdate = true;
scene.add(mesh);

The grid gives you even coverage. The noise displacement breaks the regularity so it doesn't look mechanical. Noise-driven scale variation means some instances are big and some are small -- natural looking. Combine this with per-instance color and you've got a field of objects that looks organic despite being algorithmically placed.

Phyllotaxis pattern (the spiral arrangement found in sunflowers):

const phyllCount = 5000;
const phyllMesh = new THREE.InstancedMesh(
  new THREE.ConeGeometry(0.05, 0.2, 6),
  new THREE.MeshStandardMaterial({ color: 0xaacc44 }),
  phyllCount
);

const goldenAngle = Math.PI * (3 - Math.sqrt(5));  // ~137.5 degrees

for (let i = 0; i < phyllCount; i++) {
  const angle = i * goldenAngle;
  const r = Math.sqrt(i) * 0.15;

  dummy.position.set(
    Math.cos(angle) * r,
    0,
    Math.sin(angle) * r
  );
  dummy.rotation.set(
    (Math.random() - 0.5) * 0.3,
    angle,
    (Math.random() - 0.5) * 0.3
  );
  dummy.scale.set(1, 0.5 + Math.random() * 1.5, 1);
  dummy.updateMatrix();
  phyllMesh.setMatrixAt(i, dummy.matrix);

  const t = i / phyllCount;
  color.setHSL(0.2 + t * 0.15, 0.5, 0.3 + t * 0.1);
  phyllMesh.setColorAt(i, color);
}

phyllMesh.instanceMatrix.needsUpdate = true;
phyllMesh.instanceColor.needsUpdate = true;
scene.add(phyllMesh);

The golden angle creates the classic sunflower spiral where no two elements overlap efficiently. Math.sqrt(i) * spacing for the radius gives even density (without the sqrt, density increases toward the center). This placement algorithm is beautiful in itself -- 5,000 small cones arranged in a spiral creates a flower-like sculpture from pure math.

Custom per-instance attributes

Color and transform aren't always enough. What if each instance needs its own animation phase, size modifier, opacity, material variant, or arbitrary data? InstancedBufferAttribute lets you attach any per-instance data and read it in a custom shader.

const count = 20000;
const geometry = new THREE.IcosahedronGeometry(0.1, 0);
const instancedMesh = new THREE.InstancedMesh(geometry, null, count);

// custom per-instance data
const phases = new Float32Array(count);
const scales = new Float32Array(count);
const hues = new Float32Array(count);

for (let i = 0; i < count; i++) {
  phases[i] = Math.random() * Math.PI * 2;
  scales[i] = 0.5 + Math.random() * 2.0;
  hues[i] = Math.random();

  // position in a sphere volume
  const theta = Math.random() * Math.PI * 2;
  const phi = Math.acos(2 * Math.random() - 1);
  const r = Math.pow(Math.random(), 1 / 3) * 8;

  dummy.position.set(
    r * Math.sin(phi) * Math.cos(theta),
    r * Math.sin(phi) * Math.sin(theta),
    r * Math.cos(phi)
  );
  dummy.updateMatrix();
  instancedMesh.setMatrixAt(i, dummy.matrix);
}

// attach as instanced buffer attributes
geometry.setAttribute('aPhase',
  new THREE.InstancedBufferAttribute(phases, 1)
);
geometry.setAttribute('aScale',
  new THREE.InstancedBufferAttribute(scales, 1)
);
geometry.setAttribute('aHue',
  new THREE.InstancedBufferAttribute(hues, 1)
);

instancedMesh.material = new THREE.ShaderMaterial({
  vertexShader: `
    attribute float aPhase;
    attribute float aScale;
    attribute float aHue;

    uniform float uTime;

    varying float vHue;
    varying float vLife;

    void main() {
      vHue = aHue;

      // pulsing scale based on phase
      float pulse = 1.0 + sin(uTime * 2.0 + aPhase) * 0.3;

      // apply instance matrix, then custom scale
      vec4 mvPosition = modelViewMatrix * instanceMatrix * vec4(position * aScale * pulse, 1.0);

      vLife = pulse;
      gl_Position = projectionMatrix * mvPosition;
    }
  `,
  fragmentShader: `
    varying float vHue;
    varying float vLife;

    vec3 hsl2rgb(float h, float s, float l) {
      vec3 rgb = clamp(abs(mod(h * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
      return l + s * (rgb - 0.5) * (1.0 - abs(2.0 * l - 1.0));
    }

    void main() {
      vec3 col = hsl2rgb(vHue, 0.6, 0.3 + vLife * 0.15);
      gl_FragColor = vec4(col, 1.0);
    }
  `,
  uniforms: {
    uTime: { value: 0 }
  }
});

instancedMesh.instanceMatrix.needsUpdate = true;
scene.add(instancedMesh);

The key line is new THREE.InstancedBufferAttribute(data, itemSize). Unlike regular BufferAttribute (which is per-vertex), InstancedBufferAttribute is per-instance. In the vertex shader, aPhase, aScale, and aHue give you one value per instance -- the same value for every vertex in that instance. Combined with the instanceMatrix (which Three.js provides automatically for InstancedMesh), you have full control over each instance's appearance and behavior.

Notice how instanceMatrix appears in the vertex shader. Three.js injects this automatically when using InstancedMesh -- it's a mat4 containing the matrix you set with setMatrixAt. We multiply by it to position each instance correctly, then apply our custom scale on top.

Animated instances

Static instances are useful for scenery but animation brings them alive. Update the matrices each frame, set needsUpdate = true, and the instances move:

const clock = new THREE.Clock();
const instanceData = [];

for (let i = 0; i < count; i++) {
  instanceData.push({
    baseY: dummy.position.y,
    x: dummy.position.x,
    z: dummy.position.z,
    phase: Math.random() * Math.PI * 2,
    rotSpeed: 0.5 + Math.random() * 2.0
  });
}

function animate() {
  requestAnimationFrame(animate);
  const t = clock.getElapsedTime();

  for (let i = 0; i < count; i++) {
    const d = instanceData[i];

    dummy.position.set(
      d.x,
      d.baseY + Math.sin(t * 1.5 + d.phase) * 0.3,
      d.z
    );
    dummy.rotation.y = t * d.rotSpeed;
    dummy.updateMatrix();
    mesh.setMatrixAt(i, dummy.matrix);
  }

  mesh.instanceMatrix.needsUpdate = true;

  // update shader time
  if (mesh.material.uniforms) {
    mesh.material.uniforms.uTime.value = t;
  }

  controls.update();
  renderer.render(scene, camera);
}

Each instance bobs and spins independently based on its stored phase and speed. The JavaScript loop updates 10,000 matrices per frame, which takes a few milliseconds. For 10K instances this is fine. For 100K+ you'd want to move the animation to the vertex shader (like we did for particles in ep065) to avoid the JavaScript bottleneck.

The vertex shader approach: store static base positions in the instance matrices, and compute the animation (bob, rotation, scale pulse) entirely in the vertex shader using uTime and a per-instance phase attribute. That way the instanceMatrix buffer never changes and doesn't need re-uploading. The GPU handles the per-frame animation internally.

Forest: instanced trees

Allez, time for a creative application. A forest of thousands of trees from a single draw call. We'll use a simple tree shape -- a cylinder trunk + cone canopy -- and instance it across terrain:

// simple tree shape: cylinder trunk + cone canopy
function createTreeGeometry() {
  const trunk = new THREE.CylinderGeometry(0.04, 0.06, 0.4, 6);
  const canopy = new THREE.ConeGeometry(0.25, 0.6, 8);
  canopy.translate(0, 0.5, 0);

  // merge into one geometry
  const merged = new THREE.BufferGeometry();

  const trunkPos = trunk.attributes.position.array;
  const canopyPos = canopy.attributes.position.array;
  const positions = new Float32Array(trunkPos.length + canopyPos.length);
  positions.set(trunkPos);
  positions.set(canopyPos, trunkPos.length);

  merged.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  merged.computeVertexNormals();
  return merged;
}

const treeCount = 8000;
const treeGeo = createTreeGeometry();
const treeMat = new THREE.MeshStandardMaterial({
  vertexColors: false,
  roughness: 0.8
});

const trees = new THREE.InstancedMesh(treeGeo, treeMat, treeCount);
const treeColor = new THREE.Color();

const terrainSize = 60;

for (let i = 0; i < treeCount; i++) {
  const x = (Math.random() - 0.5) * terrainSize;
  const z = (Math.random() - 0.5) * terrainSize;

  // terrain height from noise
  const y = noise2D(x * 0.05, z * 0.05) * 3.0;

  // skip trees on low ground (water level)
  if (y < 0.2) {
    dummy.position.set(0, -100, 0);
    dummy.scale.setScalar(0.001);
    dummy.updateMatrix();
    trees.setMatrixAt(i, dummy.matrix);
    continue;
  }

  // vary tree size by terrain height
  const heightFactor = (y / 3.0);
  const scale = 0.6 + heightFactor * 0.8 + Math.random() * 0.4;

  dummy.position.set(x, y, z);
  dummy.rotation.y = Math.random() * Math.PI * 2;
  dummy.scale.set(scale, scale * (0.8 + Math.random() * 0.4), scale);
  dummy.updateMatrix();
  trees.setMatrixAt(i, dummy.matrix);

  // darker green in valleys, lighter on ridges
  treeColor.setHSL(
    0.28 + Math.random() * 0.06,
    0.4 + Math.random() * 0.2,
    0.15 + heightFactor * 0.12
  );
  trees.setColorAt(i, treeColor);
}

trees.instanceMatrix.needsUpdate = true;
trees.instanceColor.needsUpdate = true;
trees.castShadow = true;
trees.receiveShadow = true;
scene.add(trees);

8,000 trees, one draw call. Each tree has random rotation, terrain-following height, size variation based on elevation, and color variation from dark valley greens to lighter ridge greens. Trees that would be below water level are hidden by moving them far below the scene and scaling to near-zero.

The castShadow = true on an InstancedMesh makes ALL instances cast shadows. Three.js handles this automatically -- the shadow map renders all instances in the same single draw call. So you get 8,000 shadow-casting trees for the same cost as one. The shadow map resolution becomes the limiting factor, not the instance count.

Crystal cave: instanced minerals

Another creative application. Instanced crystals growing from the walls and floor of a dark cave:

const crystalCount = 3000;
const crystalGeo = new THREE.ConeGeometry(0.03, 0.15, 5);
crystalGeo.translate(0, 0.075, 0);  // pivot at base

const crystalMat = new THREE.MeshPhysicalMaterial({
  color: 0x000000,
  emissive: 0x2244aa,
  emissiveIntensity: 0.8,
  roughness: 0.1,
  clearcoat: 1.0,
  transparent: true,
  opacity: 0.85
});

const crystals = new THREE.InstancedMesh(crystalGeo, crystalMat, crystalCount);
const crystalColor = new THREE.Color();

for (let i = 0; i < crystalCount; i++) {
  // place on hemisphere surface (cave ceiling and walls)
  const theta = Math.random() * Math.PI * 2;
  const phi = Math.random() * Math.PI * 0.8;
  const r = 5;

  const x = r * Math.sin(phi) * Math.cos(theta);
  const y = r * Math.cos(phi);
  const z = r * Math.sin(phi) * Math.sin(theta);

  dummy.position.set(x, y + 2, z);

  // point crystal inward (toward center)
  dummy.lookAt(0, 2, 0);
  dummy.rotateX(Math.PI / 2);

  // random tilt
  dummy.rotateX((Math.random() - 0.5) * 0.4);
  dummy.rotateZ((Math.random() - 0.5) * 0.4);

  // varied sizes
  const s = 0.5 + Math.random() * 3.0;
  dummy.scale.set(s * 0.7, s, s * 0.7);

  dummy.updateMatrix();
  crystals.setMatrixAt(i, dummy.matrix);

  // blues, teals, purples
  crystalColor.setHSL(
    0.55 + Math.random() * 0.2,
    0.5 + Math.random() * 0.3,
    0.3 + Math.random() * 0.15
  );
  crystals.setColorAt(i, crystalColor);
}

crystals.instanceMatrix.needsUpdate = true;
crystals.instanceColor.needsUpdate = true;
scene.add(crystals);

3,000 crystals pointing inward from a hemispherical cave surface. The lookAt trick points each crystal toward the center, then rotateX(PI/2) flips it so the point faces inward (since cones point up by default). Random tilt gives each crystal an organic angle. The emissive material with clearcoat makes them glow slightly and catch sharp highlights.

Add a couple of point lights inside the cave and bloom post-processing from ep069, and this becomes genuinely atmospheric. The emissive crystals each contribute a tiny bit of visible glow, and the clearcoat creates sparkling highlights as you orbit the camera. One draw call for all 3,000 crystals.

Grass field: vertex shader wind

The grass field is the classic instancing showcase. A hundred thousand thin triangles covering terrain, animated by wind in the vertex shader. Here's where we combine instancing with custom shader animation for maximum scale:

const bladeCount = 100000;

// single grass blade: a thin triangle
const bladeGeo = new THREE.BufferGeometry();
const bladeVerts = new Float32Array([
  -0.015, 0, 0,
   0.015, 0, 0,
   0, 0.2, 0
]);
bladeGeo.setAttribute('position', new THREE.BufferAttribute(bladeVerts, 3));
bladeGeo.computeVertexNormals();

// per-instance data
const offsets = new Float32Array(bladeCount * 3);
const rotations = new Float32Array(bladeCount);
const heights = new Float32Array(bladeCount);
const colorVar = new Float32Array(bladeCount);

for (let i = 0; i < bladeCount; i++) {
  offsets[i * 3] = (Math.random() - 0.5) * 30;
  offsets[i * 3 + 1] = 0;
  offsets[i * 3 + 2] = (Math.random() - 0.5) * 30;
  rotations[i] = Math.random() * Math.PI * 2;
  heights[i] = 0.6 + Math.random() * 0.8;
  colorVar[i] = Math.random();
}

bladeGeo.setAttribute('aOffset',
  new THREE.InstancedBufferAttribute(offsets, 3));
bladeGeo.setAttribute('aRotation',
  new THREE.InstancedBufferAttribute(rotations, 1));
bladeGeo.setAttribute('aHeight',
  new THREE.InstancedBufferAttribute(heights, 1));
bladeGeo.setAttribute('aColorVar',
  new THREE.InstancedBufferAttribute(colorVar, 1));

const grassMat = new THREE.ShaderMaterial({
  vertexShader: `
    attribute vec3 aOffset;
    attribute float aRotation;
    attribute float aHeight;
    attribute float aColorVar;

    uniform float uTime;

    varying float vHeight;
    varying float vColorVar;

    void main() {
      vHeight = position.y / 0.2;  // 0 at base, 1 at tip
      vColorVar = aColorVar;

      // scale blade height
      vec3 p = position;
      p.y *= aHeight;

      // wind: bend the tip, base stays fixed
      float windStrength = sin(uTime * 1.5 + aOffset.x * 0.3 + aOffset.z * 0.2) * 0.08;
      windStrength += sin(uTime * 2.3 + aOffset.x * 0.5) * 0.03;
      p.x += windStrength * vHeight * vHeight;  // quadratic bend

      // rotation around Y
      float c = cos(aRotation);
      float s = sin(aRotation);
      vec3 rotated = vec3(
        p.x * c - p.z * s,
        p.y,
        p.x * s + p.z * c
      );

      // offset to world position
      vec3 worldPos = rotated + aOffset;

      gl_Position = projectionMatrix * modelViewMatrix * vec4(worldPos, 1.0);
    }
  `,
  fragmentShader: `
    varying float vHeight;
    varying float vColorVar;

    void main() {
      // green gradient: dark at base, lighter at tip
      vec3 baseColor = vec3(0.08, 0.18, 0.05);
      vec3 tipColor = vec3(0.3, 0.5, 0.15);

      // per-instance color variation
      vec3 warmTint = vec3(0.35, 0.45, 0.1);
      vec3 coolTint = vec3(0.1, 0.35, 0.15);
      vec3 tint = mix(coolTint, warmTint, vColorVar);

      vec3 col = mix(baseColor, mix(tipColor, tint, 0.5), vHeight);

      gl_FragColor = vec4(col, 1.0);
    }
  `,
  uniforms: {
    uTime: { value: 0 }
  },
  side: THREE.DoubleSide
});

const grass = new THREE.InstancedMesh(bladeGeo, grassMat, bladeCount);
const identityMatrix = new THREE.Matrix4();
for (let i = 0; i < bladeCount; i++) {
  grass.setMatrixAt(i, identityMatrix);
}
grass.instanceMatrix.needsUpdate = true;
scene.add(grass);

function animate() {
  requestAnimationFrame(animate);
  const t = clock.getElapsedTime();
  grassMat.uniforms.uTime.value = t;

  controls.update();
  renderer.render(scene, camera);
}

100,000 grass blades, all animated by wind in the vertex shader. The wind is two layered sine waves sampled at each blade's world position -- nearby blades get similar wind, creating visible waves rolling across the field. The vHeight * vHeight quadratic factor makes the base stay fixed while the tip sways most -- like real grass that bends from the top.

Notice we're NOT calling setMatrixAt with actual transforms here. Instead we set identity matrices and handle all positioning in the vertex shader via aOffset and aRotation. This means the instance matrix buffer never needs updating -- it's static identity matrices. All animation happens on the GPU. The JavaScript animation loop only updates the uTime uniform, which is a single float. Zero per-instance CPU work per frame.

This is the pattern for massive-scale instancing: store static per-instance data in InstancedBufferAttributes, compute all animation in the vertex shader, never touch the instance matrix buffer after initialization.

Performance: when to use what

Here's a practical guide based on what I've found works:

Individual Mesh objects: up to ~100-200 objects. Simple scenes, prototyping, objects that need independent raycasting, physics, or event handling. Each mesh can have its own material. Easy to work with but doesn't scale.

InstancedMesh with JavaScript updates: up to ~50,000 instances with per-frame matrix updates. The JavaScript loop that calls setMatrixAt is the bottleneck. Good for moderately animated scenes where each instance moves differently.

InstancedMesh with shader animation: up to ~500,000+ instances. Static instance matrices, all motion computed in the vertex shader. The GPU does everything. This is the maximum performance tier before you start hitting GPU vertex processing limits.

Points (from ep065): up to ~2,000,000+ particles. Simpler than InstancedMesh (no geometry, just screen-facing sprites) but limited to flat circles/textures. Use Points for particle effects and InstancedMesh for shaped objects.

The crossover point between "just use individual meshes" and "you need instancing" is around 500-1000 objects. Below that, the draw call overhead is barely noticeable. Above it, you'll start seeing frame drops on typical hardware.

Combining instancing with post-processing

This is where it all comes together. Take the crystal cave from earlier in this episode and run it through the bloom + vignette pipeline from ep069:

// ... crystal cave setup from above ...

import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));

const bloom = new UnrealBloomPass(
  new THREE.Vector2(window.innerWidth, window.innerHeight),
  0.8, 0.4, 0.7
);
composer.addPass(bloom);
composer.addPass(new OutputPass());

// 3000 emissive crystals now glow with bloom halos
// still just one draw call for all the crystals

3,000 glowing crystals with bloom post-processing, all rendered in one draw call. Orbit inside the cave and it's genuinely beautiful -- each crystal has its own color, the emissive glow bleeds softly, the clearcoat catches sharp highlights. The combination of instancing (for quantity) and post-processing (for visual polish) is incredibly powerful for creative coding.

What's ahead

We've covered the full instancing toolkit -- InstancedMesh for massive object counts from a single draw call, per-instance transforms with the dummy Object3D trick, per-instance color, custom per-instance data with InstancedBufferAttribute, animated instances both from JavaScript and from vertex shaders, placement algorithms (grids, scatter, phyllotaxis), and creative applications in forests, crystal caves, and grass fields.

This changes what's possible in real-time 3D. Before instancing, complex scenes meant choosing between visual density and frame rate. With instancing, you can have both. The techniques from the last several episodes -- procedural geometry (ep066), custom shaders (ep064), animation (ep067), lighting (ep068), post-processing (ep069) -- all combine with instancing to create scenes that would have required a render farm not that long ago.

Next we'll explore working with text in 3D space -- turning letterforms into geometry you can manipulate, extrude, deform, and animate. Typography becomes sculptural material when you treat each character as a mesh.

't Komt erop neer...

  • The draw call bottleneck: each individual Mesh in Three.js costs one CPU-to-GPU draw call. At 10,000+ meshes, the draw call overhead kills frame rate even though the GPU could handle the triangles. Check renderer.info.render.calls to see the number
  • InstancedMesh(geometry, material, count) draws one geometry N times with per-instance 4x4 transform matrices. One draw call for all instances. 10,000 cubes go from 10,000 draw calls to 1. The performance difference is massive -- often 10x or more
  • setMatrixAt(index, matrix) sets each instance's transform. Use a dummy Object3D to set position, rotation, scale, call updateMatrix(), then copy the matrix. Always set instanceMatrix.needsUpdate = true after modifying instances
  • setColorAt(index, color) gives each instance its own color, multiplied with the base material color. Drive it from position, noise, data, anything. Set instanceColor.needsUpdate = true
  • InstancedBufferAttribute attaches custom per-instance data (phase, scale, opacity, anything) readable in custom shaders. Unlike regular BufferAttribute (per-vertex), it provides one value per instance across all vertices
  • Animated instances: either update matrices in JavaScript (good up to ~50K instances) or compute motion entirely in the vertex shader (good up to 500K+). The shader approach stores static data in attributes and computes animation from uTime -- zero per-instance CPU cost per frame
  • Placement algorithms: grid + noise displacement for even coverage with organic feel, phyllotaxis for spiral arrangements, random scatter for particle-like distributions. The instancing API doesn't care how you compute the matrices
  • Creative applications: forests (trees on noise terrain with height-based size/color), crystal caves (instances on hemisphere surface pointing inward, emissive material + bloom), grass fields (100K+ thin triangles with vertex shader wind animation using layered sine waves)
  • InstancedMesh supports castShadow and receiveShadow -- all instances get shadows for the cost of one shadow map render. Combine with post-processing (bloom, SSAO, vignette) for polished scenes at massive scale

Sallukes! Thanks for reading.

X

@femdev