King’s Bounty II, на первый взгляд визуально простая игра, но в ней сосредоточено огромное множество сложных и комплексных технических решений, продиктованных одновременным выходом на все платформы. Отдельным, и, пожалуй, самым сложным испытанием для команды стало портирование на Nintendo Switch
Острожно, лонгрид!
Эта статья охватывает небольшую часть проделанных нами работ.
Автор статьи Пивоваров Олег (Tech Art - Console / PC портирование, оптимизация).Статья написана в соавторстве с Владимиром Ширшовым (Tech Lead), Владимиром Барановым (Render & Toolset Programmer).
Как всё начиналось
К попыткам запустить проект на Nintendo Switch мы приступили на середине Alpha-стадии разработки. Я присоединился к основной команде разработки прямо в момент начала работ по портированию. StoryMode вызвались помочь нам в портировании проекта на Nintendo Switch.
Однако первый билд получился не таким, как нам хотелось бы, и едва умещался в расширенную память девкита:
один фрейм занимает 160 ms в HD;
большая часть материалов не компилируется и заменена на заглушки;
дальность прорисовки зарезана до 5 метров от игрока, чтобы игра не падала по памяти.
На фоне всего шло активнейшее производство игры:
велось производство Art контента;
локация пролога и загружаемые арены на этапе раннего прототипа;
основной уровень готов процентов на 70%, но все еще подлежит активным переработкам;
реализация геймплейных фич в самом разгаре - программисты заняты на них;
В связи с этим нам нужно было что-то делать, чтобы успеть в сроки, и я попытаюсь объяснить в этой статье, что было проделано для этого за отведенное нам время до релиза.
Проблематика
Актуальную статистику по потреблению памяти нам удалось заполучить далеко не сразу после первого запуска сборки на устройстве. Процесс этот был для нас в новинку на тот момент, а корректный сбор информации был необходим для дальнейшей работы.
Основными потребителями оказались:
Textures - избыточное разрешение и неудачно упакованные текстуры;
Render Target - ресурсы с 16bit, лишние capture;
Render Thread и RHIMisc - неиспользуемые фичи рендера;
Skeletal Meshes - рандомизируемые крауды и излишне детальные NPC;
Static Meshes - LOD и множество уникальной, детализированной геометрии;
Physics - коллизии;
LoadMapMisc - сериализованная на уровне информация;
Programm Misc - множество мелких проблем;
Большую часть задач, связанных с памятью и не касающихся непосредственно арт-контента, взяли на себя StoryMode под руководством технического продюсера KB2.
Проблемы арт-контента решались на моей стороне, а пограничными случаями, такими как Render Thread, Render Target и Load Map Misc - занималась вся команда.
CPU:
Здесь нельзя выделить отдельный источник проблем, так как мы сталкивались с самыми разнообразными хитчами, проблемами блюпринтов и их нативизации, особенностями игровой логики, случаями с сохранением и загрузкой, и многим другим.
CPU-задачами занимались наши коллеги из StoryMode до момента сдачи Alpha версии. После борьбу продолжила основная команда программистов.
GPU:
Мы были вынуждены остановиться на Deferred Renderer, поскольку часть ключевого игрового функционала уже была значительно завязана на deferred decals, а на самих уровнях активно использовались декали для детализации окружения.
Оптимизма не прибавляло и использование Dynamic Light - никакого уменьшения числа динамических источников освещения, никакого precompute visibility, только длинные пассы отрисовки теней.
Что мы сделали:
отключили множество стандартных фич: PreShadows, TranslucentLighting, AdditionalPass и т.д.;
минимизация использования Custom Depth;
настроили Shadow Pass и модифицировали подход к источникам освещения и объектам, отбрасывающим тени;
отказались от многих решений, использующих Render Target (особенно 16bit);
отказались от использования niagara (на момент UE4.22 - сильно нестабильной и ресурсоемкой)
Количество DrawCall в сцене, связанное с большим количеством секций на мешах и просто огромным числом объектов, избыточность по числу инструкций материалов и плотность геометрии повлияли на то, что основными статьями расхода времени фрейма оказались Prepass и BasePass.
Даже в конце разработки нам приходилось прибегать к различным ухищрениям, например, добавлять тест на включение Custom Depth в бою, только если юниты частично скрыты на экране за другими объектами.
До таргета в 30ms было еще очень много работы.
Streaming:
Перед началом работ по стримингу нам необходимо было исключить падения игры по памяти, из-за чего работы по стримингу уровней и Entity (персонажи и геймплейные объекты) отложили в долгий ящик.
Это сыграло с нами злую шутку - мы упустили из виду, что увеличение дистанций стриминга значительно увеличивает количество асинхронно подгружаемых объектов, что в свою очередь дает значительный негативный импакт на диск, CPU, и память.
За дело
Simple Memory Problems: VertexPaint, Distance Fields
Перед тем как приступить к комплексным системам, рассмотрим несколько забавных проблем.
В проекте используются сложные материалы покраски стен - слой краски, штукатурки и подложка в виде кирпича. Оригинальная версия основывалась на Vertex Color покраске, выполняемой специалистами уже на уровне.
У такого решения существует один неприятный недостаток:Unreal Engine 4 хранит информацию о Vertex Color непосредственно на уровне, в proxy окрашенного меша. При покраске одной вершины будет создан уникальный VertexBuffer с цветом для каждой вершины данного меша и всех его LOD.
Данные о покраске хранятся в формате FColor, что соответствует uint32 - 4 byte на вершину. Таким образом, мы накопили лишних 350MB.
Похожая история, но с отдельно взятыми ассетами происходит при включенном Reversed Index Buffer и Adjacency Buffer - все это дополнительная информация на геометрии лежащая мертвым грузом, если вы их действительно не используете - отключайте. Лучше всего отключать данные параметры глобально на весь проект, и использовать точечно, только для объектов которые действительно этого требуют.Также обратите внимание на коллизии, по умолчанию UE4 держит на каждый меш еще и комплексную коллизию.
Вернемся к материалу, в нем мы реализовали логику разрушения краски и штукатурки по сложному градиенту от земли и до крыши. Соответственно, логичным решением было бы вовсе отказаться от Vertex Color в пользу генеративного материала базирующегося к примеру на Distance Fields.
И тут снова вступают в игру ограничения Nintendo Switch: Distance Fields собирается в огромную 3D текстуру формата R8, размером 512x512x512 (~170,5 MB), и парочку дополнительных размером поменьше. Большая часть Distance Fields эффектов является достаточно дорогой, поэтому было принято решение отказаться от них именно на младшей платформе.
Вышли мы из ситуации, добавив World Heightmap - R8 - 1024x1024 - 1,33 MB.HeightMap запекается на весь мир, в нее пишется высота landscape и объектов, используемых в качестве оснований для зданий.
При разнице высот в мире в 23500 юнитов, погрешность при упаковке в R8 по Z составляет 10 units, а точность по XY составляет 300 units, что, в целом, достаточно для всей последующей генерации.
В новом материале мы семплируем в Vertex Shader и Noise, и HeightMap, и по получившейся из них маске создаем новую сложную покраску здания.
Если вы будете делать подобную систему, то рекомендую сразу закладывать Height map с отрисовкой по перекладке, поддерживающей несколько логических слоев - ландшафт, уровень воды, площадки и крыши домов - это расширит ваши возможности по авторасстановке декалей, покраске, маскировке эффектов и созданию процедурной воды. Если хранить данные по методу одной глобальной карты и перекладочных масок уточнения, то можно добиться точности до 1 unit по XYZ, при этом потребление памяти не сильно вырастет.
Platform Switch: Materials
В базовой логике UE4 предполагается использование Quality Switch как для разделения платформ по логике материалов, так и для настроек качества на PC:
High - PC
Medium - Xbox / PS4
Low - Nintendo Switch
В целом, это достаточно приятная логика разделения веток материала, но у нее есть небольшие недостатки:
Подобное деление не предполагает появления промежуточных настроек качества, требуемых для поддержки PS5 / Xbox Series,
UE4 имеет разные компиляторы шейдеров под PC / Xbox one / PS4 / Nintendo Switch. К примеру, компилятор под младшую платформу не знает стандартную функцию mad(). А поведение одного и того же материала может отличаться к примеру на Xbox и PS4, с чем мы неоднократно столкнулись.
Родные механизмы UE4 плохо держат разделение контента по веткам.
Мы смогли предугадать появление таких проблем еще на ранних этапах портирования и заложили систему Platform Switch.
Конечно, при написании HLSL вставок можно обойтись #ifdef PLATFORM, но это не закроет проблему отличающихся ресурсов, да и далеко не все технические художники понимают HLSL. Более того Epic Games исповедует идеологию редактора нод, что обязывает нас этому следовать.
Texture Repack
Грамотная упаковка текстур - залог эффективного использования памяти.
На один уникальный меш обычно используется от 3х до 4х уникальных текстур, в нашем случае это:
_D (Base Color)
_RME (R: Roughness, G: Metallic, B: Emissive)
_N (Normal map)
_TID (RGB ID map)
Когда в проекте число текстурных объектов превышает 13 000, то устранение одного лишнего текстурного объекта начинает играть существенную роль.
Просто так выбросить один атлас нельзя: его нужно упаковать в формат, содержащий в себе все необходимые данные. В том числе, нужно поправить все ссылки в чуть больше чем 8 500 material instances. В короткие сроки такую задачку вручную выполнить невозможно, да и сама процедура должна пройти достаточно незаметно, чтобы не остановить производство игры и безболезненно перевести арт-отдел на новый формат (или вовсе не переводить).
Упаковка
Самые очевидные кандидаты на слияние воедино это - _RME и _N.
Карты Normal Map и Roughness в нашем проекте используются на каждом объекте, их можно считать константно существующими.Metallic карты используются всего в 20% случаев, а объектов использующих Emi всего несколько десятков.
Соответственно, достаточно разумным решением является использование формата упаковки NrgRM, где
R: Normal Map R Channel
G: Normal Map G Channel
B: Roughness
A: 1-Metallic
Emissive выносим в отдельную G8 карту.
Особенности данного решенияМетод компрессии Normal Map в UE4 - это формат с R и G каналами, по 8 бит каждый,
“B” канал восстанавливается скрыто от пользователя.Default метод компрессии - (5, 6, 5) на каждый канал соответственно, что влечет за собой возникновение более заметных артефактов компрессии, но зачастую некритичных.
В случае, если предполагается использовать Alpha канал, то будет правильнее перейти на более щадящую компрессию BC7.
Теперь необходимо переработать этап семплирования текстур.
Bake-нода - это своеобразный InOut, то есть входящие параметры на компиляции материала отбрасываются, а сам по себе он возвращает записанный в него T2D объект. При этом же на этапе Bake мы используем только входящую в Bake-ноду часть. На вход пользователь подает нужную ему информацию, а в Size Reference указывается текстурный объект, с которого заимствуется финальное разрешение. Можно подавать и фиксированное разрешение, но этого делать не рекомендуется, так как текстуры разных объектов имеют разное разрешение, а задавать последнее в Material Instance, к сожалению, невозможно ввиду реалий нашего проекта.
Запуск процедуры перепаковки выполняется либо на месте для всех производных от материала, либо отдельно для вообще всех materials и material instances.
Как устроен процесс:Система обходит все существующие материалы и выделяет в каждом из них блок материала, поданный на вход Bake-нод в отдельный материал - Bake Master. Для каждого Material Instance создается свой Bake Master Instance с наследованием параметров.
Затем выполняется процесс первичной дедупликации - все Material Instances от одного Bake Master проверяются на совпадение параметров и, в случае полного соответствия - этот instance исключается из очереди на перепаковку.
После все текстурные объекты просто запекаются с использованием по возможности Raw ресурсов (во избежание возникновения дополнительных артефактов компрессии) и сохраняется хэш получившегося объекта.
Каждый новый испеченный атлас сверяется по своему хэшу с уже существующими и при совпадении значений удаляется, сохраняя в кэше редиректор.
Последним этапом получившиеся текстурные ресурсы проставляются в оригинальные Material instance.
Из интересных особенностей - мы проверяем автоматически каждый ресурс на наличие или отсутствие Alpha канала во время обработки текстур и, соответственно, переводим ресурс либо в BC7, либо оставляем default метод компрессии без альфа канала. Учитывая, что разница в размере текстуры с альфа каналом и без двукратная, то такой подход позволяет еще сильнее экономить ресурсы.
Texture Repack позволил сократить потребление памяти текстурами примерно наполовину, и по сути, с его запуском мы впервые честно влезли в память на Nintendo Switch.
Texture Repack - очень полезная технология, так как теперь специалисты имеют возможность составлять удобный им пайплайн работы с текстурами, но у этого есть и обратная сторона.
Без дополнительного этапа процессинга - билд не сможет запуститься, у художников значительно больше шансов на ошибку, и сложнее заниматься менеджментом ресурсов.
На мой взгляд, подобного рода технологии стоит использовать только в крайних случаях, если у вас есть возможность построить пайплайн производства правильно с самого начала, с корректной упаковкой такая система вам никогда не понадобится.
Post Processing Level Production
Несмотря на заголовок, к полному постпроцессингу (автоматической обработке уровня роботами, согласно заданным правилам) мы пришли далеко не сразу, а сама подсистема зародилась по определенным обстоятельствам из совсем другого технического решения.
На момент начала работ, наш средний кадр был в 14 000 000 tris, при рекомендуемом пиковом значении для Switch 3 000 000, при этом на environment максимально допустимо израсходовать 2 300 000 tris - остальное зарезервировано на персонажей, юнитов в бою и эффекты.
Рассмотрим немного структуру уровня:Наша игра - псевдо Open World, в котором уровень - по сути широкий коридор.
Если посмотреть на карту, то можно обратить внимание, что города по большей части огорожены стеной и увидеть снаружи содержимое нельзя (за редким исключением), как собственно и наоборот.
Участки за пределами городов хоть и не ограничивают обзор, но идти в любом направлении свободно - не выйдет, дорогу тебе всегда преградит обрыв. Находясь на высокой позиции не выйдет посмотреть что же именно происходит под ногами, и наоборот - задрать голову вверх и увидеть что же происходит на утесе.
Еще одним интересным фактом является особенность подхода UE4 к Bounding Box (BB, используемых для Occlusion query), именно по нему отрабатывает Occlusion Culling, но при этом он всегда строго ориентирован по осям и никак не может быть повернут. Из-за этого при повороте оригинальной геометрии в пространстве, BB может увеличиваться в 2 раза, и казалось бы, невидимый объект попадает в render pipeline. Здесь нас спасают множественные тесты в viewspace, но все равно мы получаем лишние Draw Calls и лишний раз обрабатываем геометрию в Vertex Shader.
В игре мы используем World Composition, а базовый пайплайн уровня выглядит так:Level Designer и Level Artists работают на оригинальном уровне, поделенном на чанки размером в 25400 юнитов, а каждую ночь уровень отправляется на нарезку - каждый оригинальный чанк разбивается на 16 частей, для каждой из которых генерируется Proxy Mesh (Level LOD) а интерактивные объекты записываются в Entity Storage. По итогам в билд попадает специально подготовленный контент.
У подобного подхода есть один существенный недостаток - World Composition работает строго по расстоянию от игрока до чанка, что делает невозможным ограничение загрузки компонент уровня исходя из условий видимости и доступности.
В итоге, казалось бы, пустая сцена под городом тянет за собой все городские ассеты и больше половины из них мы не в состоянии откулить и увидеть. Precompute Visibility же, к сожалению, не дружит с dynamic light.
Городской конструктор
На ранних этапах разработки в игре планировалась камера с видом сверху - ровно такая же как в King’s Bounty: The Legend - и, как результат, конструктор проектировался именно под нее: Модуль размером с трехэтажное здание, с встроенной в него кирпичной отбивкой, окнами, дверьми и лестницами. Каждый отдельный элемент - это своя секция со своим материалом, в итоге один дом может нам стоить от 12 DrawCalls только в Base Pass.
Проблемы начинаются в момент изменения ракурса камеры на вид от третьего лица. Одиночные домики уже не смотрятся так хорошо как раньше: им не хватает детализации, а сцене насыщенности и общей композиции. В итоге, чтобы насытить кадр, художники начинают собирать из конструктора Мега Дома.
Домики взаимопроникают в друг друга порой до 70% или закопаны в землю по последний этаж, в итоге в этих множественных пересечениях скрывается огромное количество геометрии, которую игрок никогда не увидит, но при этом она отправляется на отрисовку, съедая так нужные нам ресурсы.
Общие проблемы локации
Псевдослучайные генераторы объектов (CDO) - для ускорения производства уровня level designers заготовили себе prefabs, что, в целом, вполне логично, но имело ряд особенностей:
вместо prefab использовался обычный actor blueprint;
в actor blueprint под разными root component заготавливают разные композиции объектов.
при добавлении на сцену компонент выбирает только композицию согласно выбранному seed;
При такой реализации в память загружаются все объекты несмотря на то, что пользователь видит только одну композицию
Общие проблемы ISM и HISM - некоторые внутренние генераторы объектов автоматически собирают компоненты в HISM и ISM. В генераторах используется избыточное количество уникальной геометрии и авто-разбиение на чанки, в результате в один компонент попадало меньше минимально эффективного числа объектов и работало во вред.
Пресеты объектов, с ручным отключением компонент - аналогично предыдущему пункту: на уровень уже выставляется собранный actor blueprints, после чего художник прячет часть объектов снимая bool - visible. Такой подход чреват лишними объектами в памяти и наличием скрытой компоненты.
Ручная детализация уровня - на уровне очень активно используется расстановка бочек, горшочков, монет и другой мелочевки вручную - каждый предмет отдельным Static Mesh (STM) - правда, в обертке из actor blueprint.
Избыток уникального контента - на разных платформах разное количество памяти и, если на PC можно себе позволить три вариации одного объекта, незначительно отличающихся между собой по геометрии и текстурам, то вот на младших платформах ценен каждый килобайт и все, что не вносит существенного вклада в картинку, должно быть исключено.
Помимо перечисленного, на уровне хватало и других мелких проблем.
Socket Composition
Для начала посмотрим как мы можем разобраться с проблемами конструктора в рамках UE4:
LOD - создание ручных лодов под разные ситуации, настройка дистанций переключения и по платформенное разделение. Увы, метод не решает проблему с геометрией, спрятанной в взаимопересечениях конструктора, а эффективное расстояние переключения LOD для зданий, на уровне, где отсутствует детализация - начинается на дистанции включения Level LOD.
HLOD и Merge объектов на сцене - активное использование данного метода приводит к появлению на сцене уникальной и зачастую очень тяжелой геометрии, а мы существенно ограничены в доступной нам памяти. Да и производить зачистку геометрии после ее объединения - означает запретить работы Level Designers и Level Artist, чего в нашей разработке произойти никак не могло до Release Candidate.
Оба варианта не очень подходят к нашим условиям, так что стоит пересмотреть модуль конструктора. Воспользуемся тем, что каждый компонент конструктора выставлен на уровне как Blueprint Actor, а значит у нас есть возможность сохранить совместимость с геометрией уровня.
Большие компоненты конструктора - плохо. Средние по размеру компоненты не особо подходят - оригинальные дома сложной покатой формы, и любые попытки их разрезать на средние по размеру компоненты заканчиваются либо потерей оригинального внешнего вида, либо появлению огромного количества уникальных объектов.
Пришлось выбрать вариант разделения оригинальной геометрии на:
Main Mesh - основное тело элемента конструктора, представленное 1-4 секциями.
Detail Mesh - маленькие компоненты, представленные отдельными секциями. Например - кирпичная отбивка стен, окна, двери, ступеньки. Условием выделения в компонент является отдельная секция от Main Mesh, также при сокрытии элемента не должны оставаться явные дыры в основной геометрии. На каждый вид Detail Mesh допустимо использовать всего 1-2 вариации.
В местах где изначально располагались Detail Mesh размещаем socket с маркировкой нужного нам меша, на основании них специальный компонент будет добавлять меши, сгруппированные в instance group (группировка осуществляется по сторонам здания).
Данное разделение оказалось эффективным - кол-во геометрии в кадре тестовой сцены упало вдвое, а кол-во Draw Calls упало на ⅙.
При кажущейся высокой эффективности данного метода его недостатком становятся другие вещи:
Специалисты хотят иметь больше инструментов для разнообразия элементов, в результате, вместо оговоренных нескольких вариаций их число иногда возрастает до 8-10.
При определении что же должно попасть в Detail mesh, очень часто допускаются ошибки и в них выносятся огромные элементы конструктора, а на компоненты нередко разбиваются элементы, имеющие в оригинале одну секцию с Main Mesh.
В результате эффективность подсистемы значительно падает - мы забиваем фрейм уникальной геометрией и не можем собрать ее в инстансы.
Имей мы на начало работ готовый C++ Socket Composition компонент с полной обвязкой внутри движка (вместо прототипа) и готовый инструментарий для работы в 3D редакторах (DB с Detail Mesh, а также скрипты, позволяющие автоматизировать процесс) - результат был бы значительно лучше..
Что же касается программной реализации, С++ версия обзавелась хитрой моделью быстрого поиска и спавна detail mesh в рантайме с автоматической сборкой в instance - это позволило реализовать модель авто бюджетирования фрейма и выиграла нам значительное количество ресурсов (CPU / GPU)
К сожалению у этой модели остался технический недостаток - мы хитчили в момент пересборки. Побороть эту проблему в разумные сроки не удалось, и мы решили остановиться на статической модели работы SC, а в будущем вернуться к runtime и довести ее до ума.
Optifine Scripts
Теперь наша задача - решить общие проблемы локации и поправить ошибки, принесенные Socket Composition.
Мы не можем модифицировать оригинальные уровни, но можем добавить дополнительный этап процессинга сразу после нарезки, что позволяет распараллелить оптимизационные и производственные работы.
Optifine Scripts - модульная подсистема в первую очередь нацеленная на обработку выставленной объектов на уровни.
Clean Up Module
Удаление компонента, если снят флаг visible или стоит флаг hide in game.
Замена Blueprint Actor на ActorMimic - версия AActor без скриптов убирает ссылку на оригинальный CDO.
Удаление референсов к объектам, не отображаемым в игре.
Per Platform Replace - объекты могут быть удалены или заменены на кукинге на другой, указанный в доп. атрибутах, для каждой отдельной платформы.
Similarity - наборы правил, применяемых ко всем объектам, выставленным на уровне, для использования Per Platform Replace в автоматическом режиме.Позволяет подменить визуально схожие объекты для сокращения количества уникальной геометрии в кадре и улучшению инстанцирования сцены.
Blueprint Optifine Scripts - пользовательские BP скрипты, выполняющиеся на пост процессинге.
Auto Collision Merge - группировка коллизий нескольких компонентов в блоки;
Cluster Merge
По пользовательским указателям объединяет группу объектов в один AActor;
По пользовательским указателям удаляет / подменяет / двигает объекты;
При объединении пересчитывает и заново присваивает Bounding Box (BB) для группы объектов;
Автоматически пересобирает STM, ISM, HISM в новые группы для эффективности работы инстансинга.
На базе Blueprint Optifine Scripts появилось множество пользовательских подсистем:
Автоматическая лимитация VFX на уровне;
Collision LOD - для разного скейла геометрии можно использовать разные коллизии;
AutoCheck - проверки объектов на каст теней, на movable, на включенный стенсил и многое другое.
Данная подсистема помогла нам избавится от большей части критичных проблем уровня, и вместе с Texture Repack, обеспечила нам возможность влезть в память на всех платформах, не испортив при этом визуальный ряд.
Проблемы стриминга
После того как основные проблемы с памятью были решены, мы смогли поднять дистанцию стриминга до приемлемой в 11000 units. Как итог - возросло число единовременно существующих на сцене объектов, а, соответственно, значительно возросла нагрузка на CPU и на чтение с диска.
Помочь бы мог пересмотр модели Стриминга для уменьшения единовременно загружаемых объектов, например, перевод на Regions Streaming, когда чанк у нас более не квадратной формы, а представлен в виде 2D Convex Hull, на границы же чанка назначаются параметры доступности его соседа. Вариантов реализации всего 2:
Перейти на родные механизмы ручного стриминга UE4, что чревато значительным усложнением работ по созданию уровня и его поддержке, а в итоге - к срыву сроков сдачи.
Написать свою модель регионального стриминга, со своим редактором, и по пути модифицировать логику нарезки уровня. На этот вариант вовсе нет ресурсов и времени.
Мы остановились на идее распределения геометрии по приоритетам загрузки - World / Detail / Runtime. Готового механизма под подобное деление в UE4 нет, но у нас уже достаточно инструментария чтобы быстро заготовить его.
В Optifine Scripts мы добавляем функционал по собственной нарезке, чтобы он умел создавать собственные чанки, научился разбирать существующие actor на отдельные компоненты согласно классификатору и переносить получившиеся объекты на разные уровни.
Со стороны движка пришлось добавить поддержку приоритетов загрузки уровня и сброса очереди. Подробнее о получившемся в итоге стриминге вы можете ознакомиться в статье Владимира Ширшова.
Данное решение почти полностью сняло проблемы стриминга, оставшиеся же неровности мы закрыли при помощи File Open Order.
Grass problems
Любопытный читатель уже давно задался вопросом: «Причем тут Tech Art?».
И будет прав, ведь мне приходилось решать разные задачи, которые далеко не всегда относились к тому, чем я изначально должен был заниматься. Давайте же вернемся к моей реальной дисциплине и рассмотрим метод подготовки быстрой травы для Nintendo Switch.
Step 1 - Grass Bend
На стадии альфы наши коллеги решили добавить метод записи вектора отгиба травы от позиции игрока или от каста заклинания, генерируя разные Particle Emiter. Внутри каждой системы спавнятся частицы с предзаписанным вектором отгиба, а весь результат захыватывается отдельным Scene Capture (пример реализации).
У такой реализации есть неочевидная особенность: при использовании Scene Capture мы создаем отдельный View Family, с набором собственных буферов (уменьшенный на кол-во тех, которые UE4 смог переиспользовать от основного рендера) и полной инициализацией всех пассов.
В итоге, на Nintendo Switch данный механизм стоит как минимум 3.5 ms. Технически, конечно, можно именно для данной реализации уменьшить тик камеры, но мы все равно будем сталкиваться с периодическим хитчем, что никак нас не устраивает.
На старших консолях мы столкнулись с периодическим крашем - Data Race, отследить точную причину которого весьма сложно и весьма накладно по времени.
Обойти проблему и решить задачку можно иначе:
Отказаться от Scene Capture в пользу Canvas и рисовать туда напрямую.
Браш с векторами позиционируется в локальных координатах Canvas через опрос о позиции персонажа и других объектов взаимодействующих с травой.
А в современной ситуации, начиная с UE4.26 - воспользоваться Niagara - и ее 2D grid (мы же зарелизились на UE4.24)
Увы, на такую реализацию времени не хватило, и отгибание травы пришлось отключить.
Step 2 - Grass Mesh and Shader
Перед тем как понять, все ли хорошо с нашей травой, замеряем ее стоимость:
Оригинальная потребляет в BasePass 17,5 ms а в PrePass 11,5 ms. Все это при grass density 0.25 от PC версии.
Настроим Grass Type и Static Mesh травы в UE4::
Выключаем каст теней - для травы достаточным является Contact Shadows.
Удаляем дубликаты идентичных видов травы внутри одного Grass Type - увы, UE4 не может объединять в HISM одинаковые меши сложенные в разные слоты.
Уменьшаем число используемых LOD до минимально разумного - 3х шт., так как их переключение также тратит ресурсы CPU и увеличивает количество Draw Calls.
В нашем случае LOD2 - минимальный билборд.Сокращаем разнообразие видов подвидов травы до разумных 1-2х все с той же целью.
Этого уже достаточно, для того чтобы выиграть ресурсы GPU и на PC и старших консолях, но пока недостаточно для Nintendo Switch.
В оригинальной версии травы активно используется сочетание SpeedTree и UE4 SimpleWind, а также модифицированная версия ветра для работы через pivot painter.
Увы, оба варианта весьма тяжеловесны по количеству инструкций выполняемых в VertexShader и по хранению данных на объекте. Соответственно, необходимо полностью пересмотреть модель записи и декодирования данных.
Для начала вручную сокращаем количество вершин на всех LOD до минимального, не портящего внешний вид травы - каждая вершинка дает о себе знать на больших масштабах.
UV-0: данные для маппинга текстурных объектов, все стандартно.
UV-1: информация о длине стебля, точке крепления листов и их длине;
UV-2: вектор направленя листа относительно стебля;
Vertex Color (VC): Разница между origin и позицией каждой вершины листа, приведенная в 0-1.
Как это выглядит на практике
Начало стебля в 0, окончание в 1. (Помните, что в HLSL отчет ведется от верхнего левого угла, а V ориентировано вниз). Лист размещаем по V в том же расстоянии от начала стебля, где он находится в пространстве, и ориентируем его по U компоненте. Чем ближе кончик листа будет к 1 по U, тем сильнее будет к нему применяться ветер. Лист можно наклонять по V, тогда чем сильнее угол наклона, тем сильнее он будет отклоняться вместе с основным стеблем.
При написании основного материала растительности не используем Static Switch Param - необходимо минимизировать количество пермутаций. Также минимизируем все Param, чем больше констант, тем лучше сможет оптимизировать материал компилятор.
Для декодирования данных используем три функции (код под катом):
WindBending1Step - для использования на последнем LOD.
float3 WindBending1Step (Texture2D WNoise, SamplerState WNoiseSampler,
float time, float3 WorldPosition,
float4 WndVec, float TexCoord1)
{
const float3 invVec = float3 (0,0,-1);
const float UVScale1 = 0.0005;
const float PanSpeed = 0.1;
float2 WUV = WorldPosition.rg * UVScale1 + PanSpeed * time;
float Noise = Texture2DSample (WNoise, WNoiseSampler, WUV);
float HeightWeight = (TexCoord1 * TexCoord1).g;
return (HeightWeight * invVec + WndVec.rgb)
* HeightWeight * Noise * WndVec.a;
}
WindBending2Step - универсальная функция для работы ветра и с стеблем и с листом.
float3 WindBending2Step(Texture2D WNoise, SamplerState WNoiseSampler,
Texture2D HNoise, SamplerState HNoiseSampler,
float time, float3 WorldPosition,
float4 WndVec, float TexCoord1, float TexCoord2)
{
const float3 invVec = float3 (0,0,-1);
const float UVScale1 = 0.0005;
const float UVScale2 = 0.002;
const float PanSpeed = 0.075;
float2 Weight = TexCoord1 * TexCoord1;
float3 AWeight = Weight.r * invVec + WndVec.rgb;
float3 BWeight = Weight.g * invVec + WndVec.rgb;
float mTime = PanSpeed * time;
float2 WUV1 = WorldPosition.rg * UVScale1 + mTime;
float2 WUV2 = WorldPosition.rg * UVScale2 + TexCoord2.r + mTime;
float2 Noise = float2 (Texture2DSample (WNoise, WNoiseSampler, WUV1).r,
Texture2DSample (HNoise, HNoiseSampler, WUV2).r)
* Weight;
return WndVec.a * (Noise.g * AWeight + 3.0 * Noise.r * BWeight);
}
Custom Leaf Rotation - применяется совместно с WindBending2Step, позволяя отклонять листья в разные стороны в зависимости от направления ветра.
float3 CustomLeafRotation (float4 WndVec, float3 WorldPosition, float time,
float3 VC, float TexCoord1, float TexCoord2,
float TurningForce = 2.0)
{
const float3 upVec = float3 (0,0,1);
// 0.5 and 80 - Vertex Vectors from 0-1 space to World Space (Source size)
float3 Origin = WorldPosition - TransformLocalVectorToWorld((VC-0.5)*80);
float3 LeafVec = float3(normalize(2*(TexCoord2-0.5),0);
float RotLeafStrenght = dot(LeafVec, -WndVec.rgb)+1;
float leafRotBasis = RotLeafStrenght * RotLeafStrenght * (-0.0875) *
clamp(dot(cross(LeafVec, -WndVec.rgb), upVec),-1.0, 1.0);
float leafRotVariation = sin(TexCoord2.r * time * 0.01 * WndVec.a) * 0.01;
float PerVertexMaxForce = TexCoord1.r * TurningForce;
float RotationAngle = (leafRotBasis + leafRotVariation) * PerVertexMaxForce;
return RotateAboutAxis(float4(upVec, RotationAngle), Origin, WorldPosition);
}
Так, одинаковое кол-во инстансов (сценарий использования единовременно всех биомов в перемешку, с полным покрытием фрейма) показывает на оригинальной, чутка облегченной версии травы - 63ms, а представленная переработка выдает 22ms.
Данная версия травы присутствует только на Nintendo Switch, и готовилась в первую очередь под нее.
Чтобы иметь возможность разделить Grass и Foliage поверсионно для разных консолей, нам пришлось добавить свой Per Platform Replace теперь и в Grass Type и в Foliage.
THE END
Наша команда проделала огромную работу. Перед нами стояла задача - еще во время активной фазы разработки успеть запустить полноценную играбельную, нативную версию на Nintendo Switch и PS4, PS5, Xbox One и Xbox Series, при этом не потеряв в картинке и не остановив все производство.
Не повторяйте наш путь - если вы планируете игру с выходом на Nintendo Switch, то разрабатывайте игру сразу с таргетом и ограничениями самой младшей платформы или же занимайтесь портированием игры только после выпуска всех DLC и патчей.
Команде все еще предстоит много работы - необходимо доработать стриминг до регионального, необходимо изменить подход к изготовлению контента, доработать рендер и много-много других вещей.
Если вам понравилась данная статья, то я могу рассказать еще много всего о особенностях разработки проекта под консоли, устройства движка и другие веселые и не очень Tech Art истории.
Работы, упомянутые в статье, были бы невозможны без помощи коллег, а именно:
Владимир Баранов - программная реализация Platform Switch, работы по оптифайнеру и доработке стриминга, оптимизация рендера и просто огромное количество неоценимо важных для проекта работ.
Владимир Ширшов, Алексей Модянов - работы по оптимизации стриминга.
Елена Помелова - наш замечательный QA, записавший File Open Order. Огромная помощь в выявлении проблем и тестирования всех консолей.
Максим Миненко - доработка движка для исключения неиспользованных ресурсов при компиляции материалов, помощь с оптифайнером второй итерации.
Максим Рещиков - прототип первичной подгрузки слоев стриминга.
Иван Алхимов и Александр Смертин - активная работа с материалами.
Александр Рыбалка - курирование работ StoryMode.
StoryMode - запуск первой сборки под Nintendo Switch, помощь в оптимизации памяти и написании тулов.