3
Boids 3D — Reynolds Flock
drag to orbit · hold mouse to scatter the flock
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.