Unity Shader知识点(五)法线贴图生成凹凸效果Shader

此文及专栏系以《Shader入门精要》书籍为基础整理的Unity Shader学习笔记,尽量以初学者视角还原(其实半年前我就是初学者),错误还需指正。专栏仍在更新中,预计初学者等级10篇左右,欢迎关注。 

本篇是实操部分的第五个Shader,目标是创作一个Shader,采用法线贴图生成具有凹凸的渲染效果,具体名词可能不再解释。

关于纹理和贴图的碎碎念

我们学习Shader,有些封装的很好的语义和算法可以跳过,但是想必大家最终的目的,往往是成为TA或者在开发工作流程中起到作用,所以涉及到一些游戏美术的概念或者Unity交互界面的用法,还是有必要解释的。(入门精要这里专门用了篇幅解释)当然,如果你只是想掌握这种Shader或者查阅资料时看到本文,那么请跳过。

从美术的角度来说,他们或许不关心贴图是怎么在渲染管线中作用的,但是一定会关心他的贴图能不能完美的呈现出来——贴图的缩放错了显得精度低,偏移错了就好像衣服穿反了,这些问题非常明显。这里,建模软件和unity都发展出了简单易懂的贴图模式管理。

Unity的图片管理功能

做过美术的同学可能有印象,建模软件一般是拖进去一张图片素材,然后在材质面板来管理这张图怎么用,而unity则是在图片本身的inspector面板进行编辑。例如,Texture type中选定图片的用途,来决定面板中的属性;Alpha Source则决定是否导入图片的Aplha通道信息(你可以理解为图片透明度是否导入);而我们经常要改动的是Wrap Mode这里,这个选项决定纹理的“铺设”,如果我们选择Repeat,这张纹理会在uv方向上不断被重复,像图片里这个砖墙材质,就很适合repeat;clamp则是直接截取,纹理会按照自身大小直接铺上去,如果纹理是设定好的还好,如果不合适可能会发生错位。Filter Mode决定了图片被缩放后的运算方法,其选项有着不同的性能和效果,可以自行尝试。

Unity Shader知识点(五)法线贴图生成凹凸效果Shader_第1张图片

 法线贴图的概念

 还要学习一个概念,就是贴图其实并不只是一张图片纹理,它经常是人眼无法理解的信息。由于图片作为一种存储格式,很适合处理像平面凹凸、光照强度这些信息(想象一下天气预报的温度图、地理的海拔图,其实都是把其他的信息转换成了图片的色彩信息),所以我们应用中出现了法线贴图、光照贴图这种东西。

光照贴图辐照贴图这种东西暂不需要我们在Shader中实现,先不论,本次Shader要实现的对象就是法线贴图。看下面这张图片:左边是图片纹理,右边是对应法线贴图,可以看出,法线贴图是有凹凸和阴影效果的。事实上,不仅是看起来,法线贴图的每个像素点的颜色值(RGB格式,由3个分量组成,每个分量取值区间为0到1)均对应这一点的法线方向。

Unity Shader知识点(五)法线贴图生成凹凸效果Shader_第2张图片

法线贴图的原理

要写法线Shader,法线贴图的原理恐怕是跑不了的。我们可能会注意到一个事实:为什么这个法线贴图都是蓝色的?其实古早的法线贴图并非如此,我们的法线向量是(0,0,1)这样一个三维向量,每个分量取值是-1到1,映射到RGB 的0到1区间是这样:

color = \frac{1+normal}{2}

 这样说来,我们每个点的颜色信息也应该是分量都在[0,1]区间,应当是五颜六色的。但是这样五颜六色的贴图其实并不只管,并且并不能很好地从视觉上反映凹凸和阴影,原因很简单,想象一下假如模型是个半球,每个点的法线向量都不一样,乱七八糟,贴图就是个五颜六色的(看左下图),美术看到人都晕了。 

为了解决这个问题,我们采用的是切线空间法线贴图,用线性代数概念来理解,这就是说,每个顶点的坐标空间不同,经过了世界坐标到局部坐标的变换,这个局部坐标是基于模型的切线来的,切线方向是x轴,切平面的法线为z轴。例如,假如一个模型是个大平面,其法线向量都是(0,0,1),因为每个点的法线都与切平面垂直,没有偏离嘛。我们上面的变换后,这个法线向量变成了(0.5,0.5,1),这是啥?在RGB中刚好是蓝色。这就是为什么切线空间法线贴图大部分是蓝色,尤其是平整的地方。

