3

Boids 3D — Reynolds Flock

drag to orbit · hold mouse to scatter the flock

Craig Reynolds' 1987 boids model lifted into three dimensions. Each of agents applies three local steering rules every frame against its toroidal neighbors: **separation** repels from anyone within with force , **alignment** matches the mean heading inside , and **cohesion** steers toward the center of mass inside . The combined steering force is capped, velocity is clamped to a band so flocks don't stall, and positions wrap toroidally in a cube. Cones are tinted by local speed (cool blue → warm orange) so you can read shear zones at a glance — the leading edge of any coherent flock runs hot, the trailing rear runs cool. Hold the mouse to drop a predator units in front of the camera; boids inside flee with a falloff, which is enough to split the swarm into transient sub-flocks that re-coalesce when you let go. Naive neighbor search; at that's pair-checks per frame, well inside budget.

idle
226 lines · three
view source
// Three.js 3D Boids — Reynolds' three rules in volumetric form.
//
// Contract: runs in the main-thread iframe with THREE on window. init()
// builds the scene; tick() updates boids and re-aims their cones along
// the velocity direction. Runtime auto-renders.

const N = 120;
const BOX = 30;                       // toroidal box: [-BOX/2, BOX/2]^3
const HALF = BOX / 2;

const R_SEP = 2.0,  R_SEP2 = R_SEP * R_SEP;
const R_ALIGN = 5.0, R_ALIGN2 = R_ALIGN * R_ALIGN;
const R_COH = 5.0,  R_COH2 = R_COH * R_COH;
const R_FLEE = R_SEP * 3, R_FLEE2 = R_FLEE * R_FLEE;

const W_SEP = 2.6;
const W_ALIGN = 1.0;
const W_COH = 0.9;
const W_FLEE = 6.0;

const MAX_SPEED = 8.0;
const MIN_SPEED = 2.5;
const MAX_FORCE = 18.0;

// Cone geometry default points along +Y. We orient via quaternion below.
const CONE_LEN = 0.9;
const CONE_RAD = 0.28;

let boids;            // [{pos:Vector3, vel:Vector3, mesh, color}]
let predator;         // {pos:Vector3, active:bool, mesh}
let camOrbit;         // {yaw, pitch, dist}
let isDragging = false;
let lastMouseX = 0, lastMouseY = 0;
let idleTimer = 0;

// Scratch vectors so we don't allocate every frame.
const _sep = new THREE.Vector3();
const _ali = new THREE.Vector3();
const _coh = new THREE.Vector3();
const _flee = new THREE.Vector3();
const _force = new THREE.Vector3();
const _diff = new THREE.Vector3();
const _up = new THREE.Vector3(0, 1, 0);
const _q = new THREE.Quaternion();
const _camFwd = new THREE.Vector3();

function wrapDelta(v) {
  // Wrap a delta-coordinate into [-HALF, HALF] for shortest-path neighbor math.
  if (v > HALF) return v - BOX;
  if (v < -HALF) return v + BOX;
  return v;
}

function init({ scene, camera, renderer }) {
  renderer.setClearColor(0x07080d, 1);

  // Soft directional + ambient so cones read as volumes, not flat shapes.
  scene.add(new THREE.AmbientLight(0xffffff, 0.55));
  const sun = new THREE.DirectionalLight(0xfff0d8, 0.9);
  sun.position.set(20, 30, 15);
  scene.add(sun);
  const fill = new THREE.DirectionalLight(0x88aaff, 0.35);
  fill.position.set(-15, -10, -20);
  scene.add(fill);

  // Wireframe cage so the toroidal box is legible.
  const cage = new THREE.LineSegments(
    new THREE.EdgesGeometry(new THREE.BoxGeometry(BOX, BOX, BOX)),
    new THREE.LineBasicMaterial({ color: 0x2a3344, transparent: true, opacity: 0.55 }),
  );
  scene.add(cage);

  // Single shared cone geometry; per-boid mesh gets its own MeshStandardMaterial
  // so we can recolor by speed every tick.
  const coneGeo = new THREE.ConeGeometry(CONE_RAD, CONE_LEN, 10, 1);
  // Shift so the cone's tip leads (origin → tip) when we orient via lookAt-style
  // quaternion below. Default cone has base at -h/2, tip at +h/2 — good.

  boids = new Array(N);
  for (let i = 0; i < N; i++) {
    const pos = new THREE.Vector3(
      (Math.random() - 0.5) * BOX,
      (Math.random() - 0.5) * BOX,
      (Math.random() - 0.5) * BOX,
    );
    const dir = new THREE.Vector3(
      Math.random() - 0.5,
      Math.random() - 0.5,
      Math.random() - 0.5,
    ).normalize();
    const speed = MIN_SPEED + Math.random() * (MAX_SPEED - MIN_SPEED);
    const vel = dir.multiplyScalar(speed);

    const mat = new THREE.MeshStandardMaterial({
      color: 0x88c0ff,
      roughness: 0.55,
      metalness: 0.15,
      flatShading: true,
    });
    const mesh = new THREE.Mesh(coneGeo, mat);
    mesh.position.copy(pos);
    scene.add(mesh);

    boids[i] = { pos, vel, mesh, mat };
  }

  // Predator marker — only visible while held.
  const predGeo = new THREE.SphereGeometry(0.6, 16, 12);
  const predMat = new THREE.MeshBasicMaterial({ color: 0xff4060, transparent: true, opacity: 0.85 });
  const predMesh = new THREE.Mesh(predGeo, predMat);
  predMesh.visible = false;
  scene.add(predMesh);
  predator = { pos: new THREE.Vector3(), active: false, mesh: predMesh };

  camOrbit = { yaw: 0.6, pitch: 0.35, dist: 52 };
  positionCamera(camera);
}

