Three.js From Zero · Article s0-02

Galaxy Generator

Galaxy Generator is Article s0-02 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.

← Three.js From Zero S0-02 · Quick Wins

Season 0 · Article 02 · Quick Wins

A few hundred thousand procedurally-placed stars, a spiral that emerges from one equation, and a full cinematic stack on top — bloom, a DSLR-style depth-of-field focus, tone mapping, and a 3-color ramp you can mix. Tune it live in the panel, or step through the Build it up stages to watch it grow from a flat spiral to the final shot, one idea at a time.

— fps · — stars
Build it up
Shape
Color
Bloom · glow
Focus · depth of field
Cinematic
drag to orbit · scroll to zoom

Drag to orbit. Push the count slider up to see when your machine hits its wall — a recent laptop GPU should handle 300k easily.

The whole trick in three lines

Every spiral galaxy you've ever seen in a Three.js tutorial uses the same three ideas:

  1. Place each particle on a straight line from the center out to some random radius.
  2. Rotate that line based on the radius — outer particles spin further around. This bends the straight arms into spirals.
  3. Add a tiny random offset on each axis. Without it, your arms are paper-thin. With it, they have body.

Everything else — color, size, animation — is icing.

Step 1 — A Points object

Points is just Mesh's cousin: same geometry input, different draw call. Where a mesh draws triangles, points draws one quad per vertex.

const geometry = new THREE.BufferGeometry();
const material = new THREE.PointsMaterial({
  size: 0.012,
  sizeAttenuation: true,    // particles get smaller with distance (perspective)
  depthWrite: false,        // additive blending depends on this being false
  blending: THREE.AdditiveBlending,
  vertexColors: true,       // we'll set per-particle color in the geometry
});
const points = new THREE.Points(geometry, material);
scene.add(points);

Four switches matter here. sizeAttenuation is what gives the perspective feel — without it, far particles are as big as near ones and the galaxy looks flat. AdditiveBlending + depthWrite: false is the classic combo for glowing particles: bright pixels stack on top of each other instead of overwriting. vertexColors opts in to per-particle color from the geometry attribute.

Pitfall. depthWrite: false without AdditiveBlending looks broken (particles render in arbitrary order). With AdditiveBlending, order doesn't matter because the math commutes — you can stack colors in any sequence and get the same result.

Step 2 — The spiral arm equation

For each particle, we choose:

  • A radius from 0 to maxRadius.
  • An arm index — which of the N arms this particle belongs to. i % arms distributes them evenly.
  • A base angle from the arm: (i % arms) / arms * 2π. Five arms means base angles 0, 72°, 144°, 216°, 288°.
  • A spin offset that grows with radius: radius * spin. This bends the arm.
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);

for (let i = 0; i < count; i++) {
  const radius = Math.random() * maxRadius;
  const armAngle = ((i % arms) / arms) * Math.PI * 2;
  const spinAngle = radius * spin;

  const angle = armAngle + spinAngle;

  positions[i * 3 + 0] = Math.cos(angle) * radius;
  positions[i * 3 + 1] = 0;                              // flat for now
  positions[i * 3 + 2] = Math.sin(angle) * radius;
}

That's a paper-thin spiral. Run it and you'll see clean, beautiful, totally unrealistic curves with no thickness. Now add the scatter:

const randomX = (Math.random() - 0.5) * scatter * radius;
const randomY = (Math.random() - 0.5) * scatter * radius * 0.4;  // squish Y
const randomZ = (Math.random() - 0.5) * scatter * radius;

positions[i * 3 + 0] = Math.cos(angle) * radius + randomX;
positions[i * 3 + 1] = randomY;
positions[i * 3 + 2] = Math.sin(angle) * radius + randomZ;

Two things to notice. First, scatter scales with radius: closer to center, arms are tight; further out, arms are puffy. That's how real galaxies look. Second, the Y scatter is multiplied by 0.4 so the galaxy stays roughly disc-shaped instead of ball-shaped. Pure equal scatter on all three axes gives you a fuzzy globe, not a galaxy.

Pitfall. Math.pow(Math.random(), 3) is the upgrade. Plain Math.random() distributes particles evenly across the radius. Real galaxies are denser toward the center. Cubing the random number biases it toward 0, which concentrates particles near the core. We'll use that in the final demo.

Step 3 — The color ramp

The cinematic touch: warm inner color (yellow, orange, deep red) blending to cool outer (purple, blue, sometimes white-blue). Color.lerp handles this directly.

const insideColor = new THREE.Color('#ff6030');
const outsideColor = new THREE.Color('#3a2cff');

for (let i = 0; i < count; i++) {
  // ... position math ...

  const mixed = insideColor.clone().lerp(outsideColor, radius / maxRadius);
  colors[i * 3 + 0] = mixed.r;
  colors[i * 3 + 1] = mixed.g;
  colors[i * 3 + 2] = mixed.b;
}

Don't lerp the constant colors directly — that mutates them. insideColor.clone() creates a fresh copy per particle so the originals stay intact for the next iteration. Three.js's Color API mutates by default; this trips up everyone the first time.

Step 4 — Upload to the GPU

