Unity Shader-法线贴图(Normal)及其原理

简介

以前经常听说“模型不好看啊,怎么办啊?”答曰“加法线”,”做了个高模,准备烘一下法线贴图”,“有的美术特别屌,直接画法线贴图”.....法线贴图到底是个什么鬼,当年天真的我真的被这个图形学的奇淫杂技忽悠了,然而毕竟本人还算有点刨根问底的精神,决定研究一下法线贴图的原理以及Unity下的实现。本人才疏学浅,如有错误,欢迎指正。

法线贴图是目前游戏开发中最常见的贴图之一。我们知道,一般情况下,模型面数越高,可以表现的细节越多,效果也越好。但是,由于面数多了,顶点数多了,计算量也就上去了,效果永远是和性能成反比的。怎么样用尽可能简单模型来做出更好的效果就成了大家研究的方向之一。纹理映射是最早的一种,通过纹理直接贴在模型表面,提供了一些细节,但是普通的纹理贴图只是影响最终像素阶段输出的颜色值,不能让模型有一些凹凸之类的细节表现。而法线贴图就是为了解决上面的问题,给我们提供了通过低面数模型来模拟高面数模型的效果,增加细节层次感,效果与高模相差不多,但是大大降低了模型的面数。

法线贴图原理

 
要模拟一个圆球,要想越平滑,就需要更多的面数,否则会很容易地发现面和面之间的明显边界。最早时的GPU是没有fragement编程能力的,也就是说在这种情况下,在计算时需要逐顶点计算光照,然后每个像素的颜色在各个顶点的颜色之间插值,也就是高洛德着色,这种情况下,面数决定一切效果,没有什么好办法。而当像素着色器出现之后,我们可以逐像素来计算光照效果,这时候,在计算每个像素的光照时,会计算这个像素所在的面的法向量,而这个面的法向量也是由这个面周围的顶点法线(也就是我们之前vertex shader中出现的normal)插值得来的,当然,如果面数很低,那么效果也好不到哪里去。但是,逐像素计算光照时,我们每一个像素都会根据该点的法向量来计算最终该点的光照结果,那么,我们如果能够改变这个法线的方向,不是就可以改变这个点的光照结果了呢!那么,把纹理采样的思想用在这里,我们直接用一张图来存储法线(或者法线偏移值,见下文),逐像素计算时,在采样diffuse贴图的时候,再采样一张法线的贴图,就可以修改法线了,进而修改最终的效果。
 
为什么法线贴图会让我们感觉有凹凸感呢?看下面一张图,在现实世界中,你要相信你的眼睛,眼见为实还有点道理,在计算机世界中,一切以忽悠你为目的。在平面的情况下,我们感觉物体是凹陷还是凸起,很大一部分取决于这个面的亮度,像下面这张图,有了这种亮度的对比,我们就很容易感觉这个按钮有周围的一圈凸起。

Unity Shader-法线贴图(Normal)及其原理

如果还是没理解,再看一套图片,同样一张图片,旋转180度后的结果完全相反。不信可以去截图放到MSPaint里面转一下试试,反正我是试了....

Unity Shader-法线贴图(Normal)及其原理

既然一个面的光照条件(亮度)的改变,就可以让我们感觉这个面有凹凸感,那么上面说的,通过改变法线来改变面上某点的光照条件,进而忽悠观察者,让他们感觉这个面有凹凸感的方法就行得通了。

Unity Shader-法线贴图(Normal)及其原理

假如下面是我们的低面数模型,上面是我们的高面数模型,上面的模型在计算光照时,由于面数多,每个面的法线方向不同,所以各个面的光照计算结果都不同,就有凹凸的感觉了,而下面的低模,只有一个面,整个面的光照条件都是一致的,就没有凹凸的感觉了。我们如果把上面的高模的法线信息保存下来,类似纹理贴图那样,存在一张图里,再给低模使用,低模就可以有跟高模一样的法线,进而在计算光照时达到和高模类似的效果,这也就是常说的烘法线的原理。

 

凹凸贴图(Bump Map)

 
既然说了要研究法线贴图,所以肯定要从老一辈的开始,首先来看一下凹凸贴图(Bump Map)。Bump Map是最早的法线贴图实现方式,这也是制作上最容易的一种模式,可以直接通过一张灰度图,默认为黑色,越凸起的地方颜色越亮,这种就是可以直接在PhotoShop中画的法线,但是这种法线贴图的原理理解起来比较难,我只说一下我的理解,然后附上unity中的shader实现。这种技术现在貌似已经过时了,但是思想还是流传下来了,而且这种画灰度图,或者通过灰度图生成法线贴图的方式现在仍然在使用,Unity就支持这种直接通过灰度图生成法线贴图。
 
