Pull to refresh

Boid'ы, птички и Unity3D

Reading time 10 min
Views 48K


Вторая часть: Оптимизируем Boid'ов на Unity

Задумывались ли вы когда-нибудь о то, почему птицы летая большими стаями никогда не сталкиваются и не коллапсируют в огромный галдящий перьевой ком? Хм, если подумать, это было бы круто. В любом случае, однажды в 1986 нашёлся человек по имени Крейг Рейнольдс, который решил создать простую модель поведения птиц в стаях и назвал её Boids. В модели у каждого боида есть три базовых правила: Separation, Alignment и Cohesion. Первое заключается в избегании столкновения с соседями, второе заставляет лететь примерно в ту же сторону что и соседи, а третье говорит не летать в одиночку и держаться группы. Эти простые правила позволяют создать правдоподобные стаи птиц, рыб и другой живности, чем и пользуются в кино и игровой индустрии.

В статье я расскажу как можно реализовать эту модель на практике. Для разработки я использую Unity и C#, но большинство вещей верны для других движков и языков. В этом туториале я не разжёвываю основы работы с Unity, подразумевается, что вы знаете эффект комбинации Ctrl+Shift+N на сцене, умеете работать с инспектором, дублировать и двигать объекты. Если нет, то советую начать с этой статьи. Или можете просто посмотреть на картинки.

Базовые приготовления




Создадим новый проект в Unity и сразу соорудим несколько папочек на будущее: Materials, Prefabs, Scenes, Scripts.
Закидываем на сцену Directional Light и одну сферу по имени Boid. Сферу превращаем в префаб. Заодно сразу сохраним сцену, чтобы потом об этом не думать. Теперь приступим к скриптингу.

Для модели нам требуется вычислить три параметра: Separation, Alignment и Cohesion. Начнём с последнего, он самый простой. Напомню, это вектор, направленный в сторону центра окрестных боидов. Для его нахождения нужно сложить координаты боидов и поделить сумму на их количество. Как боид узнает, что у него есть соседи? Для этого пригодится Physics.OverlapSphere. Эта функция возвратит нам все коллайдеры в заданном cohesionRadius, в том числе нашего боида, если он попадает в сферу.
boids = Physics.OverlapSphere(transform.position, cohesionRadius);

Мы обнуляем переменную, плюсуем, делим, а потом рисуем к центру разукрашенную линию от трансформа боида с помощью суперполезных Debug.DrawLine и Color.magenta. Debug.DrawLine на вход принимает координаты начала и конца линии, а также необязательный параметр цвета линии. Результаты выполнения всех дебажных функций видны только во время разработки, в билд они просто так не попадают.
Debug.DrawLine(transform.position, cohesion, Color.magenta);

Boid.cs центр
using UnityEngine;

public class Boid : MonoBehaviour
{
    private Vector3 cohesion;
    private float cohesionRadius = 10;
    private Collider[] boids;

    void Update()
    {
        cohesion = Vector3.zero;
        boids = Physics.OverlapSphere(transform.position, cohesionRadius);
        foreach (var boid in boids)
        {
            cohesion += boid.transform.position;
        }
        cohesion = cohesion / boids.Length;
        Debug.DrawLine(transform.position, cohesion, Color.magenta);
    }
}

Кидаем скрипт на префаб, копируем бойд пару раз и жмём Play. Не забудье включить отображение Gizmos иначе не увидите линии.



Собираем боиды в кучу


Так, кажется работает. Теперь нам нужно превратить полученную точку в движение. Хранить всё в одной куче нехорошо, поэтому вынесем предыдущий код в отдельную функцию. Функцию будем запускать по таймеру с помощью InvokeRepeating. Первый аргумент — название функции, второй — время старта, третий — интервал повторения. Эта функция очень полезна для отложенного запуска различных сценариев.
InvokeRepeating("CalculateVelocity", 0, 1);

Для вычисления вектора мы воспользуемся школьной математикой и вычтем из координат центра координаты боида. Добавим в скрипт публичную (скажу потом зачем) переменную velocity, в начале функции обнулим её, а в конце приплюсуем к ней новый вектор cohesion. В Update приложим результат к координатам трансформа с учётом прошедшего времени. Time.deltaTime нужен для того, чтобы перемещение не зависело от FPS и шло с одной скоростью на всех процессорах.
transform.position += velocity * Time.deltaTime;

