Animated Spiral Rotation - xelsed.ai

This sketch generates a 3D spiral galaxy made of 20,000 individually colored stars arranged along logarithmic spiral arms, rendered in WebGL and slowly rotating around a glowing core. Purple-to-blue color gradients and a layered transparent glow give it a realistic cosmic depth.

🧪 Try This!

Experiment with the code by making these changes:

  1. Speed up the rotation — Increasing the rotation increment makes the galaxy spin noticeably faster every frame.
  2. Add more spiral arms — Raising armCount splits the galaxy into more distinct spiral arms instead of just two.
  3. Loosen the spiral shape — Lowering armDensity unwinds the tight spiral into looser, more open arms.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch builds a rotating 3D galaxy out of 20,000 tiny points, each placed using polar coordinates converted to Cartesian x/y positions to form two logarithmic spiral arms plus a dense central bulge. It uses p5.js's WEBGL renderer, beginShape(POINTS) for efficient point rendering, lerpColor() for smooth purple-to-blue gradients, and layered transparent sphere() calls to fake a soft glowing core. The whole scene continuously rotates around the Z-axis using rotateZ() and an ever-incrementing angle variable, creating a mesmerizing sense of cosmic motion.

The code is organized into three functions: setup() prepares the canvas and colors and calls generateStars() once to precompute every star's position and color, generateStars() does the heavy math of placing stars along spiral arms or in the core, and draw() runs every frame to rotate the camera, render the glow, and plot every star as a point. Studying this sketch teaches you how to convert polar coordinates to Cartesian space, how logarithmic spirals are built with simple trigonometry, and how WEBGL mode's push/pop-free transform stack (translate, rotateX, rotateZ) positions an entire 3D scene.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-window WEBGL canvas, defines three colors (purple, blue, and a warm glow color), and calls generateStars() once to build the entire star field ahead of time.
  2. generateStars() loops 20,000 times, and for each star flips a weighted coin: 30% of the time it places a bright star randomly inside the small core radius, otherwise it computes a spiral arm position using the formula theta = (r - coreRadius) * armDensity + randomOffset + armIndex offset, which makes the angle grow with radius to form a spiral.
  3. Each star's polar coordinates (r, theta) are converted to x = r*cos(theta) and y = r*sin(theta), given a small random z depth, assigned a gradient color based on distance from the core, and stored in the global stars array as a plain object.
  4. Every frame, draw() clears the background to black, pushes the camera back with translate(), tilts the whole scene flat with rotateX(PI/2), then spins it further around the Z-axis using the ever-growing currentRotation variable.
  5. draw() then renders 15 layered, increasingly transparent spheres from largest to smallest to fake a glowing core, and finally loops through all 20,000 precomputed stars inside a single beginShape(POINTS)/endShape() block, setting each star's fill color and adding it as a vertex - a fast way to draw huge numbers of points in WebGL.
  6. Because currentRotation increases by a tiny amount every frame, the entire galaxy appears to slowly spin in place, while the precomputed star positions never change - only the rotation transform does.

🎓 Concepts You'll Learn

WEBGL 3D renderingPolar to Cartesian coordinate conversionLogarithmic spiralsbeginShape(POINTS) point cloudslerpColor gradients3D transforms (translate/rotateX/rotateZ)Alpha transparency layering

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. Here it's used to configure the 3D rendering mode and precompute expensive data (the star field) so draw() only has to render, not recalculate, every frame.

