SamuKata
teedeezet
teedeezet

boosty


Новая отрисовка спрайтов

Привет всем спонсорам! 
  
Я уже несколько раз упоминал систему Static Sprites ("новая отрисовка"), которую активно продолжаю делать для Grim Wild. В этом посте я бы хотел подробнее описать то, зачем она мне понадобилась и что сможет дать.
  
Изначально этот текст был частью поста "Результаты работы: апрель 2024", но я решил выделить его в отдельную тему и добавить для интересующихся людей побольше деталей и объяснений.
  
На данный момент система всё ещё находится в разработке. Мне надо сделать заделы на будущие механики, исправить оставшиеся баги и провести жёсткое тестирование, чтобы быть полностью уверенным в том, что работа сделана успешно.
  
Заметки:
• Здесь я объясняю работу всей системы в целом. Всё, что до сих пор находится в разработке или в планах, обязательно помечается, чтобы не вводить вас в заблуждение.
• Пост ориентирован на обычных читателей, а не на опытных разработчиков. Я специально упрощаю термины и не углубляюсь в ненужные детали. Если они вам нужны, то вот ссылка на
документацию движка.
• Этот пост станет основой для сценария восьмой (не 7) серии разработки, которая как раз будет посвящена оптимизации. Можно сказать, что этот текст - черновик сценария, который спонсоры получают за несколько месяцев до выхода ролика 😀   
 
Изначальная проблема
 
Unreal Engine - это 3D движок, и в реализации GW меня это полностью устраивает. Его возможности сделаны гибкими и эффективными, и я не перестаю удивляться тому, что на их основе можно построить.
Но есть у него одна особенность, которая все эти годы меня преследовала, и из-за которой мне приходилось делать много лишней работы.
 
За 2D отрисовку (которая просто рисует плоскости внутри 3D мира) в движке отвечает отдельный плагин, который называется Paper2D. Он хоть и имеет свои достоинства, но для нужд Grim Wild совершенно не подходит. Поэтому, когда в 2023 году проект стал "игрой мечты", которая позиционируется как высококачественный продукт, игнорировать создаваемые плагином проблемы оказалось нельзя.
 
Главная из них - это тормоза при высоком количестве рисуемых на сцене спрайтов. Спрайт, говоря по-простому, это плоскость в пространстве, на которую накладывается текстура (картинка).
Paper2D отлично справляется с неподвижными спрайтами. Вся загвоздка как раз в том, что в Grim Wild практически каждый объект может двигаться и анимироваться. И вот с этим у плагина всё очень туго.
 
Итогом долгих поисков решения стало создание своей собственной отрисовки на основе Paper2D. Я назвал её Static Sprites, потому что вместо создания отдельных 3D моделей* для каждого отдельно взятого спрайта (как это делает P2D), мои объекты ссылались на одни и те же буферы с данными.
 
*Я называю геометрию "3D моделями", чтобы читателям было проще представлять, что же это такое. По сути, геометрия - это набор вершин, из которых строятся треугольники (полигоны)
 
По мере реализации Static Sprites, список отклонений от плагина постепенно увеличивался. Сейчас от P2D там практически ничего не осталось, а количество фишек настолько большое, что я называю всю систему "новой отрисовкой". О ней сегодня и пойдёт речь. 

Цели и задачи

Главная цель всей системы - значительно увеличить производительность отрисовки.
  
Причины лагов в Paper2D лежат на поверхности: 
 
• Создание отдельных "3D моделей" (Vertex Buffer, Index Buffer, Vertex Factory) для каждого спрайта на сцене. Да, если вы поместите на сцену 100 картофелин, они движком будут считаться разными "3D моделями", хотя по факту являются абсолютно одинаковыми плоскостями с абсолютно одинаковыми текстурами.
 
• Вычисление информации для отрисовки каждый кадр (Dynamic Relevance). Если брать наш пример с сотней картофелин, то мы каждый кадр обращаемся к каждой из них, требуем с нуля вычислить полигоны, которые сейчас должны рисоваться (FMeshBatch), а ещё берём материал (шейдер). Опять же, наши картофелины никак не поменялись. Нам не надо уничтожать собранную о них в прошлом кадре информацию, чтобы вычислить её заново.
  
