StaticECS — Bitmap Entity Component System

от автора

StaticEcs

Коротко о том, что такое ECS (Entity Component System)

Сущность — числовой идентификатор без данных.
Компонент — структура данных (Position, Health, Velocity), которая может быть прикреплена к сущности.
Система — код, перебирающий сущности с нужным набором компонентов и обрабатывающий их.

Главный вопрос производительности: как быстро найти и обойти все сущности, у которых есть, например, Position и Velocity одновременно — когда таких сущностей сотни и тысячи? Именно ответ на него и определяет способ хранения данных.


Существующие подходы к хранению в ECS

За десятилетия существования ECS сложились два фундаментальных подхода, каждый с вариациями.

Архетипы

Unity DOTS, Flecs, Bevy, Arch

Базовая идея: сущности с одинаковым набором компонентов хранятся вместе. Такая группа называется архетипом. Внутри него данные каждого компонента лежат в непрерывном массиве — итерация проходит по памяти линейно, что отлично для кэша процессора. В плотном варианте все данные лежат прямо в таблице архетипа. В разреженном — таблица хранит лишь индексы, а данные в отдельных пулах; миграция дешевле, но каждый доступ требует дополнительного разыменования.

Главная цена — миграция. Каждый раз, когда набор компонентов сущности меняется (добавляется Health или убирается Velocity), сущность физически переезжает из одной таблицы в другую: все её данные копируются, дыра в старом архетипе латается, индексы обновляются. Кроме того, каждая уникальная комбинация компонентов порождает отдельную таблицу — в реальных проектах их могут быть тысячи, и запрос вынужден сканировать множество маленьких фрагментов памяти.

Плотный архетип: таблица [Position, Velocity]

Sparse Sets

EnTT, LeoECS

Здесь нет таблиц. Каждый тип компонента живёт в собственном независимом хранилище: плотный массив данных плюс разреженный массив, отображающий ID сущности на позицию в нём. Сущность не привязана ни к какой группе — добавить или убрать компонент можно без копирования данных. Часто дополняются битовыми масками на сущностях для предварительной фильтрации перед обращением к пулам.

Главная цена — поштучная проверка при многокомпонентных запросах. Для запроса [Position + Velocity] обходим сущности наименьшего пула и для каждой отдельно проверяем наличие второго компонента: либо по битовой маске, либо через разреженный массив. Одна проверка — одна сущность. При сотнях тысяч сущностей это даёт непредсказуемые обращения к памяти, которые процессор не может эффективно предзагрузить.

Sparse Set: два массива на каждый тип компонента

Ограничения существующих подходов

Оба подхода несут структурные ограничения, которые следуют из самой модели данных и не могут быть устранены оптимизацией реализации.

Архетипы и их вариации (Unity DOTS, Flecs, Bevy, Arch)

  • Стоимость миграции. Добавление или удаление компонента перемещает сущность в другую таблицу: найти целевой архетип по маске компонентов, скопировать данные, залатать дыру, обновить индекс.

  • Взрыв архетипов. Каждая уникальная комбинация = новая таблица. Сотни или тысячи в продакшене. Запросы сканируют множество маленьких массивов.

  • Нет явной группировки. Группировка по сигнатуре, а не по намерению. Теги-дискриминаторы множат архетипы ещё больше.

Sparse Sets и битмасочные улучшения (EnTT, LeoECS, LeoEcs Proto)

  • Промахи кэша. Многокомпонентный запрос: выбрать наименьший пул, затем для каждой сущности — sparse-lookup или проверка битовой маски в других пулах. Цепочка случайных обращений к памяти не даёт процессору предзагружать данные в кэш.

  • Поштучная фильтрация. Даже с битовыми масками на сущностях, каждая сущность проверяется индивидуально. Нет способа пропустить целый регион — 1M подходящих сущностей = 1M проверок. Невозможно векторизировать.

  • Затраты памяти на управление хранилищем. Каждый компонент держит разреженный массив размером во весь диапазон ID сущностей: даже если компонент есть у 1% сущностей, sparse-массив выделяется под все 100%.

  • Нет группировки. Нет встроенного способа разделить сущности по логическому типу или пространственному региону.