首先,通过灰度图来表现凹凸,那么,我们怎样判断一个点处在凹凸的边缘呢?答案是通过斜率,比如我要对(x,y)进行采样,怎样求这一点的斜率呢,学过数学的都知道,我们可以通过两点确定一条直线,进而求出这条直线的斜率。那么我们就可以对(x-1,y)和(x+1,y)两点进行采样,竖向也是一样,通过(x,y-1)和(x,y+1)进行采样,那么,我们就可以获得这一点上灰度值的变化,如果灰度值不变,说明该点不在边缘,如果灰度值有改变,那么说明该点在边缘,那么我们就可以根据这个斜率值来修改法线,进而修改光照结果。
 
我又掏出了我十分不熟练的PhotoShop,画了一张传说中的Bump Map,恩,感觉还不错,目前RGB通道都有信息,反正只是实验,和shader对应就好了:
Unity Shader-法线贴图(Normal)及其原理
Bump Map类型的shader如下(仅仅是实验基于灰度的Bump,完全不实用....)
  1. //Bump Map
  2. //by:puppet_master
  3. //2016.12.13
  4. Shader "ApcShader/BumpMap"
  5. {
  6. //属性
  7. Properties{
  8. _Diffuse("Diffuse", Color) = (1,1,1,1)
  9. _MainTex("Base 2D", 2D) = "white"{}
  10. _BumpMap("Bump Map", 2D) = "black"{}
  11. _BumpScale ("Bump Scale", Range(0.1, 30.0)) = 10.0
  12. }
  13. //子着色器
  14. SubShader
  15. {
  16. Pass
  17. {
  18. //定义Tags
  19. Tags{ "RenderType" = "Opaque" }
  20. CGPROGRAM
  21. //引入头文件
  22. #include "Lighting.cginc"
  23. //定义Properties中的变量
  24. fixed4 _Diffuse;
  25. sampler2D _MainTex;
  26. //使用了TRANSFROM_TEX宏就需要定义XXX_ST
  27. float4 _MainTex_ST;
  28. sampler2D _BumpMap;
  29. float4 _BumpMap_TexelSize;
  30. float _BumpScale;
  31. //定义结构体:应用阶段到vertex shader阶段的数据
  32. struct a2v
  33. {
  34. float4 vertex : POSITION;
  35. float3 normal : NORMAL;
  36. float4 texcoord : TEXCOORD0;
  37. };
  38. //定义结构体:vertex shader阶段输出的内容
  39. struct v2f
  40. {
  41. float4 pos : SV_POSITION;
  42. float3 worldNormal : TEXCOORD0;
  43. //转化纹理坐标
  44. float2 uv : TEXCOORD1;
  45. };
  46. //定义顶点shader
  47. v2f vert(a2v v)
  48. {
  49. v2f o;
  50. o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
  51. //把法线转化到世界空间
  52. o.worldNormal = mul(v.normal, (float3x3)_World2Object);
  53. //通过TRANSFORM_TEX宏转化纹理坐标,主要处理了Offset和Tiling的改变,默认时等同于o.uv = v.texcoord.xy;
  54. o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
  55. return o;
  56. }
  57. //定义片元shader
  58. fixed4 frag(v2f i) : SV_Target
  59. {
  60. //unity自身的diffuse也是带了环境光,这里我们也增加一下环境光
  61. fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;
  62. //归一化法线,即使在vert归一化也不行,从vert到frag阶段有差值处理,传入的法线方向并不是vertex shader直接传出的
  63. fixed3 worldNormal1 = normalize(i.worldNormal);
  64. //采样bump贴图,需要知道该点的斜率,xy方向分别求,所以对于一个点需要采样四次
  65. fixed bumpValueU = tex2D(_BumpMap, i.uv + fixed2(-1.0 * _BumpMap_TexelSize.x, 0)).r - tex2D(_BumpMap, i.uv + fixed2(1.0 * _BumpMap_TexelSize.x, 0)).r;
  66. fixed bumpValueV = tex2D(_BumpMap, i.uv + fixed2(0, -1.0 * _BumpMap_TexelSize.y)).r - tex2D(_BumpMap, i.uv + fixed2(0, 1.0 * _BumpMap_TexelSize.y)).r;
  67. //用上面的斜率来修改法线的偏移值
  68. fixed3 worldNormal = fixed3(worldNormal1.x * bumpValueU * _BumpScale, worldNormal1.y * bumpValueV * _BumpScale, worldNormal1.z);
  69. //把光照方向归一化
  70. fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
  71. //根据半兰伯特模型计算像素的光照信息
  72. fixed3 lambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;
  73. //最终输出颜色为lambert光强*材质diffuse颜色*光颜色
  74. fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz + ambient;
  75. //进行纹理采样
  76. fixed4 color = tex2D(_MainTex, i.uv);
  77. return fixed4(diffuse * color.rgb, 1.0);
  78. }
  79. //使用vert函数和frag函数
  80. #pragma vertex vert
  81. #pragma fragment frag
  82. ENDCG
  83. }
  84. }
  85. //前面的Shader失效的话,使用默认的Diffuse
  86. FallBack "Diffuse"
  87. }

