Зомби-шутер на DOTS в Unity

Всем привет! Меня зовут Николай Запольнов, я преподаватель на курсах Unity Basic и Unity Pro в OTUS. И сегодня я хочу рассказать вам про, без преувеличения, будущее Unity — технологию DOTS.

В этой статье мы узнаем, что же такое этот DOTS, какие проблемы он решает и как применить его на практике.

Но начать, все же, стоит с теории.

DOTS

DOTS (“Data Oriented Tech Stack” — технический стек, ориентированный на данные) — это новый подход к разработке игр на движке Unity. Впервые он был анонсирован в 2017 году, а некоторые его компоненты еще раньше — в 2016. В рамках этого технического стека Unity руководствуется слоганом “performance by default” (“производительный по умолчанию”): они создают оптимизированный и эффективный фреймворк в противоположность “классическому” Unity, где программист должен внимательно выбирать, какое API он использует, создавать пулы объектов и т.д.

Для обеспечения максимальной производительности, DOTS включает в себя множество компонентов. Так, например, Job System (Система задач) предоставляет удобный и простой API для многопоточного программирования. Но самым важным нововведением DOTS является использование паттерна Entity-Component-System (ECS).

Entity Component System

Одним из основных столпов классического объектно-ориентированного программирования является наследование. Дочерние классы, собственно, наследуют свойства и методы родительского класса, расширяя и дополняя их новыми возможностями. Такой подход позволяет переиспользовать код, но в больших проектах он также создает и проблему: построить подходящую иерархию объектов не всегда возможно. В результате, в проекте появляются так называемые божественные классы (god classes), реализующие огромный набор методов, часть которых используется одними дочерними классами, а часть — другими.

В качестве альтернативы наследованию была предложена композиция. В геймдеве такой подход часто называют Entity-Component (EC) и именно он применяется в “классическом” Unity. В паттерне EC сущности (entities, в Unity они реализованы классом GameObject) являются лишь контейнерами для компонентов. А компоненты, в свою очередь, реализуют конкретную функциональность и содержат в себе как код, так и данные. Это позволяет собирать каждую конкретную сущность из фрагментов функциональности, как из кубиков Лего.

Паттерн Entity-Component-System развивает эту парадигму еще дальше. Он предлагает оставить в компонентах только данные, а код вынести в системы. Это позволяет оперировать группой компонентов и выводит переиспользование кода на новый уровень. А еще, такой подход позволяет хранить данные компонентов в памяти последовательно. Это сильно увеличивает эффективность работы кеша на современных процессорах и, соответственно, повышает производительность.

Но довольно теории, давайте перейдем к практике!

Подготавливаем проект

Сразу хочется предупредить, что хотя DOTS и находится в разработке уже много лет, он все еще находится в состоянии preview и довольно сырой. Многие компоненты еще не завершены и поддерживают только часть функциональности, а иногда могут содержать и серьезные баги. По этой причине, в сегодняшней статье я буду использовать смешанный подход: часть кода будет написана на DOTS, а часть будет реализована на старых добрых GameObject’ах.

Эта статья написана и проверена на Unity 2020.1.16f1. Вы можете использовать и другую версию, но тогда существует вероятность, что что-то не заработает. DOTS очень активно развивается и программные интерфейсы иногда меняются.

Начнем с пустого проекта (я использовал шаблон “3D” в Unity Hub). Итак, прежде всего нам потребуется добавить DOTS в наш проект. Делается это через менеджер пакетов, но просто так их не найти. Некоторое время назад авторы Unity убрали все preview-пакеты из общего списка, и теперь, чтобы их установить, необходимо нажать в верхнем левом углу пакетного менеджера кнопку “+” и в появившемся меню выбрать “Add package from git URL…”:

Зомби-шутер на DOTS в Unity

Потребуется установить следующие пакеты:

  • com.unity.entities — реализация паттерна Entity-Component-System в DOTS. 
  • com.unity.physics — физический движок для DOTS.
  • com.unity.rendering.hybrid — гибридный рендерер для DOTS, выполняет роль “моста” между системой DOTS и стандартной архитектурой рендеринга Unity.

Установка этих пакетов также приведет к установке и других компонентов DOTS. Давайте кратко рассмотрим, что еще добавится в наш проект:

  • com.unity.burst — компилятор высокопроизводительного кода для C#.
  • com.unity.collections — альтернативные версии коллекций (список, очередь, словарь и т.д.), основанные на использовании памяти движка вместо сборщика мусора.
  • com.unity.jobs — система многопоточного программирования для DOTS. Позволяет легко распараллеливать код и автоматически отслеживать ошибки, возникающие при многопоточном программировании. Например, “условия гонки” (race conditions).
  • com.unity.mathematics — оптимизированная библиотека векторной математики взамен стандартных Vector2, Vector3 и т.д.

