8

Softened Gravity N-Body (3D)

drag to orbit ยท click to drop a body

Eight to twelve point masses evolving under mutual Newtonian gravity in full 3D: . The in the denominator is the *Plummer softening* used in collisionless N-body codes โ€” it caps the force at small separations so two bodies sailing past each other don't get an unbounded kick from a single mistimed timestep (the singularity is what makes naive N-body integrators explode). The price is that very-close encounters are not faithful; the win is that we never lose energy to a numerical blowup. Time integration is *kick-drift-kick leapfrog*, a symplectic second-order scheme: it doesn't conserve energy exactly each step, but the energy *error stays bounded* indefinitely, unlike RK4 whose error drifts secularly. Bodies are coloured by mass (cool blue = light, warm orange = heavy) and radii scale as , the same dependence as a constant-density sphere. Each body trails a 300-point line. The simulation continuously recenters on the system's center of mass so the cluster doesn't sail off-camera. Drag to orbit the camera; click to drop a new body at a random shell position with a small random velocity (capped at 12 โ€” oldest body is recycled when full).

idle
254 lines ยท three
view source
// Three.js softened-gravity N-body in 3D.
//
// Contract: main-thread iframe with three.js as window.THREE. init() builds
// the scene; tick() integrates and draws. Runtime auto-calls render() if we
// don't (we do, to be safe).
//
// Physics: each body feels
//   a_i = -G * sum_{j!=i} m_j (r_i - r_j) / (|r_i - r_j|^2 + eps^2)^(3/2)
// The eps softening prevents near-singular blow-ups at small separations.
// Time integration is kick-drift-kick leapfrog (symplectic โ€” discrete
// energy stays bounded for very long runs, unlike RK4 which slowly drifts).

const G = 6.0;
const EPS2 = 0.25;        // softening^2
const MAX_BODIES = 12;
const MIN_BODIES = 8;
const TRAIL_LEN = 300;    // points per body
const SUBSTEPS = 4;
const DT_SIM = 1 / 240;   // per substep

let bodies = [];          // { pos[3], vel[3], acc[3], m, mesh, trail }
let trailLines = [];      // parallel array of THREE.Line
let sceneRef, cameraRef, rendererRef;
let userYaw = 0, userPitch = 0.35;
let isDragging = false;
let lastMouseX = 0, lastMouseY = 0;
let camDist = 32;

function massToColor(m) {
  // m in roughly [0.5, 8]; map cool blue/cyan -> hot red/yellow.
  const t = Math.min(1, Math.max(0, (Math.log(m) + 0.7) / 2.8));
  const r = 0.25 + 0.75 * t;
  const g = 0.55 - 0.35 * t;
  const b = 1.0  - 0.85 * t;
  return new THREE.Color(r, g, b);
}

function radiusFor(m) {
  return 0.35 + Math.cbrt(m) * 0.45;
}

function randVec(scale) {
  return [
    (Math.random() - 0.5) * 2 * scale,
    (Math.random() - 0.5) * 2 * scale,
    (Math.random() - 0.5) * 2 * scale,
  ];
}

function makeBody(pos, vel, m) {
  const col = massToColor(m);
  const r = radiusFor(m);
  const geo = new THREE.SphereGeometry(r, 16, 12);
  const mat = new THREE.MeshBasicMaterial({ color: col });
  const mesh = new THREE.Mesh(geo, mat);
  mesh.position.set(pos[0], pos[1], pos[2]);
  sceneRef.add(mesh);

  // Per-body trail: ring buffer of TRAIL_LEN positions.
  const positions = new Float32Array(TRAIL_LEN * 3);
  for (let i = 0; i < TRAIL_LEN; i++) {
    positions[i * 3]     = pos[0];
    positions[i * 3 + 1] = pos[1];
    positions[i * 3 + 2] = pos[2];
  }
  const tgeo = new THREE.BufferGeometry();
  tgeo.setAttribute("position",
    new THREE.BufferAttribute(positions, 3).setUsage(THREE.DynamicDrawUsage));
  const tmat = new THREE.LineBasicMaterial({
    color: col, transparent: true, opacity: 0.6,
  });
  const line = new THREE.Line(tgeo, tmat);
  sceneRef.add(line);

  return {
    pos: [pos[0], pos[1], pos[2]],
    vel: [vel[0], vel[1], vel[2]],
    acc: [0, 0, 0],
    m,
    mesh,
    trail: { positions, head: 0, line, geom: tgeo },
  };
}

