14

Inner Solar System (3D)

tap to launch a comet ยท drag to orbit the camera

Mercury, Venus, Earth (with the Moon in tow), and Mars circle a glowing Sun, with distant Jupiter creeping along its much wider orbit and a 220-rock asteroid belt scattered between them โ€” all rendered in three.js against a 1400-star backdrop. Distances are squashed so everything fits on screen, but the *relative* orbital periods follow Kepler's third law : with measured in scene units, each body's angular speed is , so Mercury whips through about ten laps in the time Mars completes one, and Jupiter barely moves. Each belt asteroid carries its own little ellipse โ€” eccentricity, argument of periapsis, inclination โ€” and is propagated by solving Kepler's equation via Newton iteration. **Tap anywhere to fling a comet** onto a steeply eccentric, inclined orbit: it'll dive in, brighten as it grazes the Sun, and elongate visibly along the way. Drag to orbit the camera; let go and it auto-rotates.

idle
357 lines ยท three
view source
// Inner solar system in three.js โ€” Sun + Mercury, Venus, Earth (+ Moon), Mars,
// plus distant Jupiter, a Mars/Jupiter asteroid belt, and clickable comets.
//
// Distances and sizes are not literal AU/km โ€” they're squashed into something
// you can actually see on a 600px canvas. But the *relative* ordering and the
// orbital periods follow Kepler's third law: T = sqrt(a^3) in our units, so
// the relative speeds match reality. Mercury whips around fast, Jupiter crawls.

// Semi-major axes (scene units). Real ratios are roughly 0.39 : 0.72 : 1 : 1.52
// : 5.2 in AU; we scale by ~8 for visibility (Jupiter is squashed harder).
const PLANETS = [
  { name: "mercury", a: 3.1,  size: 0.35, color: 0xa6a29c, axial: 1.2 },
  { name: "venus",   a: 5.8,  size: 0.75, color: 0xe6c98a, axial: 0.4 },
  { name: "earth",   a: 8.0,  size: 0.85, color: 0x4a90d6, axial: 1.5 },
  { name: "mars",    a: 12.2, size: 0.55, color: 0xc1502c, axial: 1.4 },
  { name: "jupiter", a: 24.0, size: 1.6,  color: 0xd4a574, axial: 2.1 },
];

// Master orbital speed. We multiply by 1/T per Kepler, where T = sqrt(a^3).
// 0.45 gives Earth a ~14 second year โ€” fast enough to feel alive, slow
// enough that you can track individual planets.
const ORBIT_SPEED = 0.45;

const COMET_CAP = 5;
const BELT_COUNT = 220;

let sun;
let pointLight;
let starfield;
let planets = []; // { mesh, pivot, a, T, axial, phase, name }
let moon, moonPivot;
let belt; // THREE.Points for asteroid belt
let beltData; // parallel array of { a, e, phase, omega, inc } per particle
let elapsed = 0;
let userYaw = 0.4, userPitch = 0.5;
let isDragging = false;
let dragMoved = false;
let lastMouseX = 0, lastMouseY = 0;
let comets = []; // { head, trail, trailPositions, drawCount, writeIdx, a, e, omega, inc, M, n, color, life }

