Флокинг: имитация стаи (boids)

Сегодня ты заставишь сотню цыплят летать как единая стая — без единого общего командира, только по трём простым правилам, которые каждый цыплёнок применяет к соседям.
Флокинг (boids) — модель поведения стаи, в которой каждая частица следует трём правилам по отношению к соседям: разделение (не толкаться), выравнивание (лететь в общем направлении) и сплочение (держаться кучи). Никакого центрального мозга — слаженность рождается сама.

Откуда берётся стая

Видел, как осенью скворцы собираются в огромное облако и текут по небу, будто это одно живое существо? Облако сжимается, растягивается, огибает дерево, делится надвое и снова сливается — и при этом никто им не дирижирует. Нет главной птицы с микрофоном, которая кричит «все влево!». Каждая птица просто смотрит на пару-тройку ближайших соседей и подстраивается под них. А красивое облако на сотни особей получается само собой.

То же самое ты видел в играх и мультиках: косяки рыб в подводных уровнях, рои пчёл, толпы зомби, которые обтекают препятствия. Почти всё это — один и тот же приём, придуманный ещё в 1986 году программистом Крейгом Рейнольдсом. Он назвал свои искусственные стаи boids (от «bird-oid», «птицеподобные») и доказал крутую вещь: чтобы получить сложное поведение толпы, не нужно прописывать поведение толпы. Достаточно дать каждому отдельному существу несколько простых правил — а сложность вырастет сама. Это называется эмерджентность: целое ведёт себя умнее, чем любая его часть.

В прошлых уроках ты уже подобрался к этому вплотную. Ты делал эмиттеры с жизненным циклом частиц, где каждая частица жила сама по себе. А в уроке про притяжение и отталкивание ты научился толкать частицы силами. Флокинг — это следующий шаг: теперь частицы будут реагировать не на одну точку-магнит, а друг на друга. К концу урока у тебя по холсту понесётся живая стая цыплят, которая кружит, рассыпается и снова собирается в косяк.

Три правила стаи

Вся магия держится на трёх правилах. Представь, что ты — один цыплёнок в стае и летишь в общей куче. Что ты делаешь каждое мгновение? Ровно три вещи, и каждая даёт тебе одну стрелку-силу, куда подвинуться.

  • Разделение (separation): не толкайся. Если сосед подлетел слишком близко — отодвинься от него. Это сила, направленная прочь от тех, кто залез в твоё личное пространство. Без неё стая слиплась бы в одну точку.
  • Выравнивание (alignment): лети как все. Посмотри, куда в среднем летят соседи, и поверни в ту же сторону. Это сила, которая подгоняет твою скорость под среднюю скорость стаи. Благодаря ей стая течёт единым потоком, а не как попало.
  • Сплочение (cohesion): держись кучи. Найди середину между соседями (их «центр масс») и подтянись к ней. Это сила к центру компании. Без неё стая разлетелась бы и распалась на одиночек.

Заметь, как три правила тянут в разные стороны и уравновешивают друг друга. Сплочение тянет цыплят в кучу, разделение не даёт им слипнуться, а выравнивание следит, чтобы они летели согласованно. Это как компания друзей на прогулке: вы держитесь вместе (сплочение), но не наступаете друг другу на пятки (разделение), и идёте примерно в одну сторону, а не разбредаетесь (выравнивание). Три простых желания — и получается живая, текучая толпа.

Главная идея флокинга: никто не управляет стаей. Каждый цыплёнок видит только соседей рядом и считает для себя три маленькие силы. Сложное поведение облака — это сумма множества простых решений.

Как правило превращается в силу

Все три правила выдают одно и то же — вектор-силу: стрелку «толкнись вон туда». Мы складываем три стрелки в одну, прибавляем её к скорости цыплёнка, а скорость прибавляем к позиции — ровно как ты делал с гравитацией. Разница лишь в том, что теперь сила зависит не от одной точки, а от того, кто летает рядом. Поэтому в каждом правиле есть один и тот же шаг: пройтись по всем соседям, кто ближе заданного радиуса, и что-то усреднить.

Разбираем на примерах

Пример 1. Один цыплёнок-boid с классом

Прежде чем делать стаю, соберём одну частицу. Заведём класс Boid с позицией и скоростью (вектора тебе уже родные) и научим его двигаться и отскакивать от краёв. Никаких правил стаи пока нет — просто летающий цыплёнок.