Кроме компонентов DOTS нам также пригодится пакет Cinemachine. Это очень удобный инструмент для управления камерами в Unity. Подробно останавливаться на нем сегодня я не буду, но крайне рекомендую ознакомиться, если вы про него еще не слышали. Устанавливаем:

Зомби-шутер на DOTS в Unity

Ну и наконец стоит добавить в проект пару наборов ассетов. Я буду использовать следующие бесплатные паки:

Познаем сущности

Для начала, давайте создадим в сцене плоскость, которая будет играть роль Земли. Я делаю это как обычно: щелкаю правой кнопкой мыши в окне Hierarchy и выбираю 3D Object⇨Plane:

Зомби-шутер на DOTS в Unity

Назначу ей материал зеленого цвета, чтобы она была больше похожа на траву:

Зомби-шутер на DOTS в Unity

Теперь у нас в сцене есть обычный GameObject, изображающий плоскость. Так причем же здесь DOTS? Пока что не причем. На текущем этапе развития технологии, сцену в Unity мы по-прежнему создаем на основе игровых объектов.

Но теперь у нас на вооружении есть новый компонент: ConvertToEntity. Добавив этот компонент в объект Plane, мы укажем Unity при запуске игры превратить его в сущность в системе ECS:

Зомби-шутер на DOTS в Unity

Компонент предлагает два режима преобразования: Convert and Destroy (выбран по умолчанию) и Convert And Inject Game Object. Выбор первого режима приводит к удалению игрового объекта после создания сущности, а при выборе второго игровой объект сохраняется. Мы пока оставим эту настройку как есть, т.е. будем уничтожать объект.

Давайте запустим игру и посмотрим, что получилось:

Зомби-шутер на DOTS в Unity

Итак, игровой объект Plane пропал из иерархии, но плоскость все же рисуется в игровом окне. Очевидно, что теперь она стала сущностью. Как можно убедиться в этом?

Зомби-шутер на DOTS в Unity

Unity предоставляет для этого удобный инструмент, который можно найти в меню Window⇨Analysis⇨Entity Debugger:

Зомби-шутер на DOTS в Unity

В левой части этого окна показаны все системы в порядке их исполнения, а также длительность работы каждой из них. Мы пока не создали ни одной своей системы, поэтому все, что мы видим здесь — встроенные системы Unity. Не будем подробно на них останавливаться.

Выбрав конкретную систему, в правом столбце можно увидеть, какими конкретно сущностями она оперирует. А если выбран пункт All Entities, как на скриншоте, то мы увидим список абсолютно всех сущностей. В моем примере их всего две: WorldTime (стандартная сущность Unity, отслеживающая текущее время) и Plane — наша плоскость!

Если выбрать сущность Plane, инспектор покажет нам, какие компоненты она в себя включает:

Зомби-шутер на DOTS в Unity

Как видите, вместо стандартных компонентов Unity здесь используются совершенно другие, новые компоненты. Так, компонент Transform был заменен на три компонента: LocalToWorld, Rotation и Translation. Вместо MeshFilter и MeshRenderer используется компонент RenderMesh. А для физики добавился компонент PhysicsCollider.

Я покажу как пользоваться этими компонентами чуть позже. Пока же для нас важно обратить внимание на следующие моменты:

  • Сущности DOTS существуют в своем отдельном мире и никак не взаимодействуют с игровыми объектами.
  • Для стандартных компонентов Unity существуют аналогичные им по функциональности компоненты и системы DOTS (здесь важно отметить, что многие из них еще в разработке и не обладают всей полнотой возможностей стандартных компонентов)
  • Редактировать параметры компонентов во время исполнения игры в инспекторе нельзя. Что довольно-таки неудобно при отладке. Надеюсь это все-таки поправят.

Что за игра без игрока?

Давайте теперь создадим главного персонажа, которым будет управлять наш игрок. Я добавлю новый, пустой игровой объект и назову его Player. Сразу же добавлю туда компонент ConvertToEntity, чтобы не забыть про него.

