需要预先学习:


1. 凹凸感的来源

Phong模型与Blinn-Phong模型 的光照计算只依赖一个输入描述表面形状:法线(normal)。一个表面看起来是平的还是坑坑洼洼的,本质上取决于法线在表面上如何变化——砖墙缝隙处法线突然翻转,凹陷处法线朝向内倾,人眼把这些明暗变化解读为"凹凸"。

要让一面砖墙真的有凹凸,最直接的办法是建出缝隙的几何:每个砖块、每条沟壑都用三角形描述。代价是三角形数量爆炸——一面墙几十万三角形,剔除总结 与 前向渲染与延迟渲染 里讨论的所有开销跟着翻倍。而仔细想想,这些三角形只是提供了"法线在像素间变化"这一件事。

法线贴图(normal mapping)的思路:几何保持平滑(低多边形),把逐像素的法线存进一张纹理,着色时采样它代替插值法线。凹凸感来自法线变化,而不是几何变化——一张图就能骗过光照计算。

方案 三角形数量 细节层级 代价
几何建模凹凸 极多 顶点级 顶点处理、带宽、内存
顶点法线 少 顶点级 细节受顶点密度限制
法线贴图 少 像素级 一张纹理 + 每像素几次运算

注:法线贴图不会改变轮廓——一颗砖墙的侧影依然是平板一块,缝隙在掠射角的轮廓上看不出深度。它是"光照层面的欺骗",近看与侧面观察时会穿帮,因此常与足够多的几何细节(中模)配合使用。


2. 法线贴图存的是什么

法线是单位向量,三个分量范围 [−1,1][-1, 1];纹理纹素是无符号字节,范围 [0,1][0, 1]。存储时做一个线性映射:

npixel=n+12×255n_{pixel} = \frac{n + 1}{2} \times 255

采样时再映射回去:

n=texel×2−1n = texel \times 2 - 1

一张典型的法线贴图整体呈现淡蓝色:绝大部分纹素的 RGB 接近 (128,128,255)(128, 128, 255)。映射回法线就是 (0,0,1)(0, 0, 1)——大部分表面点的法线都"朝向纹理平面自身",只有凹凸处的法线偏离这个方向。这个淡蓝色是检查一张图是不是法线贴图的最快特征。

// 片元着色器:采样并解码法线
vec3 normal = texture(normalMap, TexCoords).rgb;
normal = normalize(normal * 2.0 - 1.0);   // [0,1] -> [-1,1] 并重新归一化

注意:解码后必须重新 normalize。8bit 量化与纹理过滤(见 纹理 的双线性插值)都会破坏单位长度,直接参与点积(如 n⋅l\mathbf{n} \cdot \mathbf{l})会让光照强度忽明忽暗。


3. 切线空间

采样出来的法线 (0,0,1)(0, 0, 1) 朝哪个方向?这取决于法线贴图存储的坐标空间。三种选择对比:

存储空间 法线分量含义 问题
世界空间 相对世界坐标轴 物体一旋转、光照一变化,贴图就全部失效;一张贴图只能用于一个固定朝向
模型空间 相对模型坐标轴 可跟随物体旋转,但一张贴图绑定一个模型姿态,UV 翻转、镜像缩放全部失效
切线空间 相对"贴图所在表面"的局部坐标 与世界、模型姿态完全无关,同一张贴图任意复用

切线空间(tangent space)是为每个表面点定义的局部坐标系,三个轴:

轴 名称 方向
NN 法线(normal) 垂直于表面,即贴图的 zz 轴
TT 切线(tangent) 沿纹理 uu 增大的方向在表面上的投影,即贴图的 xx 轴
BB 副切线(bitangent,也称 binormal) 沿纹理 vv 增大的方向,即贴图的 yy 轴

直觉:把贴图"平铺"在表面上,TT 是贴图上向右的方向,BB 是贴图上向上的方向,NN 是贴图垂直冒出来的方向。法线贴图里的 (0,0,1)(0, 0, 1) 表示"与贴图垂直",正是平滑表面的默认法线;凹凸处的法线在切线空间里偏离 zz 轴,产生明暗变化。

切线空间的三大好处,也是它成为工业标准的原因:

  • 贴图可复用:同一张砖墙法线贴图可以贴到任何物体、任何朝向的墙上;
  • 物体可变形:角色动画扭曲网格时,切线空间跟着表面走,法线始终正确;
  • 可镜像:UV 翻转复用贴图时切线空间自动适配(对称贴图省一半显存)。

4. TBN 矩阵的构造

切线空间只是定义,渲染时需要把它变成一个矩阵参与计算。把 TT、BB、NN 三个向量按列排成 3×33 \times 3 矩阵:

