Варианты шейдеров Unity Советы по оптимизации и устранению неполадок

При написании шейдеров в Unity у нас есть удобная возможность включить несколько функций, проходов и логики ветвления в один исходный файл. Во время сборки исходные файлы шейдеров компилируются в программы шейдеров, которые содержат один или несколько вариантов. Вариант - это версия шейдера, следующая одному набору условий, что приводит (в большинстве случаев) к линейному пути выполнения без статических условий ветвления.
Причина, по которой мы используем варианты, а не храним все пути ветвления в одном шейдере, заключается в том, что GPU отлично справляются с распараллеливанием кода, который предсказуем и всегда следует по одному и тому же пути, что приводит к более высокой пропускной способности. Если в скомпилированной шейдерной программе присутствуют условные сигналы, GPU придется тратить ресурсы на выполнение задач прогнозирования, ожидание завершения других путей и так далее, что приведет к неэффективности.
Хотя это и приводит к значительному повышению производительности GPU по сравнению с динамическим ветвлением, у него есть и некоторые недостатки. Время сборки будет увеличиваться по мере увеличения количества вариантов, иногда даже на несколько часов за сборку. Игра также будет загружаться дольше, поскольку ей придется потратить больше времени на загрузку и предварительное прогревание шейдеров. Наконец, Вы можете заметить значительное потребление памяти шейдерами во время выполнения, если их варианты не управляются должным образом, иногда более 1 ГБ.
Количество генерируемых вариантов зависит от множества факторов, включая заданные ключевые слова и свойства, настройки качества, уровни графики, включенные графические API, эффекты постобработки, активный конвейер рендеринга, режимы освещения и тумана, а также включен ли XR, среди прочего. Шейдеры, которые приводят к большому количеству вариантов, часто называют uber-шейдерами. Во время выполнения Unity загружает тот вариант, который соответствует требуемым настройкам и ключевым словам, о чем мы расскажем позже.
Это особенно важно, если учесть, что мы часто видим шейдеры, содержащие более 100 ключевых слов, что приводит к неуправляемому количеству результирующих вариантов, часто называемому взрывом вариантов шейдеров. Нередко можно увидеть шейдеры с начальным пространством вариантов в миллионы до применения какой-либо фильтрации.
Чтобы облегчить эту проблему, Unity попытается уменьшить количество вариантов, генерируемых на основе нескольких проходов фильтрации. Например, если XR не включен, то варианты, необходимые для этого, будут удалены. Затем Unity учитывает, какие функции Вы на самом деле используете в своих сценах, например, режимы освещения, туман и т.д. Их особенно сложно обнаружить, поскольку разработчики и художники могут внести, казалось бы, безопасные изменения, которые на самом деле приводят к значительному увеличению количества вариантов шейдеров, без какого-либо очевидного способа обнаружения, если только Вы не установите некоторые меры предосторожности как часть Вашего конвейера развертывания.
Хотя это и полезно, данный процесс не идеален, и мы можем многое сделать, чтобы убрать как можно больше вариантов без ущерба для визуального качества Вашей игры.
Здесь я хочу поделиться несколькими практическими советами о том, как справиться с вариантами, понять, откуда они берутся, и найти несколько эффективных способов их уменьшить. В результате время сборки Вашего проекта и объем памяти значительно сократятся.
Варианты шейдеров генерируются, основываясь на всех возможных комбинациях ключевых слов shader_feature и multi_compile, используемых в Вашем шейдере, а также на других факторах. Ключевые слова, отмеченные как multi_compile, всегда включаются в Вашу сборку, а те, что отмечены как shader_feature, будут включены, если на них ссылается какой-либо материал в Вашем проекте. По этой причине Вам следует использовать shader_feature всегда, когда это возможно.
Чтобы увидеть, какие ключевые слова определены в шейдере, Вы можете выбрать его и проверить в Инспекторе.