Эти две проблемы в совокупности рождают третью, катастрофическую по своим масштабам. Она связана с тем, что каждый отдельный спрайт создаёт свой личный Draw Call.
 
Draw Call - это набор команд для отрисовки, который создаётся процессором (CPU) и посылается видеокарте (GPU) для того, чтобы она его обработала и выдала на экран результат. Больше Draw Calls = меньше производительность (это очень упрощённо. Я нашёл
статью на русском языке, где вы можете ознакомиться с темой глубже)
 
И представьте: каждый глаз и рот человека. Каждая причёска. Каждый предмет на полу или летящая пуля: всё это создавало по 2 Draw Calls (один на "3D модель" и один на шейдер). 100 комплексных пешек на сцене = 2000 Draw Calls (считая одежду и предметы в руках). Это уже предел. 
  
Решение проблемы есть. Нужно использовать бэтчинг. Batching - это группировка объектов в единый Draw Call. И в Paper2D такое есть... но только для неподвижных объектов. Это называется статический бэтчинг, и он просто объединяет все помеченные объекты в одну большую "3D модель".
+ Таким образом можно рендерить очень много полигонов
– Объекты внутри нельзя индивидуально двигать*
– У всей этой большой "3D модели" получаются единые границы отрисовки (Render Bounds). Это значит, что она будет рисоваться полностью, даже если большинство объектов внутри на экране игрока не видны
– Ограниченные возможности. Например, в GW я использую Stencil ("трафарет") для индикации выделенных объектов. И это нельзя сделать с помощью статического бэтчинга
 
*Двигать можно, но придётся удалять старую информацию о полигонах (Vertex Buffer & Index Buffer) и строить её заново. Делать это каждый кадр - плохая затея.
 
А вот "бэтчинг" для двигающихся объектов (который не поддерживается в P2D) в Unreal Engine называется Dynamic Instancing. И именно его я и использовал в своей самописной системе.
  
Заметка для знатоков Unity: Dynamic Instancing в UE работает совсем не так, как Dynamic Batching в Unity. Потому что это не совсем "бэтчинг" в классическом понимании. Это Instancing (отдельный термин)
 
Детали
 
Для того, чтобы сгруппировать спрайты в единый Draw Call, должны соблюдаться некоторые условия:
1. Они имеют одну и ту же геометрию ("3D модель")
2. Они имеют один и тот же материал (шейдер)
3. Параметры материала у них одинаковые (например, текстура для отрисовки)
 
С первым пунктом всё легко и просто. Static Sprite'ы изначально создавались именно для этого. В моём случае, "3D модель" любого объекта в игре - это плоскость 64x64 пикселя*. Конечно, объекты бывают разные: и 16x16 пикселей, и 128x256 пикселей. Но окончательное изменение масштаба накладывается уже на саму плоскость, а не на вертексы внутри.

*1 пиксель текстуры в GW равен 1 unreal unit'у пространства, так что я могу приравнивать их между собой.
 
Так как геометрия плоскости в течение жизни никак меняться не может, нам нет смысла вычислять информацию для отрисовки каждый кадр. Вместо этого мы создаём FMeshBatch ровно один раз при добавлении спрайта на сцену, а затем сохраняем (кэшируем) его.
 
В UE эта фича называется Static Relevance, и она позволяет сэкономить ресурсы процессора, немного пожертвовав оперативной памятью.
 
***
 
Со вторым пунктом, "Они имеют один и тот же материал (шейдер)", дела обстоят сложнее.
 
Шейдер - это программа, выполняемая на видеокарте, в которой каждому пикселю исходя из какой-то логики и вводных параметров даётся финальное значение цвета.
 
Например, предметы, сделанные из определённого материала, получают его цветовой оттенок. И это прописано в шейдере. Кстати, логика там не такая простая, как в RimWorld. Я уже писал об этом в результатах работы за ноябрь 2023.
В RimWorld происходит простое умножение цвета пикселя на базовый цвет материала. В Grim Wild идёт сдвиг по яркости и насыщенности.
 
