Оптимизация игр на Unreal Engine 4

 width=

Это руководство призвано помочь разработчикам улучшить производительность в играх, сделанных на Unreal Engine 4 (UE4). Здесь мы расскажем об инструментах, которыми можно пользоваться и в самом движке, и за его пределами, о наилучших подходах в использовании редактора, а также о скриптинге, помогающем увеличить фреймрейт и стабильность проекта.

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

При написании этого руководства использовался UE4 версии 4.14.

Единицы измерения

Для измерения оптимизационных улучшений используются кадры в секунду (эту единицу измерения также называют «фреймрейтом» или «фпс») и миллисекунды на кадр («мс»).

График ниже показывает отношение между средним фреймрейтом и миллисекундами.

 width=

Чтобы узнать мс при каком-либо фпс, просто узнаем величину, обратную фпс (т.е. берем 1 и делим ее на фпс), а затем умножаем ее на 1000.

1/ФПС x 1000 = МС

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

Вот пара примеров увеличения фпс на 20 кадров в секунду:

  • Чтобы поднять фпс с 100 до 120, нужно улучшить результат на 1,66 мс
  • Чтобы поднять фпс с 10 до 30, нужно улучшить результат на 66,67 мс

Инструменты

Перед тем, как начать, давайте рассмотрим три инструмента – чтобы понять, что происходит под капотом движка. Это UE4 CPU Profiler, UE4 GPU Visualizer и Intel Graphics Performance Analyzers (Intel GPA).

Profiler

UE4 CPU Profiler – это инструмент, который встроен в UE4 и позволяет отслеживать производительность в игре, будь это игра вживую или просто сохраненный фрагмент.

Чтобы найти Profiler, кликните в UE4 на Window > Developer Tools > Session Frontend.

 width=

Как добраться до окна Session Frontend

В Session Frontend выберите вкладку Profiler.

 width=

Profiler в Unreal Engine

Теперь, когда вы в окне Profiler, выберите Play-In-Editor (PIE), а затем выберите Data Preview и Live Preview, чтобы увидеть данные, считываемые с игры. Чтобы начать захват данных, нажмите Data Capture, а чтобы сохранить эти данные для дальнейшего просмотра, отожмите Data Capture.

 width=

Просмотр процессов через Profiler

В Profiler каждое действие и команда отражаются в миллисекундах. Каждую область можно изучить на предмет того, как она влияет на фреймрейт проекта.

Более подробно о Profiler читайте в документации Epic.

GPU Visualizer

UE4 GPU Visualizer определяет, сколько вычислительных ресурсов требуется на проходы рендеринга (от «rendering pass»), а также позволяет во всех подробностях просматривать, что происходит в пределах того или иного кадра.

Открыть GPU Visualizer можно через консоль разработчика, вписав туда «ProfileGPU».

 width=

Консольная команда ProfileGPU

После того, как вы впишите команду, появится окно GPU Visualizer. В нем демонстрируется, сколько времени занимают проходы, а также примерное расположение этих проходов в сцене.

 width=

Просмотр процессов через GPU Visualizer

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

Более подробно о GPU Visualizer читайте в документации Epic.

Intel GPA

Intel Graphics Performance Analyzers (Intel GPA) – это набор инструментов для анализа и оптимизации, призванных помочь разработчикам сделать свои графические проекты более производительными.

В этом руководстве мы сфокусируемся на двух аспектах этого набора: Analyze Application и Frame Analyzer. Для начала загрузите GPA с Intel Developer Zone. Установив, скомпилируйте свой проект с настройкой Development (чтобы выбрать ее, кликните на File > Package Project > Build Configuration > Development).

Когда проект скомпилируется, запустите Graphics Monitor, кликните на пункт Analyze Application, выберите нужный файл *.exe в поле Command Line и, нажав на кнопку Run, запустите его.

Далее запустится игра – так же, как запускается обычно, однако в левом верхнем углу теперь будет меню со статистикой. Чтобы расширить его, кликните Ctrl+F1. Если нажать на Ctrl+F1 один раз, появится несколько окошек с показателями, измеряемыми в реальном времени. Если нажать Ctrl+F1 еще раз, появится список команд (плюс горячие клавиши, которые нужно нажать для их выполнения), с помощью которых можно экспериментировать c игрой, пока та будет запущена.

 width=

Меню Intel GPA в игре

Чтобы сделать кадр для последующего анализа в Frame Analyzer, нужно зайти в игру и сделать два дополнительных действия.