Альтернатива: Bitmap Index

Ключевая идея приходит из совершенно другой области: индексирование баз данных. Вместо того, чтобы хранить индекс как число, можно хранить индекс как бит в битовой карте. Тогда, чтобы найти пересечение двух битовых карт, достаточно выполнить побитовое AND, которое обрабатывает сразу 64 числа за одну инструкцию CPU.

Другая аналогия: представьте поисковый движок, вместо того чтобы для каждого документа хранить список его слов, движок делает наоборот — для каждого слова хранит список документов, в которых оно встречается. Это и есть инвертированный индекс. Чтобы найти документы, содержащие слова A и B одновременно, достаточно взять два списка и найти их пересечение

Применяем к ECS:

  • Каждый тип компонента владеет битовой картой сущностей, которые его имеют (аналогия со Sparse Set — каждый тип компонента владеет Sparse/Dense массивом индексов сущностей)

  • Запрос All<Position, Velocity> становится побитовым AND двух битовых карт

  • Результат — битовая карта подходящих сущностей, вычисленная пакетно

Побитовое AND — запрос All<Position, Velocity>

Ключевое отличие от традиционного Sparse Set ECS с битмасочным ускорением: В Sparse Set ECS каждая сущность хранит битовую маску своих компонентов. Запрос = проверка маски каждой сущности поштучно. В Bitmap модели каждый компонент хранит битовую маску сущностей. Запрос = AND двух карт пакетно, без поштучной проверки.


Иерархическая битовая карта

Плоская битовая карта на тысячи сущностей непрактична для линейного сканирования, т.к. позволяет отсекать лишь по 64 сущности за одну операцию. Это решается дополнительной битовой картой высокого уровня, каждый бит в которой характеризует сразу весь блок из 64 сущностей, таким образом позволяя отсекать сразу 4096 сущностей за одну инструкцию CPU.

Далее этот высокий уровень называется чанком.

Эвристики чанка

На один чанк (4096 сущностей) каждый пул компонент хранит 2 битовые карты высокого уровня, выходит по 2 бита на каждый блок (блок — 64 сущности):

  • Блок не пустой — если бит установлен, хотя бы одна сущность в блоке имеет этот компонент. Используется для включающих фильтров (All): если бит не установлен, весь блок из 64 сущностей пропускается.

  • Блок полный — если бит установлен, все 64 сущности в блоке имеют этот компонент. Используется для исключающих фильтров: если запрос требует, чтобы компонент отсутствовал, а весь блок им заполнен — блок пропускается целиком без сканирования.

Эвристические маски чанка

Одна операция AND над двумя 64-битными словами проверяет все 4096 сущностей чанка. Нулевой результат — весь чанк пропускается целиком.

Полная картина: от эвристики чанка до конкретных сущностей

Иерархическая фильтрация: от эвристик чанка до сущностей

Сравнение при 10 000 сущностей, 1 000 совпадений

  • Архетипный ECS: поиск подходящих архетипов, затем итерация по каждой таблице отдельно

  • Sparse set ECS: итерация всего наименьшего пула (может быть значительно больше 1 000), для каждой сущности — sparse-lookup в остальных пулах с возможными промахами кэша

  • Bitmap ECS: 3 чанка × 1 операция AND эвристик = проверка 12 288 сущностей за 3 операции. Затем в лучшем случае 16 операций AND на уровне блоков (1 000 / 64), в зависимости от разреженности данных


Модель памяти

Прежде чем говорить о возможностях, ограничениях и производительности — давайте разберёмся, как данные физически устроены в памяти. Это фундамент, на котором строится всё остальное.

Физически данные хранятся в плоских массивах — никаких вложенных структур. Логически они организованы по четырём уровням: Кластер → Чанк → Сегмент → Блок. Внутри каждого сегмента хранилище разбито на независимые пулы: метаданные сущностей отдельно, данные каждого компонента отдельно.

