Сколько уже было мануалов "Как сделать игру на Unity за 3 часа", "Делаем Counter-Strike за вечер" и т.п.? Низкий порог входа — это, несомненно, главный плюс и минус Unity. Действительно, можно накидать “ассетов”, дописать несколько простых “скриптов”, обмотать синей изолентой и это даже будет как-то работать. Но когда проект обрастает игровыми механиками, сложной логикой поведения, то проблемы при подобном подходе нарастают как снежный ком. Для внедрения новых механик требуется переписывание кода во многих местах, постоянная проверка и переделывание префабов из-за побившихся ссылок на компоненты логики, не говоря уже об оптимизации и тестировании всего этого. Разумеется, архитектуру можно продумать изначально, но на практике это всегда недостижимая цель — дизайн-документ довольно часто меняется, какие-то части выкидываются, добавляются абсолютно новые и никак не связанные со старой логикой поведения. Компоненты в Unity — это шаг в правильном направлении в виде декомпозиции кода на изолированные блоки, но особенности реализации не позволяют достичь необходимой гибкости, а самое главное, производительности. Разработчики придумывают свои фреймворки и велосипеды, но чаще всего останавливаются на ECS (Entity Component System). ECS – одно из решений, продолжающее идею компонентной модели Unity, но придающее ей ещё больше гибкости и сильно упрощающее рефакторинг и дальнейшее расширение приложения новым функционалом без кардинальных изменений в текущем коде.
Что такое ECS
ECS — это шаблон проектирования "Сущность Компонент Система" (Entity Component System, не путать с Elastic Cloud Storage :). Если совсем по-простому, то есть “Сущности” (Entity) — объекты-контейнеры, не обладающие свойствами, но выступающие хранилищами для “Компонентов”. “Компоненты” — это блоки данных, определяющие всевозможные свойства любых игровых объектов или событий. Все эти данные, сгруппированные в контейнеры, обрабатываются логикой, существующей исключительно в виде “Систем” — “чистых” классов с определенными методами для выполнения. Данный паттерн является независимым от какого-либо “движка” и может быть реализован множеством способов. Все “сущности”, “системы” и “компоненты” должны где-то храниться и каким-то образом инициализироваться — все это является особенностями реализации каждого ECS решения для конкретного “движка”.
Постойте, скажете вы, но ведь в Unity всё так и есть! Действительно, в Unity “Сущность” — это GameObject, а “Компонент” и “Система” — это наследники MonoBehaviour. Но в этом и заключается основное различие между компонентной системой Unity и ECS — логика в ECS обязательно должна быть отделена от данных. Это позволяет очень гибко менять логику (даже удалять / добавлять её), не ломая данные. Другой бонус — данные обрабатываются “потоком” в каждой системе и независимо от реализации в “движке”, в случае с MonoBehaviour происходит довольно много взаимодействия с “Native”-частью, что съедает часть производительности. Об особенностях внутреннего устройства вызова методов у наследников MonoBehaviour можно почитать в официальном блоге Unity: 10000 вызовов Update()
Пример работы ECS
Задача от дизайнера: “надо сделать перемещение игрока и загрузку следующего уровня, когда он доходит то точки Х”.
Разбиваем задачу на несколько подзадач, по одной на “систему”:
- UserInputSystem — пользовательский ввод.
- MovePlayerSystem — перемещение игрока на основе ввода.
- CheckPointSystem — проверка достижения точки игроком.
- LoadLevelSystem — загрузка уровня в нужный момент.
Определяем компоненты:
- UserInputEvent — событие о наличии пользовательского ввода с данными о нем. Да, события — это тоже компоненты!
- Player — хранение текущей позиции игрока и его скорости.
- CheckPoint — точка взаимодействия на карте.
- LoadLevelEvent — событие о необходимости загрузки нового уровня.
И вот как это всё примерно работает:
- Загружается сцена и инициализируются все системы в указанной выше последовательности. Да, порядок обработки систем можно контролировать без сложных телодвижений- это ещё один приятный бонус.
- Создаются сущности игрока (с добавлением на него компонента Player) и сущности контрольной точки (с добавлением на неё компонента CheckPoint).
- Тут стартует основной цикл обработки систем — по сути аналог метода MonoBehaviour.Update.
- UserInputSystem проверяет пользовательский ввод через стандартное Unity-api и создает новую сущность с компонентом UserInputEvent и данными о вводе (если он был).
- MovePlayerSystem проверяет — есть ли сущности с компонентом UserInputEvent и есть ли сущности с компонентом Player. Если пользовательский ввод есть — обрабатываем всех найденных “игроков” (даже если он один) с полученными данными, а сущность с компонентом UserInputEvent удаляем полностью. Да, это работает очень быстро, не вызывает работы сборщика мусора — все уходит во внутренний пул для последующего переиспользования.
- CheckPointSystem проверяет — есть ли сущности с компонентом CheckPoint и есть ли сущности с компонентом Player. Если есть и то и то — в цикле проверяет дистанции между каждым игроком и точкой. Если один из “игроков” находится достаточно близко для срабатывания — создает новую сущность с компонентом LoadLevelEvent.
- LoadLevelSystem проверяет — есть ли сущности с компонентом LoadLevelEvent и выполняет загрузку новой сцены при наличии. Все сущности с таким компонентом удаляются перед этим.
- Повторяем основной цикл обработки систем.
Выглядит как чрезмерное усложнение кода по сравнению с одним “MonoBehaviour” классом в десяток строк, но изначально:
- Позволяет отделить ввод от остальной логики. Мы можем поменять модель ввода с клавиатуры на мышь, контроллер, тачскрин и остальной код не поломается.
- Позволяет расширять поведение по обработке игрока новыми способами без ломания текущих. Например, мы можем добавить зоны замедления / ускорения на карте путем добавления еще одной или нескольких систем и изменением параметра скорости в компоненте Player для определенных сущностей.
- Позволяет иметь на карте сколько угодно контрольных точек, а не только одну, как просил дизайнер.
- Позволяет даже иметь несколько игроков, управляющихся одним способом. Тоже может быть частью игровой механики, как в BinaryLand:
Особенности ECS
Исходя из примера выше, можно вывести основные особенности ECS по отношению к компонентной модели Unity.
Плюсы
- Гибкость и масштабируемость (добавление новых, удаление старых систем и компонентов).
- Эффективное использования памяти (особенность реализации, мы можем переиспользовать инстансы “чистых” C#-классов как угодно в отличие от “MonoBehaviour”).
- Простой доступ к объектам (выборка (фильтрация) сущностей с определенными компонентами производится ядром ECS без потери скорости и аллокаций памяти — это именно то, чего не хватает компонентной системе Unity).
- Понятное разделение логики и данных.
- Проще тестировать (легко воспроизводить тестовое окружение).
- Возможность использования логики на сервере без Unity (нет зависимостей от самого “движка”).
Минусы
- Больше кода
- Для событий самой Unity необходимо каким-то образом пробрасывать их в ECS-окружение через “MonoBehaviour”-обертки.
Для многих, кто долго работал с Unity и ни разу не использовал ECS, поначалу будет сложно привыкнуть к такому подходу. Но вскоре, начинаешь “думать” компонентами / системами и всё собирается быстрее и легче, чем при сильно связанных компонентах на базе “MonoBehaviour”.
Встроенное ECS-решение в Unity
Сейчас даже сами разработчики Unity поняли, что пора что-то менять в их компонентной системе, чтобы повысить производительность приложений. Где-то год назад было анонсировано, что ведётся разработка собственной ECS и C# Job system. И вот, в 2018.1 версии, мы уже можем примерно представить, что же это будет в будущем, пусть даже и в Preview статусе.
Со штатной Unity ECS – пока ничего не понятно. Разработчики нигде не пишут, что она подходит только для ограниченного спектра задач, но когда возникают вопросы в результате переписывания с других ECS-решений — отвечают в стиле “вы неправильно используете ECS”. Т.е. по сути это получается не “multipurpose”-решение, что довольно странно. Релиза не было, всё еще могут поменять несколько раз, есть проблемы с передачей ссылочных типов (например, string), поэтому я не могу порекомендовать делать что-то большое на штатной ECS в её текущем состоянии.
Альтернативные ECS-решения для Unity
ECS-паттерн был придуман не вчера и на https://github.com можно найти множество его реализаций, включая версии для Unity. Относительно свежие и обновляющиеся:
Я имел дело только с двумя первыми вариантами.
Entitas — самое популярное и поддерживаемое большим сообществом решение (потому что было первым). Оно достаточно быстрое, есть интеграция с Unity-редактором для визуализации ECS-объектов, присутствует кодогенерация для создания оберток с удобным api поверх пользовательских компонентов. За последний год кодогенератор отделился в независимый проект и стал платным, так что это скорее минус. Еще один достаточно весомый минус (особенно для мобильных платформ) — память выделяется под все возможные варианты компонентов на каждой сущности, что не очень хорошо. Но в целом, он хорош, отлично документирован и готов к использованию на реальных проектах. Размер: 0.5mb + 3mb поддержки редактора.
Примеров с использованием Entitas достаточно много, но и существует / пиарится проект давно. Из примеров с исходниками можно посмотреть Match 1.
Общая производительность Entitas оценивается примерно так:
С LeoECS я знаком лучше, потому что делаю на нём новую игру. Оно компактное, не содержит закрытого кода в виде внешних сборок, поддерживает assembly definitions из Unity 2017, более оптимизировано по использованию памяти, практически нулевой GC (только на первичном наборе пулов), никаких зависимостей, C# v3.5 с опциональной поддержкой inline-ов для FW4.6. Из приятных вещей: DI через разметку атрибутами, интеграция с Unity-редактором для визуализации ECS-объектов и готовая обвязка для событий uGUI. Размер: 18kb + 16kb поддержки редактора.
В качестве готового примера с исходниками можно посмотреть классическую игру "Змейка".
Сравнение скорости Entitas и LeoECS: результаты достаточно близки с небольшим перевесом в ту и другую сторону.
Заключение
Я не эксперт в данном вопросе (только недавно начал использовать Unity в связке с ECS), поэтому и решил поделиться своими наблюдениями и мыслями в первую очередь с теми, кто "собирает" игры на Unity из ассетов с кучей скриптов на каждом. Да, это работает. Сам такой был. Но если вы делаете не прототип или какую-нибудь одноразовую игру без необходимости её поддержки и дальнейшего развития, то подумайте 10 раз — вам же потом во всём этом разбираться и переделывать.
Используя ECS я даже получаю удовольствие от процесса рефакторинга :) В игру легко добавляются новые фичи, изменяются старые — и всё это без боли и конфликтов с дизайнером, решившим добавить новую зубодробительную механику или удалить старые, наигравшись с ними.
Комментарии (29)
Katsuko
09.05.2018 01:19Может я плохо понял идею нового подхода, но что мне мешало раньше (в принципе я так и делал) отделять тот же input от основной логики (реализация которых чаще всего была не в MonoBehaviour классах) моего приложения. Из прочитанной статьи я не уловил профита новой системы.
Leopotam
09.05.2018 09:53Основная идея в том, что логику можно удалять / добавлять путем редактирования списка активных систем в ECS — при этом ничего остального менять не потребуется. В юнити этого можно достичь только через менеджеры, основанные на MonoBehaviour-ах, но это будет все-равно медленнее чем чистые классы. Вторая особенность — это быстрая выборка сущностей-GameObject-ов с определенными компонентами, по сути аналог FindObjectsOfType(), но позволяет работать с комплексными типами, например:
[EcsFilterInclude (typeof (IsBoxAnimationStarted))] [EcsFilterExclude (typeof (TweenPosition))] EcsFilter _tweenedBoxes;
Звучит примерно как «хочу получить все сущности, на которых висит компонент IsBoxAnimationStarted и отсутствует компонент TweenPosition». Это делается без потери производительности — по сути все совместимые фильтры обновляются только в момент добавления / удаления компонентов по событию, а не каждый фрейм. Сильно напоминает выборки из реляционной базы данных.
evnuh
09.05.2018 15:50Ни в статье, ни в комментариях, нигде никто не упомянул, зачем ECS вообще нужна — для распараллеливания логики по ядрам. Это всё, ради чего оно и задумывалось, вместе с Job System. В ECS ваши системы работают независимо друг от друга, а значит могут быть спокойны распараллелены, а так же обрабатывать сущности (Entities) пачками, тоже разделяя пачки по ядрам.
Leopotam
09.05.2018 16:06Это ни коим образом не задача ECS, а просто побочный эффект и часть реализации в штатном решении Unity ECS, что ограничивает применение обработки в Job-ах (запрет на референсные типы — как часть этой проблемы).
Нужно смотреть на время синхронизации потоков. По моим тестам количество сущностей должно быть не менее 1к на каждый поток с обработкой сложнее простого перебора в цикле — тогда имеет смысл параллелить, иначе все сожрет синхронизация и получится даже медленнее: leopotam.com/16
Belfegnar
10.05.2018 00:18Спасибо за статью) Есть пара вопросов.
1) Существуют ли спидтесты — сравнения производительности Unity ECS (с включенным оптимизатором) и описанных в статье альтернативных систем?
2) Как вообще уживается ECS и data-oriented design? В Unity ECS, насколько я понимаю, все ограничения связаны как раз с решением объединить эти два подхода (и внедрением своего компилятора, оптимизирующего все и вся)Leopotam
10.05.2018 09:541. Спидтестов к штатной ECS нет, потому что оно еще не в релизе + придется ограничивать себя MarshalByValue типами внутри компонентов — получается синтетика вместо реальных тестов. Нельзя использовать ссылки на инстансы классов, нельзя использовать строки и т.п — все то, что в любом случае необходимо использовать для использования ECS в качестве основного архитектурного паттерна для приложения.
2. DOD — это не про расположение данных, а про сам механизм обработки: «одна команда (система) — много данных (компонентов)», или SIMD. Т.е по сути ECS можно называть паттерном, построенным по принципам DOD. То, что «используем только MarshalByValue для плотного расположения в памяти и надежде на использование кеша» — это особенности реализации, когда меняем постоянное копирование больших блоков данных внутри «движка» на скорость обработки этих данных пользовательским кодом.
Griboks
Если пишите про компонентную систему, то надо тогда и про систему зависимостей пост в догонку написать.
Leopotam
Что есть «система зависимостей»? Фильтрация сущностей с определенными компонентами, включая составные?
Griboks
Точнее, система внедрения зависимостей. Да вообще всё, что с ними связано. Это очень важная система, про которую стоит написать. Например, https://habr.com/post/245589 или как совместить эту систему с ECS. И какие есть нюансы, какие решения. В общем, тема для статьи, однозначно.
Leopotam
Это зависит от реализации ECS, применительно к той, которой пользуется автор — все делается автоматически через разметку атрибутами без использования конструкторов:
"_world" — это инстанс-контейнер для всех сущностей, так же предоставляющий апи по управлению ими.
"_positionFilter" — сюда будут автоматически набиваться сущности, на которых гарантированно есть компонент «TweenPosition».
Регистрация систем выглядит примерно так:
Обновление вызывается примерно так:
Это все есть в README у каждой реализации ECS и на статью это не тянет.
Griboks
Да и этот пост на статью не тянет, но его зачем-то написали. Тем более, эта тема интересна с точки зрения теории, а не кода.
Leopotam
Да и комментарий не стоило писать про конкретные особенности DI в реализации, если интересовала только теория.
Griboks
Неужели вы думаете, что какую-либо реализацию нельзя развить в теорию? Это распространённое заблуждение, что, ограничивая задачу с одной стороны, мы ограничиваем её в целом.
Я имел в виду особенности совместимости ECS и DI в данном случае.
Leopotam
Нельзя, потому что это особенность реализации. Где-то DI делается исключительно через конструктор + все остальные поля запрашиваются руками через апи, что дает много бойлерплейта (Entitas), где-то делается автоматически через reflection (штатная ECS). Никакой разницы нет, функции выполняются идентичные, поэтому не совсем понимаю, почему эта тема показалась настолько важной.
Griboks
А потом вдруг оказывается, что никакой гибкости нету, и системы, например DI, намертво зашиты в ECS. Какая-то нестыковочка получается.
Leopotam
Так гибкость дается в управлении состояниями компонентов, а не в инициализации, которая является особенностью реализации. Если так рассуждать, то можно докопаться и до того, что юнити нас ограничивает в любом случае необходимостью использования MonoBehaviour-ов, что вообще невозможно использовать — негибко и зашито намертво.
Griboks
А разве автор не также рассуждает? И приходит к выводу, что встроенная ECS действительно плохая, поэтому и рассказывает о новой «правильной» ECS, которая позволяет сделать всё то, для чего она предназначена.
Leopotam
Советую перечитать статью, автор об этом не говорит, говорят об этом сами юнитехи, когда говорят, что нельзя использовать их ECS в роли основного архитектурного фреймворка для всего приложения. Еще есть проблемы с референсными типами (решения нет): forum.unity.com/threads/alternative-to-using-string.523240
Griboks
Но разве это не говорит о том, что можно просто заменить DIS, и ничего другое не пострадает? И речь тут идёт не об инициализации самой системы, а её функционировании — внедрении зависимостей по ходу работы редактора и самой игры.
Т.е. я могу заменить поиск объектов на кэш объектов, и всё будет прекрасно работать дальше. Иными словами, я могу выбрать любую (или же нет?) DIS (какую?) и подключить её обособленно (или с бубном?). Про это и следовало бы написать. Всё-таки, это и наиболее проблемная и часто используемая грань разработки.
Leopotam
Кеш тут не сильно поможет — его нужно будет валидировать руками на каждое изменение в компонентах, а «выборки» могут быть составными (например, 4-5 компонентов по AND + 2 компонента по NOT), над такими кешами придется хорошо подумать. Собственно, всю эту ручную работу и делает любой ECS фреймворк. Как именно — особенности реализации.
Griboks
А если я захочу использовать свою DIS, то мне придётся отказаться от ECS? Что же это за ECS такая?
Leopotam
Никто не мешает использовать свое решение для инжекта своих данных в системы. Данные, связанные с ECS, инжектятся самой ECS.
Griboks
Какие данные? Скорость бега игрока? Кастомные компоненты? Цвет полосы загрузки? Как отличить эти данные, от используемых в самой игре? Как тогда называется модель организации всех этих данных, логики и системы, разрабатываемых лично моими руками? ECSOS? А зачем тогда нужна ECS, если она не работает с моими данными? Вы меня запутали(
Leopotam
Начнем с того, что зачем своя система инжекта, если есть штатная? Все, что связано с юнити-компонентами — никак не взаимодействует с ECS, связи нужно делать руками и кидать в ECS-компоненты, это можно делать любыми средствами.
mopsicus Автор
Действительно, стоит перечитать статью — я так не говорил, вы немного переврали.
Это статья про ECS в Unity, и можно вполне говорить про неё в отрыве от DI, потому что как уже написали — это особенности реализации.
Griboks
Но почему тогда вы пишете, что ECS — это модель сосуществования контейнеров, данных и систем, а теперь утверждаете, что системы — это, оказывается, уже неотделимая часть ECS, которая контролируется исключительно той или иной ECS, которую нельзя разделить, заменить или как-то редактировать?
mopsicus Автор
Так мы говорим про разные системы видимо
В ECS, «системы» — это внутренняя логика приложения, разбитая на модули, каждая система выполняет определенную задачу. Смотрите пример.
А вы говорите про DI, которая внешняя, и может быть разная в каждой ECS.