Galaxy — Rendering 80,000 Stars Without Slowdown

A spiral galaxy with 80,000 stars sounds impossible at 60 FPS — until you discover InstancedMesh and custom instanced shaders. The full story of how I cut GPU draw calls from 80,000 to 1.

The Problem: 80,000 Draw Calls

The naive approach to rendering many objects in Three.js is: create a Mesh for each one, add it to the scene. For 80,000 stars that means 80,000 Mesh objects, 80,000 draw calls per frame, and approximately 4 FPS on a mid-range GPU.

A draw call is a CPU→GPU command that says "render this geometry with this material". Each call has overhead — state changes, buffer binds, API validation. Modern GPUs can handle millions of triangles per frame, but only ~1,000–3,000 draw calls before the CPU becomes the bottleneck.

InstancedMesh — One Draw Call for All

Three.js's InstancedMesh solves this by packing all instance data (position, rotation, scale, colour) into GPU buffers, then executing a single instanced draw call. The GPU handles the per-instance variation entirely in the vertex shader.

const starGeometry = new THREE.SphereGeometry(0.05, 4, 4); // tiny sphere
const starMaterial = new THREE.MeshBasicMaterial();
const stars = new THREE.InstancedMesh(starGeometry, starMaterial, STAR_COUNT);

const dummy = new THREE.Object3D();
const color = new THREE.Color();

for (let i = 0; i < STAR_COUNT; i++) {
  // Spiral galaxy distribution
  const arm = Math.floor(Math.random() * 4);
  const angle = arm * (Math.PI / 2) + r * 2.5 + noise(r);
  const r = Math.random() ** 0.5 * RADIUS;

  dummy.position.set(
    Math.cos(angle) * r,
    (Math.random() - 0.5) * r * 0.1,
    Math.sin(angle) * r
  );
  dummy.updateMatrix();
  stars.setMatrixAt(i, dummy.matrix);

  // Temperature-based colour (blue hot → orange cool)
  const temp = Math.random();
  color.setHSL(0.6 - temp * 0.5, 0.9, 0.7 + temp * 0.2);
  stars.setColorAt(i, color);
}
stars.instanceMatrix.needsUpdate = true;
stars.instanceColor.needsUpdate = true;

Performance Before and After

Approach Draw Calls FPS (RTX 3060) FPS (Intel iGPU)
80,000 × Mesh 80,000 4 1
Points (BufferGeometry) 1 60 38
InstancedMesh (spheres) 1 60 45

Points (GPU points rendering) is also a valid approach and even slightly faster, but InstancedMesh lets each star be actual 3D geometry — you can make them glow, animate individual ones, or change their size based on stellar class.

Custom Instanced Shaders

InstancedMesh works with standard Three.js materials, but for the galaxy I wanted custom per-instance effects: a bloom-like halo, size variation by stellar class, and a subtle twinkle. This required a custom ShaderMaterial that reads the instance matrix.

Three.js injects the instance matrix automatically as instanceMatrix when you use USE_INSTANCING. You can access the per-instance colour via instanceColor in the vertex shader with the USE_INSTANCING_COLOR define.

Spiral Galaxy Distribution

Real spiral galaxies have arms that follow logarithmic spirals. The position distribution I used:

The fbm_noise (fractal Brownian motion) adds organic arm irregularity so the result doesn't look mechanical.

See it at /galaxy/ — orbit the camera to see the disc edge-on or look down the galactic axis.