// Boids 3D
//
// Reynolds's flocking algorithm extended to three dimensions.
// Agents wrap around a [-1,1]^3 bounding cube.
// Rendered as instanced cone meshes, each rotated to face its velocity vector.
// Orbit-drag the camera to watch swarms form, turn, and split from any angle.
import * as THREE from 'three';
export const WARMUP = { framesBeforeReady: 480 };
export const PARAMS = {
count: { value: 250, min: 30, max: 800, step: 10, label: "Boid Count", folder: "Structure", rebuildOnChange: true },
separationRadius:{ value: 0.12, min: 0.02, max: 0.5, step: 0.005, label: "Separation Radius", folder: "Behavior" },
alignRadius: { value: 0.18, min: 0.05, max: 1.2, step: 0.005, label: "Align/Cohesion Radius", folder: "Behavior" },
separation: { value: 1.85, min: 0, max: 4, step: 0.05, label: "Separation Weight", folder: "Behavior" },
alignment: { value: 0.95, min: 0, max: 4, step: 0.05, label: "Alignment Weight", folder: "Behavior" },
cohesion: { value: 0, min: 0, max: 4, step: 0.05, label: "Cohesion Weight", folder: "Behavior" },
maxSpeed: { value: 0.02, min: 0.002, max: 0.2, step: 0.001, label: "Max Speed", folder: "Behavior" },
minSpeed: { value: 0.008, min: 0, max: 0.1, step: 0.0005, label: "Min Speed", folder: "Behavior" },
maxForce: { value: 0.002, min: 0.0005, max: 0.02, step: 0.0001, label: "Max Steer Force", folder: "Behavior" },
noise: { value: 0.004, min: 0, max: 0.05, step: 0.0005, label: "Random Noise", folder: "Behavior" },
size: { value: 0.04, min: 0.01, max: 0.15, step: 0.005, label: "Boid Size", folder: "Appearance" },
hue: { value: 200, min: 0, max: 360, step: 1, label: "Hue", folder: "Appearance" },
};
export const SHARE = {
bookmarked: ["count", "separationRadius", "alignRadius", "separation", "alignment", "cohesion", "maxSpeed", "minSpeed", "noise", "size", "hue"],
};
// Wrap a value into [-bound, bound]
function wrap(v, bound) {
const range = bound * 2;
return ((v + bound) % range + range) % range - bound;
}
// Clamp vector magnitude to max
function limitVec3(x, y, z, max) {
const mag = Math.sqrt(x * x + y * y + z * z);
if (mag > max && mag > 0) {
const s = max / mag;
return [x * s, y * s, z * s];
}
return [x, y, z];
}
// Reynolds steering — produce a bounded steer toward a desired velocity.
function steer3(dx, dy, dz, vx, vy, vz, maxSpeed, maxForce) {
const dmag = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dmag === 0) return [0, 0, 0];
let sx = (dx / dmag) * maxSpeed - vx;
let sy = (dy / dmag) * maxSpeed - vy;
let sz = (dz / dmag) * maxSpeed - vz;
const smag = Math.sqrt(sx * sx + sy * sy + sz * sz);
if (smag > maxForce && smag > 0) {
const f = maxForce / smag;
sx *= f; sy *= f; sz *= f;
}
return [sx, sy, sz];
}
export function sceneSetup(THREE, scene, camera, renderer, params, seed) {
scene.background = new THREE.Color(0x07080a);
camera.position.set(0, 0, 3.5);
camera.lookAt(0, 0, 0);
const n = params.count | 0;
// Per-boid state arrays
const pos = new Float32Array(n * 3); // positions
const vel = new Float32Array(n * 3); // velocities
const maxSpd = params.maxSpeed;
// Use a seeded PRNG (the runtime sets seed deterministically per build).
// We don't have direct access to a seeded RNG in scene mode, so use Math.random()
// here at setup time only — this makes initial layout effectively per-page-load,
// but the simulation itself is the visible state.
for (let i = 0; i < n; i++) {
const b = i * 3;
pos[b] = Math.random() * 2 - 1;
pos[b + 1] = Math.random() * 2 - 1;
pos[b + 2] = Math.random() * 2 - 1;
// Random initial velocity at near-maxSpeed
const angle = Math.random() * Math.PI * 2;
const elev = (Math.random() - 0.5) * Math.PI;
const spd = maxSpd * (0.5 + Math.random() * 0.5);
vel[b] = Math.cos(elev) * Math.cos(angle) * spd;
vel[b + 1] = Math.sin(elev) * spd;
vel[b + 2] = Math.cos(elev) * Math.sin(angle) * spd;
}
// Instanced cone geometry: cone points along +Y
// ConeGeometry(radius, height, radialSegments)
const coneGeo = new THREE.ConeGeometry(0.5, 1.5, 6);
// Reorient cone to point along +Z for easier quaternion math later
// (we'll use quaternion from +Y to velocity direction)
const hue = params.hue / 360;
const mat = new THREE.MeshPhongMaterial({
color: new THREE.Color().setHSL(hue, 0.75, 0.55),
shininess: 40,
});
const mesh = new THREE.InstancedMesh(coneGeo, mat, n);
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
scene.add(mesh);
// Wireframe bounding box for spatial context
const boxGeo = new THREE.EdgesGeometry(new THREE.BoxGeometry(2, 2, 2));
const wireMat = new THREE.LineBasicMaterial({ color: 0x333344, transparent: true, opacity: 0.4 });
const wireBox = new THREE.LineSegments(boxGeo, wireMat);
scene.add(wireBox);
// Ambient + directional lighting
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.0);
dirLight.position.set(1, 2, 3);
scene.add(dirLight);
// Reusable objects for matrix computation
const _up = new THREE.Vector3(0, 1, 0);
const _dir = new THREE.Vector3();
const _quat = new THREE.Quaternion();
const _scale = new THREE.Vector3();
const _matrix = new THREE.Matrix4();
return { pos, vel, n, mesh, mat, _up, _dir, _quat, _scale, _matrix };
}
export function sceneAnimate(THREE, scene, camera, state, params, time, delta) {
const { pos, vel, n, mesh, mat, _up, _dir, _quat, _scale, _matrix } = state;
// Update material color from hue param
mat.color.setHSL(params.hue / 360, 0.75, 0.55);
const maxSpd = params.maxSpeed;
const minSpd = params.minSpeed;
const maxForce = params.maxForce;
const noiseAmt = params.noise || 0;
const sepR = params.separationRadius;
const sepR2 = sepR * sepR;
const algR = params.alignRadius;
const algR2 = algR * algR;
const sepW = params.separation;
const alignW = params.alignment;
const cohW = params.cohesion;
const BOUND = 1.0;
// Temp arrays for new velocities
const newVx = new Float32Array(n);
const newVy = new Float32Array(n);
const newVz = new Float32Array(n);
for (let i = 0; i < n; i++) {
const ib = i * 3;
const ix = pos[ib], iy = pos[ib + 1], iz = pos[ib + 2];
const ivx = vel[ib], ivy = vel[ib + 1], ivz = vel[ib + 2];
let sepX = 0, sepY = 0, sepZ = 0; // direction-summed away-from-neighbors
let algX = 0, algY = 0, algZ = 0; // sum of neighbor velocities
let dispX = 0, dispY = 0, dispZ = 0; // sum of toroidal displacements
let sepCount = 0;
let algCount = 0;
for (let j = 0; j < n; j++) {
if (i === j) continue;
const jb = j * 3;
// Toroidal delta
let dx = pos[jb] - ix;
let dy = pos[jb + 1] - iy;
let dz = pos[jb + 2] - iz;
// Wrap to shortest path in cube
if (dx > BOUND) dx -= BOUND * 2;
if (dx < -BOUND) dx += BOUND * 2;
if (dy > BOUND) dy -= BOUND * 2;
if (dy < -BOUND) dy += BOUND * 2;
if (dz > BOUND) dz -= BOUND * 2;
if (dz < -BOUND) dz += BOUND * 2;
const dist2 = dx * dx + dy * dy + dz * dz;
if (dist2 < sepR2 && dist2 > 0) {
const dist = Math.sqrt(dist2);
sepX += -dx / dist / dist;
sepY += -dy / dist / dist;
sepZ += -dz / dist / dist;
sepCount++;
}
if (dist2 < algR2) {
algX += vel[jb];
algY += vel[jb + 1];
algZ += vel[jb + 2];
dispX += dx;
dispY += dy;
dispZ += dz;
algCount++;
}
}
let fx = 0, fy = 0, fz = 0;
if (sepCount > 0) {
const [sx, sy, sz] = steer3(sepX, sepY, sepZ, ivx, ivy, ivz, maxSpd, maxForce);
fx += sx * sepW;
fy += sy * sepW;
fz += sz * sepW;
}
if (algCount > 0) {
// Alignment: desired = average neighbor velocity direction.
const avx = algX / algCount;
const avy = algY / algCount;
const avz = algZ / algCount;
const [ax, ay, az] = steer3(avx, avy, avz, ivx, ivy, ivz, maxSpd, maxForce);
fx += ax * alignW;
fy += ay * alignW;
fz += az * alignW;
// Cohesion: desired = direction toward neighbor centroid.
const cx = dispX / algCount;
const cy = dispY / algCount;
const cz = dispZ / algCount;
const [csx, csy, csz] = steer3(cx, cy, cz, ivx, ivy, ivz, maxSpd, maxForce);
fx += csx * cohW;
fy += csy * cohW;
fz += csz * cohW;
}
// Random jitter — gentle wobble.
fx += (Math.random() - 0.5) * noiseAmt;
fy += (Math.random() - 0.5) * noiseAmt;
fz += (Math.random() - 0.5) * noiseAmt;
let nvx = ivx + fx;
let nvy = ivy + fy;
let nvz = ivz + fz;
[newVx[i], newVy[i], newVz[i]] = limitVec3(nvx, nvy, nvz, maxSpd);
// Enforce minimum speed floor.
const spd3 = Math.sqrt(newVx[i] * newVx[i] + newVy[i] * newVy[i] + newVz[i] * newVz[i]);
if (spd3 < minSpd && spd3 > 0) {
const s = minSpd / spd3;
newVx[i] *= s;
newVy[i] *= s;
newVz[i] *= s;
} else if (spd3 === 0) {
const angle = Math.random() * Math.PI * 2;
const elev = (Math.random() - 0.5) * Math.PI;
newVx[i] = Math.cos(elev) * Math.cos(angle) * minSpd;
newVy[i] = Math.sin(elev) * minSpd;
newVz[i] = Math.cos(elev) * Math.sin(angle) * minSpd;
}
}
const coneSize = params.size;
_scale.set(coneSize, coneSize, coneSize);
for (let i = 0; i < n; i++) {
const ib = i * 3;
vel[ib] = newVx[i];
vel[ib + 1] = newVy[i];
vel[ib + 2] = newVz[i];
pos[ib] = wrap(pos[ib] + vel[ib], BOUND);
pos[ib + 1] = wrap(pos[ib + 1] + vel[ib + 1], BOUND);
pos[ib + 2] = wrap(pos[ib + 2] + vel[ib + 2], BOUND);
// Rotate cone (+Y axis) to face velocity direction
const vx = vel[ib], vy = vel[ib + 1], vz = vel[ib + 2];
const vMag = Math.sqrt(vx * vx + vy * vy + vz * vz);
if (vMag > 0.00001) {
_dir.set(vx / vMag, vy / vMag, vz / vMag);
} else {
_dir.set(0, 1, 0);
}
_quat.setFromUnitVectors(_up, _dir);
_matrix.compose(
new THREE.Vector3(pos[ib], pos[ib + 1], pos[ib + 2]),
_quat,
_scale,
);
mesh.setMatrixAt(i, _matrix);
}
mesh.instanceMatrix.needsUpdate = true;
}