Spiiin's blog

ECS для казуальных игр

Чем может быть полезно применение паттерна Entity Component System в казуальных играх.

TL,DR:

  • Уменьшается связность между классами
  • DOD упрощает управление параметрами из нескольких мест
  • Упрощается написание читов
  • Возможно, игра будет работать быстрее

Уменьшается связность между классами

Это первое и самое важное, одного этого пункта хватило бы.

Геймдев не особо богат на архитектурные паттерны. Часто, высший уровень абстракции игровой логики (описание сцены и геймплея) - это классы "поле" (уровень/сцена/зона/whatever), и наследники "игровых объектов". Часто встречаются ещё какие-нибудь "фабрики эффектов", содержащиеся методы для запуска всего, чего только можно.

Первая освещаемая в презентациях по ECS проблема - это то, что игровых объектов очень много, разных типов, со взаимно-пересекающейся функциональностью.

К примеру, в RTS это могут быть юниты, герои, здания, ландшафт, снаряды. В match-3 - множество отдельных типов клеток, фишек, существующих вне игровых клеток и между клетками механик, бустеры и бонусные фишки. В простом скролл-шутере – игрок, враги, стены и снаряды. В докладе о разработке Dungeon Siege упоминается о >7300 уникальных типов объектов.

Выстроить такое количество объектов в иерархию наследования практически невозможно, выделить несколько простых базовых классов, от которых наследуются все объекты – тоже. Возможное решение проблемы – выделять не базовые классы, а компоненты, из которых как из кирпичиков будут составлены объекты.

При таком подходе, судя по различным докладам, в зависимости от игры, может получиться 50-150 компонентов, которыми возможно описать все типы игровых объектов.


Вторая проблема, более серьёзная с точки зрения архитектуры игры - куда писать код логики игры?.
Паттерн ECS обещает, что ответ будет чуть более простым, чем при использовании ООП - в системы, отвечающие за необходимый функционал.

Несколько примеров из одной match-3 игры, в которой используются компоненты для описания игровых фишек, но нет систем.

Реалзиция механика спецфишки “самолёт” (типа такого) в ней выглядит как показано ниже.

Самолёт запускается методом:

void createAirplaneExplosion(airplaneBlock* plane, match3cell* parentCell, const explosionSpineSettings& settings);

Сам самолёт состоит из компонентов:

class airplaneBlock :
virtual public customPieceWithVisualFeedback, //имеет анимацию
virtual public customPieceWithCombination, //может участвовать в комбинациях
virtual public customPieceDestructive, //может быть разрушен
virtual public customPieceWithObjective, //может быть целью уровня
virtual public customPieceWithDynamicLayer //может динамически менять слой
... //и т.д.
{
//но так же имеет и методы, которые кто-то должен вызывать :(
void playDestroyAnimation(std::function<void(Event*)> callback);
void setHighestLayer();
void restoreLayer();
}

Соответственно, без использования ECS встаёт вопрос о том, куда дописывать код и где искать проблемы, когда они появляются.

Один из случаев в этом проекте:

Иногда самолёты запускаются при старте игры
В качестве бонуса игроку на старте. При этом другие бонусы от различных событий или покупок игрока расставляются по полю динамически. Программисту приходит баг от QA, что иногда поле от этого нестабильно (самолеты бьют в те клетки, куда не должны - логика самолётов подразумевает, что они умеют кружить по полю до того момента, пока одна из клеток не станет стабильной, т.е. в ней до момента удара гарантировано не будет происходить других событий).

Механика игры подразумевает, что можно заблокировать клетку от удара самолёта через метод match3cell->lock, но куда вставить его вызов?

  • в коде createAirplaneExplosion?
  • в коде самого airplaneBlock? (тогда он будет знать о существовании всего поля и клеток на нём)
  • в коде событий, добавляющих на поле бустеры?
  • добавить компонент особой блокировки в класс клетку match3cell?
  • Программист, которому прилетел баг, пошёл ещё дальше, и добавил блокировку в код туториала на старте уровня %).

В подходе с ECS, код, по идее, должен был бы выглядеть понятнее:

void updateSystems(float dt) {
...
createPreboosterAirplaneSystem(dt); //выставить компонент блокировки клеток поля на старте полёта самолётов
createOtherPreboostersSystem(dt); //обновить логику всех бонусов игрока, отработки которых нужно дождаться
normalGamePlayLogic(dt); //обычная геймплейная логика,
//включает в себя обновление самолётов:
//"если нет блокировки, ударить по клетке, иначе - продолжать летать над полем"
...
}

Т.е. на вопрос "куда дописать логику блокировки клеток от удара самолётами-пребустерами" следует очевидный ответ "в систему создания блокировки от удара самолётами-пребустерами", вместо распутывания ада взаимных вызовов и колбеков между полем, клеткой, самолётом, суперклассом запуска всех событий или ещё какими-нибудь объектами, и медитации на тему, какой из объектов более отвечает за это.

Поиск по коду суперкласса запуска всех событий даёт 25 мест, в которых клетка может быть заблокирована или разблокирована обратно %). Гораздо лучше было бы иметь 4-5 систем, которые выбирают все сущности с компонентами блокировки и удаляют эти компоненты или создают их.


Одна из фишек архитектуры ECS - возможность отследить связи между системами и компонентами - пример в докладе Тимоти Форда по Overwatch. Я попробовал перевести на ECS (с использованием библиотеки EnTT) готовую игру - сделанный за день для хакатона небольшой shoot-them-up на закрытом движке, использующем Component Systems. Заменил часть, отвечающую за апдейт на системы, и оставил без изменений систему рендера (она использует Scene Graph).

На мой взгляд, небольшие игры с хакатонов хорошо подходят для подобных тестов - бардак в коде, вызванный нехваткой времени сделать “как правильно”, реалистично симулирует состояние кода игры после длительного периода разработки, а небольшой объём кода позволяет довести задуманное до конца.

Для визуализации связей сделал такой скрипт на питоне:
https://gist.github.com/spiiin/c70c3bf0711fd952d48e505ead7bffe5
ecs-doc

Кашу из зависимостей между игровыми классами удалось превратить в 24 системы и 30 компонентов. При этом большинство систем использует 2-4 компонента, большинство компонентов используется 1-2-3 системами. Большая часть систем может быть без особых усилий переделана на реактивные (чтобы вызывать их не каждый кадр, а по колбекам на изменение каких-либо компонент на любой из сущностей) или на владеющие компонентами (для оптимизации по скорости обходов, статья с объяснением).

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

DOD упрощает управление параметрами из нескольких мест

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

auto bullet = createBullet();
auto waitAction = createWaitAction(5.0f); //создаём объект действия ожидания
//в конце действия уничтожаем объект
waitAction.onEnd = [bullet]() {
//тут не забыть проверить, что за 5 секунд объект может быть уничтожен кем-либо ещё
bullet->destroySelf();
};
bullet->addAction(waitAction); //добавляем действие на объект пули

Как проверить, что пуля не была уничтожена?
Один из способ из стандартной библиотеки с++ – использование слабого указателя на объект. Такие проверки необходимо добавлять в каждое асинхронное действие.

Другой подход - перепроверять, жив ли объект в текущий момент, через глобальный менеджер объектов. Более детально про организацию этого в игровых движках - статья Andre Weissflog. В этом случае хендлер объекта может быть представлен в виде пары (индекс объекта в массиве, номер поколения). Использование номера поколения позволяет избежать случайного обращения к уже удалённому объекту при размещении на месте удалённого объекта следующего созданного.

Если решать проблему с использованием ECS-подхода, решение может быть таким: система обновления жизни пули (и других объектов с компонентом lifetimeComponent) читает компонент таймера жизни и уменьшают его. В случае если таймер дошёл до нуля - система просто добавляет на сущность компонент-событие removeNodeComponent - маркер, что в конце кадра сущность должна быть уничтожена.

