TL;DR
Подёргивания (статтеры) часто вызваны компиляцией шейдеров «на лету». Современные API (D3D12, Vulkan) решают это через объекты состояния графического конвейера — PSO.
В Unreal Engine 5.2+ появился механизм предкэширования PSO: во время загрузки анализируются материалы, типы мешей и глобальные настройки, формируется реалистичное подмножество PSO и компилируется заранее. Это резко снижает статтеры, в том числе при пользовательском контенте.
Исторический подход Bundled PSO Cache остаётся полезен в паре с предкэшированием (например, для самых частых материалов и глобальных графических шейдеров).
Кэш драйвера сохраняет скомпилированные PSO между сессиями: первый запуск дольше, дальше — быстрее. Компромисс: держать предкэшированные PSO в памяти (меньше микростаттеров, но больше потребление ОЗУ) или выгружать их.
На мобилках предкэш есть, но с отсечением редких комбинаций и тайм-аутами; возможны редкие разовые статтеры. Консоли вне проблемы: единая архитектура GPU и шейдеры поставляются уже в машинном виде.
Пробелы покрытия ещё есть: по состоянию на UE 5.5 глобальные графические шейдеры не полностью включены в предкэш, команда двигает это направление.
Практика для команд: обновляться на свежий UE; профилировать предкэш (
r.PSOPrecache.Validation=2); регулярно гонять плейтесты с-clearPSODriverCache; автоматизировать сбор статистики PSO; не забывать про другие источники статтеров (I/O, спавн, подгрузка, захваты сцены).
Итог: предкэширование PSO в UE5 — рабочая стратегия против статтеров в больших и динамичных проектах; она уже даёт эффект, а закрытие оставшихся дыр — в активной разработке.
В сообществе Epic в последнее время активно обсуждают статтеты (stuttering или «подергивания»), вызванные компиляцией шейдеров, и их влияние на геймдев-проекты.
Сегодня мы разберёмся, почему возникает это явление, объясним, как предкэширование PSO помогает его устранить, и поделимся практиками разработки, которые позволят минимизировать статтеры. Также расскажем о наших планах по развитию системы предкэширования PSO.
Прим. переводчика: авторы статьи — инженеры, работавшие над системой PSO в Unreal Engine.
Предпосылки
Статтеры, связанные с компиляцией шейдеров, возникают, когда движок рендеринга обнаруживает необходимость скомпилировать новый шейдер прямо перед его использованием для отрисовки, и всё останавливается в ожидании окончания компиляции драйвером. Чтобы понять, почему так происходит, нужно посмотреть, как шейдеры превращаются в код, исполняемый на GPU.
Шейдеры — это программы, выполняющиеся на графическом процессоре (GPU) и отвечающие за различные этапы рендеринга 3D-изображений: преобразование, деформации, обработку теней (затенение), освещение, постобработку и т. п. Обычно их пишут на языке высокого уровня, таком как HLSL, после чего они должны быть скомпилированы в машинный код, который выполняется на GPU. Похожий процесс применяется и для CPU: код на языке высокого уровня, например C++, подаётся на вход компилятору, который генерирует инструкции для конкретной архитектуры — x64, ARM и т. д.
Однако есть ключевое отличие: каждая платформа (ПК, Mac, Android и т. п.) обычно ориентируется на один-два набора инструкций для CPU, но на множество разных GPU — с существенно отличающимися наборами инструкций. Исполняемый файл, собранный десять лет назад под x64-ПК, будет работать на современных чипах AMD и Intel, потому что оба производителя используют один и тот же набор инструкций и предоставляют строгие гарантии обратной совместимости. В отличие от этого, двоичный код для GPU, скомпилированный под AMD, не будет работать на NVIDIA (и наоборот), а наборы инструкций могут меняться даже между поколениями оборудования у одного и того же вендора.
Поэтому, хотя для CPU можно компилировать программы напрямую в исполняемый машинный код и распространять их в таком виде, для программ для GPU приходится использовать иной подход. Шейдеры на языке высокого уровня компилируются в промежуточное представление (байткод), которое использует абстрактный набор инструкций, определённый 3D-API: DXBC для Direct3D 11, DXIL для Direct3D 12, SPIR-V для Vulkan и т. п.
Игры поставляют эти бинарные файлы байткода, чтобы иметь одну общую библиотеку шейдеров, а не отдельную под каждую возможную архитектуру GPU. Во время выполнения драйвер транслирует байткод в исполняемый код для конкретного GPU, установленного в системе. Подобный подход иногда применяют и для программ для CPU — например, исходный код на Java компилируется в байткод, чтобы один и тот же бинарник запускался на всех платформах, где есть среда Java, независимо от процессора.
Когда эта система появилась, в играх было относительно мало шейдеров, и они были проще, а преобразование из байткода в исполняемый код было прямолинейным, поэтому цена такого преобразования во время выполнения была пренебрежимо мала. По мере роста мощности GPU объём шейдерного кода увеличивался, а драйверы стали выполнять всё более сложные преобразования для получения более эффективного машинного кода — в результате стоимость компиляции на лету превратилась в проблему. В Direct3D 11 ситуация дошла до критической точки, поэтому современные API, такие как Direct3D 12 и Vulkan, взялись решать её, введя понятие объектов состояния графического пайплайна (Pipeline State Objects, PSO).
Объекты состояния пайплайна
Отрисовка объекта обычно задействует несколько шейдеров (например, вершинный шейдер вместе с пиксельным), а также ряд прочих настроек GPU: режим отсечения, режим смешивания, режимы сравнения глубины и трафарета и т. д. В совокупности всё это описывает конфигурацию, или состояние, графического пайплайна.
В старых графических API, таких как Direct3D 11 и OpenGL, части состояния можно менять по отдельности и в произвольные моменты времени, из-за чего драйвер видит полную конфигурацию только тогда, когда игра отправляет команду рисования. Некоторые настройки влияют на исполняемый код шейдера, поэтому нередко драйвер может начать компиляцию шейдеров лишь при обработке команды рисования. Одна такая команда может занимать десятки миллисекунд и больше, что приводит к очень длинным кадрам при первом использовании шейдера — явлению, известному большинству игроков как статтеры (stuttering).
Современные API требуют, чтобы разработчики упаковывали все шейдеры и настройки в объект состояния пайплайна (PSO) и затем задавали его как единый блок. Важно, что PSO можно создавать в любое время, поэтому теоретически движок может подготовить всё необходимое заблаговременно (например, во время загрузки), чтобы компиляция успела завершиться до начала рендеринга.
Теория и практика
В Unreal Engine есть мощная система создания материалов, которой художники пользуются для построения визуально насыщенных и выразительных миров, и во многих играх количество материалов исчисляется тысячами. Каждый из них может порождать множество разных шейдеров — например, существуют отдельные вершинные шейдеры для отрисовки одного и того же материала на статических мешах, на скелетно-анимированных (skinned) мешах и на сплайновых мешах. Тот же вершинный шейдер может использоваться с несколькими пиксельными шейдерами, и всё это дополнительно умножается на различные наборы настроек графического пайплайна. В итоге счёт идёт на миллионы различных PSO, которые пришлось бы скомпилировать заранее, чтобы покрыть все варианты, — что, разумеется, нереализуемо ни по времени, ни по памяти (загрузка уровня заняла бы часы).
На практике во время работы игры используется лишь очень малое подмножество этих потенциальных PSO, но определить его, глядя на материал изолированно, невозможно. Подмножество также может меняться от сессии к сессии: изменение графических настроек переключает (включает/отключает) отдельные функции рендеринга, из-за чего движок начинает использовать другие шейдеры или состояния пайплайна. Ранние реализации движков на Direct3D 12 опирались на плейтесты, автоматические «пролёты» камерой по уровню и другие методы обнаружения, чтобы записывать, какие PSO реально встречаются на практике. Эти данные включались в финальную игру и использовались для создания заранее известных PSO при запуске или во время загрузки уровня. В Unreal Engine это называется «собранный кэш PSO» (Bundled PSO Cache), и до UE 5.2 это был рекомендуемый подход.
Собранного кэша для некоторых игр достаточно, но у него много ограничений. Сбор таких данных ресурсоёмок, и кэш необходимо актуализировать при изменениях контента. Процесс записи может не обнаружить все нужные PSO в играх с очень динамичными мирами — например, если объекты меняют материалы в зависимости от действий игрока.
К тому же кэш может оказаться намного больше, чем требуется в конкретной игровой сессии, если между сессиями велико разнообразие — скажем, при наличии множества карт или когда игроки могут выбирать один скин из большого набора. Fortnite — хороший пример, где собранный кэш плохо подходит: в игре проявляются все эти ограничения. Более того, из-за пользовательского контента ей понадобился бы кэш PSO для каждого отдельного «опыта»/режима, а ответственность за сбор таких кэшей легла бы на создателей контента.
Предкэширование PSO
Чтобы поддерживать большие, разнообразные игровые миры и пользовательский контент, в Unreal Engine 5.2 было введено предкэширование PSO — это техника, которая определяет потенциальные PSO во время загрузки. Когда объект загружается, система анализирует его материалы и использует сведения о меше (например, статический он или анимированный), а также глобальное состояние (например, настройки качества графики), чтобы вычислить подмножество возможных PSO, которые могут понадобиться для отрисовки этого объекта.
Это подмножество всё ещё больше фактически используемого, но значительно меньше полного пространства вариантов, поэтому становится возможным скомпилировать его в процессе загрузки. Например, в Fortnite Battle Royale на матч компилируется около 30 000 PSO, из которых используется примерно 10 000, — и это очень малая доля от общего количества комбинаций, исчисляемого миллионами.
Объекты, создаваемые во время загрузки карты, предкэшируют свои PSO, пока на экране отображается загрузка. Те, что подгружаются или создаются во время игры, могут либо дождаться готовности своих PSO перед отрисовкой, либо временно использовать материал по умолчанию, который уже скомпилирован. В большинстве случаев это задерживает подгрузку всего на несколько кадров — что незаметно. Эта система устранила статтеры, вызванные компиляцией PSO для материалов, и бесшовно работает с пользовательским контентом.
Смена материала у уже видимого меша — более сложный случай: мы не хотим скрывать объект или рисовать его материалом по умолчанию, пока компилируется новый PSO. Мы работаем над API, позволяющим игровому коду и Blueprints заранее подсказать системе, какие дополнительные PSO стоит предкэшировать. Мы также хотим изменить движок так, чтобы он продолжал рендерить прежний материал, пока идёт компиляция нового.
В Unreal Engine есть отдельный класс шейдеров, не связанных с материалами. Их называют глобальными шейдерами: это программы, которые использует рендерер для реализации различных алгоритмов и эффектов, таких как размытие в движении, масштабирование, подавление шума и т. п. Механизм предкэширования охватывает и глобальные вычислительные шейдеры, но по состоянию на UE 5.5 не обрабатывает глобальные графические шейдеры. Такие PSO всё ещё могут вызывать редкие разовые статтеры при первом использовании. Ведётся работа по закрытию оставшегося пробела в покрытии предкэширования.
Собранный кэш можно использовать вместе с предкэшированием, и для некоторых игр это даёт преимущества. Некоторые распространённые материалы можно включать в собранный кэш, чтобы они компилировались при запуске, а не в процессе игры. Это также помогает с глобальными графическими шейдерами, поскольку в процессе обнаружения они будут встречены и записаны.
Кэш драйвера
Драйверы сохраняют скомпилированные PSO на диск, чтобы при повторном использовании в следующих игровых сессиях их можно было загружать напрямую. Это помогает играм вне зависимости от используемого движка и стратегии компиляции PSO. Для проектов на Unreal Engine, применяющих предкэширование PSO, это означает, что экран загрузки при втором запуске будет заметно короче. В Fortnite загрузка в матч «Королевской битвы» занимает на 20–30 секунд дольше, когда кэш драйвера пуст. Кэш очищается при установке нового драйвера, поэтому нормально, что первый запуск игры после обновления драйвера сопровождается более долгой загрузкой.
Unreal Engine использует кэш драйвера так: PSO создаются во время загрузки и сразу же отбрасываются по завершении компиляции — отсюда и термин «предкэширование». Когда позже для рендеринга требуется PSO, движок отправляет запрос на компиляцию, но драйвер просто возвращает его из кэша, потому что система предкэширования заранее позаботилась о том, чтобы он там был. После того как PSO использован для рисования, он остаётся загруженным до тех пор, пока из сцены не будут удалены все примитивы, его использующие, — чтобы не запрашивать его у драйвера каждый кадр.
Отбрасывание после предкэширования имеет плюс: неиспользуемые PSO не занимают память. Минус в том, что извлечение PSO из кэша драйвера в момент первого обращения тоже требует времени, и хотя это гораздо быстрее, чем компиляция, это может приводить к микростаттерам при первом выводе материала на экран.
Простое решение — не отбрасывать предкэшированные PSO, а удерживать их в памяти; но это может увеличить расход памяти более чем на 1 ГБ, поэтому так стоит поступать только на машинах с достаточным объёмом ОЗУ. Мы работаем над способами уменьшить влияние на память и автоматически определять, когда предкэшированные PSO можно удерживать (не выгружать).
Лишь часть состояний влияет на генерируемый для PSO исполняемый код. Это означает, что если мы создаём два PSO с одинаковыми шейдерами, но разными настройками пайплайна, возможно, что только первый пройдёт дорогую фазу компиляции, а второй будет сразу возвращён из кэша.
К сожалению, перечень состояний, важных для генерации кода, отличается у разных GPU и может меняться между версиями драйвера. В Unreal Engine используется практический опыт, позволяющий пропускать часть комбинаций во время предкэширования. Благодаря кэшу драйвера лишние запросы обрабатываются быстрее, но движку всё равно приходится выполнять работу по их подготовке. Эта работа накапливается, поэтому отсев помогает сокращать время загрузки, а заодно и потребление памяти.
Мобильные платформы и консоли
На мобильных платформах используется тот же подход — компиляция шейдеров на самом устройстве, — и система предкэширования Unreal Engine эффективна и там. В целом мобильный рендерер задействует меньше шейдеров, чем на десктопе, но компиляция PSO занимает значительно больше времени из-за более медленных CPU, поэтому нам пришлось внести некоторые изменения в процесс, чтобы сделать его выполнимым.
Мы пропускаем некоторые редко используемые комбинации, из-за чего набор для предкэширования перестаёт быть консервативным; поэтому в отдельных случаях возможны статтеры, если рендерится редкая конфигурация состояния (пайплайна). Также у нас есть тайм-аут на предкэширование во время загрузки карты, чтобы не держать экран загрузки слишком долго. Это означает, что игра может запуститься, пока ещё выполняются задачи компиляции, — и тогда возникнут статтеры, если один из PSO, находящихся в процессе компиляции, понадобится немедленно. Чтобы минимизировать такие статтеры, мы используем систему повышения приоритета, которая перемещает задачи в начало очереди, когда требуется конкретный PSO.
Консолям не нужно решать эту проблему, потому что у них единственная целевая архитектура GPU. Отдельные шейдеры компилируются сразу в исполняемый код и поставляются вместе с игрой. Нет «комбинаторного взрыва» от использования одного и того же вершинного шейдера с множеством пиксельных шейдеров или из-за состояний пайплайна, поскольку эти факторы не вызывают повторной компиляции. Шейдеры и состояния можно собирать в PSO во время выполнения без заметных затрат, поэтому на этих платформах нет статтеров, связанных с PSO.
Ностальгия по Direct3D 11
Существует частичное заблуждение, будто в Direct3D 11 этих проблем не было, и мы время от времени слышим призывы вернуться к старой модели компиляции или даже к старым графическим API. Как уже объяснялось, статтеры возникали и тогда — и из-за устройства самого API движки никак не могли их предотвратить. Они просто случались реже или были короче главным образом потому, что в играх было меньше и проще шейдеров, а некоторые возможности (например, трассировка лучей) вовсе отсутствовали.
Драйверы тоже проделывали массу «волшебства», чтобы минимизировать статтеры, но полностью избежать их не удавалось. Direct3D 12 попытался решить проблему до того, как она усугубилась, введя PSO, однако движкам потребовалось время, чтобы эффективно ими воспользоваться — отчасти из-за сложности интеграции с уже существующими системами материалов, отчасти из-за недостатков самого API, которые стали очевидны по мере роста сложности игр.
Unreal Engine — универсальный движок с множеством сценариев использования и большим объёмом существующего контента и рабочих процессов, поэтому задача была особенно сложной. Сейчас мы, наконец, выходим на рабочее решение, а также появляются хорошие инициативы по исправлению недостатков API, например, расширение Vulkan Graphics Pipeline Library.
Мы ещё не закончили
С момента экспериментального появления в UE 5.2 система предкэширования заметно эволюционировала и предотвращает большинство статтеров, вызванных компиляцией шейдеров. Однако остаются пробелы в покрытии и другие ограничения, поэтому работа над улучшениями продолжается. Мы также взаимодействуем с производителями оборудования и ПО, чтобы адаптировать драйверы и графические API к тому, как эти системы реально используются в играх.
Наша конечная цель — чтобы предкэширование работало автоматически и оптимально, без каких-либо действий со стороны разработчиков для предотвращения статтеров. Пока система не доведена до конца, лицензиатам стоит делать следующее, чтобы обеспечить плавный геймплей:
Используйте последнюю версию движка. Поскольку предкэширование всё ещё в разработке, новые версии ведут себя лучше. Если полноценное обновление невозможно, вы сможете бэкпортировать большинство улучшений в свою модификацию движка.
Профилируйте статтеры PSO в вашей игре. Используйте
r.PSOPrecache.Validation=2, как описано в документации, чтобы выявлять пропуски или «поздние» PSO и понимать их причины.Очищайте кэш драйвера перед плейтестами. Запуск с аргументом командной строки
-clearPSODriverCacheво время плейтестов покажет, что увидит игрок при первом запуске игры или после обновления драйвера. В этом режиме обращайте внимание на статтеры и устраняйте их, используя упомянутые выше инструменты профилирования и отладки.Повторяйте этот процесс регулярно. Изменения в контенте или коде игры могут вносить новые статтеры или выявлять ошибки в системе. Мы настоятельно рекомендуем отслеживать статистику PSO в рамках автоматизированных процедур тестирования.
Следите за другими типами статтеров при перемещении по уровню. Компиляция PSO — не единственная причина статтеров, и без инструментирования трудно определить первопричину. Регулярно профилируйте игру в ходе разработки и тестирования, чтобы находить другие «дорогие» операции, вызывающие скачки времени кадра, такие как синхронная загрузка, чрезмерное порождение или подгрузка объектов, а также захваты сцены, срабатывающие при перемещении и т. д.
Если вам близки темы оптимизации рендера и устройство движка изнутри, следующий шаг — перейти от чтения к практике. Курс Unreal Engine Game Developer. Professional помогает понять архитектуру UE на уровне C++-кода, научиться создавать собственные модули, интерфейсы и AI-поведение. Отличный способ прокачать инженерное мышление в геймдеве и выйти за рамки готовых Blueprint-решений.
Чтобы узнать, подойдет ли вам программа курса, пройдите вступительный тест. А также приходите на открытые уроки, которые бесплатно проведут преподаватели курса в рамках набора:
30 октября: «Создаем квестовую систему в Unreal Engine». Записаться
11 ноября: «Деревья состояний Unreal Engine». Записаться
18 ноября: «Создание Spatial Inventory на C++ в Unreal Engine 5». Записаться