function makeStarfield(count) {
  const positions = new Float32Array(count * 3);
  const colors = new Float32Array(count * 3);
  for (let i = 0; i < count; i++) {
    const u = Math.random() * 2 - 1;
    const theta = Math.random() * Math.PI * 2;
    const r = 180 + Math.random() * 40;
    const s = Math.sqrt(1 - u * u);
    positions[i * 3]     = r * s * Math.cos(theta);
    positions[i * 3 + 1] = r * u;
    positions[i * 3 + 2] = r * s * Math.sin(theta);
    const tint = 0.7 + Math.random() * 0.3;
    const warm = Math.random();
    colors[i * 3]     = tint * (warm > 0.7 ? 1.0 : 0.85);
    colors[i * 3 + 1] = tint * 0.9;
    colors[i * 3 + 2] = tint * (warm > 0.7 ? 0.8 : 1.0);
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
  geo.setAttribute("color", new THREE.BufferAttribute(colors, 3));
  const mat = new THREE.PointsMaterial({
    size: 0.6,
    vertexColors: true,
    sizeAttenuation: true,
    transparent: true,
    opacity: 0.9,
  });
  return new THREE.Points(geo, mat);
}

function makeOrbitLine(radius, segments = 128) {
  const positions = new Float32Array((segments + 1) * 3);
  for (let i = 0; i <= segments; i++) {
    const t = (i / segments) * Math.PI * 2;
    positions[i * 3]     = Math.cos(t) * radius;
    positions[i * 3 + 1] = 0;
    positions[i * 3 + 2] = Math.sin(t) * radius;
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute("position", new THREE.BufferAttribute(positions, 3));
  const mat = new THREE.LineBasicMaterial({
    color: 0x33405a,
    transparent: true,
    opacity: 0.35,
  });
  return new THREE.Line(geo, mat);
}

function makeBelt() {
  // 200+ small Points scattered between Mars (12.2) and Jupiter (24.0),
  // each with its own eccentricity, phase, inclination. We treat each
  // particle as if on its own Keplerian orbit; we keep parameters in a
  // parallel array and recompute positions every frame.
  const positions = new Float32Array(BELT_COUNT * 3);
  const colors = new Float32Array(BELT_COUNT * 3);
  beltData = new Array(BELT_COUNT);
  for (let i = 0; i < BELT_COUNT; i++) {
    const a = 14 + Math.random() * 7.5;       // semi-major in [14, 21.5]
    const e = Math.random() * 0.18;           // small eccentricity
    const phase = Math.random() * Math.PI * 2;
    const omega = Math.random() * Math.PI * 2; // argument of periapsis
    const inc = (Math.random() - 0.5) * 0.18;  // ยฑ~10ยฐ tilt
    beltData[i] = { a, e, phase, omega, inc, n: 1 / Math.sqrt(a * a * a) };
    const shade = 0.55 + Math.random() * 0.35;
    colors[i * 3]     = shade * 0.85;
    colors[i * 3 + 1] = shade * 0.78;
    colors[i * 3 + 2] = shade * 0.65;
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute(
    "position",
    new THREE.BufferAttribute(positions, 3).setUsage(THREE.DynamicDrawUsage),
  );
  geo.setAttribute("color", new THREE.BufferAttribute(colors, 3));
  const mat = new THREE.PointsMaterial({
    size: 0.18,
    vertexColors: true,
    sizeAttenuation: true,
    transparent: true,
    opacity: 0.9,
  });
  return new THREE.Points(geo, mat);
}

// Solve Kepler's equation M = E - e*sin(E) by Newton iteration. Good to
// ~1e-6 in 5 iterations for e < 0.9.
function solveKepler(M, e) {
  let E = M;
  for (let i = 0; i < 6; i++) {
    const f = E - e * Math.sin(E) - M;
    const fp = 1 - e * Math.cos(E);
    E -= f / fp;
  }
  return E;
}

function spawnComet(scene) {
  // Highly eccentric orbit with random inclination โ€” perihelion grazes
  // the Sun (q ~ 1.5โ€“4 scene units), aphelion well beyond Jupiter so the
  // comet visibly elongates near the sun.
  const q = 1.5 + Math.random() * 2.5;
  const e = 0.75 + Math.random() * 0.2; // [0.75, 0.95]
  const a = q / (1 - e);
  const omega = Math.random() * Math.PI * 2;
  const inc = (Math.random() - 0.5) * Math.PI * 0.7; // up to ~63ยฐ tilt
  const node = Math.random() * Math.PI * 2; // longitude of ascending node
  // Start the comet *near perihelion* (mean anomaly small) so the user
  // sees something dramatic right away.
  const M0 = (Math.random() < 0.5 ? -1 : 1) * (0.05 + Math.random() * 0.4);
  const n = ORBIT_SPEED / Math.sqrt(a * a * a); // mean motion

  const TRAIL_LEN = 80;
  const trailPositions = new Float32Array(TRAIL_LEN * 3);
  const trailColors = new Float32Array(TRAIL_LEN * 3);
  const geo = new THREE.BufferGeometry();
  geo.setAttribute(
    "position",
    new THREE.BufferAttribute(trailPositions, 3).setUsage(THREE.DynamicDrawUsage),
  );
  geo.setAttribute(
    "color",
    new THREE.BufferAttribute(trailColors, 3).setUsage(THREE.DynamicDrawUsage),
  );
  geo.setDrawRange(0, 0);
  const mat = new THREE.LineBasicMaterial({
    vertexColors: true,
    transparent: true,
    opacity: 0.9,
  });
  const trail = new THREE.Line(geo, mat);
  scene.add(trail);

  const headGeo = new THREE.SphereGeometry(0.18, 12, 10);
  const headMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
  const head = new THREE.Mesh(headGeo, headMat);
  scene.add(head);

  // Color hue from a small palette โ€” icy whites and cyans.
  const hue = 0.5 + Math.random() * 0.15;

  const c = {
    head,
    trail,
    trailPositions,
    trailColors,
    trailGeo: geo,
    drawCount: 0,
    writeIdx: 0,
    trailLen: TRAIL_LEN,
    a, e, omega, inc, node, M: M0, n,
    hue,
    age: 0,
    fadingOut: false,
    fadeT: 0,
  };
  comets.push(c);

  // Cap: oldest fades out gracefully.
  if (comets.length > COMET_CAP) {
    const oldest = comets[0];
    oldest.fadingOut = true;
    oldest.fadeT = 0;
  }
}

function updateComet(c, dt) {
  c.age += dt;
  c.M += c.n * dt;
  const E = solveKepler(c.M, c.e);
  // Position in orbital plane (perifocal frame).
  const cosE = Math.cos(E), sinE = Math.sin(E);
  const xp = c.a * (cosE - c.e);
  const yp = c.a * Math.sqrt(1 - c.e * c.e) * sinE;
  // Rotate by argument of perihelion (omega) in orbital plane, then tilt
  // by inclination around X, then rotate around Y by longitude of node.
  const co = Math.cos(c.omega), so = Math.sin(c.omega);
  let x = xp * co - yp * so;
  let y = xp * so + yp * co;
  let z = 0;
  // inclination about X
  const ci = Math.cos(c.inc), si = Math.sin(c.inc);
  const y2 = y * ci - z * si;
  const z2 = y * si + z * ci;
  y = y2; z = z2;
  // node about Y
  const cn = Math.cos(c.node), sn = Math.sin(c.node);
  const x3 = x * cn + z * sn;
  const z3 = -x * sn + z * cn;
  x = x3; z = z3;

  // Push to ring buffer (trail).
  const i = c.writeIdx * 3;
  c.trailPositions[i]     = x;
  c.trailPositions[i + 1] = y;
  c.trailPositions[i + 2] = z;
  // Color: brighter near head, faded near tail. We re-color the whole
  // ring each frame so the gradient stays anchored at the head.
  c.writeIdx = (c.writeIdx + 1) % c.trailLen;
  if (c.drawCount < c.trailLen) c.drawCount++;

  // Position the head sphere.
  c.head.position.set(x, y, z);

  // Distance from sun controls glow intensity โ€” comets brighten near
  // perihelion (real comets do this from outgassing).
  const r = Math.sqrt(x * x + y * y + z * z);
  const closeness = Math.max(0, Math.min(1, 1 - (r - 1.5) / 30));
  const headHsl = c.fadingOut
    ? Math.max(0, 1 - c.fadeT / 1.5)
    : 1.0;
  c.head.material.color.setHSL(c.hue, 0.4, 0.6 + 0.35 * closeness);
  c.head.scale.setScalar((0.7 + 1.4 * closeness) * headHsl);

  // Repaint trail colors as a gradient from head -> tail with fade.
  const arr = c.trailColors;
  const fadeMul = c.fadingOut ? Math.max(0, 1 - c.fadeT / 1.5) : 1;
  for (let k = 0; k < c.drawCount; k++) {
    // Distance back from the head in ring-buffer terms.
    const stepsBack = (c.writeIdx - 1 - k + c.trailLen) % c.trailLen;
    const t = k / c.drawCount; // 0 = head-side, 1 = tail-end
    const bright = (1 - t) * (0.5 + 0.5 * closeness) * fadeMul;
    const idx = stepsBack * 3;
    // Cool blue-white near head, dimmer cyan toward tail.
    arr[idx]     = bright * (0.7 + 0.3 * (1 - t));
    arr[idx + 1] = bright * (0.85 + 0.15 * (1 - t));
    arr[idx + 2] = bright * 1.0;
  }
  c.trailGeo.attributes.position.needsUpdate = true;
  c.trailGeo.attributes.color.needsUpdate = true;
  c.trailGeo.setDrawRange(0, c.drawCount);
}

function disposeComet(c, scene) {
  scene.remove(c.head);
  scene.remove(c.trail);
  c.head.geometry.dispose();
  c.head.material.dispose();
  c.trail.geometry.dispose();
  c.trail.material.dispose();
}

function init({ scene, camera, renderer, width, height }) {
  renderer.setClearColor(0x02030a, 1);
  camera.position.set(0, 18, 30);
  camera.lookAt(0, 0, 0);

  // Sun โ€” emissive sphere so it glows even without the point light hitting it.
  const sunGeo = new THREE.SphereGeometry(2.0, 32, 24);
  const sunMat = new THREE.MeshBasicMaterial({ color: 0xffd560 });
  sun = new THREE.Mesh(sunGeo, sunMat);
  scene.add(sun);

  pointLight = new THREE.PointLight(0xfff0c4, 2.4, 0, 2);
  pointLight.position.set(0, 0, 0);
  scene.add(pointLight);

  scene.add(new THREE.AmbientLight(0x222838, 0.5));

  starfield = makeStarfield(1400);
  scene.add(starfield);

  for (const p of PLANETS) {
    const geo = new THREE.SphereGeometry(p.size, 32, 24);
    const mat = new THREE.MeshStandardMaterial({
      color: p.color,
      roughness: 0.85,
      metalness: 0.05,
    });
    const mesh = new THREE.Mesh(geo, mat);

    const pivot = new THREE.Object3D();
    mesh.position.set(p.a, 0, 0);
    pivot.add(mesh);
    scene.add(pivot);

    const orbitLine = makeOrbitLine(p.a);
    // Outer orbits are dimmer so the inner system stays the focus.
    if (p.name === "jupiter") {
      orbitLine.material.opacity = 0.18;
    }
    scene.add(orbitLine);

    const T = Math.sqrt(p.a * p.a * p.a);
    planets.push({
      mesh,
      pivot,
      a: p.a,
      T,
      axial: p.axial,
      phase: Math.random() * Math.PI * 2,
      name: p.name,
    });
    pivot.rotation.y = planets[planets.length - 1].phase;
  }

  // Moon โ€” child of Earth's mesh so it inherits Earth's orbital position.
  const earth = planets.find((p) => p.name === "earth");
  const moonGeo = new THREE.SphereGeometry(0.22, 20, 16);
  const moonMat = new THREE.MeshStandardMaterial({
    color: 0xd4d2cc,
    roughness: 0.95,
    metalness: 0.0,
  });
  moon = new THREE.Mesh(moonGeo, moonMat);
  moon.position.set(1.6, 0, 0);
  moonPivot = new THREE.Object3D();
  moonPivot.add(moon);
  earth.mesh.add(moonPivot);

  // Asteroid belt.
  belt = makeBelt();
  scene.add(belt);

  return { scene, camera };
}

function tick({ dt, time, scene, camera, renderer, width, height, input }) {
  elapsed += dt;

  // Drain clicks โ€” each click spawns a comet (unless it was the end of a
  // camera drag).
  const clicks = input.consumeClicks();
  if (clicks > 0 && !dragMoved) {
    for (let i = 0; i < clicks; i++) spawnComet(scene);
  }

  // Camera control โ€” drag to orbit, auto-rotate when idle.
  if (input.mouseDown) {
    if (!isDragging) {
      isDragging = true;
      dragMoved = false;
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
    } else {
      const dx = input.mouseX - lastMouseX;
      const dy = input.mouseY - lastMouseY;
      if (Math.abs(dx) + Math.abs(dy) > 2) dragMoved = true;
      userYaw -= dx * 0.005;
      userPitch -= dy * 0.005;
      userPitch = Math.max(-1.3, Math.min(1.3, userPitch));
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
    }
  } else {
    isDragging = false;
    // Reset dragMoved on next mousedown only; keep it true through the
    // current frame so the click-up after a drag doesn't spawn a comet.
    userYaw += dt * 0.07;
  }

  // Pull camera back a touch to fit Jupiter's orbit.
  const r = 48;
  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 * sp, r * cp * cy);
  camera.lookAt(0, 0, 0);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  renderer.setSize(width, height, false);

  // Sun spins slowly and pulses a hair.
  sun.rotation.y += dt * 0.1;
  const pulse = 1.0 + 0.02 * Math.sin(elapsed * 1.3);
  sun.scale.setScalar(pulse);

  // Planets: advance orbital angle by ORBIT_SPEED / T (Kepler), spin on axis.
  for (const p of planets) {
    p.pivot.rotation.y += (dt * ORBIT_SPEED) / p.T;
    p.mesh.rotation.y += dt * p.axial;
  }

  // Moon โ€” exaggerated to be visible.
  if (moonPivot) {
    const earthT = planets.find((p) => p.name === "earth").T;
    moonPivot.rotation.y += (dt * ORBIT_SPEED * 4) / earthT * 6;
  }

  // Asteroid belt โ€” Keplerian elliptical motion per particle.
  const beltPos = belt.geometry.attributes.position.array;
  for (let i = 0; i < BELT_COUNT; i++) {
    const d = beltData[i];
    d.phase += d.n * ORBIT_SPEED * dt;
    const M = d.phase;
    const E = solveKepler(M, d.e);
    const xp = d.a * (Math.cos(E) - d.e);
    const yp = d.a * Math.sqrt(1 - d.e * d.e) * Math.sin(E);
    const co = Math.cos(d.omega), so = Math.sin(d.omega);
    let x = xp * co - yp * so;
    let z = xp * so + yp * co;
    // tilt by inclination about X
    const y = z * Math.sin(d.inc);
    z = z * Math.cos(d.inc);
    beltPos[i * 3]     = x;
    beltPos[i * 3 + 1] = y;
    beltPos[i * 3 + 2] = z;
  }
  belt.geometry.attributes.position.needsUpdate = true;

  // Comets โ€” update, fade, retire.
  for (let i = comets.length - 1; i >= 0; i--) {
    const c = comets[i];
    updateComet(c, dt);
    if (c.fadingOut) {
      c.fadeT += dt;
      if (c.fadeT >= 1.5) {
        disposeComet(c, scene);
        comets.splice(i, 1);
      }
    }
  }

  // Starfield drift.
  starfield.rotation.y += dt * 0.005;

  renderer.render(scene, camera);
}

Comments (0)

Log in to comment.