写在前面
严格来讲,这篇博客是填了【技术美术图形部分】纹理基础2.0-凹凸映射的坑,就是在Shader中实现坐标转换并完成光照计算!由于涉及到一些TBN矩阵的内容,会进行必要的详细描述。
参考的书籍
《Unity Shader 入门精要》——冯乐乐
1 成果
1.1 不同BumpScale下的动态效果
1.2 Shader完整代码
漫反射我选择用了半兰伯特模型,同时高光项也计入了albedo的影响。
Shader "Unity Shaders Book/Chapter 7/Normal Map"
{
Properties
{
_Color ("Color", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
//法线纹理
_BumpMap ("Normal Map", 2D) = "bump" {}
_BumpScale ("Bump Scale", Range(-1.0, 1.0)) = 0 //控制凹凸程度
}
SubShader
{
Pass
{
Tags { "LightMode" = "ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
//与propertieds同步
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST; //还需要定_ST变量
fixed4 _Specular;
float _Gloss;
sampler2D _BumpMap;
float4 _BumpMap_ST; //同样需要定义_ST
float _BumpScale;
struct a2v {
//摸透了,无非就是点、法线、纹理(还要加上一个切线
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
float4 tangent : TANGENT; //点的切线
};
struct v2f {
//vert传递来的:点、法线、切线、纹理坐标
float4 pos : SV_POSITION;
float4 uv : TEXCOORD0; //存法线纹理和漫反射纹理的坐标,因此是float4
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;
//把模型空间下的TBN三向组成 模型 --> 切线空间的矩阵
// float3 binormal = cross(normalize(v.normal), normalize(v.tangent.xyz));
// float3x3 rotation = float3x3(v,tangent.xyz), binormal, v.normal); //TBN
// Or USE TANGENT_SPACE_ROTATION;
TANGENT_SPACE_ROTATION;
//viewDir和lightDir换到切线空间下
o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;
o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)).xyz;
return o;
}
fixed4 frag(v2f i) : SV_Target {
//获取顶点的法线值,但此时仅仅是一个像素颜色值,还需转换
fixed4 packNormal = tex2D(_BumpMap, i.uv.zw);
fixed3 tangentNormal = UnpackNormal(packNormal);
//加上bumpScale的影响
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
//然后就开始正常的光照计算流程
//别忘记归一化
fixed3 tangentviewDir = normalize(i.viewDir);
fixed3 tangentlightDir = normalize(i.lightDir);
fixed3 albedo = tex2D(_MainTex, i.uv.xy).rgb * _Color,rgb;
//半程
fixed3 halfDir = normalize(tangentviewDir + tangentlightDir);
//漫反射项
fixed halfLambert = dot(tangentNormal, tangentlightDir) * 0.5 + 0.5;
fixed Lambert = saturate(dot(tangentNormal, tangentlightDir));
fixed3 _diffuse = _LightColor0.rgb * halfLambert;
//环境光项
fixed3 _ambient = UNITY_LIGHTMODEL_AMBIENT.rgb;
//高光项
fixed _specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(halfDir, tangentNormal)),_Gloss);
//三项均计入albedo
fixed3 resultColor = (_diffuse + _specular + _ambient) * albedo;
return fixed4(resultColor, 1.0);
}
ENDCG
}
}
FallBack "Specular"
}
2 如何在Shader中加入法线纹理?
跟之前的将一张纹理图片简单地贴上模型不同,法线纹理的应用是需要在赋予漫反射颜色的基础上再叠加一个法线纹理,让模型最终的光照效果具有凹凸感。如何在使用漫反射纹理的基础上同时叠加法线纹理?这是我们需要关注的第一点。
2.1 也给法线纹理定义相同的属性
这里说的就是Properties语义块的属性定义,以及Cg代码块中的与这些属性相连的内容。
Properites语义块中,_MainTex怎么定义,我们法线纹理_BumpMap照做:
_MainTex ("Main Tex", 2D) = "white" {}
_BumpMap ("Normal Map", 2D) = "bump" {}
//加入了控制凹凸程度的变量
_BumpScale ("Bump Scale", Range(-1.0, 1.0)) = 0
但不同的是,为了方便在Unity中改变材质的凹凸感,以达到想要的效果,我们还额外给了一个可以调整凹凸程度的变量_BumpScale.
那么在Pass语义块中,与_MainTex也定义相同的参数:
//_MainTex的
sampler2D _MainTex;
float4 _MainTex_ST;
//_BumpMap的
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
smapler2D与2D对应;二者也都需要一个XX_ST参数访问缩放和偏移量;别忘了刚刚的float类型的_BumpScale
2.2 uv中储存两组纹理坐标
这次顶点着色器输出给片元着色器的uv定义不再是:
float2 uv : TEXCOORD0;
而是:
float4 uv : TEXCOORD0;
顶点着色器中,uv值的赋予也加上了法线纹理_BumpMap的属性影响量:
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv.zw = v.texcoord.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;
这里可以使用Unity的内置TRANSFORM_TEX宏来实现:
o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
o.uv.zw = TRANSFORM_TEX(v.texcoord, _BumpMap);
这样,就方便在片元着色器中采样切线空间下法线方向。
我们还需注意:
通常_MainTex和_BumpMap甚至是后来会遇到的遮罩纹理,几张纹理会采用同一种平铺和位移操作(甚至是不需要进行平铺和位移操作),因此三张纹理一般都会公用同一组纹理坐标,以节省我们需要储存的纹理坐标数目,从而降低顶点着色器中插值寄存器的空间占用。
3 如何统一到切线空间下?
而进行切线空间下的光照计算,又涉及到片元着色器中进行光照计算用到的一系列包括viewDir、lightDir、normal等矢量的坐标空间的统一。如何将他们统一到切线空间下?这也是我们需要关注的第二点。
3.1 TBN矩阵
提到切线空间我们第一时间就会喊出“用TBN矩阵!”。是啊!模型空间转换到切线空间谁都能想到要用TBN矩阵,且a2v结构体中多了一项:
float4 tangent : TANGENT;
这就是为了后续进行TBN计算做准备的。
3.1.1 关于TBN,我有三大问!
那么,我就不客气地提出三大问了:
第一,你有搞清楚TBN分别代表着什么吗?你会回答——当然清楚,T即Tangent,切线方向;B即Binormal,副切线(副法线)方向;N即Normal,法线方向;
第二,TBN矩阵中T、B、N到底按列排列,还是按行排列的?你思考了一下,回答——按行?
第三,那我再问你,为什么要按行排列?
我认为这三个问题(特别第三个)十分重要,同时这也涉及到“矩阵与坐标空间转换”这个基本的知识点的应用。
3.1.2 为什么TBN按行排列?
当我们已知,坐标空间A在坐标空间B下的原点O,以及x轴、y轴和z轴三个坐标轴的矢量表示,我们就能构建出坐标空间A的点和矢量 --> 坐标空间B的变换矩阵:把三个坐标轴矢量分别放入矩阵前三列,原点O坐标放入第四列,最下一行填充0,即可。
更进一步,对于矢量变换来说,位置并不重要!因此,直接截取坐标轴矢量的前三列就能完成矢量的变换,即获得一个3X3的变换矩阵。
再进一步!我们知道,这个3X3矩阵的逆矩阵,就是坐标空间B的矢量 --> 坐标空间A的变换矩阵。那么令人兴奋的来了:如果这个3X3的变换矩阵用到的三个坐标轴矢量都是单位矢量 --> 意味着构建出的3X3矩阵是个单位矩阵 --> 意味着这个单位矩阵的转置矩阵==它的逆矩阵!--> 意味着逆矩阵直接把三个矢量从“按列排列”变成“按行排列”就大功告成了!!
3.1.3 令人感动的发明:内置宏
令人感动的是,Unity提供了内置宏TANGENT_SPACE_RPTATION为我们直接计算得到rotation——这个TBN变换矩阵。在代码中我也体现出来了,我们可以进行手动计算rotation:
float3 binormal = cross(normalize(v.normal), normalize(v.tangent.xyz));
float3x3 rotation = float3x3(v,tangent.xyz), binormal, v.normal);
你会发现,它的的确确把矢量都先归一化到单位向量,再进行的向量组合。 如果不想手动计算,我们可以直接简单粗暴列个宏,完事:
TANGENT_SPACE_ROTATION;
3.1.3 对光源和视角方向做变换
由于法线信息是通过采样得到的,因此只需要对viewDir和lightDir做TBN变换:
//viewDir和lightDir换到切线空间下
o.lightDir = mul(rotation, ObjSpaceLightDir(v.vertex)).xyz;
o.viewDir = mul(rotation, ObjSpaceViewDir(v.vertex)).xyz;
3.2 采样获得法线方向
TBN矩阵的事我们说明白了,下一步就是通过采样我们的法线纹理,获得法线纹理储存的切线空间下的法线方向。
3.2.1 对法线纹理进行采样
注意,uv信息中与_BumpMap有关的是(x, y, z, w)中的z和w!
fixed4 packNormal = tex2D(_BumpMap, i.uv.zw);
那么问题又来了,法线纹理储存的是纹素呀!也就是纹素的颜色,就像我们项目中的砖块法线纹理展示出的那样:
是个偏紫蓝色的图片(至于为什么是紫蓝色?看我之前的博客!),但是我们想要的是大小在[-1, 1]内的法线信息,这时候就需要进行——反映射。
3.2.2 UnpackNormal函数
在之前的凹凸映射博客中就讲明了将法线信息存入法线纹理是需要进行映射的,而我们现在是要把信息给取出来,那就需要进行反映射了:
tangentNormal.xy = (packNormal.xy * 2 - 1);
令人感动的是,Unity提供了内置函数UnpackNormal来完成这个操作:
fixed3 tangentNormal = UnpackNormal(packNormal);
3.3 别忘了加上_BumpScale
这一步一切都是基于这个关系进行的:
//加上bumpScale的影响
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));