寫在前面:
先說一下為什么決定寫這篇文章,我也是這兩年開始學習3D物體的光照還有着色方式的,對這個特別感興趣,在Wiki還有NVIDIA官網看了相關資料后,基本掌握了渲染物體時的渲染管道(The rendering pipe-line)流程,以及各種空間坐標系(MVP),但是在用Unity的Shaderlab寫shader的時候,對於具體怎么實現各種着色有很大的疑問,決定苦心鑽研一下,過了幾個月吧,現在對寫shader還是比較熟練的,也解決了之前的疑惑,寫這篇算是一篇筆記,以后可能用到,或者初學者想參考一下都是很好的,那么言歸正傳。
看這篇教程所需要的基礎:
- 有一定的shader編寫基礎,能看懂vertex&fragment代碼。
- 了解3D的物體的頂點存儲有什么信息。
- 有一定的Unity基礎,能看懂基本的C#代碼。
- 初學者,想學習這方面技術的(雖然本篇本來就很基礎)
那么本篇文章正式開始
三種着色方式的區別:
物體在屏幕中每個像素點的顏色,取決於兩部分,一個是物體本身頂點的信息,比如位置,法線方向,顏色等等。另一個就是着色方式,着色函數可以根據頂點然后填充中間的部分,比如說你有三個頂點,你就可以用着色函數獲取一個面,至於這個面怎么顯示,就有各種各樣的方法,本篇講述三種比較常見的方法。
Flat Shading(平面着色):平面着色簡單來講,就是一個三角面用一個顏色。如果一個三角面的代表頂點(也許是按在index中的第一個頂點),恰好被光照成了白色,那么整個面都會是白的。

Gouraud Shaing(高洛德着色):與平面着色不同,三個頂點的信息都會被考慮到,然后中間的顏色用一種二維的插值。這個插值原理其實很簡單,考慮一維的插值,比如說有一個體溫穩定持續變化的人,今天的體溫是37°,后天的體溫是39°,顯而易見明天的體溫就是38°,這個就是插值了。仔細觀察那些三角面,注意同一個時間只有一個點被照射成白色,其他兩個點都是紅色,所以在面上是從白到紅逐漸變化。這里還希望大家考慮一個例子,現在想象一個巨大的三角面,但是這個三角面只有中間的部分被光照,而三角面的點沒有被光照,那么整個平面都將沒有光照效果。

Phong Shading(馮氏着色):這里要特別注意Phong Shading和Phong Lighting Model的區別,后者是說物體被光照產生的效果,而前者是考慮如何在三個頂點中填充顏色。注意馮氏着色可以說是最接近真實的了,當然開銷也是最大的,因為高洛德着色是每個頂點(vertex)計算一次光照,馮氏着色是每個片元(fragment)或者說每個點計算一次光照,點的法向量是通過頂點的法向量插值得到的。所以說不會出現高洛德着色那樣巨大三角形的問題了。注意下面這個球和flat shaing的例子的球是基本一樣的(繼續看會有解釋),只是着色方式變了。可見效果是非常非常好的。Phong Shading能在減三角面數的情況下(一定范圍內),達到看起來一樣的效果,因為插值后的法向量會光滑變化。

那么重點來了,如何在Unity中實現不同的着色效果呢?
很多時候,我們希望不同的游戲,有不同的藝術風格,我們要選擇不同的着色方式來達到我們要的效果,那么現在我就來講解。但是有一點,即使Flat Shading是最簡單的着色方式,但是處於一定的原因,我要放到最后說。
現在我要小說一下頂點中存儲的法線信息,考慮一個正方體,我們用一般的存儲方式考慮,那么它有8個點,6個平面,12個三角面(正方形分成兩個),36個頂點索引組成(對應每個三角面三個點),現在取其中的一個點,那個這個點的法線,就應該是臨近的三個平面的的法線坐標的和,現在我們來用Unity動手做出來這么一個正方體。
- 首先創建一個新的Scene。
- 保證這個Scene中至少有一個light。
- 創建一個新的空物體。(Create->Empty)。
- 給這個空物體賦予一個MeshFilter Component和一個MeshRenderer Component。
- 創建一個新的C#腳本命名為CreateGruadCube。
- 賦予如下代碼:
- 保存之后將這個腳本賦予這個新創建的空物體。
這個是我寫代碼時用的輔助圖片,不要在意它的丑陋,圖中0代表Vertices數組中的第一個點。最重要的就是寫triangle索引的時候,一定要用左手法則,不然圖片會里外翻轉,左手法則就是左手握成貓咪爪子的形狀,然后大拇指伸直,使其它四個手指的方向依次經過那些點,使大拇指朝外,比如說第一個三角形,是先經過1然后2然后0或者先2然后0然后1,大拇指都是朝外的,這樣就可以了。