Для удобства, я создам отдельный, вложенный игровой объект для внешнего вида игрока (т.е. содержащий 3d-модель персонажа). Давайте для начала используем простую капсулу (щелчок правой кнопкой в иерархии, 3D Object⇨Capsule). Чтобы она не проваливалась в землю, ей необходимо поставить Position.Y равным 1.

При создании капсулы, Unity сразу же создает и коллайдер. Но чтобы физика могла управлять нашим игровым объектом, потребуется добавить соответствующий компонент. Его я буду добавлять в родительский объект (Player) и вместо привычного RigidBody я добавлю компонент DOTS, который называется PhysicsBody:

Зомби-шутер на DOTS в Unity

В добавленном компоненте нужно поправить несколько параметров. Во-первых, массу (параметр Mass)  стоит увеличить: я поставлю 70. Во-вторых, я сброшу параметр Angular Damping в 0. Поскольку у нас персонаж будет поворачиваться из кода, мы не хотим, чтобы физика замедляла это движение. Ну и наконец, я поставлю Gravity Factor равным 1.5. В моих экспериментах, если оставить этот параметр равным 1, физика воспринимается как на Луне.

Итак, у нас есть персонаж-капсула. Но как заставить его двигаться? Если помните, я говорил, что в ECS любая логика должна находиться в системах. Нам потребуется написать систему движения игрока. А чтобы система знала, какими сущностями она должна оперировать, мы создадим специальный компонент и назначим его объекту персонажа.

Код компонента (Scripts/Components/PlayerComponent.cs):

using Unity.Entities;
 
[GenerateAuthoringComponent]
public struct PlayerComponent : IComponentData
{
    public float movementSpeed;
    public float rotationSpeed;
}

Очевидно, что он значительно отличается от привычных компонентов. Так, например, необходимо использовать библиотеку Unity.Entities вместо UnityEngine. Взамен класса создается структура. Родительский класс MonoBehaviour был заменен интерфейсом IComponentData. А еще в компоненте нет никаких методов!

Важно также обратить внимание на атрибут GenerateAuthoringComponent. Если его не добавлять, то в целом такой компонент тоже можно использовать. Но его нельзя будет создавать и редактировать в Unity. Именно благодаря этому атрибуту мы теперь сможем в инспекторе добавить наш новый компонент в игровой объект Player:

Зомби-шутер на DOTS в Unity

И даже параметры movementSpeed и rotationSpeed доступны для редактирования! Все, как мы привыкли. На скриншоте я уже проставил туда соответствующие значения (20 и 500).

Давайте теперь реализуем систему (Scripts/Systems/PlayerMovementSystem.cs):

using UnityEngine;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Physics;
using Unity.Transforms;
 
public class PlayerMovementSystem : ComponentSystem
{
    protected override void OnUpdate()
    {
        float deltaTime = Time.DeltaTime;
        float2 input = new float2(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"));
        Entities.ForEach((ref PlayerComponent player, ref LocalToWorld transform, ref PhysicsVelocity velocity) => {
                float3 dir = transform.Forward * input.y * player.movementSpeed * deltaTime;
                velocity.Linear += new float3(dir.x, 0.0f, dir.z);
                velocity.Angular = new float3(0.0f, input.x * player.rotationSpeed * deltaTime, 0.0f);
            });
    }
}

Итак, наша система наследуется от класса ComponentSystem. Это не единственный возможный родительский класс для системы, на другие мы посмотрим чуть позже. Пока важно знать, что, наследуясь от этого класса, система будет работать только в основном потоке и не сможет использовать возможности Job System по распараллеливанию. Но поскольку я здесь обращаюсь к API Unity (Time.DeltaTime и класс Input), мне это и не пригодится.

Код системы располагается в перегруженном методе OnUpdate. Он будет вызываться каждый кадр, примерно как Update в привычных нам компонентах на основе MonoBehaviour.

С помощью метода Entities.ForEach, который будет нашей с вами основной рабочей лошадкой, я могу обработать все сущности, имеющие (в данном случае) компоненты PlayerComponent, LocalToWorld и PhysicsVelocity. Если хотя бы одного из этих компонентов у сущности нет, она не будет обработана этой системой.

Как можно видеть, список компонентов получается напрямую из перечня аргументов лямбды, что очень удобно. Аргументы обязательно должны иметь спецификатор ref. Это связано с тем, что компоненты представлены структурами и без использования этого спецификатора будут получены их копии, изменение которых не приведет к изменению оригинала.

Давайте запустим игру:

Зомби-шутер на DOTS в Unity

Система работает! Персонажем можно управлять! Но погодите, что же это? Почему капсула заваливается?

В стандартной физике Unity мы могли бы использовать параметр Freeze Rotation у Rigidbody, чтобы предотвратить падение персонажа. В физике DOTS такого параметра пока что, к сожалению, нет. Поэтому я создаем еще один компонент и систему, чтобы решить эту проблему.

Компонент (Scripts/Components/FreezeVerticalRotationComponent.cs) мне здесь нужен исключительно как маркер, чтобы обозначить системе, какие именно сущности она должна обрабатывать. Никаких дополнительных данных я в нем хранить не буду:

using Unity.Entities;
 
[GenerateAuthoringComponent]
public struct FreezeVerticalRotationComponent : IComponentData
{
}

А вот код системы (Scripts/Systems/FreezeVerticalRotationSystem.cs):

using UnityEngine;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Jobs;
using Unity.Physics;
 
public class FreezeVerticalRotationSystem : JobComponentSystem
{
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        JobHandle job = Entities.ForEach((ref FreezeVerticalRotationComponent tag, ref PhysicsMass mass) => {
                mass.InverseInertia.xz = new float2(0.0f);
            }).Schedule(inputDeps);
 