TBN=(TxBxNxTyByNyTzBzNz)TBN = \begin{pmatrix} T_x & B_x & N_x \\ T_y & B_y & N_y \\ T_z & B_z & N_z \end{pmatrix}

TBN 矩阵的几何意义是坐标变换:切线空间中的一个向量左乘 TBN,得到它在世界空间的表示。采样到的法线是切线空间坐标,TBN⋅ntangentTBN \cdot n_{tangent} 就把它变换到世界空间,参与光照计算。

4.1 从三角形顶点推导

TT 与 BB 的定义锚定在 UV 上,而 UV 是逐顶点属性——推导就从单个三角形出发。设三角形顶点 P0P_0、P1P_1、P2P_2,位置为 PiP_i,纹理坐标为 (ui,vi)(u_i, v_i)。两条边可以同时用位置差和 UV 差表示:

E1=P1−P0=Δu1T+Δv1BE_1 = P_1 - P_0 = \Delta u_1 T + \Delta v_1 B

E2=P2−P0=Δu2T+Δv2BE_2 = P_2 - P_0 = \Delta u_2 T + \Delta v_2 B

其中 Δu1=u1−u0\Delta u_1 = u_1 - u_0,其余同理。写成矩阵形式:

(E1xE1yE1zE2xE2yE2z)=(Δu1Δv1Δu2Δv2)(TxTyTzBxByBz)\begin{pmatrix} E_{1x} & E_{1y} & E_{1z} \\ E_{2x} & E_{2y} & E_{2z} \end{pmatrix} = \begin{pmatrix} \Delta u_1 & \Delta v_1 \\ \Delta u_2 & \Delta v_2 \end{pmatrix} \begin{pmatrix} T_x & T_y & T_z \\ B_x & B_y & B_z \end{pmatrix}

右乘 UV 差矩阵的逆,解出 TT、BB:

(TxTyTzBxByBz)=1Δu1Δv2−Δu2Δv1(Δv2−Δv1−Δu2Δu1)(E1xE1yE1zE2xE2yE2z)\begin{pmatrix} T_x & T_y & T_z \\ B_x & B_y & B_z \end{pmatrix} = \frac{1}{\Delta u_1 \Delta v_2 - \Delta u_2 \Delta v_1} \begin{pmatrix} \Delta v_2 & -\Delta v_1 \\ -\Delta u_2 & \Delta u_1 \end{pmatrix} \begin{pmatrix} E_{1x} & E_{1y} & E_{1z} \\ E_{2x} & E_{2y} & E_{2z} \end{pmatrix}

// 伪代码:由三角形顶点位置与 UV 计算切线
Vec3 edge1 = p1 - p0;
Vec3 edge2 = p2 - p0;
float du1 = uv1.x - uv0.x, dv1 = uv1.y - uv0.y;
float du2 = uv2.x - uv0.x, dv2 = uv2.y - uv0.y;

float inv_det = 1.0f / (du1 * dv2 - du2 * dv1);

Vec3 tangent;
tangent.x = inv_det * ( dv2 * edge1.x - dv1 * edge2.x);
tangent.y = inv_det * ( dv2 * edge1.y - dv1 * edge2.y);
tangent.z = inv_det * ( dv2 * edge1.z - dv1 * edge2.z);
tangent.normalize();

每个三角形的切线算出来后,像处理顶点法线一样(见 几何体):共享该顶点的三角形切线按面积或角度加权平均,得到平滑的逐顶点切线。Assimp 加载模型时设置 aiProcess_CalcTangentSpace 可以直接拿到这一步的结果。

4.2 Gram-Schmidt 正交化

按 UV 推出的 TT、BB 与顶点法线 NN 不一定严格正交(插值平均、建模误差都会破坏正交性),使用前做一次Gram-Schmidt 正交化(Gram-Schmidt orthogonalization):

T′=normalize(T−N⋅(N⋅T))T' = \text{normalize}(T - N \cdot (N \cdot T))

B′=cross(N,T′)B' = \text{cross}(N, T')

由叉积直接求 BB,保证三个轴两两正交且构成右手系:

// 片元着色器:由顶点属性 T 与 N 正交化出 TBN
vec3 N = normalize(Normal);
vec3 T = normalize(Tangent - dot(Tangent, N) * N);   // 去掉 N 方向分量
vec3 B = cross(N, T);

mat3 TBN = mat3(T, B, N);    // 按列构造,mat3(T, B, N) 的三列分别是 T、B、N

注:BB 的方向依赖 UV 的朝向与手性。若模型存在镜像 UV(贴图左右翻转复用,常见于对称角色),一半顶点的 BB 会指向相反方向,正交化时统一用 cross(N,T)\text{cross}(N, T) 会翻转这一半的切线空间。工程做法是顶点存一个手性符号 w=±1w = \pm 1,计算 B=w⋅cross(N,T)B = w \cdot \text{cross}(N, T),或直接把 BB 也存成顶点属性。