Once both Float32Arrays are filled, attach them as buffer attributes. position and color are the names Three.js looks for by convention — the shader knows what to do with them automatically when vertexColors: true is set on the material.

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

The 3 is the itemSize — three floats per vertex (x, y, z) or (r, g, b). Get this wrong and you'll see a weird offset pattern, not a galaxy.

Step 5 — Regenerate, don't recreate

Hook the sliders up to call the generator function. But there's a trap: if you make a new BufferGeometry every change, you leak GPU memory — the old one is orphaned but its buffers don't free until garbage collection (which may never happen if you held a reference).

let galaxyPoints = null;

function generateGalaxy(params) {
  if (galaxyPoints) {
    galaxyPoints.geometry.dispose();
    galaxyPoints.material.dispose();
    scene.remove(galaxyPoints);
  }

  const geometry = new THREE.BufferGeometry();
  // ... fill positions, colors ...
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

  const material = new THREE.PointsMaterial({ ... });

  galaxyPoints = new THREE.Points(geometry, material);
  scene.add(galaxyPoints);
}

Always dispose the old geometry and material before replacing. After 50 slider changes without disposal you'll be holding ~1 GB of orphaned GPU memory.

Step 6 — The rotation

One line in the render loop:

renderer.setAnimationLoop((t) => {
  galaxyPoints.rotation.y = t * 0.0001;
  renderer.render(scene, camera);
});

That's it. Don't try to rotate individual particles in JS — that would be a per-frame iteration over 200k positions, dead at 60fps. Rotating the parent Points object is a single matrix update; the GPU handles the rest.

Step 7 — Make it cinematic: bloom, focus, and tone mapping

The galaxy above goes a few steps past the classic tutorial. Three additions turn the flat point cloud into something that looks shot on a camera — and the panel lets you toggle each one so you can see exactly what it buys you. Open Build it up and walk the stages from 1 to 8.

Bloom — the glow

Bright pixels bleed light into their neighbours. We render the scene through an EffectComposer and add an UnrealBloomPass: a RenderPass draws the galaxy, the bloom pass extracts the bright cores and blurs them outward, and an OutputPass writes the result. Strength, radius, and threshold are all live.

import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new UnrealBloomPass(resolution, /* strength */ 1.0, /* radius */ 0.6, /* threshold */ 0.0));
composer.addPass(new OutputPass());
// in the loop: composer.render() instead of renderer.render(scene, camera)

Depth of field — the DSLR focus

A real lens keeps one distance sharp and melts everything else into soft bokeh. The usual bokeh passes read the depth buffer — but our stars use additive blending with depthWrite: false, so there's no depth to sample. Instead we upgrade PointsMaterial to a small ShaderMaterial and let each star compute its own blur. That's exactly the ShaderMaterial graduation Exercise 2 hints at:

// vertex — circle of confusion grows with distance from the focal plane
float depth = -mvPosition.z;
float coc = min(abs(depth - uFocus) * uAperture, uMaxBlur);
gl_PointSize = uSize * (uScale / depth) * (1.0 + coc * 6.0);   // out-of-focus stars grow

// fragment — round sprite, and dim it as it blurs (energy spreads out)
float a = smoothstep(0.5, mix(0.5, 0.15, coc), d) / (1.0 + coc * 5.0);

The focus slider just moves uFocus — drag it and the core snaps sharp while the arms melt, or the reverse. No geometry rebuild; it's a single uniform per frame.

Tone mapping — the film curve

Finally, renderer.toneMapping = THREE.ACESFilmicToneMapping rolls the bloomed cores off gracefully instead of clipping to flat white, and toneMappingExposure becomes your cinematic exposure dial. It's most of the gap between "WebGL render" and "photo."

Common first-time pitfalls

"Particles are tiny dots, not glowing orbs." Material size is in world units when sizeAttenuation: true. A galaxy 5m across needs size around 0.012 — much smaller than you'd guess. Crank it up to 0.05 and you'll see particles overlap into a glow.
"Arms are all one color." You forgot vertexColors: true on the material, or insideColor and outsideColor are the same. The color attribute exists but the shader ignores it without the material opt-in.
"Particles render in the wrong order — closer ones look behind farther ones." depthWrite: false disables depth-buffer writes, which is correct for additive blending but means you can't sort by depth. The trick is to commit fully — both additive blending and no depth write. Half-measures look broken.
"Galaxy is a sphere, not a disc." You're scattering equally on Y as on X/Z. Squish Y by 0.3–0.4 to flatten the disc.

Exercises

  1. Add a third color stop. A middle color (white-yellow) between inside and outside makes the gradient way more interesting. Use radius / maxRadius to pick which pair to lerp between: 0–0.5 lerps inside→middle, 0.5–1 lerps middle→outside.
  2. Make particles twinkle. Add a per-particle seed attribute (random 0–1) and modulate per-particle size in a custom shader: gl_PointSize *= 0.5 + 0.5 * sin(uTime + seed * 6.28). You'll touch onBeforeCompile or graduate to a ShaderMaterial — covered in S1-08.
  3. Two galaxies, one scene. Generate two galaxies with different params, place one off-center, give them different rotation speeds. Watch the parallax. (Bonus: lerp their colors to a shared palette.)