法线贴图与空间变换

我们了解了法线贴图的原理,回忆一下之前的Shader,好像从来用的都是世界坐标法线向量,我们用切线空间,必须想办法还原。这个还原过程一般有两种方法,一是在片元着色器中,通过纹理采样得到切线空间下的法线,然后将我们惯用的视角方向、光照方向等向量转换到切线空间,一起进行运算;二是将法线首先进行采样,并将其还原到世界坐标空间中,然后进行计算。这两种方法各有利弊,我们这里选用第一种进行说明,后一种在代码上按照相关逻辑修改即可,这里不再说明。

为了将视角向量、光照向量转换到切线空间,我们需要一个变换矩阵,它的数学形式就是切线空间坐标系的三轴向量按列排列(以模型空间作为原坐标系,切线为新坐标系x轴,法线为z轴,y轴与它们垂直,构建出的新坐标系,原坐标与其变换矩阵就是这三轴在原坐标系中的向量,按列排列,线代原理可以自行查询)。

法线凹凸Shader

法线凹凸Shader其实是在我们上一章的纹理Shader基础上构成的,我们用它赋给材质,传入纹理贴图和法线贴图(可以在网络上自行下载)就可以实现物体的基本纹理和表面凹凸,也就是说,正常的3D物体渲染的基本功能,已经可以完成啦。本Shader可能与你看到的版本不一样,实测是可以跑的,并且选取了个人认为最容易理解的代码表达。

为了使用这个Shader,你还需要在Unity里将法线贴图转化为正确的类型,即Normal map。前面说过,unity管理贴图是针对每一个图片文件来进行的,因此,你需要在图片文件的inspector面板,texture type选项中进行管理,选择Normal Map,这是一种unity固定的文件格式,使用这种贴图,我们的部分语义才能有效作用。

Shader "Unlit/BumpMapShader"
{
    Properties
    {
        _Color ("Color Tine",Color) = (1, 1, 1, 1)  //这是基础色彩
        _MainTex ("Texture", 2D) = "white" {}       //这是传入的纹理图片
        _BumpMap("Normal Map",2D) = "bump"{}        //这是我们的法线贴图
        _BumpScale("Bump Scale",Float) = 1.0        //凹凸倍数
        _Specular ("Specular", Color) = (1, 1, 1, 1)//这是高光色彩
        _Gloss ("Gloss",Range(8.0, 256)) = 20         //这是光泽度
    }
    SubShader
    {
        Pass
        {
            Tags{"LightMode" = "ForwardBase"}
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "Lighting.cginc"

            fixed4 _Color;
            sampler2D _MainTex;
            float4 _MainTex_ST;     //这是纹理的UV偏移属性
            sampler2D _BumpMap;
            float4 _BumpMap_ST;
            float _BumpScale;
            fixed4 _Specular;
            float _Gloss;

            struct a2v
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float4 tangent : TANGENT;
                float2 texcoord : TEXCOORD0;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float4 uv : TEXCOORD0;
                float3 lightDir : TEXCOORD1;
                float3 viewDir : TEXCOORD2;
            };


            v2f vert (a2v v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);

                o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
                o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;   //对UV按照输入值进行变换

                fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);  
				fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);  
				fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w; 
                float3x3 rotation = float3x3(worldTangent, worldBinormal, worldNormal);

                o.lightDir = mul(rotation, WorldSpaceLightDir(v.vertex)).xyz;
                o.viewDir = mul(rotation, WorldSpaceViewDir(v.vertex)).xyz;
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed3 tangentLightDir = normalize(i.lightDir);
                fixed3 tangentViewDir = normalize(i.viewDir);

                fixed4 packedNormal = tex2D(_BumpMap, i.uv.zw);
                fixed3 tangentNormal;
                tangentNormal = UnpackNormal(packedNormal);
                tangentNormal.xy *= _BumpScale;
                tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

                fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
                fixed3 diffuse = _LightColor0.rgb * albedo * max(0,dot(tangentNormal, tangentLightDir));
                fixed3 halfDir = normalize(tangentLightDir + tangentViewDir);
                fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0,dot(tangentNormal,halfDir)),_Gloss);
                return fixed4(ambient + diffuse + specular, 1.0);
            }
            ENDCG
        }
    }
    Fallback "Specular"
}

