2017年7月31日 星期一

Unity 5 Shader系統程式介紹

Unity在Shader(著色器)開發方面提供了很大的靈活性。有些工具需要你編寫一個“合適”的自訂著色器(合適意思就是無法在節點編輯器裡完成,必須要寫程式),相較來說麻煩程度可算是相當輕量。不過,完全基於Deferred渲染器,代表我們無法像在Forward著色器中那樣可以放開手腳蠻幹,必須受限於g-buffer中包含的那些無法進行更改的資訊。

所以說如果你對Unity 5的標準著色器滿意,想拿來加點東西,比如一個額外的著色器屬性或修改某些功能,或重新製作你自己的著色器系統,需要牽涉到陰影、全域光照、光照貼圖、直接光照等等。

最主要的問題是,無法對標準著色器進行編輯。你無法直接修改標準著色器的程式。你得先下載你所安裝的Unity版本的對應著色器原始碼。

如何獲得標準著色器程式碼

具體做法是確認你所安裝的Unity 5版本,然後訪問Unity Download Archive網頁,在下拉清單中選擇平台(Win/Mac)的“Built in Shaders”,就可以下載zip檔。

概述

Zip檔中包含了標準著色器的完整原始碼,包括特殊的視圖UI以及它包含的所有內容共四個資料夾:
  • CGIncludes
  • DefaultResources
  • DefaultResourcesExtra
  • Editor

Editor僅包含實現標準著色器檢視視圖UI的.cs文件。
CGIncludes裡面包含了所有其他著色器所需要的函數。我們會仔細研究它們,因為我們將會用到那些函數。
DefaultResources和DefaultResourcesExtra 包含了許多適用於不同情況的著色器。

下面來學習如何解讀標準著色器,然後依次查看各個子系統,直接光照、陰影、全域光照等等。本文以Unity 5.4版本為例,建議大家採用Unity 5.3或以上版本,因為新版Unity將光照模型(BRDF)從Phong改為了GGX。Phong簡單快速,各向同性,但是表現能力有限;GGX較為複雜,支援各向異性,並且有接近現實世界的高光效果,表現能力強。這是一個很大的改進,使我們可以製作更多有趣的範例。

追蹤pragma

回到著色器程式碼。從一個簡單的標準著色器開始:DefaultResourcesExtra\Standard.shader。

打開這個檔案後會發現,它是一個Surface著色器,包含一個Properties部分以及不同的著色器pass。它針對
DeferredForward渲染器還有不同的pass。

讓我們分析
Forward渲染器(Forward渲染器有兩個Pass,Base Pass針對第一個光源,另一個Add Pass針對所有其他光源)的Base Pass:


[C#]

// ------------------------------------------------------------------
        //  Base forward pass (directional light, emission, lightmaps, ...)
        Pass
        {
            Name "FORWARD"
            Tags { "LightMode" = "ForwardBase" }
            Blend [_SrcBlend] [_DstBlend]
            ZWrite [_ZWrite]
            CGPROGRAM
            #pragma target 3.0
            // -------------------------------------
            #pragma shader_feature _NORMALMAP
            #pragma shader_feature _ _ALPHATEST_ON _ALPHABLEND_ON _ALPHAPREMULTIPLY_ON
            #pragma shader_feature _EMISSION
            #pragma shader_feature _METALLICGLOSSMAP
            #pragma shader_feature ___ _DETAIL_MULX2
            #pragma shader_feature _ _SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A
            #pragma shader_feature _ _SPECULARHIGHLIGHTS_OFF
            #pragma shader_feature _ _GLOSSYREFLECTIONS_OFF
            #pragma shader_feature _PARALLAXMAP
            #pragma multi_compile_fwdbase
            #pragma multi_compile_fog
            #pragma vertex vertBase
            #pragma fragment fragBase
            #include "UnityStandardCoreForward.cginc"
            ENDCG
        }



如你所見,它基本上由一堆pragma和定義組成。在ubershader樣式中,那些pragma啟動了在不同包含檔中的程式碼片段。因此要瞭解這個pass中實際發生的事情,必須打開CGIncludes\UnityStandardCoreForward.cginc,並逐一查看每個程式碼片段中的每個pragma。這個過程太長,需要太多解釋很多,所以現在還是讓我們專注於尋找基本函數,即主要的光照計算過程發生的地方。

CGIncludes\UnityStandardCoreForward.cginc的作用僅僅是將在其他cginclude中包含的東西連在一起。在這裡,它負責根據定義UNITY_STANDARD_SIMPLE,設定好要使用的頂點與片段函數。

下面看看更簡單的那個CGIncludes\UnityStandardCoreForwardSimple.cginc。它很“簡單”,因為它不支持PARALLAXMAP、DIRLIGHTMAPCOMBINED、DIRLIGHTMAP_SEPARATE,因此解讀起來也相對簡單。

基礎函數與結構體

最後,這個檔裡還有一些函數與結構體,基礎的有以下這些:
  • struct VertexOutputBaseSimple,這個資料結構用於保存從頂點著色器向片段著色器傳送的資料。
  • vertForwardBaseSimple 是在每個頂點上都會執行的函數,填充 VertexOutputBaseSimple結構體。
  • fragForwardBaseSimpleInternal,正向渲染器中,接受頂點輸出結構體,並計算第一個光源的函數。

片段函數

它回傳一個向量,其中包含四個half精度浮點數(一個顏色和透明度),它接受一個VertexOutputBaseSimple結構體:

[C#]

half4 fragForwardBaseSimpleInternal (VertexOutputBaseSimple i) 
{
    FragmentCommonData s = FragmentSetupSimple(i);
    UnityLight mainLight = MainLightSimple(i, s);  
    half atten = SHADOW_ATTENUATION(i);
    half occlusion = Occlusion(i.tex.xy);
    half rl = dot(REFLECTVEC_FOR_SPECULAR(i, s), LightDirForSpecular(i, mainLight));
    UnityGI gi = FragmentGI (s, occlusion, i.ambientOrLightmapUV, atten, mainLight);
    half3 attenuatedLightColor = gi.light.color * mainLight.ndotl;
    half3 c = BRDF3_Indirect(s.diffColor, s.specColor, gi.indirect, PerVertexGrazingTerm(i, s), PerVertexFresnelTerm(i));
    c += BRDF3DirectSimple(s.diffColor, s.specColor, s.oneMinusRoughness, rl) * attenuatedLightColor;
    c += UNITY_BRDF_GI (s.diffColor, s.specColor, s.oneMinusReflectivity, s.oneMinusRoughness, s.normalWorld, -s.eyeVec, occlusion, gi);
    c += Emission(i.tex.xy);
    UNITY_APPLY_FOG(i.fogCoord, c);
    return OutputForward (half4(c, 1), s.alpha);
}


從程式碼中可以看到,它收集了所需的資訊,並對直接與間接光的貢獻、霧、衰減、自發光和遮蔽進行了計算。

這些過程已被封裝,因此我們需要依次查看這些函數,才能瞭解它們的實際用途,以及程式碼的具體作用。

追蹤更多的函數

對光源進行實際計算的函數並不在此檔中,部分在CGIncludes/UnityStandardCore.cginc中:
  • MainLight (實際上用於 UnityStandardCoreForward.cginc中的MainLightSimple)

[C#]

UnityLight MainLightSimple(VertexOutputBaseSimple i, FragmentCommonData s) 
{
    UnityLight mainLight = MainLight(s.normalWorld);
    #if defined(LIGHTMAP_OFF) && defined(_NORMALMAP)
        mainLight.ndotl = LambertTerm(s.tangentSpaceNormal, i.tangentSpaceLightDir);
    #endif
    return mainLight;
}



我們能看到那裡對LambertTerm進行了計算,但僅在光照貼圖關閉且法線貼圖打開時會這樣。

CGIncludes/UnityStandardBRDF.cginc:

  • BRDF3DirectSimple (使用BRDF3Direct)


[C#]

half3 BRDF3_Direct(half3 diffColor, half3 specColor, half rlPow4, half oneMinusRoughness) 
{
    half LUT_RANGE = 16.0; // must match range in NHxRoughness() function in GeneratedTextures.cpp
    // Lookup texture to save instructions
    half specular = tex2D(unity_NHxRoughness, half2(rlPow4, 1-oneMinusRoughness)).UNITY_ATTEN_CHANNEL * LUT_RANGE;
#if defined(_SPECULARHIGHLIGHTS_OFF)
    specular = 0.0;
#endif


它看起來在使用查表法計算鏡面反射的貢獻。

  • LambertTerm, 導向到DotClamped


[C#]

inline half DotClamped (half3 a, half3 b) 
{
    #if (SHADER_TARGET < 30 || defined(SHADER_API_PS3))
        return saturate(dot(a, b));
    #else
        return max(0.0h, dot(a, b));
    #endif
}



從MainLightSimple我們得知,傳入的參數是N和L。所以片段函數首先設定好片段,計算主光源ndotl、衰減、遮蔽、全域光照以及燈光顏色。然後計算最終光線的所有貢獻,並將直接、間接、全域光照加總後再計算霧。

如你所見,著色器在光照計算方面相當少,只使用了一個Lambert演算法,並查了一下表。它不像基於物理的著色器使用的那麼多,這很可能是最精簡的標準著色器版本了。



重新回到Standard.shader,這次在UnityStandardCoreForward.shader中,我們選擇另一個“不簡單”的那個分支。它將我們引向UnityStandardCore.shader,而我們感興趣的是fragForwardBaseInternal函數。


[C#]

half4 fragForwardBaseInternal (VertexOutputForwardBase i) 
{
    FRAGMENT_SETUP(s)
#if UNITY_OPTIMIZE_TEXCUBELOD
    s.reflUVW       = i.reflUVW;
#endif
 
    UnityLight mainLight = MainLight (s.normalWorld);
    half atten = SHADOW_ATTENUATION(i);
 
 
    half occlusion = Occlusion(i.tex.xy);
    UnityGI gi = FragmentGI (s, occlusion, i.ambientOrLightmapUV, atten, mainLight);
 
    half4 c = UNITY_BRDF_PBS (s.diffColor, s.specColor, s.oneMinusReflectivity, s.oneMinusRoughness, s.normalWorld, -s.eyeVec, gi.light, gi.indirect);
    c.rgb += UNITY_BRDF_GI (s.diffColor, s.specColor, s.oneMinusReflectivity, s.oneMinusRoughness, s.normalWorld, -s.eyeVec, occlusion, gi);
    c.rgb += Emission(i.tex.xy);
 
    UNITY_APPLY_FOG(i.fogCoord, c.rgb);
    return OutputForward (c, s.alpha);
}


拿來做參考的簡單版本:


[C#]

half3 c = BRDF3_Indirect(s.diffColor, s.specColor, gi.indirect, PerVertexGrazingTerm(i, s), PerVertexFresnelTerm(i)); 
    c += BRDF3DirectSimple(s.diffColor, s.specColor, s.oneMinusRoughness, rl) * attenuatedLightColor;
    c += UNITY_BRDF_GI (s.diffColor, s.specColor, s.oneMinusReflectivity, s.oneMinusRoughness, s.normalWorld, -s.eyeVec, occlusion, gi);


與上一節中的版本不同,最終的顏色由對UNITY_BRDF_PBS、UNITY_BRDF_GI和Emission的呼叫結果相加得出。

Emission與簡單版本中的相同。UNITY_BRDF_PBS和UNITY_BRDF_GI是包含檔中定義的函式別名。在下面這些包含檔中進行查找:


[C#]

#include "UnityCG.cginc"
#include "UnityShaderVariables.cginc"
#include "UnityInstancing.cginc"
#include "UnityStandardConfig.cginc"
#include "UnityStandardInput.cginc"
#include "UnityPBSLighting.cginc"
#include "UnityStandardUtils.cginc"
#include "UnityStandardBRDF.cginc"
 
#include "AutoLight.cginc"


UnityStandardBRDF和UnityPBSLighting看起來最像,所以先查看它們。它們就在UnityPBSLighting.cginc中,不同的著色器目標會選擇不同的函式。

選擇BRDF1_Unity_PBS,它就在UnityStandardBRDF.cginc中,它看起來是最逼真的可用BRDF,而BRDF3_Unity_PBS則是消耗最低的版本。

如你所見,這是個大函式,因此跳過一些與優化相關的細節,依次逐塊的進行講解,首先從這個非常有用的注釋開始:



[C#]

// Main Physically Based BRDF
// Derived from Disney work and based on Torrance-Sparrow micro-facet model
//
//   BRDF = kD / pi + kS * (D * V * F) / 4
//   I = BRDF * NdotL
//
// * NDF (depending on UNITY_BRDF_GGX):
//  a) Normalized BlinnPhong
//  b) GGX
// * Smith for Visiblity term
// * Schlick approximation for Fresnel


注解給出了使用的公式,以及引用與作用。NDF(法線分佈函數)有多個選擇,但這裡僅介紹GGX,因為我覺得它更好。

下面對注解中的公式進行簡單的介紹:

  • kD: 漫反射率
  • pi: π常量
  • kS: 鏡面反射率
  • D: 法線分佈
  • V: 幾何可見度係數
  • F: 菲涅爾反射率

自訂光照函數:


[C#]

half4 BRDF1_Unity_PBS (half3 diffColor, half3 specColor, half oneMinusReflectivity, half oneMinusRoughness, 
    half3 normal, half3 viewDir,
    UnityLight light, UnityIndirect gi)
{
    half roughness = 1-oneMinusRoughness;


將光滑度轉換為粗糙度。


[C#]

half3 halfDir = Unity_SafeNormalize (light.dir + viewDir);


half向量。

正確處理NdotV(查看檔中的注解):


[C#]

half nl = DotClamped(normal, light.dir);
 
half nh = BlinnTerm (normal, halfDir);
half nv = DotClamped(normal, viewDir);
 
half lv = DotClamped (light.dir, viewDir);
half lh = DotClamped (light.dir, halfDir);


計算 V 和 D:

[C#]

half V = SmithJointGGXVisibilityTerm (nl, nv, roughness);
   half D = GGXTerm (nh, roughness);


根據Disney BRDF,計算漫反射項,以及鏡面反射係數:


[C#]

half disneyDiffuse = (1 + (Fd90-1) * nlPow5) * (1 + (Fd90-1) * nvPow5);
   half specularTerm = (V * D) * (UNITY_PI/4); // Torrance-Sparrow model, Fresnel is applied later (for optimization reasons)
   //HACK (see file for more comments)
   specularTerm = max(0, specularTerm * nl);
   half diffuseTerm = disneyDiffuse * nl;
 
   // surfaceReduction = Int D(NdotH) * NdotH * Id(NdotL>0) dH = 1/(realRoughness^2+1)
   half realRoughness = roughness*roughness;       // need to square perceptual roughness
   half surfaceReduction = 1.0 / (realRoughness*realRoughness + 1.0);          // fade \in [0.5;1]
 
   half grazingTerm = saturate(oneMinusRoughness + (1-oneMinusReflectivity));


將所有的加總,包括全域光照貢獻:

[C#]

 half3 color =    diffColor * (gi.diffuse + light.color * diffuseTerm)
                    + specularTerm * light.color * FresnelTerm (specColor, lh)
                    + surfaceReduction * gi.specular * FresnelLerp (specColor, grazingTerm, nv);
 
    return half4(color, 1);
}


以上就是光照函數的全部。下面來深入介紹全域光照對最終結果的貢獻。

本節我們將介紹全域光照貢獻的計算方式。過程有些麻煩,因為進行關鍵計算的程式碼隱匿在品質選擇層的層層定義之後。

所以讓我們查看下所有與全域光照有關的函數和結構體,它們就位於我們前面三節提及的代碼中。

在UnityStandardCore.cginc中, fragForwardBaseInternal:



[C#] 


UnityGI gi = FragmentGI (s, occlusion, i.ambientOrLightmapUV, atten, mainLight); 
half4 c = UNITY_BRDF_PBS (s.diffColor, s.specColor, s.oneMinusReflectivity, s.oneMinusRoughness, s.normalWorld, -s.eyeVec, gi.light, gi.indirect); 

    c.rgb += UNITY_BRDF_GI (s.diffColor, s.specColor, s.oneMinusReflectivity, s.oneMinusRoughness, s.normalWorld, -s.eyeVec, occlusion, gi);

在我們的片段Forward基本函數中,FragmentGI被用於計算全域光照資料:“gi”,它被傳遞給UNITY_BRDF_PBS 和UNITY_BRDF_GI (它們的定義分別對應著不同的品質級別)。

在UnityStandardBRDF.cginc中, BRDF1_Unity_PBS:



[C#]

half4 BRDF1_Unity_PBS (half3 diffColor, half3 specColor, half oneMinusReflectivity, half oneMinusRoughness, 
    half3 normal, half3 viewDir,
    UnityLight light, UnityIndirect gi)
{
[...]
    half3 color =    diffColor * (gi.diffuse + light.color * diffuseTerm)
                    + specularTerm * light.color * FresnelTerm (specColor, lh)
                    + surfaceReduction * gi.specular * FresnelLerp (specColor, grazingTerm, nv);
    return half4(color, 1);
}


這是UNITY_BRDF_PBS部分,它接受gi,用它來計算著色圖元的顏色。

以下兩個定義至少需要定義一個:

  • LIGHTMAP_ON
  • DYNAMICLIGHTMAP_ON

還有一堆額外的定義,用來控制程式的跳轉,或決定函數的選擇:
  • DIRLIGHTMAP_SEPARATE
  • DIRLIGHTMAP_COMBINED
  • UNITY_BRDF_PBS_LIGHTMAP_INDIRECT
  • UNITY_BRDF_GI
  • UNITY_SHOULD_SAMPLE_SH
  • UNITY_SPECCUBE_BLENDING
  • UNITY_SPECCUBE_BOX_PROJECTION
  • _GLOSSYREFLECTIONS_OFF
  • UNITY_SPECCUBE_BOX_PROJECTION

全域光照資料的運作基本是這樣的,從基本結構體流向與定義相關的函數:

  • 結構體UnityGI (在UnityLightingCommon.cginc中) 保存著多個UnityLight,取決於光照貼圖的類型
  • 結構體UnityGIInput (在UnityLightingCommon.cginc中) 保存著計算GI所需的其他不同資訊,被用於許多函式中
  • 函式UNITY_BRDF_GI (在UnityPBSLighting.cginc中) 在fragForwardBaseInternal 中用於計算對BRDF的間接貢獻(通過呼叫BRDF_Unity_Indirect)
  • 函數BRDF_Unity_Indirect(在UnityPBSLighting.cginc中)將UNITY_BRDF_PBS_LIGHTMAP_INDIRECT 的結果與傳入的colour相加
  • 函數UNITY_BRDF_PBS_LIGHTMAP_INDIRECT (在UnityPBSLighting.cginc中) 被定義為BRDF2_Unity_PBS (但一條注釋說也可以使用BRDF1_Unity_PBS ,以獲得更佳品質)
  • 函式BRDF2_Unity_PBS 或BRDF1_Unity_PBS,我們在前面一節中見過。這裡用於計算間接貢獻
  • 函數FragmentGI (在UnityStandardCore.cginc中) 填充必要的資料,包括來自反射探針的資料,然後傳遞給UnityGlobalIllumination
  • 函式UnityGlobalIllumination:(4個版本,不同的簽名)傳遞資料給UnityGI_Base 和UnityGI_IndirectSpecular
  • 函式UnityGI_Base(在UnityGlobalIllumination.cginc中)對光照貼圖進行採樣和解碼,混合即時衰減和應用遮蔽
  • 函數UnityGI_IndirectSpecular(在UnityGlobalIllumination.cginc中),計算反射,對盒型投影進行矯正(如果已啟動),處理遮擋

對於瞭解全貌同樣有用的東西:
  • 結構體UnityIndirect(在UnityLightingCommon.cginc中) 僅包含一個漫反射和一個鏡面反射顏色
  • 結構體UnityLight(在UnityLightingCommon.cginc中)保存光源的顏色、方向和NdotL
  • 貼圖立方體unity_SpecCube0 和unity_SpecCube1: 反射探針
  • 結構體Unity_GlossyEnvironmentData: 保存粗糙度和反射UV
  • 函數ResetUnityGI: 清空一個UnityGI結構體
  • 函數ResetUnityLight: 清空一個UnityLight 結構體
  • 函數ShadeSHPerPixel: 對每個圖元進行Spherical Harmonics採樣
這些知識應該已足以讓你在修改標準著色器時,不會意外的將全域光照玩壞XD。

著作人