Боїди: 3 правила, емерджентна поведінка та небо з 10 000 птахів

Крейг Рейнольдс опублікував модель Боїдів у 1987 році. Три сили керування — розділення, вирівнювання, згуртованість — це все, що потрібно, щоб заповнити небо переконливим стайним польотом птахів. Ось як я перетворив ці три рядки псевдокоду на симуляцію з 10 000 агентів у реальному часі, що працює на 60 FPS у браузері.

Обіцянка: розум з нічого

Перш ніж я зрозумів принцип боїдів, я вважав, що реалістична зграйна поведінка повинна включати складне планування шляху: птахи спілкуються, досягають консенсусу, змінюють лідера по черзі. Реальність майже ображає своєю простотою. Кожен птах точно знає лише три речі:

🚫 Розділення Уникати сусідів, що занадто близько — не стикатися.
🧭 Вирівнювання Прямувати до середнього напрямку сусідніх членів зграї.
🎯 Згуртованість Прямувати до центру мас сусідніх членів зграї.

Це все. Жодного спілкування, жодного лідера, жодного глобального стану. Кожен агент вибирає локальний радіус околиці та застосовує зважені сили. Емерджентний результат виглядає моторошно живим.

Наївний алгоритм — пошук сусідів O(n²)

Моя перша реалізація була проста й коректна: для кожного боїда пройтися по всіх інших боїдах і перевірити відстань. Для N боїдів це N × (N−1) / 2 порівнянь за кадр.

function updateBoid(i, boids) {
  let sep = new THREE.Vector3();
  let ali = new THREE.Vector3();
  let coh = new THREE.Vector3();
  let count = 0;

  for (let j = 0; j < boids.length; j++) {
    if (i === j) continue;
    const d = boids[i].pos.distanceTo(boids[j].pos);
    if (d < PERCEPTION_RADIUS) {
      // Розділення: відштовхнутись від близьких сусідів
      const diff = boids[i].pos.clone().sub(boids[j].pos).divideScalar(d * d);
      sep.add(diff);
      // Вирівнювання: середня швидкість
      ali.add(boids[j].vel);
      // Згуртованість: відстежувати центр мас
      coh.add(boids[j].pos);
      count++;
    }
  }

  if (count > 0) {
    ali.divideScalar(count).normalize().multiplyScalar(MAX_SPEED);
    coh.divideScalar(count).sub(boids[i].pos).normalize().multiplyScalar(MAX_SPEED);
  }

  // Зважена сума → прискорення
  boids[i].acc
    .add(sep.multiplyScalar(W_SEP))
    .add(ali.multiplyScalar(W_ALI))
    .add(coh.multiplyScalar(W_COH));
}

Чудово працює на 200 боїдах. На 2 000 виникають труднощі. На 10 000 вкладка браузера зависає — 100 мільйонів перевірок відстані за кадр.

Виправлення: сітка просторового хешування

Ключова ідея: боїда цікавлять лише сусіди в межах PERCEPTION_RADIUS. Можна розподілити боїдів у 3D-сітку комірок розміром PERCEPTION_RADIUS × PERCEPTION_RADIUS × PERCEPTION_RADIUS. Тоді для кожного боїда перевіряються лише навколишні 3×3×3 = 27 комірок замість усіх N боїдів.

const CELL = PERCEPTION_RADIUS; // розмір комірки = радіус сприйняття

function cellKey(x, y, z) {
  // Дискретизувати позицію у світі до індексу комірки
  const cx = Math.floor(x / CELL);
  const cy = Math.floor(y / CELL);
  const cz = Math.floor(z / CELL);
  return `${cx},${cy},${cz}`;
}

// Побудувати хеш кожен кадр
const grid = new Map();
for (const b of boids) {
  const key = cellKey(b.pos.x, b.pos.y, b.pos.z);
  if (!grid.has(key)) grid.set(key, []);
  grid.get(key).push(b);
}

