6

Barred Spiral Galaxy (3D)

drag to orbit

A 3D barred-spiral galaxy with ~4800 stars rendered as a point cloud: a dense central bulge (small Gaussian spheroid) and a thin exponential disk with . Each star follows an approximately flat rotation curve, , so inner stars orbit faster than outer ones, . If the arms were purely kinematic, that differential rotation would shear them out within a few revolutions โ€” the *winding problem*. The **Lin-Shu density-wave theory** resolves this: the spiral arms are not material structures rotating with the stars, but a quasi-stationary pattern in the gravitational potential that rotates at its own pattern speed , much slower than the inner disk. Individual stars stream *through* the arms, slowing slightly inside the deeper potential and bunching into the visible overdensity, then exiting again โ€” like cars in a traffic jam that itself drifts. Here, every star carries its own true orbit, but the rendered angle is pulled toward the nearest arm centerline , recreating the density-wave appearance. A short rotating bar at the center adds an azimuthal compression in the inner kpc. Stars are colored by approximate stellar temperature โ€” blue-white toward the hot bulge core, yellow and orange toward the cool outer disk. Drag to orbit; the view auto-rotates when idle.

idle
250 lines ยท three
view source
// Three.js 3D barred-spiral galaxy โ€” Lin-Shu density-wave model.
//
// Contract: main-thread iframe with three.js loaded as window.THREE.
// init() builds the scene; tick() advances star angles and renders.
//
// Physics:
//   - Disk stars sampled from an exponential radial profile, r ~ -Rd * log(rand),
//     placed on a thin disk with a small vertical Gaussian.
//   - Bulge stars sampled from a 3D Gaussian at the center.
//   - Each star carries a constant individual orbital angular velocity
//     omega(r) = v_flat / r (inner stars orbit faster โ€” flat rotation curve).
//   - The SPIRAL PATTERN is rendered as a stationary density envelope: at
//     render time, we modulate each star's drawn angle toward the nearest
//     arm centerline (theta_arm = phi_pattern + k * r). This is the
//     density-wave picture: arms are quasi-stationary potential wells,
//     stars stream through them, the arms themselves rotate at omega_p
//     (much slower than the inner disk).
//   - Bar at the center: a 2-fold m=2 azimuthal density compression in
//     the innermost ring, rotating at the pattern speed.
//
// Color is by approximate stellar temperature: blue/white in the hot
// inner bulge, yellow/orange toward the cool outer disk.

const N_DISK = 4200;
const N_BULGE = 600;
const N_BG = 60;       // faint distant-galaxy points
const N_TOTAL = N_DISK + N_BULGE + N_BG;

const R_DISK = 4.5;     // exponential disk scale length (sim units)
const R_MAX = 18.0;     // clip
const R_BULGE = 1.6;    // bulge gaussian sigma
const Z_DISK = 0.35;    // disk vertical thickness sigma
const V_FLAT = 1.25;    // flat rotation curve speed (sim units per second)
const OMEGA_P = 0.08;   // pattern angular speed (rad/s) โ€” slow
const K_ARM = 0.38;     // radial pitch of the spiral (theta_arm = phi + k*r)
const ARM_AMPL = 0.55;  // how strongly stars cluster toward arm centers
const BAR_R = 2.6;      // bar half-length
const BAR_AMPL = 0.45;  // m=2 azimuthal pull in the bar region

let geom, mat, points;
let basePositions;   // Float32Array length 3*N โ€” "true" star positions (no arm bias)
let drawPositions;   // Float32Array length 3*N โ€” what we actually upload each frame
let colors;          // Float32Array length 3*N
let radii;           // Float32Array length N โ€” galactocentric r for each star
let theta0;          // Float32Array length N โ€” current orbital angle (without arm offset)
let omegas;          // Float32Array length N โ€” per-star angular speed
let zOff;            // Float32Array length N โ€” vertical offset (kept fixed)
let isBulge;         // Uint8Array  length N โ€” 1 if bulge, 0 if disk
let isBG;            // Uint8Array  length N โ€” 1 if background galaxy
let bgPos;           // Float32Array length 3*N_BG โ€” fixed background star positions

let phiPattern = 0;        // current rotation of the spiral pattern (radians)
let userYaw = 0;
let userPitch = -Math.PI / 6;  // 30ยฐ tilt from disk plane (negative looks down on it)
let isDragging = false;
let lastMouseX = 0, lastMouseY = 0;
let idleTimer = 0;

