Homemade Gamedev
4 subscribers
8 videos
3 files
Пишу про разработку своей игры с нуля
Download Telegram
Неделю назад я уперся в производительность. В качестве теста спавнил NPC на карте и смотрел, как они между собой взаимодействуют и чисто случайно натыкал их около 100 и увидел, что картинка заметно лагает. И это при том, что я отказался от GameObject и все сделал на DMI + VAT и все обновляю в логических тиках 10 раз в секунду. Я думал, что это решит все проблемы, но оказалось, что система принятия решений в тиках оперирует жирными контекстами, плюс сами данные раскиданы по разным классам.
В общем я решил перевести сервис NPC на ECS. Изначально я думал, что уже придерживаюсь этих принципов, используя структуры вместо классов
Ну т.е. вместо class AgentData я писал struct AgentData (состав полей один и тот же) и наивно полагал, что это и есть ECS. А ну и данные хранил не в словарях типа Dict<guid, T> а в массивах таких вот структур. И да, я попытался сделать синхронизированные хранилища. Т.е. при создании агента емкость добавлялась во все хранилища. Но проблема была в том, что у меня была одна точка входа, где они все были собраны и я все равно всегда запрашивал кучу лишних данных (и еще не эффективно).

Оказалось (внезапно), что я ошибался про ECS-подход, и мне надо переписать хранение для параметров NPC (я их называю агенты) - а это около 2-3к строчек кода. Плюс для понимания переход довольно болезненный, тк я привык мыслить в объектно-ориентированном ключе и чувствую, что мой мозг сопротивляется.

Хотя уже сейчас, когда прослеживается финал этого рефакторинга, я начал понимать, что это того стоит. Осталось только потом проверить метрики производительности. Интересно, что будет, если она не изменится?😂
Сегодня сидел и думал, как хранить намерения у агентов.
История такая. Есть NPC (агенты) и я бы хотел, чтобы они у меня совершали разные действия. В общем случае, действия могут выполняться параллельно - агент может стоять и разговаривать (например).
Я в итоге представил агента как фабрику с различными линиями сборки (слотами). На слот я могу подать очередь намерений, но в каждый момент времени активна будет только одна задача из этой очереди в слоте.
И в тиках мне надо обновлять статусы активных задач. В ООП-модели я оперировал просто огромным контекстом. Получал вначале агента, проходился по всем его задачам, вычленял те из них, что находятся в статусе Active и дальше их уже обрабатывал. Это прям серьезные накладные расходы.

К чему в итоге пришел. Не знаю, как до этого не догадался с самого начала, сейчас это кажется супер-очевидной идеей.
Итак, у агента N слотов, в каждом слоте может быть одна и только одна активная задача в каждый момент времени. Другими словами, слот может либо быть свободен, либо занят. Т.е. 1 - если слот занят, 0 - если свободен. Идеально подходит под битовую запись, а номер слота буду определять прямо из перечисления
    public enum ActionSlots : byte
{
Legs,
Social,
SocialRequests,
Mouth,
Hands,
Pee,
Ass,
Mind
}

Например, 11000000 означает, что у агента занят слот Legs и Social, а остальные - свободны.
Данные храню просто в массиве по агентам.
private ulong[] mask;

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

Так вот, смотрю в прошлый код и вижу попытки оптимизации и использования Burst на IJobParallelFor
Условно говоря я проходил в цикле каждый кадр (каждый Update) каждого (!!!) агента и создавал такую структуру
var input = new VisualLerpInput
{
From = visual.From,
To = visual.To,
Speed = movement.Speed,
Progress = visual.GetProgress()
};

где visual.From и visual.To - это ячейки откуда и куда перемещается агент, затем скорость и прогресс интерполяции.
Затем после цикла создавал джобу
[BurstCompile]
public struct VisualLerpJob : IJobParallelFor {
[ReadOnly] public NativeArray<VisualLerpInput> Inputs;
public NativeArray<Vector3> OutputPositions;
public NativeArray<float> OutputProgress;
public float DeltaTime;

public void Execute(int index) {
var input = Inputs[index];
float shift = math.clamp(input.Speed * DeltaTime, 0f, 1f);
float newProgress = math.saturate(input.Progress + shift);
float3 pos = math.lerp(input.From, input.To, newProgress);

OutputPositions[index] = pos;
OutputProgress[index] = newProgress;
}
}

тут я вычислял дельта смещение для каждого индекса агента, затем в цикле обновлял данные в визуале