Сначала включите Toggle Draw Events. Чтобы сделать это, впишите в консоли «ToggleDrawEvents».

 width=

Консольная команда ToggleDrawEvents

Когда вы включите эту функцию, командам отрисовки, идущим от движка, будут присвоены названия. Это позволит понимать что к чему, когда вы будете смотреть на захваченный кадр в Frame Analyzer.

Наконец, сохраните кадр, нажав на горячие клавиши Ctrl+Shift+C.

Сохранив кадр, запустите Graphics Monitor, кликните на пункт Graphics Frame Analyzer и выберите кадр, который нужно загрузить. После того, как сохранение будет завершено, программа покажет всю информацию о графике, имеющейся в кадре.

 width=

Intel GPA

Более подробно об Intel GPA читайте в документации Intel.

Пример использования Intel GPA

Обилие данных в Intel GPA поначалу кажется сложным, поэтому давайте начнем с самых больших фрагментов информации. В правом верхнем углу окна выставьте обе оси (X и Y) на GPU Duration – в результате получится график того, какие команды отрисовки в этом кадре являются наиболее ресурсоемкими.

В нашем примере, т.е. в кадре с пустынным ландшафтом, видно, что самым ресурсоемким получился базовый проход. Выбрав самый большой пик на графике (то есть, в сущности, самую ресурсоемкую команду отрисовки), а также пункт Highlighted в левом нижнем превью-окне (оно называется Render Target Preview), мы видим, что причиной пика стал ландшафт (он подсвечен розовым цветом).

Далее, перейдя в окно Process Tree List (оно находится выше превью-окна и показывает список процессов), чтобы найти выбранную команду отрисовки, мы видим, что этот ландшафт состоит из 520200 примитивов, а на его обработку у GPU (это и есть показатель GPU Duration) уходит 1,3185 миллисекунд (мс).

 width=

 width=

Поиск самой ресурсоемкой команды отрисовки в кадре

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

Во-первых, ландшафт можно пересобрать при помощи режима Manage у инструмента UE4 для создания ландшафтов, что позволяет снизить количество примитивов до 129032, а GPU Duration – до 0,8605 мс. Таким образом, сцена оптимизируется на 5%.

 width=

Видим уменьшение GPU Duration

Чтобы снова снизить ресурсную «стоимость» ландшафта, давайте взглянем на материалы. Наш ландшафт использует материал с 13 текстурами 4096 x 4096 (4K), и в результате на текстурный стриминг приходится в целом 212,5 Мб.

 width=

Просмотр отрендеренных текстур в Intel GPA

Сжав все ландшафтные текстуры до 2048 х 2048 (2K), мы сократили GPU Duration до 0,801 мс и улучшили производительность на дополнительные 6%.

В итоге снижение текстурного стриминга для ландшафта до 53,1 Мб и уменьшение числа примитивов позволили сделать проект более быстрым. И все это ценой лишь очень небольшого снижения визуального качества ландшафта.

 width=

Видим снижение GPU Duration, достигнутое благодаря снижению размера текстур

В целом, просто пересобрав сцену и изменив текстуры, мы сумели достичь следующего:

  • Снизили GPU Duration при обработке ландшафта на 40 % (с 1,3185 до 0,801 мс)
  • Улучшили фпс на 18 кадров (с 143 до 161)
  • Снизили мс на 0,7 миллисекунд

Оптимизация в редакторе

Forward Rendering против Deferred Rendering

Deferred Rendering – это стандартный метод рендеринга, используемый в UE4. Использование Deferred Rendering, как правило, улучшает картинку, но и может повлечь проблемы с производительностью, особенно в VR-играх и на слабых машинах. В данных случаях разумнее переключиться на Forward Rendering.

К примеру, в сцене Reflection из магазина Epic можно заметить что между рендерингом методами Deferred и Forward есть некоторые отличия.

 width=

Сцена Reflection, отрендеренная методом Deferred

 width=

Сцена Reflection, отрендеренная методом Forward

При Forward-рендеринге страдают отражения, освещение и тени, но остальные визуальные элементы не меняются. В итоге производительность улучшается, но нужны ли такие жертвы, решать, разумеется, вам.

Если взглянуть в Intel GPA на кадр из этой сцены, отрендеренной методом Deferred, мы увидим, что сцена работает на 103,6 мс (9 фпс), и значительную часть этого времени занимает обработка освещения и отражений.

 width=

Данные о кадре из сцены Reflection, отрендеренной методом Deferred на Intel HD Graphics 530

