目的
PBR(Physical Based Rendering) 可以說是很多遊戲開發的標配了,理解Unity內部PBR的實現,對我們來說有幾點意義:
- 可以讓我們對Unity內部材質各種參數的調節有個 更理性的認識 ;
- 可以在其它建模工具中進行此 PBR的插件實現 ,使得建模時就能看到Unity引擎中的光照效果;
- 可以讓我們對PBR關照有個系統的認識,便於對其進行 改進(估計要讀好幾篇論文==);
- 等等
前言
我們採用 Unity2018.3.6版本的內置Shader 來進行分析,Unity官方提供各個版本的內置Shader源碼,下載連接在 這裏 ;
Unity內置着色器還是挺複雜的,在常用的“Standard” Shader中便包括了前向光照、延遲光照、全局光照等多種光照模塊,我們這裏只針對 前向光照(forward rendering) 來進行分析;
由於其源碼的複雜性,不太建議對Unity Shader掌握不熟悉的人直接去看,這裏的熟悉包括 Shader的語法 ,以及Unity對Shader的包裝語法,另外還有 常見的光照類型 等其它知識(厲害的人邊看邊查文檔到也還行==)。
文章前半部分對源碼框架進行了剖析,想要 直接使用光照算法 的可直接跳到 總結部分 ;
Standard Shader框架
Unity內置Standard Shader的框架 如下代碼所示,各部分功能爲:
Shader "Standard"
{
Properties
{
...
}
CGINCLUDE
#define UNITY_SETUP_BRDF_INPUT MetallicSetup
ENDCG
SubShader //LOD 300
{
...
}
SubShader //LOD 300
{
...
}
FallBack "VertexLit"
CustomEditor "StandardShaderGUI"
}
- Properties爲該Shader中所使用的可調節屬性,對應於引擎中的這個界面;
SubShader //LOD 300
爲Lod Of Detail大於等於300情況下所使用的SubShader,SubShader //LOD 15
0同理,參考這裏;FallBack "VertexLit"
表示Lod Of Detail小於150時,所使用的頂點關照,參考這裏;CustomEditor "StandardShaderGUI"
表示採用StandardShaderGUI來Unity中顯示Shader屬性,參考這裏;
由於SubSahder //LOD 300
模塊下的內容比較全面,包含了高LOD下更加真實的光照實現,因此我們針對此模塊來進行分析,該 SubSahder 模塊下的框架圖 如代碼所示。
SubShader
{
Tags { "RenderType"="Opaque" "PerformanceChecks"="False" }
LOD 300
// Base forward pass (directional light, emission, lightmaps, ...)
Pass
{
...
}
// Additive forward pass (one light per pass)
Pass
{
...
}
// Shadow rendering pass
{
...
}
// Deferred pass
{
...
}
// Extracts information for lightmapping, GI (emission, albedo, ...)
// This pass it not used during regular rendering.
{
...
}
}
根據註釋可以知道:
- Pass1針對平行光的Forward Rendering;
- Pass2針對普通光源的Forward Rendering;
- Pass3針對Shadow Rendering;
- Pass4針對Deferred Rendering;
- Pass5針對Global Illumination;
由於我們主要學習的PBR光照的實現,並不針對渲染流程進行研究,因此我們 針對Pass1進行研究 ,Pass2與Pass1研究方法類似,所以就不費更多篇幅。 Pass1的框架圖 如下代碼所示,
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 multi_compile_instancing
// Uncomment the following line to enable dithering LOD crossfade. Note: there are more in the file to uncomment for other passes.
//#pragma multi_compile _ LOD_FADE_CROSSFADE
#pragma vertex vertBase
#pragma fragment fragBase
#include "UnityStandardCoreForward.cginc"
ENDCG
}
可以看着這裏有很多宏定義,但是跟我們所研究的 PBR光照相關的代碼 爲:
#pragma vertex vertBase
#pragma fragment fragBase
#include "UnityStandardCoreForward.cginc"
Pass2中的相關代碼爲:
#pragma vertex vertAdd
#pragma fragment fragAdd
#include "UnityStandardCoreForward.cginc"
即, 頂點着色器入口函數爲"vertBase",片段着色器入口函數爲"fragBase",且這些函數實現在"UnityStandardCoreForward.cginc"文件中 。因此我們的目標就是到"UnityStandardCoreForward.cginc"文件中查看"vertBase"與"fragBase"函數的實現,其 文件中代碼實現部分 爲:
#if UNITY_STANDARD_SIMPLE
#include "UnityStandardCoreForwardSimple.cginc"
VertexOutputBaseSimple vertBase (VertexInput v) { return vertForwardBaseSimple(v); }
VertexOutputForwardAddSimple vertAdd (VertexInput v) { return vertForwardAddSimple(v); }
half4 fragBase (VertexOutputBaseSimple i) : SV_Target { return fragForwardBaseSimpleInternal(i); }
half4 fragAdd (VertexOutputForwardAddSimple i) : SV_Target { return fragForwardAddSimpleInternal(i); }
#else
#include "UnityStandardCore.cginc"
VertexOutputForwardBase vertBase (VertexInput v) { return vertForwardBase(v); }
VertexOutputForwardAdd vertAdd (VertexInput v) { return vertForwardAdd(v); }
half4 fragBase (VertexOutputForwardBase i) : SV_Target { return fragForwardBaseInternal(i); }
half4 fragAdd (VertexOutputForwardAdd i) : SV_Target { return fragForwardAddInternal(i); }
#endif
由此可以知道針對平行光的 PBR光照實現分爲兩種情況,一種是簡化版的實現,位於"UnityStandardCoreForwardSimple.cginc"文件中,一種是標準版的實現,位於"UnityStandardCore.cginc"文件中 。代碼中的"vertAdd"函數與"fragAdd"函數實際爲Pass2中針對普通光源所採用的頂點與片段函數。這裏我們只 針對"UnityStandardCore.cginc"文件來查看"vertForwardBase"函數與"fragForwardBaseInternal"函數的實現,即標準版平行光光照下PBR的實現 。
最終我們打開"UnityStandardCore.cginc"文件終於看到了具體的實現,而不再是一噸宏定義繞來繞去。。。具體內容在下面講解。
Vertex Shader的實現
爲了便於理解,我在代碼中進行了必要的註釋
其實現代碼爲:
VertexOutputForwardBase vertForwardBase (VertexInput v)
{
UNITY_SETUP_INSTANCE_ID(v);
VertexOutputForwardBase o;
UNITY_INITIALIZE_OUTPUT(VertexOutputForwardBase, o);
UNITY_TRANSFER_INSTANCE_ID(v, o);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
//將頂點座標從局部座標系轉換到裁剪座標系
float4 posWorld = mul(unity_ObjectToWorld, v.vertex);
#if UNITY_REQUIRE_FRAG_WORLDPOS
#if UNITY_PACK_WORLDPOS_WITH_TANGENT
o.tangentToWorldAndPackedData[0].w = posWorld.x;
o.tangentToWorldAndPackedData[1].w = posWorld.y;
o.tangentToWorldAndPackedData[2].w = posWorld.z;
#else
o.posWorld = posWorld.xyz;
#endif
#endif
o.pos = UnityObjectToClipPos(v.vertex);
//紋理座標獲取
o.tex = TexCoords(v);
//視線方向獲取
o.eyeVec.xyz = NormalizePerVertexNormal(posWorld.xyz - _WorldSpaceCameraPos);
//法線從從局部座標系轉換到世界座標系
float3 normalWorld = UnityObjectToWorldNormal(v.normal);
//Tangent向量從從局部座標系轉換到世界座標系
#ifdef _TANGENT_TO_WORLD
float4 tangentWorld = float4(UnityObjectToWorldDir(v.tangent.xyz), v.tangent.w);
float3x3 tangentToWorld = CreateTangentToWorldPerVertex(normalWorld, tangentWorld.xyz, tangentWorld.w);
o.tangentToWorldAndPackedData[0].xyz = tangentToWorld[0];
o.tangentToWorldAndPackedData[1].xyz = tangentToWorld[1];
o.tangentToWorldAndPackedData[2].xyz = tangentToWorld[2];
#else
o.tangentToWorldAndPackedData[0].xyz = 0;
o.tangentToWorldAndPackedData[1].xyz = 0;
o.tangentToWorldAndPackedData[2].xyz = normalWorld;
#endif
//We need this for shadow receving
UNITY_TRANSFER_LIGHTING(o, v.uv1);
//獲取LightMap對應紋理座標
o.ambientOrLightmapUV = VertexGIForward(v, posWorld, normalWorld);
//視差貼圖Tangent向量的變換
#ifdef _PARALLAXMAP
TANGENT_SPACE_ROTATION;
half3 viewDirForParallax = mul (rotation, ObjSpaceViewDir(v.vertex));
o.tangentToWorldAndPackedData[0].w = viewDirForParallax.x;
o.tangentToWorldAndPackedData[1].w = viewDirForParallax.y;
o.tangentToWorldAndPackedData[2].w = viewDirForParallax.z;
#endif
//霧效相關參數的計算
UNITY_TRANSFER_FOG_COMBINED_WITH_EYE_VEC(o,o.pos);
return o;
}
可以看到,頂點着色器主要進行相關頂點座標、法線、紋理座標、tangent向量的變換操作,沒有進行實際的着色計算。
Fragment Shader的實現
仍然對主要的模塊進行註釋:
half4 fragForwardBaseInternal (VertexOutputForwardBase i)
{
UNITY_APPLY_DITHER_CROSSFADE(i.pos.xy);
FRAGMENT_SETUP(s)
UNITY_SETUP_INSTANCE_ID(i);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);
//計算光源的衰減效果
UnityLight mainLight = MainLight ();
UNITY_LIGHT_ATTENUATION(atten, i, s.posWorld);
//計算環境光遮蔽與全局光照效果
half occlusion = Occlusion(i.tex.xy);
UnityGI gi = FragmentGI (s, occlusion, i.ambientOrLightmapUV, atten, mainLight);
//計算材質的PBS光照效果
half4 c = UNITY_BRDF_PBS (s.diffColor, s.specColor, s.oneMinusReflectivity, s.smoothness, s.normalWorld, -s.eyeVec, gi.light, gi.indirect);
c.rgb += Emission(i.tex.xy);
//霧效的計算
UNITY_EXTRACT_FOG_FROM_EYE_VEC(i);
UNITY_APPLY_FOG(_unity_fogCoord, c.rgb);
return OutputForward (c, s.alpha);
}
可以看到材質PBS光照效果的計算實際上只有倒數第二塊那一部分內容,UNITY_BRDF_PBS宏的實現在"UnityPBSLighting.cginc"文件中,其代碼爲:
// Default BRDF to use:
#if !defined (UNITY_BRDF_PBS) // allow to explicitly override BRDF in custom shader
// still add safe net for low shader models, otherwise we might end up with shaders failing to compile
#if SHADER_TARGET < 30 || defined(SHADER_TARGET_SURFACE_ANALYSIS) // only need "something" for surface shader analysis pass; pick the cheap one
#define UNITY_BRDF_PBS BRDF3_Unity_PBS
#elif defined(UNITY_PBS_USE_BRDF3)
#define UNITY_BRDF_PBS BRDF3_Unity_PBS
#elif defined(UNITY_PBS_USE_BRDF2)
#define UNITY_BRDF_PBS BRDF2_Unity_PBS
#elif defined(UNITY_PBS_USE_BRDF1)
#define UNITY_BRDF_PBS BRDF1_Unity_PBS
#else
#error something broke in auto-choosing BRDF
#endif
#endif
可以看到,在Unity內部,其BRDF的實現也有不同的版本,BRDF1_Unity_PBS、BRDF2_Unity_PBS、BRDF3_Unity_PBS三個函數的實現在"UnityStandardBRDF.cginc"文件中可以查找到,我們針對 BRDF1_Unity_PBS 進行分析即可,其它的函數實現與其類似。
其具體代碼爲:
// 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
half4 BRDF1_Unity_PBS (half3 diffColor, half3 specColor, half oneMinusReflectivity, half smoothness,
float3 normal, float3 viewDir,
UnityLight light, UnityIndirect gi)
{
float perceptualRoughness = SmoothnessToPerceptualRoughness (smoothness);
float3 halfDir = Unity_SafeNormalize (float3(light.dir) + viewDir);
#define UNITY_HANDLE_CORRECTLY_NEGATIVE_NDOTV 0
#if UNITY_HANDLE_CORRECTLY_NEGATIVE_NDOTV
half shiftAmount = dot(normal, viewDir);
normal = shiftAmount < 0.0f ? normal + viewDir * (-shiftAmount + 1e-5f) : normal;
float nv = saturate(dot(normal, viewDir)); // TODO: this saturate should no be necessary here
#else
half nv = abs(dot(normal, viewDir)); // This abs allow to limit artifact
#endif
float nl = saturate(dot(normal, light.dir));
float nh = saturate(dot(normal, halfDir));
half lv = saturate(dot(light.dir, viewDir));
half lh = saturate(dot(light.dir, halfDir));
// Diffuse term
half diffuseTerm = DisneyDiffuse(nv, nl, lh, perceptualRoughness) * nl;
float roughness = PerceptualRoughnessToRoughness(perceptualRoughness);
#if UNITY_BRDF_GGX
// GGX with roughtness to 0 would mean no specular at all, using max(roughness, 0.002) here to match HDrenderloop roughtness remapping.
roughness = max(roughness, 0.002);
float V = SmithJointGGXVisibilityTerm (nl, nv, roughness);
float D = GGXTerm (nh, roughness);
#else
// Legacy
half V = SmithBeckmannVisibilityTerm (nl, nv, roughness);
half D = NDFBlinnPhongNormalizedTerm (nh, PerceptualRoughnessToSpecPower(perceptualRoughness));
#endif
float specularTerm = V*D * UNITY_PI; // Torrance-Sparrow model, Fresnel is applied later
# ifdef UNITY_COLORSPACE_GAMMA
specularTerm = sqrt(max(1e-4h, specularTerm));
# endif
// specularTerm * nl can be NaN on Metal in some cases, use max() to make sure it's a sane value
specularTerm = max(0, specularTerm * nl);
#if defined(_SPECULARHIGHLIGHTS_OFF)
specularTerm = 0.0;
#endif
// surfaceReduction = Int D(NdotH) * NdotH * Id(NdotL>0) dH = 1/(roughness^2+1)
half surfaceReduction;
# ifdef UNITY_COLORSPACE_GAMMA
surfaceReduction = 1.0-0.28*roughness*perceptualRoughness; // 1-0.28*x^3 as approximation for (1/(x^4+1))^(1/2.2) on the domain [0;1]
# else
surfaceReduction = 1.0 / (roughness*roughness + 1.0); // fade \in [0.5;1]
# endif
// To provide true Lambert lighting, we need to be able to kill specular completely.
specularTerm *= any(specColor) ? 1.0 : 0.0;
half grazingTerm = saturate(smoothness + (1-oneMinusReflectivity));
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);
}
由最後的一部分代碼
half3 color = diffColor * (gi.diffuse + light.color * diffuseTerm)
+ specularTerm * light.color * FresnelTerm (specColor, lh)
+ surfaceReduction * gi.specular * FresnelLerp (specColor, grazingTerm, nv);
可以看出, Unity內部的PBR光照也是採用漫反射 + 全局漫反射 + 高光反射 + 全局高光反射項來組成的 ;全局反射項並不是需要根據烘培的 lightmap 進行計算,其計算並不是實時的,這裏不進行詳細介紹,而是重點介紹漫反射與高光反射。
漫反射項爲diffColor*light.color * diffuseTerm;
高光反射項爲specularTerm * light.color * FresnelTerm (specColor, lh);
漫反射項
diffColor*light.color * diffuseTerm計算公式中,最重要的是 diffuseTerms項 的計算,參考此連接5.3部分,從代碼中可以看到:
/*式中:
n : normal;
v : view direction;
l : light direction;
h : half direction;即normalize(n+l);
兩個字母即表示兩個方向的點積,即nv = dot(normal, view);
perceptualRoughness表示0-1範圍的粗糙度;
*/
half diffuseTerm = DisneyDiffuse(nv, nl, lh, perceptualRoughness) * nl;
可知,diffuseTerm的計算,Unity主要採用迪斯尼所使用的計算方法, DisneyDiffuse函數的具體代碼 爲:
half DisneyDiffuse(half NdotV, half NdotL, half LdotH, half perceptualRoughness)
{
half fd90 = 0.5 + 2 * LdotH * LdotH * perceptualRoughness;
// Two schlick fresnel term
half lightScatter = (1 + (fd90 - 1) * Pow5(1 - NdotL));
half viewScatter = (1 + (fd90 - 1) * Pow5(1 - NdotV));
return lightScatter * viewScatter;
}
所採用的公式對應爲文獻中的:
按照迪斯尼的說法, 這個模型有更好的藝術友好性,而不是嚴格的遵從物理規律。即便如此,該模型也比普通的Lambert模型要複雜的多,因爲其考慮了菲尼爾現象以及粗糙度的影響,並且能夠更好的吻合實驗數據 。以後在使用漫反射模型的情況下,可以考慮使用此模型來代替Lambert模型,來獲取更好的效果。
高光反射項
在高光項的計算公式中,主要是 specularTerm 與 FresnelTerm 的計算。
FresnelTerm爲高光反射模型中常見的 菲涅爾項 ,其實現爲常見的 Schlick Fresnel approximation ,即:
inline half3 FresnelTerm (half3 F0, half cosA)
{
half t = Pow5 (1 - cosA); // ala Schlick interpoliation
return F0 + (1-F0) * t;
}
計算公式爲:
其中, F0爲光線垂直入射時,該材質的反射光線所佔出射光線的比例 (另一部分發生折射或吸收)。
實際上 specularTerm*FresnelTerm 爲高光反射BRDF,此BRDF採用的是微表面模型(Microfacet),可參考這裏,其計算公式爲下式的右部分:
FresnelTerm對應了F項,specularTerm對應了;而 在Unity內部的實現中,specularTerm 爲:
float specularTerm = V*D * UNITY_PI;
其中 V對應了G項,D對應D項,其中(4cos(θl)*cos(θv))項好像被拋棄了,不知道爲什麼== 。
這樣 specularTerm的計算就轉化爲V、D的計算,Unity中這兩項的計算代碼 爲:
#if UNITY_BRDF_GGX
// GGX with roughtness to 0 would mean no specular at all, using max(roughness, 0.002) here to match HDrenderloop roughtness remapping.
roughness = max(roughness, 0.002);
float V = SmithJointGGXVisibilityTerm (nl, nv, roughness);
float D = GGXTerm (nh, roughness);
#else
// Legacy
half V = SmithBeckmannVisibilityTerm (nl, nv, roughness);
half D = NDFBlinnPhongNormalizedTerm (nh, PerceptualRoughnessToSpecPower(perceptualRoughness));
#endif
可以看到 最新的計算模型爲GGX模型,而遺留計算模型爲BlinnPhong模型 ;我們這裏研究GGX模型,因爲GGX模型更加高級且更加真實,BlinnPhong模型大家自己類比查看源碼即可;
GGX模型中V項的計算代碼爲:
// Ref: http://jcgt.org/published/0003/02/03/paper.pdf
inline float SmithJointGGXVisibilityTerm (float NdotL, float NdotV, float roughness)
{
#if 0
// Original formulation:
// lambda_v = (-1 + sqrt(a2 * (1 - NdotL2) / NdotL2 + 1)) * 0.5f;
// lambda_l = (-1 + sqrt(a2 * (1 - NdotV2) / NdotV2 + 1)) * 0.5f;
// G = 1 / (1 + lambda_v + lambda_l);
// Reorder code to be more optimal
half a = roughness;
half a2 = a * a;
half lambdaV = NdotL * sqrt((-NdotV * a2 + NdotV) * NdotV + a2);
half lambdaL = NdotV * sqrt((-NdotL * a2 + NdotL) * NdotL + a2);
// Simplify visibility term: (2.0f * NdotL * NdotV) / ((4.0f * NdotL * NdotV) * (lambda_v + lambda_l + 1e-5f));
return 0.5f / (lambdaV + lambdaL + 1e-5f); // This function is not intended to be running on Mobile,
// therefore epsilon is smaller than can be represented by half
#else
// Approximation of the above formulation (simplify the sqrt, not mathematically correct but close enough)
float a = roughness;
float lambdaV = NdotL * (NdotV * (1 - a) + a);
float lambdaL = NdotV * (NdotL * (1 - a) + a);
#if defined(SHADER_API_SWITCH)
return 0.5f / (lambdaV + lambdaL + 1e-4f); // work-around against hlslcc rounding error
#else
return 0.5f / (lambdaV + lambdaL + 1e-5f);
#endif
#endif
}
嗯…很複雜,但是註釋還是很清楚的,其具體採用的是 Smith joint masking-shadowing function,V(G)項的具體的計算公式 爲:
lambda_v = (-1 + sqrt(a2 * (1 - NdotL2) / NdotL2 + 1)) * 0.5f;
lambda_l = (-1 + sqrt(a2 * (1 - NdotV2) / NdotV2 + 1)) * 0.5f;
G = 1 / (1 + lambda_v + lambda_l);
由於計算公式的複雜性,Unity的應用代碼中對齊進行的簡化,該公式的具體來源可參考這裏.
GGX模型中D項的計算代碼爲:
inline float GGXTerm (float NdotH, float roughness)
{
float a2 = roughness * roughness;
float d = (NdotH * a2 - NdotH) * NdotH + 1.0f; // 2 mad
return UNITY_INV_PI * a2 / (d * d + 1e-7f); // This function is not intended to be running on Mobile,
// therefore epsilon is smaller than what can be represented by half
}
迪斯尼列舉了三種模型:
中間的GGX模型實際上等價於TR(Trowbridge-Reitz)模型,第三項爲Generalized-Trowbridge-Reitz模型,即指數項可變化;
可以看到,Unity採用的是中間GGX計算公式,不過在代碼中有一些trick使用,爲了在應用中達到更好的效果。
總結
- 可以看到,我們整個流程分析下來,根據一些宏的定義存在不同的PBS計算方法,我們所走的這一條應該是最複雜的那一條。
- Unity所採用的PBS計算方法,實際上就是我們經常用的漫反射與高光反射,只不過其採用的模型更加高級、效果更好而已,而且在模型的應用中使用了很多tips。
- 由於PBS計算只是採用的核心的光照算法,而在工程應用中卻需要進行多層包裝,所以這裏對我們這一條路所使用的光照算法進行總結,爲了便於對核心算法進行學習,以及使用。總結後計算公式爲:
的計算:
的計算:
式中粗體字母表示爲矢量,普通字母爲標量;
值得注意的是,Unity爲了效果表現,並沒有完全按照公式進行計算,比如漫反射項沒有除以,高光反射項沒有除以;