1. 程式人生 > >UnityShader 漫反射(蘭伯特與半蘭伯特光照模型-逐頂點和逐畫素光照)

UnityShader 漫反射(蘭伯特與半蘭伯特光照模型-逐頂點和逐畫素光照)

漫反射效果

漫反射

是指投射在粗糙表面上的光向各個方向反射的現象。當一束平行的入射光線射到粗糙的表面時,表面會把光線向著四面八方反射,所以入射線雖然互相平行,由於各點的法線方向不一致,造成反射光線向不同的方向無規則地反射,這種反射稱之為“漫反射”或“漫射”。這種反射的光稱為漫射光。很多物體,如植物、牆壁、衣服等,其表面粗看起來似乎是平滑,但用放大鏡仔細觀察,就會看到其表面是凹凸不平的,所以本來是平行的太陽光被這些表面反射後,瀰漫地射向不同方向。

實現原理

漫反射光照是用於對那些被物體表面隨機散射到各個方向的輻射度進行建模的。在漫反射中,視角的位置是不重要的,因為反射是完全隨機的,因此可以認為在任何反射方向上的分佈都是一樣的。但是,入射光線的角度很重要。

漫反射光照符合蘭伯特定律(Lambert's law)

: 反射光線的強度與表面法線和光源方向之間夾角的餘弦值成正比。因此,漫反射部分的計算如下:

Cdiffuse=(Clight · mdiffuse)max(0, n · )

其中, n 是表面法線, I 是指向光源的單位向量, mdiffuse 是材質的漫反射顏色, clight 是光源顏色。需要注意的是,我們需要防止法線和光源方向點乘的結果為負值,為此,我們使用取最大值的函式來將其擷取到 0, 這可以防止物體被從後面來的光源照亮。


逐頂點計算著色shader

我們在shader中需要計算輸出的顏色,逐頂點著色也就是說我們的計算主要放在了vertex shader中,根據頂點來計算,每個頂點中計算出了該點的顏色,直接作為vertex shader的輸出,pixel(fragment) shader的輸入,當到達pixel階段時,直接輸出頂點shader的結果。比如一個三角形面片,在vertex階段,分別計算了每個頂點的顏色值,在pixel階段時,這個面片經過投影,最終顯示在螢幕上的畫素,會根據該畫素周圍的頂點來插值計算畫素的最終顏色,這種著色方式也叫做
高洛德著色

下面看一下unity shader實現的逐頂點著色:

Shader "Diffuse/Diffuse Vertex-Level" {
	Properties {
		_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)      // 控制材質的漫反射顏色 
	}
	SubShader {
		Pass { 
		     // LightMode 標籤是Pass 標籤中的一種,它用於定義該Pass 在Unity 的光照流水線中的角色
			//只有定義了正確的LightMode,我們才能得到一些Unity 的內建光照變數,例如下面的_LightColor0
			Tags { "LightMode"="ForwardBase" }
		
			CGPROGRAM
			
			#pragma vertex vert
			#pragma fragment frag

			//為了使用Unity 內建的一些變數,如後面要講到的_LightColor0,還需要包含進Unity 的內建檔案Lighting.cginc
			#include "Lighting.cginc"
			
			fixed4 _Diffuse;
			
			struct a2v {
				float4 vertex : POSITION;
				float3 normal : NORMAL;
			};
			
			struct v2f {
				float4 pos : SV_POSITION;
				fixed3 color : COLOR;
			};
			
			v2f vert(a2v v) {
				v2f o;

				//將頂點從模型空間轉換到投影空間
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				
				// Get ambient term
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
				
				// 把法線從模型空間轉化到世界空間  
				fixed3 worldNormal = normalize(mul(v.normal, (float3x3)_World2Object));
				// 得到世界空間下的光照方向
				fixed3 worldLight = normalize(_WorldSpaceLightPos0.xyz);
				// 根據蘭伯特模型計算頂點的光照資訊
				fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLight));

				o.color = ambient + diffuse;
				
				return o;
			}
			
			fixed4 frag(v2f i) : SV_Target {
				return fixed4(i.color, 1.0);
			}
			
			ENDCG
		}
	}
	FallBack "Diffuse"
}