class Boid {
  constructor(x, y) {
    this.pos = createVector(x, y);
    this.vel = createVector(random(-2, 2), random(-2, 2));
    this.acc = createVector(0, 0); // ускорение, копим сюда силы
    this.maxSpeed = 3;
  }

  update() {
    this.vel.add(this.acc);          // силы меняют скорость
    this.vel.limit(this.maxSpeed);   // не даём разогнаться до бесконечности
    this.pos.add(this.vel);          // скорость двигает позицию
    this.acc.mult(0);                // обнуляем ускорение на следующий кадр
  }

  edges() {
    // вылетел за край — появляется с другой стороны (мир-бублик)
    if (this.pos.x > width) this.pos.x = 0;
    if (this.pos.x < 0) this.pos.x = width;
    if (this.pos.y > height) this.pos.y = 0;
    if (this.pos.y < 0) this.pos.y = height;
  }

  show() {
    noStroke();
    fill(255, 221, 51);
    circle(this.pos.x, this.pos.y, 10);
  }
}

Результат: заготовка одного цыплёнка-частицы. Сам по себе он пока ничего не делает на экране — это чертёж, по которому мы скоро наделаем целую стаю.

Разберём ключевые места. У каждого boid три вектора: pos (где он), vel (куда летит) и acc — ускорение, копилка для сил этого кадра. В update() происходит вся физика: силы из acc прибавляются к скорости, скорость ограничивается через vel.limit(maxSpeed) (иначе цыплёнок разгонится до космической), скорость двигает позицию, и в конце acc.mult(0) обнуляет копилку — на следующем кадре силы посчитаем заново. Метод edges() делает мир «бубликом»: вылетел справа — влетел слева, как в старой Pac-Man. А show() просто рисует жёлтую точку-тело.

Пример 2. Стая без правил — хаос

Создадим массив из сотни таких цыплят и запустим. Пока без флокинга — чтобы ты своими глазами увидел разницу, когда правила появятся.

let flock = [];

function setup() {
  createCanvas(600, 600);
  for (let i = 0; i < 100; i++) {
    flock.push(new Boid(random(width), random(height)));
  }
}

function draw() {
  background(135, 206, 235); // небо
  for (let boid of flock) {
    boid.edges();
    boid.update();
    boid.show();
  }
}

Результат: по голубому небу разлетается сотня жёлтых точек-цыплят, каждый летит в свою случайную сторону. Картина похожа на россыпь пикселей или снег в телевизоре без сигнала — движение есть, а порядка нет. Никто ни на кого не смотрит.

Это важная отправная точка. Сейчас каждый цыплёнок живёт сам по себе: его скорость задана один раз в конструкторе и больше не меняется (силы-то мы не считаем, acc всегда нулевое). Запомни эту картинку хаоса — через пару примеров мы добавим три правила, и тот же самый код, те же сто точек вдруг начнут вести себя как стая. Вся разница будет в одном новом методе.

Пример 3. Добавляем три правила

Вот сердце урока. Напишем три метода, каждый возвращает вектор-силу, и метод flock(), который складывает их и кладёт в acc. Добавь это внутрь класса Boid.

  separation(boids) {
    let perception = 30;        // личное пространство
    let steer = createVector(0, 0);
    let count = 0;
    for (let other of boids) {
      let d = dist(this.pos.x, this.pos.y, other.pos.x, other.pos.y);
      if (other !== this && d < perception) {
        let diff = p5.Vector.sub(this.pos, other.pos); // стрелка ОТ соседа
        diff.div(d);            // чем ближе сосед, тем сильнее толчок
        steer.add(diff);
        count++;
      }
    }
    if (count > 0) steer.div(count);
    return steer;
  }

  alignment(boids) {
    let perception = 50;
    let avg = createVector(0, 0);
    let count = 0;
    for (let other of boids) {
      let d = dist(this.pos.x, this.pos.y, other.pos.x, other.pos.y);
      if (other !== this && d < perception) {
        avg.add(other.vel);     // копим скорости соседей
        count++;
      }
    }
    if (count > 0) {
      avg.div(count);           // средняя скорость стаи рядом
      avg.sub(this.vel);        // насколько надо подвернуть
    }
    return avg;
  }

  cohesion(boids) {
    let perception = 50;
    let center = createVector(0, 0);
    let count = 0;
    for (let other of boids) {
      let d = dist(this.pos.x, this.pos.y, other.pos.x, other.pos.y);
      if (other !== this && d < perception) {
        center.add(other.pos);  // копим позиции соседей
        count++;
      }
    }
    if (count > 0) {
      center.div(count);              // центр масс соседей
      center.sub(this.pos);           // стрелка К центру
    }
    return center;
  }

  flock(boids) {
    let sep = this.separation(boids);
    let ali = this.alignment(boids);
    let coh = this.cohesion(boids);

    sep.mult(1.5); // разделение чуть важнее — не дать слипнуться
    ali.mult(1.0);
    coh.mult(1.0);

    this.acc.add(sep);
    this.acc.add(ali);
    this.acc.add(coh);
  }