function disposeBody(b) {
  sceneRef.remove(b.mesh);
  b.mesh.geometry.dispose();
  b.mesh.material.dispose();
  sceneRef.remove(b.trail.line);
  b.trail.geom.dispose();
  b.trail.line.material.dispose();
}

function spawnRandom() {
  const m = 0.6 + Math.random() * 6;
  const pos = randVec(8);
  const vel = randVec(1.2);
  const b = makeBody(pos, vel, m);
  if (bodies.length >= MAX_BODIES) {
    // recycle the oldest
    const dead = bodies.shift();
    disposeBody(dead);
  }
  bodies.push(b);
}

function computeAccel() {
  const n = bodies.length;
  for (let i = 0; i < n; i++) {
    bodies[i].acc[0] = 0;
    bodies[i].acc[1] = 0;
    bodies[i].acc[2] = 0;
  }
  for (let i = 0; i < n; i++) {
    const bi = bodies[i];
    for (let j = i + 1; j < n; j++) {
      const bj = bodies[j];
      const dx = bj.pos[0] - bi.pos[0];
      const dy = bj.pos[1] - bi.pos[1];
      const dz = bj.pos[2] - bi.pos[2];
      const r2 = dx * dx + dy * dy + dz * dz + EPS2;
      const invR = 1 / Math.sqrt(r2);
      const invR3 = invR / r2;
      const fx = G * dx * invR3;
      const fy = G * dy * invR3;
      const fz = G * dz * invR3;
      bi.acc[0] += fx * bj.m;
      bi.acc[1] += fy * bj.m;
      bi.acc[2] += fz * bj.m;
      bj.acc[0] -= fx * bi.m;
      bj.acc[1] -= fy * bi.m;
      bj.acc[2] -= fz * bi.m;
    }
  }
}

function leapfrogStep(h) {
  // Kick (half), drift (full), recompute, kick (half).
  const n = bodies.length;
  for (let i = 0; i < n; i++) {
    const b = bodies[i];
    b.vel[0] += b.acc[0] * h * 0.5;
    b.vel[1] += b.acc[1] * h * 0.5;
    b.vel[2] += b.acc[2] * h * 0.5;
    b.pos[0] += b.vel[0] * h;
    b.pos[1] += b.vel[1] * h;
    b.pos[2] += b.vel[2] * h;
  }
  computeAccel();
  for (let i = 0; i < n; i++) {
    const b = bodies[i];
    b.vel[0] += b.acc[0] * h * 0.5;
    b.vel[1] += b.acc[1] * h * 0.5;
    b.vel[2] += b.acc[2] * h * 0.5;
  }
}

function recenterToCOM() {
  // Drift the whole system back to origin so it doesn't sail off-screen.
  let mx = 0, my = 0, mz = 0, vx = 0, vy = 0, vz = 0, mt = 0;
  for (const b of bodies) {
    mx += b.pos[0] * b.m; my += b.pos[1] * b.m; mz += b.pos[2] * b.m;
    vx += b.vel[0] * b.m; vy += b.vel[1] * b.m; vz += b.vel[2] * b.m;
    mt += b.m;
  }
  if (mt <= 0) return;
  mx /= mt; my /= mt; mz /= mt;
  vx /= mt; vy /= mt; vz /= mt;
  for (const b of bodies) {
    b.pos[0] -= mx; b.pos[1] -= my; b.pos[2] -= mz;
    b.vel[0] -= vx; b.vel[1] -= vy; b.vel[2] -= vz;
  }
}