Как Вы можете видеть, ключевые слова делятся на переопределяемые и не переопределяемые. Локальные ключевые слова (те, что определены в самом файле шейдера) с глобальной областью применения могут быть отменены глобальным ключевым словом шейдера с соответствующим именем. Если вместо этого они определены в локальной области (с помощью multi_compile_local или shader_feature_local), их нельзя переопределить, и они будут отображаться в разделе "Не переопределяемые". Ключевые слова глобальных шейдеров предоставляются движком Unity, и их можно переопределять. Поскольку они могут быть добавлены в любой момент в процессе сборки, не все глобальные ключевые слова могут появиться в этом списке.
Ключевые слова могут быть определены во взаимоисключающих группах, называемых наборами, путем определения их в одной директиве. Таким образом, Вы избежите создания вариантов для комбинаций ключевых слов, которые никогда не будут включены одновременно (например, два разных типа освещения или тумана).
#pragma shader_feature LIGHT_LOW_Q LIGHT_HIGH_Q
Чтобы уменьшить количество ключевых слов для каждой платформы, Вы можете использовать макросы препроцессора, чтобы определить их только для соответствующей платформы, например:
#ifdef SHADER_API_METAL
#pragma shader_feature IOS_FOG_FEATURE
#else
#pragma shader_feature BASE_FOG_FEATURE
#endif
Обратите внимание, что эти выражения с макросами не могут зависеть от других ключевых слов или функций, связанных не только с целью сборки.
Ключевые слова также могут быть ограничены определенным проходом, что уменьшает количество потенциальных комбинаций. Для этого Вы можете добавить к директиве один из следующих суффиксов:
- _vertex
- _фрагмент
- _hull
- _домен
- _геометрия
- _raytracing
Например:
#pragma shader_feature_fragment FRAG_FEATURE_1 FRAG_FEATURE_2
Это может вести себя по-разному в зависимости от используемого Вами рендерера. Например, в OpenGL суффиксы OpenGL ES и Vulkan будут игнорироваться.
Вы можете использовать директиву #pragma skip_variants, чтобы определить ключевые слова, которые должны быть исключены при генерации вариантов для конкретного шейдера. При создании сборки игрока все варианты шейдеров для этого шейдера, содержащие одно из этих ключевых слов, будут пропущены.
Вы также можете опционально определить ключевые слова с помощью директивы #pragma dynamic_branch, которая заставит Unity полагаться на динамическое ветвление и не генерировать варианты для этих ключевых слов. Хотя это и уменьшает количество результирующих вариантов, это может привести к снижению производительности GPU в зависимости от шейдера и содержания игры, поэтому при его использовании рекомендуется профилировать соответствующим образом.
Обычно варианты шейдеров не компилируются до тех пор, пока Вы не соберете игру. Используя эту опцию, Вы можете проверить варианты шейдеров для конкретной платформы сборки или графического API. Это позволит Вам проверить ошибки заранее. Кроме того, Вы можете вставить сгенерированный код в инструменты анализа производительности шейдеров GPU, такие как PVRShaderEditor, для дальнейшей оптимизации.

Внизу Вы заметите запись, в которой указано, сколько вариантов включено, исходя из материалов, присутствующих в текущей открытой сцене, без применения скриптовых зачисток. Если Вы нажмете кнопку Show, то появится временный файл с дополнительной отладочной информацией о том, какие ключевые слова были использованы или удалены на различных платформах, включая количество вариантов вершинных стадий.
Флажок Preprocess only выше позволяет Вам переключаться между скомпилированным кодом шейдера и препроцессированным исходным кодом шейдера для более простой и быстрой отладки.
Если Вы используете встроенный конвейер рендеринга и работаете с поверхностным шейдером, у Вас есть возможность проверить сгенерированный код, который Unity будет использовать для замены Вашего упрощенного источника шейдера при сборке. Затем Вы можете заменить исходный код шейдера сгенерированным кодом, если хотите изменить результат.