        return job;
    }
}

Поскольку эта система не взаимодействует со стандартным API движка, я могу сделать ее многопоточной. Для этого вместо класса ComponentSystem используется родительский класс JobComponentSystem.

Теперь также поменялась и сигнатура метода OnUpdate. Теперь он принимает аргумент JobHandle и возвращает JobHandle. Эти хендлы позволят менеджеру правильно организовать параллельное выполнение этой системы с другими системами. Очень важно не забывать передавать эти хендлы, чтобы избежать возникновения условия гонки (race condition), когда две системы попытаются одновременно работать с одной и той же сущностью.

Здесь также используется Entities.ForEach, но обратите внимание, что дополнительно вызывается метод Schedule, который собственно и запускает выполнение кода нашей системы во вспомогательных потоках.

Добавим компонент FreezeVerticalRotationComponent к объекту Player в редакторе и запустим игру:

Зомби-шутер на DOTS в Unity

И теперь наш персонаж не заваливается.

Иллюзия жизни

Персонаж-капсула — это, несомненно, весело. Но веселее было бы, если бы наш главный герой был больше похож на человека, а главное — был анимирован.

К сожалению, система анимации на DOTS все еще находится на очень ранних этапах разработки и в данном проекте я ее использовать не буду. Воспользуюсь старым добрым Animator Controller.

Для начала, перетащу в качестве дочернего объекта в Player префаб TT_demo/prefabs/TT_demo_police из пакета ToonyTinyPeopleDemo и назначу ему заранее заготовленный контроллер. Я не буду подробно останавливаться на устройстве Animator Controller, приведу здесь лишь скриншот:

Зомби-шутер на DOTS в Unity

Также к объекту TT_demo_police я добавлю компонент ConvertToEntity, установив параметр Conversion Mode в Convert and Inject Game Object. Таким образом, при запуске игры для объекта 3d-модели также будет создана сущность, но, в отличие от игрока и капсулы, сохранится и GameObject. Он, правда,окажется в корне (так как родительский объект превратился в сущность без сохранения GameObject):

Зомби-шутер на DOTS в Unity

И тут есть один нюанс. Если сейчас запустить игру, мы увидим, что при движении нашего персонажа игровой объект с 3d-моделью будет оставаться на месте:

Зомби-шутер на DOTS в Unity

Чтобы это поправить, я добавлю новый компонент и систему.

Компонент (Scripts/Components/CopyTransformComponent.cs) также выполняет роль простого маркера:

using Unity.Entities;
 
[GenerateAuthoringComponent]
public struct CopyTransformComponent : IComponentData
{
}

А система (Scripts/Systems/CopyTransformSystem.cs) просто осуществляет копирование положения сущности в компонент Transform игрового объекта:

using UnityEngine;
using Unity.Transforms;
using Unity.Entities;
 
public class CopyTransformSystem : ComponentSystem
{
    protected override void OnUpdate()
    {
        Entities.ForEach((Entity entity, ref CopyTransformComponent tag, ref LocalToWorld localToWorld) => {
                var transform = EntityManager.GetComponentObject<Transform>(entity);
                transform.position = localToWorld.Position;
                transform.rotation = localToWorld.Rotation;
            });
    }
}