Косяков тут несколько
1. Первый - это как и раньше - работа с жирными контекстами
ref var agent = ref agentController.Get(i);
var context = actionExecutionContextProvider.GetByIndex(i);
var movement = context.MovementContext;
var visual = context.VisualContext;

Вот это вот вызывалось каждый кадр - это просто смерть для производительности

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

Понятно, что все эти проблемы решаются и без ECS, просто с ECS первая решается просто по умолчанию - запрашиваешь из конкретного стора данные из нескольких массивов одинаковой размерности
А вторая, в целом, тоже. Просто я итерируюсь не по всем агентам, а только по тем, у которых есть компонент движения (технически я его присваиваю все равно всем, чтобы не дрочить каждый раз Attach-Detach при старте и завершении перемещения, просто значения ставлю в -1, если агент не двигается, это дешевая проверка.
This media is not supported in your browser
VIEW IN TELEGRAM
Штош, примерно такой результат получился. Следующая на очереди на переписывание система анимации и поворота - там, конечно, предвидится небольшой геморр с очередями.

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

Одно радует, что переписывать надо в основном только хранение, сама логика не особо меняется. Ну типа если весь сервис NPC я пишу с мая в свободное время, то переезд на ECS будет, очевидно, быстрее, но он уже идет 3 недели (из которых дней 15 я просто пытался понять, что и как делать в принципе) и продлится, скорее всего еще 2-3. А это минус полтора месяца.

А если бы я заметил проблему сразу, то пришлось бы переписывать только механику перемещения, а это не так много.
This media is not supported in your browser
VIEW IN TELEGRAM
Как я и ожидал, с анимацией и поворотом вышел геморрой
В целом анимация переехала и это было очень быстро, но есть нюанс - агент стартует сразу же с первого кадра, как получает задачу на перемещение, а завершает анимацию как только попал в целевую ячейку.

Чтобы это пофиксить, пришлось залезть туда, куда я изначально не планировал залазить - в систему постановки задач и там тоже кое-что почикать, это оказалось сложнее, чем я думал.
Условно говоря, задача - управляющий мозг - контроллер над действиями. Это самостоятельный класс с контрактом, который используется в игре. Но для ее создания мне надо точно знать числовой идентификатор сущности агента, на которого вешается задача. Эта информация хранится в ECS-сторах и перспектива вытаскивать ссылки на сторы в игровую реализацию меня не радовала - я хочу, чтобы движок предоставлял удобные сервисы для работы над объектами, которыми он оперирует, а игра просто брала бы нужные структуры данных и ничего не знала бы про саму организацию хранения данных в движке.
Казалось бы, столько геморра ради одного единственного параметра в конструкторе, но что поделать.

В итоге мне пришлось раньше времени делать публичный фасад для сервиса NPC
Публичный фасад оперирует очень простыми структурами типа вот таких
public readonly record struct AgentInfo(
Guid Id,
int EntityId,
int CellIndex
);


И есть публичный интерфейс сервиса
public interface IAgentsReadService
{
IReadOnlyList<AgentInfo> GetAgents(int skip = 0, int take = 256);
AgentInfo? GetAgent(Guid id);
}

Контракт (публичный интерфейс) прокидывается на сторону игры и игра для получения списка агентов юзает этот сервис. А через DTO получает целочисленный идентификатор агента.

Посмотрим, что из этого выйдет, еще не закончено, но по идее это должно сработать

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

Эта механика уже заложена в саму архитектуру задач - я ввел там такое понятие как критерии приёмки, и задачу перемещения завершаю как раз по нахождению в окрестности, а не по ячейкам грида.

В этой архитектуре есть такое понятие как команды на завершение - т.е. сама задача напрямую не владеет действиями, которые совершает агент, она ими руководит через команды (это аналог того, что когда вам кто-то ставит задачу, он не передвигает непосредственно ваши ноги или руки, а пишет что-то текстом, или говорит голосом). Тут примерно так же
Задача кидает команду
actionExecutionController.Stop(new ActionHandle(Id), index);

(Это так выглядит в текущей ООП-шной архитектуре)
А дальше уже есть тик-система, которая такие команды обрабатывает. В ECS мне надо только, чтобы задача передала в контроллер нужный целочисленный идентификатор сущности агента. Для этого и затевался весь описанный в начале треш
Доделал обработку команд. Оказалось не особо сложным. Пришлось отрефакторить старую систему обработки и разбить класс на несколько более мелких, но так стало даже понятнее.
Вначале получилось так, что агент как бы телепортируется в точку назначения. Причина была в том, что движение (интерполяция) реализована с запаздыванием
Есть данные физической модели, а есть данные визуала. Они разделены, тк карту можно вращать и координаты визуала будут меняться, а модели - нет.
Так вот, визульное отображение агента начинает движение от клетки А к клетке B в тот момент, когда модель (данные) телепортировалась в клетку B. Это и есть запаздывание между данными и отображением.

С другой стороны, условие прерывания на задаче стояло на совпадение клетки агента и целевой клетки. Т.е как только моделька попадала в целевую клетку, задача считала, что движение завершено => посылала команду на завершение действие => действие завершалось => агент останавливался (анимация останавливалась), но позиции визуала и модели еще были рассинхронизированы на 1 клетку.
Отсюда и возникает телепортация

Пришлось поправить критерий завершения задачи перемещения. Решил поставить совпадение не модельки, а именно визуала. Получилось лучше (2 ролик), теперь нету телепортации и визуал останавливается в точности тогда, когда нужно.

Осталось еще доделать обработку завершения действия - снимать его с агента, чистить очереди, слоты действий, снимать компоненты и удалить саму сущность, чтобы не было мусора

Потом можно переходить к поворотам визуала при перемещении
Что я еще понял при переходе на ECS
Например, есть компонент, который хранит координаты визуала (позицию и поворот)
private float[] x;
private float[] y;
private float[] z;
private float4[] rot;

и он используется в разных контекстах
1. Подготовка данных для рендеринга
2. Интерполяция
3. Сервис спавна
и так далее
Так вот, у меня был соблазн сделать для компонента фасад, который бы инкапсулировал все публичные методы для работы с данными этого компонента, но это частично свело бы на нет саму суть ECS
в итоге пришел к тому, что сам стор должен реализовывать 3 интерфейса
1. ComponentReader
public interface IVisualPositionReader
{
public float3 GetPos(Entity e);
public float4 Rot(Entity e);
}

2. ComponentWriter
public interface IVisualPositionWriter
{
public void SetPosition(int e, in float3 pos);
public void SetRotation(int e, in quaternion q);
}

3. RefAccessor
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);
}