А если взглянуть на кадр, отрендеренный методом Forward, мы увидим, что показатель «мс» улучшился с 103,6 до 44,0 (т.е. на 259%), а больше всего времени уходит на базовый проход и пост-обработку, над оптимизацией которых тоже можно поработать.

 width=

Данные о кадре из сцены Reflection, отрендеренной методом Forward на Intel HD Graphics 530

Уровень детализации

Статические сетки в UE4 могут состоять из тысяч или даже сотен тысяч треугольников – чтобы показать самые мельчайшие детали, которыми 3D-художник украсил свою работу. Однако, когда игрок находится далеко от модели, он этих деталей не видит, а движок эти треугольники по-прежнему обрабатывает. Чтобы решить эту проблему и тем самым оптимизировать игру, мы можем использовать так называемые «уровни детализации» (или просто LOD – от англ. «level of detalization»), чтобы на близком расстоянии эти детали показывались, а на далеком – нет.

Генерация LOD

В стандартном пайплайне LOD’ы создаются 3D-моделлером еще при создании самой модели. Хотя этот метод позволяет контролировать конечный результат, в UE4 встроен отличный инструмент для автоматической генерации LOD’ов.

Автогенерация LOD’ов

Для этого выберите нужную модель, перейдите во вкладку Details, а затем к пункту LOD Settings. Там найдите пункт Number of LODs (т.е. количество уровней детализации) и впишите туда нужное значение.

 width=

Автоматическая генерация уровней детализации

Кликните на Apply Changes. Для движка это станет сигналом для генерации нескольких LOD’ов, и оригинальной моделью среди них будет LOD0. В примере ниже показано, что при создании пяти LOD’ов количество треугольников у нашей статической сетки уменьшается с 568 до 28 – это значительное снижение нагрузки на GPU.

 width=

Количество треугольников и вершин, а также размер на экране для каждого LOD’а

Если разместить эту модель на сцене, мы увидим, как она будет меняться при удалении от камеры.

 width=

Визуальная демонстрация LOD’ов, показанная в зависимости от размера на экране

Материалы для LOD’ов

Еще одна функция LOD’ов – это то, что каждый из них может использовать собственный материал. Это позволяет еще больше снизить «стоимость» статической сетки.

 width=

Материалы, присвоенные каждому уровню детализации

К примеру, в игровой индустрии повсеместно используются карты нормалей. Однако в VR-играх возникает проблема – карты нормалей не идеальны, поскольку при ближайшем рассмотрении игрок видит, что это просто плоская поверхность.

Эту проблему можно решить при помощи LOD’ов. Поскольку LOD0 детализирован до такой степени, что на нем видны мелочи вроде болтов и шурупов, когда игрок рассматривает этот объект вблизи, то испытывает более сильный эффект погружения. Поскольку все эти детали смоделированы, на первом LOD’е от карты нормалей можно отказаться. Когда игрок удаляется от этого объекта, движок переключается на другой LOD, на котором как раз стоит карта нормалей, снижающая детализацию модели. Когда игрок удалится еще дальше, карту нормалей можно убрать, потому что она станет слишком маленькой и ее попросту не будет видно.

Статические сетки-экземпляры

Каждый раз, когда на сцене появляется новый объект, это требует вызова дополнительной команды отрисовки на устройстве, обрабатывающем графику. Если это статическая сетка, то для каждой копии этой сетки потребуется отдельный вызов команды отрисовки. Один из способов оптимизировать это (т.е. ситуацию, когда одна и та же статическая сетка повторяется на сцене несколько раз) – создать экземпляры статических сеток и тем самым снизить количество вызываемых команд отрисовки.

К примеру, у нас есть две сферы, состоящие из 200 восьмигранных сеток – одна зеленая, а другая синяя.

 width=

Сфера из статических сеток и сеток-экземпляров

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

 width=

Команды отрисовки для 200 статических сеток (максимум – 569)

Синие восьмигранники – это сетки-экземпляры. Это значит, что для генерации всех этих моделей использовался только один набор команд отрисовки.

 width=

Команды отрисовки для 200 сеток-экземпляров (максимум – 143)

Если взглянуть на оба примера через GPU Visualizer, то базовый проход для зеленой (со статическими сетками) сферы занимает 4,30 мс, а для синей (с сетками-экземплярами) – 3,11 мс. Таким образом, мы оптимизируем сцену на 27%.

