【UnityShader从零开始】(5)法线贴图

发表于2015-08-14
评论0 2.5k浏览

原文地址原文链接在我的Blog中http://www.cnblogs.com/Esfog/p/3608833.html ,有任何问题欢迎在本帖或者或者我的Blog中留言与我讨论.


        在前面我们已经介绍过了漫反射和镜面反射,这两个是基本的光照类型,仅仅依靠它们就想制作出精美的效果是远远不够的,这一篇我们就来了解一下如何利用一种叫做法线贴图的技术并结合我们前面讲过的知识来制作出更精细的效果.

法线贴图NormalMap


  首先要提到的是,什么是法线贴图,如果大家想看更专业的解释可以自行求助搜索引擎,这里我说一下我的个人理解:在游戏中,如果角色或物体模型做的越精细(面数越多),那么渲染后效果也就越好,但很多时候处于对时间成本(据说一个美术做一个高模是要花不少时间的)和游戏性能(面数越多,GPU的运算量就越大)的考虑,我们一般在游戏内使用的是底模(面数较少的模型),而通过其它的一些技术手段来达到相似的效果.而应用的最广泛的可能就要数法线贴图了.在有光照的环境下,如果物体表面是凹凸不平的,那么它在接受光照的时候在不同的区域就会呈现出不同的明暗效果来展现这种凹凸感,上两篇中我们介绍过漫反射和镜面反射的计算中我们都用到了物体表面的法线,正因为物体表面法线的不同才导致了最终光照结果的不同,如果我们能够把整个模型表面各个位置的法线映射到一张二维贴图上,然后在这张贴图上存储上法线的信息,不就可以达到通过底模+二维贴图达到高模效果了么?而这里的二维贴图就是我们所说的法线贴图.


  为什么叫它法线贴图呢?它和我们之前一直使用的纹理贴图有何区别呢?纹理贴图中我们存储的是颜色值RGBA,而法线贴图里存储的是物体表面的法线,两类贴图的读取映射方式都是一致的,都是通过顶点自带信息里的texcoord里的uv坐标来读取,不过法线读取之后并不能直接使用,还要经过一些处理,我们会在后面说.


   下面在正式进入代码之前,我们先来了解几个知识点,很重要。


  1.切线空间

  这个概念并不是十分好理解,但只要仔细想想也是可以弄清楚的。我想大家对本地空间一定不陌生,一般美术做完的模型里面每个顶点的坐标都是本地坐标,也就是说对于模型上的各个部位共用一个一个统一的坐标原点,但有时候这样并不是很方便,比如建了一个人体模型,如果我们只是想以相对手为基准而进行一些动作,而不是坐标原点,这时候原本的本地坐标系便不再适应。我们可以以手臂为基准再建立一个坐标系。综上不难理解,之所以存在不同的坐标系,根本上是为了方便我们只考虑相关的因素,而排除不相关的因素。就像如果我想以手为基准进行一个弯手指的操作是不需要考虑我这个手指在模型空间的位置坐标的,这样有效的降低了问题的复杂度。而切线空间的概念提出也是为了方便使用法线贴图(当然了也许有其他用途)。下面结合图(图片来源CSDN作者BonChoix)来说一下:

       


     其实纹理坐标可以以一个三角面作为一个单位来分析,在一个三角面上的三个顶点他们都拥有自己的纹理坐标,而又因为三点确定一个平面(前提是三点不在一条直线啊),那么纹理坐标其实也就是个二维空间的坐标(一般横轴是u,纵轴是v)。那对于一个三角面来说,其上的三个顶点所拥有的切线和法线(图中T和N)其实是一样的,通过叉乘我们可以求得笛卡尔坐标系中的两一个坐标轴B(Binormal,一般称之为负法线).实际上纹理坐标上的u对应的就是切线方向,而V对应的就是负法线方向。法线贴图上存储的法线信息也就是对应的这个三维空间。这个地方比较绕,我说的也不是很清楚,大家结合图好好理解一下吧。


  2.DXT5nm压缩格式

       


  美术做出来的一张法线贴图一般来说使用的RGB三个通道的,分别用来存放法线的XYZ三个轴向的坐标。但是unity在导入法线贴图的时候会自动将法线贴图压缩成DXT5nm格式,这个格式的好处是它只使用ag两个通道来存放两个轴向的坐标值而另外一个轴向的坐标值由于是单位坐标可以通过1减去另外前两个轴向坐标的平方和来得到,从而可以以同样的容量存放更大尺寸的法线贴图,我们都知道图片的颜色通道存的都是非负数(法线贴图生成的时候已经把[-1,1]压缩为[0-1]),而我们的三维空间是[-1,1],所以我们要把它解析放大一下,方法就是对对应的颜色通道值乘以2再减1,有一点要额外说明,如果你是针对移动平台来开发游戏那么Unity不会为你压缩法线贴图,这意味着你还要使用RGB通道来解析法线贴图。至于他为什么呈现蓝色,因为贴图本身还是用RGB来存的,而其中B通道对应的是z轴的方向,代表三角面的法线方向,一般来说一个片段对应的法线知识于平面法线方向有少许偏移,所以z还是接近于1的所以说会呈现出蓝色。

  

  下面我们进行实例代码的分析:


[C#] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<font style="color:rgb(0, 0, 0)"><font style="background-color:rgb(245, 250, 254)"><font face="Verdana, Arial, Helvetica, sans-serif"><font style="font-size:12px">Shader "Esfog/NormalMap"
{
    Properties
    {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _NormalMap("NormalMap",2D) = "Bump"{}
        _SpecColor("SpecularColor",Color) = (1,1,1,1)
        _Shininess("Shininess",Float) = 10
    }
    SubShader
    {
         
        Pass
        {
            Tags { "LightMode"="ForwardBase" }
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            uniform sampler2D _MainTex;
            uniform sampler2D _NormalMap;
            uniform float4 _SpecColor;
            uniform float _Shininess;
            uniform float4 _LightColor0;
 
            struct VertexOutput
            {
                float4 pos:SV_POSITION;
                float2 uv:TEXCOORD0;
                float3 lightDir:TEXCOORD1;
                float3 viewDir:TEXCOORD2;
            };
 
            VertexOutput vert(appdata_tan v)
            {
                VertexOutput o;
                o.pos = mul(UNITY_MATRIX_MVP,v.vertex);
                o.uv = v.texcoord.xy;
                float3 normal = v.normal;
                float3 tangent = v.tangent;
                float3 binormal= cross(v.normal,v.tangent.xyz) * v.tangent.w;
                float3x3 Object2TangentMatrix = float3x3(tangent,binormal,normal);
                o.lightDir = mul(Object2TangentMatrix,ObjSpaceLightDir(v.vertex));
                o.viewDir = mul(Object2TangentMatrix,ObjSpaceViewDir(v.vertex));
                return o;
            }
 
            float4 frag(VertexOutput input):COLOR
            {
                float3 lightDir = normalize(input.lightDir);
                float3 viewDir = normalize(input.viewDir);
                float4 encodedNormal = tex2D(_NormalMap,input.uv);
                float3 normal = float3(2.0*encodedNormal.ag - 1,0.0);
                normal.z = sqrt(1 - dot(normal,normal));
                float4 texColor = tex2D(_MainTex,input.uv);
                float3 ambient = texColor.rgb * UNITY_LIGHTMODEL_AMBIENT.rgb;
                float3 diffuseReflection = texColor.rgb * _LightColor0.rgb * max(0,dot(normal,lightDir));
                float facing;
                if(dot(normal,lightDir)<0)
                {
                    facing = 0;
                }
                else
                {
                    facing = 1;
                }
                float3 specularRelection = _SpecColor.rgb * _LightColor0.rgb * facing * pow(max(0,dot(reflect(-lightDir,normal),viewDir)),_Shininess);
 
                return float4(ambient + diffuseReflection + specularRelection,1);
            }
            ENDCG
        }
         
    }
    FallBack "Diffuse"
}</font></font></font></font>


    其中一部分内容与上几节中已经提到,这里只解释本节新内容部分。


  第6行,由于这节中我需要额外一张法线贴图,所以我们在Properties中定义一个新的变量来存放贴图。与定义存放纹理贴图的变量方式没什么区别。


      第30~31行,之前说过计算光照在任何一个空间都可以,只要参与计算的点和向量都是在同一空间内的就可以。我们在VertexOutput里定义这两个变量,是为了在切线空间下的入射光方向以及视线方向。这里要解释一下为什么选择了切线空间来计算。主要是考虑的计算量对效率的影响,如果我们在世界空间内进行计算(当然你也可以在其他空间,比如视图空间等等),那么我们要生成一个从世界空间到切线空间的变换矩阵,由于在顶点着色器中我们无法进行纹理读取tex2D操作,也就是无法获得法线贴图的数据,那么我们就必须把组成变换矩阵的相关变量通过VertexOutput传到片段着色器里面重新组装起来,我们需要把参与计算的光线视线转到世界空间,再把从法线贴图中得到的切线空间的法线向量通过矩阵转到世界空间参与计算。由于这个操作是所有逐像素计算的所以计算量相对来说较大。而如果我们在切线空间来计算的话,就可以省去很多麻烦,由于我们现在顶点着色器中建立起一个模型空间到切线空间的转换矩阵(具体原理后面解释),然后把后面片段着色器计算光照需要用到的光线方向和视线方向通过矩阵转到切线空间。而在片段着色器中我们把切线空间中的法线值拿来之后不需要做任何处理就可以参与光照计算了。这种把主要计算量从fragment转移到vertex的思想是渲染优化的常用方法。而实际上SurfaceShader在计算光照的时候也是在切线空间的。

 

      第39~42行,这四行代码主要是为了后面建立变换矩阵。因为我们要建立从模型空间到切线空间的变换矩阵,所以我们需要使用切线空间中三个坐标轴在模型空间(本地空间)的表示,而且要单位化。为什么这么做网上可能有专业的数学解释,这里面我只谈谈我自己纠结了很久很久以后所整理的一套理解方法,在第42行可以看到我们最终构造的变换矩阵第一行是T(切线)在本地坐标的表示,第二行是B(负法线)在本地坐标的表示,第三行是N(法线)在本地坐标中的表示。将这三个都单位化之后,假设T为(Tx,Ty,Tz),当我们将一个模型空间的向量以列向量(设起为(a,b,c))的方式左乘这个变换矩阵的时候。那么得到的变换后的在切线空间坐标的x = Tx*a + Ty*b + Tz *c 。看着这个式子然后你这样想:在原本的模型空间中x轴正方向的单位向量为n(1,0,0),可以理解为在一个对于给定的向量v=(a,b,c),那么如果我们将两者点乘则得到v·n = 1*a + b*0 + c*0=a。其实因为n是单位向量摸为1,所以v·n = |v||n|cosθ=|v|cosθ,这下明白了吧,实际上当n为单位向量时候v·n得到的是v在n方向上的投影大小.那么如果我们把一个本地空间的向量转换到切线空间,实际上也就是要求这个向量在切线空间三个坐标轴单位方向上的投影,以之前提到的T为例,那么如果我们将本地空间中的向量v·T那么得到的就是v在切线空间x轴方向上的投影长度也就是新的x坐标了。对于另外两个方向也是同样的道理.就不多说了,这是我的理解方式,大家也可以以自己的想法去思考一下.在第41行中我们用cross方法进行了切线和法线的叉乘来得到负法线,注意顺序,不可以写反,而至于后面为什么乘一个v.tangent.w呢,我在国外论坛查了查,收获不是很多,英语有限大概知道的意思是这个值只可能是1或者-1,主要是因为纹理空间的uv方向可能由于左右手不同的原因是倒着的,最终会导致差乘以后的负法线是反着的,可以通过这个w值来矫正,如果有哪位同学知道的更多请告诉我.这四行代码可以使用Unity定义的一个宏TANGENT_SPACE_ROTATION来代替,它在UnityCG.cginc中定义.


  第43~44行,这个就是将入射光方向和视线方向从本地空间转换到切线空间传给片段着色器用来参与计算光照.原因上面说过了.这里我们用到了两个新的函数ObjSpaceLightDir(float4 v)和ObjSpaceViewDir(float4 v),分别是Unity为我们封装好的用来将得到光线和视线在本地空间中的方向向量.他们在UnityCG.cginc中有定义,大家可以自己去看。当然你也可以自己写,不过为了减少代码量和健壮性以后尽量使用Unity为你封装好的除非你有特殊需求。


  第52~54行,这三行代码是为了从法线贴图中解析出正确的法线向量。先是从法线贴图中读取出rgba,然后我们说过由于是DXT5nm压缩格式,所以我们只使用ag两个通道,其中存放x坐标,g存放y坐标,上面说过了他们是[0-1]的范围,我们要把他们转换到[-1,1],再通过1-(x*x+y*y)来得到z.这样我们就得到了法线贴图中所要表达的法线坐标了.后面就用我们求得的法线去参与漫反射和高光的计算吧.这几行代码也可以用Unity提供的UnpackNormal函数代替,它在UnityCG.cginc中定义.



  (~ o ~)~系列教程的第五篇到此结束了,这篇的内容要比之前的知识在理解上有更高的难度,主要是涉及到一些数学思想在里面,如果要想在渲染方面有深入研究的话,数学是避不开的东西,所以各位同学做好准备迎接挑战吧,我的数学也很渣渣.只能咬着牙坚持下去啊.我还法线写博客真的是个好的锻炼,当把自己认为已经理解了的东西写成文字讲给别人的时候你会发现新的问题,甚至之前自己理解的误区,比如上面提到的法线关于构造变换矩阵里面的投影的想法,我是刚才写着写着才突然发现的.


  老规矩上图看效果

            

           这是上一篇的漫反射+高光效果

     

    这是使用了咱们刚才写的Shader的效果,很精细有没有,腰带上的纹路,脸上的法令纹等等.当然有点光有点曝,效果不是特别好.


如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引