Кроме того, раз наш центр превратился в вектор, то мы поменяем наш Debug.DrawLine на другой не менее фантастический Debug.DrawRay. Разницы никакой, просто второй аргумент должен быть в относительных координатах, прям как у нас.

Boid.cs cohesion
using UnityEngine;

public class Boid : MonoBehaviour
{
    public Vector3 velocity;

    private float cohesionRadius = 10;
    private Collider[] boids;
    private Vector3 cohesion;

    private void Start()
    {
        InvokeRepeating("CalculateVelocity", 0, 1);
    }

    void CalculateVelocity()
    {
        velocity = Vector3.zero;
        cohesion = Vector3.zero;
        boids = Physics.OverlapSphere(transform.position, cohesionRadius);
        foreach (var boid in boids)
        {
            cohesion += boid.transform.position;
        }
        cohesion = cohesion / boids.Length;
        cohesion = cohesion - transform.position;
        velocity += cohesion;
    }

    void Update()
    {
        transform.position += velocity * Time.deltaTime;
        Debug.DrawRay(transform.position, cohesion, Color.magenta);
    }
}



Разделяем боиды


Расчёт separation немного сложнее. Нужно посчитать наиболее полезное направление выхода из кучи соседей. Для этого можно найти взвешенную сумму векторов от каждого соседа. Вектор от соседа делим на расстояние до него, которое получаем с помощью Vector3.magnitude. В получившейся сумме наибольшим весом будут обладать ближайшие соседи.
separation += (transform.position - boid.transform.position) / (transform.position - boid.transform.position).magnitude;

Имеет смысл ограничить число учитываемых соседей определённой дистанцией, для этого добавим одну переменную для счётчика и одну для радиуса разделения.
if ((transform.position - boid.transform.position).magnitude < separationDistance)

Кроме того, нам совсем не нужно попадание нулевого вектора в сумму из-за коллайдера самого боида. Не забывайте, что Physics.OverlapSphere покрывает все коллайдеры, в том числе коллайдер боида. Поэтому немного изменим условие.
if (boid != collider && (transform.position - boid.transform.position).magnitude < separationDistance)

Boid.cs separation
using UnityEngine;

public class Boid : MonoBehaviour
{
    public Vector3 velocity;

    private float cohesionRadius = 10;
    private float separationDistance = 5;
    private Collider[] boids;
    private Vector3 cohesion;
    private Vector3 separation;
    private int separationCount;

    private void Start()
    {
        InvokeRepeating("CalculateVelocity", 0, 1);
    }

    void CalculateVelocity()
    {
        velocity = Vector3.zero;
        cohesion = Vector3.zero;
        separation = Vector3.zero;
        separationCount = 0;

        boids = Physics.OverlapSphere(transform.position, cohesionRadius);
        foreach (var boid in boids)
        {
            cohesion += boid.transform.position;

            if (boid != collider && (transform.position - boid.transform.position).magnitude < separationDistance)
            {
                separation += (transform.position - boid.transform.position) / (transform.position - boid.transform.position).magnitude;
                separationCount++;
            }
        }

        cohesion = cohesion / boids.Length;
        cohesion = cohesion - transform.position;
        if (separationCount > 0)
        {
            separation = separation / separationCount;
        }

        velocity += cohesion + separation;
    }

	void Update()
	{
        transform.position += velocity * Time.deltaTime;

        Debug.DrawRay(transform.position, separation, Color.green);
        Debug.DrawRay(transform.position, cohesion, Color.magenta);
	}
}



Организуем боиды


Чтобы боиды не просто бездумно собирались в ровные кучки нам нужно, чтобы они повторяли поведение соседей. Расчёт alignment очень прост, мы суммируем публичные переменные velocity (ага!) от каждого соседа и делим на их количество. Доступ к прицепленным скриптам можно получить с помощью GameObject.GetComponent. Он может находить не только скрипты, а вообще любые компоненты. Замечательная штука.
alignment += boid.GetComponent<Boid>().velocity;

Boid.cs alignment
using UnityEngine;

public class Boid : MonoBehaviour
{
    public Vector3 velocity;

    private float cohesionRadius = 10;
    private float separationDistance = 5;
    private Collider[] boids;
    private Vector3 cohesion;
    private Vector3 separation;
    private int separationCount;
    private Vector3 alignment;

    private void Start()
    {
        InvokeRepeating("CalculateVelocity", 0, 1);
    }

