Navigate

Search topics across all sections

GitHub
Shader Recipes

Galaxy Generator

A cosmic paint splatter made from math. Each particle is positioned using polar coordinates -- an angle and a radius -- with randomness creating the organic spiral arm look. The result: thousands of colored dots that form a procedural spiral galaxy, generated entirely from code.

You generate thousands of particles but they appear as a uniform circular blob. There are no visible spiral arms -- just a dense, round cluster of particles in the center.

terminal
Galaxy has no spiral structure. Particles form a round disk with no arms visible.

Real-world

Imagine you are a cosmic painter with a lazy susan (a spinning turntable). You put a canvas on it and start flicking paint from the center outward. As the turntable spins, each droplet curves into a spiral arc instead of flying straight.

Droplets near the center barely curve because they have not traveled far. But droplets near the edge curve dramatically because the turntable has rotated significantly by the time they reach the rim. This is the spin parameter -- how much extra rotation each particle gets based on its distance from the center.

Now you do not flick paint in every direction equally. You aim at 3 or 4 specific angles (the arms). And your flick is not perfect -- each droplet scatters slightly off-course (the randomness). Together: arm direction + spin + scatter = a spiral galaxy.

How the Galaxy is Built

From random numbers to a spiral galaxy in four stages.

Polar Coords

Random radius + arm angle

Spin

Outer particles rotate more

Scatter

Random offset from arm line

Color

Inner=warm, outer=cool

Building the Galaxy Step by Step

We position every particle with math, then let the GPU render them all in a single draw call.

Step 1 -- Place particles on spiral arms

Galaxy.tsxTSX
for (let i = 0; i < count; i++) {
  const radius = Math.random() * 5;

  // Which arm (evenly spaced around the circle)
  const armAngle = ((i % arms) / arms) * Math.PI * 2;

  // Spin: more rotation for outer particles
  const spinAngle = radius * spin;

  const angle = armAngle + spinAngle;

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

Each particle gets a random radius (distance from center) and is assigned to one of the spiral arms using modulo. The spin parameter adds extra rotation proportional to the radius -- this is what curves straight lines into spirals.

Step 2 -- Add organic scatter

Galaxy.tsxTSX
// Cubic distribution: concentrated near center
const rx = Math.pow(Math.random(), 3)
  * (Math.random() < 0.5 ? 1 : -1)
  * randomness * radius * 0.3;

positions[i3]     += rx;
positions[i3 + 1] += ry; // slight vertical spread
positions[i3 + 2] += rz;

The key trick: pow(random, 3) creates a cubic distribution. Most values cluster near 0 (close to the arm), with occasional outliers. The random sign (positive or negative) scatters in both directions. Multiplying by radius makes outer regions wider, matching real galaxy arm behavior.

Step 3 -- Color from center to edge

Galaxy.tsxTSX
const colorInner = new THREE.Color(innerColor);
const colorOuter = new THREE.Color(outerColor);
const temp = new THREE.Color();

// Per-particle color based on distance
const radiusNorm = radius / maxRadius;
temp.copy(colorInner).lerp(colorOuter, radiusNorm);

colors[i3]     = temp.r;
colors[i3 + 1] = temp.g;
colors[i3 + 2] = temp.b;

Real galaxies have hot, bright cores and cooler outer regions. We mimic this by lerping between an inner color (warm orange) and an outer color (cool blue) based on each particle's normalized radius. Each particle gets its own color stored as a buffer attribute.

Step 4 -- Custom shader for circular points

galaxy-shaders.glslGLSL
// Vertex: size attenuation
gl_PointSize = uSize * aScale * (200.0 / -mvPos.z);

// Fragment: circular shape with soft edges
float dist = length(gl_PointCoord - vec2(0.5));
if (dist > 0.5) discard;
float alpha = 1.0 - smoothstep(0.0, 0.5, dist);
gl_FragColor = vec4(vColor, alpha);

Without a custom fragment shader, each point is a visible square. gl_PointCoord gives the UV of each pixel within the point sprite. We compute the distance from center, discard pixels outside radius 0.5, and use smoothstep for soft edges. The result: each particle is a soft glowing circle.

What you just learned

Spiral arms are created by giving particles an angle based on their arm index, then adding radius-proportional spin.

pow(random, 3) creates a cubic distribution that concentrates scatter near the arm centerline with natural-looking outliers.

Per-particle attributes (color, scale) are passed as bufferAttributes and read as 'attribute' variables in the vertex shader.

gl_PointCoord in the fragment shader gives the UV within each point sprite, enabling circular masking with discard.

A single Points draw call can render 10,000+ particles -- far more efficient than individual meshes.

Question

The galaxy uses a flat random distribution for radius: Math.random() * 5. This means particles are spread evenly from center to edge. But real galaxies have many more stars near the center. What would you change in the radius calculation to create a denser core?

Think about it...

You set spin to 0 and armCount to 4. What shape do you see?

Hint: Spin controls how much extra rotation particles get based on their distance from center. With spin=0, what happens to the spiral?

Try These Challenges

Put what you learned into practice. Try each challenge in the demo above using the Leva controls, then check the solution.

Try This!

Beginner

Set armCount to 2 — simple spiral

Try This!

Beginner

Set armCount to 8 — complex galaxy

Try This!

Beginner

Max spin — tight spiral

These are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.

1
All particles on a single spiral line
Missing randomness offset from arm centerline
Don't do this
Galaxy.tsxTSX
// Every particle sits exactly on the arm curve
const angle = armAngle + radius * spin;
positions[i3]     = Math.cos(angle) * radius;
positions[i3 + 1] = 0;
positions[i3 + 2] = Math.sin(angle) * radius;
// Result: thin lines, not a galaxy
Without random scatter, particles sit exactly on the mathematical spiral curve. Real galaxies have stars spread around the spiral arms, not on thin lines. Using pow(random, 3) concentrates most particles near the arm center with occasional outliers, which looks more natural than uniform randomness.
2
Particles are square instead of circular
No distance-based alpha or discard in fragment shader
Don't do this
fragment.glslGLSL
// Fragment shader just sets a flat color
void main() {
  gl_FragColor = vec4(vColor, 1.0);
  // Result: visible square particles
}
GPU point sprites are square quads. To make them circular, calculate the distance from the center of each point using gl_PointCoord (which goes from 0,0 at the top-left to 1,1 at the bottom-right). Discard pixels outside radius 0.5 and use smoothstep for soft edges.
3
All particles the same color
Using uniform color instead of per-particle attribute
Don't do this
galaxy-shaders.glslGLSL
// Using a uniform -- same color for all
uniform vec3 uColor;
void main() {
  gl_FragColor = vec4(uColor, 1.0);
}
A uniform is the same for all vertices. To give each particle its own color (e.g., orange at center, blue at edges), you need a per-vertex attribute. Generate a color array on the CPU, attach it as a bufferAttribute, and pass it through a varying to the fragment shader.

Best Practices

Use additive blending

Galaxies are made of light. Additive blending makes overlapping particles brighter (like real stars clustered together). Dense regions glow naturally without any extra code.

Concentrate scatter at center

Multiply scatter by radius so outer particles spread more than inner ones. This matches real galaxy morphology where the core is dense and compact while arms fan out at the edges.

Vary particle sizes

Give each particle a random scale attribute. A mix of sizes creates visual variety -- a few large bright stars among many small dim ones looks far more convincing than uniform dots.

Slow rotation animation

A very slow rotation (0.05 radians per second) makes the galaxy feel alive without being distracting. Too fast and it breaks the illusion of cosmic scale.