и в конечные системы прокидываются именно эти интерфейсы
Тогда получается, что разделяется ответственность по чтению из стора и запись в стор
А небезопасный ref-доступ закрывается для всего, что не входит в сам контур ECS-движка (internal)
#ECS
Еще одна идея, которая появилась на набитых шишках
Суть такая, есть агент (NPC), он совершает какие-либо действия
В простейшем примере - перемещается.
У меня есть 2 сущности в ECS:
1. Для агентов
2. Для намерений
Они все создаются через единый EntityManager (да, я не использую встроенный фреймворк Unity. На вопрос "почему" мне пока сложно ответить, смутно есть 2 причины - я хочу сам разобраться, как это работает + мне хочется, чтобы логика ядра не зависела от игрового движка. И еще мне хочется делать гибрид, не хочу сразу +100 к сложности разработки получить, хочу постепенно усложнять логику по мере необходимости)

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

Что пришло в голову
Идея создавать какие-то доп. свойства для хранения версий мне не оч понравилась, и я вспомнил, что как-то на работе проверял в инспекторе хрома какое-то веб-приложение и увидел там какую-то странную версию записей типа 166777061. Причем я помню, что первая версия была нормальной, типа 1, а следующая уже 166 и дальше какой-то набор цифр. Тогда я нихера не понял зачем это надо, подумал, что разрабы дебилы и не смогли норм версионность сделать.

Сейчас я взглянул на это по-другому
Можно же в обычном целочисленном значении закодировать и айдишник и версию
Допустим есть число 166777061
я предполагаю, что в нем сколько-то битов будет храниться под именно идентификатор, а сколько-то под версию
Я сделал такую разбивку
public const int IndexBits = 24;
public const int VersionBits = 8;

Таким образом 166777061 это 9-я версия 101-й сущности. Вот это 101 - это компактный индекс, который и должен храниться в sparse[]. Тк если хранить в тупую EntityId, то sparse сразу разрастается до огромных значений.

И надо еще ввести EntityPool, который будет хранить занятые идентификаторы и отвечать, какие версии для каких сущностей сейчас активны

Как теперь выглядит перемещение агента
Теперь агент создает только одну сущность для намерения с одним целочисленным идентификатором, а каждый раз при перемещении в новую точку он будет создавать новую версию этой сущности
Таким образом размерность sparse[] массива всегда равна количеству одновременно живущих сущностей в данный момент времени (как и dense, но они хранят разные данные)

