Spiral Arms of Galaxies — density waves and 80 000 stars in Three.js
Why do spiral arms persist for billions of years even though individual stars orbit at different speeds? The answer — density wave theory — also explains how to render a convincing procedural galaxy from just a handful of maths equations.
1. Milky Way structure
The Milky Way is a barred spiral galaxy (type SBbc in the Hubble sequence). Its main structural components, relevant to our simulation, are:
- Bulge: dense, roughly spherical central region (~10 000 ly radius). Old yellow-red stars, very high density. Contains the central bar.
- Disk: thin flat region (~100 000 ly diameter, ~1 000 ly thick). Where spiral arms live. Contains young blue-white stars and gas.
- Spiral arms: 4 main arms (Perseus, Norma, Sagittarius, Scutum-Centaurus). The arms are regions of higher star density — not a fixed set of stars.
- Halo: spherical region of old red/yellow stars and globular clusters, extending to ~300 000 ly. We usually skip this in simulations.
2. The winding paradox
Stars closer to the galactic centre orbit faster than stars farther out (differential rotation). If spiral arms were fixed patterns of the same stars, differential rotation would wind them up into a thin ring within a few hundred million years — a tiny fraction of the galaxy's 10 billion year lifetime.
Yet we observe galaxies with distinct two-armed spirals. The resolution of this winding paradox is that the arms are not the same stars — they are density waves, like a traffic jam that persists long after individual cars have passed through it.
3. Density wave theory (Lin & Shu, 1964)
Chia-Chiao Lin and Frank Shu proposed that spiral arms are quasi-stationary density waves in the galactic disk. Stars (and gas) travel on slightly elliptical orbits; when those orbits are oriented appropriately, they bunch up periodically to produce armlike concentrations.
The key insight for simulation: instead of evolving N-body gravitational dynamics (which would require millions of particles at O(N²) cost), we approximate the steady-state density pattern with a parametric distribution:
- Place most stars along a predefined log-spiral backbone.
- Add random scatter around each backbone point — lower scatter near arm peaks, higher scatter in inter-arm regions.
- Assign stellar ages: young hot blue stars cluster in the arms (born there recently); old red stars are uniformly distributed throughout the disk.
This produces visually convincing galaxies without any real orbital mechanics. The galaxy simulation in this project uses exactly this approach.
4. Logarithmic spiral equation
Observed spiral galaxies closely follow logarithmic spirals — curves in which the angle increases linearly with the logarithm of the radius. In polar coordinates:
or equivalently:
θ = (1/b) · ln(r/a)
a — scale factor (radius at θ=0)
b — tightness factor (controls how quickly the arm opens)
θ — angle (radians)
For the Milky Way, the pitch angle (angle between the arm tangent and
a circle) is ≈ 12–15°, corresponding to
b ≈ tan(13°) ≈ 0.231. Tighter spirals (like Sa galaxies)
have a smaller pitch angle, while looser ones (Sc) are larger.
To generate Cartesian coordinates for a star on the n-th arm, rotated by an offset angle:
θ_arm = (2π / N) · i ← equal angular spacing
For a point at radius r on that arm:
θ = (1/b) · ln(r/a) + θ_arm
x = r · cos(θ)
y = r · sin(θ)
Then scatter: x += gaussian(0, σ(r)), y += gaussian(0, σ(r))
where σ(r) = r · scatter_factor (scatter grows with radius)
// Arm point generation
function armPoint(radius, armIndex, numArms, tightness, scatter) {
const a = 1.0;
const b = tightness; // ~0.2 – 0.4
const armOffset = (Math.PI * 2 / numArms) * armIndex;
const theta = (1 / b) * Math.log(radius / a) + armOffset;
const s = scatter * radius;
const dx = gaussRand() * s;
const dy = gaussRand() * s;
return {
x: radius * Math.cos(theta) + dx,
y: radius * Math.sin(theta) + dy,
z: gaussRand() * radius * 0.004 // thin disk
};
}
// Box-Muller transform for Gaussian random numbers
function gaussRand() {
let u, v;
do { u = Math.random(); } while (u === 0);
do { v = Math.random(); } while (v === 0);
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
5. Three.js: InstancedMesh for 80 000 stars
Rendering 80k individual THREE.Mesh objects would cause
thousands of draw calls per frame and crash the GPU. The solution is
InstancedMesh: one geometry + one material, with
per-instance transforms stored in a 4×4 matrix buffer. One draw call
for all 80k stars.
import * as THREE from 'three';
const STAR_COUNT = 80_000;
const NUM_ARMS = 2;
const TIGHTNESS = 0.28;
const SCATTER = 0.22;
const GALAXY_R = 100; // scene units
// Tiny sphere as star geometry (or use Points for even better perf)
const geo = new THREE.SphereGeometry(0.12, 4, 4);
const mat = new THREE.MeshBasicMaterial({ vertexColors: true });
const mesh = new THREE.InstancedMesh(geo, mat, STAR_COUNT);
// Enable per-instance color
mesh.instanceColor = new THREE.InstancedBufferAttribute(
new Float32Array(STAR_COUNT * 3), 3
);
const dummy = new THREE.Object3D();
const color = new THREE.Color();
for (let i = 0; i < STAR_COUNT; i++) {
const arm = i % NUM_ARMS;
const t = (i / STAR_COUNT);
// Bulge stars (inner 15%)
if (Math.random() < 0.15) {
dummy.position.set(
gaussRand() * 10,
gaussRand() * 10,
gaussRand() * 3
);
color.setHSL(0.07, 0.6, 0.7); // warm yellow-orange
} else {
const r = 4 + Math.pow(Math.random(), 0.5) * (GALAXY_R - 4);
const pt = armPoint(r, arm, NUM_ARMS, TIGHTNESS, SCATTER);
dummy.position.set(pt.x, pt.z, pt.y); // y-up in Three.js
setStarColor(color, r / GALAXY_R);
}
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
mesh.setColorAt(i, color);
}
mesh.instanceMatrix.needsUpdate = true;
mesh.instanceColor.needsUpdate = true;
scene.add(mesh);
Slow rotation
The galaxy rotates with differential velocity: inner stars faster, outer stars slower. In the animation loop, skew each star's angular position by a small per-frame delta proportional to 1/r:
function animate() {
requestAnimationFrame(animate);
// Rotate the whole galaxy slowly around Y axis
mesh.rotation.y += 0.00012;
renderer.render(scene, camera);
}
THREE.Points with a
round sprite texture instead of
InstancedMesh + SphereGeometry to render 200k+ stars
without rasterising actual geometry. The GPU renders a single quad per
point and discards pixels outside the circle via the alpha texture.
6. Stellar colours and the HR diagram
Real stars span a colour range from blue-white hot O/B-type stars to cool red M-type giants. The Hertzsprung-Russell diagram maps luminosity vs. temperature. For our galaxy simulation we only care about the main sequence (90% of stars) and approximate colour by temperature:
| Spectral class | Temperature (K) | Colour | Fraction | Simulated RGB |
|---|---|---|---|---|
| O, B | 10 000–50 000 | Blue-white | 0.1% | rgb(200, 210, 255) |
| A | 7 500–10 000 | White | 0.6% | rgb(232, 238, 255) |
| F | 6 000–7 500 | Yellow-white | 3% | rgb(255, 252, 232) |
| G (Sun) | 5 200–6 000 | Yellow | 7% | rgb(255, 232, 147) |
| K | 3 700–5 200 | Orange | 12% | rgb(255, 192, 96) |
| M | 2 400–3 700 | Red | 76% | rgb(255, 100, 64) |
In the simulation we simplify this to a hue-based rule: stars in the arms (recently formed from dense gas clouds) are biased toward blue-white; old disk stars and bulge stars are biased toward orange-red. The radial position acts as a proxy for stellar age:
function setStarColor(color, normalizedRadius) {
// Young stars near arms: blue-white. Old diffuse stars: warm orange.
const isArm = Math.random() < (1.0 - normalizedRadius * 0.5);
if (isArm && Math.random() < 0.08) {
// Hot young O/B star in arm
color.setRGB(0.79, 0.88, 1.0);
} else if (Math.random() < 0.25) {
// F/G type — yellow-white
color.setHSL(0.13, 0.8, 0.82 + Math.random()*0.12);
} else {
// M class red giant — bulk of stars
const hue = 0.05 + Math.random() * 0.04;
color.setHSL(hue, 0.7, 0.55 + Math.random()*0.2);
}
}
7. Procedural nebulae and dust lanes
Spiral arms contain not just stars but vast clouds of gas and dust — nebulae — that glow pink (hydrogen-alpha emission from HII regions) and dark (dust obscuring background stars). These are the most visually striking features of galaxy images.
For real-time rendering we use additive-blended sprite particles: large semitransparent quads with a soft circular texture, placed along the spiral arms with various colours:
// Nebula layer — additive sprites along arm
const NEBULA_COUNT = 200;
const nebulaGeo = new THREE.BufferGeometry();
const nebPos = new Float32Array(NEBULA_COUNT * 3);
const nebColor = new Float32Array(NEBULA_COUNT * 3);
for (let i = 0; i < NEBULA_COUNT; i++) {
const arm = i % NUM_ARMS;
const r = 8 + Math.random() * (GALAXY_R * 0.7);
const pt = armPoint(r, arm, NUM_ARMS, TIGHTNESS, 0.06);
nebPos[i*3] = pt.x;
nebPos[i*3+1] = pt.z * 0.5; // flattened
nebPos[i*3+2] = pt.y;
// Mix pink HII regions and blue-white emission
const pinkEmission = Math.random() < 0.6;
if (pinkEmission) {
nebColor[i*3] = 0.9;
nebColor[i*3+1] = 0.3;
nebColor[i*3+2] = 0.5;
} else {
nebColor[i*3] = 0.3;
nebColor[i*3+1] = 0.5;
nebColor[i*3+2] = 1.0;
}
}
nebulaGeo.setAttribute('position',
new THREE.BufferAttribute(nebPos, 3));
nebulaGeo.setAttribute('color',
new THREE.BufferAttribute(nebColor, 3));
const nebMat = new THREE.PointsMaterial({
size: 8.0,
vertexColors: true,
blending: THREE.AdditiveBlending,
transparent: true,
opacity: 0.18,
depthWrite: false,
sizeAttenuation: true,
map: nebulaSpriteTexture, // radial gradient → white circle
});
scene.add(new THREE.Points(nebulaGeo, nebMat));
Creating the sprite texture
// Procedural nebula texture — Canvas + DataTexture
function makeNebulaTexture(size = 64) {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d');
const r = size / 2;
const grad = ctx.createRadialGradient(r, r, 0, r, r, r);
grad.addColorStop(0.0, 'rgba(255,255,255,1)');
grad.addColorStop(0.4, 'rgba(255,255,255,0.4)');
grad.addColorStop(1.0, 'rgba(255,255,255,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, size, size);
return new THREE.CanvasTexture(canvas);
}
8. Extensions and improvements
- N-body gravitational simulation: replace the parametric distribution with actual gravitational forces between star particles. Even 5 000 particles show realistic arm formation from a disk initial condition. Requires Barnes-Hut O(N log N) for speed. See the N-Body article.
-
Bloom post-processing: add
THREE.UnrealBloomPassfor the bright core and hot stars to glow. Crucial for cinema-quality galaxy renders. The galaxy sim already uses this — see the site's galaxy simulation. - Barred spiral: modify the bulge to emit a central elongated bar. Stars within r < 15 units are placed along a rotated linear cluster, then the two arms emerge from the bar's ends.
- Galaxy merger: simulate two galaxy systems approaching each other. Even without true N-body physics, animating a second InstancedMesh on a Keplerian flyby trajectory looks impressive and illustrates tidal forces.
- GPU instanced animation: instead of rotating the whole mesh, write a custom vertex shader that reads per-instance orbital parameters from a texture, computing position on the GPU every frame.
🌌 Spiral Galaxy
The live galaxy simulation renders 80 000 stars with procedural spiral arms, animated rotation, and bloom post-processing.