Instancing

Draw 50,000 objects in one go instead of one at a time.

What it is

Every mesh costs a draw call: the CPU telling the GPU "draw this, with these settings". A few hundred are fine; thousands make the CPU the bottleneck long before the GPU breaks a sweat. An InstancedMesh draws many copies of one geometry and material in a single call, each copy with its own transform matrix and colour. The GPU is happy to draw millions of triangles; what it hates is being asked thousands of times.

When to use it

  • Particles, stars, debris, crowds of identical objects
  • Forests, grass, buildings, tiles: anything repeated
  • Data visualisations with thousands of points
Official docs ↗

One draw call against thousands

Loading 3D scene…

Switch to Separate meshes and the draw calls jump to one per cube on screen (Three.js skips cubes outside the camera's view: frustum culling), while the triangles stay the same. Turn on the FPS meter in the sidebar to feel the difference: 2,000 separate meshes can struggle on a laptop, while 50,000 instances usually don't. Separate meshes are capped at 2,000 here for that reason.

Show code · src/app/three-instancing/InstancingScene.tsx
"use client";

import { OrbitControls } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { BoxGeometry, Color, type Group, type InstancedMesh, MeshStandardMaterial, Object3D } from "three";
import { ThreeCanvas } from "@/components/three/ThreeCanvas";
import { Button } from "@/components/ui";

type Mode = "instanced" | "meshes";
const COUNTS = { instanced: [1_000, 10_000, 50_000], meshes: [500, 1_000, 2_000] } as const;

// A spiral galaxy: the same positions for both modes, so the only difference
// is how they're drawn. Deterministic (no Math.random) so it's identical on
// every render.
function galaxy(count: number) {
  const points: { x: number; y: number; z: number; s: number; hue: number }[] = [];
  for (let i = 0; i < count; i++) {
    const t = i / count;
    const arm = i % 3;
    const r = 0.3 + t * 6;
    const angle = arm * ((Math.PI * 2) / 3) + r * 0.9 + Math.sin(i * 12.9898) * 0.35;
    const spread = 0.35 * (1 - t * 0.5);
    points.push({
      x: Math.cos(angle) * r + Math.sin(i * 78.233) * spread,
      y: Math.sin(i * 39.425) * spread * 0.6,
      z: Math.sin(angle) * r + Math.cos(i * 43.758) * spread,
      s: 0.04 + (1 - t) * 0.06,
      hue: 0.72 - t * 0.35,
    });
  }
  return points;
}

// One InstancedMesh: one geometry, one material, one draw call. Each copy only
// gets its own matrix (position, rotation, scale) and colour.
function Instanced({ count }: { count: number }) {
  const mesh = useRef<InstancedMesh>(null);
  useLayoutEffect(() => {
    const m = mesh.current!;
    const dummy = new Object3D();
    const color = new Color();
    galaxy(count).forEach((p, i) => {
      dummy.position.set(p.x, p.y, p.z);
      dummy.rotation.set(i, i * 2, 0);
      dummy.scale.setScalar(p.s);
      dummy.updateMatrix();
      m.setMatrixAt(i, dummy.matrix);
      m.setColorAt(i, color.setHSL(p.hue, 0.8, 0.6));
    });
    m.instanceMatrix.needsUpdate = true;
    if (m.instanceColor) m.instanceColor.needsUpdate = true;
  }, [count]);
  return (
    <instancedMesh key={count} ref={mesh} args={[undefined, undefined, count]}>
      <boxGeometry />
      <meshStandardMaterial roughness={0.4} />
    </instancedMesh>
  );
}

// The naive way: a separate mesh per cube. Geometry and material are shared,
// so memory is fine, but every mesh is still its own draw call.
function Meshes({ count }: { count: number }) {
  const [geometry, material] = useMemo(() => [new BoxGeometry(), new MeshStandardMaterial({ color: "#a89dff", roughness: 0.4 })], []);
  const points = useMemo(() => galaxy(count), [count]);
  return (
    <>
      {points.map((p, i) => (
        <mesh key={i} geometry={geometry} material={material} position={[p.x, p.y, p.z]} rotation={[i, i * 2, 0]} scale={p.s} />
      ))}
    </>
  );
}

// Reads the renderer's own counters twice a second.
function Stats({ onStats }: { onStats: (s: { calls: number; triangles: number }) => void }) {
  const gl = useThree((s) => s.gl);
  const last = useRef(0);
  useFrame(({ clock }) => {
    if (clock.elapsedTime - last.current < 0.5) return;
    last.current = clock.elapsedTime;
    onStats({ calls: gl.info.render.calls, triangles: gl.info.render.triangles });
  });
  return null;
}

function Spin({ animate, children }: { animate: boolean; children: React.ReactNode }) {
  const group = useRef<Group>(null);
  useFrame((_, delta) => { if (animate && group.current) group.current.rotation.y += delta * 0.15; });
  return <group ref={group}>{children}</group>;
}

export function InstancingScene() {
  const [mode, setMode] = useState<Mode>("instanced");
  const [count, setCount] = useState<number>(10_000);
  const [stats, setStats] = useState({ calls: 0, triangles: 0 });

  const choose = (m: Mode) => { setMode(m); setCount(COUNTS[m][1]); };

  return (
    <>
      <ThreeCanvas camera={{ position: [0, 6, 9], fov: 45 }} label={`A spiral galaxy of ${count.toLocaleString("en")} cubes`}>
        {(animate) => (
          <>
            <color attach="background" args={["#08070c"]} />
            <ambientLight intensity={0.6} />
            <directionalLight position={[4, 6, 3]} intensity={2} />
            <Spin animate={animate}>{mode === "instanced" ? <Instanced count={count} /> : <Meshes count={count} />}</Spin>
            <Stats onStats={setStats} />
            <OrbitControls minDistance={3} maxDistance={20} />
          </>
        )}
      </ThreeCanvas>
      <div className="mb-2 flex flex-wrap items-center gap-2">
        <Button variant={mode === "instanced" ? "primary" : "ghost"} aria-pressed={mode === "instanced"} onClick={() => choose("instanced")}>InstancedMesh</Button>
        <Button variant={mode === "meshes" ? "primary" : "ghost"} aria-pressed={mode === "meshes"} onClick={() => choose("meshes")}>Separate meshes</Button>
        <span className="mx-1 h-5 w-px bg-line" aria-hidden="true" />
        {COUNTS[mode].map((n) => (
          <Button key={n} variant={n === count ? "primary" : "ghost"} aria-pressed={n === count} onClick={() => setCount(n)}>{n.toLocaleString("en")}</Button>
        ))}
      </div>
      <div className="mb-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
        <Stat label="Objects" value={count.toLocaleString("en")} />
        <Stat label="Draw calls per frame" value={stats.calls.toLocaleString("en")} />
        <Stat label="Triangles" value={stats.triangles.toLocaleString("en")} />
      </div>
    </>
  );
}

function Stat({ label, value }: { label: string; value: string }) {
  return (
    <div className="rounded-lg border border-line bg-bg px-3 py-2">
      <div className="text-xs text-muted">{label}</div>
      <div className="text-xl font-bold tabular-nums">{value}</div>
    </div>
  );
}