Проверяя одну из своих механик, я спавнил последовательно NPC одного за другим и, внезапно, обнаружил, что где-то на 60 агентах у меня картинка уже заметно подлагивает.
В этот момент, в очередной раз смотря в код, я понял, что нужен тотальный рефакторинг. И вместо того, чтобы отрефакторить мою ООП-шную архитектуру, я решил переписать модуль NPC на какое-то подобие ECS. Естественно, я решил не использовать библиотеки Unity, а написать какой-то свой гибрид.
В этой статье я попытаюсь описать сложности, с которыми я столкнулся и свои впечатления от итога.
Это еще одна статья из цикла про разработку игр без прикладного опыта. Если вам интересна эта и подобные темы - подписывайтесь на мой ТГ-канал Homemade Gamedev, где посты выходят чаще, и я пишу про текущие задачи в проекте.
Введение
Для начала я вкратце расскажу, как устроена связь между агентами и действиями, которые они выполняют.

В игре симуляция устроена через логические тики, которые модулирует менеджер тиков (TickableManager). В нем регистрируются тик-системы, такие как:
BehaviourSystem - поведенческая тик-система
ActionRequestHandlerSystem - тик-система обработки запросов на действия
И многие другие
Поведенческая система в своем тике может создавать задачи. Например - задача на перемещение, сидение, разговор и так далее. Задача непосредственно никак не влияет на агента и его существование, у задачи есть свой жизненный цикл, одним из этапов которого является отправка запроса на действие. Действие - это уже непосредственно работа, выполняемая агентом. Задача создает действие через запрос (ActionRequest), который попадает в очередь запросов (ActionRequestQueue)
Далее, в тике уже другая система, ответственная за обработку запросов этой очереди (ActionRequestHandlerSystem) выгружает все запросы из очереди и отгружает их в фабрику (ActionFactory), которая создает необходимое действие (Action) в определенном слоте агента.
Специфичные для действия данные хранятся в контекстах действий (ActionContext), запрос контекста осуществляется через провайдер (ActionContextProvider).
Например, вот такой класс описывал контекст перемещения
public class MovementContext : IMovementContext {
public MovementRequestQueue MovementRequestQueue { get; }
public ICoordinateManager CoordinateManager { get; }
private float movementProgress;
public float Speed { get; private set; }
public MovementContext(MovementRequestQueue movementRequestQueue, ICoordinateManager coordinateManager) {
MovementRequestQueue = movementRequestQueue;
CoordinateManager = coordinateManager;
movementProgress = 0f;
}
public float GetProgress() {
return movementProgress;
}
public void AddProgress(float delta) {
movementProgress += delta;
}
public void ConsumeStep() {
movementProgress = Math.Max(0f, movementProgress - 1f);
}
public void SetProgress(float value) {
movementProgress = Math.Max(0f, value);
}
public void SetSpeed(float value) {
Speed = value;
}
}
Здесь, фактически 2 параметра – скорость перемещения и прогресс перемещения (нужен для вычисления целочисленной ячейки агента).
Зачем я сюда добавил ссылки на очередь – вопрос, на который сейчас мне уже сложно ответить.
Такие контексты объединялись в один огромный жирный контекст с таким интерфейсом
public interface IActionExecutionContext
{
public IMovementContext MovementContext { get; }
public ISocialContext SocialContext { get; }
public INeedsContext NeedsContext { get; }
public IIdleContext IdleContext { get; }
public IInteractionContext InteractionContext { get; }
public IVisualContext VisualContext { get; }
public IAnimationContext AnimationContext { get; }
public ITickAgeContext TickAgeContext { get; }
public IPositionContext PositionContext { get; }
public IActionControlContext ActionControlContext { get; }
}
Именно такими контекстами и оперировал провайдер контекстов - хранил массив этих жирных контекстов и выдавал мне их по Guid агента или его индексу.
Соответственно, во все системы, в которых я работал с данными действий, я прокидывал этот провайдер, далее получал жирный контекст и вычленял из него нужный атомарный контекст. Выглядело это как-то так
var visualContext = actionExecutionContextProvider.GetByIndex(index).VisualContext;
В целом, я не могу сказать, что это какая-то совсем уж плохая архитектура. Расширение довольно понятное – добавляется новое действие, я для него ввожу контекст со специфичными данными, добавляю ссылку на него в общий интерфейс - и все. Что делать, если для разных действий нужны одни и те же данные – на этот вопрос у меня тогда не было ответа, да я и не особо задумывался. Здесь можно было бы немного порефакторить контексты и сделать так, чтобы данные в них не пересекались. Я думаю, это вполне решаемая задача.
Другое дело, что везде, где мне надо работать с данными действий, мне надо было тащить провайдер контекстов, тк нельзя просто так было прокинуть конкретный контекст, иначе не получалось реализовать общий интерфейс для классов действий.
В этой архитектуре мне не нравились 2 вещи:
-
Мне сложно было понять, где именно лежат те или иные данные. Например, когда я спавнил агента, мне надо было у него инициализировать позицию (логическую и визуальную), и для этого, надо было в контроллер агента тащить этот провайдер контекстов, который изначально я задумывал как фасад для получения специфичных данных, которыми оперируют классы действий.
Выглядело это как-то так:public void SetStartPosition(int index, Vector3Int cellPos) { var context = contextProvider.GetByIndex(index); var positionContext = context.PositionContext; var visualContext = context.VisualContext; Vector3 pos = coordinateManager.CellToWorld(cellPos); positionContext.SetCellPosition(cellPos); positionContext.SetPosition(pos); visualContext.InitStartPositions(pos); }Можно было, конечно, ввести какой-то класс для хранения общих данных агентов и немного проредить данные, которыми оперируют контексты действий.
У меня не было выделено сервисов для работы с данными. Внутри самих этих контекстов были еще какие-то методы, отличные от простого CRUD. Ответственность за работу над данными размывалась по разным классам.
В результате, где-то через месяца 2 я поймал себя на мысли, что мне сложно ориентироваться в написанном коде, хотя я старался аккуратно дробить код по файлам, разбивал классы по пространствам имен. И все равно, я начинал теряться и в какой-то момент в течение нескольких дней даже писал кусок дублирующего кода, потому что забыл, что когда-то уже что-то похожее сделал.
Рефакторинг
В общем, когда я продумывал рефакторинг (изначально, не меняя архитектуру модуля), я наткнулся на статью про ECS-подход и мне очень зашла концепция хранения данных отдельно от поведения. Я решил попробовать реализовать его в своем прототипе игры. Скажу сразу, я думал, что уже процентов на 70 у меня ECS, потому что:
Я использовал структуры вместо классов. Ну т.е. я просто писал struct вместо class (состав полей тот же самый, методы – те же) и думал, что это уже часть ECS
Я хранил данные в массивах. Вместо стандартных словарей, я хранил данные по агентам в массивах таких структур.
Я ввел индексное хранилище. Когда создается агент, я присваивал ему индекс и во всех репозиториях, которые хранят массивы структур делал Resize. Соответственно, у этого агента был одинаковый индекс во всех репах.
Очевидно же, что надо только слегка ещё дотюнить код - и будет чистый ECS! Так я думал в середине октября.
Естественно, я был неприятно удивлен, когда понял, что конкретно у меня не так и увидел масштаб рефакторинга. Скажу сразу, что на ECS я решил перевести только лишь модуль NPC, все остальные модули у меня остаются ООП-шными.
Ниже я приведу шаги, через которые я прошел в ходе рефакторинга. Не буду описывать философию и терминологию ECS, про это есть отдельные статьи, рекомендую почитать, если нет понимания, что это такое.
Шаг 1. Определить сущности
Самый простой шаг. В моем прототипе игры в модуле NPC я оперирую четырьмя сущностями:
Агенты
Задачи
Действия
Запросы. Самое спорное, тк короткоживущие объекты, но за компанию так же переехали на ECS.
Для сущностей нужен единый механизм создания и какой-то реестр идентификаторов.
Я создал довольно простой EntityManager и EntityPool
Шаг 2. Определить компоненты и данные, которые в них будут храниться
Во-первых, у меня сразу вскрылась история с тем, что у меня было еще такое понятие как метаданные, которые хранили пол, возрастную группу, тип агента (ученик-учитель, уборщик и т.д.).
Итого, мне нужны были компоненты, в которых бы я хранил:
Тип агента
Пол
Возрастную группу
Возраст в тиках
Логическую позицию
Визуальную позицию
Это общие данные агентов, без учета данных, которые у них появляются в контексте действий.
Действия я решил хранить так:
-
Есть базовый компонент, хранящий общие свойства:
Тип
Слот у агента, в котором выполняется действие
Приоритет
Статус
ID запроса
-
Есть специфичные данные, которые отличаются от действия к действию, они хранятся в своих сторах, например, для перемещения они у меня такие:
Целевая точка
Тип перемещения (ходьба, бег)
В итоге у меня вырисовывалась такая структура:
-
Действие (например, GoTo)
-
Компоненты
-
Агент
Перемещение
Визуальный прогресс
-
Действие
Компонент действия, хранящий специфичные данные для перемещения
-
Запрос
Компонент, хранящий данные запроса
-
-
Системы
Система перемещения агентов
Система интерполяции визуала
...
-
-
Другое действие
...
Данные я решил хранить в структурах, состоящих из массивов. Например, для описания визуальной позиции я использовал класс, в котором хранил данные в массивах
private float[] x;
private float[] y;
private float[] z;
private float4[] rot;
Шаг 3. Последовательный перевод на новую архитектуру
Это самая муторная и трудная часть. Я то и дело порывался впилить для компонент какие-то ООП-шные интерфейсы, чтобы с ними было проще работать, но это свело бы на нет саму суть подхода.
Везде, где я раньше брал данные из жирных ООП-шных контекстов, надо было внедрять компоненты.
Всю логику пришлось выносить в системы с рефакторингом, нельзя было просто скопипастить код, его пришлось переписывать. Это очень много, ориентировочно 5-7к строчек кода, что соответствовало 12-15% всего проекта. Конечно, логика сохраняется, это не тоже самое, что придумать с нуля и написать, но все же.
Для сравнения приведу несколько примеров было-стало.
Вот так раньше у меня создавался агент через фабрику
public AgentData CreateAgent(Guid id, int typeId)
{
return new AgentData
{
Id = id,
TypeId = typeId,
Actions = new AgentActions(),
State = AgentStates.Idle,
};
}
Т.е. я создавал простую структуру, а дальше в разных частях приложения инициализировал репозиторий с метаданными, с позицией, полом, возрастом и так далее, добавляя в них эту структуру и связывая ее со специфичными данными.
Проблема тут в том, что сама по себе такая запись не валидна – агент должен быть с какой-то позицией и метаданными. Понятное дело, что это не вопрос выбора архитектуры, просто наблюдение.
После перехода на ECS агент стал создаваться так
public int CreateAgent(Guid id, int typeId)
{
Entity agentEntity = entityManager.Create(id);
//Debug.Log($"Создана сущность агента с ID = {agentEntity.Id}");
indexedStorageRegistry.Allocate();
int startCell = 0;
float3 worldPos = new float3(0, 0, 0);
agentTag.Attach(agentEntity);
agentTypes.Attach(agentEntity.Id, typeId);
models.Attach(agentEntity.Id, startCell, worldPos);
lerps.Attach(agentEntity.Id, startCell);
animationStore.Attach(agentEntity.Id);
actionBuckets.Attach(agentEntity.Id);
activeInSlotsStore.Attach(agentEntity.Id);
tickAge.Attach(agentEntity.Index, 0);
agentTypeComponent.Attach(agentEntity.Id, typeId);
socialZoneComponentStore.Attach(agentEntity, startCell);
return agentEntity.Id;
}
Здесь agentTag, agentTypes и т.д – хранилища, в которых в массивах хранятся данные по агентам.
Здесь сразу понятно, что у агента есть обязательные компоненты и они сразу на него цепляются при создании. Пусть даже пустые, главное, чтобы они были
А дальше в разных частях кода я уже инициализирую данные в эти компоненты.
Понятное дело, что сделать аналогичное создание агента можно и в ООП-подходе.
Раньше действия через фабрику создавались так
public bool Create(IActionRequest actionRequest, out IAgentAction action)
{
switch (actionRequest)
{
case GoToRequest goTo:
action = new GoToAction(goTo.Target, goTo.ClientRequestId, goTo.Mode);
return true;
Теперь стали так
public bool Route(IActionRequest action)
{
switch(action)
{
case GoToRequest goTo:
Entity intent = entityManager.Create(action.ClientRequestId);
intents.Attach(ActionTypes.Walk, intent.Id, goTo.AgentEntityId, ActionSlots.Legs, goTo.Priority, AgentActionStates.Pending, goTo.ClientRequestId);
int cellIndex = GridTopology.Index(goTo.Target);
goToStore.Attach(intent.Id, cellIndex, goTo.Mode);
Здесь я через менеджер сущностей выделяю индекс сущности для действия, затем прикрепляю к нему общий компонент, который хранит данные всех действий и специфичный компонент, в котором хранятся данные чисто для перемещения.
Шаг 4. Тестирование и решение проблем.
Тут я опишу ряд тупых, примитивных ошибок, которые возникают в таком подходе. Может быть, не самые типовые, просто те, с которыми у меня было много сложностей.
Скажу сразу, что причина этих ошибок – невнимательность и сложность отладки ECS, особенно, когда у тебя мозг живет в ООП-парадигме. Очень сложно перестроиться.
Проблема 1 – использование ref-ссылок везде, где надо и не надо
В компонентах у меня есть методы для ref-доступа к данным массивов, которые по задумке я должен использовать только в горячих циклах.
На практике это иногда приводило к тому, что я мог этот метод прокинуть в какую-то одну из систем и потом не понимал, как у меня данные обновляются.
Решение, которое мне пришло на ум, заключается в следующем:
Стор (хранилище компонент) должно реализовывать 3 интерфейса:
ComponentReader
ComponentWriter
RefAccessor
Например, для компонента, который хранит визуальные позиции агентов
private float[] x;
private float[] y;
private float[] z;
private float4[] rot;
я реализовал такие интерфейсы
public interface IVisualPositionReader
{
public float3 GetPos(Entity e);
public float4 Rot(Entity e);
}
public interface IVisualPositionWriter
{
public void SetPosition(int e, in float3 pos);
public void SetRotation(int e, in quaternion q);
}
internal interface IVisualPositionRefAccesor
{
public ref float X(int e);
public ref float Y(int e);
public ref float Z(int e);
public ref float4 Rot(int e);
}
Соответственно, в системы прокидывается не сам стор, а один или несколько этих интерфейсов.
Проблема 2. Версионность сущностей
Суть такая, есть агент (NPC), он совершает какие-либо действия. В простейшем примере - перемещается. Опять же, для простоты рассмотрим 2 сущности в ECS:
Агенты
Действия
Все эти сущности создаются через единый EntityManager. Далее у меня есть общий реестр компонентов, содержащий 2 массива
protected int[] dense = Array.Empty<int>();
protected int[] sparse = Array.Empty<int>();
Второй - хранит значения целочисленных идентификаторов сущностей. Первый - плотная упаковка второго.
К чему это приводило на практике
Допустим агент бродит по карте, перемещается из точки A в точку B, затем, когда дошел до точки B идет в другую точку и так до бесконечности.
Каждое такое перемещение между точками - это действие.
Создается действие переместиться из А в В, создается запрос, вычисляется маршрут, далее в тиках системы проталкивают агента по маршруту, но это сейчас не важно.
Важно то, что для каждого агента, который бродит по карте создавалось много сущностей-действий. Условно говоря, логика перемещения выдавала рандомную ячейку в радиусе 10 клеток от текущей позиции агента для следующего перемещения. Агент доходил до нее максимум за 5 секунд, в среднем за 3.
За 10 минут (600 сек) каждый агент создаст до 200 сущностей для перемещения, если таких агентов будет 1000, это 200к
Таким образом, за несколько минут игры в таком режиме индекс sparse уйдет за 100к. Учитывая, что в целевом решении агенты могут не только перемещаться, то агент будет создавать больше таких сущностей, и на горизонте маячит OutOfMemory, или, как минимум - замедление производительности, т.к. массивы огромные, а они создаются для каждого компонента, который я вешаю на намерение.
Какое решение пришло в голову
Очевидно, что вместо того, чтобы каждый раз создавать новую сущность, можно создавать новую версию старой сущности. Но я не хотел добавлять доп. cвойство под версию, а решил закодировать его в обычном целочисленном значении.
Допустим есть число 166777061. Я предполагаю, что в нем сколько-то битов будет храниться под идентификатор, а сколько-то под версию
public const int IndexBits = 24;
public const int VersionBits = 8;
Таким образом, 166777061 это 9-я версия 101-й сущности. Вот именно 101 - компактный индекс и должен храниться в sparse[]. Тк если хранить в тупую EntityId, то sparse сразу разрастается до огромных значений.
Проблема 3 – смешение индексов
Суть проблемы я снял в коротком видео здесь https://t.me/homemadegamedev/16?single&t=0
Разберем на примере перемещения 2 агентов. Есть система перемещения агентов, она в цикле проходит все сущности агентов, у которых есть тег, что они перемещаются.
И в этой системе есть кусок, который меняет позицию агентов
Entity agentEntity = new Entity(movingAgents[i]);
int movingAgentIndex = movementComponentStore.IndexOf(movingAgents[i]);
здесь я из стора, в котором хранится перемещение получил индекс агента
Затем делаю так
modelsPositionRefAccessor.CellIndexRef(movingAgentIndex) = nextWaypoint;
modelsPositionRefAccessor.X(movingAgentIndex) = nextPos.x;
modelsPositionRefAccessor.Y(movingAgentIndex) = nextPos.y;
modelsPositionRefAccessor.Z(movingAgentIndex) = nextPos.z;
тут я уже в другом сторе обновляю данные по ref, а индекс беру из первого стора. Пока агент один - все прекрасно, но, если их несколько - получается рассинхрон, т.к когда один из агентов завершает перемещение, с него снимается компонент движения. Затем он получает новую задачу на перемещение в новую точку - перемещение вешается еще раз, но он уже в сторе movementComponentStore будет в другой позиции. В частном случае, когда агентов 2, они просто меняются местами.
Мораль
Всегда проверять, что работаешь с нужными индексами
Писать минимальные проверки в режиме дебага.
Проблема 4. Интеграция с игровым слоем
Это, наверно, самое простое, но все же стоит сказать. В моем проекте я разделил код на 2 уровня:
Движок. Тут лежат общие механики
Игра. Тут специфичные для конкретного игрового проекта механики
В этой логике ECS – эту аббревиатуру игра вообще не должна знать, ей должно быть все равно как хранятся данные по агентам в движке. Пришлось вычистить из игрового слоя специфичные интерфейсы движка, отказаться от соблазна прокинуть какой-либо стор напрямую на сторону игры
В такой логике мне пришлось на стороне движка реализовать публичный слой, который может (и должна) использовать игра, или любой другой клиентский сервис. Например, вот так.
namespace Engine.NPC.PublicFacade
{
public interface IAgentCreateService
{
public void Create(AgentMetadataDTO agent);
}
public class AgentCreateService : IAgentCreateService
{
private readonly IAgentMetaRegService agentMetaRegService;
private readonly EntityIdStore entityIdStore;
public AgentCreateService(IAgentMetaRegService agentMetaRegService, EntityIdStore entityIdStore)
{
this.agentMetaRegService = agentMetaRegService;
this.entityIdStore = entityIdStore;
}
public void Create(AgentMetadataDTO agent)
{
if(entityIdStore.TryResolve(agent.AgentId, out Entity agentEntity))
//Entity agentEntity = new Entity(agent.EntityId);
agentMetaRegService.Register(agentEntity, agent.Gender, agent.AgeGroup);
}
}
public class AgentSpawnService : IAgentSpawnService
{
private readonly Service.IAgentSpawnService spawnService;
public AgentSpawnService(Service.IAgentSpawnService spawnService)
{
this.spawnService = spawnService;
}
public void Spawn(AgentInfoDTO agentInfo, Vector3 pos)
{
Entity entity = new Entity(agentInfo.EntityId);
spawnService.Spawn(entity, pos);
}
}
}
И так далее для любых публичных сервисов
Например, для получения агента (я же не буду выдавать наружу стор), реализовал вот такие методы
public AgentInfoDTO? GetAgent(Guid id)
{
if (!em.TryGetEntity(id, out var e)) return null;
int cellIndex = modelPositionReader.CellIndex(e);
return new AgentInfoDTO
{
Id = id,
EntityId = e.Id,
EntityNumber = e.Index,
EntityVersion = e.Version,
CellIndex = cellIndex
};
}
public IReadOnlyList<AgentInfoDTO> GetAgents(int skip = 0, int take = 256)
{
var agentEntities = agents.Entities();
var end = Math.Min(agentEntities.Length, skip + Math.Max(take, 0));
if (skip >= end) return Array.Empty<AgentInfoDTO>();
var list = new List<AgentInfoDTO>(end - skip);
for (int i = skip; i < end; i++)
{
Entity agentEntity = new Entity(agentEntities[i]);
Guid guid = em.GuidOf(agentEntity);
int cellIndex = modelPositionReader.CellIndex(agentEntity);
list.Add(new AgentInfoDTO
{
Id = guid,
EntityId = agentEntity.Id,
EntityNumber = agentEntity.Index,
EntityVersion = agentEntity.Version,
CellIndex = cellIndex
});
}
return list;
}
Т.е. публичная часть движка предоставляет публичные DTO и методы, с которыми уже работает игра:
AgentMetadataDTO metadataDTO = new AgentMetadataDTO(agent.Id, profile.Gender, ageGroupConfig.GetGroup(profile.Age), typeId);
agentCreateService.Create(metadataDTO);
VisualInfoDTO visualDTO = new VisualInfoDTO(agent.Id, profile.ModelVisualIndex);
visualRegisterService.Register(visualDTO);
тут я в фабрике, которая создает учеников создаю DTO, и отправляю их в движок.
А что по производительности?
Сделал спавн 1000 NPC с HTN-задачами на перемещение в рандомную точку у каждого агента

В профайлере видно, что в целом FPS держится на уровне близком к 30-40 с некоторыми пиками просадок (еще есть потенциал для оптимизации)
Ранее у меня все фризилось до 10-15 fps уже на 64 агентах, сейчас вполне терпимо на 1000. Важное уточнение - суть оптимизации лежала не в архитектуре ООП-ECS, а в кривых алгоритмах.
Ради интереса собрал релизный билд, чтобы протестить производительность. Вот картина на 5к агентах


Выводы
Ключевые оптимизационные проблемы лежат не в плоскости архитектуры ECS-ООП, а в алгоритмах и кэшах. Просто с ECS эти кэши проще делать. Условно говоря нам надо закэшировать состояния задач или действий, которые выполняют агенты. И, по сути, все равно надо делать набор массивов, в котором будут лежать "горячие данные". А с ECS этот массив уже есть. При необходимости его можно просто скопировать.
Чистый выигрыш в производительности именно за счет ускорения вычислений из-за архитектуры на мой субъективный взгляд составил где-то 30%
С переходом на ECS, в особенности если следовать концепции SoA - набор массивов, то сложность отладки возрастает многократно. Приходится делать какие-то обертки чисто для дебага, чтобы не умереть
Если движущихся агентов мало (меньше 500 условно), то профит от ECS не стоит геморроя в дизайне архитектуры и еще большего геморроя в отладке
Параллельные вычисления, использование Burst, или самописных Thread напрашивается само собой, а это уже, мягко говоря, существенный прирост к производительности
Идейно сложно для первого раза. Я потратил суммарно где-то часов 30-40 на то, чтобы просто понять как именно надо реализовать ECS-подход в игре. Т.е. суть сразу понятна, но вот конкретные шаги, как перевести ту или иную часть - это сложно.