Важным нововведением здесь является добавление аргумента Entity entity в лямбду. Этот аргумент передается без спецификатора ref, поскольку он по сути является лишь числовым идентификатором сущности и его нельзя менять. Но зато его можно использовать совместно с классом EntityManager для манипуляции сущностями и, в данном случае, для получения ссылки на компонент Transform у связанного с сущностью игрового объекта (привязку осуществляет компонент ConvertToEntity при конвертации, поскольку был выбран режим Convert And Inject Game Object).

Зомби-шутер на DOTS в Unity

Теперь я добавлю компонент CopyTransformComponent к игровому объекту TT_demo_police. Игровой объект Capsule я оставлю (так как в нем находится коллайдер), но отключу у него MeshRenderer, чтобы капсула не рисовалась на экране.

Запустим игру:

Зомби-шутер на DOTS в Unity

И да, теперь наш персонаж двигается.

Осталось добавить 3d-модельке игрока анимацию. Как вы, наверное, уже догадались, потребуется создать еще один компонент и систему.

N.B. В начале работы над игрой, использующей паттерн ECS, требуется создавать довольно большое количество систем и компонентов, и может показаться, что это лишняя работа по сравнению с паттерном EC. На самом деле, это не так. Преимущества ECS полностью проявляются, когда накоплена некоторая “критическая масса” компонентов и систем. В некоторой мере мы это увидим и в сегодняшней статье, когда будем реализовывать врагов.

Компонент (Scripts/Components/AnimatedCharacterComponent.cs):

using Unity.Entities;
using UnityEngine;
 
[GenerateAuthoringComponent]
public struct AnimatedCharacterComponent : IComponentData
{
    public Entity animatorEntity;
}

Система (Scripts/Systems/AnimatedCharacterSystem.cs):

using UnityEngine;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Physics;
 
public class AnimatedCharacterSystem : ComponentSystem
{
    protected override void OnUpdate()
    {
        Entities.ForEach((Entity entity, ref AnimatedCharacterComponent character, ref PhysicsVelocity velocity) => {
                var animator = EntityManager.GetComponentObject<Animator>(character.animatorEntity);
                animator.SetFloat("speed", math.length(velocity.Linear));
            });
    }
}


Ничего особенно нового здесь нет. Стоит обратить внимание, что компонент AnimatedCharacterComponent следует добавлять на родительскую сущность (Player) — ему требуется получать значения скорости из компонента PhysicsVelocity физического движка. А вот аниматор прикреплен к дочерней сущности (TT_demo_police). Чтобы получить доступ из одной сущности к другой, я просто использую переменную типа Entity в компоненте AnimatedCharacterComponent. Благодаря механизму ConvertToEntity, в редакторе сцены я смогу проставить туда игровой объект, а при запуске игры он автоматически сконвертируется в ссылку на соответствующую сущность:

Зомби-шутер на DOTS в Unity

Запустив игру, убеждаемся, что теперь анимация работает:

Зомби-шутер на DOTS в Unity

А я все гляжу, глаз не отвожу

Я сейчас ненадолго отвлекусь и наведу немного красоты: добавлю контента в уровень и настрою камеру.

Для уровня добавим интересных объектов из набора SimpleNaturePack, чтобы не бегать по пустой плоскости:

Зомби-шутер на DOTS в Unity

Камеру же я настрою с использованием Cinemachine. Подробно останавливаться на этом я не буду, приведу лишь пример настройки.

Для начала, я создам отдельный пустой игровой объект внутри TT_demo_police и поставлю его примерно на уровень головы — он будет использоваться для “прицеливания” камеры. Назову его CameraTarget.

Теперь можно создать виртуальную камеру. Для этого выберу пункт меню Cinemachine⇨Create Virtual Camera (если этого меню у вас нет, проверьте, установили ли вы пакет Cinemachine).

В инспекторе для созданной виртуальной камеры проставлю TT_demo_police в поле Follow и CameraTarget в поле Look At. В разделе Body поставлю Follow Offset X=0, Y=9, Z=-15 и Yaw Damping в 0.25.

Стреляй, Глеб Егорыч!

Бегать по карте — это здорово. Но шутер не был бы шутером, если бы там нельзя было стрелять. А у нас все еще нельзя. Нужно срочно это исправлять!

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

Сам пистолет прикреплен к анимированному скелету и его легко найти в иерархии объекта TT_demo_police:

Зомби-шутер на DOTS в Unity

Я создам внутри пистолета пустой объект (назову его GunHole) и задам ему положение (-0.1174, 0, 0.394) и поворот (0, 0, 90). Этот объект я буду использовать как референс для кода, создающего пули.