Что будет, когда агент завершит 255-е перемещение и начнет 256-е?
Порядковый номер версии начнется опять с нуля
Я предполагаю, что это займет достаточно много времени и у меня не останется нигде ссылок на старую первую версию, так что я не получу невалидную ссылку. Даже такие короткоживущие сущности, как намерения обрабатываются довольно быстро, их не надо хранить в долгосроке, и на практике достаточно запаса в 30 сек для любой пост-обработки этих сущностей. 30 сек это примерно 10-20 версий. Так что запас есть

Какие тут минусы
Раньше, зная entityId я мог сделать просто Entity e = new Entity(entityId) и получить сущность.
Теперь так делать нельзя, тк массивы хранят компактный индекс, а entityId еще надо восстановить
Теперь надо вначале получить версию из EntityPool - у него должен быть метод, который по компактному индексу вернет актуальную версию сущности
А затем уже восстановить entityId. Это делается так
public const int IndexMask = (1 << IndexBits) - 1;
public const int VersionMask = (1 << VersionBits) - 1;

public static Entity Make(int index, int version)
=> new((version & VersionMask) << IndexBits | (index & IndexMask));

т.е добавляется 2 вызова вместо простого конструктора

Пока не могу сказать, насколько сильно это влияет на производительность, тк не закончил полный переезд на эту архитектуру, в конце уже сделаю тест на производительность и посмотрю, что там показывается в профайлере
Media is too big
VIEW IN TELEGRAM
Вот так теперь выглядит перемещение
Перевел анимацию и поворот на ECS.
Сама идея тут довольно простая, но дрочево возникло с версиями сущностей - об этом написал выше.
Теперь на ECS вся базовая логика по работе с агентами. Осталась еще довольно большая фича по диалогу между NPC, потом можно двигаться дальше

В чем была тут суть рефакторинга
Раньше я вычислял поворот так
var visualContext = actionExecutionContextProvider.GetByIndex(index).VisualContext;
Vector3 dir = visualContext.CurrentTickPosition - visualContext.PreviousTickPosition;

Для этого я для каждого агента хранил дополнительно 2 параметра - положение в текущем тике и предыдущем (зачем в предыдущем - хз, тк по идее агент должен смотреть на целевую точку, куда он идет)
Но это не просто хранение, это еще и обновление этих параметров -> плюс к накладным расходам

Как это выглядит сейчас
Есть компонент, который хранит позицию и поворот
private float[] x;
private float[] y;
private float[] z;
private float4[] rot;

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

Vector3 target = coordinateManager.CellToWorld(GridTopology.ToVector3Int(modelPositionReader.CellIndex(agentE)));
Vector3 curr = visualPositionReader.GetPos(agentE);
Vector3 dir = target - curr;

здесь CellToWorld - вспомогательная функция, которая переводит Vector3Int ячейку в Vector3
ToVector3Int - вспомогательная функция, которая переводит индекс ячейки грида в Vector3Int (да, грид должен хранится в целочисленных индексах для возможности оптимальных параллельных вычислений и производительности)
#ECS
Несколько дней потратил на ебаторию межгаллактического масштаба (как всегда с тупыми багами, правятся они в 2 строчки, а времени тратишь на их локализацию в 1000 раз больше, чем на исправление)

На первом ролике показал как выглядело перемещение агентов (да, анимация до конца еще не поправлена)
Тут есть сразу 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, они просто меняются местами.

Проблема Б имеет похожую суть
В системе интерполяции я получаю ячейки, между которыми осуществляю интерполяцию визуала
if (!movements.TryGet(e, out int d)) continue;
int prevCell = movements.PrevCell(d);
int currCell = movements.CurrentCell(d);
float speed = movements.Speed(d);

здесь d - индекс в сторе перемещений

а затем создавал джобу для Burst
From = from,
To = to,
Speed = speed,
Progress = lerpsRef.Interp(d)

здесь я из стора, хранящего прогресс интерполяции получаю значение по индексу из стора, хранящего данные для перемещения

исправление супер-быстрое - надо вначале было получить индекс в конкретном сторе типа int d_lerp = lerpsRef.IndexOf(agentE);

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

Не знаю, можно ли вообще избавиться от подобного рода ошибок, в ECS вообще отладка сложнее
Media is too big
VIEW IN TELEGRAM
#ECS
Итак, вот первый результат оптимизации с переходом на ECS.
Сделал спавн 1000 NPC с HTN-задачами на перемещение в рандомную точку у каждого агента