О сетках-экземплярах нужно знать одну вещь – если у такой сетки рендерится какая-то часть, она будет рендериться и у всех остальных «клонов» этой сетки. То есть, если какой-то из «клонов» оказывается за пределами камеры, наш оптимизационный потенциал растрачивается впустую. Поэтому рекомендуем делать сетки-экземпляры небольшими кучками – вроде груды камней, кучи мешков для мусора, горы коробок или модульных зданий, находящихся поодаль.

 width=

Если большинство сеток-экземпляров находится за кадром, они по-прежнему рендерятся

Иерархические статические сетки-экземпляры

Если вы используете статические сетки с LOD’ами, обратите внимание на иерархические сетки-экземпляры.

 width=

Сфера из иерархических сеток-экземпляров с LOD’ами

Как и стандартные сетки-экземпляры, иерархические сетки-экземпляры снижают количество команд отрисовки, но также используют информацию о LOD’ах.

 width=

Сфера из иерархических сеток-экземпляров с LOD’ами; вид вблизи

Occlusion Culling

В движке UE4 функция Occlusion Culling – это система, позволяющая сделать так, чтобы объекты, которые игрок не видит, не рендерились. Это позволяет снизить системные требования к игре, поскольку движку больше не приходится рисовать абсолютно все объекты абсолютно во всех сценах и абсолютно во все кадрах.

 width=

Восьмиугольники, разбросанные по сцене

Чтобы видеть загороженные объекты (они будут показаны прозрачными кубами с зелеными краями), в консоли редактора введите «r.VisualizeOccludedPrimitives 1». Чтобы выключить эту настройку, вместо «1» впишите «0».

 width=

Края загороженных сеток; эти края стали видны после использования команды r.VisualizeOccludedPrimitives 1

То, будет сетка рендериться или нет, зависит от так называемого «граничного куба» (от англ. «bounding box»). Благодаря ему некоторые объекты могут быть невидимы для игрока, но видны для камеры – в таком случае движок принимает решение рендерить эти объекты.

 width=

Просмотр границ объекта в окне для работы с объектом

Если сетку нужно отрендерить до того, как ее увидит игрок – например, для рендеринга анимации бездействия (от англ. «idle animation», это анимация персонажа, которая активируется, когда он стоит на месте и ничего не делает; это может быть чесание затылка, ковыряние ногой в земле и т.д.) – то размер граничного куба можно увеличить. Это можно сделать в окне для работы с объектом, в меню Static Mesh Settings. Ищите там пункты Positive Bounds Extension и Negative Bounds Extension.

 width=

Задаем масштаб для границ объекта

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

Давайте представим мысленный эксперимент, где мы создаем 3D-модель, а затем экспортируем ее в UE4. Как нам подойти к созданию арены в стиле Колизея?

Допустим, игрок стоит в центре арены и оглядывает огромный Колизей, пытаясь запугать своих противников. Когда игрок вращает камеру, то ее направление и ракурс будут диктовать то, что движку нужно рендерить. Поскольку Колизей – это очень важный элемент нашей игры, мы сделали его очень детальным, но чтобы сэкономить на командах отрисовки, его нужно сделать из нескольких объектов.

Но, во-первых, мы должны отказаться от идеи того, что вся арена должна быть одним большим цельным объектом. В этом случае количество треугольников, которые нужно будет отрендерить, будет соответствовать размеру всей арены – независимо от того, смотрим ли мы на ее отдельные части или нет. Как нам оптимизировать эту модель?

Зависит от нескольких факторов. Во-первых, от того, на какие кусочки будет порезана арена, и во-вторых, от того, как форма этих кусочков будет влиять на размер граничных кубов (что важно для Occlusion Culling). Чтобы было проще, давайте представим, что игрок использует камеру с 90-градусным углом обзора.

Вариант первый – «нарезанная пицца». То есть мы создаем 8 идентичных острых кусочков, «носы» которых направлены в центр арены. Этот метод прост, но для Occlusion Culling он подходит не очень, поскольку в этом случае будет много перехлестов между граничными кубами. Если игрок будет стоять в центре и смотреть вокруг, его камера будет захватывать 3-4 куба, т.е. большую часть времени движку придется рендерить половину арены. В самом худшем случае игрок может стать спиной к стене, посмотреть на арену вокруг него и тем самым захватить в кадр все 8 кусочков «пиццы». Оптимизации никакой.

Вариант второй – «крестики-нолики». Здесь мы создаем 9 кусочков. Это не самый традиционный метод, но его преимущество в том, что здесь нет перехлестов между граничными кубами. Как и в случае с «пиццей», если игрок будет стоять в центре арены, то захватит в кадр 3-4 кусочка. Однако, став спиной к стене, он захватит в кадр 6 из 9 кусочков, что по сравнению с «пиццей» дает некоторую оптимизацию.