Но есть небольшая проблема: так как TT_demo_police (родительский объект) уже содержит в себе ConvertToEntity с режимом Convert And Inject Game Object, я не смогу сделать то же самое с моим вновь созданным объектом, Unity разрешает только одну такую конвертацию для иерархии объектов.

Поэтому, я создам новый объект Shooter внутри объекта Player и прикреплю к нему небольшой компонент на основе MonoBehaviour (Scripts/Shooter.cs):

using UnityEngine;
 
public class Shooter : MonoBehaviour
{
    public Transform gunHole;
}

И пропишу в него ссылку на GunHole в инспекторе.

А поскольку Shooter находится вне объекта TT_demo_police, я могу также добавить в него компонент ConvertToEntity:

Зомби-шутер на DOTS в Unity

Кроме положения дула, мне также потребуется и префаб пули. В префабах также можно использовать ConvertToEntity — такие префабы превратятся в сущности с компонентом Prefab при загрузке игры и будут исключены из обработки, но будут загружены и проинициализированы. Также, по аналогии с обычными префабами, из таких префабов-сущностей можно создавать обычные сущности и работает это гораздо быстрее, чем метод Instantiate!

Поэтому я создам префаб из двух игровых объектов: родительский будет содержать в себе компоненты ConvertToEntity и PhysicsBody, а дочерний — меш и коллайдер капсулы:

Зомби-шутер на DOTS в Unity

Из важного: коллайдер у пули настроен как триггер, а в параметры PhysicsBody внесены небольшие изменения: Linear Damping выставлен в 0, чтобы пуля не теряла скорость со временем, и Gravity Factor также выставлен в 0.

В объект Shooter также добавлю компонент BulletPrefabComponent (Scripts/Components/BulletPrefabComponent.cs):

using Unity.Entities;
 
[GenerateAuthoringComponent]
public struct BulletPrefabComponent : IComponentData
{
    public Entity prefab;
    public float speed;
}

Этот компонент позволит нам получить доступ к префабу из ECS-кода, а также — задать скорость пули (сразу можно ее проставить в инспекторе, я использовал значение 30).

И еще один компонент я буду проставлять непосредственно на пули (Scripts/Components/Bullet.cs):

using Unity.Entities;
using Unity.Mathematics;
 
[GenerateAuthoringComponent]
public struct BulletComponent : IComponentData
{
    public float3 speed;
    public bool destroyed;
}

Здесь параметр speed будет определять вектор направления движения пули и ее скорость, а флаг destroyed будет использоваться для обозначения пуль, столкнувшихся с препятствием. Зачем нужен этот флаг и как он используется я объясню чуть дальше.

Теперь для выстрелов нам потребуется система (Scripts/Systems/PlayerShootingSystem.cs):

using UnityEngine;
using Unity.Entities;
using Unity.Transforms;
 
public class PlayerShootingSystem : ComponentSystem
{
    protected override void OnUpdate()
    {
        if (!Input.GetButtonDown("Fire1"))
            return;
 
        Entities.ForEach((Entity entity, ref BulletPrefabComponent bulletPrefab) => {
                var shooter = EntityManager.GetComponentObject<Shooter>(entity);
                if (shooter == null)
                    Debug.LogError("BulletPrefabComponent is missing Shooter component.");
                else {
                    Entity bullet = EntityManager.Instantiate(bulletPrefab.prefab);
                    EntityManager.SetComponentData(bullet, new Translation{ Value = shooter.gunHole.position });
                    EntityManager.SetComponentData(bullet, new Rotation{ Value = shooter.gunHole.rotation });
                    EntityManager.AddComponentData(bullet, new BulletComponent{ speed = shooter.gunHole.forward * bulletPrefab.speed });
                }
            });
    }
}

Если сейчас запустить игру, мы увидим, что при выстреле пули действительно появляются:

Зомби-шутер на DOTS в Unity

Но они не двигаются! Как исправить? Правильно — завести систему (Scripts/Systems/BulletSystem.cs):

using Unity.Entities;
using Unity.Jobs;
using Unity.Physics;
 
public class BulletSystem : JobComponentSystem
{
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        JobHandle job = Entities.ForEach((ref BulletComponent bullet, ref PhysicsVelocity velocity) => {
                velocity.Linear = bullet.speed;
            }).Schedule(inputDeps);
 
        return job;
    }
}

Как видите, тут все очень просто: задаем пулям постоянную скорость и пусть себе летят.

Ожившие мертвецы

