Всё что нужно знать про ECS
Привет, Хабр! В этой статье я расскажу всё, что знаю про Entity-Component-System и попытаюсь развеять различные предубеждения об этом подходе. Здесь вы найдете много слов о преимуществах и недостатках ECS, об особенностях этого подхода, о том как с ним подружиться, о потенциальных граблях, о полезных практиках, а также в отдельном разделе коротко посмотрим на ECS фреймворки для Unity/C#.
Статья очень неспешно собиралась в течении двух лет, из-за чего вышла очень большой. Она хорошо подойдет для тех, кто хочет/начинает знакомиться с ECS. Люди же вкусившие ECS я надеюсь тоже смогут подчеркнуть для себя что-то новое. Если же вы делаете игры на любом отличном от C# языке, статья всё равно может быть вам полезна. Здесь не будет примеров кода и ни слова про историю паттерна, только мои опыт, рассуждения и наблюдения, а также опыт других ECS-фанатиков, за что им всем отдельное огромное спасибо :)
Содержание
Что такое ECS
Entity-Component-System - это архитектурный паттерн, созданный специально для разработки игр, он отлично подходит для описания динамического виртуального мира. Из-за его особенностей, некоторые считают его чуть ли не новой парадигмой программирования, это скорее не так, но мозг перестраивать скорее всего потребуется.
ECS возводит в абсолют принцип Composition Over Inheritance(композиция важнее наследования) и может являться частным примером Data Oriented Design(ориентированного на данные дизайна, далее DOD), однако это уже зависит от интерпретации паттерна конкретной реализацией.
Расшифруем название этого паттерна:
Entity - сущность, максимально абстрактный объект. Условный контейнер для свойств, определяющих чем будет являться эта сущность. Зачастую представляется в виде идентификатора для доступа к данным.
Component - компонент, свойство с данными объекта. Компоненты в ECS должны содержать исключительно чистые данные, без единой капли логики. Тем не менее часть разработчиков допускает использование разнообразных геттеров и сеттеров в компонентах, но лично я считаю, что для этих целей лучше подходят static utils(подробнее в Good Practices).
System - система, логика обработки данных. Системы в ECS не должны содержать никаких данных, только логика обработки данных. Но, опять же, часть разработчиков допускают это, чтобы определять некоторое вспомогательное поведение самой системы, например, константы или различного рода вспомогательные сервисы.
Как вы уже поняли из описанного выше: ECS строго отделяет данные от логики. Поведение объекта определяется не интерфейсами/контрактами/публичным API, как мы привыкли в классическом объектно-ориентированном программировании(далее ООП), а присвоенными объекту свойствами с данными + существующей отдельно логикой обработки. В ECS данные определяют всё - это и есть главное свойство, которое выделяет его на фоне других подходов к разработке: всё есть данные. И свойства объекта, и его характеристики, и даже события - всё это просто данные существующие в ECS-мире. Логика же является просто конвейерной обработкой всех этих данных. В некотором приближении, ECS можно сравнить с базой данных, которая обрабатывается каждый кадр потоком обработчиков написанных в стиле процедурного программирования :D
Entity-Component
Стоит отдельно проговорить, что нередко Entity-Component-System путают с очень близким архитектурным паттерном Entity-Component(иногда пишут как Entity-Component System, далее EC), но это большое заблуждение.
EC вы скорее всего уже встречали в различных движках, таких как Unigine или Unity(но их новый DOTS уже ECS). Главное отличие, как можно понять из названия - отсутствие выделенных под логику систем. В EC-подходе в компоненте хранятся и данные, и логика, а для изменения данных наружу торчит API. Компонент в таком случае уже не просто свойство объекта, а полноценное поведение, которое мы можем добавить нашей сущности. Каждый компонент, будучи отдельным объектом со своим API и ожидаемым поведением, зачастую сам обрабатывает или изменяет свои данные по чьей-либо просьбе.
Фактически, EC - классическое ООП с хорошей модульностью и сильным уклоном в сторону Composition Over Inheritance. Правда никто не запрещает сделать компоненты с чистой логикой и, как итог, получить опыт аналогичный ECS. Но вернемся к ECS…
Зачем ECS
Наверняка, на этом месте у вас уже возник вопрос: “А зачем мне этот ваш ECS вообще нужен? Какая от него польза?”. И чтобы помочь вам определиться читать ли вообще статью дальше, я расскажу чем лично мне так полюбился ECS. В дальнейшем раздел Pros and Cons раскроет подробнее и хорошие, и плохие стороны этого подхода, чтобы вы смогли окончательно определиться нужен ли вам ECS.
Лично я люблю ECS за то, что…
С ECS ты просто садишься и делаешь игру, а не воюешь с архитектурой проекта. Нет нужды строить большие и “красивые” иерархии, продумывать кучу связей и париться про “X же не должен знать про Y”. При этом принципы ECS защищают тебя(не на 100%, ессесно) от безвыходной ситуации, в которую заводит плохая архитектура, когда дальнейшее развитие проекта становится очень болезненным. И даже если всё таки что-то пошло не так - рефакторинг в ECS совсем не проблема. И это, на мой взгляд, самое кайфовое в ECS.
Код на ECS получается простым и понятным. Не нужно ползать по куче вызовов среди кучи классов, чтобы понять чем занимается конкретная система, всё видно сразу, особенно если грамотно разбивать фичу на системы, системы на методы и не переусложнять код. Вдобавок, ECS сильно упрощает профилирование: сразу видно какая логика(система) сколько времени кадра отнимает, не нужно искать источник лагов в глубине вызовов.
Очень легко манипулировать логикой. Добавление новой логики практически безболезненно - просто вставляешь новую систему в нужное место, не боясь напрямую повлиять на остальной код(стоит отметить, что возможно косвенное влияние через данные). Можно без каких-либо проблем использовать общую логику(системы) между клиентом и сервером, при сохранении используемых данных(компонентов), конечно. Можно легко переписывать системы, заменяя старые системы на отрефакторенные, при этом без какого-либо влияния на остальной код, не понравится результат - просто снова включаешь старую систему и выключаешь новую. Аналогичным механизмом можно легко устраивать A/B тесты.
Всё крутится вокруг данных. На поверку это оказывается дико удобно. С помощью прямого манипулирования данными на сущностях открываются широчайшие возможности для комбинаторики. Можно с помощью данных формировать сущность во что угодно: от камеры, которая убивает по касанию, до нематериального контейнера с конфигами. А если фреймворк предлагает инструментарий для просмотра данных на сущностях, то можно в любой момент изучить данные и их динамику на совершенно любой сущности без необходимости запуска дебаггера, чтобы заглянуть в память.
Как работать с ECS
Здесь я простыми словами, максимально абстрактно и без привязки к языку программирования опишу как проходит процесс разработки с использованием ECS на самом простом примере. Если у вас уже есть хоть какой-то опыт работы с ECS, то можете перейти сразу к следующему разделу :)
Задача: создать объект, который двигается в направлении заданного вектора движения.
Первым делом определим данные, необходимые нам для работы. Для нашей задачи потребуются позиция объекта и задаваемый вектор движения. На языке ECS это будут:
PositionComponent
для хранения вектора позицииMovementComponent
для вектора движения
Следующим шагом опишем логику. Создаём систему MovementSystem
. В главном методе системы, в зависимости от реализации это может быть Run()
/Execute()
/Update()
или что-либо другое, получаем все сущности в ECS, у которых есть PositionComponent
и MovementComponent
. Как именно это можно сделать зависит от фреймворка, но зачастую это похоже на своеобразный SQL-запрос вида GetAllEntities().With<PositionComponent>().With<MovementComponent>()
.
Запускаем цикл по полученным сущностям и для каждой производим изменение позиции: positionComponent.position += movementComponent.velocity
. Можно добавить *deltaTime
, если вы не хотите зависеть от частоты вызова системы.
Ну и наконец, мы просто создаем сущность(или даже 10 штук) с двумя нашими компонентами, задаем вектор движения отличный от нуля и теперь при каждом вызове системы MovementSystem
(вне зависимости от того где и когда мы ее вызовем) наш объект будет менять позицию в направлении заданного вектора движения. Задача выполнена! :)
Зачастую системы так или иначе встраиваются в GameLoop проекта и дёргаются каждый кадр самим движком, но можно это делать и руками, и любым другим способом, тк это просто вызов метода.
Посмотрим какие дополнительные возможности для разработки мы получили помимо решения основной задачи:
Любая другая наша система способна определить двигается ли объект простой проверкой на наличие свойства
MovementComponent
Любая другая наша система способна получить вектор движения для своих нужд
Любая другая наша система сможет задать вектор движения для любой нашей сущности по своему желанию
Если нам захочется, то мы ещё и любую другую сущность сможем заставить двигаться, просто повесив на неё PositionComponent
и MovementComponent
, при создании игр это бывает крайне полезно.
Pros and cons
В этом разделе мы обсудим чем хорош ECS и чем он плох. Часть описанных ниже особенностей имеют две стороны медали: они одновременно и приносят пользу разработке, и доставляют дискомфорт, создавая ограничения, которые иногда приходится обходить.
Преимущества
Слабая связность кода
Это крайне полезное для игроделов свойство. Оно позволяет нам производить рефакторинг и расширение кодовой базы относительно просто и не ломая старых кусков кода. Мы всегда можем добавить новое поведение с использованием старых данных буквально сбоку, без нужды как либо вмешиваться в старую логику. ECS достигает такого эффекта благодаря тому, что все взаимодействие логики выражено данными в Entity, которая в свою очередь является максимально абстрактным объектом без каких-либо гарантий, как какой-нибудьObject
в C#/Java.
Однако стоит иметь ввиду, что в ECS порядок изменения данных(то есть порядок выполнения систем) играет важную роль, что в конечном счете может повлиять на сложность рефакторинга и таки поломать вашу старую логику, а-то и создать неприятные сайд-эффект баги, об этом ещё поговорим в Недостатках.Идеальная модульность и тестируемость логики
Свойство всё есть данные даёт нам ещё одно прекрасное преимущество: если всё взаимодействие выражено в чистых данных - наша логика всегда полностью отвязана от источника данных. Это позволяет нам как безболезненно перемещать логику из проекта в проект и использовать повторно(с сохранением формата данных, ессесно), так и запускать логику на каких-угодно вводных данных для тестирования ее работы.Сложнее писать плохой код
ECS менее требователен к архитектуре, тк задает рамки с которыми сложнее(но не невозможно) создать реально плохой дизайн кода. При этом, как было сказано выше, мы можем относительно безболезненно и с минимальным влиянием на остальной код исправить проблему даже если плохой дизайн таки случился. Что приводит нас к тому, что ECS позволяет тратить меньше времени на раздумья “как впихнуть эту логику в нашу архитектуру и не сломать ничего” и просто добавлять новые фичи.Комбинаторика свойств
Это преимущество очень обрадует ваших геймдизов. Именно это преимущество и делает ECS отличным вариантом для описания динамических миров. Вы только представьте: вы можете придать любое свойство(а следовательно и логику) любой вашей сущности без какого-либо геморроя!
Захотели, чтобы у камеры появилось здоровье - пожалуйста, повесил на сущность камерыHealthComponent
и готово: она может получать урон(если есть такая система). Повесил на сущностьInFireComponent
и она тут же начинает получать урон от горения, если у нее естьHealthComponent
, красота! Нужно чтобы дом начал двигаться под управлением игрока? Да без проблем, где там мойPlayerInputListenerComponent
…
Опытный разработчик тут заметит: “Пфф, с этим справится большинство Composition over Inheritance паттернов, чем тут ECS лучше?”. Отвечаю: ECS позволяет вам комбинировать свойства не только с точки зрения формирования сущности, но и для создания специфичной логики при комбинации нескольких свойств(компонентов) на одной сущности. Не говоря уже о возможности добавить совсем новую логику для старых данных без необходимости трогать компоненты на сущности.
Границы возможностей комбинаторики в вашем проекте определяете вы сами, но об этом мы еще поговорим в разделе Good Practices.Проще соблюдать Single Responsibility логики
Когда логика у нас полностью отделена от данных и не привязана к какому-либо объекту/сущности, нам становится проще контролировать разбиение логики по ее назначению, а не месту в иерархии. Каждая система просто выполняет какую-то конкретную, свойственную только ей, задачу. Зачастую код системы вообще выглядит как вызов одного метода для множества компонентов одного типа. По итогу, код в большинстве своём легко читается и воспринимается.
Лично я сторонник принципа “Не разделяй раньше времени”, поэтому допускаю, что система может выполнять несколько функций, если эти функции плотно связаны и не используются отдельно друг от друга, но это каждый решает сам для себя, об этом мы еще поговорим в Good Practices.Более наглядный профайлинг
Благодаря тому, что за обработку у нас отвечают обособленные системы с присущей только им логикой, при профилировании мы сразу видим какая логика и сколько времени кадра отнимает. Нам не нужно идти вглубь стека вызовов, чтобы понять что больше всего занимает, например, система движения персонажа, мы сразу видим виновнуюCharMovementSystem
.
Стоит правда заметить, что это преимущество зависит от устройства ECS фреймворка, тк у самого фреймворка может быть свой стек вызовов, в котором иногда ещё поди сориентируйся.ECS может дать хороший прирост производительности
Многие считают, что хорошая производительность - основное преимущество ECS(спасибо пропаганде Unity). Это не совсем так. Скорость выполнения кода лишь приятный бонус, вытекающий из принципов паттерна: данные в одном месте - логика в другом + работа систем в духе SIMD(single instruction, multiple data), когда мы выполняем одну и ту же логику для множества одинаковых компонентов. А если фреймворк следует DOD при реализации ECS и добивается хорошей локальности данных, то мы дополнительно получаем кэш-френдли код, что однозначно порадует ваш процессор.
Ключевое слово этого пункта - “может”. Итоговая производительность ECS зависит от множества факторов: как именно фреймворк хранит данные, как фреймворк фильтрует сущности, насколько быстрый доступ систем к данным, ну и, конечно же, насколько быстро работает код внутри ваших систем. При этом последний пункт для большинства проектов дает самое большое влияние на время обработки кадра.
Однако, если взглянуть в контексте разработки на Unity, ECS всегда будет быстрее привычногоMonoBehaviour
-подхода, особенно на большом объеме данных. Но не забывайте, что всё таки главное в производительности вашей игры не столько архитектурный паттерн или производительность фреймворка, сколько алгоритмическая сложность и производительность написанного вами кода.
Да пребудет с вами Профайлер!Легче распараллеливать обработку данных
За счёт того, что логика у нас выделена в отдельный обработчик данных, а данные фактически представляют собой линейную последовательность, мы можем в рамках одной системы без особых проблем распараллелить обработку. Это бывает очень актуально, если система обрабатывает огромное количество сущностей одновременно и они между собой никак не пересекаются.
Можно пойти ещё дальше и отправить в разные потоки логику, которая не пересекается в изменяемых данных, но это все куда сложнее контролировать и отслеживать + всё равно будет bottleneck в виде синхронизации с главным потоком для подготовки данных на отрисовку. К тому же, может оказаться, что накладные расходы на подготовку данных и распределение между потоками буду выше чем время выполнения кода в ваших системах, так что нужно ещё будет дать оценку стоит ли оно того вообще.
С чистыми данными очень легко работать
Почти в каждой игре нам приходится что-то сохранять, загружать или сериализовывать для отправки по сети. Это всё куда проще совершать, когда данные отделены от логики. Нет необходимости думать “А как это должно попасть в приватные данные…”, вызывать какие-то особые методы для правильной сериализации, просто сохраняешь/загружаешь дамп нужных компонентов на сущности, проще некуда, а системы потом допилят ее до нужного состояния сами, если сочтут нужным.Можно менять ECS-фреймворки как перчатки
ECS фреймворки похожи друг на друга, тк принципы в их основе одни и те же. Разработчик, который перестроил свой мозг под ECS и хорошо разобрался в одном фреймворке однажды, без особых проблем сможет работать и с другим ECS-фреймворком. Время уйдет лишь на изучение API(нередко бывает, что и API похож) и особенностей конкретного фреймворка, но голову под новый подход перестраивать будет не нужно.
Справедливости ради, это преимущество может быть отзеркалено и к другим архитектурным паттернам, будь то DI или EC.
Недостатки
Высокий порог вхождения для бывалых
Несмотря на то, что сам концепт ECS можно описать в одном предложении, чтобы научиться варить его правильно может потребоваться много практики. ECS требует от вас забыть всё, что вы знали о проектировании раньше: все ваши вертикальные иерархии наследования, что поведение объекта определяется его интерфейсом, что объект представляет собой что-то конкретное и неизменяемое, что у объекта может быть личное(private) пространство, а логика может быть вызвана где только захочется.
В ECS всё не так, он полная противоположность описанному выше. Тут все данные открыты, все сущности абстрактны и очень динамичны, их свойства лежат в одной плоскости и доступным каждому, логика работает по принципу конвейера, а поведение сущностей вообще меняется на ходу исходя из данных.
На перестроение головы под это уходит время и пока этого не произойдет, ваш мозг будет активно сопротивляться, особенно если у вас за спиной большой опыт разработки. Он будет хотеть выстраивать наследование компонентов, делать им интерфейсы и методы, а также много всего другого, мы разберем это в разделе Ошибки новичка.
При всём вышеописанном, если у человека за спиной нету багажа из спагетти-архитектур(чистая голова джуна), то ECS осваивается быстрее и менее болезненно, чем какой-нибудь MVC.Слабая связность может мешать
Если вам вдруг понадобилось тесное взаимодействие между двумя конкретными сущностями(например, гусеничный корпус и башня танка), то вы сталкиваетесь с проблемой, что сущности то у нас максимально абстрактны и вы не можете гарантировать на уровне компилятора что на другом конце будет именно гусеничный корпус. Это будет мешать, тк игры - место, где много тесных взаимодействий и всегда хочется иметь прямую ссылку с гарантией свойств и поведения. Надо будет проверять наличие компонента и как-то обрабатывать его отсутствие, получать доступ к компоненту из сущности, чтобы начать с ним взаимодействовать…
НО, возвращаясь к слабой связности как преимуществу: это научит вас организовывать свой код так, чтобы на другом конце могла быть любая сущность, которую можно толкнуть отдачей и даже предусмотреть случай отсутствия такого компонента, чтобы игра всё равно смогла работать корректно без необходимости менять код, если вдруг ваш геймдиз захотел установить башню танка на здание.Доступ к любым данным откуда угодно
Мир ECS представляет собой полностью открытые коробки сущностей с доступными каждому данными в компонентах. Это, как и слабая связность выше, одновременно и плюс, и минус ECS.
С одной стороны это дико удобно, ибо не нужно придумывать как обходить созданные ранее при проектировании ограничивающие самого себя рамки(“X не должен знать об Y”), пытаясь натянуть сову на глобус и вытаскивая в public сокрытые ранее данные для решения какой-то сиюминутной задачи.
С другой стороны, любой неопытный программист так и норовит изменить данные оттуда, откуда этого делать не стоит, но обычно командное взаимодействие включает в себя доверие к работе других, так что доверяй, но проверяй ;)Системы работают исключительно в потоке, друг за другом
При правильном следовании принципам ECS, вы не должны вызывать логику одной системы внутри других систем. Системы вообще не должны знать о существовании друг друга, иначе это будет приводить к появлению излишней связности кода и потенциально вредит вашему проекту. Однако такое ограничение бывает неудобным и иногда будет приводить к различным обходным решениям, которые не будут нарушать принципы ECS. Если вам всё же понадобилось вызвать какой-то код “здесь и сейчас”, то просто сделайте обычный объект с методами и положите его в компонент, не мучайте себя.Плохо работает с рекурсивной логикой
Этот недостаток является следствием предыдущего. Из-за отсутствия возможности вызывать код систем вне потока и там, где захотим того мы, ECS делает почти невозможным создание рекурсивного кода за рамками какой-то одной конкретной системы.
В качестве решения этого недостатка(aka обходной путь для соблюдения принципов ECS) я вижу только создание специализированной структуры/системы, которая будет вызывать определенный список систем в бесконечном цикле, пока будет соблюдаться конкретное условие, например, пока есть сущности с компонентомDoActionComponent
. Если у вас есть более изящные обходные пути, буду рад прочитать о них в комментариях :)Очень важен порядок выполнения систем
В ECS очень важно понимать и контролировать процесс изменения ваших данных системами. Часто можно упустить влияние какой-то системы на данные, с которыми мы работаем, и, как итог, получить различные незапланированные сайд-эффекты, которые может быть очень сложно отследить(о чем следующий недостаток). Однако нередко можно при написании систем спроектировать их так, чтобы не было важно в каком порядке системы будут вызваны.Сложнее дебажить
Это достаточно спорный пункт, особенно с современными умными IDE, но многие его подмечают. В ввиду отсутствия глубокого StackTrace(у нас же логика в системах и не привязана к сущности) и невозможности отследить как и кем менялись данные и состояние сущности, может возникнуть ситуация, когда сложно найти причину почему ваша система вдруг начинает работать не так, как было задумано: сложно понять, что к этому вызову привело, хотя это просто кто-то добавил компонент на сущность или сделал лишний ++.
Если подвести черту, то в ECS без дебаг-инструментов сложно отследить почему и как менялись данные в компонентах, особенно когда у тебя тысячи сущностей, а проблемная только одна. Исправить этот недостаток могут дебаг-инструменты, которые фреймворки могут предоставить, но их может не быть из коробки и придётся писать самому или страдать.Неудачный вариант для структур данных, особенно иерархических
Реализация структур данных с помощью ECS трудна, неудобна и, мне кажется, вообще лишена смысла. Я не говорю, что это невозможно совсем(если постараться, то возможно всё), но таки это будет тернистый путь без особой выгоды в конце пути, будьте рациональны при выборе.
Я перечислю несколько проблем, который будут мешать при попытках всё таки реализовать какую-то структуру данных на ECS:
- В ECS все данные доступны отовсюду, что для таких штук как структуры данных, где требуется максимальная консистентность, может быть крайне опасно. Любой мимокрокодил может изменить любые внутренние данные в обход вашей логики, что начисто поломает вам структуру данных.
- Если честно следовать принципам ECS, то мы не сможем вызвать логику нашей структуры данных “здесь и сейчас”, как обычно требуется при работе с ними. Однако этот пункт таки можно забороть с помощью static utils/extensions, подробнее об этом в Good Practices.
- ECS - представитель горизонтальных архитектур, все данные в нём лежат в одной плоскости, почти всегда просто одномерные массивы компонентов. Это затрудняет работу если ваша структура данных требует вертикальности/иерархии.
- Нередко в структурах данных требуются ещё и перекрестные ссылки между элементами(иерархия). Но, как вы можете помнить, в ECS все крутится вокруг максимально абстрактной Entity, что затрудняет работу, тк нет гарантий, что на другом конце будет элемент нужного нам типа и это надо будет как-то отдельно обрабатывать.
- Структуре данных и её элементам обычно не требуется менять формат данных в рантайме, как и не требуется комбинаторика, они достаточно ригидны. На каждой сущности структуры данных может по итогу лежать вообще один компонент. Из этого делается вывод: а зачем тут вообще ECS?
Если вам всё же потребовалась структура данных(а они вам так или иначе потребуются), то рекомендую просто создать её отдельным объектом с методами, как это предполагает ваш ЯП, а после положить этот объект в ваш компонент и просто работать с ним из систем как обычно.Больше файлов и классов
В ECS-подходе количество файлов в проекте растет быстрее, чем при аналогичном коде в классических подходах. Как минимум из-за того, что вместо одного класса с данными и логикой у вас появляется два: компонент и система(их правда всё равно можно запрятать в один файл). Как максимум, если во имя комбинаторики делать все компоненты атомарными(1 компонент - 1 поле), то будет очень-очень много файлов…Бойлерплейт
Этот недостаток сильно зависит от конкретной реализации ECS-фреймворка. В одних фреймворках приходится писать очень много технического кода, в других разработчик постарался сделать максимально простое API и минимизировать бойлерплейт. Но, если сравнивать с другими подходами, почти всегда остаётся хотя бы крошечная доля дополнительного кода, который приходится писать: объявление систем и компонентов, получение фильтра с нужными компонентами, получение сущностей из него, получение компонента из сущности и тд.
Ошибки новичка
В этом разделе я поведаю об ошибках, которые более опытный я заметил за собой в своём старом коде и которые мешали мне в разработке, а также об ошибках, которые допускают многие новички когда только начинают осваивать ECS. Надеюсь это поможет обойти часть граблей стороной.
Наследование компонентов и интерфейсы
Наследование компонентов и использование интерфейсов - это очень частая ошибка новичков. Почему же это вредно для разработки?
Во-первых, абстракция на уровне компонентов не даёт абсолютно никакой выгоды в сравнении с ECS-абстракцией через данные(о которой я расскажу в следующем пункте).
Во-вторых, создаёт вам ограничения: вам будет сложнее расширять такие компоненты и дорабатывать связанную с ними логику.
В-третьих, приводит к ситуации, когда в компоненте появляется своя логика, а это, как мы помним, нарушение принципов ECS, чего делать не стоит.
В-четвертых, приводит к беспричинной необходимости наследовать системы или делать всякие switch-case по типам.
Сразу замечу, что наследовать систем - это не всегда хорошая идея, но и плохого она ничего не даёт, в отличии от наследования компонентов. Так что если вам захотелось понаследовать компоненты - не надо так, подумайте ещё раз как можно решить задачу иным путём.Неумение в ECS-абстракцию
ECS абстракция - это когда мы общие данные(которые должны наследоваться в ООП) просто выносим в отдельный компонент. “Наследника” такого компонента мы делаем просто: добавляем новый компонент с нужными нам данными и фильтруем сущности, где естьBaseComponent
иInheritorComponent
. Всё элементарно: если у вас появились какие-то общие данные между компонентами/сущностями - почти всегда их можно вынести в отдельный компонент и чем раньше это сделаешь, тем лучше.Включать/отключать системы для изменения логики
ECS устроен так, что мир и системы, которые его обрабатывают, статичны и существуют всегда, а вот сущности и их данные очень динамичны. И если вам нужно отключить какую-то логику, отключать для этого систему - некорректное решение, к тому же из систем зачастую нету доступа к другим системам(и это хорошо). Куда более практичный вариант - создать какой-то компонент-маркер(подробнее в Good Practices), который будет говорить, что логика системы не должна отрабатывать для сущности с маркером или больше: если вообще в мире существует хотя бы одна сущность с нашим маркером.
На этом месте многие новички заявляют: “Но ведь если у меня нету сущностей для системы, то зачем этой системе работать? Не лучше ли её отключить оптимизации ради?”. Нет, не лучше. Если допускается, что сущностей может не быть, то проще в самое начало главного метода системы добавитьif (entities.Length < 1) return;
, в масштабах игры вызов функции и сравнение двух интов - капля в море, которая никак не повлияет на вашу производительность.
Единственные легитимные случаи отключения систем в рантайме: A/B тестирование и дебаггинг/тестирование конкретных систем(большинство фреймворков даёт инструменты, чтобы делать это не из кода, а из окна редактора).Возводить ECS в абсолют
Только Ситхи возводят всё в абсолют!
Стоит помнить, что ООП не запрещён для ECS-адептов :D
Когда работаешь с ECS, не стоит прямо совсем упарываться в ECS и переносить всёпревсё на него, ибо это бывает контрпродуктивно. К тому же, как я упоминал в недостатках ECS: не все структуры данных хорошо ложатся на ECS, поэтому лучше не трепать себе нервы и просто делать в таких случаях отдельный ООП-класс.
Некоторые разработчики идут дальше и некоторые элементы проекта (например, UI) делают не по лекалам ECS, а немного в стороне любым другим удобным способом и после соединяют с ECS каким-нибудь мостом. Также всяческую вспомогательную логику(подгрузка конфигов, прямая работа с сетью, запись сохранения в файл) проще сделать ООП-сервисом и работать с ним напрямую из нужной системы, чем натягивать сову на глобус. Выбирать как именно поступать нужно исходя из здравого смысла. ECS должен помогать разработке, а не мешать ей.Пытаться дословно перекладывать существующий код на ECS
Очень часто новички пытаются дословно перенести их существующий код на ECS-рельсы. Это не самая хорошая идея, поскольку подходы к написанию кода в ECS таки отличаются от традиционных архитектурных паттернов. Результатом такого переноса обычно оказывается попоболь от ECS и очень кривой итоговый код.
Если вам всё таки требуется перенести старый код на ECS, лучшим вариантом будет написать ту же логику с нуля на ECS, используя свои знания и существующий код как инструкцию что делать.Использовать Delegates/Callbacks или реактивную логику в системах
В ECS может быть опасно захватывать какую-то логику из систем и сохранять её в компонент для дальнейшего использования или моментально производить реакцию на какие-то изменения(например, реакция системы на добавление компонента в другой системе). Помимо того, что это добавляет излишней связанности системам(они начинают сильно зависеть от внешних вызовов), это ломает наш красивый конвейер процессинга данных, добавляя логику, вызов которой мы не то чтобы контролируем. В качестве альтернативы лучше использовать отложенную реактивность о которой расскажу подробнее в Good Practices.Разбивать файлы в папки по типам
Когда начинаешь работать с ECS, по первости хочется складывать новые файлы по типам: компоненты в папочку Components, а системы в папочку Systems. Но с опытом приходит понимание, что это способ сортировки далёк от эффективности. С ним сложно ориентироваться, понимать какие компоненты с какими системами связаны.
Лучший вариант - разбиение по фичам, когда всё, что касается какой-то определенной фичи лежит в одной папке(может с внутренней иерархией components/systems). То есть все компоненты и системы связанные со здоровьем и нанесением урона будут лежать в папке Health. Это позволит одним взглядом на папку понять основной контекст данных для систем, которые в ней лежат и проще ориентироваться по проекту.
Good Practices
Сверху вы можете почитать о том как точно НЕ стоит делать при разработке на ECS, а теперь с примерами поговорим о полезных практиках и просто советах как можно делать.
Тегование сущностей маркерными компонентами
В ECS есть такое понятие, как “компонент-маркер” aka “tag-component”. Это компонент без полей, выполняющий исключительно роль маркировки сущности. Можно воспринимать его как boolean-флаг в классе: он либо есть(true), либо его нет(false).
Например, у нас есть тысяча юнитов и один из них управляется игроком. Мы можем его пометить пустымPlayerMarker
компонентом. Это даст нам возможность получить в фильтре только юниты игрока, а также понять работаем мы с обычным или с управляемым игроком юнитом, когда мы проходим по всеми юнитами разом.Минимизировать места, где меняется компонент
Чем меньше мест, где меняется компонент, тем лучше. Вцелом, это своего рода просто следование крайне полезному принципу Don’t Repeat Yourself(всем рекомендую). У такой практики много плюсов.
Во-первых, это позволит лучше понимать процесс изменения данных в вашем проекте, а соответственно упрощает дебаггинг, если что-то пойдет не так.
Во-вторых, при обновлении логики изменения данных, понадобится обновлять меньше кода, в идеале будет только одно место.
В-третьих, просто меньше шанс допустить багу с данными на ровном месте.
Например, вместо того, чтобы менятьHealthComponent
в каждой системе, где есть урон, лучше создать однуDamageSystem
, цель которой - наносить урон сущностям сHealthComponent
.Не использовать постфикс Component
Постфикс Component очень полезен для новичков, тк напоминает, что "тут лежат только данные". Но со временем необходимость в напоминании пропадает, а засорение кода вездесущим Component остаётся. Поэтому хочу дать совет: на постфикс Component можно спокойно забить, он не дает ничего полезного, кроме, возможно, некоторого упрощения поиска в автокомплите/InteliSense. Это лишь совет и может даже дело вкуса, так что вам решать как поступать с этим советом :)
Например,HealthComponent
становится у нас простоHealth
, и код становится чутка болееfluentчитаемымentity.Has<Health>()
. При этомPlayerMarker
остается без изменений, тк в данном случае постфикс несет полезную информацию, отмечая что это не простой компонент.Отложенная реактивность и однокадровые компоненты
Как было описано в Ошибках новичка, реактивность в ECS может навредить. Что же делать в случаях(а в геймдеве таких много), когда реактивность требуется, когда нужна реакция на какое-то событие? Ответ таков - отложенная реактивность.
Отложенная реактивность - это когда вместо вызова логики напрямую в момент события, мы создаем данные о том, что событие произошло, а все желающие просто отреагируют на событие в нужное для них время. Можно провести аналогию с Dirty-флагом в ООП, когда кто угодно может объявить событиеSetDirty(true)
, но логика отреагирует на это событие когда сама посчитает нужным.
В ECS мы просто создаем компонент с данными или без(можно просто добавить boolean-флаг в существующий компонент), который система обработает, когда наступит ее очередь обработки. Нередко такие компоненты существуют в мире только один кадр, чтобы оповестить все системы, но не повторять логику в следующем кадре. Удалением может заниматься как система генерирующая событие, так и какая-то отдельная система, которая удалит все компоненты типа X, там где это вам будет нужно.
Например, у нас естьDamageSystem
и чтобы ей сообщить сколько урона нанести, мы объявляем компонентMakeDamageComponent
с количеством урона и добавляем его на сущность, которая должна получить урон.DamageSystem
проходит по всем сущностям сHealthComponent
иMakeDamageComponent
, наносит урон сущности, удаляетMakeDamageComponent
и создает маркерDamagedEntityMarker
, который информирует все системы послеDamageSystem
о повреждённой сущности. В конце кадра отдельная система удаляетDamagedEntityMarker
, чтобы в следующем кадре системы не обработали этот маркер повторно.Requests/Events в качестве API для систем
Развивая идею однокадровых компонентов, мы можем с их помощью выразить своеобразное API для систем. Request-компоненты для запроса извне и Event-компоненты для уведомления всех о случившемся. Жизненный цикл обоих компонентов система может контролировать сама: удалять Requests сразу после обработки и сама чистить Events перед запуском новых событий. Как именно их называть и добавлять ли вообще постфикс Requests/Events решать вам.
Например, у нас естьDamageSystem
из предыдущего пункта. Мы можем выразить запрос на нанесение урона к ней с помощью компонентаMakeDamageRequest
, а с помощью компонентаDamagedEntityEvent
мы оповещаем другие системы. Логика внутри системы получается такая: очистить всеDamagedEntityEvent
с прошлого кадра(итого событие сделает полный круг по системам), для всех сущностей с реквестом нанести урон, удалить реквест и добавить компонентDamagedEntityEvent
.Хранение ссылки на другую сущность внутри компонента
У вас наверное давно возник вопрос “А как вообще в этом вашем ECS выстраивать связи между сущностями? Неужто отмечать компонентами сущности и потом их искать в цикле?”. Конечно же нет, всё куда проще и обыденнее: как и везде - просто сохраняем ссылку. Разница лишь в том, что сохранять нужно ссылку не на интересующий нас компонент другой сущности, а на саму сущность.
Итого, добавляем в компонент поле с сущностью(или каким там образом хранит сущности ваш фреймворк), перед использованием проверяем, что сущность жива и есть ли у нее желаемый компонент, получаем этот компонент и далее работаем с ним как нам будет угодно.
Например, можноMakeDamageRequest
вешать не прямо на сущность, а запускать в виде отдельной сущности-события со ссылкой на целевую сущность. Для этого вMakeDamageRequest
добавляем полеEntity target
, аDamageSystem
переделываем: теперь она должна пройтись по всем реквестам, проверить, чтоtarget
- живая сущность сHealthComponent
, достатьHealthComponent
и нанести урон. ЗапускMakeDamageRequest
теперь тоже будет выглядеть иначе: вместо добавления компонента прямо на сущность, мы создаем новую сущность сMakeDamageRequest
и указываемtarget
. Таким образом, в ущерб удобству фильтрации, мы получаем возможность запустить несколько разных событий нанесения урона для одной сущностиtarget
.Вынос повторяющейся логики в StaticUtils/Extensions
Со временем начинаешь замечать, что выполняешь одну и ту же логику в разных системах. Обычно это признак, что пора делать новую систему :D
Но бывает, что повторяющаяся логика является вспомогательной, связана с одним-двумя конкретными компонентами/сущностями и её результат используется для разных целей. Скажем, особая интерпретация данных в компоненте. Некоторые разработчики допускают объявление такой вспомогательной логики прямо в компоненте(в виде геттеров, например), но дабы не нарушать ECS я предлагаю другой вариант: статические утилиты(или Extensions в C#), которые мы вызываем из систем.
Например, у нас естьInTeamComponent
, внутри которого, скажем, цвет команды. Проверка, что две разных сущности принадлежат к одной команде, может потребоваться более чем в одной системе. Поэтому мы создаем статический классTeamUtils
и метод в нёмIsInSameTeam(Entity, Entity)
, где и описываем повторяющуюся логику сравнения команд для двух сущностей.Группировка систем по моменту выполнения
Как вы уже знаете, в ECS очень важен порядок вызова систем, поэтому бывает удобно сделать группировку систем на самом верхнем уровне по порядку вызова в кадре.
Например, первыми в кадре могут быть вызваны все системы связанные с инпутом, они соберут пользовательский ввод и подготовят его в ECS формат. Второй на очереди будет группа систем с игровой логикой, которая интерпретирует данные ввода на свой манер и обновит ECS мир. Ну и напоследок у нас может быть группа систем, отвечающая за рендер или просто различные вспомогательные штуки, которые должны вызываться после всей игровой логики.Выделять крупные фичи в отдельные сборки(Assembly)
Такой подход позволит отделить фичи друг от друга и контролировать зависимости между ними. В идеальном мире, они вообще не должны пересекаться без необходимости, а порядок между фичами должен быть не важен. Так же должен быть Core Assembly, где будут располагаться компоненты, которые нужны всем фичам для работы.Дробить ли компоненты/системы на мелкие кусочки?
Этот пункт имеет знак вопроса на конце не просто так, это больше дискуссионный момент, чем Good Practice. Но чтобы вы лучше понимали что нужно именно вам, я постараюсь раскрыть оба ответа на вопрос.Да, дробить компоненты нужно всегда. Такой подход в организации ECS можно назвать атомарным. Верхняя степень такого подхода - каждый компонент имеет только одно поле. Это позволит нам достичь апогея комбинаторики в проекте, избавляет от необходимости рефакторинга во имя ECS-абстракции, можно больше не задумываться “как объединить сущности с X свойством”.
Из минусов:
- Количество классов и файлов будет расти очень быстро, что может привести к путанице на больших проектах, если не уделять должного внимания организации проекта
- Количество компонентов(вцелом или на сущности) может влиять на производительность вашего фреймворка
- Сложнее по куче свойств понять что из себя представляет сущность(можно решить маркером с нормальным именем)Нет, дробить компоненты только когда это требуется. Принцип “Не разделяй раньше времени”. Этого принципа придерживаюсь лично я. Он ограничивает разрастание проекта. Метрика когда нужно отделить данные проста: эти данные используются/планируются к использованию где-то ещё в отрыве от этого компонента? Если нет, то и незачем тратить на это время. Аналогично можно подходить и к разбиению логики на системы.
Из минусов:
- Время на ECS-абстракцию всё таки придётся потратить, если дизайнер введёт ещё одну сущность с похожими данными и потребуется разделение, время на рефакторинг будет тем больше, чем больше кода уже завязано на эти данные
- Меньше свободы по конструированию сущностей
Выбор стороны за вами :)
Фреймворки для Unity/C#
Выбор новичка
Если вы новичок, предположу, что первым делом вы думали начать ковырять Unity DOTS, но я хочу вас притормозить: Unity DOTS не самый хороший вариант для начинающих, ибо он очень большой и сложный, а документации и опытных людей на нём пока не так много, как хотелось бы, к тому же он плохо совместим со старым Unity-кодом (это всё может измениться с момента написания статьи).
Если вы за минимализм, предпочитаете всё делать из кода(не редактора) или пишете на движке отличном от Unity, то для вас лучшими вариантами начать будут LeoECS или LeoECSLite. Первый вариант с более простым синтаксисом и в статусе LTS(только багфиксы), идеальный вариант для старта. Второй вариант менее юзер-френдли, но быстрее работает и активно развивается. Оба являются максимально простыми Engine-Agnostic фреймворками на C#, то есть подойдут для какого угодно использования. У них живое комьюнити, которое делает полезные расширяющие функционал модули(например, интеграция с Unity) и которое всегда может помочь советами.
Если же вы любите Unity Editor и уже привыкли вешать компоненты прямо на GameObjects, то вам лучше всего подойдет Morpeh с простым API, максимально плотной интеграцией в Unity Editor(при этом может работать и вне Unity) и удобной работой с монобехами. Коммьюнити у него пока небольшое, но автор всегда готов ответить на вопросы в ECS-чатике. Morpeh - моя личная рекомендация для начинающих. Он прост и удобен, всё нужное для работы с Unity в нём есть из коробки, просто устанавливаешь и работаешь. Главный его недостаток - он требует платный Odin Inspector для полноценной работы в Unity(но этот недостаток в будущем должен уйти).
С чем я работал
А теперь краткий обзор Unity/C# фреймворков, с которыми я познакомился лично и какие плюсы/минусы я в них заметил. Стоит подметить, что всё описанное ниже может измениться с момента публикации статьи, так что лучше проверять фреймворки самому, а не верить рандомному штурмовику на слово.
Entitas
https://github.com/sschmid/Entitas-CSharp
https://assetstore.unity.com/packages/tools/game-toolkits/entitas-87638
Самый старый для Unity/C# и до сих пор самый популярный. В вакансиях с ECS чаще всего встречается именно он.
Плюсы:
Отличный WorldViewer в редакторе Unity
Fluent-стиль кода(благодаря кодогенерации)
Хорошая документация
Очень большое коммьюнити
Множество успешных проектов на нём
Обязательный Support от разработчика(спасибо AssetStore)
Может использоваться вне Unity на чистом C#
Минусы:
Плохая производительность относительно других ECS фреймворков(но всё равно лучше, чем MonoBehaviour)
Много аллокаций, что отрицательно влияет на GC
Требует кодогенерации на каждое изменение структуры компонентов
На больших проектах API очень сильно раздувается из-за кодогенерации
Версия с Github не позволяет вызывать кодогенерацию при ошибках компиляции, версия из AssetStore с другой кодогенерацией позволяет
LeoECS
https://github.com/Leopotam/ecs
https://github.com/Leopotam/ecslite
Минималистичный и простой в освоении Engine-Agnostic фреймворк с открытым исходным кодом. Второй по звёздочкам на Github(после Entitas) и один из самых быстрых по производительности. LeoEcsLite - своего рода вторая версия фреймворка, ещё быстрее и минималистичнее, но с чуть менее удобным API.
Плюсы:
Минималистичный и лёгкий
Простое API
Хорошая производительность
Большое коммьюнити
Много полезных модулей от коммьюнити, включая поддержку Jobs/Burst в LeoEcsLite
Engine-Agnostic
Может использоваться вне Unity на чистом C#
Минусы:
Engine-Agnostic подразумевает, что многое потребуется писать руками
Open-Source подразумевает, что никакой обязательной поддержки, но коммьюнити спасает
Минимальная интеграция с Unity(решается модулями от коммьюнити)
Не всем нравится встроенный Dependency Injection через рефлексию(не актуально для LeoEcsLite)
DOTS (Unity ECS)
https://unity.com/dots
Думаю не нуждается в представлении. DOTS скорее не фреймворк, а полноценная платформа(технологический стак) с Unity ECS в качестве фреймворка внутри. Хочу подметить, что ниже описан несколько устаревший опыт. Допускаю, что актуальный DOTS мог избавиться от описанных ниже проблем.
Плюсы:
Полноценная платформа для разработки на ECS
Создается разработчиками движка с максимально плотной интеграцией в редактор
Из коробки поддерживает Jobs и Burst
Вкупе с Jobs и Burst достигает максимальной производительности среди ECS фреймворков
Имеет отличную сетевую библиотеку с предсказаниями NetCode
Механизм SubScenes
Минусы:
Work-in-progress, что приводит к ломающим старый код изменениям и свежим багам
Слабая документация, которая часто не поспевает за изменениями, приходится читать исходники
Бойлерплейт. Требует писать больше технического кода, чем аналоги, к тому же код получается трудночитаемым и далёким от лаконичности.
Без Burst/Jobs работает не быстрее open-source решений(а в некоторых кейсах медленнее)
Плохо сочетается со старым Unity-кодом(читай половина AssetStore)
DOTS по сути новый рантайм, при этом далеко не весь старый функционал перенесен на DOTS, что ограничивает в возможностях использования
Работает исключительно под Unity
Morpeh
https://github.com/scellecs/morpeh
Фреймворк с девизом “вставил и поехал”. Прост в освоении, быстрый, имеет много полезных фич из коробки и удобную интеграцию с Unity. Мой личный фаворит в плане удобства разработки под Unity.
Плюсы:
Простое API
Хорошая производительность
Отличная интеграция с Unity
Инструменты для отложенной реактивности(Globals)
Может использоваться вне Unity на чистом C#
Минусы:
Для полноценной работы в Unity требует платный Odin Inspector
Не разбит на модули, тащит весь функционал сразу
Популярен в узких кругах, поэтому коммьюнити маленькое
Достойные внимания
Здесь я перечислю другие достойные внимания фреймворки, с которыми мне пока не довелось поработать лично, но с которыми я хотел бы познакомиться поближе.
ME.ECS. Фреймворк рассчитан на сетевые игры. Из коробки детерминизм, хранение стейтов мира, роллбеки, поддержка Jobs/Burst и очень удобная интеграция с Unity.
DefaultECS. Engine-Agnostic фреймворк без какой-либо интеграции с Unity, не уступает LeoECS в производительности и имеет собственный аналог Jobs.
Actors. Взрослый фреймворк вдохновлённый LeoECS, но с плотной интеграцией в Unity и множеством удобных фичей. Сделан дизайнером для дизайнеров.
EZS. Последователь разом и LeoECS, и Entitas. Простое API, поддержка многопоточности из коробки, интеграция с Unity и реактивные системы в духе Entitas, поддержка Burst(по словам автора пока сыровата).
NanoECS. Фреймворк с кодогенерацией вдохновлённый множеством других, больше всего похож на Entitas, но с более простым API.
Quantum. Стоит очень много денег, но это даже не фреймворк, а полноценная платформа в духе DOTS для разработки сетевых игр. Детерминизм, роллбеки, своя реализация анимаций, физики и много чего другого. От разработчика требуется писать одиночную игру, всё остальное платформа сделает сама. Главный недостаток помимо цены - почти всё работает на unsafe, что практически превращает C# в С++.
Возможно в будущем я сделаю полноценную обзорную статью по фреймворкам для Unity/C#, но это будет уже совсем другая история.
Итог
Как вы могли заметить по большому списку недостатков - ECS не является серебряной пулей. У этого архитектурного решения, как и у любого другого, есть свои преимущества и свои недостатки, с которыми придется мириться, если выбирать разработку с использованием этого архитектурного паттерна. Так что выбор использовать ECS в своих проектах или нет - исключительно за вами, но я настойчиво рекомендую как минимум попробовать сделать небольшой проект на ECS, чтобы понять по душе вам такой подход или нет.
Чтобы вам было проще определиться, поделюсь с вами ссылками на русскоязычные доклады, которые мне кажутся достойными внимания:
Еще рекомендую заглянуть в этот англоязычный репозиторий, где есть ответы на множество вопросов связанных с ECS, есть ссылки на англоязычные доклады, список фреймворков под разные языки, а также примеры игр и программ, которые используют ECS.
Так же крайне рекомендую ламповый чатик в Telegram, где вы найдете орду ECS-фанатиков, с которыми всегда сможете перетереть за ECS вне зависимости от языка и движка, получить ответы на интересующие вопросы(если они не будут уровня “не читал readme”), поспорить обо всяком и узнать много нового, присоединяйтесь :)
Попутно хочу ещё раз выразить благодарность участникам чата: без их дискуссий и обсуждений эта статья вышла бы куда более унылой и пустой.
С моей личной колокольни ECS выглядит отличным вариантом для создания игр. Разработка на нём, лично для меня, сплошное удовольствие: ты именно что разрабатываешь игру, а не пытаешься придумать как интегрировать новый код в старую систему ничего не сломав. Стоит иметь в виду, что на удобство ECS разработки сильно сказывается выбор фреймворка, поэтому пробуйте разные варианты и выбирайте тщательно.
Исходя из своего опыта, я склонен думать, что за ECS(или его вариацией) будущее разработки интерактивных развлечений. И не только потому что Unity Technologies(и, возможно, даже Epic) выбрали его основным направлением, но и просто потому, что ECS обладает выгодными в контексте разработки игр преимуществами. Да и в целом это практичный подход, который кажется неудобным на старте, но приносит свои плоды на дальней дистанции. Хороших и успешных игр вам!
Напоследок, ссылки откуда я угнал картинки, это всё англоязычные статьи про ECS:
https://devforum.roblox.com/t/ecs-lua-a-tiny-and-easy-to-use-ecs-entity-component-system-engine/841175
https://devlog.hexops.com/2022/lets-build-ecs-part-1/
https://github.com/a327ex/blog/issues/24
https://blog.lmorchard.com/2013/11/27/entity-component-system/
https://yos.io/2016/09/17/entity-component-systems/