В профайлере видно, что в целом FPS держится на уровне близком к 30-40 с некоторыми пиками просадок (еще есть потенциал для оптимизации)

Ранее у меня все фризилось до 10-15 fps уже на 64 агентах, сейчас вполне терпимо на 1000.
Плюс есть фриз-места чисто в дебаге из-за того, что я вынужден был сделать разные проверки вида
#if debug


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

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

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

4. Если движущихся агентов мало (меньше 500 условно), то профит от ECS не стоит геморроя в дизайне архитектуры и еще большего геморроя в отладке
А и еще один момент, вся фишка в том, что в таком подходе, когда компоненты хранят чистые (не зависящие от Unity) данные, то супер-логичное решение - использовать параллельные вычисления
так вот, вынес я из нескольких тяжелых систем математику в Burst:
1. Для системы поворота вынес в Job расчет поворота модельки
2. Для перемещения вынес в Job прогресс (по аналогии как это сделано для системы интерполяции). В интерполяции мне важна плавность, там обновления каждый кадр, поэтому я там сразу додумался сделать расчет через Burst, а тут логические тики 10 раз в секунду и чет я не сразу до этого допер
Дальше по идее это можно расширять и параллелить поведенческую систему, которая "тикает" статусы задач

Ради интереса собрал релизный билд, чтобы протестить производительность
Вот картина на 5к агентах

Да, есть подлагивания при перемещении карты, но это вполне играбильно будет.
Никаких обновлений пока нет, я оказался очень "везучим".
В начале или середине октября занимался с так называемым "тренером" и усугубил свою межпозвоночную грыжу.
А 3 дня назад просто чихнул пару раз и она лопнула. Сейчас в больничке на реабилитации. Возможно, в течение месяца надо будет сделать лапароскопическую операцию по удалению остатков хряща из спинномозгового канала, тк они давят на корешок нерва.
Ноутбук в палату я взял, но лежать на спине пока не могу, поэтому проект +- дней 10 на паузе. Надеюсь вернуться в строй к концу след недели, но там будет уходить время на неврологов и нейрохирургов + продолжение реабилитации. Может удастся хотя бы 1-2 часа перед сном покодить
Вот, что значит "развалиться с одного чиха" 😂
Хотя сама ситуация мягко говоря неприятная, но обстоятельство, конечно..... Мне даже смешно 😂
Please open Telegram to view this post
VIEW IN TELEGRAM
image_2025-12-10_23-57-12.png
187.8 KB
Такс, я понемногу возвращаюсь к проекту
В последний раз у меня был затык с тем, что у меня лагала система, которая выдает маршрут для агентов
Идея была в чем, я спавнил сразу 1000 NPC, у них одна задача - бродить по карте, соответственно, они получают рандомную точку и запрашивают сервис маршрутизации "Дай мне маршрут от моей позиции до желаемой точки"
И так для каждого агента.

В профайлере система тратила около 15-16мс для примерно 200 агентов. Мне пока сложно сказать много это или мало, просто факт.

Я увидел там поле для оптимизации. У меня уже сервис маршрутизации поддерживает как синхронные, так и асинхронные запросы. Асинхронные могут выполняться пачкой, т.е. я могу в течение тика класть в очередь запрос на маршрут, а потом в конце тика отправить всю пачку рассчитываться. Расчет идет на самописных Thread, тк все остальное блокирует основной поток. Предполагается, что это используется для расчета тяжелых "длительных" маршрутов.

Для коротких маршрутов мне не нужна асинхронщина, нужен результат здесь и сейчас. Но я сразу поленился делать норм архитектуру и сделал так, что сервис может принимать только один запрос.

Идея оптимизации заключается в том, чтобы также рассчитывать сразу пачку маршрутов. Следующая идея была в том, чтобы заюзать для этого Burst.

Плюсы
Это быстро, реально быстро. Сейчас система обработки очереди запросов обрабатывает те же 200-250 запросов за какие-то 3.2 мс, что как минимум в 5 раза быстрее, чем было

Минусы
1. Пришлось переписать ядро А*, чтобы оно было совместимо с Burst
2. Пришлось задублировать логику А* для Burst-джобы.
3. Я не уверен, что разница между Burst и моими Thread настолько существенна, чтобы пожертвовать архитектурой и сделать дублирующую логику А* чисто для Burst.

Пока я сделал это немного на коленке, надо еще избавиться от излишнего GC, сейчас из-за него просадки (см зеленые пики на скрине). При этом логика (синяя область) реально стала занимать меньше времени в тике