Манифест: Адаптация динамической компонентной системы к Unity
Валерия Пудова
Материал исследования основан на оригинальной презентации Терренса Коэна и переводе этой презентации.
- A Dynamic Component Architecture for High Performance Gameplay
- Динамическая компонентная архитектура для высокопроизводительного игрового процесса
- Манифкст разработки
- Исходный код Unity
1. Ограничения C# и предлагаемые решения
При переносе архитектуры на среду CLR мы сталкиваемся с фундаментальными ограничениями C#, влияющими на производительность.
| Ограничение C# | Системные последствия | Предлагаемое решение |
|---|---|---|
| Отсутствие указателей на структуры | Избыточное копирование данных при передаче параметров. | Использовать ref T, Span |
| Отсутствие наследования структур | Невозможность построения классического полиморфизма. | Применять интерфейсы как контракты. Для полиморфизма данных использовать дженерик-методы и генерацию реестра 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)
- Компоненты в пуле хранятся монолитно и непрерывно друг за другом.
- Пул разделен на две зоны: использованные и неиспользованные компоненты.
- Рокировка при удалении (Swap-Back): При удалении компонента элемент из конца «использованной» зоны встает на место удаленного, а освободившийся хвост сдвигает границу пула. Это гарантирует скорость удаления за константное время O(1) и идеальную локальность данных (Data Locality) при итерации.
Устройство ростера и хэндлеров (Sparse Array)
- Из-за постоянных рокировок (Swap-Back) компоненты смещаются в памяти, поэтому внешние системы не могут хранить прямые ссылки или индексы на пул.
- Ростер выступает в роли разряженного массива (Sparse Array), хранящего стабильные хэндлеры (слоты).
- Каждый хэндлер содержит актуальный индекс компонента в плотном пуле и Generation (счетчик версий для защиты от обращения к удаленному объекту). При рокировке компонента пул обновляет указатель на свой новый индекс в соответствующем слоте ростера.
[Ростер / Хэндлеры (Разряженный массив)]
Индекс Сущности: 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-структурами). Данные конфигурации, которые никогда не изменяются в рантайме, выносятся во внешний контур.
- Что такое Prius: Это неизменяемый референсный тип данных (Reference Type). В Unity в его роли выступают ScriptableObject, данные из конфигурационных файлов (JSON/YAML) или ссылки на статические объекты сцены (рендереры, ассеты).
- Связывание: Структура компонента в плотном пуле хранит лишь минимальные динамические данные и ID конфигурации (либо прямую ссылку на Prius). Сотни однотипных сущностей ссылаются на один и тот же неизменяемый Prius, не забивая память пула дублирующимися строками и массивами настроек.
Динамические компоненты и подписки (Событийный Контур)
В DCS сообщения (события) и подписки на них — это тоже компоненты, но с принципиально иной природой:
- Компоненты-События: Временные контейнеры данных, содержащие контекст произошедшего (например, DamageEvent). Имеют короткий жизненный цикл (часто в пределах одного кадра).
- Компоненты-Подписки: Это неизменяемые контракты или делегаты. В оригинальной концепции подписка хранит ссылку на функцию скрипта.
- Роль Сущности (Entity): Сущность выступает в роли Узла (Host), связывающего контуры. Её идентификатор — это ключ к её стейт-машине и логическим процессам верхнего уровня.
4. Высокоуровневая логика (Скриптовый контур)
В системах, где отсутствует встроенный скриптовый язык (например, Lua), его роль выполняет высокоуровневый C#. Так как этот контур отвечает за событийную модель, мы выбираем между двумя стратегиями:
Подход А: Гибридный ООП (Выразительность выше производительности)
Если для игровой логики критически важна скорость разработки и лаконичность, мы используем контролируемое ООП. Высокоуровневый «скрипт» представляет собой C#-класс, который инкапсулирует состояние сущности, её методы и обработчики событий. Здесь мы сознательно жертвуем локальностью данных ради полиморфизма, наследования и читаемости бизнес-логики, так как этот код не выполняется в «горячих» циклах симуляции.
Подход Б: Компонентный конечный автомат (Производительность и DOD)
В качестве альтернативы, позволяющей не покидать рамки высокопроизводительного DOD, конечный автомат (FSM) реализуется через расщепление на несколько специализированных компонентов:
- Компонент Сущности (Entity Component): Хранит базовые, неизменяемые в рамках стейт-машины поля сущности: HostId (уникальный идентификатор в системе) и хэндлер (ссылку) на текущий активный Компонент Состояния.
- Компонент Состояния (State Component): Определяет текущее поведение сущности (например, State_Patrol, State_Attack).
- Память: Хранит минимальное количество данных (например, таймер текущего состояния) или вовсе является «маркерным» компонентом нулевого размера (Tag Component).
- Логика: Содержит статические или чисто функциональные методы обновления сущности (Update), находящейся в данном состоянии.
- События: Внутри этого компонента сосредоточены делегаты-получатели сообщений. Именно они регистрируются в событийном контуре при активации состояния. При смене состояния старые подписки инвалидируются, а новые делегаты из следующего Компонента Состояния подписываются на события.
5. Механизм доставки и диспетчеризации сообщений
Доставка сообщений (событий) строится на принципах Zero Allocation и предсказуемого ветвления процессора.
1. Доставка через хэндлеры
Вместо прямой передачи тяжеловесных объектов событий по ссылке, сообщение адресуется через его хэндлер.
- Полиморфизм без мусора: Это полностью снимает проблему полиморфизма сообщений и исключает генерацию мусора (Garbage). Методы-получатели имеют идентичную нетипизированную сигнатуру (например, принимая HostId отправителя и EventId сообщения).
- Маршрутизация: Сообщение всегда доставляется на корневой Компонент Сущности (Entity). Он выступает главным диспетчером: либо перенаправляет событие своему текущему Компоненту Состояния, либо транслирует его дальше по иерархии связанных сущностей.
2. Проблема диспетчеризации и два пути её решения
Мы выделяем два подхода к реализации преобразования сырого типа сообщения в адрес метода:
- Путь ООП (Таблицы делегатов): Каждое состояние хранит локальный компактный список/массив делегатов, сопоставленных с типами событий. Поскольку количество подписок на одну сущность в конкретный момент времени ничтожно мало, линейный поиск по такому массиву и вызов делегата занимают пренебрежимо малое время.
- Путь DOP (Статический селектор Case-Switch): Выбирается явная диспетчеризация через конструкцию switch по скалярному TypeId сообщения.
- Идеально для отладки: В отличие от «черного ящика» из сотен разрозненных делегатов, switch полностью прозрачен. Программист может поставить одну точку останова (breakpoint) на входе в селектор состояния и гарантированно перехватить любое событие.
- JIT/Burst Оптимизация: Современный JIT-компилятор .NET (и компилятор Unity Burst) превращает последовательные switch по целочисленным TypeId в оптимизированные таблицы переходов (jump tables). Процессор выполняет такой переход за константное время O(1) с минимальным риском промаха предсказателя переходов.
6. Время жизни сообщений и сигналов
Управление памятью в событийном контуре подчинено жесткому правилу: никакого накопления мусора в рантайме. По времени жизни все сообщения разделяются на две категории.
1. Кадровые события (Frame Events)
Абсолютное большинство (99.9%) сообщений в игре — это эфемерные события (например, HitEvent, ClickEvent).
- Жизненный цикл: Они живут ровно один кадр (или до конца текущей фазы симуляции).
- Очистка: В конце кадра пулы этих сообщений сбрасываются мгновенно одной операцией (коэффициент заполнения пула сдвигается на ноль через Clear()), без индивидуального удаления элементов. Это гарантирует отсутствие аллокаций.
2. Долгоживущие сигналы процессов (Signals)
Исключением являются Сигналы — сообщения, которые должны жить до тех пор, пока не завершится определенный игровой процесс (например, проигрывание кастомной анимации, завершение цепочки квестов или удержание кнопки). Мы выделяем два пути их реализации:
- Независимый Hash-Set: Хранение ID активных сигналов в компактной хэш-таблице сущности.
- Подтип в общем пуле: Сигналы хранятся в общем пуле компонентов, но помечаются флагом/подтипом Signal. Пул таких компонентов не очищается автоматически в конце кадра.
Управление сигналами через контекст (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
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) для обработки пула компонентов...
}
}
✍ Приемущества подхода
- Филигранный тайминг: Четкое разделение на Update и FixedUpdate гарантирует детерминизм физики и плавность рендера.
- O(1) управление паузой: Заморозка тысяч объектов ИИ происходит за одну побитовую операцию на уровне менеджера, полностью разгружая процессор от холостых проверок.
- Готовность к многопоточности: Менеджер заранее знает, какие данные можно безопасно уводить в параллельные потоки (EAsyncUpdateStage), не блокируя главный поток Unity.
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. Однако физическое выделение памяти (аллокация массивов пула) происходит контролируемо — вручную сразу после загрузки игры (или сцены).
- Масштабируемость: Настройки capacity из атрибутов выступают в роли базового пресета. При старте игры менеджер инициализации может переопределить эти значения на основе конфигов уровня или характеристик целевого устройства (например, выделить пул на 10 000 элементов для ПК и всего 2000 для мобильной платформы).
- Контроль фрагментации: Все пулы аллоцируют непрерывные куски памяти строго в момент инициализации игрового мира. В процессе самого геймплея создание новых пулов запрещено, что гарантирует полное отсутствие фрагментации памяти и непредвиденных просадок FPS (Garbage Collection Spikes) во время игры.