В общем, для реализации второго пункта мне пришлось сделать единый Master Material, который используют все объекты по умолчанию. В будущем в нём будет просчитываться довольно много логики. Но ничего страшного в этом нет: видеокарты в наше время достаточно мощные.
 
Итак, с учётом первых двух пунктов, мы получаем такую систему: все спрайты, созданные из одной и той же текстуры, объединяются в единый Draw Call. Например, все глаза людей. Или все картофелины. Или все причёски типа "одуванчик".
 
Если мы управляем колонией "одуванчиков", у нас всё прекрасно: 50 одинаковых спрайтов теперь создают всего 2 Draw Calls. Но если в колонии царствует свобода самовыражения, где у каждого колониста своя уникальная причёска, то эффекта от текущей системы не очень-то много. У каждой причёски своя текстура => свой набор параметров шейдера => свой отдельный Draw Call.
 
Получается, лучшее решение здесь - чтобы текстура была одна на много спрайтов. Это называется "текстурный атлас", и он успешно используется в огромном множестве игр.
  
Но в Grim Wild всё не так просто. Помните, в 6 серии я говорил о том, что все ассеты (=> и текстуры внутри) загружаются в память только по требованию? Получается, что игра сама не знает, какие текстуры в этой игровой сессии будут использоваться. К тому же, я ещё говорил про "runtime виртуальные ассеты", которые создаются во время игры. Например, новые сорта растений => новые текстуры для них.
 
Всё это говорит о том, что текстурный атлас надо строить прямо "на лету". Текстуры, загружаясь в оперативную память, должны добавляться в свободный слот одного из атласов и вычислять границы, в которых они рисуются.
 
Runtime текстурный атлас
 
С одной стороны, это очень интересная идея:
• Поддерживается динамическая загрузка и выгрузка текстур
• В атлас добавляются только те объекты, с которыми игрок встречался => лишних там нет, так что процент слотов, заполненных полезными текстурами, там предельно высок
• Контент из модов такой же производительный, как и базовая игра. Я считаю, что это безумно важно для проекта, который на них ориентируется
• На атласе можно рисовать всё, что угодно. В один слот можно добавить и текстуру. И 5 разных текстур. А можно обновить её во время игры. Про это я расскажу в разделе "Составные спрайты" ниже
 
Но с другой стороны, есть одна проблема: отсутствие компрессии. Сейчас атлас 2048x2048 пикселей занимает 16Мб видеопамяти (на видеокарте). Это не так критично, ведь атласов в игре, скорее всего, будет не так уж много. В любом случае, теоретическая возможность текстурной компрессии в реальном времени всё же есть. Правда, пока что я её не тестировал.
 
Пример того, какие объекты могут быть объединены в атлас. Правда, в игре атласы намного больше: 2048x2048 пикселей. Если говорить про объекты размером 128x128px, то это 16x16 позиций = 256 объектов. Конечно, и другие размеры спрайтов туда тоже добавляются. Пока что лимит размера одной текстуры - это 640x640px (5x5 клеток пространства)
 
Получается, что каждому спрайту в атласе принадлежит свой регион текстуры. Например, золотые монеты на атласе выше занимают [4] слот. Это регион между точками (x=0, y=128) и (x=128, y=256).
 
А индейка занимает [15] слот, и её регион - это (384, 384)➝ (512, 512) пикселей.
  
Помните третий пункт условий для объединения спрайтов в единый Draw Call? "Параметры материала (шейдера) у них одинаковые (например, текстура для отрисовки)"
  
Да, текстура у нас и правда одна. А скалярные параметры, как видите, всё ещё разные, так что никакого объединения Draw Call всё равно не будет. Но решение есть: надо хранить эти различающиеся параметры в другом месте.
 
Хранение параметров в видеопамяти
 
Unreal Engine позволяет хранить для каждого объекта* до 32 скалярных параметров, которые хранятся не в оперативной памяти, а в видеопамяти (VRAM). Фича называется Custom Primitive Data.
 