5. 在着色器中使用

法线贴图的采样发生在片元着色器,光照计算(Phong模型与Blinn-Phong模型、PBR)需要一个与世界空间光源、视线可运算的法线。两种等价做法。

5.1 做法一:把法线变换到世界空间

用 TBN 矩阵左乘采样到的切线空间法线:

#version 330 core

layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoord;
layout (location = 3) in vec3 aTangent;    // 新增顶点属性:切线

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

out vec3 FragPos;
out vec3 Normal;
out vec3 Tangent;       // 世界空间切线
out vec2 TexCoords;

void main()
{
    FragPos   = vec3(model * vec4(aPos, 1.0));

    // 法线与切线都必须用正规矩阵变换(见 [[Phong模型与Blinn-Phong模型]])
    Normal  = mat3(transpose(inverse(model))) * aNormal;
    Tangent = mat3(transpose(inverse(model))) * aTangent;

    TexCoords = aTexCoord;
    gl_Position = projection * view * vec4(FragPos, 1.0);
}
// 片元着色器(节选):法线贴图采样 + 变换到世界空间
in vec3 FragPos;
in vec3 Normal;
in vec3 Tangent;
in vec2 TexCoords;

uniform sampler2D normalMap;

vec3 getNormal()
{
    vec3 N = normalize(Normal);
    vec3 T = normalize(Tangent - dot(Tangent, N) * N);
    vec3 B = cross(N, T);

    // 采样并解码:[0,1] -> [-1,1]
    vec3 n = texture(normalMap, TexCoords).rgb * 2.0 - 1.0;
    n = normalize(n);

    // 切线空间 -> 世界空间
    return normalize(mat3(T, B, N) * n);
}

得到的 getNormal() 结果直接替换 Phong模型与Blinn-Phong模型 中插值而来的 normal,光照代码一行都不用改。

5.2 做法二:把光照搬进切线空间

反向思路:不移动法线,把光源方向、视线方向都用 TBN−1TBN^{-1} 变换到切线空间,整个光照计算在切线空间完成:

// 片元着色器(节选):光照在切线空间进行
vec3 N = normalize(Normal);
vec3 T = normalize(Tangent - dot(Tangent, N) * N);
vec3 B = cross(N, T);

mat3 TBN = mat3(T, B, N);

// 世界空间 -> 切线空间:乘逆矩阵
vec3 lightDir_t = normalize(TBN * lightPos - TBN * FragPos);    // 位置也可先变换再作差
vec3 viewDir_t  = normalize(TBN * viewPos - TBN * FragPos);

vec3 n = normalize(texture(normalMap, TexCoords).rgb * 2.0 - 1.0);

// 后续光照计算全部使用 lightDir_t / viewDir_t / n,模型不变

两种做法的对比:

做法一:法线到世界空间 做法二:光照到切线空间
每像素一次矩阵乘(变换法线) 每像素变换 lightDir、viewDir 等
与所有世界空间数据直接兼容 世界空间贴图(如 IBL 环境贴图)必须额外变回世界空间采样
PBR 管线常用(反射方向要在世界空间查环境) 光源多时反而省(一次变换,多个光源共用)

注:TBN 由正交化后的正交向量构成,正交矩阵的逆等于转置(见 变换),所以 TBN−1=TBNTTBN^{-1} = TBN^{T},transpose(TBN) 就是逆,不需要真正的矩阵求逆。GLSL 里对 mat3 求逆也有 inverse() 内置函数,但转置更便宜。


6. 常见问题

问题 现象 原因与处理
贴图整体淡蓝变成全灰 没有凹凸效果 法线贴图被当成颜色纹理加载成了 sRGB 格式,解码扭曲了数值(见 伽马校正:数据贴图必须是线性格式)
光照忽明忽暗 表面噪声闪烁 采样后忘记重新归一化
一半模型凹凸方向相反 镜像 UV 穿帮 BB 的手性(ww 符号)没有处理
非等比缩放后光照错乱 物体压扁时明暗错位 切线没有像法线一样用正规矩阵(逆转置)变换
顶点没有 tangent 属性 无法构造 TBN 建模导出时勾选切线,或加载时让 Assimp 计算(aiProcess_CalcTangentSpace),或由邻面 UV 现算

注:法线贴图是数据纹理而非颜色纹理,相关的存储与压缩也有专门格式——BC5(双通道块压缩,见 纹理压缩格式)把 xx、yy 存进两个独立块,zz 由 z=1−x2−y2z = \sqrt{1 - x^2 - y^2} 重建,精度远高于把法线塞进普通颜色压缩格式。这也是把法线贴图与漫反射贴图分开压缩的原因。