Замечательно, пули у нас есть. Но стрелять пока не в кого. Давайте создадим зомби! 

Я создам в корне сцены пустой игровой объект Zombie и настрою его по аналогии с главным героем. Сразу положу внутрь префаб TT_demo/prefabs/TT_demo_zombie. Также создам капсулу (Position 0, 1, 0) и сразу отключу у нее MeshRenderer. В сам объект Zombie добавлю компоненты PhysicsBody, FreezeVerticalRotationComponent, AnimatedCharacterComponent и ConvertToEntity. Также компонент ConvertToEntity надо добавить и в объект TT_demo_zombie, указав режим Convert And Inject Game Object. Туда же нужно добавить и CopyTransformComponent. У PhysicsBody поставлю Mass=70. В общем, очень похоже на настройку объекта Player, только без Shooter.

Приятно, что большинство компонентов и систем уже созданы. Но потребуется создать еще несколько компонентов, уникальных для зомби.

Прежде всего, нужно добавить врагу жизни (Scripts/Components/HealthComponent.cs):

using Unity.Entities;
 
[GenerateAuthoringComponent]
public struct HealthComponent : IComponentData
{
    public int value;
}

И создадим систему (Scripts/Components/AnimatedCharacterDeathSystem.cs), которая будет отыгрывать анимацию смерти персонажа, когда счетчик жизней достигнет 0:

using UnityEngine;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Physics;
 
public class AnimatedCharacterDeathSystem : ComponentSystem
{
    protected override void OnUpdate()
    {
        Entities.ForEach((Entity entity, ref AnimatedCharacterComponent character, ref HealthComponent health) => {
                var animator = EntityManager.GetComponentObject<Animator>(character.animatorEntity);
                if (health.value <= 0) {
                    animator.SetTrigger("die");
                    EntityManager.RemoveComponent<HealthComponent>(entity);
                }
            });
    }
}

Для проверки я добавлю зомби компонент HealthComponent с количеством жизней 0 и запущу игру. Зомби должен сразу умереть:

Зомби-шутер на DOTS в Unity

Превосходно, код работает! Поставлю зомби, например, 3 жизни и займусь реализацией проверки столкновения пули и врага.

Для этого мне потребуется самая сложная в этой статье система (Scripts/Systems/BulletDamageSystem.cs). Давайте сначала взглянем на нее, а потом будем разбираться:

using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Physics.Systems;
using Unity.Physics;
 
public class CollisionEventSystem : JobComponentSystem
{
    struct CollisionEventSystemJob : ITriggerEventsJob
    {
        public ComponentDataFromEntity<BulletComponent> bulletRef;
        public ComponentDataFromEntity<HealthComponent> healthRef;
 
        public void Execute(TriggerEvent triggerEvent)
        {
            Entity hitEntity, bulletEntity;
            if (bulletRef.HasComponent(triggerEvent.EntityA)) {
                hitEntity = triggerEvent.EntityB;
                bulletEntity = triggerEvent.EntityA;
            } else if (bulletRef.HasComponent(triggerEvent.EntityB)) {
                hitEntity = triggerEvent.EntityA;
                bulletEntity = triggerEvent.EntityB;
            } else
                return;
 
            var bullet = bulletRef[bulletEntity];
            bullet.destroyed = true;
            bulletRef[bulletEntity] = bullet;
 
            if (healthRef.HasComponent(hitEntity)) {
                var health = healthRef[hitEntity];
                health.value--;
                healthRef[hitEntity] = health;
            }
        }
    }
 
    BuildPhysicsWorld buildPhysicsWorldSystem;
    StepPhysicsWorld stepPhysicsWorld;
    EndSimulationEntityCommandBufferSystem endSimulationCommandBuffer;
 
    protected override void OnCreate()
    {
        buildPhysicsWorldSystem = World.GetOrCreateSystem<BuildPhysicsWorld>();
        stepPhysicsWorld = World.GetOrCreateSystem<StepPhysicsWorld>();
        endSimulationCommandBuffer = World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();
    }
 
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var job = new CollisionEventSystemJob();
        job.bulletRef = GetComponentDataFromEntity<BulletComponent>(isReadOnly: false);
        job.healthRef = GetComponentDataFromEntity<HealthComponent>(isReadOnly: false);
        var jobResult = job.Schedule(stepPhysicsWorld.Simulation, ref buildPhysicsWorldSystem.PhysicsWorld, inputDeps);
 