*Для Primitive Component. По сути, это всё, что может иметь отрисовку. То есть то, что может связываться с GPU. // Скалярные параметры = численные (float)  
 
В каждом спрайте во VRAM хранятся эти параметры:
• UV (местоположение) слота в текстурном атласе
• Базовый оттенок материала, который применяется в шейдере
 
Пункт 3. успешно выполняется: все спрайты внутри одного атласа объединяются в единый Draw Call, потому что с точки зрения CPU они теперь все абсолютно одинаковые.
 
Объекты разного размера и оттенка. И все они умещаются в 2 Draw Calls (один для геометрии и один для шейдера)
 
На экране почти 40 000 объектов (и 16 FPS, как написано справа). Draw Calls по-прежнему 2, но проблема теперь в другом. Так как каждый спрайт - это отдельный объект (который можно индивидуально двигать / выделять / пристыковывать к другим), то от количества логики задыхается уже CPU. Мы всё ещё должны вычислять каждый кадр то, будет ли объект отображаться на экране (он может быть либо скрыт, либо находиться вне экрана, либо быть полностью перекрыт другими объектами). Решение проблемы: просто не позволять игроку настолько сильно отдалять камеру.

Всё, что связано непосредственно со Static Sprites, на этом заканчивается. Главная задача, которая передо мной стояла, была успешно выполнена: производительность многократно повышена.

Композитные текстуры (в планах)

Допустим, игрок строит две солнечные панели: одну из золота, а другую - из углепластика. Обеим даётся оттенок материала:
 
Но результат получился явно не таким, который мы ожидали. Всё из-за того, что давать оттенок материала надо не всем пикселям текстуры.
  
 
Делается это с помощью отдельной маски, где мы помечаем нужные участки белым цветом.
 
И как сделать так, чтобы новая маска не сломала Dynamic Instancing? Неужели придётся делать второй текстурный атлас, который полностью копирует содержимое основного?
 
На самом деле, есть намного более подходящий вариант.
Все текстуры в GW имеют формат RGBA8. То есть у них есть 8 бит в каждом канале (красный, зелёный, синий и прозрачный), куда можно записать по 256 значений (0..255).
 
С RGB всё понятно, значения оттуда нужны игре в полном объеме. А вот от Alpha канала нам требуется всего лишь 0 или 1: пиксель текстуры либо рисуется, либо нет. Промежуточных значений нет, и на границах спрайтов это хорошо видно:
Переходы между пикселями внутри брёвен гладкие, а граница отрисовки всегда жёсткая
 
Значения 0/1 спокойно помещаются в один бит. Оставшиеся 7 штук в Alpha канале для нас бесполезны. Так почему бы не использовать их для других целей? Именно этим я и планирую заниматься.
 
Маска блеска: у меня есть идея того, что некоторые ценные объекты (например, руды в скале) "блестят" при движении камерой. Туда просто накладывается белая полупрозрачная полоска, которая заостряет на себе внимание игрока.
Составные спрайты (в процессе)
 
Большой бонус от runtime текстурного атласа заключается в том, что я конструирую визуал прямо на лету. А как именно я это делаю - для системы разницы нет. Главное, чтобы после добавления у нас были вычисленные границы слота: UV0 и UV1.
 
Я могу рисовать слоты, используя сразу несколько текстур. Получатся, как я их называю, составные спрайты (Compound Sprites).
Пока что у меня в идеях есть две сферы их применения:
• Символика государств на планете. Мы можем создавать "логотипы" из разных фигур, наложенных друг на друга с разным масштабом и местоположением. А можем даже менять эти логотипы во время игры. Например, у фракции пропало свойство "кровожадные", так что они обновили свой символ и убрали оттуда всю жестокость.
• Роботы, визуал которых генерируется исходя из деталей. Можно было бы даже дать возможность игрокам вручную их "собирать", расставляя все элементы как на холсте. Готовая текстура в таком случае на все 100% идентична по производительности самому обычному спрайту.
 
Модифицировать текстуры можно и прямо во время игры. Главное, чтобы они не залазили на соседние слоты.
 
