Astrophysics · Space
📅 March 2026 ⏱ ≈ 12 min read 🎯 Intermediate

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:

Scale reminder: 1 light-year ≈ 9.46 × 10¹⁵ m. The Milky Way is ~100 000 ly across. Our simulation compresses this to a 200-unit Three.js scene — 1 unit = 500 ly is a convenient scale. Physical accuracy is sacrificed for visual clarity.

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.

Traffic-jam analogy: on a highway, cars slow down at a bottleneck, forming a dense cluster. The individual cars keep moving through and past; the cluster (the "jam") stays in place. Galactic spiral arms work the same way with stars and gas.

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:

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:

r = a · e^(b·θ)

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:

For arm i of N arms:
θ_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);
}
Performance tip: use 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

🌌 Spiral Galaxy

The live galaxy simulation renders 80 000 stars with procedural spiral arms, animated rotation, and bloom post-processing.

Launch simulation →