Результат: три новых метода-правила и один сборщик. Сами по себе на экране они ничего не меняют — их надо вызвать в цикле. Но внутри уже спрятано всё поведение стаи.

Смотри, как три метода устроены по одному шаблону: пройтись по всем boids, отобрать тех, кто ближе perception пикселей, и что-то усреднить. Разница только в том, что усредняем. В separation() мы для каждого близкого соседа берём стрелку p5.Vector.sub(this.pos, other.pos) — она смотрит от соседа к нам, то есть «оттолкнись отсюда». И делим её на расстояние d: чем сосед ближе, тем толчок резче. В alignment() копим скорости соседей и усредняем — получаем «куда летит толпа», а потом вычитаем свою скорость, чтобы понять, насколько подвернуть. В cohesion() копим позиции, усредняем — это центр масс компании, — и стрелка к нему тянет нас в кучу.

Обрати внимание на проверку other !== this в каждом цикле: цыплёнок не должен считать соседом самого себя, иначе расстояние до себя ноль и всё ломается. А метод flock() — это дирижёр: он зовёт три правила, домножает их на коэффициенты-важности (mult) и складывает в общую копилку acc. Коэффициенты — твои ручки настройки: усиль сплочение — стая собьётся в плотный комок, усиль разделение — расплывётся пожиже.

Пример 4. Запускаем живую стаю

Осталось вызвать flock() в главном цикле — перед update(), чтобы силы успели посчитаться до движения.

function draw() {
  background(135, 206, 235);
  for (let boid of flock) {
    boid.flock(flock);  // считаем три силы по соседям
    boid.edges();
    boid.update();      // силы двигают цыплёнка
    boid.show();
  }
}

Результат: та же сотня жёлтых цыплят, но теперь они текут по небу единым облаком — то вытягиваются в косяк, то закручиваются спиралью, то делятся на два потока и снова сливаются. Точь-в-точь стая скворцов на закате. Ни один цыплёнок не получил команды «лети туда» — но вся стая движется слаженно и живо.

Вот она, эмерджентность, своими глазами. Мы не написали ни строчки про «облако», «косяк» или «спираль» — мы написали три крошечных правила для одного цыплёнка. А красивое поведение тысячи особей выросло само, из того, что каждый смотрит на соседей. Поменяй perception в правилах — стая станет дальнозоркой и плотной или близорукой и рыхлой. Поиграй с коэффициентами в flock() — и характер стаи поменяется на глазах.

Частые ошибки и подводные камни

Флокинг легко запустить и так же легко сломать. Лови самые частые грабли заранее.

1. Забыли проверку other !== this

Если не исключить самого цыплёнка из соседей, расстояние до себя будет ноль. В separation() деление diff.div(d) превратится в деление на ноль, вектор станет NaN («не-число»), и зачумлённый цыплёнок исчезнет с холста или улетит в бесконечность. Всегда проверяй other !== this внутри цикла по соседям.

2. Не ограничили скорость

Силы каждый кадр прибавляются к скорости, и без this.vel.limit(this.maxSpeed) цыплята разгоняются всё быстрее и быстрее, пока не начнут телепортироваться через весь холст за кадр. Стая превращается в дрожащую кашу. Лимит скорости — обязательная страховка любого boid.

3. Перепутали направление в separation

Разделение должно толкать прочь от соседа. Стрелка считается как p5.Vector.sub(this.pos, other.pos) — «из соседа в меня». Если случайно написать наоборот, p5.Vector.sub(other.pos, this.pos), цыплята начнут притягиваться к тем, кто близко, слипнутся в одну точку и схлопнутся. Правило: вычитаемое — это тот, от кого убегаешь.

4. Вызвали flock() после update()

Порядок важен. Сначала flock() считает силы и кладёт их в acc, и только потом update() применяет их к скорости. Если поменять местами, силы этого кадра обнулятся в update() раньше, чем подействуют, и стая снова рассыплется в хаос, как в примере 2.

5. Слишком большой радиус восприятия на сотнях частиц