以上程式碼詳細解釋:

1:我們已經重複過很多次, 頂點著色器最基本的任務就是把頂點位置從模型空間轉換到裁剪空間中, 因此我們需要使用Unity 內建的 模型*世界*投影矩陣UNITY_MATRIX_MVP 來完成這樣的座標變換。

2:接下來,我們通過Unity 的內建變數 UNITY_LIGHTMODEL_AMBIENT 得到了環境光部分。

3:然後, 就是真正計算漫反射光照的部分。為了計算漫反射光照我們需要知道4 個引數。在前面的步驟中, 我們已經知道了材質的漫反射顏色Diffuse 以及頂點法線v.normal。我們還需要知道光源的顏色和強度資訊以及光源方向。

4:Unity 提供給我們一個內建變數 _LightColor0 來訪問該Pass 處理的光源的顏色和強度資訊(注意,想要得到正確的值需要定義合適的LightMode標籤〉,而光源方向可以由 _WorldSpaceLightPos0 來得到。需要注意的是,這裡對光源方向的計算並不具有通用性。在本節中, 我們假設場景中只有一個光源且該光源的型別是平行光。但如果場景中有多個光源並且型別可能是點光源等其他型別, 直接使用 _WorldSpaceLightPos0 就不能得到正確的結果。

5:在計演算法線和光源方向之間的點積時,我們需要選擇它們所在的座標系, 只有兩者處於同一座標空間下,它們的點積才有意義。在這裡, 我們選擇了世界座標空間。而由a2v 得到的頂點法線是位於模型空間下的, 因此我們首先需要把法線轉換到世界空間中。我們已經知道可以使用頂點變換矩陣的逆轉置矩陣對法線進行相同的變換,因此我們首先得到模型空間到世界空間的變換矩陣的逆矩陣 _World20bject,然後通過調換它在mul 函式中的位置,得到和轉置矩陣相同的矩陣乘法。由於法線是一個三維向量,因此我們只需要擷取 _World2Object 的前三行前三列即可。

6:在得到了世界空間中的法線和光源方向後,我們需要對它們進行歸一化操作。在得到它們點積的結果後,我們需要防止這個結果為負值。為此,我們使用了saturate 函式。saturate 函式是CG提供的一種函式,它的作用是可以把引數擷取到[0, 1]的範圍內。最後,再與光源的顏色和強度以及材質的漫反射顏色相乘即可得到最終的漫反射光照部分。

7:最後,我們對環境光和漫反射光部分相加,得到最終的光照結果。

逐畫素計算著色shader


逐畫素計算時,我們的主要計算放到了pixel shader裡,在vertex shader階段只是進行了基本的頂點變換操作,以及頂點的法線轉化到世界空間的操作,然後將轉化後的法線作為引數傳遞給pixel shader。其他的計算都放到了pixel shader階段,這樣,針對每個畫素,我們都可以來計算這個畫素的光照情況,而不是像逐頂點計算時,先計算好頂點的顏色,然後差值得到中間的畫素顏色。這種逐畫素著色的方式也叫作 馮氏著色(注意不是馮氏光照模型,不要搞混呦)。
下面看一下unity shader實現的逐畫素著色:
Shader "Diffuse/Diffuse Pixel-Level" {
	Properties {
		_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)        // 控制材質的漫反射顏色 
	}
	SubShader {
		Pass { 
			 // LightMode 標籤是Pass 標籤中的一種,它用於定義該Pass 在Unity 的光照流水線中的角色
			//只有定義了正確的LightMode,我們才能得到一些Unity 的內建光照變數,例如下面的_LightColor0
			Tags { "LightMode"="ForwardBase" }
		
			CGPROGRAM
			
			#pragma vertex vert
			#pragma fragment frag

			//為了使用Unity 內建的一些變數,如後面要講到的_LightColor0,還需要包含進Unity 的內建檔案Lighting.cginc
			#include "Lighting.cginc"
			
			fixed4 _Diffuse;
			
			struct a2v {
				float4 vertex : POSITION;
				float3 normal : NORMAL;
			};
			
			struct v2f {
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
			};
			
			v2f vert(a2v v) {
				v2f o;
				// Transform the vertex from object space to projection space
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);

				// Transform the normal from object space to world space
				o.worldNormal = mul(v.normal, (float3x3)_World2Object);

				return o;
			}
			
			fixed4 frag(v2f i) : SV_Target {
				// Get ambient term
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
				
				// Get the normal in world space
				fixed3 worldNormal = normalize(i.worldNormal);
				// Get the light direction in world space
				fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
				
				// Compute diffuse term
				fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(worldNormal, worldLightDir));
				
				fixed3 color = ambient + diffuse;
				
				return fixed4(color, 1.0);
			}
			
			ENDCG
		}
	} 
	FallBack "Diffuse"
}