function setup() {
  // Create a WebGL canvas that fills the window
  createCanvas(windowWidth, windowHeight, WEBGL);
  // Disable drawing outlines for stars and glow
  noStroke();
  // Enable anti-aliasing for smoother edges (optional, but recommended)
  smooth();

  // Define the purple and blue colors for the gradient
  purpleColor = color(80, 0, 120);
  blueColor = color(0, 50, 150);
  // Define the warm white/yellow color for the glowing center
  glowCenterColor = color(255, 240, 220);

  // Generate all the star positions and colors
  generateStars();
}
Line-by-line explanation (7 lines)
createCanvas(windowWidth, windowHeight, WEBGL);
Creates a canvas that fills the browser window and enables the WEBGL renderer, which is required for 3D drawing like sphere() and vertex() with a z coordinate.
noStroke();
Turns off outlines so points and spheres are drawn as solid filled shapes without borders.
smooth();
Enables anti-aliasing so edges of 3D shapes look less jagged.
purpleColor = color(80, 0, 120);
Defines the deep purple color used near the galaxy's core in the color gradient.
blueColor = color(0, 50, 150);
Defines the blue color used towards the outer edge of the spiral arms.
glowCenterColor = color(255, 240, 220);
Defines a warm white/yellow color used for both the core stars and the glowing sphere layers.
generateStars();
Calls the function that computes and stores all 20,000 star positions and colors just once, since the galaxy's shape doesn't need to be recalculated every frame.

generateStars()

generateStars() is where all the interesting math lives - it's a great example of how a simple formula (radius times a density constant, added to an angle offset) can produce a naturalistic spiral shape purely through trigonometry and controlled randomness.

🔬 This single line creates the entire spiral shape. What happens if you remove the random(-armOffset, armOffset) term entirely - do the arms become perfectly thin lines?

      theta = (r - coreRadius) * armDensity + random(-armOffset, armOffset) + armIndex * TWO_PI / armCount;