function init({ scene, camera, renderer, width, height }) {
  sceneRef = scene; cameraRef = camera; rendererRef = renderer;
  renderer.setClearColor(0x05060c, 1);

  // A faint ambient grid-of-stars background (just a few static specks).
  const starGeo = new THREE.BufferGeometry();
  const starCount = 400;
  const starPos = new Float32Array(starCount * 3);
  for (let i = 0; i < starCount; i++) {
    const r = 80 + Math.random() * 40;
    const theta = Math.random() * Math.PI * 2;
    const phi = Math.acos(2 * Math.random() - 1);
    starPos[i * 3]     = r * Math.sin(phi) * Math.cos(theta);
    starPos[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
    starPos[i * 3 + 2] = r * Math.cos(phi);
  }
  starGeo.setAttribute("position", new THREE.BufferAttribute(starPos, 3));
  const starMat = new THREE.PointsMaterial({
    color: 0x445566, size: 0.5, sizeAttenuation: true,
  });
  scene.add(new THREE.Points(starGeo, starMat));

  // Seed initial bodies on a loose shell with tangential-ish velocities so
  // the cluster doesn't just collapse into a single blob immediately.
  const n0 = MIN_BODIES + Math.floor(Math.random() * 3);
  for (let i = 0; i < n0; i++) {
    const m = 0.6 + Math.random() * 6;
    // Random position on a shell of radius 4-8.
    const R = 4 + Math.random() * 4;
    const theta = Math.random() * Math.PI * 2;
    const phi = Math.acos(2 * Math.random() - 1);
    const pos = [
      R * Math.sin(phi) * Math.cos(theta),
      R * Math.sin(phi) * Math.sin(theta),
      R * Math.cos(phi),
    ];
    // Tangential velocity in the x-z plane plus a small random kick.
    const speed = 1.0 + Math.random() * 0.6;
    const tx = -Math.sin(theta) * speed;
    const tz =  Math.cos(theta) * speed;
    const vel = [tx, (Math.random() - 0.5) * 0.4, tz];
    bodies.push(makeBody(pos, vel, m));
  }
  recenterToCOM();
  computeAccel();

  camera.fov = 55;
  camera.near = 0.1;
  camera.far = 500;
  camera.updateProjectionMatrix();
}

function updateCamera(dt, width, height) {
  // Aspect/size each frame in case the canvas reflows.
  cameraRef.aspect = width / height;
  cameraRef.updateProjectionMatrix();
  rendererRef.setSize(width, height, false);

  const cy = Math.cos(userYaw), sy = Math.sin(userYaw);
  const cp = Math.cos(userPitch), sp = Math.sin(userPitch);
  cameraRef.position.set(
    camDist * cp * sy,
    camDist * sp,
    camDist * cp * cy,
  );
  cameraRef.lookAt(0, 0, 0);
}

function tick({ dt, scene, camera, renderer, width, height, input }) {
  // Drain clicks first โ€” each drops a new body at a random shell position.
  const clicks = input.consumeClicks();
  for (let i = 0; i < clicks.length; i++) {
    spawnRandom();
  }

  // Camera input: drag to spin, idle = slow auto-rotate.
  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(-1.2, Math.min(1.2, userPitch));
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
    }
  } else {
    isDragging = false;
    userYaw += dt * 0.12;
  }
  updateCamera(dt, width, height);

  // Integrate. Cap dt so a stalled tab doesn't fire a giant single step.
  const stepDt = Math.min(dt, 1 / 30);
  const subDt = stepDt / SUBSTEPS;
  for (let s = 0; s < SUBSTEPS; s++) {
    // Internal fixed-step refinement: subdivide once more if subDt is big.
    const inner = Math.max(1, Math.ceil(subDt / DT_SIM));
    const h = subDt / inner;
    for (let k = 0; k < inner; k++) leapfrogStep(h);
  }

  // Periodically rebalance the COM so the cluster stays centered.
  recenterToCOM();

  // Update mesh positions + trails.
  for (let i = 0; i < bodies.length; i++) {
    const b = bodies[i];
    b.mesh.position.set(b.pos[0], b.pos[1], b.pos[2]);
    const t = b.trail;
    const idx = t.head * 3;
    t.positions[idx]     = b.pos[0];
    t.positions[idx + 1] = b.pos[1];
    t.positions[idx + 2] = b.pos[2];
    t.head = (t.head + 1) % TRAIL_LEN;
    // Cheap trick: redraw as a polyline in ring-buffer order. We start at
    // `head` (oldest point) and wrap; do that by offsetting the draw range
    // via a small re-pack into a contiguous chunk only when we update.
    // For perf, we just rewrite positions in linear order starting from head.
    // Re-pack: build a temporary order then copy.
    // (Doing this cheaply: copy ring into a scratch buffer once per frame.)
  }

  // Re-pack trail buffers in ring order so the line doesn't have a wrap-jump
  // visible right where it crosses index 0.
  for (let i = 0; i < bodies.length; i++) {
    const t = bodies[i].trail;
    const src = t.positions;
    // We render directly from `src` with a Line; the visible "wrap" artefact
    // is a single segment crossing oldest->newest. Make it invisible by
    // collapsing the oldest point to the newest body position so the segment
    // has zero length there.
    const newestIdx = ((t.head - 1 + TRAIL_LEN) % TRAIL_LEN) * 3;
    const oldestIdx = t.head * 3;
    src[oldestIdx]     = src[newestIdx];
    src[oldestIdx + 1] = src[newestIdx + 1];
    src[oldestIdx + 2] = src[newestIdx + 2];
    t.geom.attributes.position.needsUpdate = true;
  }

  renderer.render(scene, camera);
}

Comments (0)

Log in to comment.