    void CalculateVelocity()
    {
        velocity = Vector3.zero;
        cohesion = Vector3.zero;
        separation = Vector3.zero;
        separationCount = 0;
        alignment = Vector3.zero;

        boids = Physics.OverlapSphere(transform.position, cohesionRadius);
        foreach (var boid in boids)
        {
            cohesion += boid.transform.position;
            alignment += boid.GetComponent<Boid>().velocity;

            if (boid != collider && (transform.position - boid.transform.position).magnitude < separationDistance)
            {
                separation += (transform.position - boid.transform.position) / (transform.position - boid.transform.position).magnitude;
                separationCount++;
            }
        }

        cohesion = cohesion / boids.Length;
        cohesion = cohesion - transform.position;
        if (separationCount > 0)
        {
            separation = separation / separationCount;
        }
        alignment = alignment / boids.Length;

        velocity += cohesion + separation + alignment;
    }

    void Update()
    {
        transform.position += velocity * Time.deltaTime;

        Debug.DrawRay(transform.position, separation, Color.green);
        Debug.DrawRay(transform.position, cohesion, Color.magenta);
        Debug.DrawRay(transform.position, alignment, Color.blue);
    }
}

Запускаем и… ноль реакции, всё по-прежнему. Добавим двоечку в формулу расчёта velocity.
velocity += cohesion + separation + alignment*2;

И тогда…



Режем векторы


Вжууум! Ну что же, вполне предсказуемо. Мы увеличили вектор выравнивания, который увеличил вектор скорости, который увеличил вектор выравнивания, который… Ну вы поняли. Нам нужно сделать ограничение максимальной скорости. Причём ограничение стоит поставить ещё и на всех компонентах вектора, иначе в некоторых ситуациях поведения боида становится несколько странным. Можете сами попробовать.

Для обрезания векторов в Unity есть функция Vector3.ClampMagnitude. Просто добавим после вычисления каждого из векторов конструкцию следующего вида:
velocity = Vector3.ClampMagnitude(velocity, maxSpeed);

Boid.cs clamp
using UnityEngine;

public class Boid : MonoBehaviour
{
    public Vector3 velocity;

    private float cohesionRadius = 10;
    private float separationDistance = 5;
    private Collider[] boids;
    private Vector3 cohesion;
    private Vector3 separation;
    private int separationCount;
    private Vector3 alignment;
    private float maxSpeed = 15;

    private void Start()
    {
        InvokeRepeating("CalculateVelocity", 0, 1f);
    }

    void CalculateVelocity()
    {
        velocity = Vector3.zero;
        cohesion = Vector3.zero;
        separation = Vector3.zero;
        separationCount = 0;
        alignment = Vector3.zero;

        boids = Physics.OverlapSphere(transform.position, cohesionRadius);
        foreach (var boid in boids)
        {
            cohesion += boid.transform.position;
            alignment += boid.GetComponent<Boid>().velocity;

            if (boid != collider && (transform.position - boid.transform.position).magnitude < separationDistance)
            {
                separation += (transform.position - boid.transform.position) / (transform.position - boid.transform.position).magnitude;
                separationCount++;
            }
        }

        cohesion = cohesion / boids.Length;
        cohesion = cohesion - transform.position;
        cohesion = Vector3.ClampMagnitude(cohesion, maxSpeed);
        if (separationCount > 0)
        {
            separation = separation / separationCount;
            separation = Vector3.ClampMagnitude(separation, maxSpeed);
        }
        alignment = alignment / boids.Length;
        alignment = Vector3.ClampMagnitude(alignment, maxSpeed);

        velocity += cohesion + separation * 10 + alignment * 1.5f;
        velocity = Vector3.ClampMagnitude(velocity, maxSpeed);
    }

    void Update()
    {
        if (transform.position.magnitude > 25)
        {
            velocity += -transform.position.normalized;
        }

        transform.position += velocity * Time.deltaTime;

        Debug.DrawRay(transform.position, separation, Color.green);
        Debug.DrawRay(transform.position, cohesion, Color.magenta);
        Debug.DrawRay(transform.position, alignment, Color.blue);
    }
}

Проверяем работу усмирённых векторов.



Автоматизируем


Расставлять боиды вручную совсем не интересно. Для программной расстановки существует функция Instantiate. На вход ей нужно подавать ссылку на объект для копирования, новые координаты объекта и его вращение. Для копируемого префаба мы делаем отдельную публичную переменную, которую будем заполнять в инспекторе. Случайные координаты удобно брать из Random.insideUnitSphere, достаточно просто умножить его на радиус необходимой сферы. Наши боиды можно вращать сколько угодно, результат будет один, поэтому воспользуемся Quaternion.identity, который означает отсутствие вращения.
Instantiate(boidPrefab, Random.insideUnitSphere * 25, Quaternion.identity);

