Урок · Багатоагентність · Середній рівень
📅 Березень 2026⏱ 45 хв читання🎓 Середній рівень⚙️ JavaScript / Three.js

Симуляція рою дронів — флокінг та керування формацією

Реальні рої дронів (Amazon Prime Air, дрони Starling Murmuration, військова програма OFFSET) поєднують два рівні: висхідний рівень флокінгу Boids, що забезпечує уникнення зіткнень, і низхідний рівень керування формацією, що призначає цільові позиції. Цей урок будує обидва з нуля на JavaScript.

1. Архітектура агента

Кожен дрон — це легкий об'єкт даних. Тримайте фізику та рендеринг окремо — цикл фізики виконується з фіксованим часовим кроком (напр., 60 Гц), тоді як Three.js рендерить із тією частотою, яку дозволяє GPU.

class Drone {
  constructor(id, x, y, z) {
    this.id       = id;
    this.pos      = new THREE.Vector3(x, y, z);
    this.vel      = new THREE.Vector3();
    this.acc      = new THREE.Vector3();
    this.target   = new THREE.Vector3();  // слот формації
    this.state    = 'FLOCK';              // FLOCK | FORM | AVOID | RETURN
    this.maxSpeed = 5.0;
    this.maxForce = 0.3;
    this.mesh     = null;                 // призначається рендерером
  }

  applyForce(f) {
    this.acc.add(f);
  }

  update(dt) {
    this.vel.addScaledVector(this.acc, dt);
    this.vel.clampLength(0, this.maxSpeed);
    this.pos.addScaledVector(this.vel, dt);
    this.acc.set(0, 0, 0);
  }
}

2. Рівень 1: флокінг Boids

Рівень Boids відповідає за уникнення зіткнень між дронами і надає рою органічного руху. Поєднуються три керувальні сили — ті самі правила, що й у класичному алгоритмі Рейнольдса (1987), але налаштовані для 3D:

function boidsForces(drone, drones) {
  const SEP_RADIUS  = 4;    // особистий простір (еквівалент у метрах)
  const VIEW_RADIUS = 12;
  const W_SEP       = 1.8;
  const W_ALI       = 1.0;
  const W_COH       = 0.8;

  const sep = new THREE.Vector3();
  const ali = new THREE.Vector3();
  const coh = new THREE.Vector3();
  let ns = 0, nv = 0;

  for (const other of drones) {
    if (other === drone) continue;
    const d = drone.pos.distanceTo(other.pos);
    if (d < SEP_RADIUS) {
      sep.addScaledVector(
        new THREE.Vector3().subVectors(drone.pos, other.pos).normalize(),
        1 / d); // зважуємо за оберненою відстанню
      ns++;
    }
    if (d < VIEW_RADIUS) {
      ali.add(other.vel);
      coh.add(other.pos);
      nv++;
    }
  }

  const total = new THREE.Vector3();
  if (ns > 0) total.addScaledVector(
    seek(drone, drone.pos.clone().add(sep)), W_SEP);
  if (nv > 0) {
    ali.divideScalar(nv).normalize().multiplyScalar(drone.maxSpeed);
    total.addScaledVector(steer(drone, ali), W_ALI);
    coh.divideScalar(nv);
    total.addScaledVector(seek(drone, coh), W_COH);
  }
  return total;
}

// Seek: керуємо до цілі на максимальній швидкості
function seek(drone, target) {
  const desired = new THREE.Vector3()
    .subVectors(target, drone.pos).normalize()
    .multiplyScalar(drone.maxSpeed);
  return steer(drone, desired);
}

function steer(drone, desired) {
  return desired.sub(drone.vel).clampLength(0, drone.maxForce);
}

3. Рівень 2: патерни формацій

Рівень формації призначає кожному дрону цільовий слот у світовому просторі. Далі дрон керує собою до свого слота. Коли drone.state === 'FORM', вага згуртованості Boids зменшується і додається сильна сила прямування до слота.

V-подібна формація (перелітні птахи)