// Потім у updateBoid: перевіряти лише 27 сусідніх комірок
function neighbourCells(pos) {
  const cx = Math.floor(pos.x / CELL);
  const cy = Math.floor(pos.y / CELL);
  const cz = Math.floor(pos.z / CELL);
  const cells = [];
  for (let dx = -1; dx <= 1; dx++)
    for (let dy = -1; dy <= 1; dy++)
      for (let dz = -1; dz <= 1; dz++)
        cells.push(`${cx+dx},${cy+dy},${cz+dz}`);
  return cells;
}
Кількість боїдів Наївний O(n²) Просторовий хеш
500 60 FPS 60 FPS
2 000 22 FPS 60 FPS
10 000 <1 FPS 58 FPS

Рендеринг: конуси, а не сфери

Кожен боїд — це ConeGeometry, спрямований у напрямку польоту, що рендериться через InstancedMesh. Кожен кадр я оновлюю матрицю екземпляра, щоб вона орієнтувалась за вектором швидкості, використовуючи quaternion.setFromUnitVectors.

const UP = new THREE.Vector3(0, 1, 0); // орієнтація конуса за замовчуванням
const dir = new THREE.Vector3();
const q = new THREE.Quaternion();

for (let i = 0; i < boids.length; i++) {
  dir.copy(boids[i].vel).normalize();
  q.setFromUnitVectors(UP, dir);

  dummy.position.copy(boids[i].pos);
  dummy.quaternion.copy(q);
  dummy.scale.setScalar(1);
  dummy.updateMatrix();
  mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;

Хитрість зі швидкістю: виділити InstancedMesh на максимальну кількість один раз і ніколи не змінювати розмір. Оновлення матриць швидке (копіювання Float32Array). Створення та знищення GPU-буферів кожен кадр — повільне.

Налаштування ваг — де магія

Три ваги правил повністю контролюють характер зграї. Кілька прикладів з моїх експериментів:

Повзунки інтерфейсу в симуляції дозволяють досліджувати цей простір параметрів наживо. Це напрочуд захоплює — можна провести годину, просто налаштовуючи ваги й спостерігаючи, як змінюється характер зграї.

Додавання хижака

Найдраматичніше розширення — четверте правило: тікати від хижака. Одна модель яструба кружляє над зграєю; будь-який боїд у межах більшого «радіусу небезпеки» додає сильний вектор відштовхування. Уся зграя розколюється, відновлюється й вирує — точно як реальні зграї шпаків, що реагують на сокола-сапсана.

// Правило 4: втеча від хижака
const threat = boid.pos.distanceTo(hawk.pos);
if (threat < FLEE_RADIUS) {
  const flee = boid.pos.clone().sub(hawk.pos)
    .normalize()
    .multiplyScalar(MAX_SPEED * 2.0); // паніка — подвійна швидкість
  boid.acc.add(flee.multiplyScalar(W_FLEE));
}

Симуляція зграйної поведінки боїдів доступна за адресою /birds-flock/ (мальовничий режим з ландшафтом) та /boids/ (дослідник параметрів з регульованими вагами та перемикачем хижака). Поглиблена стаття про алгоритм за адресою Алгоритм боїдів — як зграйна поведінка виникає з трьох правил.

Чого я навчився

Боїди — це проєкт, що змусив мене закохатися в емерджентні системи. До цього я думав, що симуляції потребують складної логіки, щоб виглядати складними. Після цього я почав дивитися на кожне природне явище — мурашині стежки, транспортний потік, спрацьовування нейронів — як на потенційний набір трьох простих локальних правил, які чекають на відкриття.

Шаблон просторового хешування також став шаблоном, який я повторно використовую всюди: гідродинаміка SPH, молекулярна динаміка, широка фаза виявлення зіткнень для симуляції більярду. Щоразу, коли у вас багато агентів, що взаємодіють лише з локальними сусідами, просторовий хеш — перший інструмент, до якого варто звернутись.