Properties块

Properties块标明Shader传入的参数,也是Shader提供给Material的接口,为了实现我们的法线贴图和纹理贴图的功能,我们需要传入以下参数:

  1. 基础色彩_Color,属性为Color,默认值(1,1,1,1)
  2. 主要纹理_MainTex,属性为2D图片,默认采用“white”,即全白色
  3. 法线贴图_BumpMap,属性为2D图片,默认采用“bump”,为默认的平整法线贴图
  4. 凹凸倍数_BumpScale,属性为Float浮点数,默认为1.0倍
  5. 高光色彩_Specular,类型为Color,默认仍为(1,1,1,1)
  6. 光泽度_Gloss,范围8.0到256的浮点数,默认为20,参考专栏之前的文章高光Shader
    Properties
    {
        _Color ("Color Tine",Color) = (1, 1, 1, 1)  //这是基础色彩
        _MainTex ("Texture", 2D) = "white" {}       //这是传入的纹理图片
        _BumpMap("Normal Map",2D) = "bump"{}        //这是我们的法线贴图
        _BumpScale("Bump Scale",Float) = 1.0        //凹凸倍数
        _Specular ("Specular", Color) = (1, 1, 1, 1)//这是高光色彩
        _Gloss ("Gloss",Range(8.0, 256)) = 20         //这是光泽度
    }

格式和引用

            Tags{"LightMode" = "ForwardBase"}
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "Lighting.cginc"

            fixed4 _Color;
            sampler2D _MainTex;
            float4 _MainTex_ST;     //这是纹理的UV偏移属性
            sampler2D _BumpMap;
            float4 _BumpMap_ST;
            float _BumpScale;
            fixed4 _Specular;
            float _Gloss;

作为一个Shader,我们需要声明一些必要的Tag和引用。例如上述:

  1. Tags指出这个SubShader或Pass块在渲染流水线中的作用,这里我们指定它用于ForwardBase,该路径会计算环境光、平行光、逐顶点光源等,非常常用
  2. CGPROGRAM和ENDCG标识渲染主体代码块的开始和结束
  3. #pragma后面指定顶点着色器和片元着色器函数
  4. 为了使用一些内置变量和语义,我们包含"Lighting.cginc"库
  5. 重新声明properties块中传入的变量和参数

输入输出结构体

            struct a2v
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float4 tangent : TANGENT;
                float2 texcoord : TEXCOORD0;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float4 uv : TEXCOORD0;
                float3 lightDir : TEXCOORD1;
                float3 viewDir : TEXCOORD2;
            };

顶点着色器的输入输出结构体,是我们每次写Shader基本上都要注意的,这里a2v是输入结构体,包含以下参数:

  • vertex顶点坐标,用POSITION语义直接传入
  • normal法线坐标,用NORMAL直接传入
  • tangent切线向量,TAGENT语义传入
  • texcoord第一套纹理坐标,用TEXCOORD0传入

经过顶点着色器处理,输出的是v2f输出结构体,包含以下参数

  • pos是顶点着色器输出的坐标,固定使用SV_POSITION语义
  • uv是变换后的顶点uv坐标 ,缺省值TEXCOORD0
  • lightDir和viewDir是对应片元的光线入射向量和观察视线向量,默认是TEXCOORD1、TEXCOORD2即第二和第三套纹理坐标

顶点着色器

            v2f vert (a2v v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex); //裁剪到裁剪空间

                o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
                o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;   //对UV按照输入值进行变换

                fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);  
				fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz);  
				fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w;  //c叉积,获得副切线
                float3x3 rotation = float3x3(worldTangent, worldBinormal, worldNormal);

                o.lightDir = mul(rotation, WorldSpaceLightDir(v.vertex)).xyz;
                o.viewDir = mul(rotation, WorldSpaceViewDir(v.vertex)).xyz;   //将相关向量转化到切线空间
                return o;
            }

我们顶点着色器的设计思路如下:拿到Unity内置的法线、切线、坐标,全部变换成世界坐标,并由此得到世界坐标到切线空间的变换矩阵;拿到传入的uv坐标,按照传入的缩放和平移进行变换;借变换矩阵,对视线向量、光线向量全部转换成切线空间,也就是说,基本上是为片元着色器准备好一切数据。

