HWW Portfolio

Software and Hardware Developer

Манифест: Адаптация динамической компонентной системы к Unity

Валерия Пудова

Материал исследования основан на оригинальной презентации Терренса Коэна и переводе этой презентации.


1. Ограничения C# и предлагаемые решения

При переносе архитектуры на среду CLR мы сталкиваемся с фундаментальными ограничениями C#, влияющими на производительность.

Ограничение C# Системные последствия Предлагаемое решение
Отсутствие указателей на структуры Избыточное копирование данных при передаче параметров. Использовать ref T, Span и Unsafe для доступа по ссылке без копирования. Вне горячего цикла применять хэндлеры.
Отсутствие наследования структур Невозможность построения классического полиморфизма. Применять интерфейсы как контракты. Для полиморфизма данных использовать дженерик-методы и генерацию реестра TypeId.
Скрытая запаковка (boxing) Нагрузка на Garbage Collector (GC), производство мусора в куче. Полный запрет на хранение структур в виде интерфейсов. Использовать строго типизированные пулы событий (EventPool).
Низкая производительность vtable Промахи мимо кэша процессора при вызове виртуальных методов. Избегать виртуальных методов в горячем цикле. Реализовывать логику через статические методы или switch по TypeId.
Низкая производительность Reflection Медленная инициализация и интроспекция типов в рантайме. Использовать Source Generators на этапе компиляции. Кэшировать делегаты и TypeId, выполняя инициализацию однократно.

Правило API: Внутреннее API системы полностью отказывается от использования тяжеловесного System.Type в горячем цикле. Сравнение типов сводится к атомарным операциям процессора через скалярный TypeId (целочисленный тип ushort или int), генерируемый при компиляции. Индексы типов служат прямыми смещениями в массивах пулов.


2. Структура пула и ростера (Оптимизация под CPU)

В оригинальной DCS (для IBM Cell/SPU) использовался плотный массив ростера и разряженный массив компонентов. Для SPU с ручным управлением DMA-трансферами это было эффективно. Для современного CPU общего назначения (ПК, консоли, мобильные устройства) разряженный массив компонентов в горячем цикле — это смерть кэш-памяти из-за постоянных Cache Misses. Наше решение: Инвертировать структуру в схему «Плотные пулы компонентов + Разряженный ростер (Sparse Set)».

Устройство плотного пула (Dense Array)

Устройство ростера и хэндлеров (Sparse Array)

[Ростер / Хэндлеры (Разряженный массив)]
Индекс Сущности:   0  ->  [Слот: Индекс в пуле = 0, Gen = 1]
Индекс Сущности:   1  ->  [Слот: Пустой / Удален]
Индекс Сущности:   2  ->  [Слот: Индекс в пуле = 1, Gen = 3]

[Пул компонентов (Плотный массив)]
Индекс в пуле:      0  ->  [Данные Компонента А (Сущность 0)]
Индекс в пуле:      1  ->  [Данные Компонента Б (Сущность 2)]  <-- Итерация идет подряд без пропусков!

3. Архитектурное разделение: Контур Данных vs Событийный Контур

Компоненты системы разделяются на два фундаментально разных класса по их назначению, жизненному циклу и паттернам доступа к памяти.

                 +---------------------------------------+

                 |            Entity (Сущность)          |
                 |      (Host ID / Узел маршрутизации)   |
                 +-------------------+-------------------+
                                     |
            +------------------------+------------------------+
            |                                                 |
            v                                                 v
