0.本文示例代码地址
GitHub
1. 法线贴图理论
1.1 什么是法线贴图
一般的贴图中存储的是表面颜色值(RGBA),而法线贴图存放的则是法线信息(xyzw),假设某顶点处的 uv 坐标为 (u,v), 那么在法线贴图 (u,v)处纹素的值表示该顶点的“法线”方向。通常法线贴图中存储的并不是这个顶点的真实法线信息。
1.2 法线贴图的作用
想象一下,如果我们想要表现一个凹凸不平的模型表面(想象一个橙子的表面),有哪些办法呢?
直接把模型做成凹凸不平。这种方法最理想,效果也最好。但是模型需要太多顶点了,例如橙子表面的一个“坑”,需要增加额外的若干个顶点。
做一个一定精度的平滑模型(例如把橙子做成一个球体模型),把表面的”坑“或”凸点“信息,也就是某一点的”海拔“记录下来,渲染的时候根据这些信息动态生成顶点信息,得到凹凸不平的模型。不用说,这种方法需要单独的存储空间来记录凹凸信息,而且顶点动态生成将会非常消耗。
和第二种方法一样,做一个平滑模型,同样记录表面的“海拔”,渲染时不是动态生成顶点,而是根据“海拔”信息反推顶点的法线信息,通过光照效果来表现表面的”凹凸“。这种方法在计算光照时需要先进行表面法线的计算,比较消耗。
同样做一个光滑模型,不是记录表面的凹凸信息本身,而是记录”假定的凹凸情形下的法线信息“,渲染时根据“有偏差”的法线信息来进行光照计算,使得渲染出来的画面看起来凹凸不平。
上面第三种方法称为基于“高度纹理”的凹凸表现。而第四种方法就是基于“法线纹理”的凹凸表现。
注意:高度贴图和法线贴图用来表现“凹凸”,在模型轮廓的边缘会穿帮。比如你可以用这两种方法使一个平滑的橙子模型表面看起来凹凸不平,但是在橙子的边缘总是平滑的。
1.3 法线贴图纹素取值范围
通常贴图纹素用来表示 RGBA,那么每个分量的取值范围是[0,1],而法线的每个分量取值范围为[-1,1],所以用贴图纹素表示一个法线时,需要针对每一个分量做映射
pixel = (normal + 1) / 2;
在针对法线贴图采样后,进行逆运算
normal = 2 * pixel - 1;
得到实际的法线分量值。
1.4 法线贴图基于什么坐标系
法线贴图储存了表面法线,而法线是一个方向,那么这个方向是基于什么坐标系?通常跟随顶点数据一起传输到 顶点着色器中的法线,由 NORMAL 语义指定,是基于模型坐标系的。所以我们可以将法线在模型坐标中的值存储到法线贴图中,得到模型空间的法线贴图,而在实际制作中,应用更多的是顶点切线空间的法线贴图。
对于每个顶点,以顶点自身作为原点,顶点切线方向为x轴,法线方向为z轴,切线和法线方向叉乘得到 y 轴(副法线方向),得到这个顶点的 切线坐标空间,基于这个空间的法线记录下来得到 顶点切线空间的法线贴图。
模型空间法线贴图的优点
(1)实现简单,直观
(2)更平滑的缝合和边界处的表现。切线空间法线贴图的优点
(1)可重用,记录的是“相对法线信息”,而模型空间的法线贴图记录的是“绝对法线信息”。
(2)可以做 UV 动画来实现凹凸移动效果。
(3)可压缩。z分量永远是正方向,可以只存储xy分量。
1.5 为什么切线空间的法线贴图看起来都是偏蓝色的?
切线空间的法线贴图保存的是基于顶点的切线空间中的法线数值,而在顶点的切线空间中,真实法线的反向永远是(0,0,1),经过上述的计算公式得到法线贴图中存储的值为 (0.5,0.5, 1)
,偏蓝色。而修改后的法线通常也是 z 值最大,因为你不太可能有90度以上的法线修改,整体还是偏蓝。
通常使用顶点切线空间的法线贴图,而顶点空间中的修改后的法线值,z分量最大,换算成颜色就是 b 分量最大,所以法线贴图通常看起来偏蓝色。
2. 如何在 Shader 中应用法线贴图
我们使用在切线空间下的法线贴图,先上完整 shader 代码,然后逐步分析,代码如下:
Shader "Shader_Examples/04_NormalTexture_TangentSpace"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_SpecularColor ("SpecularColor", Color) = (1,1,1,1)
_Gloss ("Gloss", Range(8, 256)) = 20
_BumpTex ("BumpTex", 2D) = "bump" {}
_BumpScale ("BumpScale", Float) = 1.0
}
SubShader
{
Tags { "RenderType"="Opaque" "LightMode" = "ForwardBase" }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "Lighting.cginc"
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _SpecularColor;
float _BumpScale;
sampler2D _BumpTex;
float4 _BumpTex_ST;
float _Gloss;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float4 tangent : TANGENT;
float3 normal : NORMAL;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float3 lightDir : TEXCOORD1;
float3 viewDir : TEXCOORD2;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
// 模型空间副法线
fixed3 binormal = cross(normalize(v.normal), normalize(v.tangent.xyz)) * v.tangent.w;
float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);
float3 lightDir = ObjSpaceLightDir(v.vertex);
float3 viewDir = ObjSpaceViewDir(v.vertex);
o.lightDir = mul(rotation, lightDir);
o.viewDir = mul(rotation, viewDir);
o.uv = v.uv;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
float3 lightDir = normalize(i.lightDir);
float3 viewDir = normalize(i.viewDir);
float3 halfDir = normalize(lightDir + viewDir);
float4 packedNormal = tex2D(_BumpTex, i.uv);
float3 tangentNormal = UnpackNormal(packedNormal);
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
fixed3 albedo = tex2D(_MainTex, i.uv).rgb;
fixed3 diffuse = _LightColor0.rgb * albedo.rgb * saturate(dot(tangentNormal, lightDir));
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb * albedo;
fixed3 specular = _SpecularColor * _LightColor0 * pow(saturate(dot(halfDir, tangentNormal)), _Gloss);
return fixed4(diffuse + ambient + specular, 1.0);
}
ENDCG
}
}
}
渲染效果如图:
2.1 shader 属性与对应的变量
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_SpecularColor ("SpecularColor", Color) = (1,1,1,1)
_Gloss ("Gloss", Range(8, 256)) = 20
_BumpTex ("BumpTex", 2D) = "bump" {}
_BumpScale ("BumpScale", Float) = 1.0
}
漫反射纹理 _MainTex, 高光颜色 _SpecularColor 和高光系数 _Gloss 没什么好说的,新增的纹理 _BumpTex 为法线贴图,默认值为 unity 内置法线贴图 "bump",_BumpScale 用来控制表面的“凹凸”程度,后面会分析它是怎么起作用的。对应的变量声明:
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _SpecularColor;
float _BumpScale;
sampler2D _BumpTex;
float4 _BumpTex_ST;
float _Gloss;
2.2 着色器输入结构
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float4 tangent : TANGENT;
float3 normal : NORMAL;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float3 lightDir : TEXCOORD1;
float3 viewDir : TEXCOORD2;
};
- 语义
TANGENT
指定的切线是一个 float4 类型的变量,而语义NORMAL
指定的法线是 float3 类型,因为TANGENT
的z分量需要用来确定 副法线 的方向,下一个段落会介绍如何计算副法线 - 因为使用了顶点切线空间下的法线贴图,我们需要把所有的光照计算都变换到顶点切线空间下,在顶点着色器中将光线方向
lightDir
和视线方向viewDir
变换到顶点切线空间,再输入到片元着色器中。 - 因为我们这里没有涉及到纹理的 ST 变化,所以 _MainTex 和 _BumpTex 功用纹理坐标
- v2f 中并没有定义法线,因为我们这里使用的是发现贴图中的法线,而不直接使用顶点法线了
2.3 顶点着色器
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
// 模型空间副法线
fixed3 binormal = cross(normalize(v.normal), normalize(v.tangent.xyz)) * v.tangent.w;
// 模型空间到顶点切线空间的变换矩阵
float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);
// 光线方向和视线防线变换到顶点切线空间
float3 lightDir = ObjSpaceLightDir(v.vertex);
float3 viewDir = ObjSpaceViewDir(v.vertex);
o.lightDir = mul(rotation, lightDir);
o.viewDir = mul(rotation, viewDir);
o.uv = v.uv;
return o;
}
- 顶点的法线:顶点所在的所有平面的法线加权平均,得到顶点法线
- 顶点的切线:我们都知道顶点切线与顶点法线垂直、但与顶点法线垂直的方向有很多?哪一条是顶点切线呢?约定俗成 切线最终规定为顶点 uv 坐标中的 u 方向,可以参考文末的参考文章1。
- 顶点的副法线:由法线和切线叉乘得到,方向性由顶点切线的z分量确定。
- 如何计算模型空间到顶点切线空间的变换矩阵:参考我的推导过程模型空间到顶点切线空间变换矩阵的推导。结论就是:将模型空间下的切线、副法线、法线按行排列得到变换矩阵。
- 在顶点着色器中将光线方向和视线方向变换到顶点的切线空间并传递给片元着色器。
2.4 片元着色器
fixed4 frag (v2f i) : SV_Target
{
float3 lightDir = normalize(i.lightDir);
float3 viewDir = normalize(i.viewDir);
float3 halfDir = normalize(lightDir + viewDir);
float4 packedNormal = tex2D(_BumpTex, i.uv);
float3 tangentNormal = UnpackNormal(packedNormal);
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
fixed3 albedo = tex2D(_MainTex, i.uv).rgb;
fixed3 diffuse = _LightColor0.rgb * albedo.rgb * saturate(dot(tangentNormal, lightDir));
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb * albedo;
fixed3 specular = _SpecularColor * _LightColor0 * pow(saturate(dot(halfDir, tangentNormal)), _Gloss);
return fixed4(diffuse + ambient + specular, 1.0);
}
- 如何从法线贴图中得到法线:tex2D采样 _BumpTex 得到该点的法线像素值,需要计算出对应的xyz值,因我们已经在 Unity 编辑器中将 _BumpTex 设置为 "Normal Map" ,所以内置方法 UnpackNormal 已经执行了这个计算
- albedo,diffuse,ambient,specular 的计算不用多说了
- _BumpScale 的作用:用来控制“凹凸程度”,当 _BumpScale 为0时,表示该点的顶点法线和法线贴图中采样出的法线重合,说明该点没有“凹凸”,_BumpScale 绝对值越大,表示该点的顶点法线和贴图中的法线偏差越远,说明“凹凸感”越明显。
下面5个胶囊体的 _BumpScale 取值分别为 2/1/0/-1/-2
3. Unity中的法线贴图类型设置
在上面的片元着色器中,我们从法线贴图中采样出纹素后,使用了 Unity 内置函数 UnpackNormal
来计算最终的法线值。只有正确的设置图片的类型为 "Normal Map" 时,使用这个内置函数才能得到正确结果,在 Unity 中的设置面板如下:
- Create from Grayscale 表示是否“高度图”生成的纹理贴图。当我们在贴图中记录的是相对高度(黑色表示更低,白色表示更高)时,除了要设置类型为“Normal Map”之外,还要勾选这个选项,这个贴图就会被当成纹理贴图使用了。
- 勾选了 Create from Grayscale 之后,有两个选项:bumpness表示凹凸程度,filtering 决定了如何生成纹理贴图,smooth 表示生成的法线过渡比较平滑,而sharp 则表示法线过渡比较锋利。
参考文章:
1. 关于顶点法线、切线和副法线
2. 模型空间到顶点切线空间变换矩阵的推导