        var commandBuffer = endSimulationCommandBuffer.CreateCommandBuffer().AsParallelWriter();
        var result = Entities.ForEach((Entity entity, int entityInQueryIndex, ref BulletComponent bullet) => {
                if (bullet.destroyed)
                    commandBuffer.DestroyEntity(entityInQueryIndex, entity);
            }).Schedule(jobResult);
 
        endSimulationCommandBuffer.AddJobHandleForProducer(result);
        return result;
    }
}

Первая (и весьма важная) часть — это структура CollisionEventSystemJob. Она представляет собой задачу для системы Job System. В эту задачу физический движок передает перечень столкновений между физическими объектами. Код задачи в методе Execute проверяет, есть ли в одном из столкнувшихся объектов компонент BulletComponent. Если, действительно, один из объектов — пуля, то ей проставляется флажок destroy, а у второго объекта вычитаются жизни (если, конечно, у него есть соответствующий компонент; столкновение с деревом приводит просто к уничтожению пули).

Стоит обратить внимание на два момента: во-первых, обращение к компонентам происходит через класс ComponentDataFromEntity. Это нужно, потому что задача выполняется параллельно и движок должен отслеживать обращения к компонентам и не допускать одновременной работы с одним и тем же компонентом из разных потоков. Во-вторых, по той же причине, нельзя уничтожать пули сразу при обнаружении столкновения. Вместо этого, у пули проставляется флажок, который проверяется уже в безопасном окружении, где компонент может быть удален.

В методе OnUpdate я сначала прошу физический движок сообщить информацию о столкновениях и передаю ее в CollisionEventSystemJob для обработки. Как только эта работа закончена, я проверяю были ли уничтожены какие-то пули и если были, то передаю их в в командный буфер для фактического уничтожения этих сущностей.

Беги, Лола, Беги!

Последняя важная составляющая логики врага, которая у нас все еще отсутствует — это движение. Давайте же реализуем охоту на игрока!

Для поиска пути я буду использовать технологию NavMesh. Опять же, я не буду останавливаться на ней подробно, скажу лишь, что запечь NavMesh можно в окне Navigation, которое можно открыть через меню Window⇨AI⇨Navigation.

Я создам внутри игрового объекта Zombie пустой объект NavMeshAgent и добавлю в него компонент NavMeshAgent. Также я добавлю компонент ConvertToEntity с режимом Convert And Inject Game Object.

В дополнение к этому, мне потребуется новый компонент NavMeshAgentComponent (Scripts/Components/NavMeshAgentComponent.cs):

using Unity.Entities;
 
[GenerateAuthoringComponent]
public struct NavMeshAgentComponent : IComponentData
{
    public Entity moveEntity;
}

Единственный параметр здесь — это сущность, которую этот NavMeshAgent будет двигать. Добавив этот компонент в дочерний объект NavMeshAgent, в качестве moveEntity я укажу объект Zombie.

Еще один новый компонент (Scripts/Components/FollowTargetComponent.cs):

using Unity.Entities;
 
[GenerateAuthoringComponent]
public struct FollowTargetComponent : IComponentData
{
}

Этот компонент я буду использовать просто как маркер: только враги, имеющие этот компонент, будут преследовать игрока.

Последним штрихом будет написание соответствующей системы (Scripts/Systems/FollowPlayerSystem.cs):

using UnityEngine;
using UnityEngine.AI;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
 
public class FollowPlayerSystem : ComponentSystem
{
    protected override void OnUpdate()
    {
        float3 targetPosition = float3.zero;
        Entities.ForEach((Entity entity, ref LocalToWorld transform, ref FollowTargetComponent tag) => {
                targetPosition = transform.Position;
            });
 
        Entities.ForEach((Entity entity, ref NavMeshAgentComponent agent) => {
                var navMeshAgent = EntityManager.GetComponentObject<NavMeshAgent>(entity);
                if (navMeshAgent != null) {
                    navMeshAgent.SetDestination(targetPosition);
                    EntityManager.SetComponentData(agent.moveEntity, new Translation{ Value = navMeshAgent.transform.position });
                }
            });
    }
}

Запускаем, проверяем:

Зомби-шутер на DOTS в Unity

Конец

Сегодня мы с вами познакомились с платформой DOTS и паттерном ECS. Надеюсь, мне удалось заинтересовать вас этими технологиями и показать, как их использовать в реальном игровом проекте.

Скачать исходный код проекта можно с GitHub: https://github.com/zapolnov/dots-zombie-shooter

Желаю удачи в ваших экспериментах!

Зомби-шутер на DOTS в Unity