Каждый цыплёнок в каждом кадре сравнивает себя со всеми остальными — это сотня на сотню, десять тысяч проверок за кадр. Если ещё и perception огромный, frameRate проседает и анимация начинает тормозить и дёргаться. Для старта держи стаю в пределах 80–150 особей и радиус 30–60. Большие стаи — отдельная тема про оптимизацию.

Мини-проект: стая цыплят CodeChick

Теперь твоя очередь оживить стаю и сделать её узнаваемой. Возьми рабочий код из примеров 1, 3 и 4 за основу и доведи до собственной работы. Чек-лист:

  1. Запусти базовую стаю на 100 цыплят и убедись, что облако течёт и собирается. Если рассыпается — проверь порядок вызовов и направление в separation().
  2. Сделай цыплят цыплятами. В show() рисуй не просто точку, а маленькое жёлтое тело с оранжевым клювом-треугольником, повёрнутым по направлению скорости. Подсказка: возьми угол через this.vel.heading() и поверни систему координат (push/translate/rotate/pop).
  3. Покрути ручки. Поиграй коэффициентами в flock(): усиль coh — стая собьётся плотнее; усиль sep — расплывётся свободнее. Найди настройку, которая нравится именно тебе.
  4. Бонус — вожак-курсор. Добавь четвёртое правило: лёгкую силу к точке мыши (mouseX, mouseY). Теперь стая будет послушно следовать за твоим курсором, как цыплята за наседкой.
  5. Супербонус — два вида. Заведи половину стаи другого цвета и сделай так, чтобы выравнивание и сплочение действовали только между «своими». Получишь две стаи, которые делят одно небо и обтекают друг друга.

Запусти, отойди и просто понаблюдай минуту. Стая никогда не повторяется дважды — каждый прогон рисует новый танец. В этом и кайф генеративного: ты задаёшь правила, а узор рождается сам.

Итоги

Сегодня ты собрал настоящую живую стаю из простых правил. Главное:

  • Флокинг (boids) — это поведение толпы без единого командира: каждая частица смотрит только на близких соседей.
  • Три правила дают три силы: разделение (отодвинься от слишком близких), выравнивание (лети как соседи) и сплочение (тянись к центру компании).
  • Каждое правило устроено одинаково: пройтись по соседям ближе perception, усреднить — и вернуть вектор-силу. Силы складываются в acc, acc двигает скорость, скорость — позицию.
  • Эмерджентность: сложное облако из сотни частиц вырастает само из крошечных правил для одной. Это и есть магия флокинга.
  • Коэффициенты важности в flock() и радиусы perception — твои ручки настройки характера стаи.

Ты прошёл от одной точки в первом модуле до стаи живых цыплят, которые ведут себя почти как настоящие. Осталось собрать всё, что ты умеешь, в одну работу. В следующем — финальном — уроке мы возьмём цвет, анимацию, случайность, частицы и флокинг и соберём генеративный аватар CodeChick: твою личную работу, которую не стыдно опубликовать. До встречи на финише!

Проверьте себя
1. Сколько правил лежит в основе классической модели флокинга (boids)?
AТри: разделение, выравнивание и сплочение
BОдно: лететь к центру холста
CДва: притяжение и отталкивание
DПять, по числу частиц
2. За что отвечает правило разделения (separation)?
AТолкает цыплёнка прочь от слишком близких соседей, чтобы не слипались
BПодтягивает цыплёнка к центру стаи
CПодгоняет скорость под среднюю по стае
DМеняет цвет цыплёнка
3. Что делает правило выравнивания (alignment)?
AПоворачивает цыплёнка так, чтобы лететь в среднем направлении соседей
BОтталкивает от ближайшего соседа
CТянет к центру масс стаи
DОграничивает максимальную скорость
4. Зачем внутри цикла по соседям нужна проверка other !== this?
AЧтобы цыплёнок не считал соседом сам себя — иначе расстояние ноль и деление ломается (NaN)
BЧтобы ускорить отрисовку
CЧтобы поменять цвет частицы
DЭто необязательная проверка, её можно убрать
5. В каком порядке надо вызывать методы в draw(), чтобы стая работала?
AСначала flock() (считает силы), потом update() (применяет их)
BСначала update(), потом flock()
CПорядок не важен
DТолько update(), flock() не нужен
6. Почему говорят, что в флокинге проявляется эмерджентность?
AСложное поведение всей стаи вырастает само из простых правил для каждой отдельной частицы
BПотому что у стаи есть главный командир, который ей дирижирует
CПотому что частицы окрашены в разные цвета
DПотому что код очень длинный