function vFormationSlots(n, spacing = 4) {
  const slots = [];
  for (let i = 0; i < n; i++) {
    const side = i % 2 === 0 ? 1 : -1;
    const rank = Math.ceil(i / 2);
    slots.push(new THREE.Vector3(
      side * rank * spacing,    // X: ліве/праве крило
      0,                        // Y: та сама висота
      -rank * spacing * 0.8   // Z: позаду лідера
    ));
  }
  return slots; // slot[0] = лідер у початку координат
}

Формація кола

function circleFormationSlots(n, radius = 10) {
  return Array.from({length: n}, (_, i) => {
    const angle = (i / n) * Math.PI * 2;
    return new THREE.Vector3(
      Math.cos(angle) * radius, 0, Math.sin(angle) * radius);
  });
}

Формація сітки

function gridFormationSlots(n, cols = 4, spacing = 5) {
  return Array.from({length: n}, (_, i) => {
    const col = i % cols, row = Math.floor(i / cols);
    return new THREE.Vector3(
      (col - (cols - 1) / 2) * spacing,
      0,
      row * spacing);
  });
}

Щоб призначити слоти дронам, використовуйте жадібне призначення за найближчим сусідом, щоб мінімізувати сумарну відстань переміщення — або, для оптимального призначення з-понад ~30 дронами, розв'яжіть задачу про лінійне призначення (угорський алгоритм) через JavaScript-порт функції linear_sum_assignment зі scipy.

// Жадібне призначення слотів: кожен дрон бере найближчий до нього слот
function assignSlots(drones, slots) {
  const used = new Set();
  for (const d of drones) {
    let best = -1, bestDist = Infinity;
    for (let j = 0; j < slots.length; j++) {
      if (used.has(j)) continue;
      const dist = d.pos.distanceTo(slots[j]);
      if (dist < bestDist) { bestDist = dist; best = j; }
    }
    d.target.copy(slots[best]);
    used.add(best);
  }
}

4. Керування «лідер-послідовник»

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

function leaderFollowerForce(follower, leader, offsetLocal) {
  // Перетворюємо зміщення з локальної системи лідера у світову
  const leaderQ = new THREE.Quaternion()
    .setFromUnitVectors(
      new THREE.Vector3(0, 0, 1),
      leader.vel.clone().normalize()
    );
  const worldTarget = offsetLocal.clone()
    .applyQuaternion(leaderQ)
    .add(leader.pos);

  // Керування прибуттям: сповільнюємося, наближаючись до слота
  const d = follower.pos.distanceTo(worldTarget);
  const SLOW_RADIUS = 6;
  const speed = d < SLOW_RADIUS
    ? follower.maxSpeed * (d / SLOW_RADIUS)
    : follower.maxSpeed;

  const desired = worldTarget.clone()
    .sub(follower.pos).normalize().multiplyScalar(speed);
  return desired.sub(follower.vel).clampLength(0, follower.maxForce);
}

5. Уникнення перешкод — штучні потенціальні поля

Перешкоди створюють відштовхувальне потенціальне поле. Цілі створюють притягальне поле. Дрон рухається за сумарним градієнтом. Це швидко (O(1) на перешкоду) і добре працює для опуклих перешкод.

function obstacleAvoidForce(drone, obstacles) {
  const OBS_RADIUS = 8;  // радіус впливу
  const force = new THREE.Vector3();

  for (const obs of obstacles) {
    const diff = drone.pos.clone().sub(obs.center);
    const d = diff.length() - obs.radius; // відстань зазору
    if (d < OBS_RADIUS && d > 0) {
      // APF: F = k * (1/d - 1/OBS_RADIUS) * (1/d²) * unitVec
      const k = 50;
      const mag = k * (1 / d - 1 / OBS_RADIUS) * (1 / (d * d));
      force.addScaledVector(diff.normalize(), mag);
    }
  }
  return force.clampLength(0, drone.maxForce * 3);
}
Відоме обмеження APF: дрони можуть потрапляти в пастку локальних мінімумів. Розв'язки: (1) додати випадкове збурення, коли швидкість ≈ 0 протягом N кадрів, (2) перемкнутися на пошук шляху A* у разі застрягання, (3) використати RRT (швидкорозгортні випадкові дерева) для складних середовищ.