效果如下:

Unity Shader-法线贴图(Normal)及其原理
这个过程还是很有意思的,Unity为我们封装了太多东西,尤其是surface shader,只需要一句unpacknormal,然后把输出赋给o.normal就ok了,我们基本不需要做什么,但是底层的实现对于学习来说还是很必要的。
 

法线贴图(Normal Map)

 
随着GPU的发展,Geforce3的出现,带来了真正的Normal Mapping技术,也叫作Dot3 bump mapping。这种Normal Map就是我们现在在使用的法线贴图技术。与之前通过灰度表现界面的凹凸程度,进而修改法线的方式完全不同,这种Normal Map直接将法线存储到了法线贴图中,也就是说,我们从法线贴图读取的法线直接就可以使用了,而不是需要像上面那样,再通过灰度渐变值来修改法线。这种法线对于制作来说,没有灰度图那样直白,但是却是真正的法线贴图技术,所谓烘焙法线,烘焙的就是这个。
 
虽然灰度图不会直接被用于实时计算法线了,但是在离线工具中却提供了直接通过灰度图生成法线的功能。Unity中就有这种功能:
Unity Shader-法线贴图(Normal)及其原理
我们把之前画的那张灰度图直接通过这种方式改成法线贴图,从法线贴图中我们就直接可以看到凹凸的效果了。在Unity里实现法线贴图的shader之前,首先看几个问题,也是困扰了我一段时间的几个问题。
 

法线贴图是怎样存储的

 
既然法线贴图中存储的是法线的方向,也就是说是一个Vector3类型的变量,刚好和图片的RGB格式不谋而合。但是向量毕竟要灵活得多,我们正常的RGBA贴图,一个通道是8位,可以表示的大小在(0,255),那么反过来除一下,贴图中可以存储的向量的精度就是0.0039,也就是说并不是真正意义上的浮点类型,精度要小得多,不过对于一般情况下,这种精度也足够了。再一个问题,就是向量是有方向滴,而贴图中只能存储的都是正数,所以,还需要一个映射的过程。映射在图形学中真是很多见呢,比如计算半兰伯特光照时,就通过把(0,1)的光照区间转化到了(0.5,1)提高了光的亮度,使效果更好。在法线贴图中,可以用0代表向量中的-1,用255代表向量中的1,不过,在shader中,贴图的颜色一般也是(0,1)区间,所以,我们在计算时只需要把从法线贴图中采样得到的法线值进行映射,将其从(0,1)区间转化到(-1,1)区间。
 
这个步骤,Unity已经为我们完成了,我们在计算法线的时候,只需要调用UnpackNormal这个函数就可以实现区间的重新映射。从UnityCG.cginc中可以看到UnpackNormal这个函数的实现:
  1. inline fixed3 UnpackNormalDXT5nm (fixed4 packednormal)
  2. {
  3. fixed3 normal;
  4. normal.xy = packednormal.wy * 2 - 1;
  5. normal.z = sqrt(1 - saturate(dot(normal.xy, normal.xy)));
  6. return normal;
  7. }
  8. inline fixed3 UnpackNormal(fixed4 packednormal)
  9. {
  10. #if defined(UNITY_NO_DXT5nm)
  11. return packednormal.xyz * 2 - 1;
  12. #else
  13. return UnpackNormalDXT5nm(packednormal);
  14. #endif
  15. }

做法很简单,乘2 减1大法好,转化区间没烦恼(什么鬼....)

这里,我们看到了两个UnpackNormal的函数,下面的就是我们所说的直接转化区间。而上面的那个函数,看定义来说,是为了专门解出DXT5nm格式的normal map,这种类型的normal map,只用存储法向量中的两个通道,然后解开的时候,需要计算一下,重新算出另一个向量方向。这样可以实现的原理在于,存储的向量是单位向量,长度一定的情况下,就可以通过sqrt(1 - x^2 - y^2)来求得,如下图:
Unity Shader-法线贴图(Normal)及其原理
不过这是一种时间换空间的做法,以牺牲时间的代价,换来更好的压缩比以及压缩后的效果。关于DXT5nm,附上一篇参考文章:Normal Map的dds压缩
 