function positionCamera(camera) {
  const { yaw, pitch, dist } = camOrbit;
  const cy = Math.cos(yaw), sy = Math.sin(yaw);
  const cp = Math.cos(pitch), sp = Math.sin(pitch);
  camera.position.set(dist * cp * sy, dist * sp, dist * cp * cy);
  camera.lookAt(0, 0, 0);
}

// Map speed in [MIN_SPEED, MAX_SPEED] to a cool→warm hue (blue → orange/red).
function speedColor(speed, target) {
  const t = Math.min(1, Math.max(0, (speed - MIN_SPEED) / (MAX_SPEED - MIN_SPEED)));
  // Hue 0.6 (blue) → 0.02 (warm red/orange). Saturation high, lightness mid-bright.
  const h = 0.6 - 0.58 * t;
  target.setHSL(h, 0.85, 0.55 + 0.1 * t);
}

function tick({ dt, scene, camera, renderer, width, height, input }) {
  if (dt > 0.05) dt = 0.05;

  // --- camera orbit -------------------------------------------------------
  input.consumeClicks(); // drain
  if (input.mouseDown) {
    if (!isDragging) {
      isDragging = true;
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
    } else {
      const dx = input.mouseX - lastMouseX;
      const dy = input.mouseY - lastMouseY;
      camOrbit.yaw -= dx * 0.005;
      camOrbit.pitch -= dy * 0.005;
      camOrbit.pitch = Math.max(-1.3, Math.min(1.3, camOrbit.pitch));
      lastMouseX = input.mouseX;
      lastMouseY = input.mouseY;
    }
    idleTimer = 0;
  } else {
    isDragging = false;
    idleTimer += dt;
    if (idleTimer > 1.0) {
      camOrbit.yaw += dt * 0.12;
    }
  }
  positionCamera(camera);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  renderer.setSize(width, height, false);

  // --- predator: drop a point in front of the camera while mouse held ----
  predator.active = input.mouseDown;
  if (predator.active) {
    camera.getWorldDirection(_camFwd);
    // Place ~18 units in front of camera; clamp inside box for sanity.
    const depth = 22;
    predator.pos.copy(camera.position).addScaledVector(_camFwd, depth);
    predator.pos.x = Math.max(-HALF, Math.min(HALF, predator.pos.x));
    predator.pos.y = Math.max(-HALF, Math.min(HALF, predator.pos.y));
    predator.pos.z = Math.max(-HALF, Math.min(HALF, predator.pos.z));
    predator.mesh.position.copy(predator.pos);
    predator.mesh.visible = true;
  } else {
    predator.mesh.visible = false;
  }

  // --- flocking -----------------------------------------------------------
  // O(n^2). At n=120 this is ~14k pair-iterations per frame: comfortable.
  for (let i = 0; i < N; i++) {
    const b = boids[i];
    _sep.set(0, 0, 0);
    _ali.set(0, 0, 0);
    _coh.set(0, 0, 0);
    let nSep = 0, nAli = 0, nCoh = 0;

    for (let j = 0; j < N; j++) {
      if (j === i) continue;
      const o = boids[j];
      // Toroidal shortest-path delta.
      const dx = wrapDelta(o.pos.x - b.pos.x);
      const dy = wrapDelta(o.pos.y - b.pos.y);
      const dz = wrapDelta(o.pos.z - b.pos.z);
      const d2 = dx * dx + dy * dy + dz * dz;

      if (d2 < R_ALIGN2 && d2 > 0.0001) {
        _ali.x += o.vel.x; _ali.y += o.vel.y; _ali.z += o.vel.z;
        nAli++;
      }
      if (d2 < R_COH2 && d2 > 0.0001) {
        // Cohesion target: my pos + delta (the wrapped neighbor location).
        _coh.x += dx; _coh.y += dy; _coh.z += dz;
        nCoh++;
      }
      if (d2 < R_SEP2 && d2 > 0.0001) {
        // Separation: away from neighbor, weighted 1/d.
        const inv = 1 / Math.sqrt(d2);
        _sep.x -= dx * inv / Math.sqrt(d2);
        _sep.y -= dy * inv / Math.sqrt(d2);
        _sep.z -= dz * inv / Math.sqrt(d2);
        nSep++;
      }
    }

    _force.set(0, 0, 0);

    if (nSep > 0) {
      _force.addScaledVector(_sep, W_SEP * 60);
    }
    if (nAli > 0) {
      _ali.multiplyScalar(1 / nAli);
      _ali.x -= b.vel.x; _ali.y -= b.vel.y; _ali.z -= b.vel.z;
      _force.addScaledVector(_ali, W_ALIGN);
    }
    if (nCoh > 0) {
      _coh.multiplyScalar(1 / nCoh); // average delta to neighbors' COM
      _force.addScaledVector(_coh, W_COH);
    }

    // Predator flee: strong repulsion within R_FLEE.
    if (predator.active) {
      const pdx = b.pos.x - predator.pos.x;
      const pdy = b.pos.y - predator.pos.y;
      const pdz = b.pos.z - predator.pos.z;
      const pd2 = pdx * pdx + pdy * pdy + pdz * pdz;
      if (pd2 < R_FLEE2 && pd2 > 0.0001) {
        const pd = Math.sqrt(pd2);
        const falloff = 1 - pd / R_FLEE;
        const s = W_FLEE * 40 * falloff / pd;
        _force.x += pdx * s;
        _force.y += pdy * s;
        _force.z += pdz * s;
      }
    }

    // Clamp force magnitude.
    const fm2 = _force.x * _force.x + _force.y * _force.y + _force.z * _force.z;
    if (fm2 > MAX_FORCE * MAX_FORCE) {
      const fm = Math.sqrt(fm2);
      _force.multiplyScalar(MAX_FORCE / fm);
    }

    b.vel.addScaledVector(_force, dt);

    // Clamp speed.
    const v2 = b.vel.lengthSq();
    if (v2 > MAX_SPEED * MAX_SPEED) {
      b.vel.multiplyScalar(MAX_SPEED / Math.sqrt(v2));
    } else if (v2 < MIN_SPEED * MIN_SPEED) {
      // Don't let boids stall — flocks have minimum cruise speed.
      const v = Math.sqrt(v2);
      if (v > 1e-4) b.vel.multiplyScalar(MIN_SPEED / v);
      else b.vel.set((Math.random() - 0.5) * MIN_SPEED, (Math.random() - 0.5) * MIN_SPEED, (Math.random() - 0.5) * MIN_SPEED);
    }

    // Integrate.
    b.pos.x += b.vel.x * dt;
    b.pos.y += b.vel.y * dt;
    b.pos.z += b.vel.z * dt;

    // Toroidal wrap.
    if (b.pos.x >  HALF) b.pos.x -= BOX; else if (b.pos.x < -HALF) b.pos.x += BOX;
    if (b.pos.y >  HALF) b.pos.y -= BOX; else if (b.pos.y < -HALF) b.pos.y += BOX;
    if (b.pos.z >  HALF) b.pos.z -= BOX; else if (b.pos.z < -HALF) b.pos.z += BOX;

    // Orient cone along velocity. ConeGeometry's apex is +Y; align _up to vel.
    b.mesh.position.copy(b.pos);
    const speed = Math.sqrt(b.vel.lengthSq());
    if (speed > 1e-4) {
      _diff.copy(b.vel).multiplyScalar(1 / speed);
      _q.setFromUnitVectors(_up, _diff);
      b.mesh.quaternion.copy(_q);
    }

    // Tint by speed.
    speedColor(speed, b.mat.color);
  }

  renderer.render(scene, camera);
}

Comments (0)

Log in to comment.