Модель памяти StaticEcs

Проблема наивной реализации — ограничение плоских массивов

Компоненты хранятся в плоских массивах T[256]. Когда в одном сегменте соседствуют сущности с разным набором компонентов, возникают две проблемы. Дыры: одни сущности имеют Health, другие — нет; соответствующие слоты пустуют, итерация вынуждена пропускать их через битовую маску, загружая при этом лишние кэш-линии. Чередование: даже если соседние сущности имеют один и тот же компонент (например Position), их данные физически чередуются — нет возможности взять плотный непрерывный блок только «нужных» сущностей.

Проблема наивной реализации — смешанные типы в сегменте

Проблема наивной реализации — смешанные типы в сегменте

Именно это решает следующий уровень архитектуры — типы сущностей.


Типы сущностей: первое измерение

В большинстве ECS-фреймворков память разбивается только по наличию компонентов — будь то архетип или sparse set. Логическая принадлежность сущности никак не влияет на её расположение в памяти.

В StaticEcs тип сущности — это отдельная ось хранения. Тип — это просто логическая метка, которая определяет, в какие сегменты попадает сущность. Наличие компонентов не влияет на тип и не меняет место хранения: две сущности одного типа с разными наборами компонентов физически соседствуют.

Посмотрите на визуализацию ниже и сравните с примером из предыдущего раздела. Как только мы назначаем сущностям тип, UnitType-сущности попадают только в UnitType-сегменты, BulletType — только в BulletType-сегменты. Health[] у юнитов теперь заполнен без единой дыры — пул содержит данные только тех, у кого этот компонент есть. Position[] итерируется сплошным однородным блоком:

С типами сущностей: каждый тип — отдельный сегмент

Отличия от архетипов

Типы сущностей легко спутать с архетипами, но это принципиально разные концепции. В архетипном ECS сущность принадлежит архетипу, определённому её текущим набором компонентов: добавили компонент — сущность мигрирует в другой архетип вместе со всеми своими данными. Это порождает два системных эффекта: взрыв архетипов (20 компонентов с любыми комбинациями дают тысячи возможных архетипов) и дорогостоящие миграции при каждом изменении состава компонентов.

Тип сущности в StaticEcs фиксируется при создании автоматически и никогда не меняется (при необходимости вы можете переместить сущность в другой тип вручную). Добавление, удаление или замена компонента не перемещает сущность — она остаётся в тех же сегментах. Тип отвечает только на вопрос «кто эта сущность», а не «что у неё есть».

В StaticEcs на текущий момент доступно 256 типов сущностей для кастомизации пользователем. На практике этого более чем достаточно: тип — это крупная логическая категория (юнит, пуля, эффект, частица, элемент UI), а не вариация компонентного состава. Даже в сложных играх таких категорий редко больше нескольких десятков.

Дыры в сегменте и стратегия их заполнения

При уничтожении сущности её место в сегменте освобождается, образуя дыру в массиве. В отличие от архетипных фреймворков, которые заполняют пустое место последней сущностью с изменением её индекса (swap-and-pop), StaticEcs применяет иной принцип: дыры в сегменте заполняются в первую очередь новыми сущностями того же типа. При этом предпочтение отдаётся позициям, ближайшим к концу заполненной области — так живые сущности постепенно снова смыкаются в плотный непрерывный диапазон.

Заполнение дыр: новые сущности того же типа занимают освободившиеся позиции

Стабильный индекс и что он открывает