1 using System.Collections; 2 using System.Collections.Generic; 3 using UnityEngine; 4 5 //在編輯模式運行代碼用的Attribute 6 [ExecuteInEditMode] 7 public class CreateGruadCube : MonoBehaviour { 8 9 void Awake () { 10 //創建一個空網格 11 Mesh mesh = new Mesh(); 12 13 //MeshFilter傳遞mesh給MeshRenderer最終渲染物體 14 MeshFilter mf = GetComponent<MeshFilter>(); 15 16 //8個頂點 17 Vector3[] Vertices = new Vector3[8] 18 { 19 new Vector3(0, 0, 0), new Vector3(5, 0, 0), new Vector3(0, 0, 5), new Vector3(5, 0, 5), 20 new Vector3(0, 5, 0), new Vector3(5, 5, 0), new Vector3(0, 5, 5), new Vector3(5, 5, 5) 21 }; 22 23 //每個頂點有自己的法線坐標,並且是相鄰三個平面的法線坐標的和。normalize之后向量長度變為1。 24 Vector3[] Normals = new Vector3[8] 25 { 26 new Vector3(-1, -1, -1).normalized, new Vector3(1, -1, -1).normalized, new Vector3(-1, -1, 1).normalized, new Vector3(1, -1, 1).normalized, 27 new Vector3(-1, 1, -1).normalized, new Vector3(1, 1, -1).normalized, new Vector3(-1, 1, 1).normalized, new Vector3(1, 1, 1).normalized 28 }; 29 30 //每個頂點有自己的UV坐標,不熟悉可以不用管,本篇教程不關注UV坐標 31 Vector2[] UVs = new Vector2[8] 32 { 33 new Vector2(0, 0), new Vector2(1, 0), new Vector2(0, 1), new Vector2(1, 1), 34 new Vector2(0, 1), new Vector2(1, 1), new Vector2(1, 0), new Vector2(0, 0) 35 }; 36 37 //對頂點的索引,每三個數組成一個三角面,比如0, 1, 2對應Vertices中的第一個、第二個、第三個點,注意這里要用左手法則寫順序。 38 int[] Triangles = new int[36] 39 { 40 0, 1, 2, 1, 3, 2, 4, 5, 0, 5, 1, 0, 4, 0, 6, 6, 0, 2, 2, 7, 6, 7, 2, 3, 3, 5, 7, 1, 5, 3, 5, 4, 6, 5, 6, 7 41 }; 42 43 //給mesh賦值 44 mesh.vertices = Vertices; 45 mesh.normals = Normals; 46 mesh.triangles = Triangles; 47 mesh.uv = UVs; 48 49 //自己起一個名字 50 mesh.name = "Cube"; 51 52 //把mesh賦給MeshFilter 53 mf.mesh = mesh; 54 } 55 }
現在你的物體應該是這個樣子的:


不用擔心,我們繼續,然后在Asset下創建一個新的Material,隨便起個名字。然后直接拖到這個物體上。(這里光用的是系統默認的Direnctional light)
哈哈,慘不忍睹(下面會解釋原因),我們先把Directional刪掉,換成一個Point light。

- 刪掉原有的光,把物體的position改到(0, 0, 0)。
- 創建一個Point light,把position改到(-0.5, 5.5, -0.5)。
- 好多了。
現在我們已經可以看出這個物體用的是Phong Shading,你可以隨便移動光源體驗一下,可以看到光有沒有照到頂點無關緊要,這是因為每個點(片元、fragment)都單獨計算了一次。