首先我们定义输出结构体o,用UnityObjectToClipPos对坐标进行裁剪,到裁剪空间后输出到o;然后对o的uv进行计算,这里的uv总共有两套,分别对应纹理和法线贴图,计算方法均是用输入的纹理坐标texcoord乘以对应贴图偏移属性的xy分量再加上zw分量,这是因为偏移属性的前两个分量是缩放,后两个是平移;之后用UnityObjectToWorldXXX这样的语义计算出世界坐标下的法线和切线,即worldNormal和worldTagent;拿到这两个向量,就可以计算出副切线。

这里还是该详细说明下副切线,事实上,切线空间的定义主要是基于x轴来的,x轴是切线,z是切平面的法线,y轴则是基于它们俩确定的一个向量,叫副切线。由线性代数知识可以知道,y轴可以由x轴和z轴叉积求得,也就是我们用cross(worldNormal, worldTangent) * v.tangent.w得到,最后将这三个向量按列排布,即可得到rotation矩阵,是我们从世界坐标到切线空间的变换矩阵。

Unity Shader知识点(五)法线贴图生成凹凸效果Shader_第3张图片

 片元着色器

            fixed4 frag (v2f i) : SV_Target
            {
                fixed3 tangentLightDir = normalize(i.lightDir);
                fixed3 tangentViewDir = normalize(i.viewDir);

                fixed4 packedNormal = tex2D(_BumpMap, i.uv.zw);
                fixed3 tangentNormal;
                tangentNormal = UnpackNormal(packedNormal);
                tangentNormal.xy *= _BumpScale;
                tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

                fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
                fixed3 diffuse = _LightColor0.rgb * albedo * max(0,dot(tangentNormal, tangentLightDir));
                fixed3 halfDir = normalize(tangentLightDir + tangentViewDir);
                fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0,dot(tangentNormal,halfDir)),_Gloss);
                return fixed4(ambient + diffuse + specular, 1.0);
            }

片元着色器的设计思路很简单:拿到顶点着色器的输出,进行归一化,然后对传入的法线贴图、纹理贴图进行采样,然后按照片元着色器以往的思路进行计算,只不过以往的法线要换成采样得到的法线而已

那么我们用normalize,首先对顶点着色器计算的lightDir和viewDir进行归一化;然后用tex2D进行采样,这个函数输入值为采样贴图和uv坐标,输出采样得到的纹素,是一个4维向量;我们用Unity内置函数UnpackNormal对采样得到的结果进行处理,这一处理的实质是把我们法线贴图存储的数据返还为法线的格式(还记得我们刚开始关于法线转换为RGB的操作吗,这一处理就是逆过程),然后我们再次进行缩放操作,即首先将法线的xy轴值乘以_BumpScale缩放值,然后由于法线是单位向量,开根号计算出它的z分量,这样,我们就用tangentNormal得到了最后可以带入计算中的、切线空间中的法线。

接下来开始正式的着色计算。首先tex2D(_MainTex, i.uv).rgb,还是对纹理贴图进行采样并得到rgb值,乘以基础色彩color的rgb值得到纹理采样结果albedo;然后乘以内置的环境光语义UNITY_LIGHTMODEL_AMBIENT得到环境光部分;利用纹理采样结果albedo,乘以_LightColor0,再乘以tangentNormal, tangentLightDir两个向量点乘积取正的结果(这里原理参看漫反射Shader,是漫反射公式)得到漫反射结果;然后计算出tangentLightDir + tangentViewDir的均值(用于替代视线向量viewdir,这样计算出的结果更真实),代入高光反射公式得到高光反射结果(参考高光Shader,是高光反射公式);最后3个部分相加,添加1.0分量,得到最后的片元着色结果。

总结

来看看渲染结果吧,左边是我使用了墙壁砖块贴图的纹理贴图和凹凸贴图渲染出的结果,右边则是没有凹凸贴图的结果,可见左边已经很接近真实效果了;事实上,大多数真实画风游戏里的渲染,也就是这个基础上修修改改加加滤镜,也就是说Unity游戏渲染的基础我们已经了解啦~

专栏仍在更新中……

Unity Shader知识点(五)法线贴图生成凹凸效果Shader_第4张图片

你可能感兴趣的:(Shader尝试入门笔记,unity,游戏引擎,图形渲染)