Производительность
Архитектурные особенности
StaticEcs спроектирован для максимальной производительности и огромных миров:
-
Сущность никогда не перемещается в памяти при Add/Remove — операции выполняются побитово за O(1). В архетипных ECS добавление или удаление компонента вызывает перемещение сущности между архетипами с копированием всех данных. В sparse set ECS удаление компонента перемещает последний элемент на место удалённого (swap-back)
-
SoA-хранение (Structure of Arrays) — компоненты одного типа расположены в памяти последовательно, что обеспечивает оптимальное использование кэша CPU при итерации. Архетипные ECS также используют SoA внутри архетипов, но данные фрагментированы между отдельными массивами разных архетипов, количество которых растёт комбинаторно. В StaticEcs все компоненты одного типа хранятся в едином массиве сегментов — фрагментация возможна при использовании множества entityType и кластеров, но остаётся контролируемой. Sparse set ECS хранят компоненты в плотных массивах, но доступ к нескольким компонентам одной сущности требует индексации через разные массивы с потенциально разным порядком элементов
-
Статические дженерики — доступ к данным через
Components<T>— прямое обращение к статическому полю, разрешаемое на этапе компиляции. В других ECS поиск пула компонентов требует хеш-поиска по type ID или доступа через lookup с проверками безопасности -
Нет проблемы архетипного взрыва — в архетипных ECS каждая уникальная комбинация компонентов создаёт новый архетип. При 30+ типах компонентов количество архетипов может достигать тысяч, вызывая фрагментацию памяти и деградацию итерации. StaticEcs свободен от этой проблемы — количество типов компонентов не влияет на структуру хранения
-
Нулевые аллокации на горячем пути — все структуры данных предаллоцированы, запросы возвращают ref struct итераторы. В других ECS создание view/filter может требовать аллокаций при первом вызове или управляется через обёртки с накладными расходами на проверки безопасности
-
Двумерная партиция (Cluster × EntityType) — встроенная пространственная и логическая группировка на уровне памяти, позволяющая контролировать расположение сущностей без изменения набора компонентов. В других ECS группировка возможна только через фильтры запросов (теги, shared components), без прямого контроля над расположением в памяти
-
Встроенный стриминг — загрузка/выгрузка кластеров и чанков без перестроения внутренних структур. В архетипных ECS массовое создание или удаление сущностей вызывает перебалансировку чанков. В sparse set ECS массовое удаление фрагментирует плотные массивы
-
Предсказуемая производительность — время операций Add/Remove/Has не зависит от количества компонентов на сущности и общего числа типов в мире. В архетипных ECS стоимость структурных изменений растёт с количеством компонентов (копируются все данные сущности). В sparse set ECS стоимость Has/Ref постоянна, но итерация по нескольким компонентам требует пересечения множеств
Способы итерации (от быстрого к удобному)
1. ForBlock — указатели на блоки (самый быстрый для unmanaged):
readonly struct MoveBlock : W.IQueryBlock.Write<Position>.Read<Velocity> {
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Invoke(uint count, W.EntityBlock entities,
Block<Position> positions, BlockR<Velocity> velocities) {
for (uint i = 0; i < count; i++) {
positions[i].Value += velocities[i].Value;
}
}
}
W.Query().WriteBlock<Position>().Read<Velocity>().For<MoveBlock>();
2. For с функциональной структурой (без аллокаций, с состоянием):
struct MoveFunction : W.IQuery.Write<Position>.Read<Velocity> {
public float DeltaTime;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Invoke(W.Entity entity, ref Position pos, in Velocity vel) {
pos.Value += vel.Value * DeltaTime;
}
}
W.Query().Write<Position>().Read<Velocity>().For(new MoveFunction { DeltaTime = 0.016f });
3. For с делегатом (без аллокаций со static лямбдами):
// Без данных
W.Query().For(
static (ref Position pos, in Velocity vel) => {
pos.Value += vel.Value;
}
);
// С пользовательскими данными (без захвата)
W.Query().For(deltaTime,
static (ref float dt, ref Position pos, in Velocity vel) => {
pos.Value += vel.Value * dt;
}
);
4. Foreach итерация (наиболее гибкий):
foreach (var entity in W.Query<All<Position, Velocity>>().Entities()) {
ref var pos = ref entity.Ref<Position>();
ref readonly var vel = ref entity.Read<Velocity>();
pos.Value += vel.Value;
}
Методы расширения для IL2CPP
При использовании IL2CPP в Unity, стандартные дженерик-методы Entity (entity.Ref<T>(), entity.Has<T>()) могут быть на 10–25% медленнее из-за особенностей AOT-компиляции. Рекомендуется создавать типизированные методы расширения:
public static class ComponentExtensions {
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ref Position RefPosition(this W.Entity entity) {
return ref W.Components<Position>.Instance.Ref(entity);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool HasPosition(this W.Entity entity) {
return W.Components<Position>.Instance.Has(entity);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool HasTagPlayer(this W.Entity entity) {
return W.Tags<IsPlayer>.Instance.Has(entity);
}
}
// Использование — удобно и быстро
ref var pos = ref entity.RefPosition();
bool has = entity.HasPosition();
bool isPlayer = entity.HasTagPlayer();
В Mono/CoreCLR разница минимальна благодаря агрессивному инлайнингу JIT. Оптимизация актуальна именно для IL2CPP.
Параллельное выполнение
Для активации многопоточных запросов укажите количество потоков в конфигурации мира:
W.Create(new WorldConfig {
ThreadCount = WorldConfig.MaxThreadCount, // все доступные потоки CPU
// или
// ThreadCount = 8, // конкретное количество потоков
});
// Параллельная итерация
W.Query().ForParallel(
static (ref Position pos, in Velocity vel) => {
pos.Value += vel.Value;
},
minEntitiesPerThread: 50000 // минимум сущностей на поток
);
Ограничения параллельной итерации: можно модифицировать/уничтожать только текущую сущность. Нельзя создавать сущности, модифицировать другие сущности. SendEvent потокобезопасен (при отсутствии одновременного чтения того же типа).
Тип сущности (entityType)
entityType группирует логически схожие сущности в смежных сегментах памяти, что улучшает кэш-локальность:
struct UnitType : IEntityType { }
struct BulletType : IEntityType { }
struct EffectType : IEntityType { }
// Юниты расположены рядом в памяти
var unit = W.NewEntity<UnitType>();
unit.Add<Position>(); unit.Add<Health>();
// Снаряды — в своих сегментах
var bullet = W.NewEntity<BulletType>();
bullet.Add<Position>(); bullet.Add<Velocity>();
Запросы автоматически итерируют по смежным блокам памяти — чем однороднее данные, тем эффективнее кэш CPU.
Кластерные запросы
Ограничение запросов конкретными кластерами пропускает ненужные чанки:
const ushort ACTIVE_ZONE = 1;
ReadOnlySpan<ushort> clusters = stackalloc ushort[] { ACTIVE_ZONE };
// Итерация только по указанным кластерам
W.Query().For(
static (ref Position pos) => { pos.Value.Y -= 9.8f * 0.016f; },
clusters: clusters
);
Пакетные операции
Пакетные операции работают на уровне битовых масок — одна побитовая операция затрагивает до 64 сущностей за раз. Это на порядки быстрее поэлементной итерации с вызовом на каждую сущность.
Доступные операции:
| Метод | Описание |
|---|---|
BatchAdd<T>() | Добавить компоненты (default-значения, 1–5 типов) |
BatchSet<T>(value) | Добавить компоненты с значениями (1–5 типов) |
BatchDelete<T>() | Удалить компоненты или теги (1–5 типов) |
BatchEnable<T>() | Включить компоненты (1–5 типов) |
BatchDisable<T>() | Отключить компоненты (1–5 типов) |
BatchSet<T>() | Установить теги (1–5 типов) |
BatchToggle<T>() | Переключить компоненты или теги (1–5 типов) |
BatchApply<T>(bool) | Установить или удалить компонент или тег по условию (1–5 типов) |
BatchDestroy() | Уничтожить все подходящие сущности |
BatchUnload() | Выгрузить все подходящие сущности |
EntitiesCount() | Подсчитать количество подходящих сущностей |
Примеры:
// Цепочка операций — добавить компонент, установить тег, отключить компонент
W.Query<All<Position>>()
.BatchSet(new Velocity { Value = Vector3.One })
.BatchSet<IsMovable>()
.BatchDisable<Position>();
// Уничтожить все сущности с тегом IsDead
W.Query<All<Health, IsDead>>().BatchDestroy();
// Подсчёт сущностей
int count = W.Query<All<Position, Velocity>>().EntitiesCount();
// Фильтрация по кластерам и статусу сущности
ReadOnlySpan<ushort> clusters = stackalloc ushort[] { 1, 2 };
W.Query<All<Position>>().BatchDelete<Velocity>(
entities: EntityStatusType.Any,
clusters: clusters
);
// Переключить тег — у кого был, будет снят; у кого не было, будет установлен
W.Query<All<Position>>().BatchToggle<IsVisible>();
Все пакетные операции поддерживают фильтрацию по EntityStatusType (Enabled/Disabled/Any) и clusters. Методы возвращают WorldQuery для построения цепочек.
QueryMode
По умолчанию используется QueryMode.Strict — самый быстрый режим. Используйте QueryMode.Flexible только если во время итерации нужно модифицировать фильтруемые компоненты/теги на других сущностях:
// Strict (по умолчанию) — быстрый путь для полных блоков
W.Query().For(
static (ref Position pos) => { /* ... */ }
);
// Flexible — перепроверяет битовые маски на каждой итерации
W.Query().For(
static (W.Entity entity, ref Position pos) => {
// Можно модифицировать Position на других сущностях
},
queryMode: QueryMode.Flexible
);
Стриппинг (уменьшение размера сборки)
StaticEcs активно использует дженерик-перегрузки: Query0–Query6 × варианты делегатов × Read-варианты × Parallel — это порождает огромное количество дженерик-специализаций, большинство из которых не используется в конкретном проекте. Для удаления неиспользуемого кода из сборки и значительного уменьшения её размера используйте стриппинг управляемого кода.
Unity:
Установите Player Settings → Other Settings → Managed Stripping Level в Medium или High. Это удалит неиспользуемые дженерик-инстанциации, генерируемые библиотекой.
.NET (publish trimming):
<PropertyGroup>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>link</TrimMode>
</PropertyGroup>
После включения стриппинга тщательно протестируйте сборку — агрессивный стриппинг может удалить код, доступ к которому происходит только через рефлексию. Если вы используете RegisterAll для автоматического обнаружения типов, убедитесь что нужные типы сохранены (например, через атрибут [Preserve] в Unity или TrimmerRootAssembly в .NET).
Рекомендации
| Практика | Причина |
|---|---|
Используйте ForBlock для критичных циклов | Прямые указатели, минимальный оверхед |
Используйте static лямбды в For | Без аллокаций, JIT-инлайнинг |
Используйте in для read-only компонентов | Корректная семантика отслеживания изменений |
Группируйте сущности по entityType | Кэш-локальность |
| Ограничивайте запросы кластерами | Пропуск ненужных чанков |
QueryMode.Strict по умолчанию | На 10–40% быстрее Flexible |
| Пакетные операции для массовых изменений | Одна операция на 64 сущности |
| Medium/High стриппинг в Unity | Удаление неиспользуемых дженерик-перегрузок |
UnmanagedPackArrayStrategy<T> для сериализации | Блочное копирование памяти |
| Типизированные extension-методы для IL2CPP | На 10–25% быстрее дженерик-обёрток Entity |