下面我們將棄用Unity自帶的shader,寫屬於我們自己的Gouraud Shading還有Phong Shading,Flat Shading最后討論。
Gouraud Shading
- 首先在Assets下右鍵->Create->Shader->Unlit Shader(或者其它的,不重要,反正我們都要重寫)。
- 打開shader開始編輯,賦予如下代碼。
- 點開之前創建的Material。
- 在Inspector中Shader的那個選線欄里選擇Custom->GouraudShader。
//歸於Custom目錄下,起名字叫GouraudShader Shader "Custom/GouraudShader" { Properties { _MainColor ("Color", Color) = (1, 1, 1, 1) //我自己定義了一個光源的位置 _LightPos ("LightPosition", Vector) = (-0.5, 5.5, -0.5, 1) } SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct v2f { float4 vertex : SV_POSITION; float4 color : TEXCOORD0; }; float4 _MainColor; float4 _LightPos; //Gouraud Shading的重點就是光照是在vert函數中計算 v2f vert (float4 vertex : POSITION, float3 normal : NORMAL) { v2f o; o.vertex = mul(UNITY_MATRIX_MVP, vertex); //將我們之前手寫的點的坐標信息還有法線信息轉換到世界坐標系計算。 float3 worldPos = mul(UNITY_MATRIX_M, vertex); float3 worldNor = mul(UNITY_MATRIX_M, normal); //光的方向 float3 lightDir = normalize(_LightPos - worldPos); //距離,計算光的衰減用的 float3 dist = distance(_LightPos, worldPos); //Diffuse Light的計算。 float lightPor = max(0, dot(worldNor, lightDir)); //衰減系數,用2除是個神秘的原因 float atten = 2 / pow(dist, 2); //最終展現在屏幕中的顏色,插值后傳遞到fragment函數里顯示顏色。 o.color = _MainColor * lightPor * atten; return o; } fixed4 frag (v2f i) : SV_Target { //插值后的顏色 return i.color; } ENDCG } } }
現在你應該看到了下圖的效果,怎么樣,感受到了Gouraud Shading了沒有,靠近光源的那個點是白色,剩下角落里的拿下點是灰色,中間是插值過去的。
注意這里我自己定義一個光源的位置,原因是我用Unity在Shader中自帶的光線方向時總出現光線方向翻轉的問題,不過我們還是可以達到移動光源改變光照的效果。看下面。