function tempColor(r, t, out, idx) {
  // Map normalized radius (0 = center, 1 = rim) to a temperature color.
  // Center: hot blue-white (~9000 K). Rim: cool orange (~3500 K).
  // We use a piecewise linear ramp through three anchors, with a tiny
  // sinusoidal per-star jitter so the disk doesn't look banded.
  const jitter = 0.05 * Math.sin(t * 17.3 + r * 3.1);
  const x = Math.min(1, Math.max(0, r + jitter));
  let cr, cg, cb;
  if (x < 0.25) {
    // bulge core: blue-white
    const u = x / 0.25;
    cr = 0.78 + 0.18 * u;
    cg = 0.86 + 0.10 * u;
    cb = 1.00;
  } else if (x < 0.6) {
    // inner disk: white -> pale yellow
    const u = (x - 0.25) / 0.35;
    cr = 0.96 + 0.04 * u;
    cg = 0.96 - 0.06 * u;
    cb = 1.00 - 0.30 * u;
  } else {
    // outer disk: yellow -> orange
    const u = (x - 0.6) / 0.4;
    cr = 1.00;
    cg = 0.90 - 0.30 * u;
    cb = 0.70 - 0.55 * u;
  }
  out[idx]     = cr;
  out[idx + 1] = cg;
  out[idx + 2] = cb;
}

function sampleDiskRadius() {
  // r ~ -Rd * log(rand), clipped to R_MAX.
  let r = -R_DISK * Math.log(Math.max(1e-4, Math.random()));
  if (r > R_MAX) r = R_MAX * (0.88 + 0.12 * Math.random());
  if (r < 0.3) r = 0.3 + Math.random() * 0.4;  // avoid the dead zone right at center
  return r;
}