Последний вариант – «нарезанное яблоко» (1 центральный кусочек и 8 боковых). Это самый распространенный метод для этого мысленного эксперимента, и очень неплохой – перехлест между граничными кубами есть, но небольшой. Если игрок будет стоять в центре арены, то захватит в кадр 5-6 кусочков, но в отличие от первых двух вариантов, в самом худшем случае (вплотную спиной к стене) будут рендериться те же 5-6 кусочков.

 width=

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

Каскадные карты теней

Хотя каскады динамических теней (от англ. «dynamic shadow cascade») добавляют вашей игре высокий уровень детализации, они могут оказаться очень «дорогостоящими» в плане производительности – чтобы играть в такую игру без потери фреймрейта, понадобится мощный PC.

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

«Стоимость» каскадов динамических теней на Intel Graphics 350

 width=

Значением в каскадах динамических теней можно управлять динамически. Это можно сделать несколькими способами:

  • Поменяв качество теней в настройке Settings > Engine Scalability Settings > Shadows
  • Отредактировав параметры в файле «BaseScalability.ini»: в настройках Shadow.CSM.MaxCascades (между «0» и «4») и sg.ShadowQuality (между «0» и «3» – для «низкий», «средний», «высокий» и «эпический»)
  • Добавив нод Execute Console Command в блюпринт игры, где вы вручную поменяли параметр Shadow.CSM.MaxCascades

Оптимизация через скриптинг

Отключение полностью прозрачных объектов

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

Чтобы сделать это при помощи блюпринтов, в UE4 нужно задействовать несколько разных систем.

Набор параметров для материалов

Во-первых, создаем набор параметров для материалов (или просто MPC – от англ. «material parameter collection»). Здесь будут храниться линейные и векторные параметры, которые можно будет привязать к любому материалу в игре. Их можно использовать для модификации этих материалов прямо во время игры – для создания динамических эффектов.

Создаем MPC, кликая на вкладке Content Browser по Add New > Materials & Textures > Material Parameter Collection.

 width=

Создание MPC

Будучи в MPC, мы можем создать, назвать и задать дефолтные значения для линейных и векторных параметров. В нашем случае понадобится линейный параметр – мы назовем его Opacity (т.е. «прозрачность») и с его помощью будем управлять прозрачностью нашего материала.

 width=

Задаем линейный параметр под названием Opacity

Материал

Далее нам нужен материал, на котором мы будем использовать MPC. В этом материале создаем нод Collection Parameter. В этом ноде выбираем MPC и то, какие параметры будут использоваться.

 width=

Поиск нода Collection Parameter в материале

Создав нод, соединяем его с параметром Opacity на базовом материале.

 width=

Настраиваем Collection Parameter в материале

Скрипт в блюпринте

Создав MPC и материал, заходим в блюпринт и настраиваем его так, чтобы можно было задавать и считывать значения с MPC. Это делается при помощи нодов Get/Set Scalar Parameter Value и Get/Set Vector Parameter Value. Далее заходим в эти ноды, в пункте Collection выбираем набор, который хотим использовать (MPC), а в пункте Parameter Name – название параметра из этого набора.

Для этого примера мы делаем так, чтобы линейное значение Opacity было синусом ко времени игры – чтобы видеть значения в диапазоне от «1» до «-1».

 width=

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

Чтобы определять, рендерится объект или нет, создаем новую функцию под названием Set Visible Opacity. Входными значениями у нее будут параметр Opacity из MPC и статическая сетка, а выходным – значение типа Boolean, сообщающее о том, виден ли объект или нет.

Далее мы запускаем проверку на то, чтобы значение было чуть больше «0» (в данном случае, больше «0,05»). Проверка на «0» может сработать, но при приближении к «0» игрок больше не сможет видеть объект, поэтому мы можем просто выключить его до того, когда значение станет «0». Кроме того, это позволяет создать буфер – на случай ошибок с плавающей точкой, из-за которых линейный параметр не сможет получить точный «0». К примеру, если значением будет «0,0001», эта система его просто выключит.

Далее создаем нод Branch – если на выходе у него будет True, то и видимости объекта (верхний нод Set Visibility) будет дано значение «true», а если False, то видимости объекта (нижний нод Set Visibility) будет дано «false».

[caption id=»attachment_40328″ align=»aligncenter» width=»94