写在前面:
先说一下为什么决定写这篇文章,我也是这两年开始学习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(学习者即为学者),并给出这篇教程的网址链接。希望大家学习愉快!