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.
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.
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:
- Place each particle on a straight line from the center out to some random radius.
- Rotate that line based on the radius — outer particles spin further around. This bends the straight arms into spirals.
- 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.
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 % armsdistributes 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.
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
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.
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.
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.
Exercises
-
Add a third color stop. A middle color (white-yellow) between inside and outside makes
the gradient way more interesting. Use
radius / maxRadiusto pick which pair to lerp between: 0–0.5 lerps inside→middle, 0.5–1 lerps middle→outside. -
Make particles twinkle. Add a per-particle
seedattribute (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 touchonBeforeCompileor graduate to aShaderMaterial— covered in S1-08. - 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.)
UP NEXT
S0-03 — Scroll-Driven Portfolio Scene → A landing page where the camera flies through 3D sections as the user scrolls. The portfolio template every beginner asks for.