Геймдизайнер-телепат
0,0
рейтинг
7 июня 2013 в 16:54

Разработка → Boid'ы, птички и Unity3D tutorial



Вторая часть: Оптимизируем 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

Внимание: Код по ссылкам ниже устарел, последнюю версию смотрите в Procedural Toolkit

Исходники на 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++;
    }
}
Даниил Басманов @BasmanovDaniil
карма
190,2
рейтинг 0,0
Геймдизайнер-телепат
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (19)

  • +4
    Сферические птички в… Unity3D!

    Спасибо за статью, захотелось попробовать работать в Unity.
    • +1
      Попробуйте, благо бесплатно и кросплатформенно.
    • 0
      Можете пустить сферическх коней по полю. Разумеется в безвоздушной среде ;)
    • +19
      Кстати у меня долгая любовная история связана с Unity. Познакомился я с ней года три назад. Точнее нас с ней познакомили. Симпатия возникла сразу. Сначала мы с ней созванивались, долго болтали по вечерам и, наконец, встретились. В самом начале все у нас было хорошо, гуляли всюду вместе, цветы, конфеты, подарки. Но, спустя некоторое время, она перестала следить за собой, начала заглядывать в холодильник после 6 вечера, стала некрасиво выражаться и, как мне подсказывает интуиция, связалась с плохой компанией. После чего начались ссоры и идилия закончилась. Спустя несколько месяцев мы расстались. Было больно и обидно.
      По прошествии некоторого времени, на сайте знакомств, я познакомился с Unreal engine. Все было прекрасно. Она была красива, умна, следила за собой и с ней было очень легко. Однако, Unreal, помимо внимания, хотела дорогих подарков и вообще оказалась весьма падкой до денег. По сему прообщались мы с ней не долго.
      Как-то прогуливаясь по улицам интернета, я, внезапно, встретил Unity. Ее было не узнать! Похорошела, стала красиво одеваться, начала ходить в спортзал. Мы сами и не заметили как все закружилось, завертелось и мы вновь оказались вместе. Теперь, каждое утро мы просыпаемся вместе ))
      • +1
        Вас может завлечь одна роскошная немка.
        • 0
          Irrlicht?
          • +2
            Не писать пьяным на хабру
        • +12
          CryEngine не любит моих друзей — IOS и Android. А я друзей на женщин не меняю.
          • 0
            Вот Source — настоящий мужик, и брат у него есть надёжный — Linux. Подружишься с Source — не пожалеешь.
  • +9
    Задумывались ли вы когда-нибудь о то, почему птицы летая большими стаями никогда не сталкиваются и не коллапсируют в огромный галдящий перьевой ком?
    Мне больше интересно, почему люди в центре города не сталкиваются в большой ком мяса…
    • +1
      в метро в час-пик их полицаи дубинками/ограждениями разгоняют, а то бы точно полный Cohesion всем настал…
  • +5
    Так вот оно что…

    www.youtube.com/watch?v=b8eZJnbDHIg
  • +1
    Я использовал эту технику для моделирования движения косяка рыб. Не высшая степерь реалистичности, но выглядит довольно красиво. Мне нравится этот подход своей простотой, всего лишь несколько простейших векторных операций.
  • 0
    Недавно скачал Unity и первое что захотелось в нем сделать были как раз птицы, а меня опередили :)
    Спасибо)
  • 0
    Приятно видеть все больше инфы по Юнити на Хабе!

    подразумевается, что вы знаете эффект комбинации Ctrl+Shift+N на сцене

    А вот это как раз не показатель. Не один год работаю в Unity3d и никогда не использовал эту комбинацию, а вот хоткеи типа Q,W,E,R — постоянно.

    А пока можете предложить в комментариях свои варианты.

    Проект не открывал, хотя интересно что больше всего ресурсов тянет. Надо смотреть в профайлере в режиме Deep, думаю прояснит ситуацию.

    Кстати, батчинг включен? Можно количество ДК (Draw Call) сильно уменьшить.
    • 0
      Ничего не включено. Но сферы с диффузным шейдером жрут не очень много ресурсов, в основном нагрузка идёт от алгоритма. Надеялся посоревноваться в оптимизации с хабражителями.
  • +1
    if (transform.position.magnitude > 25)
    {
        velocity += -transform.position.normalized;
    }
    

    Используйте sqrMagnitude вместо magnitude (простая оптимизация)

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.