Использование Global Illumination в собственных шейдерах в Unity 5
Привет, Хабр! Unity 5 предоставляет нам из коробки систему глобального освещения (Global Illumination, GI), которая позволяет в реальном времени получать действительно очень приятную картинку, что разработчики продемонстрировали в своем нашумевшем ролике The Blacksmith. Наряду с системой глобального освещения универсальный материал Standard перевел в разряд устаревших все прежние материалы. Несмотря на крутость стандартного материала (а он, ни много ни мало, основан на физической модели), я задался вопросом, а можно ли подключить систему глобального освещения к собственному поверхностному шейдеру. Что из этого получилось, а также с чем мне пришлось столкнуться в процессе, читайте под катом.
Что хорошего в системе глобального освещения Unity 5?
В системе глобального освещения в Unity 5 меня в первую очередь привлекла не симуляция ambient-освещения, а встроенные отражения. Разработчики добавили в движок новый механизм, который получил название Reflection Probes. Принцип его работы достаточно прост: на сцене мы размещаем в нужных местах специальные маркеры (зонды), которые сохраняют отражения вокруг себя в кубических текстурах. При перемещении между маркерами выбирается пара наиболее значимых, и отражения, полученные от обоих, смешиваются. Отражения могут вычисляться в реальном времени, в момент активации маркера или вообще управляться скриптом, где можно реализовать, например, таймеры. Подобные системы часто реализуют в играх, где постоянно необходимы отражения, в частности, для имитации материала автомобильной краски. Согласитесь, очень не хочется изобретать велосипед, когда в движке уже все сделано.
Симуляция вторичного освещения также очень круто может увеличить реализм вашего рендеринга. В реальном мире многие материалы переотражают падающий на них свет и сами становятся источниками света. Чтобы рассчитать такое вторичное (indirect) освещение в реальном времени, современных вычислительных мощностей не хватает, однако его можно предрассчитать для статических объектов и изредка пересчитывать для динамических, что и реализовано в Unity 5. Рассчитанные данные упаковываются в текстуры, которые затем используются уже при рендеринге в реальном времени. Эти текстуры называются картами освещенности или лайтмапами.
Unity 5 предоставляет несколько механизмов, влияющих на формирование глобального освещения:
- Присвоение источнику света типа Baked или Mixed. Такой источник света будет работать через лайтмап и не влиять на динамические объекты (тип Baked) или работать для динамических объектов как полноценный источник света (тип Mixed).
- Создание маркеров освещенности (Light Probes). Маркеры освещенности представляют собой трехмерный граф, в узлах которого сохраняется уровень освещенности, создаваемый различными источниками света. При рендеринге в реальном времени для расчета освещения используются данные интерполированные по сетке графа.
- Технология Directional Lightmapping. При расчете indirect-освещения можно считать все поверхности идеально плоскими, т. е. переотражающими свет одинаково во всех направлениях, а можно учитывать преимущественное направление переотражения, используя для этого данные из карты нормалей. В этом и заключается суть данной технологии. Поддерживается также режим Directional Specular, который позволяет учитывать блики, что позволяет создавать полноценное вторичное освещение.
Все это имеет множество параметров, позволяющих учесть баланс производительность-качество, что еще больше подняло технологию в моих глазах. Однако, исследуя новые возможности движка, я работал на тестовой сцене с материалом Standard, что меня, в конечном счете, не устраивало, я хотел подключить собственный поверхностный шейдер к системе глобального освещения.
Создание поверхностного шейдера
Здесь меня ждал первый сюрприз: в документации к движку нет информации по тому, как правильно подключать собственный поверхностный шейдер к системе GI. Вместо этого документация пестрит заметками вида «переходим все на материал Standard» и «почему ваши материалы хуже, чем Standard». К счастью, исходники всех встроенных шейдеров находятся в открытом доступе и лежат либо в папке CGIncludes, либо их можно скачать с официального сайта. По результатам исследования исходников выяснилось следующее:
- Для того чтобы ваш шейдер взаимодействовал с системой GI, необходимо переопределить функцию со следующей сигнатурой:
inline void LightingYourModelName_GI(YourSurfaceOutput s, UnityGIInput data, inout UnityGI gi)
где YourModelName — имя вашей модели освещения, YourSurfaceOutput — имя вашей структуры данных с параметрами поверхности, UnityGIInput — входные данные для расчета глобального освещения, UnityGI — результат расчета глобального освещения.
Внутри этой функции, понятно, необходимо рассчитать глобальное освещение, для чего служит встроенная функцияinline UnityGI UnityGlobalIllumination (UnityGIInput data, half occlusion, half oneMinusRoughness, half3 normalWorld, bool reflections)
Данная функция определена в файле CGIncludes/UnityGlobalIllumination.cginc. Параметр occlusion отвечает за дополнительное затенение. В него можно, например, передать результаты работы какой-либо вариации алгоритма Ambient Occlusion. Параметр oneMinusRoughness определяет нечто вроде глянцевости материала. Данный параметр используется при расчете отражений, чем меньше глянцевость, тем менее четкие отражения мы будем получать. Булевый параметр reflections позволяет выключить расчет отражений, предназначение остальных параметров очевидно.
В итоге у меня получилась следующая функция:inline void LightingUniversal_GI(SurfaceOutputUniversal s, UnityGIInput data, inout UnityGI gi) { gi = UnityGlobalIllumination(data, 1.0 /* occlusion */, 1.0 - s.Roughness, normalize(s.Normal)); }
- Функция, содержащая модель освещения, несколько изменилась по сравнению с предыдущими версиями Unity. Теперь она имеет сигнатуру
inline fixed4 LightingYourModelName(YourSurfaceOutput s, half3 viewDir, UnityGI gi)
Параметры atten и lightDir (кому-то знакомые по предыдущим версиям движка) уступили место структуре UnityGI. В этой структуре могут содержаться до 3 источников света (light, light2 и light3), а также данные о вторичном освещении в параметре indirect. Вторичное освещение разбивается на две компоненты: diffuse — рассеянный свет от вторичных источников света и specular — бликовая компонента (именно через нее передаются отражения). Чтобы лучше понять, как все эти данные применять рассмотрим следующий псевдокод:inline fixed4 LightingYourModelName(YourSurfaceOutput s, half3 viewDir, UnityGI gi) { // Рассчитать освещение от основного источника счета gi.light #if defined(DIRLIGHTMAP_SEPARATE) #ifdef LIGHTMAP_ON // В случае использования статического лайтмапа параметры источника света будут в gi.light2 #endif #ifdef DYNAMICLIGHTMAP_ON // В случае использования динамического лайтмапа параметры источника света будут в gi.light3 #endif #endif #ifdef UNITY_LIGHT_FUNCTION_APPLY_INDIRECT // Здесь добавляется вклад indirect-освещения #endif }
Нетрудно видеть, что функция, описывающая модель освещения, содержит несколько блоков под директивами условной компиляции. Шейдеры в Unity построены в соответствии с популярной парадигмой, называемой über-шейдеры. Согласно этой парадигме пишется наиболее общий шейдер (в идеале один единственный), блоки кода в котором оборачиваются условной компиляцией. Шейдер компилируется согласно потребностям материала. В итоге, один исходник — множество скомпилированных вариантов. Так вот, возвращаясь к нашей функции, источник света gi.light должен применяться всегда, так как содержит параметры основного источника света для данного прохода шейдера. Остальные два источника света могут быть использованы только в режиме Directional Lightmapping со включенным бликовым освещением (Directional Specular). Источник света gi.light2 будет активен только в случае использования статического лайтмапа, а источник gi.light3 будет работать в условиях динамического лайтмэппинга. В конце функции под директивой UNITY_LIGHT_FUNCTION_APPLY_INDIRECT применяется вторичное освещение.
Также хочу отметить любопытную историю, которая произошла с параметром viewDir. Как некоторым наверняка известно, в функции, описывающей модель освещения, параметр viewDir можно опускать, если он вам не нужен. Это позволяет шейдерному кодогенератору формировать чуть более оптимальный код. Однако, если вы планируете использовать отражения из системы GI, параметр viewDir в сигнатуре функции придется оставить, даже если он вам не нужен. Дело в том, что встроенная функция UnityGlobalIllumination использует направление взгляда для расчета вектора отражения. Если кодогенератор не обнаружит в сигнатуре функции параметра viewDir, он оптимизирует код, и отражения перестанут работать.
В основу своего поверхностного шейдера я положил модель освещения Кука-Торранса, о которой вы можете прочитать здесь или здесь. Под спойлером вы найдете полный код получившегося шейдера. Теперь посмотрим на то, что у нас получилось.
Shader "ShadersLabs/Universal"
{
Properties
{
_MainColor("Color", Color) = (1,1,1,1)
_MainTex("Albedo", 2D) = "white" {}
_NormalMap("Normal", 2D) = "bump" {}
_EmissionMap("Emission (RGB), Specular (A)", 2D) = "black" {}
_Roughness("Roughness", Range(0,1)) = 0.1
_ReflectionPower("Reflection Power", Range(0.01, 5)) = 3
_Metallic ("Metallic", Range(0,1)) = 0.0
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 200
CGPROGRAM
#pragma surface surf Universal fullforwardshadows exclude_path:prepass exclude_path:deferred
#pragma target 3.0
struct Input
{
half2 uv_MainTex;
};
struct SurfaceOutputUniversal
{
fixed3 Albedo;
fixed3 Normal;
fixed3 Emission;
fixed Specular;
fixed Metallic;
fixed Roughness;
fixed ReflectionPower;
fixed Alpha;
};
sampler2D _MainTex;
sampler2D _NormalMap;
sampler2D _SpecularMap;
sampler2D _EmissionMap;
fixed4 _MainColor;
fixed _Roughness;
fixed _ReflectionPower;
fixed _Metallic;
inline fixed3 CalculateCookTorrance(SurfaceOutputUniversal s, half3 n, fixed vdn, half3 viewDir, UnityLight light)
{
half3 h = normalize(light.dir + viewDir);
fixed ndl = saturate(dot(n, light.dir));
fixed ndh = saturate(dot(n, h));
fixed vdh = saturate(dot(viewDir, h));
fixed ndh2 = ndh * ndh;
fixed sp2 = max(s.Roughness * s.Roughness, 0.001);
fixed G = min(1.0, 2.0 * ndh * min(vdn, ndl) / vdh);
fixed D = exp((ndh2 - 1.0)/(sp2 * ndh2)) / (4.0 * sp2 * ndh2 * ndh2);
fixed F = 0.5 + 0.5 * pow(1.0 - vdh, s.ReflectionPower);
fixed spec = saturate(G * D * F / (vdn * ndl));
return light.color * (s.Albedo * ndl + fixed3(s.Specular, s.Specular, s.Specular) * spec);
}
inline fixed3 CalculateIndirectSpecular(SurfaceOutputUniversal s, fixed vdn, half3 indirectSpec)
{
fixed rim = saturate(pow(1.0 - vdn, s.ReflectionPower));
return indirectSpec * rim * s.Metallic;
}
inline fixed4 LightingUniversal(SurfaceOutputUniversal s, half3 viewDir, UnityGI gi)
{
half3 n = normalize(s.Normal);
fixed vdn = saturate(dot(viewDir, n));
fixed4 c = fixed4(CalculateCookTorrance(s, n, vdn, viewDir, gi.light), s.Alpha);
#if defined(DIRLIGHTMAP_SEPARATE)
#ifdef LIGHTMAP_ON
c.rgb += CalculateCookTorrance(s, n, vdn, viewDir, gi.light2);
#endif
#ifdef DYNAMICLIGHTMAP_ON
c.rgb += CalculateCookTorrance(s, n, vdn, viewDir, gi.light3);
#endif
#endif
#ifdef UNITY_LIGHT_FUNCTION_APPLY_INDIRECT
c.rgb += (s.Albedo * gi.indirect.diffuse + CalculateIndirectSpecular(s, vdn, gi.indirect.specular));
#endif
return c;
}
inline void LightingUniversal_GI(SurfaceOutputUniversal s, UnityGIInput data, inout UnityGI gi)
{
gi = UnityGlobalIllumination(data, 1.0 /* occlusion */, 1.0 - s.Roughness, normalize(s.Normal));
}
void surf(Input IN, inout SurfaceOutputUniversal o)
{
fixed4 c = _MainColor * tex2D(_MainTex, IN.uv_MainTex);
fixed4 e = tex2D(_EmissionMap, IN.uv_MainTex);
o.Albedo = c.rgb;
o.Normal = normalize(UnpackNormal(tex2D(_NormalMap, IN.uv_MainTex)));
o.Specular = e.a;
o.Emission = e.rgb;
o.Metallic = _Metallic;
o.Roughness = _Roughness;
o.ReflectionPower = _ReflectionPower;
o.Alpha = c.a;
}
ENDCG
}
FallBack "Diffuse"
}
Результаты
На сцену были помещены 3 шарика, плоскость и 2 источника света (направленный, имитирующий Солнце, и точечный типа Mixed прямо перед шариками). Также был добавлен один Reflection Probe для создания отражений.
В результате мы получаем следующую картинку (слева глобальное освещение выключено, справа — включено).
На изображении ниже представлен вклад отражений и переотраженного света, которые формируются системой глобального освещения.
Если заменить режим лайтмэппинга Directional Specular в пользу режима Directional, картинка станет поскучней, однако это позволит несколько выиграть по производительности. Кроме того, режим Directional Specular не поддерживается на старых версиях графических API, например, Open GL ES 2.0.
Ложка дёгтя
Полученные результаты, в целом, меня удовлетворили. Однако, оставался последний нерешенный вопрос. Все, что я реализовал, не поддерживало режим отложенного освещения (deferred shading). Unity 5 предоставляет такой режим освещения из коробки, и, для полноты картины, его было бы круто поддержать.
Здесь меня ждало самое большое разочарование. В текущей версии движка (я использовал 5.1.3) можно переопределить функцию для записи данных в G-буфер (LightingYourModelName_Deferred), а вот функцию, которая декодирует G-буферы, переопределить нельзя. Точнее, существует способ, требующий определенных дополнительных приседаний. Документация к движку по этому поводу говорит следующее:
«The only lighting model available is Standard. If a different model is wanted you can modify the lighting pass shader, by placing the modified version of the Internal-DeferredShading.shader file from the Built-in shaders into a folder named «Resources» in your «Assets» folder.»
Таким образом, единственный теоретический способ добиться желаемого — модифицировать внутренний шейдер и подложить его в определенное место в проекте. Никаких иных более детальных указаний документация не предоставляет. К слову, простое копирование в нужное место результата мне не принесло, движок по-прежнему использовал внутренний исходный шейдер. Пришлось цеплять шейдер к камере как постэффект, пробрасывать параметры и прочее, т.е. делать кучу лишних действий, которые мог (и, по моему мнению, должен) решать движок такого уровня самостоятельно. Самое печальное в этой истории, что все это можно сделать для игровой камеры, а вот как пропатчить камеру редактора это большой вопрос. Конечно, можно представить себе теоретический способ по выцеплению камеры редактора в скрипте и программном добавлении нужного компонента, но для настолько глубоких приседаний нужен действительно веский повод. Я предпочту подождать, возможно, в будущих версиях разработчики приведут deferred shading в порядок.
Выводы
Что я вынес для себя из этой истории, разработчики Unity создали очень неплохую систему глобального освещения. Ее можно и нужно использовать, если вы не применяете в своем проекте отложенное освещение. Как альтернативу можно рассмотреть полный переход на материал Standard, на который разработчики Unity, судя по всему, делают большую ставку. Этот материал работает во всех режимах, однако, переводить свой проект на него я бы не стал. Ценой стала бы потеря контроля как над визуальным образом игры, так и над ее производительностью. Выводы для себя вы сделаете сами, я же, со своей стороны, надеюсь, что вам доставило удовольствие прочтение данного поста. Любите качественный рендеринг, до новых встреч!