Отложенное обновление трансформации (в процессе)
 
Хорошо, мы разобрались, как сделать для спрайтов эффективную отрисовку. Но их надо не только рисовать, но ещё и двигать и анимировать.
 
В шестом ролике я сказал важную вещь: "Я отказался от Actor, создав свой WorldObject". Вместе с Actor от меня ушла возможность делать объектам визуальную стыковку (Attachment), так что обновлять местоположение, обрабатывая родительские* трансформации, мне приходится вручную.
 
*Parents & Children - это термины для обозначения отношений в стыковке. Родитель владеет детьми, которые рекурсивно могут иметь своих детей. Пример цепочки стыковки:
Человек ← Тело ← Голова ← Глаз
 
И это очень хорошо! Система стыковки в Unreal Engine при обновлении трансформации родителя тут же обновляет трансформацию и всех детей, чего мне делать совершенно не надо.
 
Здесь было бы неплохо показать вам визуальный пример, потому что текстом описать тему сложно. Так что Deferred Visual Update, как я это называю, раскроется вам в полном объеме с выходом восьмого ролика.
 
Кратко: вместо того, чтобы рекурсивно обновлять трансформацию всех детей при каждом малейшем изменении, я делаю это ровно один раз в самом конце кадра. И анимирование сцен идёт там же. Такой подход должен значительно сократить затраты на визуальное обновление. Но на практике узнать я этого пока не могу - механика всё ещё в разработке.

Пул спрайтов (в планах)
 
Теперь у вас есть представление о создании и жизни спрайтов. А что насчёт уничтожения?
 
Прежде, чем к нему перейти, я опять процитирую самого себя. Причём цитата ровно та же самая: "Я отказался от Actor, создав свой WorldObject".
 
Менеджмент отрисовки после такого оказался полностью в моих руках. И я воспользовался этим, чтобы реализовать систему, в которой логика отделена от визуала и связана с ним только в одностороннем порядке: Логика ➝ Отрисовка.
 
Это значит, что логика* на отрисовку влияет, но отрисовка на логику - нет. Это своего рода "тупик", которым можно манипулировать как угодно без каких-либо опасений.
 
*Логика - это все процессы, которые происходят с WorldObject'ами: создание и уничтожение объектов. Нанесение урона, генерация налётов, появление событий и так далее.
 
Итак, возвращаемся к исходной теме. Я уже сказал, что когда спрайты создаются, их данные для рендера тут же кэшируются. Было бы не очень эффективно при каждом выходе спрайта из зоны видимости (например, пешка вышла из прогруженного чанка) удалять всё это. Как вы уже знаете, информация обо всех спрайтах для процессора и оперативной памяти абсолютно одинакова.
 
Поэтому я реализую систему Sprite Pool, когда вместо уничтожения мы "высвобождаем" спрайт, чтобы его повторно мог использовать кто-то другой. Главное условие: Sprite Pool эффективно работает только на спрайтах из одного текстурного атласа. Остальное (оттенки, UV слотов и прочие GPU данные) можно спокойно поменять в моменте.

Полный уход от Paper2D
 
Как видите, у меня есть достаточно необычные идеи. Они созданы на основе полного контроля каждого этапа жизненного цикла спрайтов.
  
Я перешёл на свои собственные механики во всём, кроме одного: самого источника текстуры, который до апреля 2024 существовал в виде ассетов PaperSprite.
  
Я, аргументируя своё решение словами "там содержится много мусорных данных, которые забивают дисковую и оперативную память", сделал свой тип ассета: Sprite Data.
  
Я впервые глубоко погрузился во взаимодействие с редактором Unreal Engine. Мне пришлось создавать удобства не для игроков, а для самого себя. Ощущения необычные.
Превью ассетов в редакторе будет рисоваться с учётом композитного Alpha канала. Это ещё одно преимущество над PaperSprite
  
Я даже сделал небольшое окно для визуального редактирования сокетов:
Оно, конечно, не такое красивое и функциональное, как у Epic Games, но для меня сойдёт.
  