При создании игры Unity определит пространство вариантов для каждого шейдера, основываясь на всех возможных перестановках его характеристик, настройках движка и других факторах. Затем эти комбинации передаются в препроцессоры для многократного снятия изоляции. Это можно расширить с помощью обратных вызовов IPreprocessShaders, чтобы создать пользовательскую логику для удаления большего количества вариантов из сборки, как описано ниже.
Шейдеры, включенные в список Всегда включенных шейдеров (в разделе Настройки проекта > Графика), будут иметь все свои варианты, включенные в сборку. По этой причине лучше использовать его только в случае крайней необходимости, так как это может легко привести к генерации большого количества вариантов.
Наконец, конвейер сборки пройдет через процесс, называемый дедупликацией, выявляя идентичные варианты в рамках одного Pass и гарантируя, что они указывают на один и тот же байткод. Это приведет к уменьшению размера на диске, но идентичные варианты все равно будут негативно влиять на время сборки, время загрузки и использование памяти во время выполнения, так что это не замена правильной зачистки вариантов.
После успешной сборки мы можем заглянуть в файл Editor.log, чтобы собрать полезную информацию о том, какие варианты шейдеров были включены в сборку. Для этого найдите в журнале "Compiling shader" и название Вашего шейдера. Вот, например, как это выглядит:
Compiling shader "GameShaders/MyShader" pass "Pass 1" (vp)
Full variant space: 608
After settings filtering: 608
After built-in stripping: 528
After scriptable stripping: 528
Processed in 0.00 seconds
starting compilation...
finished in 0.02 seconds. Local cache hits 528 (0.16s CPU time), remote cache hits 0 (0.00s CPU time), compiled 0 variants (0.00s CPU time), skipped 0 variants
В некоторых случаях Вы можете увидеть, что количество вариантов увеличилось после шага фильтрации настроек, например, если в Вашем проекте включен XR.
Если Ваша игра поддерживает несколько графических API, Вы также найдете информацию для каждого поддерживаемого рендерера:
Serialized binary data for shader GameShaders/MyShader in 0.00s
gles3 (total internal programs: 290, unique: 193)
vulkan (total internal programs: 290, unique: 193)
Наконец, Вы увидите эти журналы сжатия, которые дадут Вам представление о конечном размере шейдера на диске для определенного Graphics API:
Compressed shader 'GameShaders/MyShader' on vulkan from 1.35MB to 0.19MB
Если Вы используете Universal Render Pipeline (URP), Вы можете выбрать, будут ли журналы генерироваться только для шейдеров SRP, для всех шейдеров или отключить журналы. Для этого выберите Уровень журнала в меню Настройки проекта > Графика > Глобальные настройки URP.

Кроме того, если Вы выберите опцию Export Shader Variants ниже, после сборки будет сгенерирован JSON-файл, содержащий отчет о компиляции вариантов шейдеров. Это доступно на Unity 2022.2 или более новой версии.
Чтобы понять, какие шейдеры на самом деле компилируются для GPU во время выполнения, Вы можете включить опцию Log Shader Compilation в разделе Project Settings > Graphics.

Это приведет к тому, что Ваша игра будет выводить в журнал игрока каждый раз, когда во время игры компилируется шейдер. Он будет работать только в сборках для разработки и в режиме отладки, как описано в подсказке.
Формат выглядит следующим образом:
Compiled Shader: Folder/ShaderName, pass: PASS_NAME, stage: STAGE_NAME, keywords ACTIVE_KEYWORD_1 ACTIVE_KEYWORD_2
Имейте в виду, что некоторые платформы, например, Android, будут кэшировать скомпилированные шейдеры. По этой причине Вам, возможно, придется удалить и переустановить игру, прежде чем выполнять тестовый проход, чтобы поймать все скомпилированные шейдеры.
Наконец, Вы можете использовать пакет Memory Profiler, чтобы сделать снимок Вашей игры во время ее работы, а затем получить представление о том, какие шейдеры загружены в память в данный момент и каков их размер. Сортировка по размеру обычно дает хорошее представление о том, какие шейдеры приносят больше всего вариантов, и их стоит оптимизировать.