void updateLifetimeSystem(float delta) {
//read components
// <------------------------------------------------(0)
const auto& lifetimeView = ECS().view<nodeComponent, lifetimeComponent>(entt::exclude<removeNodeComponent>);

//logic
for (auto [entity, nodeComponent, lifetimeComp] : lifetimeView.each()) {
lifetimeComp.lifetime -= delta;
if (lifetimeComp.lifetime <= 0.0f) {
auto action = MEActionFactory::changeAlphaAction(0.0f, lifetimeComp.fadeinTime);
action->onEnd = [entity=entity](MENode* node) {
if (ECS().valid(entity)) { //<------- (1)
ECS().emplace_or_replace<removeNodeComponent>(entity);
}
};
if (!nodeComponent.dead) { //<-------- (2)
nodeComponent.node->addAction(action);
ECS().remove<lifetimeComponent>(entity);
}
}
}
}

(0) - выборка всех сущностей, у которых есть компоненты nodeComponent (визуальное отображение), lifetimeComponent (время жизни), но нет компонента removeNodeComponent (умирает)
(1) - так как используется отложенное действие - нужно проверить, что сущность ещё жива и не была уничтожена (проверка через менеджер) - ECS().valid
(2) - компонент для хранения не владеющего указателя, имеет кроме указателя флаг nodeComponent.dead , показывающий не был ли уничтожен объект, вместо использования weak_ptr/intrusive_ptr. Вместо флага может быть использован счётчик, если нужно shared-владение.


Другой пример - есть стратегия с игровым полем с персонажами на нём, и по желаниям гейм-дизайнеров, персонажи на этом поле должны плавно исчезать при открытии окна режима редактирования зданий или появления немодального игрового окна (и соответственно, плавно появляться обратно при закрытии этих режимов). Появление персонажей не мгновенно, и игроки могут успеть за это время запустить исчезновение обратно. Как добиться того, чтобы появление/исчезновение персонажей работало корректно в этом случае?

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

С ECS-подходом - можно выделить два различных компонента исчезновения и появления персонажа, и различные системы для работы с ними. Одной из идиом работы совместной работы таких систем является непосредственное применение сайд-эффектов в одном месте (сайд-эффект в этом случае - это непосредственное изменение альфа-компонента цвета для рендер-системы). Объяснение причин в докладе по Overwatch - если системы A, B, C как-либо меняют логическое состояние компонента, то лучше собрать их действие в отдельном компоненте для системы D, которые непосредственно применит все собранные изменения в одном месте.

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

Упрощается написание читов

Не секрет, что при разработке игр редко заморачиваются с покрытием кода тестами, поэтому часто читы - это средство создания сложных сценариев проверки игры, так что они становятся настолько важными, насколько важно вообще провести QA-проверку игры.

ECS подход должен помочь:

- Легко включать и отключать каждую отдельную систему через читы
Лучше даже, группу систем, отвечающую за определённую логику.

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

При проектировании объектов редко задумываются о том, чтобы частично отключать функционал класса. С системами, вызов которых сгруппирован в одном месте, это может быть сильно проще.

- Проще сделать загрузку/сохранение сцены, проигрывание повторов
Сохранение - это просто сериализация всех сущностей со всеми компонентами. Если после этого запустить все системы - логика игры должна начать работать также, как если бы состояние сущностей/компонент было получено любым другим образом.

- Проще составить сценарии тестирования
Включение/отключение систем может быть сериализовано или включено в запись повтора, результаты работы после нескольких циклов могут быть сериализованы и сверены с эталонным результатом.

Возможно, игра будет работать быстрее

(не проверял, пересказ чужих докладов, но вообще это основная идея дизайна, основанного на данных — обращения к данным спроектированы так, чтобы данные лежали рядом и за ними не нужно было “далеко ходить”)

- Проще запустить системы, несвязанные с другими, в отдельном потоке
Если отслеживать граф зависимостей компонентов, с которыми работают системы, то можно найти системы, которые могут работать параллельно с другими. С обычным ООП подходом сложно перенести часть логики в отдельный поток.

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

Для ECS на архетипах это делается по умолчанию (при создании или удалении компонента для сущности, они будет перенесена в другой контейнер).

Для ECS на разреженных множествах также существуют способы организовать хранение сущностей с одинаковыми компонентами в памяти последовательно. Для EnTT - это группы (group) - синтетические бенчмарки показывают, что это наиболее быстрый способ работы.