|
<!DOCTYPE html> |
|
<html> |
|
<head> |
|
<style> |
|
body { |
|
font-family: sans-serif; |
|
margin: 0; |
|
} |
|
|
|
#controls { |
|
padding: 5px; |
|
position: absolute; |
|
text-align: center; |
|
width: 100%; |
|
bottom: 0px; |
|
} |
|
|
|
#controls .control { |
|
background: rgba(255, 255, 255, .8); |
|
display: inline-block; |
|
padding: 10px; |
|
text-align: left; |
|
width: 200px; |
|
} |
|
|
|
#controls .control .range input, #controls .control .range .value { |
|
display: inline-block; |
|
} |
|
|
|
#controls .control .range .value { |
|
font-size: 12px; |
|
margin-top: -5px; |
|
vertical-align: middle; |
|
} |
|
|
|
#controls .control .description { |
|
font-size: 12px; |
|
} |
|
|
|
#stats { |
|
font-size: 14px; |
|
padding: 5px; |
|
position: absolute; |
|
right: 0px; |
|
} |
|
</style> |
|
</head> |
|
<div id="controls"> |
|
<div class="control"> |
|
<div class="title">Alignment</div> |
|
<div class="range"> |
|
<input data-parameter="alignment" type="range" min="0" max="1" value="1" step=".1" /> |
|
<div class="value">1.0</div> |
|
</div> |
|
<div class="description">Steer towards the average heading of local flockmates</div> |
|
</div> |
|
<div class="control"> |
|
<div class="title">Cohesion</div> |
|
<div class="range"> |
|
<input data-parameter="cohesion" type="range" min="0" max="1" value="1" step=".1" /> |
|
<div class="value">1.0</div> |
|
</div> |
|
<div class="description">Steer towards the average position of local flockmates</div> |
|
</div> |
|
<div class="control"> |
|
<div class="title">Separation</div> |
|
<div class="range"> |
|
<input data-parameter="separation" type="range" min="0" max="1" value="1" step=".1" /> |
|
<div class="value">1.0</div> |
|
</div> |
|
<div class="description">Steer to avoid crowding local flockmates</div> |
|
</div> |
|
<div class="control"> |
|
<div class="title">Perception</div> |
|
<div class="range"> |
|
<input data-parameter="perception" type="range" min="1" max="100" value="20" step="1" /> |
|
<div class="value">20</div> |
|
</div> |
|
<div class="description">Maximum distance of other boids to consider</div> |
|
</div> |
|
</div> |
|
<div id="stats"></div> |
|
<div id="simulation"></div> |
|
<body> |
|
<script src="https://unpkg.com/[email protected]/rbush.min.js"></script> |
|
<script src="vecmath.js"></script> |
|
<script src="https://d3js.org/d3-color.v1.min.js"></script> |
|
<script src="https://d3js.org/d3-interpolate.v1.min.js"></script> |
|
<script src="https://d3js.org/d3-scale-chromatic.v1.min.js"></script> |
|
<script src="https://d3js.org/d3-array.v2.min.js"></script> |
|
<script> |
|
class BoidBush extends rbush { |
|
toBBox(boid) { return {minX: boid.pos[0], minY: boid.pos[1], maxX: boid.pos[0], maxY: boid.pos[1]}; } |
|
compareMinX(a, b) { return a.pos[0] - b.pos[0]; } |
|
compareMinY(a, b) { return a.pos[1] - b.pos[1]; } |
|
} |
|
|
|
class Boids { |
|
constructor(){ |
|
this._width = innerWidth; |
|
this._height = innerHeight; |
|
this._perception = 20; |
|
this._alignment = 1; |
|
this._cohesion = 1; |
|
this._separation = 1; |
|
this._maxSpeed = 4; |
|
this.maxForce = 0.2; |
|
this.flock = []; |
|
this.tree = new BoidBush(); |
|
} |
|
|
|
alignment(n){ |
|
if (isFinite(n)){ |
|
this._alignment = n; |
|
for (let i = 0, l = this.flock.length; i < l; i++){ |
|
this.flock[i]._alignment = n; |
|
} |
|
return this; |
|
} |
|
else { |
|
return this._alignment; |
|
} |
|
} |
|
|
|
cohesion(n){ |
|
if (isFinite(n)){ |
|
this._cohesion = n; |
|
for (let i = 0, l = this.flock.length; i < l; i++){ |
|
this.flock[i]._cohesion = n; |
|
} |
|
return this; |
|
} |
|
else { |
|
return this._cohesion; |
|
} |
|
} |
|
|
|
perception(n){ |
|
if (isFinite(n)){ |
|
this._perception = n; |
|
for (let i = 0, l = this.flock.length; i < l; i++){ |
|
this.flock[i]._perception = n; |
|
} |
|
return this; |
|
} |
|
else { |
|
return this._perception; |
|
} |
|
} |
|
|
|
separation(n){ |
|
if (isFinite(n)){ |
|
this._separation = n; |
|
for (let i = 0, l = this.flock.length; i < l; i++){ |
|
this.flock[i]._separation = n; |
|
} |
|
return this; |
|
} |
|
else { |
|
return this._separation; |
|
} |
|
} |
|
|
|
width(n){ |
|
if (isFinite(n)){ |
|
this._width = n; |
|
for (let i = 0, l = this.flock.length; i < l; i++){ |
|
this.flock[i]._width = n; |
|
} |
|
return this; |
|
} |
|
else { |
|
return this._width; |
|
} |
|
} |
|
|
|
height(n){ |
|
if (isFinite(n)){ |
|
this._height = n; |
|
for (let i = 0, l = this.flock.length; i < l; i++){ |
|
this.flock[i]._height = n; |
|
} |
|
return this; |
|
} |
|
else { |
|
return this._height; |
|
} |
|
} |
|
|
|
maxSpeed(n){ |
|
if (isFinite(n)){ |
|
this._maxSpeed = n; |
|
for (let i = 0, l = this.flock.length; i < l; i++){ |
|
this.flock[i]._maxSpeed = n; |
|
} |
|
return this; |
|
} |
|
else { |
|
return this._maxSpeed; |
|
} |
|
} |
|
|
|
add(opts){ |
|
this.flock.push(new Boid(this, opts)); |
|
|
|
return this; |
|
} |
|
|
|
each(fn){ |
|
for (let i = 0, l = this.flock.length; i < l; i++){ |
|
fn(this.flock[i], i, this.flock); |
|
} |
|
return this; |
|
} |
|
|
|
tick(){ |
|
this.tree.clear(); |
|
this.tree.load(this.flock); |
|
this.each(boid => boid.update()); |
|
return this; |
|
} |
|
} |
|
|
|
class Boid { |
|
constructor(Boids, opts){ |
|
Object.assign(this, Boids); |
|
Object.assign(this, opts); |
|
|
|
// Angle, position, and speed can be assigned by the user. |
|
this.ang = this.ang || 2 * Math.random() * Math.PI; |
|
this.pos = this.pos || [ |
|
Math.random() * this._width, |
|
Math.random() * this._height |
|
]; |
|
this.speed = this.speed || 1; |
|
|
|
const obj = { |
|
pos: this.pos, |
|
ang: this.ang, |
|
speed: this.speed, |
|
vel: vecmath.sub( |
|
vecmath.trans(this.pos, this.ang, this.speed), |
|
this.pos |
|
), |
|
acc: [0, 0], |
|
id: this.flock.length |
|
}; |
|
|
|
Object.assign(this, obj); |
|
} |
|
|
|
update(){ |
|
// To learn more about this math, see https://www.youtube.com/watch?v=mhjuuHl6qHM |
|
const prev = { ...this }; |
|
|
|
let alignment = [0, 0], |
|
cohesion = [0, 0], |
|
separation = [0, 0], |
|
n = 0, |
|
candidates = this.tree.search({ |
|
minX: this.pos[0] - this._perception, |
|
minY: this.pos[1] - this._perception, |
|
maxX: this.pos[0] + this._perception, |
|
maxY: this.pos[1] + this._perception, |
|
}); |
|
|
|
for (let i = 0, l = candidates.length; i < l; i ++){ |
|
const that = candidates[i], |
|
dist = vecmath.dist(this.pos, that.pos); |
|
|
|
if (this.id !== that.id && dist < this._perception){ |
|
alignment = vecmath.add(alignment, that.vel); |
|
cohesion = vecmath.add(cohesion, that.pos); |
|
const diff = vecmath.div( |
|
vecmath.sub(this.pos, that.pos), |
|
Math.max(dist, 1e-6) |
|
); |
|
separation = vecmath.add(separation, diff); |
|
n++; |
|
} |
|
} |
|
|
|
if (n > 0){ |
|
alignment = vecmath.div(alignment, n); |
|
alignment = vecmath.setMag(alignment, this._maxSpeed); |
|
alignment = vecmath.sub(alignment, this.vel); |
|
alignment = vecmath.limit(alignment, this.maxForce); |
|
|
|
cohesion = vecmath.div(cohesion, n); |
|
cohesion = vecmath.sub(cohesion, this.pos); |
|
cohesion = vecmath.setMag(cohesion, this._maxSpeed); |
|
cohesion = vecmath.sub(cohesion, this.vel); |
|
cohesion = vecmath.limit(cohesion, this.maxForce); |
|
|
|
separation = vecmath.div(separation, n); |
|
separation = vecmath.setMag(separation, this._maxSpeed); |
|
separation = vecmath.sub(separation, this.vel); |
|
separation = vecmath.limit(separation, this.maxForce); |
|
} |
|
|
|
alignment = vecmath.mult(alignment, this._alignment); |
|
cohesion = vecmath.mult(cohesion, this._cohesion); |
|
separation = vecmath.mult(separation, this._separation); |
|
|
|
this.acc = vecmath.add(this.acc, alignment); |
|
this.acc = vecmath.add(this.acc, cohesion); |
|
this.acc = vecmath.add(this.acc, separation); |
|
|
|
this.pos = vecmath.add(this.pos, this.vel); |
|
this.vel = vecmath.add(this.vel, this.acc); |
|
this.vel = vecmath.limit(this.vel, this._maxSpeed); |
|
|
|
if (this.pos[0] > this._width) this.pos[0] = 0; |
|
if (this.pos[0] < 0) this.pos[0] = this._width; |
|
if (this.pos[1] > this._height) this.pos[1] = 0; |
|
if (this.pos[1] < 0) this.pos[1] = this._height; |
|
|
|
this.ang = vecmath.ang(prev.pos, this.pos); |
|
this.speed = vecmath.dist(prev.pos, this.pos); |
|
|
|
this.acc = vecmath.mult(this.acc, 0); |
|
} |
|
} |
|
|
|
// Initiate some boids |
|
const myBoids = (_ => { |
|
const sim = new Boids; |
|
|
|
// Add 500 boids |
|
for (let i = 0; i < 500; i++) { |
|
sim.add(); |
|
} |
|
|
|
return sim; |
|
})(); |
|
|
|
// Draw the simulation |
|
const wrapper = document.getElementById("simulation"), |
|
canvas = document.createElement("canvas"), |
|
context = canvas.getContext("2d"); |
|
|
|
canvas.width = myBoids.width(); |
|
canvas.height = myBoids.height(); |
|
wrapper.appendChild(canvas); |
|
|
|
// Some variables for stats |
|
let stats = document.querySelector("#stats"), |
|
startTime = (new Date()).getTime(), |
|
seconds = 0, |
|
secondsRounded = 0, |
|
ticks = 0, |
|
speeds = [0]; |
|
|
|
function tick(){ |
|
requestAnimationFrame(tick); |
|
context.clearRect(0, 0, myBoids.width(), myBoids.height()); |
|
|
|
// The simulation.tick method advances the simulation one tick |
|
myBoids.tick(); |
|
myBoids.each(boid => { |
|
const a = vecmath.trans(boid.pos, boid.ang - Math.PI * .5, 3), |
|
b = vecmath.trans(boid.pos, boid.ang, 9), |
|
c = vecmath.trans(boid.pos, boid.ang + Math.PI * .5, 3); |
|
|
|
context.beginPath(); |
|
context.moveTo(...a); |
|
context.lineTo(...b); |
|
context.lineTo(...c); |
|
context.lineTo(...a); |
|
|
|
const color = d3.interpolateRdPu(.6 * myBoids.maxSpeed() / boid.speed); |
|
context.strokeStyle = color; |
|
context.fillStyle = d3.color(color).brighter(2); |
|
|
|
context.fill(); |
|
context.stroke(); |
|
}); |
|
|
|
seconds = ((new Date()).getTime() - startTime) / 1e3; |
|
ticks++; |
|
stats.innerHTML = `${myBoids.flock.length} boids at ${d3.mean(speeds)} frames/sec.`; |
|
|
|
if (Math.round(seconds) !== secondsRounded){ |
|
speeds.push(ticks); |
|
if (speeds.length > 2) speeds.shift(); |
|
secondsRounded = Math.round(seconds); |
|
ticks = 0; |
|
} |
|
} |
|
tick(); |
|
|
|
// Logic for adding boids |
|
let holding = false; |
|
canvas.addEventListener("mousedown", e => { holding = true; addBoidOnEvent(e); }); |
|
canvas.addEventListener("mouseup", e => { holding = false }); |
|
canvas.addEventListener("mousemove", e => { if (holding) addBoidOnEvent(e); }); |
|
|
|
function addBoidOnEvent(e){ |
|
myBoids.add({ |
|
pos: [e.pageX, e.pageY] |
|
}); |
|
} |
|
|
|
// Logic for resizing |
|
addEventListener("resize", _ => { |
|
myBoids.width(innerWidth); |
|
myBoids.height(innerHeight); |
|
canvas.width = myBoids.width(); |
|
canvas.height = myBoids.height(); |
|
}); |
|
|
|
// Logic for using the sliders |
|
const controls = document.querySelectorAll(".control"); |
|
controls.forEach(control => { |
|
control.addEventListener("input", _ => { |
|
const t = _.target, |
|
v = +t.value; |
|
|
|
t.nextElementSibling.innerHTML = v; |
|
myBoids[t.dataset.parameter](v); |
|
}); |
|
}); |
|
|
|
</script> |
|
</body> |
|
</html> |