В процессе зачистки Unity удалит варианты шейдеров, связанные с графическими функциями, которые Ваша игра не использует. Процесс немного меняется, если Вы используете встроенный конвейер рендеринга или URP.
Чтобы определить их, перейдите в Настройки проекта > Графика. Отсюда, используя встроенный конвейер рендеринга, Вы можете выбрать, какие режимы Lightmap и Fog поддерживает Ваша игра.

Установив для них значение "Автоматически", Unity сможет определять, какие варианты снимать, основываясь на сценах, включенных в Вашу сборку.
Если Вы не уверены в том, какие функции Вы используете, Вы также можете воспользоваться кнопкой Import from Current Scene (Импорт из текущей сцены), чтобы позволить Unity определить, какие функции Вам нужны. Конечно, это полезно только в том случае, если все Ваши сцены используют одинаковые настройки, поэтому при использовании этой опции обязательно выберите репрезентативную сцену.
Если Вы используете URP, некоторые из этих опций будут скрыты. Вместо этого Вы сможете определить, какие функции требуются Вашей игре, непосредственно в активе Pipeline Settings.
Например, отключение Terrain Holes приведет к тому, что все варианты шейдеров Terrain Holes будут удалены, что также сократит время сборки.
URP обеспечивает более детальный контроль над тем, какие функции Вы хотите включить в свою игру, что потенциально может привести к созданию более оптимизированных сборок с меньшим количеством неиспользуемых вариантов.
Примечание. Это актуально только при использовании встроенного конвейера рендеринга. Эти настройки будут игнорироваться при использовании скриптового конвейера рендеринга, например, URP.
Графические уровни используются для применения различных настроек графики в зависимости от аппаратного обеспечения, на котором работает Ваша игра (не путать с настройками качества). Когда игра начнется, Unity определит графический уровень Вашего устройства, основываясь на аппаратных возможностях, графическом API и других факторах.
Их можно установить в Настройки проекта > Графика > Настройки ярусов.

Основываясь на них, Unity добавляет эти три ключевых слова ко всем шейдерам:
UNITY_HARDWARE_TIER1
UNITY_HARDWARE_TIER2
UNITY_HARDWARE_TIER3
Затем он генерирует варианты шейдеров для каждого из заданных графических уровней. Если Вы не используете графические уровни и хотите избежать связанных с ними вариантов, Вам нужно убедиться, что все графические уровни имеют абсолютно одинаковые настройки, чтобы Unity пропустила эти варианты.
Как уже упоминалось ранее, Unity попытается дедуплицировать идентичные варианты, поэтому, если, например, два из трех уровней имеют одинаковые настройки, это приведет к уменьшению размера на диске, хотя все варианты все равно будут сгенерированы. Вы можете опционально заставить Unity генерировать варианты уровней для данного API шейдеров и графического рендерера, используя параметр hardware_tier_variants, как показано ниже:
// Direct3D 11/12
#pragma hardware_tier_variants d3d11
Unity компилирует один набор вариантов шейдеров для каждого графического API, включенного в Вашу сборку, поэтому в некоторых случаях полезно вручную выбрать API и исключить те, которые Вам не нужны.
Для этого перейдите в Настройки проекта > Проигрыватель. По умолчанию выбрано значение Auto Graphics API, и Unity будет включать набор встроенных графических API и выбирать один из них во время выполнения в зависимости от возможностей устройства. Например, на Android Unity сначала попытается использовать Vulkan, а если устройство его не поддерживает, движок вернется к GLES3.2, GLES3.1 или GLES3.0 (впрочем, варианты будут идентичны для этих версий GLES).
Вместо этого отключите Auto Graphics API для соответствующей платформы и вручную выберите API, которые Вы хотели бы включить. Тогда Unity отдаст предпочтение первому в списке.

Недостатком является то, что Вы можете ограничить количество устройств, поддерживающих Вашу игру, поэтому убедитесь, что Вы знаете, что делаете, когда изменяете это, и протестируйте на различных устройствах.
Обычно во время выполнения Unity пытается загрузить вариант, наиболее близкий к запрашиваемому набору ключевых слов, если точное соответствие недоступно или было удалено из сборки игрока. Хотя это и удобно, это также скрывает потенциальные проблемы с настройкой ключевых слов шейдеров.
Начиная с Unity 2022.3, Вы можете выбрать Strict Shader Variant Matching в Project Settings > Player, чтобы гарантировать, что Unity попытается загрузить только точное соответствие для комбинации локальных и глобальных ключевых слов, которые Вам нужны.

