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

В этой статье я попытаюсь описать сложности, с которыми я столкнулся и свои впечатления от итога.

Это еще одна статья из цикла про разработку игр без прикладного опыта. Если вам интересна эта и подобные темы - подписывайтесь на мой ТГ-канал Homemade Gamedev, где посты выходят чаще, и я пишу про текущие задачи в проекте.

Введение

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

Действия и их связь с NPC
Действия и их связь с NPC

В игре симуляция устроена через логические тики, которые модулирует менеджер тиков (TickableManager). В нем регистрируются тик-системы, такие как:

  1. BehaviourSystem - поведенческая тик-система

  2. ActionRequestHandlerSystem - тик-система обработки запросов на действия

  3. И многие другие

Поведенческая система в своем тике может создавать задачи. Например - задача на перемещение, сидение, разговор и так далее. Задача непосредственно никак не влияет на агента и его существование, у задачи есть свой жизненный цикл, одним из этапов которого является отправка запроса на действие. Действие - это уже непосредственно работа, выполняемая агентом. Задача создает действие через запрос (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 вещи:

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

    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);
    }

    Можно было, конечно, ввести какой-то класс для хранения общих данных агентов и немного проредить данные, которыми оперируют контексты действий.

  2. У меня не было выделено сервисов для работы с данными. Внутри самих этих контекстов были еще какие-то методы, отличные от простого CRUD. Ответственность за работу над данными размывалась по разным классам.

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

Рефакторинг

В общем, когда я продумывал рефакторинг (изначально, не меняя архитектуру модуля), я наткнулся на статью про ECS-подход и мне очень зашла концепция хранения данных отдельно от поведения. Я решил попробовать реализовать его в своем прототипе игры. Скажу сразу, я думал, что уже процентов на 70 у меня ECS, потому что:

  1. Я использовал структуры вместо классов. Ну т.е. я просто писал struct вместо class (состав полей тот же самый, методы – те же) и думал, что это уже часть ECS

  2. Я хранил данные в массивах. Вместо стандартных словарей, я хранил данные по агентам в массивах таких структур.

  3. Я ввел индексное хранилище. Когда создается агент, я присваивал ему индекс и во всех репозиториях, которые хранят массивы структур делал Resize. Соответственно, у этого агента был одинаковый индекс во всех репах.

Очевидно же, что надо только слегка ещё дотюнить код - и будет чистый ECS! Так я думал в середине октября.

Естественно, я был неприятно удивлен, когда понял, что конкретно у меня не так и увидел масштаб рефакторинга. Скажу сразу, что на ECS я решил перевести только лишь модуль NPC, все остальные модули у меня остаются ООП-шными.

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

Шаг 1. Определить сущности

Самый простой шаг. В моем прототипе игры в модуле NPC я оперирую четырьмя сущностями:

  1. Агенты

  2. Задачи

  3. Действия

  4. Запросы. Самое спорное, тк короткоживущие объекты, но за компанию так же переехали на ECS.

Для сущностей нужен единый механизм создания и какой-то реестр идентификаторов.
Я создал довольно простой EntityManager и EntityPool

Шаг 2. Определить компоненты и данные, которые в них будут храниться

Во-первых, у меня сразу вскрылась история с тем, что у меня было еще такое понятие как метаданные, которые хранили пол, возрастную группу, тип агента (ученик-учитель, уборщик и т.д.).

Итого, мне нужны были компоненты, в которых бы я хранил:

  • Тип агента

  • Пол

  • Возрастную группу

  • Возраст в тиках

  • Логическую позицию

  • Визуальную позицию

Это общие данные агентов, без учета данных, которые у них появляются в контексте действий.

Действия я решил хранить так:

  1. Есть базовый компонент, хранящий общие свойства:

    1. Тип

    2. Слот у агента, в котором выполняется действие

    3. Приоритет

    4. Статус

    5. ID запроса

  2. Есть специфичные данные, которые отличаются от действия к действию, они хранятся в своих сторах, например, для перемещения они у меня такие:

    1. Целевая точка

    2. Тип перемещения (ходьба, бег)  

В итоге у меня вырисовывалась такая структура:

  • Действие (например, 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:

  1. Агенты

  2. Действия

Все эти сущности создаются через единый 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, они просто меняются местами.

Мораль

  1. Всегда проверять, что работаешь с нужными индексами

  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к агентах

Выводы

  1. Ключевые оптимизационные проблемы лежат не в плоскости архитектуры ECS-ООП, а в алгоритмах и кэшах. Просто с ECS эти кэши проще делать. Условно говоря нам надо закэшировать состояния задач или действий, которые выполняют агенты. И, по сути, все равно надо делать набор массивов, в котором будут лежать "горячие данные". А с ECS этот массив уже есть. При необходимости его можно просто скопировать.

  2. Чистый выигрыш в производительности именно за счет ускорения вычислений из-за архитектуры на мой субъективный взгляд составил где-то 30%

  3. С переходом на ECS, в особенности если следовать концепции SoA - набор массивов, то сложность отладки возрастает многократно. Приходится делать какие-то обертки чисто для дебага, чтобы не умереть

  4. Если движущихся агентов мало (меньше 500 условно), то профит от ECS не стоит геморроя в дизайне архитектуры и еще большего геморроя в отладке

  5. Параллельные вычисления, использование Burst, или самописных Thread напрашивается само собой, а это уже, мягко говоря, существенный прирост к производительности

  6. Идейно сложно для первого раза. Я потратил суммарно где-то часов 30-40 на то, чтобы просто понять как именно надо реализовать ECS-подход в игре. Т.е. суть сразу понятна, но вот конкретные шаги, как перевести ту или иную часть - это сложно.

Комментарии (0)