HWW Portfolio

Software and Hardware Developer

Динамическая компонентная архитектура для высокопроизводительного игрового процесса

Перевод с английского, аналитические комментарии и архитектурная реконструкция: Валерия Пудова, 2013

Аннотация: Данный документ содержит конспект презентации Терренса Коэна (Terrance Cohen), ведущего системного инженера Insomniac Games, с конференции 6–7 мая 2010 года. В нём рассматривается переход от классической иерархической объектной модели к динамической компонентной системе для повышения производительности и гибкости игрового кода.

Ключевые слова: Игровое программирование, Компоненты, Архитектура, Производительность.


1. Постановка проблемы

Традиционная объектно-ориентированная модель, основанная на глубоких и монолитных иерархиях игровых объектов (Game Objects), имеет ряд фундаментальных недостатков.

Память: привязка данных на этапе компиляции

Производительность: плохая когерентность кэша

Архитектура: возможность (Capability) против наследования

2. Предлагаемое решение

Конструирование игрового объекта через композицию компонентов во время выполнения.

Основная идея заключается в том, что любой игровой объект можно разбить на небольшие, самодостаточные блоки — компоненты.

3. Устройство пулов компонентов

Для хранения компонентов используется высокопроизводительная система, основанная на пулах.

Политика управления:

4. Производительность системы

Высокая производительность достигается за счёт нескольких факторов:

  1. Константное время операций: Все основные операции, кроме поиска по цепочке, выполняются за O(1):
    • Выделение и освобождение памяти.
    • Разрешение дескриптора (получение указателя на компонент).
    • Получение типа компонента.
    • Проверка реализации интерфейса.
  2. Отсутствие копирования: Манипуляции с ростером не требуют перемещения или копирования самих компонентов в памяти.
  3. Кэш-дружелюбность: Обновление компонентов происходит по пулам (по типам). Это гарантирует, что данные одного типа располагаются в памяти последовательно, что значительно повышает эффективность кэша.
  4. Асинхронность: Система спроектирована для упрощения асинхронных обновлений (например, на SPU или в других потоках).
    • Ростер содержит непрерывный список индексов выделенных экземпляров.
    • Часть ростера до партиции может быть передана как единый DMA-список для обработки на SPU.

5. Процесс выделения и освобождения

Дескрипторы (Handles): Для безопасной работы с компонентами используются дескрипторы, содержащие индекс и поколение (generation).

Алгоритм выделения (allocate):

  1. Проверить наличие свободного места в пуле.
  2. Создать дескриптор из индекса и текущего значения поколения.
  3. Добавить индекс в ростер (увеличить партицию).
  4. Вызвать метод Init() для инициализации нового компонента (например, передав ему “Prius”).

Алгоритм освобождения (free):

  1. Вызвать метод Deinit() для очистки ресурсов.
  2. В ростере поменять индекс удаляемого элемента местами с последним занятым элементом (на позиции partition - 1).
  3. Уменьшить партицию на 1.
  4. Увеличить значение поколения (generation) для этого индекса, чтобы сделать старые дескрипторы недействительными.

Алгоритм разрешения дескриптора (resolve):

  1. Получить индекс из дескриптора.
  2. Проверить, совпадает ли поколение в дескрипторе с поколением, хранящимся в пуле по этому индексу.
  3. Если поколения совпадают, вернуть указатель на компонент. Если нет — вернуть 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) и вызывают обратный вызов при выполнении определённых условий.

В таблице ниже представлены пример различных событий и то к какому типу они относятся.

Обновляемые (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 (обновляемое).

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”.

Что такое 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() компонента. Компонент должен привести указатель к ожидаемому типу.

Идиома:

  1. Создается объект запроса (NavQuery), который содержит все данные для инициализации.
  2. Вызывается метод NavQuery::Submit(), который выделяет компонент и передает самого себя (this) в качестве Prius.
  3. Компонент в своем методе 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 структура которая содержит данные обоих точек для планировщика пути.
  • При обновлении навигационной системы происходи следующее:
    1. Динамический пересчёт — путь не строится один раз. Он перестраивается каждый кадр (или каждый N-й кадр), потому что бот и цель движутся.
    2. На SPU — вычисления происходят на сопроцессоре (в отдельном потоке). Это означает, что навигация не блокирует основной поток.
    3. Обновление компонентов — система сама вызывает метод Update у PathComponent в нужный этап (PostUpdate или AsyncUpdate).

9. Пример: Асинхронное обновление (Выстрелы)

Вместо создания полноценных игровых объектов для снарядов (пуль, ракет), используется компонентный подход, который полностью заменяет тип Projectile.

⨠ Примечания автора 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)));

“Страж” (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.

Cсылки