+-------------------------------+                 +-------------------------------+
|   Статические компоненты      |                 |    Динамический контур        |
|      (Контур Данных)          |                 |     (Событийный Контур)       |
+-------------------------------+                 +-------------------------------+
| - Pure Structs (Plain Old Data|                 | - Компоненты-События          |
| - Хранение физики/метрик мира |                 | - Компоненты-Подписки         |
| - Линейная итерация в циклах  |                 | - Управление поведением       |
+-------------------------------+                 +-------------------------------+
            |                                                 |
            v                                                 v
+-------------------------------+                 +-------------------------------+

|     Концепция Prius           |                 |     Конечные автоматы (FSM)   |
| (Внешние статические конфиги) |                 |    (Управление логикой игры)  |
+-------------------------------+                 +-------------------------------+

Статические компоненты (Контур Данных)

Это «тупые» контейнеры состояния (Plain Old Data / Pure Structs).

Концепция Prius: Вынос статических конфигураций

Компоненты состояния должны оставаться максимально компактными (Blittable-структурами). Данные конфигурации, которые никогда не изменяются в рантайме, выносятся во внешний контур.

Динамические компоненты и подписки (Событийный Контур)

В DCS сообщения (события) и подписки на них — это тоже компоненты, но с принципиально иной природой:


4. Высокоуровневая логика (Скриптовый контур)

В системах, где отсутствует встроенный скриптовый язык (например, Lua), его роль выполняет высокоуровневый C#. Так как этот контур отвечает за событийную модель, мы выбираем между двумя стратегиями:

Подход А: Гибридный ООП (Выразительность выше производительности)

Если для игровой логики критически важна скорость разработки и лаконичность, мы используем контролируемое ООП. Высокоуровневый «скрипт» представляет собой C#-класс, который инкапсулирует состояние сущности, её методы и обработчики событий. Здесь мы сознательно жертвуем локальностью данных ради полиморфизма, наследования и читаемости бизнес-логики, так как этот код не выполняется в «горячих» циклах симуляции.

Подход Б: Компонентный конечный автомат (Производительность и DOD)

В качестве альтернативы, позволяющей не покидать рамки высокопроизводительного DOD, конечный автомат (FSM) реализуется через расщепление на несколько специализированных компонентов:

  1. Компонент Сущности (Entity Component): Хранит базовые, неизменяемые в рамках стейт-машины поля сущности: HostId (уникальный идентификатор в системе) и хэндлер (ссылку) на текущий активный Компонент Состояния.
  2. Компонент Состояния (State Component): Определяет текущее поведение сущности (например, State_Patrol, State_Attack).

5. Механизм доставки и диспетчеризации сообщений

Доставка сообщений (событий) строится на принципах Zero Allocation и предсказуемого ветвления процессора.

1. Доставка через хэндлеры

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

2. Проблема диспетчеризации и два пути её решения

Мы выделяем два подхода к реализации преобразования сырого типа сообщения в адрес метода:


6. Время жизни сообщений и сигналов

Управление памятью в событийном контуре подчинено жесткому правилу: никакого накопления мусора в рантайме. По времени жизни все сообщения разделяются на две категории.

1. Кадровые события (Frame Events)

Абсолютное большинство (99.9%) сообщений в игре — это эфемерные события (например, HitEvent, ClickEvent).

2. Долгоживущие сигналы процессов (Signals)

Исключением являются Сигналы — сообщения, которые должны жить до тех пор, пока не завершится определенный игровой процесс (например, проигрывание кастомной анимации, завершение цепочки квестов или удержание кнопки). Мы выделяем два пути их реализации:

Управление сигналами через контекст (Track)

Чтобы избежать утечек памяти (когда сигнал «завис» в пуле навсегда), время жизни сигналов привязывается к управляющему компоненту — дорожке процесса (Track / Chain). При уничтожении процесса все связанные с ним сигналы освобождаются автоматически.

// Выделяем долгоживущий трек процесса
DcsHandle track = ComponentSystem.Allocate<Track>(host_handle, chain);
// Создание сигналов, привязанных к конкретному треку (сахар над ComponentSystem.Allocate)
DcsHandle signalA = Signal.Allocate(host_handle, track);
DcsHandle signalB = Signal.Allocate(host_handle, track);
// Проверка наличия и реактивное ветвлениеif (Signal.Check(signalA))
{
    // Активируем следующий сигнал в цепочке
    Signal.Send(signalB);
}
// При завершении процесса удаление трека автоматически каскадно освобождает пулы сигналов
ComponentSystem.Free<Track>(host_handle, chain);

7. Обновление компонентов в основном цикле

Система обновления спроектирована по принципу пакетной обработки данных (Batch Processing). Компоненты обновляются не поодиночке, а глобально на уровне своих менеджеров типов. Это позволяет гибко распределять нагрузку внутри кадра Unity и мгновенно отсекать целые слои симуляции.

1. Фазовое распределение и асинхронность

Каждый тип компонента декларативно определяет, в каких фазах игрового цикла он имеет право обновляться. Для этого ComponentManager на этапе инициализации конфигурируется двумя перечислениями стадии обновления — для синхронного (основной поток Unity) и асинхронного выполнения (рабочие потоки / Job System):

public enum EUpdateStage { None, Update, FixedUpdate, PostUpdate }public enum EAsyncUpdateStage { None, Update, FixedUpdate, PostUpdate }

2. Битовые маски процессов (Глобальная фильтрация)

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

public static class Mask
{
    public const uint PAUSABLE = 0x1; // Объекты, замерзающие на паузе
    public const uint NPC      = 0x2; // Логика персонажей
    public const uint PROPS    = 0x4; // Декорации и окружение
}

Каждый менеджер пула хранит свою конфигурационную маску _mask. Во время вызова глобального обновления кадра системный диспетчер передает текущую маску контекста (например, Mask.PAUSABLE в режиме активной внутриигровой паузы).

3. Ранний выход (Early Exit) на уровне менеджера

Если результат побитовой операции AND между маской кадра и маской менеджера не равен нулю, весь менеджер типа мгновенно прекращает выполнение, даже не заглядывая в пул компонентов:

public class ComponentManager<T> : IComponentPool where T : struct
{
    private EUpdateStage _updateStages;
    private EAsyncUpdateStage_asyncUpdateStages;
    private uint _mask;

    public ComponentManager(int capacity, EUpdateStage updateStages = EUpdateStage.Update, EAsyncUpdateStage asyncUpdateStages = EAsyncUpdateStage.None, uint mask = 0)
    {
        _updateStages = updateStages;
        _asyncUpdateStages = asyncUpdateStages;
        _mask = mask;        
    }

    public void Update(EUpdateStage currentStage, uint contextMask) 
    {
        // Проверка фазы
        if ((_updateStages & currentStage) == 0) return;
        
        // Ранний выход по битовой маске (например, если игра на паузе, а пул PAUSABLE)
        if ((contextMask & _mask) != 0) return;

        // Высокопроизводительный линейный цикл по плотному пулу данных компонентов...
    }

    public void AsyncUpdate(EAsyncUpdateStage currentAsyncStage, uint contextMask) 
    {
        if ((_asyncUpdateStages & currentAsyncStage) == 0) return;
        if ((contextMask & _mask) != 0) return;

        // Запуск асинхронной задачи (Job) для обработки пула компонентов...
    }
}

Приемущества подхода


8. Создание и инициализация пулов компонентов

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

1. Декларативное описание через специализированные атрибуты

Для явного разделения типов компонентов на уровне метаданных вводятся два базовых атрибута, наследуемых от общего предка DcsPoolAttribute. Они позволяют разработчику прямо над структурой данных описать ее квант памяти, фазу обновления и битовую маску:

public abstract class DcsPoolAttribute : Attribute
{
    public int Capacity { get; }
    public EUpdateStage UpdateStage { get; }
    public EAsyncUpdateStage AsyncUpdateStage { get; }
    public uint Mask { get; }

    protected DcsPoolAttribute(int capacity, EUpdateStage updateStage, EAsyncUpdateStage asyncUpdateStage, uint mask)
    {
        Capacity = capacity;
        UpdateStage = updateStage;
        AsyncUpdateStage = asyncUpdateStage;
        Mask = mask;
    }
}
// Атрибут для стандартных компонентов состояния (статический контур данных)public class ComponentPoolAttribute : DcsPoolAttribute
{
    public ComponentPoolAttribute(int capacity = 1000,
        EUpdateStage updateStage = EUpdateStage.Update,
        EAsyncUpdateStage asyncUpdateStage = EAsyncUpdateStage.None,
        uint mask = 0) : base(capacity, updateStage, asyncUpdateStage, mask) { }
}
// Атрибут для игровых событий, сообщений и сигналов (событийный контур)public class MessagePoolAttribute : DcsPoolAttribute
{
    public MessagePoolAttribute(int capacity = 1000,
        EUpdateStage updateStage = EUpdateStage.Update,
        EAsyncUpdateStage asyncUpdateStage = EAsyncUpdateStage.None,
        uint mask = 0) : base(capacity, updateStage, asyncUpdateStage, mask) { }
}

2. Рантайм-инициализация после загрузки

На этапе компиляции Source Generators сканируют сборку на наличие этих атрибутов и генерируют статический реестр типов, связывая каждую структуру с её уникальным скалярным TypeId. Однако физическое выделение памяти (аллокация массивов пула) происходит контролируемо — вручную сразу после загрузки игры (или сцены).