Homemade Gamedev
7 subscribers
8 videos
3 files
Пишу про разработку своей игры с нуля
Здесь — заметки про код, архитектуру и путь от идеи к работающим системам.
Изначально прикладного опыта нет, помогает мне пока что только ChatGPT и такая-то матерь
Download Telegram
Доделал обработку команд. Оказалось не особо сложным. Пришлось отрефакторить старую систему обработки и разбить класс на несколько более мелких, но так стало даже понятнее.
Вначале получилось так, что агент как бы телепортируется в точку назначения. Причина была в том, что движение (интерполяция) реализована с запаздыванием
Есть данные физической модели, а есть данные визуала. Они разделены, тк карту можно вращать и координаты визуала будут меняться, а модели - нет.
Так вот, визульное отображение агента начинает движение от клетки А к клетке 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, сейчас из-за него просадки (см зеленые пики на скрине). При этом логика (синяя область) реально стала занимать меньше времени в тике