Unity, ECS и все-все-все
Сколько уже было мануалов «Как сделать игру на Unity за 3 часа», «Делаем Counter-Strike за вечер» и т.п.? Низкий порог входа — это, несомненно, главный плюс и минус Unity. Действительно, можно накидать «ассетов», дописать несколько простых «скриптов», обмотать синей изолентой и это даже будет как-то работать. Но когда проект обрастает игровыми механиками, сложной логикой поведения, то проблемы при подобном подходе нарастают как снежный ком. Для внедрения новых механик требуется переписывание кода во многих местах, постоянная проверка и переделывание префабов из-за побившихся ссылок на компоненты логики, не говоря уже об оптимизации и тестировании всего этого. Разумеется, архитектуру можно продумать изначально, но на практике это всегда недостижимая цель — дизайн-документ довольно часто меняется, какие-то части выкидываются, добавляются абсолютно новые и никак не связанные со старой логикой поведения. Компоненты в Unity — это шаг в правильном направлении в виде декомпозиции кода на изолированные блоки, но особенности реализации не позволяют достичь необходимой гибкости, а самое главное, производительности. Разработчики придумывают свои фреймворки и велосипеды, но чаще всего останавливаются на ECS (Entity Component System). ECS — одно из решений, продолжающее идею компонентной модели Unity, но придающее ей ещё больше гибкости и сильно упрощающее рефакторинг и дальнейшее расширение приложения новым функционалом без кардинальных изменений в текущем коде.
ECS — это шаблон проектирования «Сущность Компонент Система» (Entity Component System, не путать с Elastic Cloud Storage:). Если совсем по-простому, то есть »Сущности» (Entity) — объекты-контейнеры, не обладающие свойствами, но выступающие хранилищами для «Компонентов».»Компоненты» — это блоки данных, определяющие всевозможные свойства любых игровых объектов или событий. Все эти данные, сгруппированные в контейнеры, обрабатываются логикой, существующей исключительно в виде »Систем» — «чистых» классов с определенными методами для выполнения. Данный паттерн является независимым от какого-либо «движка» и может быть реализован множеством способов. Все «сущности», «системы» и «компоненты» должны где-то храниться и каким-то образом инициализироваться — все это является особенностями реализации каждого ECS решения для конкретного «движка».
Постойте, скажете вы, но ведь в Unity всё так и есть! Действительно, в Unity »Сущность» — это GameObject, а »Компонент» и »Система» — это наследники MonoBehaviour. Но в этом и заключается основное различие между компонентной системой Unity и ECS — логика в ECS обязательно должна быть отделена от данных. Это позволяет очень гибко менять логику (даже удалять / добавлять её), не ломая данные. Другой бонус — данные обрабатываются «потоком» в каждой системе и независимо от реализации в «движке», в случае с MonoBehaviour происходит довольно много взаимодействия с «Native»-частью, что съедает часть производительности. Об особенностях внутреннего устройства вызова методов у наследников MonoBehaviour можно почитать в официальном блоге Unity: 10000 вызовов Update ()
Задача от дизайнера: «надо сделать перемещение игрока и загрузку следующего уровня, когда он доходит то точки Х».
Разбиваем задачу на несколько подзадач, по одной на «систему»:
- 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 по отношению к компонентной модели Unity.
Плюсы
- Гибкость и масштабируемость (добавление новых, удаление старых систем и компонентов).
- Эффективное использования памяти (особенность реализации, мы можем переиспользовать инстансы «чистых» C#-классов как угодно в отличие от «MonoBehaviour»).
- Простой доступ к объектам (выборка (фильтрация) сущностей с определенными компонентами производится ядром ECS без потери скорости и аллокаций памяти — это именно то, чего не хватает компонентной системе Unity).
- Понятное разделение логики и данных.
- Проще тестировать (легко воспроизводить тестовое окружение).
- Возможность использования логики на сервере без Unity (нет зависимостей от самого «движка»).
Минусы
- Больше кода
- Для событий самой Unity необходимо каким-то образом пробрасывать их в ECS-окружение через «MonoBehaviour»-обертки.
Для многих, кто долго работал с Unity и ни разу не использовал ECS, поначалу будет сложно привыкнуть к такому подходу. Но вскоре, начинаешь «думать» компонентами / системами и всё собирается быстрее и легче, чем при сильно связанных компонентах на базе «MonoBehaviour».
Сейчас даже сами разработчики Unity поняли, что пора что-то менять в их компонентной системе, чтобы повысить производительность приложений. Где-то год назад было анонсировано, что ведётся разработка собственной ECS и C# Job system. И вот, в 2018.1 версии, мы уже можем примерно представить, что же это будет в будущем, пусть даже и в Preview статусе.
Со штатной Unity ECS — пока ничего не понятно. Разработчики нигде не пишут, что она подходит только для ограниченного спектра задач, но когда возникают вопросы в результате переписывания с других ECS-решений — отвечают в стиле «вы неправильно используете ECS». Т.е. по сути это получается не «multipurpose»-решение, что довольно странно. Релиза не было, всё еще могут поменять несколько раз, есть проблемы с передачей ссылочных типов (например, string), поэтому я не могу порекомендовать делать что-то большое на штатной ECS в её текущем состоянии.
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 я даже получаю удовольствие от процесса рефакторинга :) В игру легко добавляются новые фичи, изменяются старые — и всё это без боли и конфликтов с дизайнером, решившим добавить новую зубодробительную механику или удалить старые, наигравшись с ними.