Поскольку позиция сущности в массивах компонентов не меняется, пока она жива, эта позиция становится стабильным идентификатором. Это свойство имеет конкретные практические следствия:

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

  • Отношения и ссылки между сущностями. Стабильный индекс позволяет хранить прямую ссылку на сущность — без словарей обратного поиска и хэш-карт, которые требуются архетипным фреймворкам при миграции сущностей между архетипами.

  • Сетевая синхронизация. Стабильный индекс однозначно идентифицирует сущность в сети. Поскольку индекс не меняется при изменении набора компонентов, синхронизацию можно вести по идентификатору: пакет несёт только изменённые компоненты, команды или логику над сущностью.

  • Стриминг и выгрузка кластеров. Выгрузка кластера не затрагивает индексы сущностей в других кластерах. Загрузка восстанавливает данные по тем же индексам, что были при сохранении. Никакой перенумерации, никакого обхода живых сущностей для обновления перекрёстных ссылок.

  • Детерминизм. Порядок сущностей в сегментах определяется порядком их создания и заполнением дыр по типу. Одна и та же последовательность операций всегда даёт одинаковое расположение в памяти — итерация воспроизводима между запусками.

Компромиссы

Типы сущностей требуют явного проектирования — это одновременно сила модели и дополнительная когнитивная нагрузка. Вы получаете полный контроль над структурой памяти, но несёте ответственность за осмысленное разбиение на категории.


Кластеры: второе измерение

Кластер — изолированная область памяти с чанками сущностей и компонентов, объединяющая группу сущностей по произвольному признаку: уровень, регион, команда, сцена. Сущности создаются в конкретном кластере и остаются в нём до выгрузки или уничтожения.

В сочетании с типами сущностей кластеры дают двумерное партиционирование: Тип сущности × Кластер.

Тип сущности × Кластер — матрица партиционирования памяти

Запрос, ограниченный кластером, полностью пропускает другие регионы, например неактивные области или уровни.

Почему это может быть важно:

  • Стриминг. Загрузить кластер с диска — сущности появляются. Выгрузить — исчезают. Никакой перестройки архетипов, никакой фрагментации sparse set.

  • LOD / Симуляция. Активные сущности в одном кластере, спящие в другом. Системы обрабатывают только активные кластеры — нулевой overhead на спящие.

  • Уровни и игровые зоны. Каждый уровень или зона — отдельный кластер. Переход между зонами не требует перестройки мирового хранилища.

  • Сеть / Комнаты. Каждая игровая комната — отдельный кластер. Игроки перемещаются между комнатами, меняя кластер. Сериализация отдельных кластеров для сетевой синхронизации.

Насколько мне известно, у других ECS-решений нет прямого эквивалента кластеров. Unity DOTS частично приближается к этому через shared components: одинаковые значения shared component позволяют группировать сущности, и системы могут фильтровать по ним. Однако каждая уникальная комбинация shared component создаёт отдельный архетип — партиционирование достигается ценой комбинаторного взрыва архетипов. Flecs поддерживает пары отношений и может кодировать принадлежность к региону через теги-отношения, но это не первоклассная конструкция с семантикой выгрузки и скоупингом запросов. EnTT не предоставляет механизма пространственного или логического партиционирования на уровне хранилища.


Характеристики производительности

Add/Remove: O(1)

Добавление и удаление компонента не перемещает никаких данных — меняется только признак присутствия. Стоимость постоянна вне зависимости от числа компонентов.

Итерация: групповая фильтрация

Запрос фильтрует сущности не поштучно, а на нескольких уровнях иерархии сразу. На верхних уровнях работают эвристики, которые позволяют пропустить целые области памяти до того, как туда заходит итерация.

Пакетные операции

Массовые операции над результатами запроса — добавить компонент, уничтожить, переключить тег — работают с теми же блоками, что и итерация. Никакого дополнительного обхода.

Размещение в памяти: без фрагментации

Каждый компонент хранится в плотном массиве внутри своего сегмента. Типы сущностей и кластеры не добавляют накладных расходов на хранение: нет дополнительных индексов сущностей, нет вспомогательных таблиц соответствия — только данные компонентов и битовые карты присутствия.


Сравнение подходов


Результаты бенчмарков

Интеграционный бенчмарк — 16 000 сущностей

Полноценная игровая нагрузка: создание сущностей, добавление/удаление компонентов, итерация, уничтожение — всё в одном прогоне.

