3

Lorenz Attractor (3D)

drag to orbit the camera

Real-time integration of the Lorenz system with , , — but rendered in full 3D via three.js so you can orbit around the butterfly attractor instead of staring at a single planar projection. RK4 substepping keeps integration stable, and the trail is a 4000-point ring-buffer line with per-vertex hue cycling. Drag to spin the camera; let go and it auto-rotates.

idle
132 lines · three
view source
// Three.js Lorenz attractor — the full 3D butterfly.
//
// Contract: this sim runs in the main-thread iframe with three.js loaded
// as window.THREE. init() builds the scene; tick() integrates the ODE
// and draws. The runtime auto-calls renderer.render(scene, camera) each
// tick if you don't.

const SIGMA = 10;
const RHO = 28;
const BETA = 8 / 3;
const TRAIL_MAX = 4000;
const SUBSTEPS = 6;
const DT_SIM = 0.005;

let state;        // [x, y, z]
let positions;    // Float32Array of trail points
let head;         // ring-buffer write index
let count;        // points written so far
let lineGeom, lineMat, line;
let head3D, headMat;
let hueOffset = 0;
let elapsed = 0;
let userYaw = 0, userPitch = 0;
let isDragging = false;
let lastMouseX = 0, lastMouseY = 0;

function derivs(x, y, z) {
  return [
    SIGMA * (y - x),
    x * (RHO - z) - y,
    x * y - BETA * z,
  ];
}

function rk4Step(s, h) {
  const [x, y, z] = s;
  const k1 = derivs(x, y, z);
  const k2 = derivs(x + 0.5 * h * k1[0], y + 0.5 * h * k1[1], z + 0.5 * h * k1[2]);
  const k3 = derivs(x + 0.5 * h * k2[0], y + 0.5 * h * k2[1], z + 0.5 * h * k2[2]);
  const k4 = derivs(x + h * k3[0], y + h * k3[1], z + h * k3[2]);
  return [
    x + (h / 6) * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]),
    y + (h / 6) * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]),
    z + (h / 6) * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2]),
  ];
}

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

  state = [0.1, 0.0, 0.0];
  // burn-in so we start on the attractor, not the spiral-out
  for (let i = 0; i < 1500; i++) state = rk4Step(state, DT_SIM);

  positions = new Float32Array(TRAIL_MAX * 3);
  head = 0;
  count = 0;

  lineGeom = new THREE.BufferGeometry();
  lineGeom.setAttribute(
    "position",
    new THREE.BufferAttribute(positions, 3).setUsage(THREE.DynamicDrawUsage),
  );
  lineGeom.setDrawRange(0, 0);
  lineMat = new THREE.LineBasicMaterial({
    color: 0xffffff,
    vertexColors: true,
    transparent: true,
    opacity: 0.9,
  });
  // Per-vertex hue for the trail.
  const colors = new Float32Array(TRAIL_MAX * 3);
  lineGeom.setAttribute(
    "color",
    new THREE.BufferAttribute(colors, 3).setUsage(THREE.DynamicDrawUsage),
  );
  line = new THREE.Line(lineGeom, lineMat);
  // Lorenz lives roughly in z ∈ [0, 50]; recenter on the canonical bowtie.
  line.position.set(0, -25, 0);
  scene.add(line);

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

  return { scene, camera };
}

function hslToRgb(h, s, l) {
  // h in [0,1)
  const a = s * Math.min(l, 1 - l);
  const f = (n, k = (n + h * 12) % 12) =>
    l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
  return [f(0), f(8), f(4)];
}

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

  // Mouse-drag camera orbit (and auto-rotate when idle).
  const clicks = input.consumeClicks(); // drain so the buffer doesn't grow
  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.005;
      userPitch -= dy * 0.005;
      userPitch = Math.max(-1.2, Math.min(1.2, userPitch));
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
    }
  } else {
    isDragging = false;
    // Slow auto-rotate when no one is touching.
    userYaw += dt * 0.18;
  }

  const r = 90;
  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);

  // Advance ODE by `SUBSTEPS` substeps, scaled to wall-clock dt.
  const steps = Math.max(1, Math.min(12, Math.round(SUBSTEPS * (dt / (1 / 60)))));
  hueOffset = (hueOffset + dt * 12) % 360;
  const colors = lineGeom.attributes.color.array;
  for (let i = 0; i < steps; i++) {
    state = rk4Step(state, DT_SIM);
    const idx = head * 3;
    positions[idx]     = state[0];
    positions[idx + 1] = state[1];
    positions[idx + 2] = state[2];
    // Color this point on its hue.
    const t = count / TRAIL_MAX;
    const h = (hueOffset / 360 + t * 0.78) % 1;
    const [r0, g0, b0] = hslToRgb(h, 0.95, 0.6);
    colors[idx]     = r0;
    colors[idx + 1] = g0;
    colors[idx + 2] = b0;
    head = (head + 1) % TRAIL_MAX;
    if (count < TRAIL_MAX) count++;
  }
  lineGeom.attributes.position.needsUpdate = true;
  lineGeom.attributes.color.needsUpdate = true;
  lineGeom.setDrawRange(0, count);
  // Reorder so the ring buffer's logical start is the visual start of the
  // line. Cheap: just keep redrawing one fixed window and accept that the
  // wrap point shows a single discontinuity.
  head3D.position.set(state[0], state[1] - 25, state[2]);
  head3D.material.color.setHSL(hueOffset / 360, 1.0, 0.8);

  renderer.render(scene, camera);
}

Comments (0)

Log in to comment.