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:
- Pick a random arm (4 arms, spaced π/2 apart)
-
Pick a random radius
r ∝ sqrt(random)— sqrt bias puts more stars near the centre -
Compute angle:
armAngle + r × spiral_tightness + fbm_noise(r) -
Add Gaussian vertical spread:
y = gaussian(0, r × 0.08)
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.