前言
本篇将介绍什么是法线贴图(normal map),为什么需要它,它是如何实现的,切线空间及从切线空间和世界空间间的变换
什么是法线贴图
法线贴图也属于纹理贴图,它是基于凹凸贴图衍生出来的,只不过纹理贴图中的纹素是RGB颜色值,而法线贴图中的纹素是法向量的坐标
如图所示,TBN坐标系(稍后讲解)中的细箭头即为存储的法线
为什么需要法线贴图呢?
-
动机:法线的用途是计算光照,在纹理图中存储法向量,再将其带入光照计算,这看上去没有什么优势啊?事实上,这种方法有很强大的优化效果。想象一个场景,我们需要物体有真实的凹凸感,而凹凸感是光照体现的,那么我们需要法线密度高且它们的方向并不大致相同。第一想法是我们可以通过
高模建模
,在计算法向量时每个面的法线方向不同,由于各个面的光照计算结果不同且面非常多,这肯定是会产生凹凸感。但该方法开销是很大很大的,因此我们需要想一种方法——既能表现真实的凹凸感,也能大大降低开销,没错这种方法就是法线贴图 -
法线贴图的实现原理:法线贴图保存高模的法线信息提供给低模使用,这样低模就相当于有和高模那样的法线,在后续过程中计算法向量就相当于是计算高模的法向量
-
普通的纹理贴图不能实现吗?也许有小伙伴会问,直接给物体贴一个自带凹凸感的贴图不可以吗?这种方式虽然有一定的凹凸感,但不真实,很容易被识破。这是因为纹理下的网格过于平滑,仅仅将带有凹凸感的纹理绘制到光滑的柱面是不够的,因为光照是基于网格三角形的而非纹理图片
如下两张图,第一张图是普通的纹理贴图,可以看到确实很假,像一张贴图;第二张图是法线贴图,可以看到该物体就具有真实的凹凸感
压缩和存储法线图
-
法向量转换为图像格式:由于纹理图的图像格式范围一般是[0,255],而归一化法向量的范围是[-1,1],因此将法向量存于纹理图中需要进行一定的变换,公式如下:\(\large f(x) = (0.5x + 0.5) * 255\)
-
图像格式转换为法向量:\(\large f^{-1}(x) = \frac{2x}{255} - 1\)
-
存储法线图:若用压缩纹理的格式来存储法线图,BC7(
DXGI_FORMAT_BC7_UNORM
)图像格式的质量最佳,它大大减少了因压缩法线图而带来的误差
采样法线图
对法线图进行采样的方法如下:
float3 normalT = gNormalMap.Sample( gTriLinearSam, pin.Tex );
此时已经获取[0,1]范围内的坐标,但坐标的解压工作并没有完成,我们还需要将[0,1]范围内的坐标变化至[-1,1],该变换公式如下:\(\large g(x) = 2x - 1\)
normalT = 2.0f*normalT - 1.0f;
切线空间
-
定义:切线空间是一个3d纹理坐标系,xy轴分别对应uv轴,z轴为法向量,且这三条轴两两相切,但我们将这xyz轴称作TBN轴,我们称T轴为切线(tangent)、B轴为副切线( binormal)、N轴为法线.该空间针对特定三角形
-
TBN矩阵?我们需要将纹理映射至3D三角形上,而一般来说计算光照是在世界空间中进行计算,但法线图中的法向量存在于切线空间中,因此TBN矩阵定义了将纹理从切线空间转换至世界空间的过程。TBN矩阵仅仅在意向量的变化,因此这是个\(3\times3\)的矩阵
-
从uv空间到局部空间的推导
如上图所示,设纹理坐标分别为\(\large (u_0, v_0),(u_1, v_1),(u_2, v_2)\)的顶点\(\large v_0, v_1, v_2\)在切线空间中定义了一个纹理三角形,\(\large e_0 = v_1 - v_0\),\(\large e_1 = v_2 - v_0\)为3D三角形(非uv空间中)的两个边向量,它们所对应的切线空间中的边向量分别为\((\Delta_0 , \Delta v_0) = (u_1 - u_0, v_1 - v_0),(\Delta_1 , \Delta v_1) = (u_2 - u_0, v_2 - v_0)\)
用到局部空间的坐标变化来表示:\(\large \left[\begin {array} {cc} e_{0,x} & e_{0,y} & e_{0,z} \\ \large e_{1,x} & e_{1,y} & e_{1,z} \end {array} \right] = \left[\begin {array} {cc} \Delta_{u_0} & \Delta_{v_0} \\ \large \Delta_{u_1} & \Delta_{v_1} \end {array} \right] \left[\begin {array} {cc} T_{x} & T_{y} & T_{z} \\ \large B_{x} & B_{y} & B_{z} \end {array} \right]\).因为我们已经知道局部空间和uv的坐标,那TBN矩阵肯定是可以求得,需要对uv矩阵求逆:
\(\large \left[\begin {array} {cc} T_{x} & T_{y} & T_{z} \\ \large B_{x} & B_{y} & B_{z} \end {array} \right] = \large \left[\begin {array} {cc} \Delta_{u_0} & \Delta_{v_0} \\ \large \Delta_{u_1} & \Delta_{v_1} \end {array} \right]^{-1} \large \left[\begin {array} {cc} e_{0,x} & e_{0,y} & e_{0,z} \\ \large e_{1,x} & e_{1,y} & e_{1,z} \end {array} \right] \large = \frac{1}{\Delta_{u_0} \Delta{v_1} - \Delta{v_0}\Delta{u_1}} \left[\begin {array} {cc} \Delta_{v_1} & -\Delta_{v_0} \\ \large -\Delta{u_1} & \Delta_{u_0} \end {array} \right] \left[\begin {array} {cc} e_{0,x} & e_{0,y} & e_{0,z} \\ \large e_{1,x} & e_{1,y} & e_{1,z} \end {array} \right]\)上述过程使用了逆矩阵的性质:矩阵\(A = \left[\begin {array} {cc} a & b \\ \large c & d \end {array} \right]\),有\(\large A^{-1} = \frac{1}{ad - bc} \left[\begin {array} {cc} d & -b \\ \large -c & a \end {array} \right]\)
-
顶点切线空间
- 定义:在每个顶点处指定
切向量
,并对顶点求平均(共用该顶点的所有三角形的切向量的平均值)并进行归一化,使这三个向量依然两两正交且含有单位长度(可使用Gram-Schmidt).令顶点法线趋于平滑的表面 - 为什么需要它:虽然我们已经知道任意三角形所对应的切线空间,但该切线空间存在一定缺点——在法线贴图时使用该切线空间,会导致渲染结果呈现明显的三角形
划分痕迹
。这是因为切线空间必定位于对应三角形的所在平面,这会导致图面不够平滑
,也就是说三角形和三角形的共用边
- 定义:在每个顶点处指定
-
世界空间和切线空间的变换
-
局部空间到切线空间
在确定网格三角形每个顶点处的正交归一化TBN基,且已求出切线空间到局部空间的变换矩阵:\(\large M_{object} = \left[\begin {array} {cc} T_x & T_y & T_z \\\large B_x & B_y & B_z \\ \large N_x & N_y & N_z \end {array} \right]\),由于该矩阵为正交矩阵,
因此它的逆矩阵就是它的转置
:\(\left[\begin {array} {cc} T_x & B_x & N_x \\ \large T_y & B_y & N_y \\ \large T_z & B_z & N_z \end {array} \right]\) -
切线空间到世界空间
一般而言需要将法向量变换至世界空间下进行光照计算:\(n_{world} = (n_{tangent} M_{object})M_{world} = n_{tangent}(M_{object} M_{world})\),尤其已经知道局部空间下的法向量坐标,因此再乘上一个世界矩阵即可
-
-
注意点:一般切向量T和B在
局部空间中不是单位长度
,且若纹理发生扭曲形变
,这两个向量也不是正交的归一化向量
实现
法线贴图的流程:
- 美术制作需要的法线图,将其存于图像文件中
- 在app初始化时加载这些图像,并用其创建2d纹理
- 对于每个三角形而言,计算它的切向量T(求平均值)。但若是规则的三角形网格,则可以直接指定切向量,而无需求平均
- 在VS中,将顶点法线和切向量变换至世界空间,并将结果输出至PS
- 通过插值切向量和法向量来构建三角形表面每个像素点处的TBN基,再将该TBN基从切线空间变换至世界空间
实现:
-
将法线图变换至世界空间
float3 NormalSampleToWorldSpace(float3 normalMapSample, float3 unitNormalW, float3 tangentW) { //解压坐标分量,从[0,1]到[-1,1] float3 normalT = 2.0f*normalMapSample - 1.0f; // 构建正交规范基。因为插值后TN向量可能为非正交规范向量 float3 N = unitNormalW; float3 T = normalize(tangentW - dot(tangentW, N)*N); float3 B = cross(N, T); float3x3 TBN = float3x3(T, B, N); float3 bumpedNormalW = mul(normalT, TBN); return bumpedNormalW; }
构建正交规范基
-
整体实现
struct VertexIn { //... float3 TangentU : TANGENT; }; struct VertexOut { //... float3 NormalW : NORMAL; }; VertexOut VS(VertexIn vin) { //... // 若此处不是等比缩放,需要使用世界矩阵的逆转置矩阵 vout.NormalW = mul(vin.NormalL, (float3x3)gWorld); vout.TangentW = mul(vin.TangentU, (float3x3)gWorld); //... } float4 PS(VertexOut pin) : SV_Target { //... // 插值后法线可能非归一化向量 pin.NormalW = normalize(pin.NormalW); float4 normalMapSample = gTextureMaps[normalMapIndex].Sample(gsamAnisotropicWrap, pin.TexC); float3 bumpedNormalW = NormalSampleToWorldSpace(normalMapSample.rgb, pin.NormalW, pin.TangentW); //... const float shininess = (1.0f - roughness) * normalMapSample.a; Material mat = { diffuseAlbedo, fresnelR0, shininess }; float3 shadowFactor = 1.0f; float4 directLight = ComputeLighting(gLights, mat, pin.PosW, bumpedNormalW, toEyeW, shadowFactor); float4 litColor = ambient + directLight; // Add in specular reflections. float3 r = reflect(-toEyeW, bumpedNormalW); float4 reflectionColor = gCubeMap.Sample(gsamLinearWrap, r); float3 fresnelFactor = SchlickFresnel(fresnelR0, bumpedNormalW, r); litColor.rgb += shininess * fresnelFactor * reflectionColor.rgb; // Common convention to take alpha from diffuse albedo. litColor.a = diffuseAlbedo.a; return litColor; }
reference
Directx12 3D 游戏开发实战