從vertex階段到fragment階段發生了什麼

對於正方體,只有單個面,沒有特別明顯的差別,但是對於圓柱體,就可以看出一些差別了,逐頂點著色的圓柱體可以看出線條狀的輪廓,其實每個線條都是由兩個三角形面片組成的長方形面片。
為什麼逐畫素計算會得到更好的效果,因為我們逐畫素取的光照的方向是一致的,法線方向也是通過上一步的vertex shader傳遞過來的,如果畫素和頂點對應了的話,那不是每個畫素的計算結果都會一樣呢?然而,其實畫素和頂點是不對應的,這個就是傳說中的渲染流水線了,在頂點階段計算的結果,並不是直接傳遞給畫素著色器的,而是經過了一系列的插值計算,我們從vertex shader傳遞過來的法線方向,只代表了這一個頂點的頂點法線方向,而到了pixel階段,這個畫素所對應的法線等引數相當於其周圍幾個頂點進行插值後的結果。我們用這一個畫素點對應的法線方向與光照方向進行計算,就可以獲得該畫素點在光照條件下的顏色值,而不是先計算好顏色再插值得到結果。

半蘭伯特光照模型

逐畫素光照可以得到更加平滑的光照效果。但是,即便使用了逐畫素漫反射光照,有一個問題仍然存在。在光照無法到達的區域,模型的外觀通常是全黑的,沒有任何明暗變化,這會使模型的背光區域看起來就像一個平面一樣,失去了模型細節表現。 然而,實際上,我們在現實世界中經常會發現,即使我們讓一個物體不被光直接照射,我們也可能會看到物體,雖然亮度不是很高,這其實是由於物體之間光的反射造成的,也就是間接光照,間接光照是更高階的渲染,比如光線追蹤演算法等。但是在實時圖形學,我們大部分情況是通過一個環境光(Ambient Light)統一代表了間接光,這樣,即使在沒有光的時候,我們也可以看見物體。實際上我們可以通過新增環境光來得到非全黑的效果,但即便這樣仍然無法解決背光面明暗一樣的缺點。為此, 有一種改善技術被提出來,這就是半蘭伯特( Half Lambert)光照模型

