unity 2020 怎么写shader使其接受光照?_如何在Unity中造一个PBR Shader轮子
之前有業界大佬建議我去了解下Unity的PBR。說來慚愧,我查找了下資料才發現自己在這方面的知識居然是一片空白。經過幾周的學習與嘗試我對這一塊算是有了初步的了解,于是寫了這篇文章,一方面對自己學到的東西做一下梳理,一方面作為筆記方便以后忘了的時候看。當然,如果能給之后想做同樣事情的開發者帶來一點點幫助就更好了。
基于物理的渲染(PBR)已經是很成熟的東西了,兩大商業引擎(Unity,虛幻)里很早之前就有了完善的實現,使用OpenGL實現pbr有完備的教程,鏈接如下:
LearnOpenGL - Theory?learnopengl.com從我學習中查找的資料來看,現有的PBR教程要么只講原理不講實現,要么是照著上面的教程使用OpenGL或dx實現的。雖說想學好圖形學OpenGL或dx這一步是逃不掉的,但畢竟這兩者的學習成本太高,而且使用它們搭起一個能看到渲染結果的框架所花的精力可能比學習pbr本身還要多。同時,PBR作為一種渲染方法,我也沒看到什么文章介紹其中的每個步驟能使被渲染的圖像達到什么樣的效果。這些給我的學習帶來了一定的障礙。
在這篇文章中我將使用商業引擎中最好上手的Unity,通過手寫包含直接光照和間接光照(ibl)的PBR shader的方式得到和Unity自帶的standard shader相近的光照效果。文中將結合原理,以盡量簡潔的實現,接地氣的一步步講解PBR BRDF(雙向反射分布函數)方程各部分的實現和具體效果。
對于本文的讀者,有幾點我必須提一下:
1. 本文在講解時會盡量淡化數學公式的作用,遇到公式只要領會精神就行,不用擔心因為數學不好而看不懂這篇文章。要是有人想看數學演算過程的話可以去看《Real-Time Rendering》這本書,里面講的很詳細。
2. 本文追求以盡量簡單而容易理解的方式實現PBR,僅處理了單光源的情況,貼圖上也只添加了MainTexture用于做顏色上的Debug。其他各種功能性貼圖的寫法不在本文考慮范圍內(添加的方法和在非PBR shader中差不多)。
3. Unity standard shader使用的光照模型已經不是傳統的BRDF,且針對運行環境存在大量的優化措施。本人才疏學淺無力復現standard shader,只能盡量在簡單的情況下使渲染結果和standard shader相近。
本文實現的PBR shader如下:
Shader的Github鏈接
那我們開始吧。
設置Unity
項目用的Unity版本是2018.3.0f2,你用別的版本區別也不會很大。需要改下以下幾項設置:
- Editor>Project Setting>Player>Other Settings中將Color Space改成Linear
- Windows>Rendering>Lighting Setting最下面的Auto Generate關掉,如果已經有烘焙好的光照貼圖就刪掉。這一點主要是防止光照貼圖對渲染效果產生影響。
BRDF方程
先稍微講下PBR的原理,PBR的本質就是如下的一個BRDF方程:
這個方程看起來怪嚇人的,把它翻譯下是這樣的:
對于BRDF方程的解釋到處都是,我在這就不復讀了,貼一個講的比較好的:
理論 - LearnOpenGL CN?learnopengl-cn.github.io方程括號里的前半部分為漫反射部分,后半部分為鏡面反射部分。而這個方程又同時代表了直接光照和間接光照(ibl)。所以排列組合下就出現了四個部分:直接光漫反射,直接光鏡面反射,間接光漫反射,間接光鏡面反射。PBR渲染效果就是這四個部分的加和,在下面的教程中將依次實現這四部分以得到和standard shader相同的渲染效果。
shader骨架與參數
先搭建下我們手寫PBR shader 的骨架,骨架的代碼如下:
Shader "Arc/ArcHandWritePbrExp" {Properties{_MainTex("Texture", 2D) = "white" {}_Tint("Tint", Color) = (1 ,1 ,1 ,1)[Gamma] _Metallic("Metallic", Range(0, 1)) = 0 //金屬度要經過伽馬校正_Smoothness("Smoothness", Range(0, 1)) = 0.5_LUT("LUT", 2D) = "white" {}}SubShader{Tags { "RenderType" = "Opaque" }LOD 100Pass{Tags {"LightMode" = "ForwardBase"}CGPROGRAM#pragma target 3.0#pragma vertex vert#pragma fragment frag#include "UnityStandardBRDF.cginc" struct appdata{float4 vertex : POSITION;float3 normal : NORMAL;float2 uv : TEXCOORD0;};struct v2f{float4 vertex : SV_POSITION;float2 uv : TEXCOORD0;float3 normal : TEXCOORD1;float3 worldPos : TEXCOORD2;};float4 _Tint;float _Metallic;float _Smoothness;sampler2D _MainTex;float4 _MainTex_ST;sampler2D _LUT;v2f vert(appdata v){v2f o;o.vertex = UnityObjectToClipPos(v.vertex);o.worldPos = mul(unity_ObjectToWorld, v.vertex);o.uv = TRANSFORM_TEX(v.uv, _MainTex);o.normal = UnityObjectToWorldNormal(v.normal);o.normal = normalize(o.normal);return o;}fixed4 frag(v2f i) : SV_Target{float3 diffColor = 0;float3 specColor = 0;float3 DirectLightResult = diffColor + specColor;float3 iblDiffuseResult = 0;float3 iblSpecularResult = 0;float3 IndirectResult = iblDiffuseResult + iblSpecularResult;float4 result = float4(DirectLightResult + IndirectResult, 1);return result;}ENDCG}} }要傳入的參數只有五個,_MainTex是物體的貼圖,_Tint是貼圖要乘上的顏色,_Metallic是金屬度,_Smoothness是材質的光滑度,_LUT是一張作為查找表的貼圖,具體作用會在后面講到。
這里稍微講下金屬度和光滑度的意義(感謝迪士尼的前輩把PBR的復雜屬性融合到這僅有的兩個參數里)。金屬度是一個0到1范圍內的浮點數,表示被渲染物的表面材質是不是金屬,0表示非金屬,1表示金屬,0和1之間的值的作用是表現諸如沾有沙子的金屬表面之類的復雜材質。光滑度也是一個0到1范圍內的浮點數,表示被渲染物表面材質的光滑程度,0表示光滑,1表示粗糙。這兩個參數看似有些重復,其實是完全剝離開的,比如存在粗糙的金屬(磨砂鋼或是帶銹跡的欄桿)和光滑的非金屬(橡皮擦、塑料臺球等)。使用這兩個參數可以表示大部分物體的表面特征。
可以看到在參數中的金屬度前面有一個[Gamma]。這是因為金屬度這個值是用于伽馬空間的,而即使你開了Linear模式Unity也不會對一個滑動條做伽馬校正。在這里加[Gamma]就是告訴Unity這個值也要和貼圖一樣在使用前從伽馬空間轉換到線性空間中。如果不加[Gamma]的話在金屬度為0和1中間值的時候渲染效果會和Standard Shader不一樣。
骨架中其他代碼都很容易理解,作用基本是只是將各種數據傳遞到片元著色器,在這里我就不細講了。稍微要提一下的是我引用了Unity自帶的UnityStandardBRDF.cginc這個文件,這是Unity PBR的核心文件,我本文中shader也參考了其中的實現(Unity的PBR實現中有一些trick,不看源碼很難把效果調整到和standard shader相近)。想看這個shader的可以去Unity官網下載內置shader。
吐個槽,Unity的內置shader源碼實在是難讀,無數個宏和函數調用根本不知道去哪里找。要是哪位好心人有一鍵Find Reference的vscode插件或vs插件麻煩給我說下,在下先行謝過。。。
效果比對場景搭建
接下來做一些用于效果比對的工作(如果只是想看shader怎么寫可以跳過這一步)。
準備這樣一個shader:
Shader "Arc/MyFirstPbr" {Properties{_Tint ("Tint", Color) = (1 ,1 ,1 ,1)_MainTex ("Texture", 2D) = "white" {}[Gamma] _Metallic("Metallic", Range(0, 1)) = 0_Smoothness("Smoothness", Range(0, 1)) = 0.5}SubShader{Tags { "RenderType"="Opaque" }LOD 100Pass{Tags {"LightMode" = "ForwardBase"}CGPROGRAM#pragma target 3.0#pragma vertex vert#pragma fragment frag#include "UnityPBSLighting.cginc"struct appdata{float4 vertex : POSITION;float3 normal : NORMAL;float2 uv : TEXCOORD0;};struct v2f{float4 vertex : SV_POSITION;float2 uv : TEXCOORD0;float3 normal : TEXCOORD1;float3 worldPos : TEXCOORD2;};float4 _Tint;float _Metallic;float _Smoothness;sampler2D _MainTex;float4 _MainTex_ST;v2f vert (appdata v){v2f o;o.vertex = UnityObjectToClipPos(v.vertex);o.worldPos = mul(unity_ObjectToWorld, v.vertex);o.uv = TRANSFORM_TEX(v.uv, _MainTex);o.normal = UnityObjectToWorldNormal(v.normal);o.normal = normalize(o.normal);return o;}fixed4 frag (v2f i) : SV_Target{i.normal = normalize(i.normal);float3 lightDir = _WorldSpaceLightPos0.xyz;float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);float3 lightColor = _LightColor0.rgb;float3 specularTint;float oneMinusReflectivity;float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;albedo = DiffuseAndSpecularFromMetallic( // 從金屬度生成漫反射顏色,鏡面反射顏色等albedo, _Metallic, specularTint, oneMinusReflectivity);UnityLight light;light.color = lightColor;light.dir = lightDir;light.ndotl = DotClamped(i.normal, lightDir);UnityIndirect indirectLight;indirectLight.diffuse = 0;indirectLight.specular = 0;return UNITY_BRDF_PBS( //生成直接光pbr結果albedo, specularTint,oneMinusReflectivity, _Smoothness,i.normal, viewDir,light, indirectLight);}ENDCG}} }這個shader使用Unity內置的函數DiffuseAndSpecularFromMetallic和UNITY_BRDF_PBS宏實現直接光PBR效果,即standard shader里的直接光部分。寫這個shader是為了進行PBR直接光照效果的比對。
建立一個用于效果比對的場景。在場景中排布4×3的12個球體,最上面一排四個球貼上裝載有上面寫的直接光PBR shader的材質,中間一排貼裝載有上面的Shader骨架的材質,最下面一排貼裝載有standard shader的材質(你可能需要建12個Material來完成這一步)。每個球體使用同一張黃色貼圖并將顏色設為#28FFFF,貼圖在這:
貼圖的Github鏈接
其實這里設置的貼圖和顏色只是為了Debug。。。你想設什么都行。
將場景中的攝像機投影模式設置為正交方便看結果。根據圖中的參數更改每個球的金屬度和粗糙度,改完后會得到以下結果:
上面一排是pbr直接光效果,下面一排就是我們最終要實現的standard shader效果。中間一排由于用的是我們之前的shader骨架所以還是黑的。
shader編寫
準備工作結束,現在正式開始寫shader。先把后面會用到的一些數據算好,算好后的片元著色器長這樣:
fixed4 frag(v2f i) : SV_Target {i.normal = normalize(i.normal);float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);float3 lightColor = _LightColor0.rgb;float3 halfVector = normalize(lightDir + viewDir); //半角向量float perceptualRoughness = 1 - _Smoothness;float roughness = perceptualRoughness * perceptualRoughness;float squareRoughness = roughness * roughness;float nl = max(saturate(dot(i.normal, lightDir)), 0.000001);//防止除0float nv = max(saturate(dot(i.normal, viewDir)), 0.000001);float vh = max(saturate(dot(viewDir, halfVector)), 0.000001);float lh = max(saturate(dot(lightDir, halfVector)), 0.000001);float nh = max(saturate(dot(i.normal, halfVector)), 0.000001);float3 diffColor = 0;float3 specColor = 0;float3 DirectLightResult = diffColor + specColor;float3 iblDiffuseResult = 0;float3 iblSpecularResult = 0;float3 IndirectResult = iblDiffuseResult + iblSpecularResult;float4 result = float4(DirectLightResult + IndirectResult, 1);return result; }lightDir是光照的方向,viewDir是視角方向,lightColor是光源的顏色,這三者都直接使用Unity的內置參數計算出。halfVector是Blinn-phong光照模型中的半角向量,講Blinn-phong模型的文章大多把這個向量作為一個經驗值,但其實半角向量是有理論依據的,具體的在下面會講到。
接下來是粗糙度一家。perceptualRoughness是一次方的粗糙度,即1-光滑度參數。roughness是粗糙度的二次方,squareRoughness是粗糙度的四次方。他們在后面的計算中都會被用到。
接著是向量點積一家,顧名思義就是各種向量互相點積。值得注意的是這些向量都要被Clamp一下防止除0的情況出現。
該算的也算好了,讓我們開始依次寫掉BRDF的四個部分。
直接光漫反射
首先寫直接光的漫反射部分,這部分在BRDF方程中長這樣:
這其實就是個蘭伯特光照模型,不同之處是下面除了個PI,這個PI的作用是保證能量守恒,數學推導可以去看《Real-time Rendering》我在這里就不講了。這部分寫好后長這樣:
float3 Albedo = _Tint * tex2D(_MainTex, i.uv); float3 diffColor = kd * Albedo * lightColor * nl;就兩行,第一行是貼圖采樣并乘上顏色得到Albedo,第二行是乘上光源顏色和nl得到結果,乘的kd是一個保證能量守恒的系數,我們先不管它。注意,我們這里又沒有除PI,這不是我忘了而是為了向Unity妥協,Unity的UnityStandardBRDF.cginc中有這么一段注釋:
// HACK: theoretically we should divide diffuseTerm by Pi and not multiply specularTerm! // BUT 1) that will make shader look significantly darker than Legacy ones // and 2) on engine side "Non-important" lights have to be divided by Pi too in cases when they are injected into ambient SH也就是說Unity為了1. 保證shader看起來和Legacy版本差不多亮 2. 避免在ibl部分對非重要光源做特殊處理 在這里沒有除PI。我們為了達到和Unity相近的渲染效果也不去除這個PI。改完后的這部分長這樣:
float nh = max(saturate(dot(i.normal, halfVector)), 0.000001);//添加的代碼從這開始 float kd = 1; float3 Albedo = _Tint * tex2D(_MainTex, i.uv); float3 diffColor = kd * Albedo * lightColor * nl; //添加的代碼到這里結束float3 specColor = 0; float3 DirectLightResult = diffColor + specColor;此時我們的中間一排從黑色變成了下圖的這個樣子:
簡直是教科書般的蘭伯特光照效果。。。于是我們完成了四個部分中最簡單的第一部分,下面進入直接光鏡面反射部分。
直接光鏡面反射
直接光鏡面反射部分的方程長這樣:
分母是4×nv×nl,這是個積分積出來的配平系數,要看推導過程的繼續找《 Real-time Rendering》,我們在這直接拿著用 。關鍵在于分子上的DFG三個值。
D是Normal Distribution Function,應該翻譯成法線分布函數,這是個統計學的函數,它描述的是在受到表面粗糙度的影響下,取向方向與中間向量一致的微平面的數量。換句話說,比如假設給定向量h,如果我們的微平面中有35%與向量h取向一致,則法線分布函數將會返回0.35。常用的公式如下:
這個式子被稱為Trowbridge-Reitz GGX,其中的h為半角向量,n為法線,
是表面的粗糙度。這里的粗糙度Unity用的是(1-smoothness)的平方,即為代碼中的roughness。除以PI也是為了保證能量守恒。下面我們在代碼里計算這個值:float3 Albedo = _Tint * tex2D(_MainTex, i.uv);//添加的代碼從這開始 float lerpSquareRoughness = pow(lerp(0.002, 1, roughness), 2);//Unity把roughness lerp到了0.002 float D = lerpSquareRoughness / (pow((pow(nh, 2) * (lerpSquareRoughness - 1) + 1), 2) * UNITY_PI); //添加的代碼到這里結束float3 diffColor = kd * Albedo * lightColor * nl;按照上文的計算roughness已經是(1-smoothness)的平方了,在這里直接用。值得注意的是這里將roughness clamp到了0.002到1之間,這也是Unity的做法,的目的是保證在smoothness為0表面完全光滑時也會留有一點點高光。完成這一步后我們輸出D來看看是什么樣的效果:
可見在smoothness為0的時候整個球面的D值是都1/PI也就是灰的,在smoothness為1的時候幾乎全黑的球面上留下了一個高光的亮點,如果把smoothness從1向0調整這個亮點會不斷變大變暗最后覆蓋整個球。可以看到這和高光反射的效果很相似,這個D的值也正是高光亮斑效果的來源。(如果沒有之前將smoothness clamp到0.002到1之間的過程,在smoothness為1的時候球面上兩點會消失)
接下來看G,G被稱為幾何函數,描述的是微平面間相互遮蔽的比率,如圖(圖片嫖自learn opengl):
這種遮蔽會消耗掉光的能量導致表面變暗,計算方法如下:
之所以要乘兩遍是因為光線在入射時會進行一次以光線方向l為參數的遮蔽,出射時會進行一次以視線方向v為參數的遮蔽,二者乘起來才是完整的G。關于k的計算,直接光照和間接光照時的k都在逼近二分之一,只不過直接光照時這個值最小為八分之一而不是0。這是為了保證在表面絕對光滑時也會吸收一部分光線,畢竟完全不吸收光線的物體在現實中不存在。這部分的代碼如下:
float D = lerpSquareRoughness / (pow((pow(nh, 2) * (lerpSquareRoughness - 1) + 1), 2) * UNITY_PI);//添加的代碼從這開始 float kInDirectLight = pow(squareRoughness + 1, 2) / 8; float kInIBL = pow(squareRoughness, 2) / 8; float GLeft = nl / lerp(nl, 1, kInDirectLight); float GRight = nv / lerp(nv, 1, kInDirectLight); float G = GLeft * GRight; //添加的代碼到這里結束float3 diffColor = kd * Albedo * lightColor * nl;輸出G看效果前需要先把相機從正交模式調回透視模式否則會因為正交相機神奇的視角方向導致結果出錯。G的結果如下:
從結果可以看出,在光滑度為0的時候由于吸收率高所以整個球會灰一些。在光線照不到的地方和照得到的地方產生了明顯的明暗分界,其實在視線看得到和看不到的地方也有這么一條分界線只是攝像機看不到背面(視線導致的分界線在正交相機下是能看到的)。
接下來是F,F是大家耳熟能詳的菲涅爾系數,對菲涅爾系數的介紹我直接照抄OpenGL文檔了:
菲涅爾(發音為Freh-nel)方程描述的是被反射的光線對比光線被折射的部分所占的比率,這個比率會隨著我們觀察的角度不同而不同。當光線碰撞到一個表面的時候,菲涅爾方程會根據觀察角度告訴我們被反射的光線所占的百分比。利用這個反射比率和能量守恒原則,我們可以直接得出光線被折射的部分以及光線剩余的能量。
當垂直觀察的時候,任何物體或者材質表面都有一個基礎反射率(Base Reflectivity),但是如果以一定的角度往平面上看的時候所有反光都會變得明顯起來。你可以自己嘗試一下,用垂直的視角觀察你自己的木制/金屬桌面,此時一定只有最基本的反射性。但是如果你從近乎90度(譯注:應該是指和法線的夾角)的角度觀察的話反光就會變得明顯的多。如果從理想的90度視角觀察,所有的平面理論上來說都能完全的反射光線。這種現象因菲涅爾而聞名,并體現在了菲涅爾方程之中。
菲涅爾函數的實際實現和理論有一定的不同,下面說說不同在什么地方:
首先,真正的菲涅爾方程超級復雜,不太具有實用價值。實際實現時用的是菲涅爾方程的近似版本,有兩種:
上面一種是Fresnel-Schlick近似法求得的常用版本,下面一種是虛幻引擎用的擬合版本,后面一種由于exp2函數的高效率算起來會快一些。
其次,方程中的F0理論上是平面的基礎反射率,但實際實現時需要考慮另一個情況,即菲涅爾方程只對非金屬有效,在表面為金屬時需要用到跟金屬表面顏色相關的另一個方程。為了能夠用同一個材質表示金屬和非金屬的不同屬性,將材料的金屬性參數整合到F0的計算中,實際F0計算的代碼如下:
float3 F0 = lerp(unity_ColorSpaceDielectricSpec.rgb, Albedo, _Metallic);unity_ColorSpaceDielectricSpec.rgb是一個常數,大致是float3(0.04, 0.04, 0.04)這樣的東西,F0的計算就是在這個常數和表面顏色之間根據材質的金屬性進行插值。在材料為金屬時F0為表面顏色,為非金屬時F0是很接近黑色的一個值。做了這一步后F0按理說已經不是基礎反射率也不應該叫F0了,Unity把這玩意叫SpecColor,但我感覺SpecColor這個名稱比F0更容易產生混淆。。。所以我這里還是把變量名命名為F0.
最后,有些地方的菲涅爾方程是這樣的:
在這個方程里使用的是nv而不是vh。這一點困惑了我很久,最后才理解這nv和vh的沖突其實是宏觀和微觀的沖突。
使用nv的菲涅爾方程是宏觀的,即菲涅爾方程確實由表面法線和視角方向求得。但在這里我們處理的不是宏觀平面而是由法線分布函數D篩選出的法線為h的微平面,故這里實際用的應該是vh。也可以這么理解,微觀上半角向量h就是微平面的法線,這也就是說我們熟悉的Blinn-Phong光照模型本質上是一個BRDF。。。
這里最大的坑在于如果你輸出由使用nv的菲涅爾方程得到的結果,你會發現它實在是太符合菲涅爾效應的物理特性,而使用vh的效果很不明顯。但實際上使用nv得到的效果和真實菲涅爾效應完全相反,邊緣會反而比中間暗。所以在這里大膽使用vh就好。Unity在這里用的是lh,這是一種對GGX shader渲染效果的優化方法,感興趣的可以看看,鏈接如下:
Optimizing GGX Shaders with dot(L,H)?filmicworlds.com計算F的實際代碼如下:
float G = GLeft * GRight;//添加的代碼從這開始 float3 F0 = lerp(unity_ColorSpaceDielectricSpec.rgb, Albedo, _Metallic); float3 F = F0 + (1 - F0) * exp2((-5.55473 * vh - 6.98316) * vh); //添加的代碼到這里結束float3 diffColor = kd * Albedo * lightColor * nl;注意F0和F均為float3,F0中帶有表面顏色信息,最終效果中光滑度和金屬度均為1的表面的高光帶有的顏色就是從F0中來的。
輸出得到的F:
可以看到完全看不出菲涅爾的效果。。。但是無論是Unity還是OpenGl都是這么實現的,最后得到的渲染結果也是對的,所以我個人理解而言菲涅爾這一步的作用與其說是表現菲涅爾效應還不如說是把金屬表面和非金屬表面區分開來,即金屬的高光帶有表面顏色Albedo而非金屬不帶。同時菲涅爾系數也是一個用于計算能量守恒的重要參數,這點在下面再詳細講。
算出了DGF這三個系數,我們只要除掉配平系數就可以得到高光部分結果。配平系數分母中的nl和nv實際上是可以和G的分子約掉的,Unity的做法就是約掉nl和nv后把配平系數和G的相乘得到一個變量V拿來計算高光部分結果。約掉這兩者可以降低運算開銷還能防止除0好處多多,不過我這里為了容易理解還是不約了。
最后把DFG和配平系數乘起來并和漫反射結果加和的代碼如下:
float3 F = F0 + (1 - F0) * exp2((-5.55473 * vh - 6.98316) * vh);//添加到部分從這里開始 float3 SpecularResult = (D * G * F * 0.25) / (nv * nl);//直接光照部分結果 float3 specColor = SpecularResult * lightColor * nl * UNITY_PI; //添加到部分到這里結束float3 diffColor = kd * Albedo * lightColor * nl; float3 DirectLightResult = diffColor + specColor;SpecularResult分子的乘0.25是Unity的做法,畢竟用乘法比除4效率高。注意到在鏡面反射結果這里乘上了一個PI,這也是Unity的trick,因為之前少給漫反射除了個PI,這里為了保證漫反射和鏡面反射的比例所以多乘了個PI。
然后我們把之前設置成1的kd重新計算,kd為(1-F)乘上(1-_Metallic)。乘上(1-F)是為了保證能量守恒,乘一次(1-_Metallic)是因為金屬會更多的吸收折射光線導致漫反射消失,這是金屬物質的特殊物理性質。鏡面反射方程中的ks就是菲涅爾系數F這里不用再乘一遍。
然后我們把漫反射和鏡面反射結果加起來,直接光部分就此完成,直接光部分的完整代碼如下:
float3 Albedo = _Tint * tex2D(_MainTex, i.uv);float lerpSquareRoughness = pow(lerp(0.002, 1, roughness), 2);//Unity把roughness lerp到了0.002 float D = lerpSquareRoughness / (pow((pow(nh, 2) * (lerpSquareRoughness - 1) + 1), 2) * UNITY_PI);float kInDirectLight = pow(squareRoughness + 1, 2) / 8; float kInIBL = pow(squareRoughness, 2) / 8; float GLeft = nl / lerp(nl, 1, kInDirectLight); float GRight = nv / lerp(nv, 1, kInDirectLight); float G = GLeft * GRight;float3 F0 = lerp(unity_ColorSpaceDielectricSpec.rgb, Albedo, _Metallic); float3 F = F0 + (1 - F0) * exp2((-5.55473 * vh - 6.98316) * vh);float3 SpecularResult = (D * G * F * 0.25) / (nv * nl);//漫反射系數 float3 kd = (1 - F)*(1 - _Metallic);//直接光照部分結果 float3 specColor = SpecularResult * lightColor * nl * UNITY_PI; float3 diffColor = kd * Albedo * lightColor * nl; float3 DirectLightResult = diffColor + specColor;直接光部分得到的渲染效果如下:
可以看到和使用Unity內置函數實現的直接光PBR效果十分接近(形狀不一樣是因為用的是透視攝像機)。
我看到的大部分教程寫到這就結束了,但實際上此時離真正的PBR還差的很遠(圖片中第二排球和第三篇球的差距還很大)。BRDF的直接光部分和傳統非物理渲染的效果其實區別不大,真正不同之處在于間接光部分。和直接光部分直接弄幾個公式乘起來就行不同,這部分確實很難啃。在下文中,我會實現間接光的漫反射和高光反射部分,并盡量通俗易懂的把這兩部分講明白。
間接光
間接光的實現與ibl(基于圖像的渲染)和SH(球諧光照)這兩個名詞分不開。基于圖像的渲染已經是很大的一個體系了,在這里特指基于環境貼圖cubemap對表面進行渲染。球諧光照實際上就是將周圍的環境光采樣成幾個系數,然后渲染的時候用這幾個系數來對光照進行還原,這種過程可以看做是對周圍環境光的簡化。這兩者在后面的實驗中都會被用到。
間接光部分使用的也是和直接光相同的BRDF方程,不同之處在于BRDF加一加就好,而這里真的要解積分。。。總的公式如下:
間接光漫反射
拆分出的間接光漫反射公式如下:
后面那個積分看起來很嚇人,想看解法的話《 Real-time Rendering》上有,我這里只談實現。在Shader里每幀做積分顯然是不現實的,普遍的做法是把采樣得到的cubemap預處理成一張貼圖,就像下圖這樣:
預處理過程如果想手寫的話可以看OpenGL的實現,而作為一個成熟的引擎Unity已經幫我們把cubemap處理好存起來了。Unity里有這么一組變量:
// SH lighting environmenthalf4 unity_SHAr;half4 unity_SHAg;half4 unity_SHAb;half4 unity_SHBr;half4 unity_SHBg;half4 unity_SHBb;half4 unity_SHC;這里存的是積分后用球諧函數編碼的全局光照。即Unity做的事情為:先將環境貼圖cubemap積分成模糊的全局光照貼圖,再將全局光照貼圖投影到球諧光照的基函數上存儲,這里的七個參數即為存儲的基函數的系數。Unity用的基函數叫三階的伴隨勒讓德多項式,式子的參數如下所示:
unity_SHA的三個系數存儲的是l=1時的參數,unity_SHB存儲的是l=2時的第1,2,4個參數,unity_SHC單獨存儲(m=2,l=2)時的最后一個參數。(l=0,m=0)和(l=2,m=0)的系數代表的光照數據影響太小被Unity舍棄掉了。
公式挺嚇人,但實際上每個參數表示的都是球面上某一部分的光照,如圖:
具體的計算代碼見UnityCG.cginc,在這里直接調用其中的ShadeSH9函數。此函數傳入歸一化的法線,返回的即為重建的積過分的環境光照信息。
half3 ambient_contrib = ShadeSH9(float4(i.normal, 1));float3 ambient = 0.03 * Albedo;float3 iblDiffuse = max(half3(0, 0, 0), ambient.rgb + ambient_contrib);float kdLast = 1;float3 iblDiffuseResult = iblDiffuse * kdLast * Albedo;第二行往后的代碼都很容易理解。ambient是環境光影響不大,隨便設個很暗的值就行。這樣得到的iblDiffuse就是方程中積分部分的值,乘上kd乘上Albedo即為間接光漫反射的結果,此處的kd和上面的kd不一樣需要重新計算,先設成1。注意這里和直接光部分一樣沒有對顏色除PI。
做完這一步得到的渲染結果如下:
可以看到在材質金屬度為0的時候效果和standard shader已經很接近了,我們離勝利只有一步之遙。
間接光鏡面反射
間接光鏡面反射方程如下:
可以看到方程十分復雜,虛幻引擎的做法是使用近似算法split sum把它簡化成下面這樣:
左邊的括是一個和粗糙度有關的函數,由于它跟粗糙度相關我們不能和漫反射一樣用一張貼圖解決,這種時候我們就想起了我們的老朋友LOD。把環境cubemap渲染成一張叫Pre-Filtered Environment Map的帶LOD的類似于下面這樣的貼圖:
然后根據粗糙度對這張貼圖進行三次線性采樣,采樣得到的顏色就是方程左邊括號內的結果。
Unity自然也給了這張圖,就存儲在unity_SpecCube0這個變量里。這個變量大家都見過,存儲的是場景和天空盒的反射探針數據(還有一個變量叫unity_SpecCube1,存儲的離物體最近的反射探針的數據)。有了圖我們就開始采樣,采樣代碼如下:
float mip_roughness = perceptualRoughness * (1.7 - 0.7 * perceptualRoughness); float3 reflectVec = reflect(-viewDir, i.normal);half mip = mip_roughness * UNITY_SPECCUBE_LOD_STEPS; half4 rgbm = UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0, reflectVec, mip); float3 iblSpecular = DecodeHDR(rgbm, unity_SpecCube0_HDR);代碼比較難懂我一行行講。第一行是采樣用的粗糙度的計算,Unity的粗糙度和采樣的mipmap等級關系不是線性的,Unity內使用的轉換公式為mip = r(1.7 - 0.7r),這是Unity shader的實現,只是個很接近實際值的擬合曲線,真正的計算方式如下:
float m = roughness*roughness; const float fEps = 1.192092896e-07F; float n = (2.0 / max(fEps, m * m)) - 2.0; n /= 4; roughness = pow( 2 / (n + 2), 0.25);這個函數來源于這篇文章:
Microfacet Based Bidirectional Reflectance Distribution Function?jbit.net其中有這樣一個公式(公式21):
關于這個除4來源于這篇文章的Pre-convolved Cube Maps vs Path Tracers部分:
Power Drops Within Lys?s3.amazonaws.com我是看不懂了。。。想看的或是看得懂的大佬求求你給我講講吧。。。
第二行好說,就是根據視線方向和法線求出個反射向量留著以后用。
第三行是用從0到1之間的mip_roughness函數換算出用于實際采樣的mip層級,UNITY_SPECCUBE_LOD_STEPS是一個定義在UnityStandardConfig.cginc文件中的常量,沒改的話就是6。
第四行的UNITY_SAMPLE_TEXCUBE_LOD是一個采樣函數,粗糙度越高采樣出的結果就越模糊。cubemap的采樣使用三線性插值,即從兩張最近的mipmap層級上各做一次二次線性插值再將結果插值。
最后一行使用DecodeHDR將顏色從HDR編碼下解碼。可以看到采樣出的rgbm是一個4通道的值,最后一個m存的是一個參數,解碼時將前三個通道表示的顏色乘上xM^y,x和y都是由環境貼圖定義的系數,存儲在unity_SpecCube0_HDR這個結構中。
于是我們得到了iblSpeclar,也就是上面間接高光的方程里左邊括號的值。
右邊括號是一個定值,業界的做法是將值放到一張查找圖中,用的時候根據nv和粗糙度采樣。這種LUT(Look up texture)如下:
導入這張圖的時候注意要改下圖片的設置。之前把Unity的顏色空間設置為線性,這個設置的意思并不是說我們不需要做伽馬校正了,而是在導入貼圖的時候Unity會自動將貼圖設置為sRGB格式從伽馬空間轉到線性空間,在輸出顏色的時候再自動做伽馬映射將計算的值從線性空間映射到伽馬空間顯示到屏幕上。之所以要設置sRGB格式是因為大部分用于顯示顏色的貼圖(Color Texture)為了在伽馬顏色空間中輸出正確顏色在導入前就做過一次伽馬校正了,而我們的LUT并不用于輸出顏色,只是個用于查找數據的圖并沒有被校正過,所以設置中需要把sRGB取消掉。同時關閉貼圖的mipmap生成,將Wrap mode設置為clamp,Filter mode設置為Bilinear(沒有mipmap也沒法設置成三次線性)。再將下面的壓縮參數都設置為最大(能不壓縮就不壓縮)。
將導入的查找圖拖到材質的LUT上,接著進行編碼,代碼如下:
float3 iblSpecular = DecodeHDR(rgbm, unity_SpecCube0_HDR);//添加的部分從這里開始 float2 envBDRF = tex2D(_LUT, float2(lerp(0, 0.99, nv), lerp(0, 0.99, roughness))).rg; // LUT采樣 //添加的部分到這里結束float3 iblDiffuseResult = iblDiffuse * kdLast * Albedo;注意這里把nv和roughness都clamp到0到0.99之間,這是因為當這兩個值都為1的時候LUT的顏色會發生突變,導致被渲染的物體上產生亮斑。
注意,這個LUT是虛幻和OpenGL的實現,實際上Unity用的是另一套東西,Unity計算間接光漫反射的那行源碼如下:
surfaceReduction * gi.specular * FresnelLerp (specColor, grazingTerm, nv);注意到Unity用的是一個叫surfaceReduction的系數,以及一個在F0(specColor就是我們的F0)和grazingTerm之間進行插值的菲涅爾系數。紋理采樣的開銷遠大于計算這幾個參數的開銷,也就是說Unity這個做法通常而言比采樣LUT要來的快。但關于這幾個值的理論意義我是實在找不到,所有教程也都不約而同的把這點跳了過去。我感覺Unity是用一個高效的擬合函數現算了LUT里的數據,但Unity給的公式我并沒有推出來。。。這點先欠著,之后等我搞明白了再補上,當然要是有大佬能告訴我答案就更好了。
這幾個值的計算方法如下:
ifdef UNITY_COLORSPACE_GAMMA// 1-0.28*x^3 as approximation for (1/(x^4+1))^(1/2.2) on the domain [0;1]surfaceReduction = 1.0-0.28*roughness*perceptualRoughness; # else// fade in [0.5;1]surfaceReduction = 1.0 / (roughness*roughness + 1.0); # endifhalf grazingTerm = saturate(smoothness + (1-oneMinusReflectivity));inline half3 FresnelLerp (half3 F0, half3 F90, half cosA) {half t = Pow5 (1 - cosA); // ala Schlick interpoliationreturn lerp (F0, F90, t); }我們也可以模仿Unity的間接光鏡面反射實現,代碼如下:
float surfaceReduction = 1.0 / (roughness*roughness + 1.0); //Liner空間 //float surfaceReduction = 1.0 - 0.28*roughness*perceptualRoughness; //Gamma空間float oneMinusReflectivity = 1 - max(max(SpecularResult.r, SpecularResult.g), SpecularResult.b); float grazingTerm = saturate(_Smoothness + (1 - oneMinusReflectivity)); float4 IndirectResult = float4(iblDiffuse * kdLast * Albedo + iblSpecular * surfaceReduction * FresnelLerp(F0, grazingTerm, nv), 1);這樣做得到的實際效果和使用LUT的情況差別不大。
接著計算間接光的菲涅爾系數和kd,把上面設為1的KLast刪掉重新計算,代碼如下:
float2 envBDRF = tex2D(_LUT, float2(lerp(0, 0.99, nv), lerp(0, 0.99, roughness))).rg; // LUT采樣//添加到的分從這里開始 float3 Flast = fresnelSchlickRoughness(max(nv, 0.0), F0, roughness); float kdLast = (1 - Flast) * (1 - _Metallic); //添加的部分到這里結束float3 iblDiffuseResult = iblDiffuse * kdLast * Albedo;在frag shader上面添加fresnelSchlickRoughness函數:
float3 fresnelSchlickRoughness(float cosTheta, float3 F0, float roughness) {return F0 + (max(float3(1.0 - roughness, 1.0 - roughness, 1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0); }這里的菲涅爾系數計算和前面的有兩點不同,第一點是這里沒有用于計算微片元朝向的D函數,計算菲涅爾系數使用的是真正的nv而不是vh,第一點是這里的菲尼爾系數計算使用的是粗糙度而不是金屬度。
使用nv是由于環境光來自半球內圍繞法線N的所有方向,因此無法和直接光照中的法線分布函數D一樣使用單個半角向量來確定微平面分布,所以在此我們只能使用法線和視線的夾角(即nv)來計算菲涅爾效果。
使用粗糙度而不是金屬度其實是一種經驗化的做法,方法來自Sébastien Lagarde:
Adopting a physically based shading model?seblagarde.wordpress.com間接光和直射光的屬性相同,因此我們期望較粗糙的表面在邊緣上的反射較弱。但之前我們在直接光中計算的菲涅爾系數時完全沒有把粗糙度考慮進去,所以表面邊緣的反射率總會相對實際值偏高從而帶來失真。這一點在粗糙的非金屬表面邊緣上十分明顯,失真效果如下:
在這里使用的由Sébastien Lagarde描述的以粗糙度為系數的菲涅爾方程可以有效緩解這個問題。
根據新的菲涅爾系數即可算出新的kd,有了這些值就可以進行環境光部分的加和工作,代碼如下:
float3 iblDiffuseResult = iblDiffuse * kdLast * Albedo;//添加到的分從這里開始 float3 iblSpecularResult = iblSpecular * (Flast * envBDRF.r + envBDRF.g); //添加的部分到這里結束float3 IndirectResult = iblDiffuseResult + iblSpecularResult;高光部分乘上的就是根據LUT采樣出的顏色和菲涅爾系數計算出的值。現在我們的BRDF方程四部分就全部計算完成了,得到的結果如下:
可以看到第二行小球的渲染效果已經和第三行的standard shader看不出太大區別了,大功告成。
完整的shader如下:
Shader "Arc/ArcHandWritePbrExp" {Properties{_MainTex("Texture", 2D) = "white" {}_Tint("Tint", Color) = (1 ,1 ,1 ,1)[Gamma] _Metallic("Metallic", Range(0, 1)) = 0 //金屬度要經過伽馬校正_Smoothness("Smoothness", Range(0, 1)) = 0.5_LUT("LUT", 2D) = "white" {}}SubShader{Tags { "RenderType" = "Opaque" }LOD 100Pass{Tags {"LightMode" = "ForwardBase"}CGPROGRAM#pragma target 3.0#pragma vertex vert#pragma fragment frag#include "UnityStandardBRDF.cginc" struct appdata{float4 vertex : POSITION;float3 normal : NORMAL;float2 uv : TEXCOORD0;};struct v2f{float4 vertex : SV_POSITION;float2 uv : TEXCOORD0;float3 normal : TEXCOORD1;float3 worldPos : TEXCOORD2;};float4 _Tint;float _Metallic;float _Smoothness;sampler2D _MainTex;float4 _MainTex_ST;sampler2D _LUT;v2f vert(appdata v){v2f o;o.vertex = UnityObjectToClipPos(v.vertex);o.worldPos = mul(unity_ObjectToWorld, v.vertex);o.uv = TRANSFORM_TEX(v.uv, _MainTex);o.normal = UnityObjectToWorldNormal(v.normal);o.normal = normalize(o.normal);return o;}float3 fresnelSchlickRoughness(float cosTheta, float3 F0, float roughness){return F0 + (max(float3(1 ,1, 1) * (1 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0);}fixed4 frag(v2f i) : SV_Target{i.normal = normalize(i.normal);float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);float3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz);float3 lightColor = _LightColor0.rgb;float3 halfVector = normalize(lightDir + viewDir); //半角向量float perceptualRoughness = 1 - _Smoothness;float roughness = perceptualRoughness * perceptualRoughness;float squareRoughness = roughness * roughness;float nl = max(saturate(dot(i.normal, lightDir)), 0.000001);//防止除0float nv = max(saturate(dot(i.normal, viewDir)), 0.000001);float vh = max(saturate(dot(viewDir, halfVector)), 0.000001);float lh = max(saturate(dot(lightDir, halfVector)), 0.000001);float nh = max(saturate(dot(i.normal, halfVector)), 0.000001);float3 Albedo = _Tint * tex2D(_MainTex, i.uv);float lerpSquareRoughness = pow(lerp(0.002, 1, roughness), 2);//Unity把roughness lerp到了0.002float D = lerpSquareRoughness / (pow((pow(nh, 2) * (lerpSquareRoughness - 1) + 1), 2) * UNITY_PI);float kInDirectLight = pow(squareRoughness + 1, 2) / 8;float kInIBL = pow(squareRoughness, 2) / 8;float GLeft = nl / lerp(nl, 1, kInDirectLight);float GRight = nv / lerp(nv, 1, kInDirectLight);float G = GLeft * GRight;float3 F0 = lerp(unity_ColorSpaceDielectricSpec.rgb, Albedo, _Metallic);float3 F = F0 + (1 - F0) * exp2((-5.55473 * vh - 6.98316) * vh);float3 SpecularResult = (D * G * F * 0.25) / (nv * nl);//漫反射系數float3 kd = (1 - F)*(1 - _Metallic);//直接光照部分結果float3 specColor = SpecularResult * lightColor * nl * UNITY_PI;float3 diffColor = kd * Albedo * lightColor * nl;float3 DirectLightResult = diffColor + specColor;half3 ambient_contrib = ShadeSH9(float4(i.normal, 1));float3 ambient = 0.03 * Albedo;float3 iblDiffuse = max(half3(0, 0, 0), ambient.rgb + ambient_contrib);float mip_roughness = perceptualRoughness * (1.7 - 0.7 * perceptualRoughness);float3 reflectVec = reflect(-viewDir, i.normal);half mip = mip_roughness * UNITY_SPECCUBE_LOD_STEPS;half4 rgbm = UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0, reflectVec, mip);float3 iblSpecular = DecodeHDR(rgbm, unity_SpecCube0_HDR);float2 envBDRF = tex2D(_LUT, float2(lerp(0, 0.99, nv), lerp(0, 0.99, roughness))).rg; // LUT采樣float3 Flast = fresnelSchlickRoughness(max(nv, 0.0), F0, roughness);float kdLast = (1 - Flast) * (1 - _Metallic);float3 iblDiffuseResult = iblDiffuse * kdLast * Albedo;float3 iblSpecularResult = iblSpecular * (Flast * envBDRF.r + envBDRF.g);float3 IndirectResult = iblDiffuseResult + iblSpecularResult;float4 result = float4(DirectLightResult + IndirectResult, 1);return result;}ENDCG}} }項目的github鏈接結語
結語寫了半天感覺怎么寫都是廢話,干脆寫個當致謝寫得了。感謝 @膜力鴨蘇蛙可 大佬的大力支持,感謝三無用戶 @pc fu 大佬的寶貴意見,感謝《猴子都能看懂的pbr(才怪)》這篇文章和它的作者(雖然文章中的一些理解和我不太一樣)。當然,最該感謝的還是業界發明這些渲染模型,以及開發出游戲引擎的前輩們。本人才疏學淺,文章中如果有什么錯誤希望各位大佬多多指正,在此先行謝過。
總結
以上是生活随笔為你收集整理的unity 2020 怎么写shader使其接受光照?_如何在Unity中造一个PBR Shader轮子的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Python学习笔记: Python 标
- 下一篇: 编程方法学笔记:karel