貓都能學會的Unity3D Shader入門指南(二)
關于本系列
這是Unity3D Shader入門指南系列的第二篇,本系列面向的對象是新接觸Shader開發的Unity3D使用者,因為我本身自己也是Shader初學者,因此可能會存在錯誤或者疏漏,如果您在Shader開發上有所心得,很歡迎并懇請您指出文中紕漏,我會盡快改正。在之前的開篇中介紹了一些Shader的基本知識,包括ShaderLab的基本結構和語法,以及簡單逐句地講解了一個基本的shader。在具有這些基礎知識后,閱讀簡單的shader應該不會有太大問題,在繼續教程之前簡單閱讀一下Unity的Surface Shader Example,以檢驗您是否掌握了上一節的內容。如果您對閱讀大部分示例Shader并沒有太大問題,可以正確地指出Shader的結構,聲明和使用的話,就說明您已經準備好繼續閱讀本節的內容了。
法線貼圖(Normal Mapping)
法線貼圖是凸凹貼圖(Bump mapping)的一種常見應用,簡單說就是在不增加模型多邊形數量的前提下,通過渲染暗部和亮部的不同顏色深度,來為原來的貼圖和模型增加視覺細節和真實效果。簡單原理是在普通的貼圖的基礎上,再另外提供一張對應原來貼圖的,可以表示渲染濃淡的貼圖。通過將這張附加的表示表面凸凹的貼圖的因素于實際的原貼圖進行運算后,可以得到新的細節更加豐富富有立體感的渲染效果。在本節中,我們將首先實現一個法線貼圖的Shader,然后對Unity Shader的光照模型進行一些討論,并實現一個自定義的光照模型。最后再通過更改shader模擬一個石頭上的積雪效果,并對模型頂點進行一些修改使積雪效果看起來比較真實。在本節結束的時候,我們就會有一個比較強大的可以滿足一些真實開發工作時可用的shader了,而且更重要的是,我們將會掌握它是如何被創造出來的。
關于法線貼圖的效果圖,可以對比看看下面。模型面數為500,左側只使用了簡單的Diffuse著色,右側使用了法線貼圖。比較兩張圖片不難發現,使用了法線貼圖的石頭在暗部和亮部都有著更好的表現。整體來說,凸凹感比Diffuse的結果增強許多,石頭看起來更真實也更具有質感。
本節中需要用到的上面的素材可以在這里下載,其中包括上面的石塊的模型,一張貼圖以及對應的法線貼圖。將下載的package導入到工程中,并新建一個material,使用簡單的Diffuse的Shader(比如上一節我們實現的),再加上一個合適的平行光光源,就可以得到我們左圖的效果。另外,本節以及以后都會涉及到一些Unity內建的Shader的內容,比如一些標準常用函數和常量定義等,相關內容可以在Unity的內建Shader中找到,內建Shader可以在Unity下載頁面的版本右側找到。
接下來我們實現法線貼圖。在實現之前,我們先簡單地稍微多了解一些法線貼圖的基本知識。大多數法線圖一般都和下面的圖類似,是一張以藍紫色為主的圖。這張法線圖其實是一張RGB貼圖,其中紅,綠,藍三個通道分別表示由高度圖轉換而來的該點的法線指向:Nx、Ny、Nz。在其中絕大部分點的法線都指向z方向,因此圖更偏向于藍色。在shader進行處理時,我們將光照與該點的法線值進行點積后即可得到在該光線下應有的明暗特性,再將其應用到原圖上,即可反應在一定光照環境下物體的凹凸關系了。關于法向貼圖的更多信息,可以參考wiki上的相關條目。
回到正題,我們現在考慮的主要是Shader入門,而不是圖像學的原理。再上一節我們寫的Shader的基礎上稍微做一些修改,就可以得到適應并完成法線貼圖渲染的新Shader。新加入的部分進行了編號并在之后進行說明。
Shader "Custom/Normal Mapping" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
//1
_Bump ("Bump", 2D) = "bump" {}
}
SubShader {
Tags { "RenderType"="Opaque" }
LOD 200
CGPROGRAM
#pragma surface surf Lambert
sampler2D _MainTex;
//2
sampler2D _Bump;
struct Input {
float2 uv_MainTex;
//3
float2 uv_Bump;
};
void surf (Input IN, inout SurfaceOutput o) {
half4 c = tex2D (_MainTex, IN.uv_MainTex);
//4
o.Normal = UnpackNormal(tex2D(_Bump, IN.uv_Bump);
o.Albedo = c.rgb;
o.Alpha = c.a;
}
ENDCG
}
FallBack "Diffuse"
}
- 聲明并加入一個顯示名稱為
Bump
的貼圖,用于放置法線圖 - 為了能夠在CG程序中使用這張貼圖,必須加入一個sample,希望你還記得~
- 獲取Bump的uv信息作為輸入
- 從法線圖中提取法線信息,并將其賦予相應點的輸出的Normal屬性。
UnpackNormal
是定義在UnityCG.cginc文件中的方法,這個文件中包含了一系列常用的CG變量以及方法。UnpackNormal
接受一個fixed4的輸入,并將其轉換為所對應的法線值(fixed3)。在解包得到這個值之后,將其賦給輸出的Normal,就可以參與到光線運算中完成接下來的渲染工作了。
現在保存并且編譯這個Shader,創建新的material并使用這個shader,將石頭的材質貼圖和法線圖分別拖放到Base和Bump里,再將其應用到石頭模型上,應該就可以看到右側圖的效果了。
光照模型
在我們之前的看到的Shader中(其實也就上一節的基本diffuse和這里的normal mapping),都只使用了Lambert的光照模型(#pragma surface surf Lambert),這是一個很經典的漫反射模型,光強與入射光的方向和反射點處表面法向夾角的余弦成正比。關于Lambert和漫反射的一些詳細的計算和推論,可以參看wiki(Lambert,漫反射)或者其他地方的介紹。一句話的簡單解釋就是一個點的反射光強是和該點的法線向量和入射光向量和強度和夾角有關系的,其結果就是這兩個向量的點積。既然已經知道了光照計算的原理,我們先來看看如何實現一個自己的光照模型吧。
在剛才的Shader上進行如下修改。
- 首先將原來的
#pragma
行改為這樣
#pragma surface surf CustomDiffuse
- 然后在SubShader塊中添加如下代碼
inline float4 LightingCustomDiffuse (SurfaceOutput s, fixed3 lightDir, fixed atten) {
float difLight = max(0, dot (s.Normal, lightDir));
float4 col;
col.rgb = s.Albedo * _LightColor0.rgb * (difLight * atten * 2);
col.a = s.Alpha;
return col;
}
- 最后保存,回到Unity。Shader將編譯,如果一切正常,你將不會看到新的shader和之前的在材質表現上有任何不同。但是事實上我們現在的shader已經與Unity內建的diffuse光照模型撇清了關系,而在使用我們自己設定的光照模型了。
喵的,這些代碼都干了些什么!相信你一定會有這樣的疑惑...沒問題,沒有疑惑的話那就不叫初學了,還是一行行講來。首先正像我們上一篇所說,#pragma
語句在這里聲明了接下來的Shader的類型,計算調用的方法名,以及指定光照模型。在之前我們一直指定Lambert為光照模型,而現在我們將其換為了CustomDiffuse。
接下來添加的代碼是計算光照的實現。shader中對于方法的名稱有著比較嚴格的約定,想要創建一個光照模型,首先要做的是按照規則聲明一個光照計算的函數名字,即Lighting<Your Chosen Name>
。對于我們的光照模型CustomDiffuse,其計算函數的名稱自然就是LightingCustomDiffuse
了。光照模型的計算是在surf方法的表面顏色之后,根據輸入的光照條件來對原來的顏色在這種光照下的表現進行計算,最后輸出新的顏色值給渲染單元完成在屏幕的繪制。
也許你已經猜到了,我們之前用的Lambert光照模型是不是也有一個名字叫LightingLambert的光照計算函數呢?Bingo。在Unity的內建Shader中,有一個Lighting.cginc文件,里面就包含了LightingLambert的實現。也許你也注意到了,我們所實現的LightingCustomDiffuse的內容現在和Unity內建中的LightingLambert是完全一樣的,這也就是使用新的shader的原來視覺上沒有區別的原因,因為實現確實是完全一樣的。
首先來看輸入量,SurfaceOutput s
這個就是經過表面計算函數surf處理后的輸出,我們講對其上的點根據光線進行處理,fixed3 lightDir
是光線的方向,fixed atten
表示光衰減的系數。在計算光照的代碼中,我們先將輸入的s的法線值(在Normal mapping中的話這個值已經是法線圖中的對應量了)和輸入光線進行點積(dot函數是CG中內置的數學函數,希望你還記得,可以參考這里)。點積的結果在-1至1之間,這個值越大表示法線與光線間夾角越小,這個點也就應該越亮。之后使用max來將這個系數結果限制在0到1之間,是為了避免負數情況的存在而導致最終計算的顏色變為負數,輸出一團黑,一般來說這是我們不愿意看到的。接下來我們將surf輸出的顏色與光線的顏色_LightColor0.rgb
(由Unity根據場景中的光源得到的,它在Lighting.cginc中有聲明)進行乘積,然后再與剛才計算的光強系數和輸入的衰減系數相乘,最后得到在這個光線下的顏色輸出(關于difLight * atten * 2中為什么有個乘2,這是一個歷史遺留問題,主要是為了進行一些光強補償,可以參見這里的討論)。
在了解了基本實現方式之后,我們可以看看做一些修改玩玩兒。最簡單的比如將這個Lambert模型改亮一些,比如換成Half Lambert模型。Half Lambert是由Valve創造的可以使物體在低光線條件下增亮的技術,最早被用于半條命(Half Life)中以避免在低光下物體的走形。簡單說就是把光強系數先取一半,然后在加0.5,代碼如下:
inline float4 LightingCustomDiffuse (SurfaceOutput s, fixed3 lightDir, fixed atten) {
float difLight = dot (s.Normal, lightDir);
float hLambert = difLight * 0.5 + 0.5;
float4 col;
col.rgb = s.Albedo * _LightColor0.rgb * (hLambert * atten * 2);
col.a = s.Alpha;
return col;
}
這樣一來,原來光強0的點,現在對應的值變為了0.5,而原來是1的地方現在將保持為1。也就是說模型貼圖的暗部被增強變亮了,而亮部基本保持和原來一樣,防止過曝。使用Half Lambert前后的效果圖如下,注意最右側石頭下方的陰影處細節更加明顯了,而這一切都只是視覺效果的改變,不涉及任何貼圖和模型的變化。
表面貼圖的追加效果
OK,對于光線和自定義光照模型的討論暫時到此為止,因為如果展開的話這將會一個龐大的圖形學和經典光學的話題了。我們回到Shader,并且一起實現一些激動人心的效果吧。比如,在你的游戲場景中有一幕是雪地場景,而你希望做一些石頭上白雪皚皚的覆蓋效果,應該怎么辦呢?難道讓你可愛的3D設計師再去出一套覆雪的貼圖然后使用新的貼圖?當然不,不是不能,而是不該。因為新的貼圖不僅會增大項目的資源包體積,更會增大之后修改和維護的難度,想想要是有好多石頭需要實現同樣的覆雪效果,或者是要隨著游戲時間堆積的雪逐漸變多的話,你應該怎么辦?難道讓設計師再把所有的石頭貼圖都蓋上雪,然后再按照雪的厚度出5套不同的貼圖么?相信我,他們會瘋的。
于是,我們考慮用Shader來完成這件工作吧!先考慮下我們需要什么,積雪效果的話,我們需要積雪等級(用來表示積雪量),雪的顏色,以及積雪的方向。基本思路和實現自定義光照模型類似,通過計算原圖的點在世界坐標中的法線方向與積雪方向的點積,如果大于設定的積雪等級的閾值的話則表示這個方向與積雪方向是一致的,其上是可以積雪的,顯示雪的顏色,否則使用原貼圖的顏色。廢話不再多說,上代碼,在上面的Shader的基礎上,更改Properties里的內容為
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_Bump ("Bump", 2D) = "bump" {}
_Snow ("Snow Level", Range(0,1) ) = 0
_SnowColor ("Snow Color", Color) = (1.0,1.0,1.0,1.0)
_SnowDirection ("Snow Direction", Vector) = (0,1,0)
}
沒有太多值得說的,唯一要提一下的是_SnowDirection設定的默認值為(0,1,0),這表示我們希望雪是垂直落下的。對應地,在CG程序中對這些變量進行聲明:
sampler2D _MainTex;
sampler2D _Bump;
float _Snow;
float4 _SnowColor;
float4 _SnowDirection;
接下來改變Input的內容:
struct Input {
float2 uv_MainTex;
float2 uv_Bump;
float3 worldNormal; INTERNAL_DATA
};
相對于上面的Shader輸入來說,加入了一個float3 worldNormal; INTERNAL_DATA
,如果SurfaceOutput中設定了Normal值的話,通過worldNormal可以獲取當前點在世界中的法線值。詳細的解說可以參見Unity的Shader文檔。接下來可以改變surf函數,實裝積雪效果了。
void surf (Input IN, inout SurfaceOutput o) {
half4 c = tex2D (_MainTex, IN.uv_MainTex);
o.Normal = UnpackNormal(tex2D(_Bump, IN.uv_Bump));
if (dot(WorldNormalVector(IN, o.Normal), _SnowDirection.xyz) > lerp(1,-1,_Snow)) {
o.Albedo = _SnowColor.rgb;
} else {
o.Albedo = c.rgb;
}
o.Alpha = c.a;
}
和上面相比,加入了一個if…else…的判斷。首先看這個條件的不等式的左側,我們對雪的方向和和輸入點的世界法線方向進行點積。WorldNormalVector
通過輸入的點及這個點的法線值,來計算它在世界坐標中的方向;右側的lerp函數相信只要對插值有概念的同學都不難理解:當Snow取最小值0時,這個函數將返回1,而Snow取最大值時,返回-1。這樣我們就可以通過設定_Snow的值來控制積雪的閾值,要是積雪等級Snow是0時,不等式左側不可能大于右側,因此完全沒有積雪;相反要是Snow取最大值1時,由于左側必定大于-1,所以全模型積雪。而隨著取中間值的變化,積雪的情況便會有所不同。
應用這個Shader,并且適當地調節一下積雪等級和顏色,可以得到如下右邊的效果。
更改頂點模型
到現在位置,我們還僅指是在原貼圖上進行操作,不管是用法線圖使模型看起來凸凹有致,還是加上積雪,所有的計算和顏色的輸出都只是“障眼法”,并沒有對模型有任何實質的改動。但是對于積雪效果來說,實際上積雪是附加到石頭上面,而不應當簡單替換掉原來的顏色。但是具體實施起來,最簡單的辦法還是直接替換顏色,但是我們可以稍微變更一下模型,使原來的模型在積雪的方向稍微變大一些,這樣來達到一種雪是附加到石頭上的效果。
我們繼續修改之前的Shader,首先我們需要告訴surface shadow我們要改變模型的頂點。首先將#param行改為
#pragma surface surf CustomDiffuse vertex:vert
這告訴Shader我們想要改變模型頂點,并且我們會寫一個叫做vert
的函數來改變頂點。接下來我們再添加一個參數,在Properties中聲明一個_SnowDepth
變量,表示積雪的厚度,當然我們也需要在CG段中進行聲明:
//In Properties{…}
_SnowDepth ("Snow Depth", Range(0,0.3)) = 0.1
//In CG declare
float _SnowDepth;
接下來實現vert方法,和之前積雪的運算其實比較類似,判斷點積大小來決定是否需要擴大模型以及確定模型擴大的方向。在CG段中加入以下vert方法
void vert (inout appdata_full v) {
float4 sn = mul(transpose(_Object2World) , _SnowDirection);
if(dot(v.normal, sn.xyz) >= lerp(1,-1, (_Snow * 2) / 3)) {
v.vertex.xyz += (sn.xyz + v.normal) * _SnowDepth * _Snow;
}
}
和surf的原理差不多,系統會輸入一個當前的頂點的值,我們根據需要計算并填上新的值作為返回即可。上面第一行中使用transpose
方法輸出原矩陣的轉置矩陣,在這里_Object2World是Unity ShaderLab的內建值,它表示將當前模型轉換到世界坐標中的矩陣,將其與積雪方向做矩陣乘積得到積雪方向在物體的世界空間中的投影(把積雪方向轉換到世界坐標中)。之后我們計算了這個世界坐標中實際的積雪方向和當前點的法線值的點積,并將結果與使用積雪等級的2/3進行比較lerp后的閾值比較。這樣,當前點如果和積雪方向一致,并且積雪較為完整的話,將改變該點的模型頂點高度。
加入模型更改前后的效果對比如下圖,加入模型調整的右圖表現要更為豐滿真實。
文章列表