Кстати, о сокетах. Сокеты - это "разъёмы" для стыковки двух спрайтов. Например, у тела есть сокет для стыковки с головой. А у головы - для причёски.
  
PaperSprite никаким адекватным способом не позволял сделать так, чтобы сокеты добавлялись модами. У меня даже была спроектирована костыльная механика на основе AssetUserData, которая могла бы обойти это ограничение.
  
Сейчас, когда "я сам себе хозяин", сокеты спокойно могут создаваться и в модах тоже. Причём для любых спрайтов. Даже добавляемых базовой игрой.
  
Пример: мод на налобные фонарики добавляет... налобные фонарики, которые крепятся поверх всей одежды на лоб. Для этого мододелу нужно создать для головы человека отдельный сокет, и теперь это можно реализовать без проблем.
 
Бонус: улучшение интерфейса
  
Раньше каждая иконка на пользовательском интерфейсе была отдельной текстурой. Теперь, вдохновившись своей работой в области атласов, я решил реализовать их и в этой сфере тоже.
  
Правда, атлас иконок - это самая обычная текстура, а никакая не runtime-генерирующаяся. Да и объединения Draw Calls нет. Но мне это и не нужно. Главное - экономия оперативной памяти*, и с этой задачей атлас как раз справляется.
 
Есть всего один атлас на всю игру. И да, если к релизу останется уж слишком много свободного пространства, то я могу уменьшить размер, и все иконки автоматически обновят свои границы, потому что я такое запрограммировал.
 
*Да, очень смешно слышать про "экономию оперативной памяти", когда я показываю огромный и на 97% пустой атлас. Но, как уже было сказано, позже я подгоню его размеры под реальное количество иконок в игре.
 
Иконки, кстати, это тоже новый тип ассета:
StartUV и SizeUV генерируются автоматически на основе индекса иконки и размера атласа.
 
У этого нововведения есть интересная особенность. Так как все иконки в базовой игре ссылаются на одну текстуру, мы можем её очень легко заменить на другую. Возможно, это было бы полезно в модах, чтобы художники могли перерисовать всё под свой стиль.
 
Итоги
 
Новая система отрисовки - это сложное, но интересное занятие. Я только недавно начал погружаться в сферу Tech Art'а, так что опыта у меня пока не так много. Я не отрицаю, что мог сделать что-то криво или не очень эффективно. Я не спорю, что за пределами Unreal Engine знания в Tech Art'е у меня околонулевые, так что моя система явно не самая крутая, которую можно было сделать для моих нужд.
 
Но мы имеем то, что имеем. Когда всё из категорий "в работе" или "в планах" будет реализовано, я сделаю для вас отдельный ролик. Ещё, может быть, я сделаю туториал на тему того, как что-то примерно похожее можно реализовать в UE без единой строчки C++ кода (думаю, это было бы полезно для новичков).
 
Как только я закончу работу над всеми вышеперечисленными пунктами, версия 0.3 будет логически закончена. Я начну работу над версией 0.4, где, наконец-то, игра станет наполняться контентом и игровым процессом.
 
А пока что, спасибо за внимание!

Новая отрисовка спрайтов Новая отрисовка спрайтов Новая отрисовка спрайтов Новая отрисовка спрайтов Новая отрисовка спрайтов Новая отрисовка спрайтов Новая отрисовка спрайтов Новая отрисовка спрайтов Новая отрисовка спрайтов Новая отрисовка спрайтов Новая отрисовка спрайтов Новая отрисовка спрайтов Новая отрисовка спрайтов

Comments

<div ><div><span class="text">Ничего не понял, но полностью поддерживаю. :)</span></div>

Алексей Мельников

<div ><div><span class="text">Я ещё забыл упомянуть, что runtime текстурный атлас позволит выбирать детализацию текстур. По умолчанию 1 клетка пространства = 128x128 пикселей. Но если у игрока слишком мало VRAM (а я ориентируюсь на 4GB), то он может в 2 раза понизить детализацию. Размер атласа вместо 2048x2048 станет 1024x1024, а 1 клетка пространства - 64x64</span></div></div>

teedeezet


More Creators