🔬 This threshold decides what fraction of stars land in the dense core versus the spiral arms. What happens visually if you change 0.3 to 0.05 (a much smaller, sparser core) or 0.7 (a huge bright blob)?

    if (random() < 0.3) {
function generateStars() {
  for (let i = 0; i < numStars; i++) {
    let r, theta, z;
    let starColor;

    // Randomly decide if the star is in the core or arms (30% chance for core stars)
    if (random() < 0.3) {
      // Core Star: Random position within the core radius
      r = random(coreRadius);
      theta = random(TWO_PI);
      // Core stars have less z-variation, creating a thicker bulge
      z = random(-15, 15);
      // Core stars are brighter, matching the glow center
      starColor = glowCenterColor;
    } else {
      // Arm Star:
      // Choose which arm the star belongs to
      let armIndex = floor(random(armCount));
      // Radius ranges from the core edge to the galaxy's outer edge
      r = random(coreRadius, galaxyRadius);

      // Calculate theta for a logarithmic spiral pattern
      // The theta increases with radius, creating the spiral shape
      // armDensity controls the tightness of the spiral
      // random(-armOffset, armOffset) adds thickness to the arms
      // armIndex * TWO_PI / armCount offsets each arm evenly
      theta = (r - coreRadius) * armDensity + random(-armOffset, armOffset) + armIndex * TWO_PI / armCount;

      // Arm stars have more z-variation further from the center, creating a thinner disc
      z = random(-r * 0.08, r * 0.08);

      // Apply color gradient: purple near the core, blue towards the edge
      // map(r, coreRadius, galaxyRadius, 0, 1, true) ensures values stay between 0 and 1
      starColor = lerpColor(purpleColor, blueColor, map(r, coreRadius, galaxyRadius, 0, 1, true));
    }

    // Convert polar coordinates (r, theta) to Cartesian coordinates (x, y)
    let x = r * cos(theta);
    let y = r * sin(theta);

    // Add the star data to the stars array
    stars.push({
      x: x,
      y: y,
      z: z,
      color: starColor,
      size: random(1, 3) // Random small star size (single pixel in WebGL)
    });
  }
}
Line-by-line explanation (14 lines)

🔧 Subcomponents:

for-loop Star Generation Loop for (let i = 0; i < numStars; i++) {

Runs once for every star to compute its position and color, filling the stars array.

conditional Core or Arm Decision if (random() < 0.3) {

Randomly assigns 30% of stars to the dense core and 70% to the spiral arms.

calculation Spiral Angle Calculation theta = (r - coreRadius) * armDensity + random(-armOffset, armOffset) + armIndex * TWO_PI / armCount;

Computes the angle for an arm star so that angle grows with radius, producing a logarithmic spiral shape, with added randomness for a natural look.

calculation Polar to Cartesian Conversion let x = r * cos(theta); let y = r * sin(theta);

Converts the polar coordinates (radius and angle) into x/y positions that p5.js can actually plot.

for (let i = 0; i < numStars; i++) {
Repeats the star-creation process numStars (20,000) times, once per star.
if (random() < 0.3) {
Generates a random number between 0 and 1; if it's below 0.3, there's a 30% chance this star becomes a bright core star instead of an arm star.
r = random(coreRadius);
Picks a random distance from the center that's somewhere between 0 and the core radius, keeping core stars clustered tightly in the middle.
theta = random(TWO_PI);
Picks a completely random angle around the full circle (0 to 2π) since core stars aren't part of the spiral pattern.
z = random(-15, 15);
Gives the star a small random depth so the core looks like a slightly puffy 3D bulge rather than a flat disc.
starColor = glowCenterColor;
Colors core stars the same warm white/yellow as the glow effect, making the center appear bright and dense.
let armIndex = floor(random(armCount));
Randomly picks which spiral arm (0 or 1, since armCount is 2) this star belongs to.
r = random(coreRadius, galaxyRadius);
Picks a random radius somewhere between the edge of the core and the outer edge of the galaxy for arm stars.
theta = (r - coreRadius) * armDensity + random(-armOffset, armOffset) + armIndex * TWO_PI / armCount;
This is the key spiral formula: as radius (r) increases, the angle increases too, which winds the stars into a spiral shape. Random offset adds thickness, and the arm index term spaces multiple arms evenly around the circle.
z = random(-r * 0.08, r * 0.08);
Gives arm stars a depth range that grows with distance from center, making the outer disc thinner relative to its width than the bulging core.
starColor = lerpColor(purpleColor, blueColor, map(r, coreRadius, galaxyRadius, 0, 1, true));
Blends between purple and blue based on how far out the star is - map() converts the radius into a 0-to-1 fraction, and lerpColor() mixes the two colors by that fraction.
let x = r * cos(theta);
Standard trigonometry formula that converts polar coordinates into the x (horizontal) Cartesian coordinate.
let y = r * sin(theta);
Converts polar coordinates into the y (vertical) Cartesian coordinate, completing the conversion from (r, theta) to (x, y).
stars.push({ x: x, y: y, z: z, color: starColor, size: random(1, 3) });
Saves this star's computed data as an object inside the global stars array, so draw() can later render it without redoing any math.

draw()

draw() runs 60 times per second and is responsible for all the per-frame work: clearing the screen, positioning the 3D camera via transforms, and rendering both the layered glow effect and the entire precomputed star field using an efficient POINTS shape.

🔬 This loop builds the glow by stacking 15 transparent spheres. What happens if you change the max alpha from 80 to 255 - does the core become a solid opaque blob instead of a soft glow?

  for (let i = glowLayers; i >= 1; i--) {
    // Calculate radius and alpha for the current glow layer
    let r = map(i, 0, glowLayers, 0, glowRadius);
    let alpha = map(i, 0, glowLayers, 0, 80); // Alpha decreases towards the outside (more transparent)

    // Set fill color with transparency
    fill(glowCenterColor, alpha);
    // Draw a sphere for the glow layer
    sphere(r);
  }
function draw() {
  // Set the background to black
  background(0);

  // Position the camera back from the origin and lay the galaxy flat on the XY plane
  translate(0, 0, -300); // Move camera back
  rotateX(PI / 2);      // Rotate the galaxy to be horizontal (on the XY plane)

  // Rotate the entire galaxy around its Z-axis
  rotateZ(currentRotation);
  // Increment rotation speed (adjust value for faster/slower rotation)
  currentRotation += 0.002;

  // --- Draw the glowing center ---
  let glowRadius = coreRadius * 1.8; // Glow extends beyond the core
  let glowLayers = 15;                // Number of transparent spheres for the glow effect

  // Loop from largest (most transparent) to smallest (most opaque) spheres
  for (let i = glowLayers; i >= 1; i--) {
    // Calculate radius and alpha for the current glow layer
    let r = map(i, 0, glowLayers, 0, glowRadius);
    let alpha = map(i, 0, glowLayers, 0, 80); // Alpha decreases towards the outside (more transparent)

    // Set fill color with transparency
    fill(glowCenterColor, alpha);
    // Draw a sphere for the glow layer
    sphere(r);
  }

  // --- Draw the stars ---
  // beginShape(POINTS) is an efficient way to draw many single-pixel points in WebGL
  beginShape(POINTS);
  for (let i = 0; i < stars.length; i++) {
    let star = stars[i];
    // Set the fill color for the current star
    fill(star.color);
    // Add the star's 3D coordinates as a vertex
    vertex(star.x, star.y, star.z);
  }
  endShape(); // End the POINTS shape
}
Line-by-line explanation (16 lines)

🔧 Subcomponents:

for-loop Glow Sphere Layers for (let i = glowLayers; i >= 1; i--) {

Draws 15 increasingly transparent spheres from largest to smallest to fake a soft glow around the core.

for-loop Star Point Rendering for (let i = 0; i < stars.length; i++) {

Loops through all precomputed stars and draws each as a colored 3D point vertex.

background(0);
Fills the entire canvas with black every frame, erasing the previous frame's drawing so the galaxy doesn't smear.
translate(0, 0, -300); // Move camera back
Shifts everything drawn afterward 300 units back in 3D space, effectively pulling the 'camera' back so the whole galaxy fits in view.
rotateX(PI / 2); // Rotate the galaxy to be horizontal (on the XY plane)
Tilts the entire scene 90 degrees so the galaxy, which was built flat on the XY plane, appears to lie flat rather than standing upright facing the viewer.
rotateZ(currentRotation);
Spins the whole galaxy around its Z-axis by the current rotation angle, which is what makes it appear to rotate over time.
currentRotation += 0.002;
Increases the rotation angle slightly every frame - since draw() runs 60 times per second, this produces smooth continuous spinning.
let glowRadius = coreRadius * 1.8; // Glow extends beyond the core
Calculates how far the glow effect should extend, sized relative to the core radius.
for (let i = glowLayers; i >= 1; i--) {
Loops backward from 15 down to 1, drawing the largest, most transparent sphere first and the smallest, most opaque sphere last so they layer correctly.
let r = map(i, 0, glowLayers, 0, glowRadius);
Converts the loop counter into an actual sphere radius, so higher i values produce bigger spheres.
let alpha = map(i, 0, glowLayers, 0, 80); // Alpha decreases towards the outside (more transparent)
Converts the loop counter into a transparency value, making outer layers more see-through and inner layers more solid, which creates a soft glow gradient.
fill(glowCenterColor, alpha);
Sets the fill color to the warm glow color with the calculated transparency for this layer.
sphere(r);
Draws a 3D sphere of radius r centered at the origin, one of 15 overlapping spheres that together create a soft glow effect.
beginShape(POINTS);
Starts a custom shape made of individual points, which is much faster than drawing 20,000 separate circle() or point() calls.
for (let i = 0; i < stars.length; i++) {
Loops through every star stored in the stars array from generateStars().
fill(star.color);
Sets the color for this specific star before adding its vertex - each point can have a different color in a POINTS shape.
vertex(star.x, star.y, star.z);
Adds this star's 3D position as a point vertex to the shape being built.
endShape(); // End the POINTS shape
Finishes the POINTS shape, telling p5.js to actually render all the vertices that were added.

windowResized()

windowResized() is a special p5.js function that p5 calls automatically on browser resize events, letting you keep responsive full-window sketches without manually detecting resize events yourself.

function windowResized() {
  // Resize the canvas when the window is resized
  resizeCanvas(windowWidth, windowHeight);
}
Line-by-line explanation (1 lines)
resizeCanvas(windowWidth, windowHeight);
A built-in p5.js callback that automatically fires whenever the browser window changes size; this resizes the canvas to match the new window dimensions so the galaxy always fills the screen.

📦 Key Variables

stars array

Stores an object for every star (position, depth, color, size) so they only need to be computed once in setup rather than every frame.

let stars = [];
numStars number

The total number of stars to generate, controlling the visual density of the galaxy.

let numStars = 20000;
galaxyRadius number

The maximum distance stars can be placed from the center, defining the overall size of the galaxy.

let galaxyRadius = 450;
coreRadius number

The radius of the dense central bulge where 30% of stars cluster.

let coreRadius = 80;
armDensity number

A multiplier controlling how quickly the spiral angle increases with radius, determining how tightly the arms wind.

let armDensity = 0.007;
armCount number

The number of distinct spiral arms in the galaxy.

let armCount = 2;
armOffset number

The amount of random angular variation added to arm stars, controlling arm thickness and organic randomness.

let armOffset = 0.6;
purpleColor object

A p5.Color used as the gradient's start color, applied near the galaxy's core.

let purpleColor;
blueColor object

A p5.Color used as the gradient's end color, applied towards the outer edge of the arms.

let blueColor;
glowCenterColor object

A warm white/yellow p5.Color used for both core stars and the layered glow spheres.

let glowCenterColor;
currentRotation number

Tracks the galaxy's current rotation angle in radians, incremented every frame to animate the spin.

let currentRotation = 0;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

PERFORMANCE generateStars()

Every star's color is computed with lerpColor(), which allocates a new p5.Color object 20,000 times during setup - this is a one-time cost but could be slow on low-end devices when combined with a large numStars.

💡 Precompute a small palette of colors (e.g. 100 gradient steps) once, then look up the nearest one by radius instead of calling lerpColor() per star.

BUG draw() glow spheres

Drawing 15 overlapping sphere() calls every frame is relatively expensive in WEBGL and the glow is recalculated identically every frame even though it never changes shape.

💡 Render the glow once to an offscreen createGraphics() buffer or texture and reuse it, since only the outer star rotation actually needs to update each frame.

STYLE star object properties

Each star object stores a size property that is generated with random(1, 3) but is never actually used anywhere in draw() - vertex() point size in WEBGL POINTS mode is controlled by strokeWeight(), not by this size field.

💡 Either remove the unused size property to save memory across 20,000 objects, or apply it by calling strokeWeight(star.size) before each vertex if per-star point sizes are desired.

FEATURE draw()

The galaxy only rotates passively with no user interaction - mouse or touch input isn't used at all despite being a natural fit for a 3D scene.

💡 Use mouseX/mouseY (mapped to rotateX/rotateY) or a simple orbitControl() call to let viewers tilt and explore the galaxy interactively.

🔄 Code Flow

Code flow showing setup, generatestars, draw, windowresized

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> generatestars[generatestars] generatestars --> star-loop[Star Generation Loop] star-loop --> core-vs-arm[Core or Arm Decision] core-vs-arm --> spiral-formula[Spiral Angle Calculation] spiral-formula --> polar-to-cartesian[Polar to Cartesian Conversion] polar-to-cartesian --> generatestars setup --> draw[draw loop] draw --> glow-loop[Glow Sphere Layers] glow-loop --> draw draw --> star-render-loop[Star Point Rendering] star-render-loop --> draw draw --> windowresized[windowresized] click setup href "#fn-setup" click generatestars href "#fn-generatestars" click draw href "#fn-draw" click windowresized href "#fn-windowresized" click star-loop href "#sub-star-loop" click core-vs-arm href "#sub-core-vs-arm" click spiral-formula href "#sub-spiral-formula" click polar-to-cartesian href "#sub-polar-to-cartesian" click glow-loop href "#sub-glow-loop" click star-render-loop href "#sub-star-render-loop"

❓ Frequently Asked Questions

What visual effects does the Animated Spiral Rotation - XeLseDai sketch create?

The sketch displays a mesmerizing spiral galaxy filled with rotating stars and vibrant color gradients, simulating a dynamic and immersive cosmic scene.

Is there any user interaction feature in the Animated Spiral Rotation - XeLseDai sketch?

The sketch is primarily a visual experience without interactive elements, focusing on the captivating animation of stars and colors.

What creative coding techniques are showcased in the Animated Spiral Rotation - XeLseDai sketch?

This sketch demonstrates concepts such as procedural generation of star positions and colors, along with smooth animations in a 3D WebGL environment.

Preview

Animated Spiral Rotation - xelsed.ai - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Animated Spiral Rotation - xelsed.ai - Code flow showing setup, generatestars, draw, windowresized
Code Flow Diagram