NativeAOT 10.0 — интеграционный бенчмарк
.NET 10.0 — интеграционный бенчмарк
Потребление памяти — NativeAOT 10.0

Микро-бенчмарки — 100 000 сущностей, 13 фреймворков

Отдельные операции, измеренные изолированно. BenchmarkDotNet, .NET 9, Linux.

Добавление 4 компонентов
Удаление 4 компонентов
Система 3 компонента, 10% совпадений
Создание сущности с 3 компонентами
Удаление сущности

Фреймворк стабильно занимает высокие места в бенчмарках. При этом потребление памяти значительно ниже — без дополнительных данных.

P.S. MassiveEcs также перешёл на данную архитектуру — результаты в таблице выше отражают это.

Источники: ecs-benchmark-runner-dotnet (интеграционный, 16K сущностей) · Микро-бенчмарки (100K сущностей, 13 фреймворков)


Основные возможности

Помимо архитектурных преимуществ, StaticEcs — полнофункциональный фреймворк. Каждая подсистема спроектирована так, чтобы дополнять битовую архитектуру.

Почему «Static» — и что это даёт на практике

Название отражает ключевое архитектурное решение: всё состояние мира хранится в статических полях параметризованных классов. Каждый уникальный тип-параметр мира (World<TWorld>) получает собственное изолированное хранилище — доступ к данным прямой, без промежуточных уровней абстракции.

C# выполняет мономорфизацию при компиляции: каждая инстанциация обобщённого типа компилируется в отдельный код. Это означает, что вызовы методов на статических обобщённых типах являются прямыми — без виртуальных вызовов, без таблиц диспетчеризации, без интерфейсного оверхеда.

  • Нет виртуальных вызовов — весь API строится на обобщённых методах и структурах. Никаких интерфейсов в горячих путях. Каждый вызов — прямой или встроенный.

  • Удобный API без жертв производительностью — благодаря мономорфизации удаётся реализовать выразительный API — сложные запросы, типобезопасные хэндлы, цепочки фильтров — без каких-либо потерь в производительности.

  • Изоляция миров — несколько миров (например, игровой и UI) живут в разных инстанциациях World<T>. Их данные физически разделены.

  • Строгая типизация и выразительная система запросов — запросы читаются как декларация намерений: фильтры по компонентам, типам сущностей, изменениям и произвольным комбинациям компонуются в единую конструкцию.

Быстрый старт:

using FFS.Libraries.StaticEcs;// Определяем тип мираpublic struct WT : IWorldType { }// Определяем тип-алиас для удобного доступаpublic abstract class W : World<WT> { }// Определяем тип системpublic struct GameSystems : ISystemsType { }// Определяем тип-алиас для системpublic abstract class GameSys : W.Systems<GameSystems> { }// Определяем компонентыpublic struct Position : IComponent { public Vector3 Value; }public struct Direction : IComponent { public Vector3 Value; }public struct Velocity : IComponent { public float Value; }// Определяем системуpublic struct VelocitySystem : ISystem {    public void Update() {        // Итерация через foreach        foreach (var entity in W.Query<All<Position, Velocity, Direction>>().Entities()) {            ref var pos = ref entity.Mut<Position>();            ref readonly var dir = ref entity.Read<Direction>();            ref readonly var vel = ref entity.Read<Velocity>();            pos.Value += dir.Value * vel.Value;        }        // Или через делегат (быстрее, без аллокаций)        W.Query().For(            static (ref Position pos, in Velocity vel, in Direction dir) => {                pos.Value += dir.Value * vel.Value;            }        );    }}public class Program {    public static void Main() {        W.Create();        W.Types().RegisterAll();        W.Initialize();        GameSys.Create();        GameSys.Add(new VelocitySystem(), order: 0);        GameSys.Initialize();        var entity = W.NewEntity<Default>().Set(            new Position { Value = Vector3.Zero },            new Direction { Value = Vector3.UnitX },            new Velocity { Value = 1f }        );        GameSys.Update();        W.Tick();        GameSys.Destroy();        W.Destroy();    }}

