此文及专栏系以《Shader入门精要》书籍为基础整理的Unity Shader学习笔记,尽量以初学者视角还原(其实半年前我就是初学者),错误还需指正。专栏仍在更新中,预计初学者等级10篇左右,欢迎关注。
本篇是实操部分的第五个Shader,目标是创作一个Shader,采用法线贴图生成具有凹凸的渲染效果,具体名词可能不再解释。
我们学习Shader,有些封装的很好的语义和算法可以跳过,但是想必大家最终的目的,往往是成为TA或者在开发工作流程中起到作用,所以涉及到一些游戏美术的概念或者Unity交互界面的用法,还是有必要解释的。(入门精要这里专门用了篇幅解释)当然,如果你只是想掌握这种Shader或者查阅资料时看到本文,那么请跳过。
从美术的角度来说,他们或许不关心贴图是怎么在渲染管线中作用的,但是一定会关心他的贴图能不能完美的呈现出来——贴图的缩放错了显得精度低,偏移错了就好像衣服穿反了,这些问题非常明显。这里,建模软件和unity都发展出了简单易懂的贴图模式管理。
做过美术的同学可能有印象,建模软件一般是拖进去一张图片素材,然后在材质面板来管理这张图怎么用,而unity则是在图片本身的inspector面板进行编辑。例如,Texture type中选定图片的用途,来决定面板中的属性;Alpha Source则决定是否导入图片的Aplha通道信息(你可以理解为图片透明度是否导入);而我们经常要改动的是Wrap Mode这里,这个选项决定纹理的“铺设”,如果我们选择Repeat,这张纹理会在uv方向上不断被重复,像图片里这个砖墙材质,就很适合repeat;clamp则是直接截取,纹理会按照自身大小直接铺上去,如果纹理是设定好的还好,如果不合适可能会发生错位。Filter Mode决定了图片被缩放后的运算方法,其选项有着不同的性能和效果,可以自行尝试。
还要学习一个概念,就是贴图其实并不只是一张图片纹理,它经常是人眼无法理解的信息。由于图片作为一种存储格式,很适合处理像平面凹凸、光照强度这些信息(想象一下天气预报的温度图、地理的海拔图,其实都是把其他的信息转换成了图片的色彩信息),所以我们应用中出现了法线贴图、光照贴图这种东西。
光照贴图辐照贴图这种东西暂不需要我们在Shader中实现,先不论,本次Shader要实现的对象就是法线贴图。看下面这张图片:左边是图片纹理,右边是对应法线贴图,可以看出,法线贴图是有凹凸和阴影效果的。事实上,不仅是看起来,法线贴图的每个像素点的颜色值(RGB格式,由3个分量组成,每个分量取值区间为0到1)均对应这一点的法线方向。
要写法线Shader,法线贴图的原理恐怕是跑不了的。我们可能会注意到一个事实:为什么这个法线贴图都是蓝色的?其实古早的法线贴图并非如此,我们的法线向量是(0,0,1)这样一个三维向量,每个分量取值是-1到1,映射到RGB 的0到1区间是这样:
这样说来,我们每个点的颜色信息也应该是分量都在[0,1]区间,应当是五颜六色的。但是这样五颜六色的贴图其实并不只管,并且并不能很好地从视觉上反映凹凸和阴影,原因很简单,想象一下假如模型是个半球,每个点的法线向量都不一样,乱七八糟,贴图就是个五颜六色的(看左下图),美术看到人都晕了。
为了解决这个问题,我们采用的是切线空间法线贴图,用线性代数概念来理解,这就是说,每个顶点的坐标空间不同,经过了世界坐标到局部坐标的变换,这个局部坐标是基于模型的切线来的,切线方向是x轴,切平面的法线为z轴。例如,假如一个模型是个大平面,其法线向量都是(0,0,1),因为每个点的法线都与切平面垂直,没有偏离嘛。我们上面的变换后,这个法线向量变成了(0.5,0.5,1),这是啥?在RGB中刚好是蓝色。这就是为什么切线空间法线贴图大部分是蓝色,尤其是平整的地方。
我们了解了法线贴图的原理,回忆一下之前的Shader,好像从来用的都是世界坐标法线向量,我们用切线空间,必须想办法还原。这个还原过程一般有两种方法,一是在片元着色器中,通过纹理采样得到切线空间下的法线,然后将我们惯用的视角方向、光照方向等向量转换到切线空间,一起进行运算;二是将法线首先进行采样,并将其还原到世界坐标空间中,然后进行计算。这两种方法各有利弊,我们这里选用第一种进行说明,后一种在代码上按照相关逻辑修改即可,这里不再说明。
为了将视角向量、光照向量转换到切线空间,我们需要一个变换矩阵,它的数学形式就是切线空间坐标系的三轴向量按列排列(以模型空间作为原坐标系,切线为新坐标系x轴,法线为z轴,y轴与它们垂直,构建出的新坐标系,原坐标与其变换矩阵就是这三轴在原坐标系中的向量,按列排列,线代原理可以自行查询)。
法线凹凸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块标明Shader传入的参数,也是Shader提供给Material的接口,为了实现我们的法线贴图和纹理贴图的功能,我们需要传入以下参数:
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和引用。例如上述:
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是输入结构体,包含以下参数:
经过顶点着色器处理,输出的是v2f输出结构体,包含以下参数
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矩阵,是我们从世界坐标到切线空间的变换矩阵。
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游戏渲染的基础我们已经了解啦~
专栏仍在更新中……