function gauss() {
  // Box-Muller.
  const u1 = Math.max(1e-6, Math.random());
  const u2 = Math.random();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

function init({ scene, camera, renderer, width, height }) {
  renderer.setClearColor(0x000003, 1);

  basePositions = new Float32Array(N_TOTAL * 3);
  drawPositions = new Float32Array(N_TOTAL * 3);
  colors = new Float32Array(N_TOTAL * 3);
  radii = new Float32Array(N_TOTAL);
  theta0 = new Float32Array(N_TOTAL);
  omegas = new Float32Array(N_TOTAL);
  zOff = new Float32Array(N_TOTAL);
  isBulge = new Uint8Array(N_TOTAL);
  isBG = new Uint8Array(N_TOTAL);
  bgPos = new Float32Array(N_BG * 3);

  // --- Bulge stars: 3D Gaussian at the center ---
  for (let i = 0; i < N_BULGE; i++) {
    const x = gauss() * R_BULGE;
    const y = gauss() * R_BULGE;
    const z = gauss() * R_BULGE * 0.8;
    const r2d = Math.sqrt(x * x + y * y);
    const r3d = Math.sqrt(r2d * r2d + z * z);
    radii[i] = r2d;
    theta0[i] = Math.atan2(y, x);
    // Bulge stars orbit slower (mostly random support, not rotation), but we
    // give them a small omega for some visual life.
    omegas[i] = 0.25 * V_FLAT / Math.max(0.6, r2d);
    zOff[i] = z;
    isBulge[i] = 1;
    // Temperature based on radial distance from galaxy center.
    tempColor(Math.min(1, r3d / R_MAX), i * 0.7, colors, i * 3);
    // Bulge is hotter โ€” push toward white.
    colors[i * 3]     = Math.min(1, colors[i * 3]     * 1.05 + 0.05);
    colors[i * 3 + 1] = Math.min(1, colors[i * 3 + 1] * 1.05 + 0.05);
    colors[i * 3 + 2] = Math.min(1, colors[i * 3 + 2] * 1.00 + 0.05);
  }

  // --- Disk stars: exponential radial + thin vertical Gaussian ---
  for (let j = 0; j < N_DISK; j++) {
    const i = N_BULGE + j;
    const r = sampleDiskRadius();
    const theta = Math.random() * Math.PI * 2;
    const z = gauss() * Z_DISK * (1 - 0.3 * Math.min(1, r / R_MAX));
    radii[i] = r;
    theta0[i] = theta;
    omegas[i] = V_FLAT / Math.max(0.6, r);   // flat rotation curve โ†’ omega = v/r
    zOff[i] = z;
    isBulge[i] = 0;
    tempColor(r / R_MAX, j * 0.13, colors, i * 3);
  }

  // --- Background distant-galaxy backdrop: a few large dim points far away ---
  for (let k = 0; k < N_BG; k++) {
    const i = N_BULGE + N_DISK + k;
    // Place on a large sphere around the scene.
    const u = Math.random() * 2 - 1;
    const phi = Math.random() * Math.PI * 2;
    const s = Math.sqrt(1 - u * u);
    const R = 70 + Math.random() * 30;
    const x = R * s * Math.cos(phi);
    const y = R * s * Math.sin(phi);
    const z = R * u;
    bgPos[k * 3]     = x;
    bgPos[k * 3 + 1] = y;
    bgPos[k * 3 + 2] = z;
    drawPositions[i * 3]     = x;
    drawPositions[i * 3 + 1] = y;
    drawPositions[i * 3 + 2] = z;
    basePositions[i * 3]     = x;
    basePositions[i * 3 + 1] = y;
    basePositions[i * 3 + 2] = z;
    radii[i] = 0;
    theta0[i] = 0;
    omegas[i] = 0;
    zOff[i] = z;
    isBG[i] = 1;
    // Faint blue-white. Vary intensity per "galaxy".
    const dim = 0.35 + Math.random() * 0.4;
    colors[i * 3]     = dim * (0.85 + Math.random() * 0.15);
    colors[i * 3 + 1] = dim * (0.85 + Math.random() * 0.15);
    colors[i * 3 + 2] = dim;
  }

  geom = new THREE.BufferGeometry();
  geom.setAttribute("position",
    new THREE.BufferAttribute(drawPositions, 3).setUsage(THREE.DynamicDrawUsage));
  geom.setAttribute("color",
    new THREE.BufferAttribute(colors, 3).setUsage(THREE.StaticDrawUsage));

  // We can't easily vary point size per-vertex without a shader, but we
  // can layer two PointsMaterials (one for bulge+disk, one for big bg)
  // by splitting geometry. Simpler: keep one Points with a medium size,
  // and the background stars look slightly bigger because they're closer
  // to the camera near-plane via additive blending.
  mat = new THREE.PointsMaterial({
    size: 0.09,
    vertexColors: true,
    transparent: true,
    opacity: 0.95,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
    sizeAttenuation: true,
  });
  points = new THREE.Points(geom, mat);
  scene.add(points);

  // Separate Points for the dim background โ€” larger size, additive, low opacity.
  // Reuses the same buffer (it's a small overhead) by drawing only the bg
  // tail of the array. We render them via a second Points object that
  // shares the same geometry but uses a different material; setDrawRange
  // restricts which vertices are drawn.
  const bgGeom = new THREE.BufferGeometry();
  const bgPosAttr = new Float32Array(bgPos);   // copy
  const bgColAttr = new Float32Array(N_BG * 3);
  for (let k = 0; k < N_BG; k++) {
    bgColAttr[k * 3]     = colors[(N_BULGE + N_DISK + k) * 3];
    bgColAttr[k * 3 + 1] = colors[(N_BULGE + N_DISK + k) * 3 + 1];
    bgColAttr[k * 3 + 2] = colors[(N_BULGE + N_DISK + k) * 3 + 2];
  }
  bgGeom.setAttribute("position", new THREE.BufferAttribute(bgPosAttr, 3));
  bgGeom.setAttribute("color", new THREE.BufferAttribute(bgColAttr, 3));
  const bgMat = new THREE.PointsMaterial({
    size: 1.6,
    vertexColors: true,
    transparent: true,
    opacity: 0.55,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
    sizeAttenuation: false,   // screen-space size so distant points are still visible
  });
  const bgPoints = new THREE.Points(bgGeom, bgMat);
  scene.add(bgPoints);

  // Mark the main Points to skip the BG tail (rendered separately).
  geom.setDrawRange(0, N_BULGE + N_DISK);

  camera.position.set(0, -22, 13);
  camera.lookAt(0, 0, 0);

  return { scene, camera };
}

function tick({ dt, time, scene, camera, renderer, width, height, input }) {
  // --- Camera control: drag to orbit, auto-rotate when idle ---
  const clicks = input.consumeClicks(); void clicks;
  if (input.mouseDown) {
    if (!isDragging) {
      isDragging = true;
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
    } else {
      const dx = input.mouseX - lastMouseX;
      const dy = input.mouseY - lastMouseY;
      userYaw -= dx * 0.006;
      userPitch -= dy * 0.006;
      userPitch = Math.max(-Math.PI / 2 + 0.05, Math.min(Math.PI / 2 - 0.05, userPitch));
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
    }
    idleTimer = 0;
  } else {
    isDragging = false;
    idleTimer += dt;
    if (idleTimer > 0.8) {
      userYaw += dt * 0.08;
    }
  }

  const R = 26;
  const cy = Math.cos(userYaw), sy = Math.sin(userYaw);
  const cp = Math.cos(userPitch), sp = Math.sin(userPitch);
  camera.position.set(
    R * cp * sy,
    R * cp * cy,    // y is the disk plane direction in our orientation
    R * sp + 2,
  );
  camera.up.set(0, 0, 1);
  camera.lookAt(0, 0, 0);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  renderer.setSize(width, height, false);

  // --- Advance pattern angle (slow) and per-star orbital angles (fast) ---
  phiPattern += OMEGA_P * dt;
  if (phiPattern > Math.PI * 2) phiPattern -= Math.PI * 2;

  // For each star: compute its current orbital angle, then displace it
  // toward the nearest spiral arm centerline. This is the density-wave
  // visualization โ€” orbits are individual, but the arm pattern is locked
  // to phiPattern, NOT to the orbits.
  //
  // Two arms: arm_n centers at theta_arm(r) = phiPattern + K_ARM * r + n*pi (n=0,1).
  // We compute the angular offset from the star's natural theta to the
  // nearest arm and pull a fraction ARM_AMPL of the way toward it. This
  // produces visible density enhancements at the arms without breaking
  // each star's individual rotation. The bar adds an m=2 pull in the
  // bar region.
  for (let i = 0; i < N_BULGE + N_DISK; i++) {
    theta0[i] += omegas[i] * dt;
    if (theta0[i] > Math.PI * 2) theta0[i] -= Math.PI * 2;
    if (theta0[i] < 0) theta0[i] += Math.PI * 2;

    const r = radii[i];
    let drawTheta = theta0[i];

    if (!isBulge[i]) {
      // Find the nearest of the two arm centerlines at this radius.
      const armBase = phiPattern + K_ARM * r;
      // Two arms 180ยฐ apart. Compute delta to whichever is closer.
      let d1 = theta0[i] - armBase;
      let d2 = theta0[i] - (armBase + Math.PI);
      // Wrap to [-pi, pi].
      d1 = ((d1 + Math.PI) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2) - Math.PI;
      d2 = ((d2 + Math.PI) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2) - Math.PI;
      const d = Math.abs(d1) < Math.abs(d2) ? d1 : d2;
      // Pull amplitude tapers with r: stronger in the inner disk, weaker
      // at the rim where arms naturally wash out.
      const taper = Math.exp(-r / (R_DISK * 1.8));
      drawTheta = theta0[i] - d * ARM_AMPL * taper;
    } else if (r < BAR_R) {
      // Bar: m=2 azimuthal compression toward the bar axis (which rotates
      // with the pattern). Bar axis direction = phiPattern.
      const barAxis = phiPattern;
      let d1 = theta0[i] - barAxis;
      let d2 = theta0[i] - (barAxis + Math.PI);
      d1 = ((d1 + Math.PI) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2) - Math.PI;
      d2 = ((d2 + Math.PI) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2) - Math.PI;
      const d = Math.abs(d1) < Math.abs(d2) ? d1 : d2;
      const barTaper = 1 - r / BAR_R;
      drawTheta = theta0[i] - d * BAR_AMPL * barTaper;
    }

    const x = Math.cos(drawTheta) * r;
    const y = Math.sin(drawTheta) * r;
    const idx = i * 3;
    drawPositions[idx]     = x;
    drawPositions[idx + 1] = y;
    drawPositions[idx + 2] = zOff[i];
  }

  geom.attributes.position.needsUpdate = true;

  renderer.render(scene, camera);
}

Comments (0)

Log in to comment.