Динамическая компонентная архитектура для высокопроизводительного игрового процесса
Перевод с английского, аналитические комментарии и архитектурная реконструкция: Валерия Пудова, 2013
Аннотация: Данный документ содержит конспект презентации Терренса Коэна (Terrance Cohen), ведущего системного инженера Insomniac Games, с конференции 6–7 мая 2010 года. В нём рассматривается переход от классической иерархической объектной модели к динамической компонентной системе для повышения производительности и гибкости игрового кода.
Ключевые слова: Игровое программирование, Компоненты, Архитектура, Производительность.
1. Постановка проблемы
Традиционная объектно-ориентированная модель, основанная на глубоких и монолитных иерархиях игровых объектов (Game Objects), имеет ряд фундаментальных недостатков.
Память: привязка данных на этапе компиляции
- Данные жёстко привязаны к иерархии классов во время компиляции.
- Выделение памяти происходит на протяжении всего времени жизни объекта.
- Исключение: В некоторых архитектурах отдельные подсистемы (например, физика) могут выделять память независимо от самого игрового объекта.
Производительность: плохая когерентность кэша
- Неоднородные массивы: Итерации по массивам объектов разных типов приводят к промахам кэша.
- Виртуальные вызовы: Полиморфизм через виртуальные функции (virtual dispatch) мешает предсказанию ветвлений и инлайнингу.
- Фрагментация данных: Данные экземпляров разбросаны по памяти из-за неиспользуемых полей и выравнивания.
Архитектура: возможность (Capability) против наследования
- Возможности (что умеет объект) жёстко зафиксированы на этапе компиляции и полностью определяются его классом.
- Изменение возможностей требует изменения всей иерархии наследования.
- Это часто приводит к дублированию кода или использованию множественного наследования реализации.
- Хотя множественное наследование является одним из решений, оно создаёт ненужные, жёсткие связи между частями иерархии и не решает проблем производительности.
2. Предлагаемое решение
Конструирование игрового объекта через композицию компонентов во время выполнения.
Основная идея заключается в том, что любой игровой объект можно разбить на небольшие, самодостаточные блоки — компоненты.
- Каждый компонент представляет собой отдельную сущность, отвечающую за конкретную область (состояние, логику или «трансформацию данных»). Например: позиция, модель, анимация, физика, ИИ, сенсоры.
- По своей сути, игровой процесс — это конечный автомат, управляющий состоянием. Компоненты — это и есть это состояние.
3. Устройство пулов компонентов
Для хранения компонентов используется высокопроизводительная система, основанная на пулах.
- Базовый класс: Все компоненты наследуются от базового класса
Component, который содержит 12 байт служебной информации. - Пулы: Компоненты организованы в пулы однородных (гомогенных) объектов. Каждый тип компонента имеет свой собственный пул.
- Ростер (Roster): Это массив индексов, который хранит ссылки на все выделенные экземпляры компонентов в пуле.
- Партиция (Partition): Это индекс в ростере, который отделяет выделенные экземпляры от свободных. Все элементы до партиции — заняты, после — свободны. Это делает процесс выделения и освобождения памяти очень быстрым.
Политика управления:
- Компонент выделяется (
allocate) только тогда, когда он активно используется. - Размер пула должен быть достаточным только для максимального количества одновременно существующих компонентов данного типа. Это экономит память.
4. Производительность системы
Высокая производительность достигается за счёт нескольких факторов:
- Константное время операций: Все основные операции, кроме поиска по цепочке, выполняются за O(1):
- Выделение и освобождение памяти.
- Разрешение дескриптора (получение указателя на компонент).
- Получение типа компонента.
- Проверка реализации интерфейса.
- Отсутствие копирования: Манипуляции с ростером не требуют перемещения или копирования самих компонентов в памяти.
- Кэш-дружелюбность: Обновление компонентов происходит по пулам (по типам). Это гарантирует, что данные одного типа располагаются в памяти последовательно, что значительно повышает эффективность кэша.
- Асинхронность: Система спроектирована для упрощения асинхронных обновлений (например, на SPU или в других потоках).
- Ростер содержит непрерывный список индексов выделенных экземпляров.
- Часть ростера до партиции может быть передана как единый DMA-список для обработки на SPU.
5. Процесс выделения и освобождения
Дескрипторы (Handles): Для безопасной работы с компонентами используются дескрипторы, содержащие индекс и поколение (generation).
Алгоритм выделения (allocate):
- Проверить наличие свободного места в пуле.
- Создать дескриптор из индекса и текущего значения поколения.
- Добавить индекс в ростер (увеличить партицию).
- Вызвать метод
Init()для инициализации нового компонента (например, передав ему “Prius”).
Алгоритм освобождения (free):
- Вызвать метод
Deinit()для очистки ресурсов. - В ростере поменять индекс удаляемого элемента местами с последним занятым элементом (на позиции
partition - 1). - Уменьшить партицию на 1.
- Увеличить значение поколения (
generation) для этого индекса, чтобы сделать старые дескрипторы недействительными.
Алгоритм разрешения дескриптора (resolve):
- Получить индекс из дескриптора.
- Проверить, совпадает ли поколение в дескрипторе с поколением, хранящимся в пуле по этому индексу.
- Если поколения совпадают, вернуть указатель на компонент. Если нет — вернуть
NULL(дескриптор устарел).
Связи между компонентами: Ссылки на другие компоненты обычно хранятся в виде дескрипторов и разрешаются при необходимости.
6. API системы компонентов
Ниже представлен основной интерфейс для работы с системой.
namespace DynamicComponent
{
// ========================================
// API для объектов-владельцев (Hosts)
// ========================================
// Вызывается из GameObjects (или других объектов), которые содержат компоненты.
// Выделяет компонент типа type, добавляет его в цепочку chain объекта-владельца.
// Параметр prius используется для передачи данных инициализации.
// Возвращает NULL, если нет свободного места в пуле.
Component* Allocate(Type type, HostHandle host_handle, Chain* chain, void* prius = NULL);
// Преобразует дескриптор в указатель на компонент.
// Возвращает NULL, если дескриптор пуст или устарел (компонент был переиспользован).
Component* ResolveHandle(Type type, ComponentHandle component_handle);
// Возвращает один компонент указанного типа, принадлежащий данному объекту-владельцу.
Component* Get(Type type, HostHandle host_handle, Chain chain);
// Возвращает первый компонент в цепочке владельца, который реализует указанный интерфейс.
Component* GetComponentThatImplements(Type type, HostHandle host_handle, Chain chain);
// Возвращает массив ВСЕХ компонентов указанного типа в цепочке владельца.
// count: на входе - размер массива, на выходе - количество найденных компонентов.
Component** GetComponents(Type type, HostHandle host_handle, Chain chain, u32& count);
// Возвращает массив ВСЕХ компонентов, реализующих интерфейс, в цепочке владельца.
Component** GetComponentsThatImplement(Type type, HostHandle host_handle, Chain chain, u32& count);
// Освобождает компонент из цепочки владельца.
void Free(Type type, HostHandle host_handle, Chain& chain, ComponentHandle& component_handle);
// Освобождает ВСЮ цепочку компонентов владельца.
// (GameObjects автоматически вызывают это при уничтожении).
void FreeChain(HostHandle host_handle, Chain& chain);
// Безопасное приведение типа (аналог dynamic_cast, но для компонентов).
// Используйте этот макрос вместо C-style кастов.
#define COMPONENT_CAST(component, type) \
((type##Component*)ValidCast(component, DynamicComponent::type))
inline Component* ValidCast(Component* component, Type type);
// ========================================
// API для систем, использующих DCS
// ========================================
// Возвращает список типов компонентов, которые реализуют интерфейс указанного типа.
Type* GetTypesThatImplement(Type type, u32& count);
// Проверяет, реализует ли компонент типа type интерфейс interface.
bool TypeImplements(Type type, Type interface);
// Возвращает количество выделенных в данный момент компонентов указанного типа.
u32 GetNumAllocated(Type type);
// Возвращает массив указателей на ВСЕ выделенные компоненты указанного типа.
Component** GetComponents(Type type, u32& count);
// Возвращает массив ВСЕХ компонентов (включая невыделенные) и массив индексов выделенных.
Component* GetComponentsIndexed(Type type, u16*& indices, u32& count);
// Обновляет все компоненты, которые должны обновляться на указанной стадии (UpdateStage).
void UpdateComponents(UpdateStage::Enum stage);
// Освобождает компонент, который был выделен без привязки к цепочке владельца.
void Free(Type type, ComponentHandle& component_handle);
// Возвращает текущую стадию обновления на PPU.
// Возвращает UpdateStage::None, если UpdateComponents() не в стеке вызовов.
UpdateStage::Enum GetCurrentUpdateStage();
// Возвращает битовую маску стадий, на которых обновляется данный тип.
u8 GetTypeUpdateStages(Type type);
}
7. Пример: Скриптовые события (ScriptEvents)
Скриптовые события — это тоже компоненты. Они могут быть привязаны к игровому объекту, но управляются системой событий (EventSystem). Они выделяются по запросу из скриптов (например, Lua) и вызывают обратный вызов при выполнении определённых условий.
- События (Event) это компоненты
- Это просто структура данных, которая физически лежит в пуле компонентов, точно так же, как Transform или Health. Это не «как бы компонент», это буквально компонент. Ты добавляешь его в хост (Entity) как обычный компонент, и система обрабатывает его через те же механизмы (Chain, Roster, Generations).
- Жизненным циклом события управляет не сам объект, а та система, которая его создала. Система решает, когда выделить событие, когда проверить его условия, и когда удалить. Объект (Entity) просто «носит» этот компонент в своей цепочке, но не управляет им.
- Регистрируется и создается в Lua
- Создание события происходит не в C++ коде, а вызывается из Lua-скрипта. Когда Lua вызывает CreateEvent(), внутри C++ движка происходит выделение памяти в пуле (Allocate) и регистрация компонента в Chain. Для нас это означает, что API создания событий должен быть вызван извне (через Interop или обертку).
- Когда условие истинно (например, прошло 2 секунды или объект вошел в зону) — система вызывает функцию обратного вызова (callback), которую разработчик прикрепил к этому событию.
- Способы выполнения/удовлетворения условий события
- Обновляемые (Updated): Автоматически обновляются системой компонентов и опрашивают свои условия каждый кадр.
- Уведомляемые (Notified): Ожидают уведомления от игрового процесса о наступлении события.
В таблице ниже представлены пример различных событий и то к какому типу они относятся.
| Обновляемые (Poll) | Уведомляемые (Notify) |
|---|---|
| - Location (Позиция) | - Checkpoint (Контрольная точка) |
| - Timer (Таймер) | - Damage (Урон) |
| - Visible (Видимость) | - Death (Смерть) |
| - NumAlive (Кол-во живых) | |
| - Active (Активность) | |
| - Custom (Пользовательские): | |
| - Reload (Перезарядка) | |
| - Zoom (Прицеливание) | |
| - Grapple (Захват) | |
| - SeatEnter (Вход в сидение) |
⨠ Важно C++ — это просто “движок данных”.
- Он умеет: хранить компоненты, быстро пробегать по массивам, проверять числа (условия).
- Он не знает, что означают эти условия. Он просто сравнивает float и int.
- Вся игровая логика (что делать, когда условие выполнено) живёт в Lua.
События и подписки не нужны в C++ коде.
- В C++ есть только компоненты с данными и пулы, которые умеют быстро искать совпадения.
- А “подписка” — это просто запись в Lua, которая говорит: “Когда C# найдёт компонент с такими-то полями — вызови мою Lua-функцию”.
Регистрация типа события: Новый тип события регистрируется с указанием его имени, базового типа (для проверки интерфейсов) и стадий обновления.
namespace DynamicComponent
{
typedef u16 Type;
// пустое значение. В C# мы бы написали
// const ushort UnregisteredType = 65535;. Если ты передаешь этот ID как
// base_type, значит у этого типа нет родителя. Он корневой.
Type unregistered_type = 0xFFFF;
Type TypeRegistrar::RegisterType(
const char* name,
Type base_type = unregistered_type,
UpdateStage::Enum update_stages = UpdateStage::None,
AsyncUpdateStage::Enum async_update_stages = AsyncUpdateStage::None
);
}
⨠ Важно Создание типа события ничем не отличается от создания компонента. Оно также получает флаги stages и также обрабатывается на стороне PPU или SPU. Теоретически событие может совершать действия подобно компонентам: воспроизводить звук.
| Параметр | Тип | Смысл |
|---|---|---|
| name | const char* | Имя типа. Это просто строка для отладки и для Lua (чтобы скрипты могли обращаться по имени, а не по числу). В среде исполнения она не используется для логики, только для удобства. |
| base_type | Type | Идентификатор базового типа. Это ключевой параметр, который заменяет наследование. Ты говоришь: «Мой новый тип — это дочерний для base_type». Это создает дерево типов, но не через классы, а через числа. |
| update_stages | UpdateStage::Enum | Когда этот компонент должен обновляться на основном процессоре (PPU). Флаги: None, Update, FixedUpdate, PostUpdate. Это жесткая привязка к жизненному циклу движка. |
| async_update_stages | AsyncUpdateStage::Enum | Когда этот компонент должен обновляться на сопроцессоре (SPU — аналог Job System). Это означает, что данные этого типа будут обрабатываться параллельно на другом ядре. |
Пример инициализации системы событий:
Предположим, у нас есть базовый тип ScriptEvent и два конкретных типа: CheckpointEvent (уведомляемое) и LocationEvent (обновляемое).
LocationEventбудет обновляться на SPU во времяPreUpdateдля опроса условий (например, проверки позиции), а затем на PPU во времяPostUpdateдля вызова делегатов, если условие выполнено.- Для повышения производительности, опрос условий можно вынести полностью на SPU. Если условие выполнено, SPU-задача может добавить компонент в общий список для вызова делегатов на PPU, что минимизирует нагрузку на главное ядро.
using namespace DynamicComponent;
void ScriptEventSystem::Init()
{
// Регистрируем базовый тип
m_event_type = TypeRegistrar::RegisterType("ScriptEvent");
// Регистрируем уведомляемое событие
m_checkpoint_type = TypeRegistrar::RegisterType("CheckpointEvent", m_event_type);
// Регистрируем обновляемое событие
m_location_type = TypeRegistrar::RegisterType(
"LocationEvent",
m_event_type, // Базовый класс - ScriptEvent
UpdateStage::PostUpdate, // Обновляется на PPU в PostUpdate
AsyncUpdateStage::PreUpdate // И на SPU в PreUpdate
);
}
8. Пример: Навигация (Выделение и инициализация)
Для системы навигации используется интересный идиоматический подход к инициализации, называемый “Prius”.
- Навигационные компоненты (
NavComponents) принадлежат игровым объектам (обычно ботам) и используются для запросов к навигационной базе данных (сетка + динамические препятствия). - Они обновляются навигационной системой.
Что такое Prius?
Выше в документе есть использование имени prius.
//allocate a component of type, add it to host's component chain,
// and optionally park a prius in the component
// returns null if no space is available for allocation
Component* DynamicComponent::Allocate( Type type, HostHandle host_handle,
Chain* chain, void* prius = NULL );
В контексте системы компонентов, Prius — это «легкое транспортное средство для передачи данных инициализации». Это просто указатель void*, который передается в метод Allocate() и затем передается в метод Init() компонента. Компонент должен привести указатель к ожидаемому типу.
Идиома:
- Создается объект запроса (
NavQuery), который содержит все данные для инициализации. - Вызывается метод
NavQuery::Submit(), который выделяет компонент и передает самого себя (this) в качестве Prius. - Компонент в своем методе
Init()приводит полученный указатель к типуNavQueryи инициализируется.
// Метод Submit создает и инициализирует компонент
NavComponent* NavQuery::Submit(GameObject* host)
{
// Получаем тип компонента (виртуальный метод)
DynamicComponent::Type type = GetComponentType();
// Выделяем компонент, передавая this как Prius
DynamicComponent::Component* component = host->AllocateComponent(type, this);
// Безопасно приводим к нужному типу
NavComponent* nav = COMPONENT_CAST(component, Nav);
return nav;
}
// Вспомогательный метод в GameObject
DynamicComponent::Component* GameObject::AllocateComponent(DynamicComponent::Type type, void* prius)
{
return DynamicComponent::Allocate(type, m_handle, &m_component_chain, prius);
}
⨠ Важно Виртуальный метод GetComponentType() Это ключевой момент для полиморфизма. Разные наследники NavQuery могут переопределить этот метод и вернуть свой тип. Например,
PathQueryвернётPathComponent, аPatrolQueryвернётPatrolComponent. Это имитация виртуального конструктора в DOD.
Пример игрового кода: Бот-преследователь создает два запроса (для себя и цели) и один запрос пути.
void FollowerBot::Initialize(GameObject* target)
{
// Запрос на ближайшую точку к объекту-источнику (боту)
Nav::GameObjectQuery source_query(this);
// Запрос на ближайшую точку к цели
Nav::GameObjectQuery target_query(target);
// Запрос на построение пути между точками
Nav::PathQuery path_query(source_query, target_query);
// Отправляем запрос, создаем компонент пути
Nav::PathComponent* m_path = path_query.Submit();
}
⨠ Важно
GameObjectQueryэто структура которая хранит хендлер хост объекта и прочие возможно кешированы параметры которые необходимы навигационной системе.PathQueryструктура которая содержит данные обоих точек для планировщика пути.- При обновлении навигационной системы происходи следующее:
- Динамический пересчёт — путь не строится один раз. Он перестраивается каждый кадр (или каждый N-й кадр), потому что бот и цель движутся.
- На SPU — вычисления происходят на сопроцессоре (в отдельном потоке). Это означает, что навигация не блокирует основной поток.
- Обновление компонентов — система сама вызывает метод Update у PathComponent в нужный этап (PostUpdate или AsyncUpdate).
9. Пример: Асинхронное обновление (Выстрелы)
Вместо создания полноценных игровых объектов для снарядов (пуль, ракет), используется компонентный подход, который полностью заменяет тип Projectile.
- Компонент
Shotпринадлежит объекту, который произвел выстрел. - Пуля — это просто компонент в пуле. Нет визуальных объектов (или есть, но они рендерятся через VFX/GPU).
- Используются две иерархии типов компонентов, для разделения состояния и действия. Одно дерево описывает что происходит, а другое — как это происходит:
Shot: Представляет конечный автомат состояния выстрела.- Не обновляется автоматически, а уведомляется о событиях от текущих действий например от
ShotAction.
- Не обновляется автоматически, а уведомляется о событиях от текущих действий например от
ShotAction: Представляет конкретное состояние выстрела в данный момент (например, движение вперед, полет по дуге).- это POD-структура (Plain Old Data), которую можно безопасно передавать на (SPU).
- Это активный компонент, который обновляется системой на (PPU) или асинхронно на (SPU).
- Когда ShotAction достигает цели (или происходит другое событие), он уведомляет Shot: «Цель достигнута, переходи в состояние “Взрыв”».
- Shared — это оптимизация памяти:
- У всех пуль может быть общее действие “Лететь вперёд”.
- Не требуется создавать копию кода для каждой пули, достаточно просто ссылаться на общее действие. Пример: 1000 пуль используют один ShotAction “Движение по дуге”. Данные (позиция, скорость) хранятся в 1000 Shot-ах, а логика — в одном ShotAction.
⨠ Примечания автора Shared
ShootActionсамый сомнительный пункт в списке. Это буквально антипаттерн DOP. Единственное рациональное объяснение этому может быть ограничена память PlayStation 3.
Особенность реализации (PPU/SPU):
Чтобы эффективно обрабатывать выстрелы на SPU, используется трюк с разделением объявлений.
#ifndef SPU // PPU-часть
class ShotMoveForward : public ShotAction
{
DERIVED_COMPONENT(ShotMoveForward, ShotAction)
public:
virtual void ParkPrius(void* prius);
virtual void Update(UpdateStage::Enum stage);
// ... другие PPU-специфичные данные и методы
};
#else // SPU-часть
struct ShotMoveForward
{
// Эта переменная "затеняет" ShotAction::m_next_location на PPU
puspu_vec4 m_location ALIGNED(16);
// ... другие SPU-специфичные данные (POD)
};
#endif // Общая для PPU и SPU часть
// Данные, доступные на обоих процессорах
puspu_vec4 m_direction;
f32 m_speed;
} __attribute__((aligned(16)));
- На PPU тип является полиморфным (
ShotMoveForwardнаследуется отShotAction). - На SPU это простой POD-тип (Plain Old Data), что позволяет легко передавать его через DMA без необходимости “чинить” виртуальные таблицы.
- Важно, что
m_locationна SPU иm_next_locationв базовом классеShotActionна PPU находятся по одному и тому же смещению в памяти.
“Страж” (Sentinel) для определения начала SPU-данных: Чтобы гарантировать правильное выравнивание и определить, где начинаются данные для SPU, используется макрос, добавляющий массив нулевого размера.
// Макрос, указывающий начало асинхронных данных
#define BEGIN_ASYNC_COMPONENT_DATA \
u32 m_async_data[0] __attribute__((aligned(16)));
class ShotAction : public DynamicComponent::Component
{
COMPONENT(ShotAction);
// ... другие данные
DynamicComponent::Handle m_shot;
DynamicComponent::Type m_shot_type;
// Данные для SPU начинаются здесь
BEGIN_ASYNC_COMPONENT_DATA
vec4f m_next_location; // Эта переменная совпадает с m_location в SPU-версии
};
Этот подход позволяет иметь единый код логики на SPU, работающий с простыми структурами, и полиморфный, удобный код управления на PPU.