Valve 公司在開發遊戲《半條命》時提出了一種技術,由於該技術是在原蘭伯特光照模型的基礎上進行了一個簡單的修改, 因此被稱為半蘭伯特光照模型
廣義的半蘭伯特光照模型的公式如下:
Cdiffuse=(Clight · mdiffuse)(α (n· I)+ β)
可以看出, 與原蘭伯特模型相比,半蘭伯特光照模型沒有使用max操作來防止n I 的點積為負值,而是對其結果進行了一個α倍的縮放再加上一個β大小的偏移。絕大多數情況下, α和β
的值均為0.5 ,即公式為: Cdiffuse=(Clight · mdiffuse)(0.5 (n· I)+ 0.5)
通過這樣的方式,我們可以把n·I 的結果範圍從[-1, 1 ]對映到[0, 1 ]範圍內。也就是說,對於模型的背光面,在原蘭伯特光照模型中點積結果將對映到同一個值,即0 值處;而在半蘭伯特模型中,背光面也可以有明暗變化,不同的點積結果會對映到不同的值上。
方法很簡單,乘以0.5再加上0.5。這樣,原本亮度為1的地方,乘以0.5變成了0.5,加上0.5就又成了1,而原本光照強度為0的地方,就變成了0.5,原本為負數的地方,也能保證為大於0了。半蘭伯特光照這種區間轉化的原理圖如下所示:
需要注意的是,半蘭伯特是沒有任何物理依據的,它僅僅是一個視覺加強技術。

下面看一下逐畫素計算的半蘭伯特光照shader,與蘭伯特光照相比,只是將法線向量與光照方向向量的點乘結果用一種更好的方式區間轉化到了(0,1)區間:
Shader "Diffuse/Half Lambert" {
	Properties {
		_Diffuse ("Diffuse", Color) = (1, 1, 1, 1)    // 控制材質的漫反射顏色 
	}
	SubShader {
		Pass { 
		     // LightMode 標籤是Pass 標籤中的一種,它用於定義該Pass 在Unity 的光照流水線中的角色
			//只有定義了正確的LightMode,我們才能得到一些Unity 的內建光照變數,例如下面的_LightColor0
			Tags { "LightMode"="ForwardBase" }
		
			CGPROGRAM
			
			#pragma vertex vert
			#pragma fragment frag

			//為了使用Unity 內建的一些變數,如後面要講到的_LightColor0,還需要包含進Unity 的內建檔案Lighting.cginc
			#include "Lighting.cginc"
			
			fixed4 _Diffuse;
			
			struct a2v {
				float4 vertex : POSITION;
				float3 normal : NORMAL;
			};
			
			struct v2f {
				float4 pos : SV_POSITION;
				float3 worldNormal : TEXCOORD0;
			};
			
			v2f vert(a2v v) {
				v2f o;
				// Transform the vertex from object space to projection space
				o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
				
				// Transform the normal from object space to world space
				o.worldNormal = mul(v.normal, (float3x3)_World2Object);
				
				return o;
			}
			
			fixed4 frag(v2f i) : SV_Target {
				// Get ambient term
				fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
				
				// Get the normal in world space
				fixed3 worldNormal = normalize(i.worldNormal);
				// Get the light direction in world space
				fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
				
				// Compute diffuse term
				fixed halfLambert = dot(worldNormal, worldLightDir) * 0.5 + 0.5;
				fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * halfLambert;
				
				fixed3 color = ambient + diffuse;
				
				return fixed4(color, 1.0);
			}
			
			ENDCG
		}
	} 
	FallBack "Diffuse"
}

我們用一個人物模型,分別使用兩種shader,進行一下對比,左側的shader主要計算在vertex,右側的shader主要計算放在pixel:


可以看出,如果模型比較細緻,其實在diffuse情況下,是沒有特別明顯的區別的,而大部分計算放在vertex shader中,對於效率更有益處,vertex shader一般不是GPU的瓶頸,逐頂點計算可以比逐畫素計算省很多,所以將盡可能多的計算放在vertex階段而不是fragment階段是一個很好的優化shader的策略。但是,注意!只是在diffuse的情況,如果我們的shader中有高光 specular,那麼,用逐頂點計算高光就會出現特別難看的光斑。
由於unity shader中漫反射是帶有環境光的,所以我們也在shader中計算了環境光。由於沒有全域性光照,所以間接光照就通過 UNITY_LIGHTMODEL_AMBIENT這個巨集進行訪問。

源工程下載:UnityShader 漫反射(蘭伯特與半蘭伯特光照模型-逐頂點和逐畫素光照)