为什么法线贴图存储在切线空间

 
既然知道了法线可以存储在贴图中,我们就再来看一下,为什么法线贴图中一般都存储的是切线空间,为什么不存储在世界空间或者模型空间。首先看一下世界空间,如果我们的法线贴图存储的世界空间的法线信息,我们可以直接解出法线的值,在世界空间进行计算,是最直接并且计算效率最高的做法,但是世界空间的法线贴图就跟当前环境之间耦合过大了,比如同样的两个模型,仅仅是旋转方向不同,也需要两张法线贴图,这很明显是多余的,于是就有人想出了基于模型空间的法线,基于模型空间,在计算时,把模型空间的法线转换到世界空间,虽然多了一步操作,但是同一个模型可以共用法线,不用考虑旋转等问题。但是,人们感觉模型空间的法线贴图跟模型的耦合度还是高,那就继续解耦吧,于是基于切线空间的法线贴图就诞生了。下图为模型空间与切线空间法线。
 
Unity Shader-法线贴图(Normal)及其原理
 
所谓的切线空间,跟那些比较常见的坐标系,比如世界坐标,模型坐标一样,也是一个坐标系,用三个基向量就可以表示。我们用模型上的一个点来看,这个点的有一个法线的方向,也就是这个点所在的面的法线的方向N,这个方向是确定的,我们可以用它作为Z轴。而剩下的两个轴,刚好就在这个面上,互相垂直,但是这两个轴的可选种类就多了,因为在这个面上任意两个向量都可以表示这个面。目前最常用的方式是以该点的uv二维坐标系表达该点的切线(tangent)和该点的次法线(binormal)所构成的切平面。它的法线既处处都垂直于它的表面。我们用展uv的方式,将纹理展开摊平,那么所有的法线就都垂直于这个纹理平面,法线就是z轴,而uv set,准确地说是该点uv朝着下一个顶点uv的方向向量分别作为tangent和binormal轴,也就是x,y轴。但是这样做有一个弊端,就是x轴和y轴之间不互相垂直,计算Tangent空间的公式如下:
 
T = normalize(dx/du, dy/du, dz/du)
N = T × normalize(dx/dv, dy/dv, dz/dv)
B = N × T
 
很遗憾我们在在Unity里面看不到全部源代码,不过从shader的定义中可以看到B的求解以及TBN矩阵的构建过程:
  1. // Declares 3x3 matrix 'rotation', filled with tangent space basis
  2. #define TANGENT_SPACE_ROTATION \
  3. float3 binormal = cross( normalize(v.normal), normalize(v.tangent.xyz) ) * v.tangent.w; \
  4. float3x3 rotation = float3x3( v.tangent.xyz, binormal, v.normal )
float3x3是行向量构建,可以参照这里,然后我们就可以通过mul(rotation,v)把需要的向量从模型空间转化到tangent空间。不过大部分内容Unity已经帮我们做好了,主要是TBN空间的创建,如果需要自己写渲染器的话,这个是一个比较麻烦的过程,也有类似3DMax中导出顶点tangent值中的做法,直接在导出的时候将tangent空间信息导出,存储在顶点中。
 
 
最后总结一下:tangent space下,其实跟我们上一节计算的斜率很像,我们计算斜率基本也是tangent值。而这里T(x轴)使用normalize(dx/du, dy/du, dz/du),相当于计算了模型空间下x,y,z值随着纹理u坐标方向的斜率,换句话说,切线空间反映了模型空间坐标xyz随着纹理坐标uv的变化率(坡度),这也正是normal map中要存储的信息,所以normal map中的内容正好可以使用切线空间进行存储。
 

为什么法线贴图都是蓝色的

 
既然我们知道了法线贴图中存储的是切线空间的法线。而法线贴图所对应的表面,绝大部分的位置肯定是平滑的,只有需要凹凸变化的地方才会有变化,那么大部分地方的法线方向不变,也就是在切线空间的(0,0,1),这个值按照上面介绍的映射关系,从(-1,1)区间变换到(0,1)区间:(0*0.5+0.5,0*0.5+0.5,1*0.5+0.5)= (0.5,0.5,1),再转化为颜色的(0,255)区间,最终就变成了(127,127,255)。好了,打开photoshop,看一下这个颜色值是什么:
Unity Shader-法线贴图(Normal)及其原理
法线一般就是这个颜色嘛!那么,其他的地方,如果有凹凸感,就需要调整法线的方向,那么颜色就不一样了。
 

