Что такое ECS и с чем его компилят
Привет! Эта статья — первая в блоге MY.GAMES, международного разработчика и издателя видеоигр. Здесь мы объединим наработки и экспертизу всех 14 наших игровых студий. Впрочем, будем рассказывать не только о внутренней кухне экосистемы MY.GAMES, но и о том, чем живет геймдев в России и мире в целом, о современных трендах и новостях индустрии.
Меня зовут Андрей Боронников, и я работаю в команде экспериментальных проектов студии IT Territory, где занимаюсь разработкой игровых прототипов. Как-то раз в процессе поиска новых подходов к быстрой разработке прототипов мы вспомнили о таком замечательном подходе, как Entity Component System. О нем уже многое было раньше сказано, но мало что показано.
О чем сегодня пойдет речь?
Для начала мы поговорим, куда в общем и целом движется Unity и зачем им Data Orientation System. Рассмотрим принцип работы ECS, зачем он нужен, кто вообще его использует и наиболее популярные реализации — и, конечно же, примеры практического применения ECS в играх.
Итак, начнем.
Куда катится Unity?
Вообще говоря, наибольший бум обсуждения ECS начался в тот момент, когда компания Unity Technologies объявила о намерении максимально глубоко интегрировать этот подход в разработку игр на их движке. Но почему они вообще решили двигаться в этом направлении?
Unity Technologies известна тем, что старается разработать универсальный движок для создания любых игр: от казуальных match-3 до фотореалистичных AAA-проектов.
Unity старается угодить нуждам всех, дать людям возможность максимально эффективной и комфортной работы над своим движком. А потому сейчас сосредоточена преимущественно на двух проблемах:
Скорость разработки игры;
Оптимизация работы игры.
И если скорость разработки игры на самом деле в большей степени зависит непосредственно от пайплайнов разработки конкретных студий, то с оптимизацией работы игры Unity как раз-таки пытается нам помочь.
Как можно оптимизировать игру?
Unity Technologies выделила три основных направления:
Оптимизация математических вычислений: чем меньше система будет проводить математических операций, тем быстрее будет работать игра;
Распределение вычислений на несколько отдельных потоков, чтобы исполнение проходило одновременно в двух и более параллелях, тем самым ускоряя работу игры;
Правильная работа с данными, чтобы уменьшить число обращений к шине данных, а значит и затраты процессора.
В Unity все это продумали и разработали несколько программных решений, формирующих Data-Oriented Tech Stack — набор технологий, цель которого — максимально эффективно использовать возможности современного железа.
Burst Compiler — это новая основанная на LLVM вычислительная технология бэкенд-компиляции, превращающая код C# в глубоко оптимизированный машинный код. Она выпускается в виде пакета Unity и интегрируется в движок с помощью менеджера пакетов.
Для упрощения разработки кода, распределенного на отдельные потоки, Unity выпустила библиотеку под названием Job System. Она позволяет более безопасно работать с многопоточным кодом.
Но по большей части и особенно подробно мы поговорим о работе с данными. По этом аспекте Unity пытается использовать, доработать и внедрить в разработку игр такой архитектурный паттерн, как Entity Component System. Unity попыталась реализовать набор некоторых оптимизаций и улучшений со своей стороны, но получилось ли им реализовать то, что они задумали, — это пока еще большой вопрос.
Однако для полного понимания того, о чем мы будем говорить, рассмотрим основные принципы ECS и зачем он вообще нужен.
Так что же такое ECS?
Итак, как уже фигурировало выше, название ECS состоит из трех понятий: Entity, Component и System — именно эти три сущности и представляют работу всей архитектуры.
Entity является некоторой сущностью, которая существует в мире игры — как GameObject, если проводить аналогию с Unity. Но в ECS это по сути просто пустой указатель на набор компонентов (Components), которые привязываются к конкретной Entity. А компоненты — это набор данных: например, для расчета гравитации это ускорение, для позиции — координаты.
Вся логика содержится в системах (System) — это классы, выполняющие какую-то конкретную задачу, но делающие это особым образом.
Gravity System — это система, часть игры, которая отвечает за воздействие на объекты ускорения свободного падения. Она обращается к игровому пространству, в котором хранятся все наши Entity, и запрашивает только те, которые содержат в себе компоненты Gravity. Другими словами, Gravity — это компонент, который говорит, что наш объект подвержен воздействию гравитации. Для самого обращения к сущностям можно провести аналогию с SQL-запросами, где мы, формируя запрос, указываем только наборы интересующих нас данных. После этого система получает набор нужных Entity, а после проводит необходимые математические операции. На схеме это изменения координат объекта, находящегося под воздействием силы тяжести Земли. Она записывает полученный результат в компонент Position и передает управление уже следующей системе, которая, в свою очередь, так же обращается ко всем Entity, удовлетворяющим ее запросам.
Таким образом, весь игровой цикл зависит от последовательной передачи управления от одной системы к другой, и мы можем точно проследить весь путь обработки данных. Системы между собой могут в принципе не взаимодействовать и не знать друг о друге, а общаться посредством ивентов или тех же самых компонентов, которые передаются в общий контейнер игрового мира. Кто-то из моих коллег привел аналогию с шинами данных. Когда мы оставляем какое-то сообщение на общей шине, системы, которым интересны сообщения конкретного типа, его обрабатывают. На мой взгляд, это наиболее понятное пояснение принципа общения систем между собой.
Одно из важнейших преимуществ ECS — это низкая связность кода. Благодаря этому можно отключать ненужные системы, фичи и игровые механики, если того требует логика игры, и все остальное при этом будет работать без перебоев. Например, если отключить систему, которая убивает игрока по окончании его здоровья, игрок перестанет умирать. Как бы активация чита. Это относится к любым игровым механикам.
Например, как на схеме ниже. Это пример плохого кода, который достаточно просто написать, не утруждая себя предварительным планированием архитектуры. Там очень высокая связность, и для того, чтобы убрать из цепочки какой-то отдельный класс, придется тратить время на то, чтобы соединить оставшиеся элементы.
А это код, написанный на архитектуре ECS. Каждый кругляш — это отдельная система. И в целом эту цепочку очень легко и удобно видоизменять, так как все системы идут одна за другой.
На схеме ниже показано, как легко можно просто убрать систему 2, и работа алгоритма автоматически будет передаваться с первой системы на третью. А захотим мы вставить пятую систему — просто вставляем ее после 3, и все отлично работает.
Второе преимущество ECS — возможность быстро распараллелить вычисления над большим объемом данных.
Например у нас есть 3 системы:
Cube System;
Triangle System;
Sphere System.
Cube System обрабатывает кубы, порождая треугольники для Triangle System и сферы для Sphere System в зависимости от содержания коробки, которая подаётся ей на вход. И просто глядя на схему ниже, если представить ее как конвейерную линию, а системы — отдельными заводами, можно сразу же предположить, что для потоковой обработки данных достаточно распараллелить эти линии и построить рядом еще несколько отдельных заводов, а входящее сырье в виде кубов, треугольников и сфер распределять по этим отдельным заводам, которые представляют из себя набор операций.
Третье преимущество, которое так полезно в геймдеве, — это возможность комбинирования.
В классическом представлении объектно-ориентированного программирования при написании игры вы будете наследовать один класс за другим. Например, от Renderable наследуются как классы Moveable и StaticObject, которые описывают движущиеся объекты и статические, так и ForceField — невидимая точку, перемещающаяся в пространстве. А если понадобится реализовать другие сущности — пули, астероиды и т. д., — то их мы будем наследовать непосредственно от Moveable, но ему также нужен и Renderable. А если мы захотим реализовать еще и пришельцев с искусственным интеллектом?
При наращивании этого дерева мы можем сталкиваться с различными проблемами, из-за которых структура наследования нас будет не очень-то устраивать. Конечно можно было бы решить эту задачу и другими способами, но в ECS достаточно просто накинуть компоненты на Entity, которые будут идентифицировать их как объекты, способные выполнять конкретные операции. Например, мы можем создать MoveableObject, которому добавляем только передвижение в необходимом физическом направлении, и получим ForceField. Или можно присвоить элементу теги Renderable и Movable, которые можно использовать для отображения перемещающихся объектов типа Asteroid. Если хотим — добавляем ему еще компонент AI и получаем противника EnemyAlien без сложной диаграммы классов. Или мы можем создать статичное разрушаемое препятствие только с помощью накидывания Renderable и Damageble. А если захотим его перемещать — просто накинем MoveableObject.
Вам этого мало? Хотите заставить перемещать по определенной траектории или наделить ИИ? Просто привяжите компонент-тег AI, и он будет всё это делать! Передумали? Или просто захотели отключить перемещение? Давайте удалим компонент прямо во время игры. Он просто перестанет перемещаться, так как система обработки MoveableObject просто его не увидит и пропустит. Таким образом геймдизайнеры получают большую вариативность и возможность комбинировать любые интересующие нас Entity, чтобы собрать какую-то уникальную сущность без вреда для программистов.
Итак, подытожим, какие преимущества есть у ECS:
Низкая связность кода;
Простота написания кода для распределенных вычислений;
Возможность комбинирования свойств сущностей.
А где и как используется ECS?
Архитектура ECS сегодня встречается во многих играх. Один из первых примеров, который хочется привести здесь — Dungeon Siege. В ней разработчики старались так организовать архитектуру, чтобы можно было накидывать отдельные сущности (заклинания, эффекты) юнитов так, чтобы эти сущности между собой комбинировались и обрабатывались особым образом — порой даже таким, каким не предусматривал разработчик изначально. Это и были первые шаги к архитектуре ECS.
Из более новых примеров можно привести Operation Flashpoint. Там разработчики на полную использовали ECS с целью реализации работы с сетевым кодом, поскольку для синхронизации на ECS достаточно просто передавать набор сущностей с их компонентами, и уже будет автоматически проще синхронизировать работу.
Пример из современности — Raid: Shadow Legends. Там разработчики использовали ECS для того, чтобы оптимизировать скорость разработки, тем самым показывая, что при разработке новой фичи вам не обязательно перелопачивать весь предыдущий код — достаточно рядом подключить новую систему, накинуть новые компоненты, и таким образом получится новая фича, что значительно ускоряет разработку. А возможность комбинирования дала геймдизайнерам простор для творчества.
А какие есть реализации ECS?
Начнем, конечно же, с решения, предлагаемого самими Unity в DOTS. Компания долго и упорно старается разработать свою ECS, но пока далека от идеальной реализации. Каждые полгода выходят обновления, полностью меняющие API, что сильно замедляет полноценную разработку системы на библиотеке движка.
Она старается реализовать более эффективную работу с памятью, что на самом деле ближе к Data-Oriented Design, чем к ECS. А потому особым моментом внутри реализации Unity является использование Chunks, в которые помещаются отдельные архетипы. Архетип — это критерий отбора существующих сущностей, содержащих в себе конкретный набор компонентов.
На картинке мы видим сущности A, B и C, которые разделяются на два архетипа M и N. Система работает таким образом, чтобы при изменении архетипа сущности она автоматически подтягивалась и размещалась в интересующий нас Chunk, чтобы процессор мог проходить их по очереди, не перескакивая на отдельные элементы. То есть, их реализация ECS «под капотом» копирует куски памяти при добавлении/удалении компонентов на Entity, чтобы получать сплошные куски памяти для прохода по ним без разрывов, группируя их в отдельные Chunk.
Но с этим пока далеко не все гладко, поэтому Unity до сих пор никак не может закончить свою реализацию ECS. Вероятнее всего, это связано с проблемами такого рода, как если мы будем перекладывать компоненты на сотнях тысяч Entity, такая система убьет всю скорость работы системы как раз из-за перекладывания кусков памяти между вызовами систем. То есть, на статичных данных скорость работы действительно будет иметь значительное преимущество, но при работе с реальной бизнес-логикой возникнут проблемы.
И, возможно, по причине отсутствия готового решения, но большого списка преимуществ данной архитектуры у комьюнити так много разнообразных реализаций ECS. Например:
По популярности Entitas в разы превосходит все остальные библиотеки — но она уже очень давно не обновляется и не поддерживается. В марте этого года разработчики опубликовали некогда платный кодогенератор в открытый исходный код для общего пользования, так что по факту она все еще остается жить в формате community-driven.
LeoECS — другая популярная библиотека, к слову, русскоязычного автора, сопровождающаяся довольно понятно документацией, делающей эту библиотеку наиболее предпочтительной для новичков. Ее-то мы и будем подробно рассматривать далее. Разрабатывается, помимо классической, еще и лайт-версия, которая будет содержать в себе только ядро, заточенное на быстродействие, и в целом нацеленная на то, чтобы дать больший контроль разработчикам над системой. Но далее в статье мы обратимся именно к классической версии.
Разбираемся в коде ECS. С чего начать?
Первым делом мы скачиваем с GitHub набор следующих библиотек:
LeoECS — само ядро (мы будем брать полную версию, не лайт);
Unity Integration — библиотеку, позволяющую интегрировать LeoECS в Unity,
EcsUI — библиотеку, позволяющую работать с UI-элементами Unity.
Небольшое отступление: LeoECS — движконезависимая библиотека, которую можно использовать не только в Unity: можно написать хоть свой игровой движок в обвязке LeoECS. Но рассматривать для удобства мы его будем все-таки в связке с Unity.
Добавляем все это в Unity мы через Package Manager — достаточно указать URL на GitHub, и все автоматически подтянется в движок.
Теперь создадим точку входа. В качестве примера возьмем достаточно простой проект — клон нашумевшей в свое время игры Flappy Bird. В нашей игре вместо птицы будет летающий куб, который должен проскочить сквозь трубы. Итоговый проект полностью написан с использованием LeoECS, найти его можно на GitHub в моем профиле.
Ниже в статье я буду делать ссылки на конкретные коммиты, где добавлял описываемые в данной статье файлы. Каждый коммит — отдельный этап логического блока, на которые я старался разделить весь проект для упрощения понимания логики написания кода на основе LeoECS. Поэтому на некоторых коммитах проект не будет работать, так как потребует следующего этапа.
В первую очередь разберемся, как вообще устроена игра. У нас имеется класс EcsStartUp
, который наследует MonoBehaviour и висит на GameObject в сцене. Он представляет собой обычную точку входа:
sealed class EcsStartup : MonoBehaviour
EcsStartup создается при инициализации внутри Unity при помощи Unity Integration: LeoECS → Create Startup template.
Далее мы видим приватные классы EcsWorld
(контейнер, который содержит в себе все Entity) и EcsSystems
(набор систем, которые будут работать внутри нашей игры). В Start мы инициализируем World
и EcsSystems
, чтобы с помощью внедрения зависимостей подтянулся необходимый контейнер.
Показать код
using Leopotam.Ecs;
using UnityEngine;
sealed class EcsStartup : MonoBehaviour
{
private EcsWorld _world;
private EcsSystems _systems;
private void Start()
{
_world = new EcsWorld();
_systems = new EcsSystems(_world);
#if UNITY_EDITOR
Leopotam.Ecs.UnityIntegration.EcsWorldObserver.Create(_world);
Leopotam.Ecs.UnityIntegration.EcsSystemsObserver.Create(_systems);
#endif
_systems
.Init();
}
private void Update()
{
_systems?.Run();
}
private void OnDestroy()
{
if (_systems != null)
{
_systems.Destroy();
_systems = null;
_world.Destroy();
_world = null;
}
}
}
После создания систем их необходимо инициализировать. Поскольку LeoECS не зависит от выбора движка, нам необходимо уведомлять системы об обновлении кадра с помощью метода Run
.
В случае уничтожения компонента при помощи destroy
мы докладываем об этом системам и миру. Таким образом, мы можем увидеть точку входа.
Внимательный читатель заметит, что в дефайне UNITY_EDITOR
мы создали какие-то Observer’ы. Из Unity Integration добавляются два специальных объекта, при описании которых можно посмотреть, какие сущности есть в мире, какие фильтры для систем и сами системы работают. Тут же можно их включить и выключить — и вообще очень полезная штука для дебага. При запуске приложения из редактора создаются новые неактивные GameObject’ы, в которых можно посмотреть общее состояние игры, а именно — набор Entities, и выбрать, какие системы включить или выключить для более удобного моделирования ситуаций и дебага.
Но как именно работают системы? Для этого я специально описал искусственную систему DemoSystem:
Показать код
using Leopotam.Ecs;
using UnityEngine;
namespace Systems.Demo
{
public class DemoSystem : IEcsPreInitSystem, IEcsInitSystem, IEcsRunSystem, IEcsDestroySystem, IEcsPostDestroySystem
{
public void PreInit()
{
Debug.Log("PreInit is analogue Awake");
}
public void Init()
{
Debug.Log("Init is analogue Start");
}
public void Run()
{
Debug.Log("Run is analogue Update");
}
public void Destroy()
{
Debug.Log("Destroy is analogue Destroy");
}
public void PostDestroy()
{
Debug.Log("PostDestroy are no analogues in Unity. Called after destruction");
}
}
}
Когда мы хотим описать конкретную систему внутри Unity с использованием LeoECS, нам необходимо описать, как именно она должна работать. Для этого нужно реализовать конкретные интерфейсы. Таких интерфейсов несколько: IEcsPreInitSystem, IEcsInitSystem, IEcsRunSystem, IEcsDestroySystem и IEcsPostDestroySystem. У каждого интерфейса есть свой метод, имеющий свой аналог в Unity. В системе можно реализовывать как от один, так и несколько интерфейсов. Каждый из них описывает сигнатуру метода, который вызывается в определенный момент кадра в последовательности PreInit, Init, Run, Destroy, PostDestroy. Если производить аналогию с юнити — это Awake, Start, Update, Destroy, соответственно, кроме разве что PostDestroy — у него нет аналога в Unity, а вызывается он в ECS после метода Destroy.
Для того, чтобы запустить DemoSystem в работу, необходимо подключить ее к _systems
в EcsStartup
:
_systems
.Add(new DemoSystem())
.Init();
Теперь, если мы запустим сцену, в консоли мы увидим сообщения о том, что реализованные методы отрабатываются в нужное время и в нужном месте.
Но пора внести интерактивность. Давайте посмотрим, как передавать управление внутрь нашей игры. Делать мы это будем с помощью обычного статического класса Input через систему, которую мы для этого создадим. Класс системы KeyInputSystem реализует IEcsRunSystem
, внутри которого мы прослушиваем нажатие любой кнопки:
public class KeyInputSystem : IEcsRunSystem
{
private EcsWorld _world = null;
public void Run()
{
if (Input.anyKeyDown)
{
_world.NewEntity().Get<AnyKeyDownTag>(); //read it in DemoSystem
}
}
}
Если нажата хоть какая-то кнопка, то внутри EcsWorld
создается новая сущность, и метод Get пытается обратиться к компоненту, тип которого мы указали в generic type. Get работает по принципу «ленивой инициализации»: возвращает существующий компонент или создает и возвращает новый, если его не было. Другими словами, когда мы создаем пустую Entity и запрашиваем какой-то компонент, он накидывается в дефолтном состоянии.
Компоненты в LeoECS представлены в виде отдельных структур:
public struct AnyKeyDownTag : IEcsIgnoreInFilter
{
}
Сами же эти эвенты можно получить в DemoSystem этого же коммита. Туда мы добавили EcsFilter<AnyKeyDownTag> _inputFilter
и переработали метод Run():
private EcsFilter<AnyKeyDownTag> _inputFilter = null;
public void Run()
{
if (!_inputFilter.IsEmpty())
{
Debug.Log("The button was pressed");
}
}
EcsFilter
— это сущность, которая хранит в себе ссылки на Entity, удовлетворяющие описанному правилу. В данном случае через нее можно получить все Entity, которые имеют на себе AnyKeyDownTag
. В Update, если фильтр не пустой, будем отправлять сообщение в лог, что нажата какая-то клавиша.
Возвращаясь немного назад, стоит отметить, что мы пометили структуру интерфейсом IEcsIgnoreInFilter
для того, чтобы убрать аллокации кешей в фильтрах под данный тип компонентов, так как в них все равно нет данных. Но тут можно обратить внимание, что сообщения сыпятся постоянно. А все потому, что в KeyInputSystem
мы создали сущность с компонентом AnyKeyDownTag
. Но нигде не уничтожили. В итоге _inputFilter
будет всегда не пустым после нажатия клавиши. Для того, чтобы это исправить в этом коммите, внутри EcsStartup
мы добавили OneFrame<AnyKeyDownTag>()
. Это означает, что в каждом кадре будет осуществляться удаление всех компонентов AnyKeyDownTag
со всех сущностей.
_systems
.OneFrame<AnyKeyDownTag>()
.Add(new KeyInputSystem())
.Add(new DemoSystem())
.Init();
Вообще, тут стоит поговорить о порядке инициализации EcsSystems
. В конструктор мы отправляем экземпляр EcsWorld
, который подтягивается через DI во все системы, которые мы подключаем к EcsSystems
. Поэтому private EcsWorld _world = null;
нигде не задаётся, но при этом не выпадает NRE при выполнении.
Затем через Add мы добавляем системы, и эти системы будут выполняться в том порядке, в котором мы их и указали. То есть, сейчас сначала отрабатывается KeyInputSystem
, а потом DemoSystem
. Ну а после добавления OneFrame перед запуском систем в Run всегда будет обрабатываться удаление всех компонентов на сущностях.
Важный момент! Сущности не могут существовать без компонентов, а потому в LeoECS они прекратят свое существование, если к концу выполнения кадра останутся пустыми.
Ну а теперь давайте начнем работать непосредственно с Unity. Для начала создадим PrefabFactory. Это специальная фабрика для спавна объекта, к которой мы будем обращаться из мира ECS. Она будет висеть в игровом пространстве сцены, и к ней будет обращаться наша система, занимающаяся спавном.
Посмотреть код
public class PrefabFactory : MonoBehaviour
{
private EcsWorld _world;
public void SetWorld(EcsWorld world)
{
_world = world;
}
public void Spawn(SpawnPrefab spawnData)
{
GameObject gameObject = Instantiate(spawnData.Prefab, spawnData.Position, spawnData.Rotation, spawnData.Parent);
var monoEntity = gameObject.GetComponent<MonoEntity>();
if (monoEntity == null)
return;
EcsEntity ecsEntity = _world.NewEntity();
monoEntity.Make(ref ecsEntity);
}
}
SpawnPrefab — это компонент, который хранит в себе данные, требуемые для спавна:
public struct SpawnPrefab
{
public GameObject Prefab;
public Vector3 Position;
public Quaternion Rotation;
public Transform Parent;
}
Для отправки сообщений в фабрику создадим SpawnSystem. Она будет ожидать на вход эвент о необходимости заспавнить префаб (собственно компонент SpawnPrefab) и пересылать его в фабрику, находящуюся на сцене:
Показать код
public class SpawnSystem : IEcsPreInitSystem, IEcsRunSystem
{
private EcsWorld _world;
private SceneData _sceneData;
private EcsFilter<SpawnPrefab> _spawnFilter = null;
private PrefabFactory _factory;
public void PreInit()
{
_factory = _sceneData.Factory;
_factory.SetWorld(_world);
}
public void Run()
{
if (_spawnFilter.IsEmpty())
{
return;
}
foreach (int index in _spawnFilter)
{
ref EcsEntity spawnEntity = ref _spawnFilter.GetEntity(index);
var spawnPrefabData = spawnEntity.Get<SpawnPrefab>();
_factory.Spawn(spawnPrefabData);
spawnEntity.Del<SpawnPrefab>();
}
}
}
Здесь стоит разобраться немного поподробнее. SceneData — это MonoBehaviour
, который хранит в себе данные о текущей сцене. В частности — ссылку на фабрику. Он, так же как и PrefabFactory
, висит на сцене в Unity:
В SpawnSystem
мы его подключаем через DI, отправляя в метод Inject
из ECSStartup
:
_systems
.OneFrame<AnyKeyDownTag>()
.Add(new KeyInputSystem())
.Add(new SpawnSystem())
.Add(new DemoSystem())
.Inject(_sceneData)
.Init();
Затем в PreInit
получаем PrefabFactory
и отправляем туда ECSWorld
, чтобы наша фабрика могла уведомлять World
о том, что она что-то заспавнила.
В Run
мы сначала проверяем пустоту фильтра _spawnFilter
, а если фильтр не пустой — проходимся по всем элементам.
foreach (int index in _spawnFilter)
{
ref EcsEntity spawnEntity = ref _spawnFilter.GetEntity(index);
var spawnPrefabData = spawnEntity.Get<SpawnPrefab>();
_factory.Spawn(spawnPrefabData);
spawnEntity.Del<SpawnPrefab>();
}
Сам фильтр хранит в себе индексы на Entity, для того, чтобы их получить мы вызываем ref _spawnFilter.GetEntity(index);
Для того, чтобы получить компонент, навешенный на EcsEntity
, стоит обратиться к Get<T>()
, где T
— тип требуемого компонента. То есть, это почти как аналог GetComponent<T>()
из Unity с той лишь разницей, что если GetComponent
вернет null в случае отсутствия компонента, то Get<T>
из LeoECS создаст новый компонент с дефолтными параметрами. То есть,. Get<T>
тут скорее GetOrAdd<T>().
Затем полученный компонент мы отправляем в нашу фабрику, где инстанцируется требуемый префаб, а после удаляем компонент SpawnPrefab
с EcsEntity
, так как он нам больше не нужен. Обращу внимание, что это не удаление Entity, однако, если на этом Entity не будет никаких компонентов, кроме SpawnPrefab
, то он будет уничтожен, так как LeoECS не терпит пустых сущностей.
Важный момент! В нашем проекте есть MonoEntity. Это специальный MonoBehaviour, который будет брать данные у наших кастомных MonoLinkBase
и отправлять данные из них в ECSWorld. MonoEntity
сам является наследником MonoLinkBase
.
Показать код
public class MonoEntity : MonoLinkBase
{
private EcsEntity _entity;
private MonoLinkBase[] _monoLinks;
public MonoLink<T> Get<T>() where T: struct
{
foreach (MonoLinkBase link in _monoLinks)
{
if (link is MonoLink<T> monoLink)
{
return monoLink;
}
}
return null;
}
public override void Make(ref EcsEntity entity)
{
_entity = entity;
_monoLinks = GetComponents<MonoLinkBase>();
foreach (MonoLinkBase monoLink in _monoLinks)
{
if (monoLink is MonoEntity)
{
continue;
}
monoLink.Make(ref entity);
}
}
}
Сам же MonoLinkBase — это абстрактный класс, описывающий сигнатуру метода для создания компонента для EcsEntity
из префаба:
public abstract class MonoLinkBase : MonoBehaviour
{
public abstract void Make(ref EcsEntity entity);
}
Но по факту это только заготовок для более важной реализации класса MonoLink, от которого, в свою очередь, мы будем наследовать все MonoLink
’и.
public abstract class MonoLink<T> : MonoLinkBase where T : struct
{
public T Value;
public override void Make(ref EcsEntity entity)
{
entity.Get<T>() = Value;
}
}
В итоге MonoLink<T>
— это просто связующее звено между префабами Unity и миром ECS. От него будут наследоваться почти все MonoBehaviour’ы для нашего проекта:
То есть, для того, чтобы заспавнить какой-то префаб, имеющий визуальное (и физическое) представление себя в Unity, а также иметь ссылки на свои компоненты в ECS мире, нужно собрать префаб имеющий на себе MonoEntity
и набор требуемых реализаций MonoLink
’ов, ттаких как GameObjectMonoLink — компонент для реализации ссылки на GameObject
из ECSWorld
.
public class GameObjectMonoLink : MonoLink<GameObjectLink>
{
public override void Make(ref EcsEntity entity)
{
entity.Get<GameObjectLink>() = new GameObjectLink
{
Value = gameObject
};
}
}
Итак, можно считать, что база готова, и приступать к описанию бизнес-логики самой игры.
Сначала стоит создать систему инициализирующую игру. В нашем случае это будет SpawnPlayer:
public class SpawnPlayer : IEcsInitSystem
{
private EcsWorld _world = null;
private StaticData _sceneData;
public void Init()
{
_world.NewEntity().Get<SpawnPrefab>() = new SpawnPrefab
{
Prefab = _sceneData.PlayerPrefab,
Position = Vector3.zero,
Rotation = Quaternion.identity,
Parent = null
};
}
}
Тут все просто. Реализуем интерфейс IEcsInitSystem
, где в методе Init просто создаем новую сущность, навешивая компонент SpawnPrefab
, где говорим, что нам нужно заспавнить префаб игрока в нулевых, в стандартном вращении без указания родителей.
Таким образом, на этапе инициализации сцен будет заспавнен эвент, требующий спавна префаба игрока. А затем система спавна подхватит этот эвент, заставит фабрику заспавнить префаб, и игра пойдет дальше.
Но откуда мы вообще берем префаб? Его можно было бы взять из SceneData
, но в данном случае лучше завести какие-то общие настройки для всего проекта. Поэтому для этой цели мы создадим StaticData:
[CreateAssetMenu(menuName = "Config/StaticData", fileName = "StaticData", order = 0)]
public class StaticData : ScriptableObject
{
public GameObject PlayerPrefab;
}
Сейчас там только префаб игрока, но туда можно добавлять любые интересующие нас данные для всего проекта.
В итоге в ECSStartup нужно добавить интересующие нас системы и забиндить данные через DI.
_systems
.OneFrame<AnyKeyDownTag>()
.Add(new KeyInputSystem())
.Add(new SpawnPlayer())
.Add(new SpawnSystem())
.Add(new DemoSystem())
.Inject(_staticData)
.Inject(_sceneData)
.Init();
Теперь, если все правильно настроить, наша игра будет спавнить префаб игрока и спамить сообщения в случае нажатия клавиш. Так что идем дальше.
В данном комите я реализовал некоторый список компонентов и требуемых для них MonoLink’ов. Это такие компоненты как:
GameObjectMonoLink : MonoLink<GameObjectLink>
RBMonoLink : MonoLink<RigidbodyLink>
PlayerTagMonoLink : MonoLink<PlayerTag>
GravitationalMonoLink : MonoLink<Gravitational>
Вообще, для компонентов-структур не обязательно помечать сериализуемыми, но для позиции я это все же сделал, так как намерен иметь возможность редактировать ее в Unity:
[Serializable]
public struct Position
{
public Vector3 Value;
}
Теперь стоит поговорить о том, как вообще двигать объекты из LeoECS.
Как будет перемещаться наш куб? Сначала на него будет воздействовать гравитация, потом мы должны будем переместить куб и обновить позицию в мире Unity. Это три системы:
GravitationSystem
MoveSystem
UpdateRigidbodyPosition
Внутри у них все просто:
1. GravitationSystem
просто добавляет наш компонент Velocity с вектором того, куда действует гравитации в соответствии с глобальными настройками ко всем Entity у которых висит Gravitational-компонент.
public class GravitationSystem : IEcsRunSystem
{
private StaticData _staticData;
private EcsFilter<Gravitational> _filter = null;
public void Run()
{
var deltaTime = Time.fixedTime;
foreach (int index in _filter)
{
ref EcsEntity entity = ref _filter.GetEntity(index);
ref Velocity velocity = ref entity.Get<Velocity>();
velocity.Value -= _staticData.GlobalGravitation * deltaTime;
}
}
}
2. MoveSystem
берет все эти компоненты Velocity и задает для них новую позицию:
public class MoveSystem : IEcsRunSystem
{
private EcsFilter<Velocity> _filter = null;
public void Run()
{
foreach (int index in _filter)
{
ref EcsEntity entity = ref _filter.GetEntity(index);
ref Position position = ref entity.Get<Position>();
Velocity velocity = _filter.Get1(index);
position.Value += velocity.Value;
}
}
}
3. UpdateRigidbodyPosition
просто обновляет позицию для Rigidbody
всем компонентам, которые имеют RigidbodyLink
и Position
. Благодаря этой системе происходит само смещение объектов в мире Unity.
public class UpdateRigidbodyPosition : IEcsRunSystem
{
private EcsFilter<RigidbodyLink, Position> _filter = null;
public void Run()
{
if (_filter.IsEmpty())
{
return;
}
foreach (int index in _filter)
{
ref RigidbodyLink rigidbody = ref _filter.Get1(index);
var newPosition = _filter.Get2(index);
rigidbody.Value.MovePosition(newPosition.Value);
}
}
}
Осталось только подключить системы в ECSStartup.
Но для начала создадим группу физических систем, которые нужно обновлять в FixedUpdate()
, так что создадим:
private EcsSystems _fixedSystem;
И в Start()
проинициализируем наши новые системы:
_fixedSystem = new EcsSystems(_world);
_fixedSystem
.Add(new GravitationSystem())
.Add(new MoveSystem())
.Add(new UpdateRigidbodyPosition())
.Inject(_staticData)
.Init();
Добавив вызов Run
в FixedUpdate()
.
private void FixedUpdate()
{
_fixedSystem?.Run();
}
Также стоит добавить и EcsSystemsObserver
для отладки в Editor’e:
Leopotam.Ecs.UnityIntegration.EcsSystemsObserver.Create(_fixedSystem);
Собираем префаб нашего игрока, и можно наблюдать за тем, как наш кубик будет падать:
Для того, чтобы заставить игрока прыгать, нам нужно переработать KeyInputSystem:
public class KeyInputSystem : IEcsRunSystem
{
private EcsWorld _world = null;
private EcsFilter<PlayerTag> _filter = null;
public void Run()
{
var isHasInput = Input.anyKeyDown;
if (!isHasInput)
{
return;
}
foreach (int index in _filter)
{
ref EcsEntity entity = ref _filter.GetEntity(index);
entity.Get<AnyKeyDownTag>();
}
}
}
Теперь мы будем брать все сущности с тегом «игрок» (PlayerTag
) и вешать на них AnyKeyDownTag
, чтобы пометить их для новой системы AddVelocityInputSystem:
public class AddVelocityInputSystem : IEcsRunSystem
{
private StaticData _staticData;
private EcsFilter<AnyKeyDownTag> _filter = null;
public void Run()
{
foreach (int index in _filter)
{
ref EcsEntity entity = ref _filter.GetEntity(index);
ref Velocity velocity = ref entity.Get<Velocity>();
velocity.Value += _staticData.PlayerAddForce;
}
}
}
Сама AddVelocityInputSystem
будет просто брать все сущности с AnyKeyDownTag
и добавлять к их Velocity величину из статики — _staticData.PlayerAddForce
.
Теперь, если запустить игру, куб будет прыгать, главное — не забудьте добавить ее в ECSStartup
.
А теперь — помните, я говорил об удобстве расширения кода и преимущества комбинирования в ECS? Пришло время показать это на практике. Создадим ObstacleSpawner. Это система, которая раз в определенное время будет отправлять эвент о необходимости заспавнить, а система спавна уже все сделает сама.
Показать код
public class ObstacleSpawner : IEcsInitSystem, IEcsRunSystem
{
private StaticData _staticData;
private SceneData _sceneData;
private EcsWorld _world = null;
private float _spawnDelay;
private float _lastTime;
public void Init()
{
_spawnDelay = _staticData.SpawnTimer;
}
public void Run()
{
_lastTime += Time.deltaTime;
if (_lastTime > _spawnDelay)
{
_world.NewEntity().Get<SpawnPrefab>() = new SpawnPrefab
{
Prefab = _staticData.ObstaclePrefab,
Position = _sceneData.SpawnObstaclePosition.position,
Rotation = Quaternion.identity,
Parent = _sceneData.SpawnObstaclePosition
};
_lastTime -= _spawnDelay;
}
}
}
Препятствия будут спавниться, но не двигаться…
Для того, чтобы заставить двигаться препятствия, достаточно накинуть на префаб VelocityMonoLink
и задать вектор движения. Система перемещения и апдейта позиции RB автоматически подтянет препятствия. Но так как на препятствиях не будет Gravity
компонента, они не будут падать, а будут двигаться только в одном направлении, указанном в префабе.
Как работать с физикой
Наконец, поговорим о том, как устроена работа с физикой на примере детектирования смерти аватара игрока.
Для начала мы дожны создать компоненты, которые будут обрабатываться ECS как коллизия физических объектов. У нас это OnCollisionEnterEvent и OnTriggerEnterEvent. Для генерации этих эвентов создадим PhysicsLinkBase — это базовый класс для всех сущностей, которые будут отправлять в мир ECS соответствующие эвенты:
public abstract class PhysicsLinkBase : MonoLinkBase
{
protected EcsEntity _entity;
public override void Make(ref EcsEntity entity)
{
_entity = entity;
}
}
От него наследуются OnCollisionEnterMonoLink и OnTriggerEnterMonoLink. Все, что они делают — это генерируют компоненты физических событий, привязывая их к Entity. Реализация OnCollisionEnterMonoLink
выглядит так:
public class OnCollisionEnterMonoLink : PhysicsLinkBase
{
public void OnCollisionEnter(Collision other)
{
_entity.Get<OnCollisionEnterEvent>() = new OnCollisionEnterEvent
{
Collision = other,
Sender = gameObject
};
}
}
А реализация OnTriggerEnterMonoLink
соответственно:
public class OnTriggerEnterMonoLink : PhysicsLinkBase
{
private void OnTriggerEnter(Collider other)
{
_entity.Get<OnTriggerEnterEvent>() = new OnTriggerEnterEvent()
{
Collider = other,
Sender = gameObject
};
}
}
Таким образом, когда 2 объекта, один из которых содержит на себе такой физический детектор, сталкиваются, в ECSWorld
на Entity объекта, к которому привязан OnCollisionEnterMonoLink
или OnTriggerEnterMonoLink
, создается OnCollisionEnterEvent
и OnTriggerEnterEvent
, соответсвенно. И мы уже можем работать с этой информацией.
Для детектирования коллизии создадим ObstacleCollisionCheckerSystem. Она будет отлавливать все пересечения PlayerTag с объектами, которые помечены как ObstacleTagMonoLink
(ObstacleTag
тоже нужен для пометки препятствий) и уведомлять остальные системы через OnObstacleCollisionEvent
.
Показать код
public class ObstacleCollisionCheckerSystem : IEcsRunSystem
{
private EcsWorld _world = null;
private EcsFilter<PlayerTag, OnCollisionEnterEvent> _filter = null;
public void Run()
{
if (_filter.IsEmpty())
{
return;
}
foreach (int index in _filter)
{
ref EcsEntity entity = ref _filter.GetEntity(index);
var onCollisionEnterEvent = entity.Get<OnCollisionEnterEvent>();
GameObject collisionGameObject = onCollisionEnterEvent.Collision.gameObject;
var obstacle = collisionGameObject.GetComponent<ObstacleTagMonoLink>();
if (obstacle == null)
continue;
EcsEntity obstacleCollision = _world.NewEntity();
obstacleCollision.Get<OnObstacleCollisionEvent>();
}
}
}
Для детектирования смерти создаем систему DeadByObstacleCollisionSystem. Она должна будет считать жизни и вообще является некоторым мостом между коннектом игрока с препятствием и смертью. Тут стоит описывать всю логику обработки конкретного коннекта препятствия с игроком:
public class DeadByObstacleCollisionSystem : IEcsRunSystem
{
private EcsWorld _world = null;
private EcsFilter<OnObstacleCollisionEvent> _filter = null;
public void Run()
{
if (!_filter.IsEmpty())
{
_world.NewEntity().Get<DeadEvent>();
}
}
}
За финальную реализацию смерти отвечает DeadCheckerGameplaySystem. Он ждет DeadEvent
и говорит миру, что нужно поставить игру на паузу через GameProgress
.
Показать код
public class DeadCheckerGameplaySystem : IEcsInitSystem, IEcsRunSystem
{
private EcsWorld _world = null;
private EcsFilter<DeadEvent> _deadFilter = null;
private EcsFilter<GameProgress> _gameProgress;
public void Init()
{
_world.NewEntity().Get<GameProgress>() = new GameProgress
{
IsPause = false
};
}
public void Run()
{
if (_deadFilter.IsEmpty())
return;
foreach (int index in _gameProgress)
{
ref GameProgress progress = ref _gameProgress.Get1(index);
progress.IsPause = true;
Debug.Log("Cube is dead");
}
}
}
Сейчас нет систем, которые ставят игру на паузу, а потому мы только отправляем сообщение о смерти кубика в консоль.
Стоит отвлечься и поговорить про группирование систем. Сейчас у нас множество разных систем, и описывать их все в две отдельные системы не очень удобно. Тут можно немного порефакторить и вынести реализацию отдельных групп. И инициализировать системы внутри EcsStartup.
Для начала стоит описать функции инициализации систем по группам:
Показать код
private EcsSystems SpawnSystems()
{
return new EcsSystems(_world)
.Add(new SpawnPlayer())
.Add(new ObstacleSpawner())
.Add(new SpawnSystem());;
}
private EcsSystems InputSystems()
{
return new EcsSystems(_world)
.OneFrame<AnyKeyDownTag>()
.Add(new KeyInputSystem())
.Add(new AddVelocityInputSystem())
.Add(new ClampVelocitySystem());;
}
private EcsSystems MovableSystems()
{
return new EcsSystems(_world)
.Add(new GravitationSystem())
.Add(new MoveSystem())
.Add(new UpdateRigidbodyPosition());
}
private EcsSystems CoreGameplaySystems()
{
return new EcsSystems(_world)
.OneFrame<OnObstacleCollisionEvent>()
.Add(new ObstacleCollisionCheckerSystem())
.OneFrame<DeadEvent>()
.Add(new DeadByObstacleCollisionSystem())
.Add(new DeadCheckerGameplaySystem());
}
А затем, в методе Start
просто поочередно вызывать инициализацию и подключать в наши EcsSystems _systems
и _fixedSystem
.
Таким образом, мы можем группировать системы и оперировать именно группами, добавляя к одной группе другую.
Показать код
EcsSystems inputSystems = InputSystems();
EcsSystems spawnSystems = SpawnSystems();
_systems
.Add(inputSystems)
.Add(spawnSystems)
.Inject(_staticData)
.Inject(_sceneData)
.Init();
EcsSystems coreSystems = CoreGameplaySystems();
EcsSystems movableSystems = MovableSystems();
_fixedSystem
.Add(movableSystems)
.Add(coreSystems)
.OneFrame<OnCollisionEnterEvent>()
.Inject(_staticData)
.Init();
Как работать с UI
Еще один момент, который обычно вызывает вопросы при знакомстве работы с ECS — это то, как работать с UI.
Вообще говоря, можно взять на вооружение идею проекта с MonoLink’aми, но авторы LeoECS выкатили свое решение в виде EcsUI, которая предоставляет API для работы с UnityUI. Может быть, кому-то этот вариант покажется более удобным, а потому продемонстрируем его работу у нас в игре.
Для начала стоит реализовать обычный GameHud. Тут все как в «обычной Unity»: набор полей — ссылок на UI-элементы. В SceneData мы, конечно же, сохраним ссылочку на него.
Показать код
public class GameHud : MonoBehaviour
{
private const string SceneName = "SampleScene";
public GameObject StartGame;
public GameObject GameOver;
public TMP_Text Score;
public string FormatScore = "Score: {0}";
public void Awake()
{
StartGame.SetActive(true);
GameOver.SetActive(false);
Score.gameObject.SetActive(false);
}
public void OnStartGameClick()
{
StartGame.SetActive(false);
Score.gameObject.SetActive(true);
}
public void ShowGameOver()
{
GameOver.SetActive(true);
}
public void OnNewGameClick()
{
SceneManager.LoadScene(SceneName);
}
public void SetScore(int value)
{
Score.text = string.Format(FormatScore, value);
}
}
Сверстаем простенький экран с кнопками старта и рестарта.
Теперь же поговорим о структуре EcsUI. Его идея проста. Есть EcsUiEmitter
, который связывается с ECSWorld, и есть компоненты, которые только отправляют события об изменении состояния. Например, EcsUiClickAction
отправляет эвент о том, что на данную кнопку осуществили нажатие.
Таким образом, для работы с UI нам необходимо разместить EcsUiEmitter
на родительском объекте — например, ECSSturtup
. И его указывать в качестве эмитера для всех Action
.
Главное — не забыть заинжектить его в системы, которые будут его использовать.
_systems.InjectUi(_uiEmitter)
На сами кнопки навесим MonoBehaviour EcsUiClickAction
, которые мы возьмем из EcsUI.
Теперь при нажатии на кнопку будет приходить Entity с компонентом EcsUiClickEvent
, из которого мы можем получить имя виджета и ссылку на сам объект, который сгенерировал этот эвент. Таким образом, мы можем реализовать UIGameProgressSystem. Идея в том, что когда мы получаем какой-то эвент о нажатии кнопки, если это был эвент на нажатие кнопки с именем виджета (которое задается в самом EcsUiClickAction
), соответствующим имени, указанном в StartGameBtn
, то мы просто меняем состояние паузы.
Посмотреть код
public class UIGameProgressSystem : IEcsRunSystem
{
private const string Startgamebtn = "StartGameBtn";
private PauseService _pauseService;
private EcsFilter<EcsUiClickEvent> _filter = null;
private EcsFilter<GameProgress> _filterGameProgress = null;
public void Run()
{
foreach (int index in _filter)
{
EcsUiClickEvent click = _filter.Get1(index);
if (click.WidgetName.Equals(Startgamebtn))
{
ref GameProgress gameProgress = ref _filterGameProgress.Get1(0);
gameProgress.IsPause = false;
_pauseService.ResetPause();
}
}
}
}
Аналогично реализована и кнопка рестарта игры при смерти пользователя.
Для отображения количества очков мы можем просто вручную обращаться к TMP_Text
когда система ScoreCounterSystem будет обрабатывать OnObstacleExit
, генерируемый ObstacleTriggerEnterCheckerSystem.
Вместо заключения
Итак, мы вкратце рассмотрели, как устроена типичная движконезависимая система ECS.
Самое сложное — держать себя в руках, когда ты пишешь на ECS, потому что очень легко можно настолько сильно раздробить игру на отдельные системы, что потеряется сама возможность отключать их по одиночке. Поэтому необходимо поддерживать code review, обязательно обмениваться мнениями с коллегами, чтобы было общее понимание того, что вы делаете.
Но с этой проблемой сталкиваются в основном только на первых парах. С опытом вы начнте лучше осознавать, когда стоит выделять отдельные системы, а когда нет.
Самое сложное, с чем вы столкнетесь при переходе на ECS, — это с проблемой brain shift’а, когда нужно переключиться со своей привычной парадигмы программирования. У меня на это ушла неделя-полторы. Первое время я очень сильно страдал. Но когда вы осознаете, как нужно работать с этой архитектурой и насколько это получается эффективно, вы вряд ли захотите от нее отказаться.
Надеюсь, что вас заинтересовали возможности ECS, и вы загорелись желанием попробовать сотворить что-то свое на этой архитектуре.