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

Разработка игр | |

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

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

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

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

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

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

Чтобы узнать мс при каком-либо фпс, просто узнаем величину, обратную фпс (т.е. берем 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.

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

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

Profiler в Unreal Engine

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

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

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

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

GPU Visualizer

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

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

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

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

Просмотр процессов через 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 игрой, пока та будет запущена.

Меню Intel GPA в игре

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

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

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

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

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

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

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 миллисекунд (мс).

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

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

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

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

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

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

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

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

Видим снижение 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 есть некоторые отличия.

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

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

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

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

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

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

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

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

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

Генерация LOD

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Occlusion Culling

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Поменяв качество теней в настройке 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.

Создание MPC

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

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

Материал

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

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

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

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

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

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

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

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

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

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

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

Функция Set Visible Opacity

Нод Event Tick, время и проверка на рендер

Если в блюпринте сцены используется нод Event Tick, эти скрипты будут работать, даже если объектов на экране не видно. Как правило, в этом нет ничего страшного, но чем меньше блюпринтов «тикает» в течение каждого кадра, тем шустрее эта сцена бегает.

Вот несколько ситуаций, когда можно использовать этот тип оптимизации:

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

В качестве самого простого решения перед Event Tick можно поставить нод Was Recently Rendered. Таким образом, для того, чтобы наш Event Tick включался/выключался, нам не нужно подключать к нему специальные события и детекторы. Кроме того, эта система по-прежнему может быть независима от других процессов, происходящих в сцене.

Управление нодом Event Tick с помощью проверки на рендер

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

Значение Emissive Value в Material Collection задано таким образом, что во время рендера оно будет работать как абсолютная синусоида игрового времени

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

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

Это работает и с остатком целочисленного деления, но в этом случае граф выглядит по-другому.

Проверку на рендер при помощи нода Was Recently Rendered можно сделать и чуть позже. То есть, если у объекта, которым управляет нод Event Tick, есть какие-то задачи, которые нужно выполнять каждый кадр, но можно сделать так, чтобы эти задачи выполнялись и до проверки на рендер (см. граф ниже). Чем меньше нодов будет вызываться с каждым «тиком» нода Event Tick, тем лучше.

Использование проверки на рендер для управления визуальными фрагментами блюпринта

Еще один способ снизить «стоимость» блюпринта – это замедлить его и позволить ноду Event Tick «тикать» только один раз в течение определенного интервала. Этот интервал задается при помощи нода Set Actor Tick Interval.

Переключение между интервалами

Кроме того, интервал, с которым «тикает» нод Event Tick, можно задать в пункте Tick Interval – он находится во вкладке Details у блюпринта, над которым вы работаете. Здесь интервал задается в секундах.

Пункт Tick Interval во вкладке Details

Это удобно, к примеру, когда нужно сделать счетчик, срабатывающий каждую секунду.

Создаем параллельный счетчик, срабатывающий каждую секунду

В качестве примера того, как этот тип оптимизации может снизить средний показатель мс, давайте взглянем на граф ниже:

Невероятно полезный пример того, как делать не нужно

Здесь у нас нод ForLoop, который считает от «0» до «10000», и для него через нод SET задано целое число Count. Этот граф очень ресурсоемок и неэффективен – настолько, что показатель мс у нашей сцены составляет целых 53,49 мс.

Просмотр в Stat Unit «стоимости» невероятно полезного примера

Перейдя в Profiler, мы понимаем почему. Этот простой, но крайне неэффективный блюпринт за каждый тик «поедает» 43 мс.

Просмотр в Profiler фрагмента, отвечающего за срабатывание Event Tick в каждом кадре

Но если заставить этот блюпринт «тикать» каждую секунду, то большую часть времени он будет «поедать» 0 мс. Если посмотреть на среднее время (выделите какой-нибудь фрагмент таймлайна в окне Graph View) за три «тика», мы увидим, что средний показатель составляет 0,716 мс.

Просмотр в Profiler фрагмента, отвечающего за срабатывание Event Tick каждую секунду

Или возьмем более распространенный случай: допустим, у нашего блюпринта 1,4 мс, и если сцена будет работать на 60 фпс, то на обработку этого блюпринта будет уходить 84 мс. Но если уменьшить время, в течение которого у блюпринта «тикает» нод Event Tick, то это снизит и общее время, затрачиваемое на обработку этого блюпринта.

Массовое движение, нод ForLoop и многопоточность

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

В этом разделе мы воспользуемся скриптом, который будет динамически перемещать 1600 сфер-экземпляров вверх/вниз по модифицированной синусной кривой.

Ниже – простой скрипт, создающий решетку. Просто добавляем компонент Instanced Static Mesh, во вкладке Details выбираем сетку, которую будем использовать, а затем добавляем следующие ноды:

Скрипт для создания простой решетки

Создав решетку, добавляем этот скрипт во вкладку Event Graph.

Пару слов о ноде Update Instance Transform. Если какой-либо из экземпляров будет трансформирован, это изменение не будет показано, пока пункт Mark Render State Dirty не будет помечен как «true». Но это ресурсоемкая операция, т.к. проверка идет через каждую сетку. Чтобы сэкономить вычислительные ресурсы, особенно если этот нод запускается по несколько раз за «тик», можно сделать так, чтобы сетки обновлялись в конце блюпринта. В скрипте ниже пункт Mark Render State Dirty помечается как «true» только при соблюдении двух условий – если на ноде ForLoop стоит Last Index и если значение в Index соответствует Grid Size минус 1.

Блюпринт с динамическим движением для статических сеток-экземпляров

Кроме того, при помощи блюпринта типа Actor, скрипта для создания решетки и события для динамического движения мы можем создать разные варианты решетки, где одновременно будет показываться 1600 сеток.

Несколько вариантов решетки с 1600 сетками

Запустив сцену, мы увидим элементы решетки, плавающие вверх и вниз.

Решетка из 1600 статических сеток-экземпляров, динамически двигающихся вверх и вниз

Тем не менее, тип разбиения на фрагменты влияет на скорость, с которой работает сцена.

Во-первых, если решетка будет состоять из 1600 отдельных фрагментов, то все 1600 блюпринтов будут обработаны за 16,86 мс (т.е. в среднем 0,0105 мс на один блюпринт). То есть, хотя «цена» одного блюпринта невелика, их суммарное количество замедляет систему. Единственная вещь, которую здесь можно сделать – это уменьшить количество блюпринтов, срабатывающих с каждым «тиком». Другая причина большой нагрузки заключается в большом количестве отдельных сеток, из-за чего увеличивается количество и команд отрисовки, и команд для трансформации сетки.

Во-вторых, если решетка будет состоять из одного фрагмента, в который входит 1600 сеток, то этот вариант будет очень хорошо оптимизирован в плане команд отрисовки (потому что на всю решетку потребуется только одна команда отрисовки), но «стоимость» блюпринта, которому за один «тик» нужно будет обработать 1600 сеток, составит 19,63 мс.

Но если разбить решетку по-другому (на 4 фрагмента на 400 сеток, на 64 по 25 или на 16 по 100), то результат получается более оптимизированным – благодаря уменьшенному времени обработки скрипта и умению UE4 работать с многопоточностью. Благодаря последней UE4 может распределять нагрузку по обработке блюпринтов по нескольким рабочим потокам, тем самым эффективно используя все ядра CPU.

Если мы посмотрим на время обработки блюпринтов и то, как они распределяются по рабочим потокам, то увидим следующее:

Структуры данных

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

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

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

Самый распространенный метод использования объектного пула – это взять первую, пока еще не включенную пулю в массиве/списке, переместить ее в стартовую позицию, включить ее, а затем выключить, когда она вылетит за экран или попадет во врага. Проблема этого метода во времени, которое требуется на работу скрипта, т.е. в большом «О». Поскольку вы делаете много циклов, проверяя объекты и ища, какой из них нужно выключить, при использовании 5000 объектов на поиск одного объекта может уйти очень много циклов. У этого метода время будет представлено в виде O(n), где «n» – это количество объектов в наборе.

Хотя O(n) – это далеко не худший алгоритм. Чем ближе мы будем к O(1) – т.е. к фиксированной «стоимости», независящей от размера – тем более эффективным будет скрипт и более шустрой будет игра. Чтобы провернуть этот прием вместе с объектным пулом, мы используем структуру данных, именуемую «очередью». Как и в настоящей очереди, эта структура данных берет первый объект в наборе, использует его, а затем удаляет – и так до тех пор, пока не использует все объекты в очереди.

Используя эту «очередь» для нашего объектного пула, мы можем взять передний фрагмент набора, включить его, затем убрать и сразу же поместить его в заднюю часть набора. Это создаст в скрипте эффективный цикл и уменьшит время работы скрипта до O(1). Мы также можем добавить к этому циклу проверку – если удаленный объект был включен, то скрипт берет его и, не создавая новый объект, помещает в конец очереди, увеличивая размер набора, но не увеличивая времени обработки скрипта.

Очереди

Ниже – несколько картинок, которые демонстрируют, как использовать очереди. В них к блюпринтам добавляются различные функции, которые делают код более «чистым» и придают ему возможность повторного использования.

  • Удаление

Реализация конструкции queue::pop в блюпринте UE4; удаляет элемент с переднего конца очереди

  • Добавление

Реализация конструкции queue::push в блюпринте UE4; вставляет новый элемент в конец очереди

  • Определение того, пуста ли очередь

  • Определение размера очереди

Реализация конструкции queue::size в блюпринте UE4; сообщает размер очереди

  • Возвращение указателя на первый элемент в очереди

Реализация конструкции queue::front в блюпринте UE4; возвращает указатель на первый элемент в очереди

  • Возвращение указателя на последний элемент в очереди

Реализация конструкции queue::back в блюпринте UE4; возвращает указатель на последний элемент в очереди

  • Вставка элемента в определенное место очереди

Вставляет указанный элемент в указанное место очереди (с проверкой позиции)

  • Обмен данными

Реализация конструкции queue::swap в блюпринте UE4; заставляет два контейнера обменяться данными (с проверкой позиции)

Стеки

Ниже – несколько картинок, которые демонстрируют, как использовать стеки. В них к блюпринтам добавляются различные функции, которые делают код более «чистым» и придают ему возможность повторного использования.

  • Удаление

Реализация конструкции stack::pop в блюпринте UE4; удаляет элемент с переднего конца стека

  • Добавление

Реализация конструкции stack::push в блюпринте UE4; вставляет новый элемент в конец стека

  • Определение того, пуст ли стек

Реализация конструкции stack::empty в блюпринте UE4; сообщает, пуст ли стек

  • Определение размера стека

Реализация конструкции stack::size в блюпринте UE4; сообщает размер стека

  • Возвращение указателя на последний элемент в стеке

Возвращает указатель на последний элемент в стеке

  • Вставка элемента в определенное место стека

Вставляет указанный элемент в указанное место стека (с проверкой позиции)

Авторы статьи – сотрудники Intel Эррин М. (Errin M) и Джефф Роус (Jeff Rous).

Оригинал можно прочесть тут.

Владимир FrostBite Хохлов frostbite@progamer.ru

Поделиться

Обсудить