Unity下法线贴图Shader实现

 
解决了上面几个问题之后,我们就可以看一下Unity Shader中实现法线贴图的方式。光照模型仍然采用之前的半兰伯特光照,vertex fragemnt shader实现(surface版本的就两句话,也就不写了):
  1. //Bump Map
  2. //by:puppet_master
  3. //2016.12.14
  4. Shader "ApcShader/NormalMap"
  5. {
  6. //属性
  7. Properties{
  8. _Diffuse("Diffuse", Color) = (1,1,1,1)
  9. _MainTex("Base 2D", 2D) = "white"{}
  10. _BumpMap("Bump Map", 2D) = "bump"{}
  11. _BumpScale ("Bump Scale", Range(0.1, 30.0)) = 10.0
  12. }
  13. //子着色器
  14. SubShader
  15. {
  16. Pass
  17. {
  18. //定义Tags
  19. Tags{ "RenderType" = "Opaque" }
  20. CGPROGRAM
  21. //引入头文件
  22. #include "Lighting.cginc"
  23. //定义Properties中的变量
  24. fixed4 _Diffuse;
  25. sampler2D _MainTex;
  26. //使用了TRANSFROM_TEX宏就需要定义XXX_ST
  27. float4 _MainTex_ST;
  28. sampler2D _BumpMap;
  29. float _BumpScale;
  30. //定义结构体:vertex shader阶段输出的内容
  31. struct v2f
  32. {
  33. float4 pos : SV_POSITION;
  34. //转化纹理坐标
  35. float2 uv : TEXCOORD0;
  36. //tangent空间的光线方向
  37. float3 lightDir : TEXCOORD1;
  38. };
  39. //定义顶点shader
  40. v2f vert(appdata_tan v)
  41. {
  42. v2f o;
  43. o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
  44. //这个宏为我们定义好了模型空间到切线空间的转换矩阵rotation,注意后面有个;
  45. TANGENT_SPACE_ROTATION;
  46. //ObjectSpaceLightDir可以把光线方向转化到模型空间,然后通过rotation再转化到切线空间
  47. o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex));
  48. //通过TRANSFORM_TEX宏转化纹理坐标,主要处理了Offset和Tiling的改变,默认时等同于o.uv = v.texcoord.xy;
  49. o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
  50. return o;
  51. }
  52. //定义片元shader
  53. fixed4 frag(v2f i) : SV_Target
  54. {
  55. //unity自身的diffuse也是带了环境光,这里我们也增加一下环境光
  56. fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * _Diffuse.xyz;
  57. //直接解出切线空间法线
  58. float3 tangentNormal = UnpackNormal(tex2D(_BumpMap, i.uv));
  59. //normalize一下切线空间的光照方向
  60. float3 tangentLight = normalize(i.lightDir);
  61. //根据半兰伯特模型计算像素的光照信息
  62. fixed3 lambert = 0.5 * dot(tangentNormal, tangentLight) + 0.5;
  63. //最终输出颜色为lambert光强*材质diffuse颜色*光颜色
  64. fixed3 diffuse = lambert * _Diffuse.xyz * _LightColor0.xyz + ambient;
  65. //进行纹理采样
  66. fixed4 color = tex2D(_MainTex, i.uv);
  67. return fixed4(diffuse * color.rgb, 1.0);
  68. }
  69. //使用vert函数和frag函数
  70. #pragma vertex vert
  71. #pragma fragment frag
  72. ENDCG
  73. }
  74. }
  75. //前面的Shader失效的话,使用默认的Diffuse
  76. FallBack "Diffuse"
  77. }

结果:

Unity Shader-法线贴图(Normal)及其原理

总结

本篇文章简单探究了一下bump map以及normal map的原理以及在Unity中的实现。法线贴图可以很好地在低模上模拟高模的效果,虽然多采样了一次贴图,但是能模拟出数倍于模型本身面数的效果,极大地提升了实时渲染的效果。虽然法线贴图也有一些弊端,因为法线贴图只是给人造成一种凹凸的假象,所以在视角与物体平行时,看到的物体表面仍然是平的。并且还会有一些穿帮的现象,不过毕竟瑕不掩瑜,法线贴图仍然是目前渲染中最常使用的技术之一。为了解决上面的问题,一些更加高级的贴图技术,如视差贴图和位移贴图就诞生了。之后再研究这两种更加高级一点的贴图技术,本篇到此为止。上面给出了一些关于tangent空间求解的参考链接。最后再附上一些关于法线贴图原理的参考。
上一篇:WebConfig 配置文件详解


下一篇:WebConfig节点详解