В цикле повторяем действие выше и получаем любое нужное количество боидов. Кидаем новый скрипт на пустой объект в центре сцены и заполняем ссылку на префаб.

HeartOfTheSwarm.cs
using UnityEngine;

public class HeartOfTheSwarm : MonoBehaviour
{
    public Transform boidPrefab;
    public int swarmCount = 100;


    void Start()
	{
        for (var i = 0; i < swarmCount; i++)
        {
            Instantiate(boidPrefab, Random.insideUnitSphere * 25, Quaternion.identity);
        }
    }
}

За стремительно улетающей стаей боидов не очень удобно наблюдать, хорошо бы посадить их на цепь. Для этого в Update добавим небольшое условие:
if (transform.position.magnitude > 25)
{
    velocity += -transform.position.normalized;
}

С его помощью боиды, координаты которых находятся за границей виртуальной сферы, будут поворачивать в сторону центра. Напоследок немного поиграемся с множителями векторов и другими параметрами, иначе не получится нужного эффекта. Смотрите финальный код под спойлером ниже.
velocity += cohesion + separation * 10 + alignment * 1.5f;

Запускаем, любуемся.

Boid.cs
using UnityEngine;

public class Boid : MonoBehaviour
{
    public Vector3 velocity;

    private float cohesionRadius = 10;
    private float separationDistance = 5;
    private Collider[] boids;
    private Vector3 cohesion;
    private Vector3 separation;
    private int separationCount;
    private Vector3 alignment;
    private float maxSpeed = 15;

    private void Start()
    {
        InvokeRepeating("CalculateVelocity", 0, 0.1f);
    }

    void CalculateVelocity()
    {
        velocity = Vector3.zero;
        cohesion = Vector3.zero;
        separation = Vector3.zero;
        separationCount = 0;
        alignment = Vector3.zero;

        boids = Physics.OverlapSphere(transform.position, cohesionRadius);
        foreach (var boid in boids)
        {
            cohesion += boid.transform.position;
            alignment += boid.GetComponent<Boid>().velocity;

            if (boid != collider && (transform.position - boid.transform.position).magnitude < separationDistance)
            {
                separation += (transform.position - boid.transform.position) / (transform.position - boid.transform.position).magnitude;
                separationCount++;
            }
        }

        cohesion = cohesion / boids.Length;
        cohesion = cohesion - transform.position;
        cohesion = Vector3.ClampMagnitude(cohesion, maxSpeed);
        if (separationCount > 0)
        {
            separation = separation / separationCount;
            separation = Vector3.ClampMagnitude(separation, maxSpeed);
        }
        alignment = alignment / boids.Length;
        alignment = Vector3.ClampMagnitude(alignment, maxSpeed);

        velocity += cohesion + separation * 10 + alignment * 1.5f;
        velocity = Vector3.ClampMagnitude(velocity, maxSpeed);
    }

    void Update()
    {
        if (transform.position.magnitude > 25)
        {
            velocity += -transform.position.normalized;
        }

        transform.position += velocity * Time.deltaTime;

        Debug.DrawRay(transform.position, separation, Color.green);
        Debug.DrawRay(transform.position, cohesion, Color.magenta);
        Debug.DrawRay(transform.position, alignment, Color.blue);
    }
}



Вот и всё, боиды летают в своей клетке. Однако их так мало! При количестве больше сотни всё начинает ощутимо тормозить. Не мудрено, ведь мы не делали ни единой оптимизации. В следующей части я расскажу о том, как можно оптимизировать наш код, чтобы он мог держать 60 FPS на гораздо большем количестве боидов. А пока можете предложить в комментариях свои варианты.

Вторая часть: Оптимизируем Boid'ов на Unity

Исходники на GitHub | Онлайн версия для обладателей Unity Web Player

Мини-бонус для тех, кому интересно как я делал анимации.
Screenshot.cs
using UnityEngine;

public class Screenshot : MonoBehaviour
{
    private int count;

    void Update()
    {
        if (Input.GetButtonDown("Jump"))
        {
            InvokeRepeating("Capture", 0.1f, 0.3f);
        }
    }

    void Capture()
    {
        Application.CaptureScreenshot(Application.dataPath + "/Screenshot" + count + ".png");
        count++;
    }
}
Tags:
Hubs:
+55
Comments 19
Comments Comments 19

Articles