Система сущностей

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

Компоненты и теги

Компоненты — обычные структуры данных. Опциональные хуки жизненного цикла при добавлении и удалении реализуются только при необходимости, без них никаких накладных расходов. Включение/отключение компонента сохраняет данные в памяти, но исключает сущность из обычных запросов без структурных изменений.

Теги — маркеры без данных. Они существуют только как биты в битовой карте — без аллокации массивов, без памяти на сущность. Поскольку теги это уже битовые карты, фильтрация по тегу не добавляет стоимости к итерации — это та же операция AND, которую запрос уже выполняет. Пакетные операции на результатах запросов (поставить тег всем подходящим = одна операция на 64 сущности).

Мультикомпоненты — коллекции переменной длины на сущность

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

Данные мультикомпонентов аллоцируются по сегментам, а не индивидуально на каждую сущность. Это даёт критическое преимущество при сериализации: при сохранении чанка или кластера не нужно обходить каждую сущность в поисках её коллекций — достаточно сохранить сегмент целиком. Поддержка поиска, сортировки, доступа через span, итерации по ссылке.

Иерархия — графовые отношения

Связи позволяют выражать отношения между сущностями напрямую: одиночная ссылка на другую сущность или динамическая коллекция ссылок. Типичные сценарии — parent/child, owner/target, членство в группе, инвентарь.

Связи поддерживают хуки жизненного цикла: при создании и разрыве связи можно автоматически обновлять обратные ссылки или запускать произвольную логику. Поддерживаются все классические паттерны отношений: однонаправленные, двунаправленные, один-к-одному, один-ко-многим, многие-ко-многим. Глубокие операции позволяют рекурсивно уничтожать или копировать граф связанных сущностей.

Система запросов

Запрос описывает, какие данные и сущности нужны, а не как их искать. Фильтры компонуются в декларацию намерений и разрешаются на уровне битовых карт — без обхода каждой сущности отдельно.

Примеры того, что можно выразить в запросе:

  • все юниты с Health и Position, у которых нет тега IsDead

  • все сущности типа Bullet, у которых Health изменился с последнего тика

  • все сущности, у которых был добавлен компонент OnHit — независимо от их типа

  • все юниты в конкретном кластере (регионе, уровне)

  • все юниты или враги с Health, без тега IsDead, у которых: изменился Health или добавлен тег Stun или Shield отключён

Последний пример в коде:

W.Query<    None<IsDead>,    Or<AllChanged<Health>, AllAdded<Stun>, AllDisabled<Shield>>,    EntityIsAny<Unit, Enemy>>().For(static (var entity, ref Health hp) => {    // обработка});

Итерация доступна в нескольких режимах: делегат, foreach, блочный доступ для максимальной производительности, параллельная итерация. Пакетные операции на результатах запроса: уничтожить всех, добавить всем тег, удалить компонент — одним вызовом.

Отслеживание изменений — zero-allocation, нативное для битовых карт

Трекинг изменений в StaticEcs — это не отдельная система, прикрученная сверху. Он использует ту же инфраструктуру битовых карт, что и хранение компонентов. Добавление, удаление, изменение, создание — каждый тип отслеживания — это слой битовой карты рядом с картой наличия компонента. Та же операция AND, та же блочная фильтрация, та же нулевая стоимость аллокаций.

Кольцевой буфер на основе тиков (по умолчанию 8 тиков истории). Системы автоматически видят изменения за период с последнего обновления. Без очередей событий, без dirty-списков, без хэш-сетов — только битовые карты, вращающиеся через кольцевой буфер. Подключается по типу компонента через конфигурацию.

Битовые слои трекинга рядом с картой наличия компонента

Система событий

Компонент хорошо описывает состояние, но плохо — момент, когда что-то произошло. Смена времени суток, изменение погоды, завершение волны, переход уровня — это одноразовые сигналы: непонятно, кто и когда сбросит компонент-флаг, а нескольким независимым системам нужно среагировать каждой по-своему. Та же проблема возникает при уведомлении UI, передаче сигналов во внешние сервисы — звуковой движок, аналитику, сеть — и в многомодульных проектах, где модули не должны знать друг о друге напрямую, но обмениваться сигналами.

Реализация на кольцевом буфере (до 131K событий одновременно) с независимыми курсорами на каждого получателя. Событие удаляется только после того, как все его прочитали. Без аллокаций, отправка потокобезопасна.

Система событий: WeatherSystem → WeatherChanged → получатели

Ресурсы — глобальные данные мира

Не все данные привязаны к сущностям. Конфигурация игры, ссылка на камеру, время кадра, сетевое состояние — это данные уровня мира, а не отдельной сущности. Ресурсы предоставляют типизированные синглтоны с доступом за O(1) через статическую генерик-инфраструктуру, без словарей и хэш-таблиц.

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

Сериализация

Бинарные снапшоты доступны на шести уровнях — от всего мира до отдельной сущности:

  • World — полное состояние мира. Save/Load игры.

  • Cluster — все сущности в кластере. Стриминг зон.

  • Chunk — 4096 сущностей. Тонкий стриминг.

  • Entities — отдельные сущности. Сетевая синхронизация.

  • GID Store — метаданные сущностей. Целостность связей при загрузке и выгрузке кластеров.

  • Events — буфер событий. Replay, отладка.

GZIP-сжатие, версионирование схемы с хуками миграции, кастомные данные в снапшотах, pre/post callbacks.


Заключение

Инвертированная иерархическая битовая карта — это не оптимизация архетипов или sparse sets, а третья самостоятельная модель хранения данных ECS. Центральный принцип прост: компоненты индексируют сущности, а не наоборот. Это меняет стоимость почти всех операций: добавление/удаление компонента не перемещает никаких данных и не требует перестройки структур хранения, фильтрация в запросах работает иерархически на нескольких уровнях, а пакетные операции обрабатывают данные блоками без дополнительного обхода.

Типы сущностей и кластеры — это то, чего нет в других ECS-решениях. Они решают класс задач, которые в архетипных фреймворках требуют дорогих обходных путей, а в sparse sets практически не решаются вовсе: стриминг регионов, LOD-симуляция, сетевые комнаты и тд. При этом накладные расходы на эти возможности стремятся к нулю — память не дублируется, индексных структур нет.

Порог вхождения не выше, чем у других ECS-фреймворков: базовые операции — создание сущностей, добавление компонентов, запросы — работают привычно и не требуют знания устройства модели. Типы сущностей и кластеры — это не обязательный старт, а инструменты, которые подключаются по мере роста проекта. Можно начать с одного дефолтного типа и добавлять разбивку тогда, когда в ней появится реальная потребность. Сложность освоения линейна: каждый следующий слой открывается только тогда, когда он действительно нужен.

Идея инвертированного индекса не нова — она давно используется в поисковых движках и СУБД. Применение её к ECS потребовало нескольких нетривиальных решений: иерархия для практичной фильтрации, эвристические маски для раннего отсечения, типы сущностей как управляемая ось размещения. Результат — модель, которая хорошо масштабируется и оставляет разработчику контроль над тем, где и как лежат данные.


StaticEcs — open-source, разрабатываемый мной в свободное время на протяжении последних полутора лет.

Архитектура инвертированного индекса набирает популярность: MassiveEcs и в дальнейшем ME.BECS также применили эту модель, познакомившись с этим подходом.

Автор MassiveEcs также принимает активное участие в развитии данной архитектуры — совместно прорабатываются решения, обсуждаются компромиссы, находятся улучшения.

Попробуйте StaticEcs в своём проекте. Фреймворк доступен для .NET 6–10, netstandard2.1, NativeAOT и Unity, имеет подробную документацию.

Пишите комментарии, задавайте вопросы или открывайте issues. Если вам понравилась идея, реализация или эта статья — буду рад ⭐ на GitHub.

ссылка на оригинал статьи https://habr.com/ru/articles/1027940/