- 創建一個新的腳本叫做GruadHelper。
- 確保新創建的點光源的名字是"Point light"。
- 賦予如下代碼。
- 將腳本賦予物體。
using System.Collections; using System.Collections.Generic; using UnityEngine; [ExecuteInEditMode] public class GruadHelper : MonoBehaviour { public GameObject Light; private void Start() { //獲取光源 Light = GameObject.Find("Point light"); } // Update is called once per frame void Update () { //獲取材質 MeshRenderer mr = GetComponent<MeshRenderer>(); //將光源的位置信息傳遞給材質,也就是我們的Gouraud Shader mr.sharedMaterial.SetVector("_LightPos", Light.transform.position); } }
現在你的物體應該是這幅模樣,這樣你就可以隨便移動光源,來體驗我們自己寫的Gouraud Shader的效果了。注意在移動的過程中,你能明顯體驗到巨大三角形例子的缺陷,而且某些點被光照時的效果很奇怪,那是因為三角面並不總是直角頂點位於同一個頂點,可以看出效果不是很好,不過達到了體驗高洛德着色的效果。我很想把上面的代碼一行一行解釋,但是忙里偷閑寫這篇教程就已經很占時間了,必要的注釋都已經給出,如果你不知道shader怎么寫,我推薦你先看這篇教程:
貓都能學會的Unity3D Shader入門指南(一)。如果你有一定的基礎,但是對我的代碼不理解,你可以先看看Unity官網的v&f shader例子,對於學習shader都是很有幫助的!

Phong Shading
下面我給出Phong Shading的代碼。
- 根據上面的步驟創建一個新的Shader。
- 打開Shader進行編輯。
- 賦予如下代碼。
- 將Material的Shader改成Custom->PhongShader。
//歸於Custom目錄下,起名字叫GouraudShader Shader "Custom/PhongShader" { Properties { _MainColor("Color", Color) = (1, 1, 1, 1) //我自己定義了一個光源的位置 _LightPos("LightPosition", Vector) = (-0.5, 5.5, -0.5, 1) } SubShader { Tags{ "RenderType" = "Opaque" } LOD 100 Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct v2f { float4 vertex : SV_POSITION; //TEXCOORD就是插值用的 float3 worldPos : TEXCOODR0; float3 worldNor : TEXCOODR1; }; float4 _MainColor; float4 _LightPos; v2f vert(float4 vertex : POSITION, float3 normal : NORMAL) { v2f o; o.vertex = mul(UNITY_MATRIX_MVP, vertex); //將我們之前手寫的點的坐標信息還有法線信息轉換到世界坐標系計算。 //將這些信息插值傳給frag函數 o.worldPos = mul(UNITY_MATRIX_M, vertex); o.worldNor = mul(UNITY_MATRIX_M, normal); return o; } //Phong Shading的重點就是在frag函數中計算 fixed4 frag(v2f i) : SV_Target { //光的方向 float3 lightDir = normalize(_LightPos - i.worldPos); //距離,計算光的衰減用的 float3 dist = distance(_LightPos, i.worldPos); //Diffuse Light的計算。 float lightPor = max(0, dot(i.worldNor, lightDir)); //衰減系數,用2除是個神秘的原因 float atten = 2 / pow(dist, 2); //返回最終的計算結果 return _MainColor * lightPor * atten; } ENDCG } } }
現在你的物體應該是這個樣子了,可以明顯看出Phong Shading的效果非常棒,尤其是沒有巨大三角形問題,你可以隨意移動光源體驗一下。這樣無論你如何改變三角形的排列方式,光照都是一樣的。怎么樣,對這兩個着色方式有更深的理解了吧!下面我們終於要講Flat Shading了!

分享頂點與不分享頂點(Flat Shading):
首先,我們回顧之前我們創建這個三角形的時候,我們一共創建了8個頂點,幾個三角面(3~6個)可以共用一個頂點,那么問題來了,我能否不共享這些頂點呢,不分享這些頂點的話,我能帶來什么效果呢,下面大家跟我一起做。
- 將Point light的position改到(-0.5, 5.5, -0.5)。
- 用Unity創建一個Cube,Create->3D->Cube。
- 將Cube的position改到(-3.5, 2.5, 2.5)。
- 將Cube的Scale改到(5, 5, 5)。
- 將之前創建的Material賦予這個Cube。
現在你的界面應該是這樣子的,我給大家簡單的說明一下,Unity自帶的Cube的mesh是24個頂點,12個三角面,36個三角面索引組成的,每個頂點的法線方向,都是頂點所在三角面的法線。現在我們任意移動一下光源,切換之前寫的兩個Shader,隨便玩一玩。然后仔細看下面的第二張圖。

也許細心的你會發現,如果一個正方體完全不分享頂點,應該是12 * 3 = 36個頂點,但是Unity的Cube只有24個頂點,原因是在同一個正方形面上,對角線的頂點是共用的,這樣達到的效果一樣但是可以節省8個頂點的存儲空間。所以當多個三角面位於一個四邊形(不凹也不凸)上時,理論上是可以進行“壓縮處理”的。
下面這張圖是我測試時候自己照的,用的是上面的PhongShader,左邊是Unity自帶的不分享頂點Cube,右側是我們自己創建的分享頂點的Cube,大家應該不難發現,分享頂點的Cube在光照下顯的更光滑!這是因為在計算三角面中間的那些點就是運行frag函數的時候,分享頂點的Cube的法線插值是光滑變化的!而不分享頂點的法線就像我之前說的,即使插了值,也都是朝向同一個方向,就拿之前發燒人的例子來講,今天是37°,后天也是37°,插了值的明天也是37°,這樣在Cube邊緣的那些點計算光源的時候,法線變化非常大,所以顏色落差也很大,就造成了很尖銳(Sharp)的感覺,這與Unity中的Flat Shading密不可分。

那么問題來了,這個不分享頂點的Cube也不是Flat Shading啊,那我們究竟怎么達到Flat Shading的效果呢???下面大家跟我一起做。
- 將我們的Point light屏蔽掉。
- 新建一個Material,shader就用默認的就好。
- 創建一個Directional light。
現在你的場景應該是這個樣子,左圖是正面,右圖是背面。喵喵???為什么不分享頂點的Cube變成了Flat Shading,而分享頂點的Cube變成了Cat Shit???這是因為之前我們使用的是Point light,而Point light是要計算光源到點的距離,根據距離進行適當的衰減。所以用點光源(Point light)最少能達到Gouraud Shading的效果,沒法達到Flat Shading,而Directional light不管在哪里強度都是一樣,方向都是一樣的,所以不分享頂點的Cube就變成了Flat Shading,而分享頂點的Cube變成了一個面數非常低的球,我在說Phong Shading的時候提到相關闡述。如果你把之前寫的Shader里的所有關於距離計算的東西都刪掉,然后使光線方向不變,你也可以達到相同效果。


那么既然我們的模型不一定都是不分享頂點的,那么如果我們要達到Flat Shading,我們應該怎么辦呢?
現在你可以回去上面看看Flat Shading的時候我給大家的例子,一個Flat Shading的球,Unity自帶的球(Sphere)是分享頂點的,例子中的球是我對Unity的球進行改造后的,有代碼基礎的同學可以試試自己寫這個代碼,步驟類似我創建Cube mesh的時候的方法。那么不會寫代碼或者不知道該怎么寫的,跟我來做。
(注意,下面的腳本不是我自己寫的,這個方法我摘自UnityAnswer的這篇文章)
- 首先在Assets目錄下創建一個新的文件夾Editor。
- 在Editor下創建一個新的C#腳本命名為NoSharedVertices。
- 賦予如下腳本:
- 運用你思考的能力,仔細學習63-68和71-72行。
1 using UnityEngine; 2 using UnityEditor; 3 4 public class NoSharedVertices : EditorWindow 5 { 6 7 private string error = ""; 8 9 [MenuItem("Window/No Shared Vertices")] 10 public static void ShowWindow() 11 { 12 EditorWindow.GetWindow(typeof(NoSharedVertices)); 13 } 14 15 void OnGUI() 16 { 17 //Transform curr = Selection.activeTransform; 18 GUILayout.Label("Creates a clone of the game object where the triangles\n" + 19 "do not share vertices"); 20 GUILayout.Space(20); 21 22 if (GUILayout.Button("Process")) 23 { 24 error = ""; 25 NoShared(); 26 } 27 28 GUILayout.Space(20); 29 GUILayout.Label(error); 30 } 31 32 void NoShared() 33 { 34 Transform curr = Selection.activeTransform; 35 36 if (curr == null) 37 { 38 error = "No appropriate object selected."; 39 Debug.Log(error); 40 return; 41 } 42 43 MeshFilter mf; 44 mf = curr.GetComponent<MeshFilter>(); 45 if (mf == null || mf.sharedMesh == null) 46 { 47 error = "No mesh on the selected object"; 48 Debug.Log(error); 49 return; 50 } 51 52 // Create the duplicate game object 53 GameObject go = Instantiate(curr.gameObject) as GameObject; 54 mf = go.GetComponent<MeshFilter>(); 55 Mesh mesh = Instantiate(mf.sharedMesh) as Mesh; 56 mf.sharedMesh = mesh; 57 Selection.activeObject = go.transform; 58 59 //Process the triangles 60 Vector3[] oldVerts = mesh.vertices; 61 int[] triangles = mesh.triangles; 62 Vector3[] vertices = new Vector3[triangles.Length]; 63 for (int i = 0; i < triangles.Length; i++) 64 { 65 //這個循環是有用的部分,其他的基本都是可選的 66 vertices[i] = oldVerts[triangles[i]]; 67 triangles[i] = i; 68 } 69 mesh.vertices = vertices; 70 mesh.triangles = triangles; 71 mesh.RecalculateBounds(); 72 mesh.RecalculateNormals(); 73 74 // Save a copy to disk 75 string name = "Assets/Editor/" + go.name + Random.Range(0, int.MaxValue).ToString() + ".asset"; 76 AssetDatabase.CreateAsset(mf.sharedMesh, name); 77 AssetDatabase.SaveAssets(); 78 } 79 }
如果你看懂了這幾行代碼,你會發現其實它就是把原來重疊的點,復制了一定次數,但是這個腳本並沒有進行我之前所說的“壓縮處理”。
學習之后(不要怕麻煩),繼續跟我做:
- 在場景中創建一個球Create->3D->Shpere。
- 選中這個球,在Unity的工具欄中選擇Window->No Share Vertices在新打開的窗口中按Process。
- 現在在原來球的位置多出了一個球,保證這兩個球不重疊。
現在你能看出來分享頂點和不分享頂點的區別了吧!是不是特別有意思!當然我們還有另外一種方法,下面介紹。

下面這種方法適用於我們從外界導入模型的時候。我以一個蛇頭為模型,大家跟我做:
- 在Assets中選中你的模型。
- 在Inspector(控制面板)中選中Model。
- 找到Normals那行,改成Calculate。
- 將下面那行SmoothingAngle改成0。
- 你的模型將變成不分享頂點,並且Unity為你模型的點重新計算了法線,法線方向為所在三角面的法線方向。


這樣以來你這個模型在Directional light的照射下,並且使用Gouraud或者Phong Shading(當然是前者)都會達到一樣的Flat Shading的效果。
最后說點什么:
我個人在學習Shader的過程中也遇到了很多困難,而且有時候網上並沒有什么資料,也沒有什么人可以問,只能一點一點的學習,希望大家看到這篇教程會學習到自己需要的知識!另外,你可以隨便轉載本篇文章,但是一定要注明作者是z12603(學習者即為學者),並給出這篇教程的網址鏈接。希望大家學習愉快!