6. Скінченний автомат агента

Поведінкою кожного дрона керує скінченний автомат. Це чітко розділяє різні режими поведінки:

Стан Активні сили Перехід до
FLOCK Розділення + Вирівнювання + Згуртованість FORM (команда користувача), AVOID (виявлено перешкоду)
FORM Розділення (висока W) + Прямування до слота (висока W) FLOCK (формацію розпущено), AVOID
AVOID Відштовхування від перешкоди + розділення Boids FLOCK/FORM (перешкоду пройдено)
RETURN Прямування до бази + Розділення LAND (досягнуто бази)
function updateDrone(drone, drones, obstacles, formation, dt) {
  const obsForce = obstacleAvoidForce(drone, obstacles);
  const inDanger = obsForce.length() > 0.5;

  if (inDanger) {
    drone.state = 'AVOID';
  } else if (drone.state === 'AVOID') {
    drone.state = formation ? 'FORM' : 'FLOCK';
  }

  switch (drone.state) {
    case 'FLOCK':
      drone.applyForce(boidsForces(drone, drones));
      break;
    case 'FORM':
      drone.applyForce(boidsForces(drone, drones).multiplyScalar(0.3));
      drone.applyForce(seek(drone, drone.target).multiplyScalar(2.0));
      break;
    case 'AVOID':
      drone.applyForce(obsForce);
      drone.applyForce(boidsForces(drone, drones).multiplyScalar(1.5));
      break;
  }

  drone.applyForce(obsForce); // застосовується завжди
  drone.update(dt);
}

7. Рендеринг у Three.js з InstancedMesh

Для 100+ дронів використовуйте THREE.InstancedMesh, щоб рендерити всіх дронів за один виклик відмалювання. Положення та кватерніон кожного дрона записуються в матрицю екземплярів щокадру.

// Створюємо instanced mesh для N дронів
const geo = new THREE.ConeGeometry(0.3, 1.0, 6);
geo.rotateX(Math.PI / 2);  // спрямовуємо вздовж осі Z
const mat = new THREE.MeshStandardMaterial({ color: 0x34d399 });
const mesh = new THREE.InstancedMesh(geo, mat, drones.length);
scene.add(mesh);

const dummy = new THREE.Object3D();
const _up    = new THREE.Vector3(0, 1, 0);

function syncInstancedMesh() {
  for (let i = 0; i < drones.length; i++) {
    const d = drones[i];
    dummy.position.copy(d.pos);
    if (d.vel.lengthSq() > 0.001) {
      dummy.quaternion.setFromUnitVectors(_up, d.vel.clone().normalize());
    }
    dummy.updateMatrix();
    mesh.setMatrixAt(i, dummy.matrix);
  }
  mesh.instanceMatrix.needsUpdate = true;
}

8. Посібник із налаштування

Параметр За замовчуванням Ефект збільшення
SEP_RADIUS 4 Дрони розходяться далі — менша щільність, більші проміжки
VIEW_RADIUS 12 Більший окіл → рівномірніший напрямок, повільніше розділення
W_SEP 1.8 Жорсткіше уникнення зіткнень, менша згуртованість
W_ALI 1.0 Дисциплінованіше вирівнювання напрямку
W_COH 0.8 Щільніше скупчення навколо центру групи
maxForce 0.3 Чутливіші повороти, але можливі коливання
maxSpeed 5.0 Швидший рій — може знадобитися більший радіус розділення
Продуктивність: наївний пошук сусідів O(N²) стає вузьким місцем за N ≈ 500. Для більших роїв використовуйте просторову геш-сітку (поділіть 3D-простір на комірки, перевіряйте лише дрони в сусідніх комірках), щоб знизити складність приблизно до O(N). За N = 1000 (ціль 60 FPS) розмір комірки, що дорівнює VIEW_RADIUS, дає ~50 кандидатів на дрон проти ~1000 — 20-кратне прискорення.