Проверяя одну из своих механик, я спавнил последовательно 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-подход в игре. Т.е. суть сразу понятна, но вот конкретные шаги, как перевести ту или иную часть - это сложно.