Em 1986, Craig Reynolds publicou um modelo surpreendentemente simples para simular o voo de bandos de pássaros, cardumes de peixes e enxames de insetos. Nenhuma partícula conhece o plano global: cada uma age apenas com base nos vizinhos imediatos. O comportamento coletivo — formação, divisão, reunificação — emerge espontaneamente dessas três regras locais.
O modelo Boids
Cada partícula $i$ tem posição $\vec{x}_i$ e velocidade $\vec{v}_i$. A cada passo de tempo, três forças são calculadas a partir dos vizinhos dentro do raio $r_v$:
1. Separação — afasta-se de vizinhos muito próximos ($d_{ij} < r_s$):
$$\vec{s}_i = \frac{1}{n_s} \sum_{\substack{j \neq i \\ d_{ij} < r_s}} \frac{\vec{x}_i - \vec{x}_j}{d_{ij}}$$
2. Alinhamento — ajusta a velocidade à média dos vizinhos:
$$\vec{a}_i = \frac{1}{|N_i|} \sum_{j \in N_i} \vec{v}_j$$
3. Coesão — move-se em direção ao centro de massa dos vizinhos:
$$\vec{c}_i = \frac{1}{|N_i|} \sum_{j \in N_i} \vec{x}_j - \vec{x}_i$$
Força de esterçamento
Cada regra produz uma direção desejada $\hat{u}$. A força de esterçamento correspondente é:
$$\vec{f} = \text{clip}\!\left(\hat{u}\, v_\text{max} - \vec{v}_i,\; f_\text{max}\right)$$
onde $\text{clip}(\vec{w}, f_\text{max})$ limita a magnitude: $\min\!\left(1,\, \tfrac{f_\text{max}}{|\vec{w}|}\right)\vec{w}$. Isso impede acelerações abruptas, produzindo curvas suaves.
A aceleração total é a soma ponderada:
$$\vec{F}_i = w_s\,\vec{f}_\text{sep} + w_a\,\vec{f}_\text{ali} + w_c\,\vec{f}_\text{coh}$$
com pesos $w_s = 2{,}0$, $w_a = 1{,}0$, $w_c = 0{,}8$ nesta simulação.
Integração numérica
A cada frame (passo de tempo $\Delta t = 1$):
$$\vec{v}_{t+1} = \text{clip}(\vec{v}_t + \vec{F}_i,\; v_\text{max}), \qquad \vec{x}_{t+1} = \vec{x}_t + \vec{v}_{t+1}$$
Trata-se de integração de Euler explícita — simples e eficiente para este propósito.
Repulsão das bordas
Para evitar que as partículas saiam da tela, aplica-se uma força proporcional à proximidade da borda. Se a distância da partícula à borda é $d_b < m$ (margem):
$$f_b = f_0 \left(1 - \frac{d_b}{m}\right)$$
A força cresce linearmente até zero na margem e atinge $f_0$ encostada na borda.
Detecção de grupos
A cada frame, um algoritmo de busca em largura (BFS) encontra as componentes conexas: dois partículas pertencem ao mesmo grupo se $d_{ij} < r_v$. Quando exatamente dois grupos se formam, as forças de alinhamento e coesão passam a agir apenas dentro de cada grupo — o que impede a fusão e faz os grupos "repelirem" um ao outro.
Simulação interativa
Código completo
Salve como boids.html e abra no navegador.
<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Simulação de Partículas Boids</title>
<style>
html, body { height: 100%; margin: 0; overflow: hidden; }
body {
background-color: #000;
display: flex;
flex-direction: column;
align-items: center;
height: 100vh;
box-sizing: border-box;
}
svg {
border: 1px solid #222;
background-color: #050505;
width: 90%;
flex-grow: 1;
margin-bottom: 5vh;
}
#controls {
margin: 20px 0;
background: rgba(40,40,40,0.8);
padding: 10px;
border-radius: 8px;
color: #eee;
font-family: Arial, sans-serif;
font-size: 14px;
display: flex;
align-items: center;
gap: 10px;
}
#controls input {
width: 50px; padding: 5px;
border-radius: 4px; border: 1px solid #555;
background: #222; color: #eee;
}
#controls button {
padding: 5px 10px; border-radius: 4px;
border: none; background: #007bff;
color: white; cursor: pointer;
}
#controls button:hover { background: #0056b3; }
</style>
</head>
<body>
<div id="controls">
<label for="particleCountInput">Partículas (5-40):</label>
<input type="number" id="particleCountInput" min="5" max="40" value="30">
<button id="reloadButton">Recarregar</button>
</div>
<svg id="particleSVG"></svg>
<script>
const svg = document.getElementById('particleSVG');
const particleCountInput = document.getElementById('particleCountInput');
const reloadButton = document.getElementById('reloadButton');
function getParticleCountFromURL() {
const urlParams = new URLSearchParams(window.location.search);
let count = parseInt(urlParams.get('particulas'), 10);
if (isNaN(count) || count < 5) count = 30;
else if (count > 40) count = 40;
return count;
}
const numParticles = getParticleCountFromURL();
particleCountInput.value = numParticles;
reloadButton.addEventListener('click', () => {
let newCount = parseInt(particleCountInput.value, 10);
if (isNaN(newCount) || newCount < 5) newCount = 5;
else if (newCount > 40) newCount = 40;
window.location.href = window.location.pathname + `?particulas=${newCount}`;
});
let svgWidth, svgHeight;
function setupSvgCanvas() {
const svgRect = svg.getBoundingClientRect();
svgWidth = svgRect.width;
svgHeight = svgRect.height;
svg.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
init();
}
window.addEventListener('resize', setupSvgCanvas);
const NEIGHBOR_RADIUS = 70;
const DESIRED_SEPARATION = 25;
const MAX_SPEED = 2.8;
const MAX_FORCE = 0.16;
const SEPARATION_WEIGHT = 2.0;
const ALIGNMENT_WEIGHT = 1.0;
const COHESION_WEIGHT = 0.8;
const BORDER_REPULSION_MARGIN = 50;
const BORDER_REPULSION_FORCE = 0.3;
class Particle {
constructor(x, y, color) {
this.x = x; this.y = y;
const initialAngle = Math.random() * 2 * Math.PI;
this.vx = Math.cos(initialAngle) * MAX_SPEED;
this.vy = Math.sin(initialAngle) * MAX_SPEED;
this.ax = 0; this.ay = 0;
this.angle = Math.atan2(this.vy, this.vx);
this.radius = 4 + Math.random() * 1.5;
this.bodyLength = this.radius * 2.8;
this.originalColor = color;
this.color = color;
this.tailAttachLocalX = -this.bodyLength * 0.65;
this.tailLength = 25 + Math.floor(Math.random() * 15);
this.tailPoints = [];
const initialCosA = Math.cos(this.angle);
const initialSinA = Math.sin(this.angle);
for (let i = 0; i < this.tailLength; i++) {
this.tailPoints.push({
x: this.x + (this.tailAttachLocalX - (i+1)*2) * initialCosA,
y: this.y + (this.tailAttachLocalX - (i+1)*2) * initialSinA
});
}
this.group = null;
this.createSVGElements();
}
createSVGElements() {
this.bodyElement = document.createElementNS(svg.namespaceURI, 'path');
this.bodyElement.setAttribute('fill', this.color);
svg.appendChild(this.bodyElement);
this.tailElement = document.createElementNS(svg.namespaceURI, 'path');
this.tailElement.setAttribute('fill', 'none');
this.tailElement.setAttribute('stroke', this.color);
this.tailElement.setAttribute('stroke-linecap', 'round');
this.tailElement.setAttribute('stroke-linejoin', 'round');
svg.appendChild(this.tailElement);
}
applyForce(forceX, forceY) { this.ax += forceX; this.ay += forceY; }
flock(allParticles) {
let sepSumX=0,sepSumY=0,aliSumX=0,aliSumY=0;
let cohSumX=0,cohSumY=0,separationCount=0,alignmentCohesionCount=0;
for (let other of allParticles) {
if (other === this) continue;
const inDifferentGroups = this.group!==null && other.group!==null
&& this.group !== other.group;
const dx = other.x - this.x, dy = other.y - this.y;
const distance = Math.sqrt(dx*dx + dy*dy);
if (distance > 0 && distance < DESIRED_SEPARATION) {
sepSumX += (this.x - other.x) / distance;
sepSumY += (this.y - other.y) / distance;
separationCount++;
}
if (!inDifferentGroups && distance > 0 && distance < NEIGHBOR_RADIUS) {
aliSumX += other.vx; aliSumY += other.vy;
cohSumX += other.x; cohSumY += other.y;
alignmentCohesionCount++;
}
}
let totalSteerX = 0, totalSteerY = 0;
if (separationCount > 0) {
sepSumX /= separationCount; sepSumY /= separationCount;
let mag = Math.sqrt(sepSumX*sepSumX + sepSumY*sepSumY);
if (mag > 0) {
sepSumX = (sepSumX/mag)*MAX_SPEED;
sepSumY = (sepSumY/mag)*MAX_SPEED;
let steerX = sepSumX - this.vx, steerY = sepSumY - this.vy;
let steerMag = Math.sqrt(steerX*steerX + steerY*steerY);
if (steerMag > MAX_FORCE) {
steerX = (steerX/steerMag)*MAX_FORCE;
steerY = (steerY/steerMag)*MAX_FORCE;
}
totalSteerX += steerX * SEPARATION_WEIGHT;
totalSteerY += steerY * SEPARATION_WEIGHT;
}
}
if (alignmentCohesionCount > 0) {
aliSumX /= alignmentCohesionCount; aliSumY /= alignmentCohesionCount;
let magAli = Math.sqrt(aliSumX*aliSumX + aliSumY*aliSumY);
if (magAli > 0) {
aliSumX = (aliSumX/magAli)*MAX_SPEED;
aliSumY = (aliSumY/magAli)*MAX_SPEED;
let steerX = aliSumX - this.vx, steerY = aliSumY - this.vy;
let steerMag = Math.sqrt(steerX*steerX + steerY*steerY);
if (steerMag > MAX_FORCE) {
steerX = (steerX/steerMag)*MAX_FORCE;
steerY = (steerY/steerMag)*MAX_FORCE;
}
totalSteerX += steerX * ALIGNMENT_WEIGHT;
totalSteerY += steerY * ALIGNMENT_WEIGHT;
}
cohSumX /= alignmentCohesionCount; cohSumY /= alignmentCohesionCount;
let desiredCohX = cohSumX - this.x, desiredCohY = cohSumY - this.y;
let magCoh = Math.sqrt(desiredCohX*desiredCohX + desiredCohY*desiredCohY);
if (magCoh > 0) {
desiredCohX = (desiredCohX/magCoh)*MAX_SPEED;
desiredCohY = (desiredCohY/magCoh)*MAX_SPEED;
let steerX = desiredCohX - this.vx, steerY = desiredCohY - this.vy;
let steerMag = Math.sqrt(steerX*steerX + steerY*steerY);
if (steerMag > MAX_FORCE) {
steerX = (steerX/steerMag)*MAX_FORCE;
steerY = (steerY/steerMag)*MAX_FORCE;
}
totalSteerX += steerX * COHESION_WEIGHT;
totalSteerY += steerY * COHESION_WEIGHT;
}
}
this.applyForce(totalSteerX, totalSteerY);
}
applyBorderRepulsion() {
const r = this.radius;
if (this.x - r < BORDER_REPULSION_MARGIN)
this.applyForce(BORDER_REPULSION_FORCE*(1-(this.x-r)/BORDER_REPULSION_MARGIN),0);
if (this.x + r > svgWidth - BORDER_REPULSION_MARGIN)
this.applyForce(-BORDER_REPULSION_FORCE*(1-(svgWidth-this.x-r)/BORDER_REPULSION_MARGIN),0);
if (this.y - r < BORDER_REPULSION_MARGIN)
this.applyForce(0,BORDER_REPULSION_FORCE*(1-(this.y-r)/BORDER_REPULSION_MARGIN));
if (this.y + r > svgHeight - BORDER_REPULSION_MARGIN)
this.applyForce(0,-BORDER_REPULSION_FORCE*(1-(svgHeight-this.y-r)/BORDER_REPULSION_MARGIN));
}
update(allParticles) {
this.flock(allParticles);
this.applyBorderRepulsion();
this.vx += this.ax; this.vy += this.ay;
let speed = Math.sqrt(this.vx*this.vx + this.vy*this.vy);
if (speed > MAX_SPEED) {
this.vx = (this.vx/speed)*MAX_SPEED;
this.vy = (this.vy/speed)*MAX_SPEED;
}
this.x += this.vx; this.y += this.vy;
this.ax = 0; this.ay = 0;
this.angle = Math.atan2(this.vy, this.vx);
const cosA = Math.cos(this.angle), sinA = Math.sin(this.angle);
let targetTailX = this.x + this.tailAttachLocalX * cosA;
let targetTailY = this.y + this.tailAttachLocalX * sinA;
const tailFollowFactor = 0.15;
for (let i = 0; i < this.tailLength; i++) {
const point = this.tailPoints[i];
point.x += (targetTailX - point.x) * tailFollowFactor;
point.y += (targetTailY - point.y) * tailFollowFactor;
targetTailX = point.x; targetTailY = point.y;
}
this.updateSVG();
}
updateSVG() {
this.bodyElement.setAttribute('fill', this.color);
this.tailElement.setAttribute('stroke', this.color);
const cosA = Math.cos(this.angle), sinA = Math.sin(this.angle);
const arcCenterX = this.x + this.bodyLength*0.25*cosA;
const arcCenterY = this.y + this.bodyLength*0.25*sinA;
const tailAttachX = this.x + this.tailAttachLocalX*cosA;
const tailAttachY = this.y + this.tailAttachLocalX*sinA;
const perpX = -sinA*this.radius, perpY = cosA*this.radius;
const arcStartX = arcCenterX+perpX, arcStartY = arcCenterY+perpY;
const arcEndX = arcCenterX-perpX, arcEndY = arcCenterY-perpY;
let bodyPath = `M ${arcStartX} ${arcStartY} A ${this.radius} ${this.radius} 0 1 1 ${arcEndX} ${arcEndY} L ${tailAttachX} ${tailAttachY} Z`;
this.bodyElement.setAttribute('d', bodyPath);
if (this.tailPoints.length > 0) {
let tailPath = `M ${tailAttachX} ${tailAttachY}`;
const maxTailWidth = this.radius*1.4, minTailWidth = 0.6;
for (let i = 0; i < this.tailLength; i++) {
const progress = i / this.tailLength;
const strokeWidth = Math.max(minTailWidth, (1-progress)*maxTailWidth+minTailWidth*progress);
this.tailElement.setAttribute('stroke-width', strokeWidth);
const point = this.tailPoints[i];
tailPath += ` L ${point.x} ${point.y}`;
}
this.tailElement.setAttribute('d', tailPath);
}
}
remove() {
if (this.bodyElement) svg.removeChild(this.bodyElement);
if (this.tailElement) svg.removeChild(this.tailElement);
}
}
const particles = [];
function findGroups(allParticles) {
const groups = [], visited = new Set();
for (const particle of allParticles) {
if (!visited.has(particle)) {
const newGroup = [], queue = [particle];
visited.add(particle);
while (queue.length > 0) {
const current = queue.shift();
newGroup.push(current);
for (const other of allParticles) {
if (!visited.has(other)) {
const dx = current.x - other.x, dy = current.y - other.y;
if (Math.sqrt(dx*dx+dy*dy) < NEIGHBOR_RADIUS) {
visited.add(other); queue.push(other);
}
}
}
}
groups.push(newGroup);
}
}
return groups;
}
function init() {
particles.forEach(p => p.remove());
particles.length = 0;
for (let i = 0; i < numParticles; i++) {
const hue = 20 + i*4 + Math.random()*25;
const margin = BORDER_REPULSION_MARGIN + 20;
particles.push(new Particle(
margin + Math.random()*(svgWidth - margin*2),
margin + Math.random()*(svgHeight - margin*2),
`hsl(${hue}, 100%, 80%)`
));
}
}
let twoGroupMode = false;
function animate() {
const groups = findGroups(particles);
if (groups.length === 2 && !twoGroupMode) {
twoGroupMode = true;
groups.forEach((group, i) => { group.forEach(p => { p.group = i; }); });
} else if (groups.length > 2) {
twoGroupMode = false;
particles.forEach(p => { p.group = null; });
}
particles.forEach(p => { p.update(particles); });
requestAnimationFrame(animate);
}
setupSvgCanvas();
animate();
</script>
</body>
</html>