Если шейдер не найден, он использует шейдер ошибки и выведет в консоль сообщение об ошибке, содержащее шейдер, индекс подшейдера, фактический проход и запрашиваемые ключевые слова. Это очень удобно, когда Вам нужно найти недостающие варианты, которые Вам действительно нужны. Как обычно бывает с зачисткой, это работает только в Проигрывателе и не оказывает никакого влияния на Редактор.
Во время игры в редакторе Unity отслеживает, какие шейдеры и их варианты в данный момент используются в Вашей сцене, и позволяет Вам экспортировать их в коллекцию. Для этого перейдите в Настройки проекта > Графика. В самом низу Вы заметите раздел Shader Loading, показывающий, сколько шейдеров в данный момент отслеживается как активные.
Не забудьте предварительно нажать кнопку Clear, чтобы получить более точный образец, а затем войдите в режим Play и начните работать со своей сценой, убедившись, что Вы встретили все игровые элементы, требующие определенных шейдеров. Это увеличит количество отслеживаемых счетчиков. Затем нажмите кнопку "Сохранить в активе...", чтобы сохранить все эти данные в активе коллекции.

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

Один из подходов, используемых в некоторых проектах, заключается в том, чтобы запустить эту процедуру для каждого уровня игры, сохранив коллекцию для каждого из них, а затем удалить все варианты, которые не присутствуют ни в одном из этих списков, с помощью скрипта IPreprocessShaders (рассматривается в следующем разделе). Хотя это и удобно, по моему опыту, это также довольно чревато ошибками. Трудно гарантировать, что Вы встретите все требуемые варианты в ходе одного прохождения игры, а некоторые функции могут быть загружены только на устройстве и в определенных случаях, в результате чего список не всегда будет точным. По мере того, как Ваша игра будет меняться, добавлять новые элементы на уровни или менять материалы, коллекции нужно будет обновлять. По этой причине я бы использовал его в основном для отладки и исследований, а не для прямой интеграции в Ваш конвейер сборки.
Всякий раз, когда шейдер будет скомпилирован в Вашу сборку игры, Unity будет отправлять обратный вызов. Это происходит как в сборках Player, так и в сборках Asset Bundles. Мы можем удобно прослушивать их с помощью IPreprocessShaders.OnProcessShader и IPreprocessComputeShaders.OnProcessComputeShader (для вычислительных шейдеров), и добавить пользовательскую логику, чтобы убрать ненужные варианты. Таким образом, мы можем значительно сократить время сборки, размер сборки и общее количество вариантов, которые попадут в Вашу сборку.
Для этого создайте скрипт, реализующий интерфейс IPreprocessShaders, а затем напишите свою логику зачистки в OnProcessShader. Например, вот сценарий, который удалит все варианты, содержащие ключевое слово шейдера DEBUG, в сборках релиза:
public class StripDebugVariantsPreprocessor : IPreprocessShaders
{
public int callbackOrder => 0;
ShaderKeyword keywordToStrip;
public StripDebugVariantsPreprocessor()
{
keywordToStrip = new ShaderKeyword("DEBUG");
}
public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data)
{
if (EditorUserBuildSettings.development)
{
return;
}
for (int i = data.Count - 1; i >= 0; i--)
{
if (data[i].shaderKeywordSet.IsEnabled(keywordToStrip))
{
data.RemoveAt(i);
}
}
}
}
Порядок обратного вызова позволяет Вам определить, какой сценарий предварительной обработки должен быть запущен первым, что позволяет Вам создавать многоступенчатые проходы зачистки. Сценарии с более низким приоритетом будут выполнены первыми.
Посетите обсуждение на форуме Graphics-Shaders, чтобы узнать больше.