
У архетипов в ECS есть неотъемлимое преимущество перед обычными sparse set’ами — локальность данных. Но есть и существенная проблема — хаотическое копирование данных при миграции из одного архетипа в другой. Что если попробовать избавиться от этого недостатка, оставив локальность данных? Предлагаю рассмотреть статический архетип, который требует явного вызова метода для миграции данных.
Проблема
Рассмотрим проблему на конкретном примере: на игровой механике стрельбы, которую реализуем с помощью ECS.
Есть снаряд, выпущенный из оружия. Он летит в определенном направлении и попадает в цель. В момент попадания снаряд взрывается. Сущность снаряда перейдет в сущность взрыва. Ненужные компоненты будут удалены, новые добавлены.
Понятно, что есть множество вариантов это реализовать как-то по другому, но рассмотрим именно этот вариант для наглядности.
Итак, для полета снаряда нам понадобятся компоненты:
-
Position— текущая позиция, -
Velocity— скорость и направление полета, -
Damage— урон.
А попадание будет представлено:
-
Position— местом попадания, -
ExplosionRadius— радиусом поражения, -
Damage— уроном.
Сами компоненты будут иметь вот такой вид:
public struct Position : IComponent { public Vector3 Vector; }public struct Velocity : IComponent { public Vector3 Vector; }public struct ExplosionRadius : IComponent { public float Value; }public struct Damage : IComponent { public int Value; }
При переходе сущности из снаряда во взрыв, т.е. при переходе из одного архетипа в другой система вынуждена:
-
скопировать все компоненты сущности из старого архетипа,
-
вставить в подходящие массивы нового архетипа,
-
удалить запись в старом архетипе.
Чем больше компонентов, тем дороже каждая миграция. В сценариях с частыми переходами между состояниями (снаряды, бафы, фазы атак) это становится заметными накладными расходами.
Вопрос напрашивается сам собой: можно ли сохранить локальность данных и при этом взять контроль над моментом копирования в свои руки?
Как возможный вариант решения проблемы, предлагаю рассмотреть статический архетип.
Статический архетип
Идея проста: набор компонентов архетипа фиксируется на этапе инициализации. После этого сущность попадает в архетип и находится там до тех пор, пока мы сами не решим её переместить или удалить. Никакого неявного перемещения, только мы контролируем этот процесс.
Данные, как и в классическом архетипе, хранятся в плотных типизированных массивах. Но добавление и удаление отдельных компонент невозможно.
Рассмотрим механизм работы таких статических архетипов. В этом нам поможет библиотека PewPew.Ecs. Там как раз реализованы статические архетипы.
Нам понадобится nuget пакет PewPew.Ecs, который включает в себя всё, что нужно.
Объявление статических архетипов
Из примера выше объявим 2 архетипа: Projectile, будет отвечать за снаряд, и Explosion, будет отвечать за взрыв, с нужным набором компонент.
Нам понадобится атрибут [Archetype]. Он нужен для SourceGenerator, который добавит нужные методы и структуры.
// объявляем архетип снаряда[Archetype]public ref struct Projectile{ // добавляем нужные компоненты как ref поля public ref Position Position; public ref Velocity Velocity; public ref Damage Damage; // объявляем конструктор public Projectile(ref Position position, ref Velocity velocity, ref Damage damage) { Position = ref position; Velocity = ref velocity; Damage = ref damage; }}// по аналогии объявляем архетип взрыва с нужным набором полей[Archetype]public ref struct Explosion{ public ref Position Position; public ref ExplosionRadius ExplosionRadius; public ref Damage Damage; public Explosion(ref Position position, ref ExplosionRadius explosionRadius, ref Damage damage) { Position = ref position; ExplosionRadius = ref explosionRadius; Damage = ref damage; }}
Инициализация
Для работы со статическими архетипами нам понадобится HybridWorld — мир, который умеет хранить сущности как в обычных sparse set’ах, так и в статических архетипах.
// создаем HybridWorldHybridWorld world = WorldFactory.Shared.CreateHybridWorld();// объявляем архетип летящего снаряда Projectile с компонентами Position, Velocity, Damageworld.InitProjectileArchetype();// объявляем архетип взрывающегося снаряда Explosion с компонентами Position, ExplosionRadius, Damageworld.InitExplosionArchetype();
Явная инициализация имеет большое преимущество перед неявной. Процесс прогрева всех внутренних коллекций происходит в понятное для разработчика время.
Добавление сущности
После инициализации можно добавлять сущности в архетип:
// получаем сгенерированный архетип для ProjectileProjectileArchetype projectileArchetype = world.GetProjectileArchetype();// создаем идентификатор сущностиEntityId entityId = world.CreateEntityId();// добавляем в архетип новую сущностьProjectile projectile = projectileArchetype.Add(entityId);// назначаем компонентам нужные значенияprojectile.Position.Vector = spawnPoint;projectile.Velocity.Vector = direction;projectile.Damage.Value = 50;
Метод Add возвращает refструктуру Projectile с refссылками на компоненты прямо в массивах архетипа. Никаких промежуточных копий.
Явная миграция
Перейдём к ключевой части. Рассмотрим сценарий: в каждом тике мы проверяем летящие снаряды и переводим те, что во что-то попали, в архетип взрыва.
// получаем сгенерированный архетип для ProjectileProjectileArchetype projectileArchetype = world.GetProjectileArchetype();// получаем сгенерированный архетип для ExplosionExplosionArchetype explosionArchetype = world.GetExplosionArchetype();// получаем все сущности хранящиеся в архетипеSpan<EntityId> entities = projectileArchetype.Entities;// пример итерации с перемещением сущностиfor (var i = 0; i < entities.Length; i++){ EntityId entityId = entities[i]; // проверка столкновений if (HitSomething(entityId)) // копирование компонент из старого архетипа в новый projectileArchetype.MoveEntityTo(entityId, explosionArchetype);}
Метод MoveEntityTo копирует общие компонентыPosition и Damage из старого архетипа в новый, а компонент Velocity просто отбрасывается, т.к. в новом архетипе взрыва его нет.
Также в новом архетипе есть компонент ExplosionRadius , который будет содержать значения по умолчанию. Этот компонент мы можем инициализировать строкой ниже чуть позже.
Именно метод MoveEntityTo выполняет копирование компонентов. Но копирование происходит только тогда, когда мы сами этого захотели. Никакой скрытой работы за кадром.
Локальность данных
Осталось продемонстрировать, что итерация по сущностям работает также, как и для классического архетипа. Заодно продемострируем использование SIMD инструкций.
Поскольку компоненты одного архетипа хранятся в плотных массивах, мы можем обрабатывать их пачками. Для этого воспользуемся HybridWorld , который поддерживает батч-запросы через IBatchQuery:
// создаем фильтр для сущностейFilterDefinition flyingDefinition = new FilterDefinition() .With<Position>() .With<Velocity>();// создаем запрос, который будет выполняться для сущностей, удовлетворяющих фильтруFlightBatchQuery flightBatchQuery = new FlightBatchQuery();// запускаем запросworld.ExecuteBatchQuery<FlightBatchQuery, Position, Velocity>(flyingDefinition, flightBatchQuery);// именно структуры позволяют инлайнить методы, что позволяет убрать накладные расходы на вызовы методаpublic readonly struct FlightBatchQuery : IBatchQuery<Position, Velocity>{ // метод обработает сущности пачкой, когда сущности хранятся в статическом архетипе, // тут содержится SIMD специфичная логика сложения значений, // метод SparseUpdate ниже делает тоже самое, только поштучно для каждой сущности public void BatchUpdate(Span<Position> positions, Span<Velocity> velocities) { int vectorCount = Vector<float>.Count; int length = positions.Length - positions.Length % vectorCount; var posVectors = MemoryMarshal.Cast<Position, Vector<float>>(positions[..length]); var velVectors = MemoryMarshal.Cast<Velocity, Vector<float>>(velocities[..length]); for (int i = 0; i < posVectors.Length; i++) posVectors[i] += velVectors[i]; for (int i = length; i < positions.Length; i++) positions[i].Vector += velocities[i].Vector; } // поштучно обрабатывает сущности, когда сущности хранятся в sparse set коллекциях public void SparseUpdate(ref Position position, ref Velocity velocity) => position.Vector += velocity.Vector;}
Данные лежат в памяти последовательно, процессор читает их блоками, кэш-промахов минимум. Именно за это мы и любим архетипный подход.
Ограничения
Статический архетип, в свою очередь, тоже имеет свои минусы:
-
Сущность не может иметь компоненты, кроме тех, что объявлены в статическом архетипе. Но сущность может существовать отдельно от архетипа и хранить компоненты в sparse set’ах.
-
Сущность может находиться только в одном архетипе одновременно.
Эти ограничения обусловленны сложностями, возникающими в случае итерации по таким сущностям. Какие именно там возникают сложности, мы можем обсудить в комментариях.
Когда использовать?
Статические архетипы хорошо работают в следующих сценариях:
-
количество сущностей достаточно большое (более 10000 сущностей в одном мире), чтобы реализовать преимущество локальности данных, как и в случае классического архетипа,
-
структура сущностей известна заранее и меняется дискретно: снаряд летит / взрывается, враг жив / мёртв, юнит стоит / бежит,
-
важна предсказуемость момента копирования данных,
-
нужна SIMD-обработка или локальность данных.
Если же структура данных непредсказуема, то, вероятно, стоит рассмотреть обычные sparse set хранилища.
Заключение
Статический архетип это компромисс между классическим архетипом и sparse set хранилищем. Он позволяет сохранить локальность данных и получить SIMD-дружественный доступ к компонентам, но взамен требует явно вызывать копирование данных с помощью метода MoveEntityTo. Копированием управляет разработчик, а не ECS.
P.S. Буду рад обсудить статические и классические архетипы в комментариях!
ссылка на оригинал статьи https://habr.com/ru/articles/1031234/