Разработка игр
ECS: Entity Component System
2016 год. Blizzard готовит Overwatch и упирается в стену: на консолях ROI (Replication of Interest) при 12 игроках + способности + снаряды просто не вписывается в 16.6 мс кадра. ООП-иерархия с виртуальными вызовами съедает кадровый бюджет ещё до начала рендера. Команда переписывает gameplay-движок на ECS - и FPS вырастает в 3 раза без изменения визуала. На GDC 2017 разработчики Тимоти Форд и Дэн Рид показывают графики: один тип компонента - один смежный массив - один цикл по нему. Никаких иерархий, никаких virtual destructors. Игра, которая принесла Blizzard $1B за год, стоит на data-oriented фундаменте.
- **Overwatch** (Blizzard, 2016) - переход на ECS на консольных платформах позволил вписаться в 16.6 мс/кадр при 12 игроках и десятках одновременных эффектов; на GDC 2017 показывали 3x ускорение по сравнению с ООП-прототипом.
- **Bevy** (Rust, 2020+) - современный ECS с автоматическим распараллеливанием систем по графу зависимостей; для типичных игр scheduler использует все ядра CPU без ручной синхронизации.
- **Unity DOTS** (2019+) - официальный data-oriented стек Unity: Entities, Burst Compiler (SIMD), Jobs System. Демо City с 50 000 NPC в реальном времени, что невозможно на классическом MonoBehaviour.
Entities: ID без поведения
Overwatch обрабатывает 12 игроков, 60 снарядов в воздухе, эффекты способностей, частицы дыма - всё на 60 кадрах/с. Классическая ООП-иерархия `Entity → Character → Player` упиралась бы в виртуальные вызовы и кэш-промахи. Blizzard переписала движок на ECS, и FPS вырос в 3 раза при том же содержимом сцены. В ECS сущность (Entity) - это не объект с полями и методами, а 64-битный идентификатор. Никаких полей `transform`, `health`, `velocity` внутри Entity. Только ID. Это первый разрыв с ООП-привычками: сущность не имеет состояния, она лишь обозначает 'что-то существует'.
Generational index: ID часто состоит из двух частей - index (позиция в массиве) и generation (счётчик пересоздания). Когда сущность уничтожается, её index освобождается, но generation увеличивается. Старые ссылки видят несовпадение generation и понимают: 'эта сущность уже не та'. Защита от dangling references без GC.
Зачем в Entity использовать generational index вместо простого incrementing ID?
Components: чистые данные
Component - это структура чистых данных без методов. `Transform { position, rotation, scale }`, `Velocity { dx, dy, dz }`, `Health { current, max }`. Никаких `update()`, `take_damage()` - только поля. Хранятся компоненты не вместе с сущностью, а в отдельных массивах: один массив для всех Transform, другой для всех Velocity, третий для всех Health. Это и есть Structure of Arrays (SoA) против Array of Structures (AoS). Кеш процессора любит SoA: когда система движения проходит по всем Velocity подряд, ей не нужно подгружать неиспользуемые поля Health. Bevy ECS и Unity DOTS заточены именно под SoA.
Archetype - набор компонентов, которыми обладает сущность. Все сущности с одинаковым набором (Transform + Velocity + Mesh) хранятся в одном архетипе - смежных массивах. Добавление/удаление компонента перемещает сущность в другой архетип. Это дорогая операция, поэтому в горячем цикле компоненты не меняются.
Почему SoA (Structure of Arrays) быстрее AoS (Array of Structures) для типичной игровой логики?
Systems: логика как функция над данными
Система - это функция, которая получает запрос (query) на множество компонентов и применяет к ним логику. `MovementSystem` запрашивает `(Position, Velocity)` и обновляет Position. `DamageSystem` запрашивает `(Health, IncomingDamage)` и уменьшает Health. Сущности, не имеющие нужного набора компонентов, просто отсутствуют в выборке - не нужно проверять `if hasComponent`. Системы оркеструются планировщиком, который видит зависимости по чтению/записи и распараллеливает независимые системы по ядрам. Bevy ECS делает это автоматически: системы, читающие разные компоненты, бегут одновременно на пуле потоков.
Write conflict: две системы, пишущие в один компонент, не могут работать параллельно. Планировщик ECS сериализует их. Поэтому 'разбивай' большие компоненты на мелкие - меньше шанс конфликта. Transform лучше разделить на Position + Rotation + Scale, чтобы система анимации (Rotation) и система движения (Position) шли параллельно.
Почему ECS-планировщик может запустить MovementSystem и DamageSystem параллельно?
Data-Oriented Design: думать о памяти, не об объектах
Mike Acton (Insomniac Games) на CppCon 2014 сформулировал суть: 'код существует для трансформации данных. Любая абстракция, скрывающая структуру данных, врёт о реальной стоимости'. Cache miss из L1 в RAM - 200 циклов. Виртуальный вызов с непредсказуемой целью - 30+ циклов. ECS убирает обе беды: данные одного типа лежат смежно (нет cache miss при проходе), системы - обычные функции (нет виртуальных вызовов). На PS4 при 33 мс на кадр (30 fps) один cache miss съедает 6% бюджета одной сущности. Naughty Dog, Insomniac, DICE, Blizzard - все перешли на DOD не потому, что 'модно', а потому что иначе не уложиться в кадровое время на массовых сценах.
False sharing: если две системы пишут в соседние поля одной cache line (например, Position и Rotation одной сущности при AoS), CPU тратит время на синхронизацию L1-кешей разных ядер. SoA устраняет false sharing: Position для всех сущностей в одном массиве, Rotation - в другом.
ECS - это просто 'компонент вместо наследования', замена ООП на композицию
ECS - это в первую очередь способ организации памяти для cache-friendly обработки; композиция - приятное следствие
Если использовать ECS, но хранить компоненты в HashMap<EntityID, Box<dyn Component>>, проиграешь и в скорости, и в простоте. Польза приходит из SoA-расположения и пакетной обработки. Без этого получишь архитектурную моду без производительности.
Mike Acton говорит: 'код существует для трансформации данных'. Какое практическое следствие этого тезиса для ECS?
Ключевые идеи
- **Entity** - это идентификатор (часто generational index), без полей и методов; задача - обозначить существование, не описать.
- **Component** - чистые данные без поведения; хранится в SoA-массивах для cache-friendly обработки и SIMD.
- **System** - функция, запрашивающая нужные компоненты; планировщик параллелит независимые системы по ядрам автоматически.
- **Data-Oriented Design** - архитектура от характеристик памяти, а не от онтологии: 'как часто меняется и кто читает', а не 'что это такое'.
Связанные темы
ECS - архитектурная основа современных движков, тесно связанная с другими аспектами разработки игр:
- Game Loop и тики — ECS-планировщик встраивается в game loop: на каждом тике он запускает граф систем; чёткое разделение фиксированных и переменных тиков критично для детерминизма
- Netcode и reconciliation — Детерминированная симуляция требуется для reconciliation; ECS даёт её естественно: одинаковый порядок систем + одинаковые компоненты = одинаковый результат на клиенте и сервере
Вопросы для размышления
- Если в игре есть редкий компонент Boss (одна сущность из 100 000), как ECS-движки оптимизируют запрос (Boss, Position) без прохода по всем 100 000?
- Возврат к мотивации: команда Overwatch получила 3x ускорение перейдя на ECS. На каких частях кадрового бюджета этот выигрыш сосредоточен и почему?
- Какие классы задач плохо ложатся на ECS (когда классическая иерархия объектов оказывается удобнее)?
Связанные уроки
- gd-01 — ECS-планировщик встроен в game loop
- gd-12 — Детерминированная ECS - основа netcode reconciliation
- gd-17 — DOD/SoA - фундамент GPU-оптимизации рендера
- gd-09 — ECS-запросы компонентов используют spatial hashing
- alg-01-big-o — Cache-locality мышление аналогично Big-O мышлению
- ml-09-gradient-descent — Пакетная обработка компонентов - то же мышление что mini-batch
- la-01-vectors-intro