法线贴图与TBN矩阵
需要预先学习:
1. 凹凸感的来源
Phong模型与Blinn-Phong模型 的光照计算只依赖一个输入描述表面形状:法线(normal)。一个表面看起来是平的还是坑坑洼洼的,本质上取决于法线在表面上如何变化——砖墙缝隙处法线突然翻转,凹陷处法线朝向内倾,人眼把这些明暗变化解读为"凹凸"。
要让一面砖墙真的有凹凸,最直接的办法是建出缝隙的几何:每个砖块、每条沟壑都用三角形描述。代价是三角形数量爆炸——一面墙几十万三角形,剔除总结 与 前向渲染与延迟渲染 里讨论的所有开销跟着翻倍。而仔细想想,这些三角形只是提供了"法线在像素间变化"这一件事。
法线贴图(normal mapping)的思路:几何保持平滑(低多边形),把逐像素的法线存进一张纹理,着色时采样它代替插值法线。凹凸感来自法线变化,而不是几何变化——一张图就能骗过光照计算。
| 方案 | 三角形数量 | 细节层级 | 代价 |
|---|---|---|---|
| 几何建模凹凸 | 极多 | 顶点级 | 顶点处理、带宽、内存 |
| 顶点法线 | 少 | 顶点级 | 细节受顶点密度限制 |
| 法线贴图 | 少 | 像素级 | 一张纹理 + 每像素几次运算 |
注:法线贴图不会改变轮廓——一颗砖墙的侧影依然是平板一块,缝隙在掠射角的轮廓上看不出深度。它是"光照层面的欺骗",近看与侧面观察时会穿帮,因此常与足够多的几何细节(中模)配合使用。
2. 法线贴图存的是什么
法线是单位向量,三个分量范围 ;纹理纹素是无符号字节,范围 。存储时做一个线性映射:
采样时再映射回去:
一张典型的法线贴图整体呈现淡蓝色:绝大部分纹素的 RGB 接近 。映射回法线就是 ——大部分表面点的法线都"朝向纹理平面自身",只有凹凸处的法线偏离这个方向。这个淡蓝色是检查一张图是不是法线贴图的最快特征。
// 片元着色器:采样并解码法线
vec3 normal = texture(normalMap, TexCoords).rgb;
normal = normalize(normal * 2.0 - 1.0); // [0,1] -> [-1,1] 并重新归一化
注意:解码后必须重新
normalize。8bit 量化与纹理过滤(见 纹理 的双线性插值)都会破坏单位长度,直接参与点积(如 )会让光照强度忽明忽暗。
3. 切线空间
采样出来的法线 朝哪个方向?这取决于法线贴图存储的坐标空间。三种选择对比:
| 存储空间 | 法线分量含义 | 问题 |
|---|---|---|
| 世界空间 | 相对世界坐标轴 | 物体一旋转、光照一变化,贴图就全部失效;一张贴图只能用于一个固定朝向 |
| 模型空间 | 相对模型坐标轴 | 可跟随物体旋转,但一张贴图绑定一个模型姿态,UV 翻转、镜像缩放全部失效 |
| 切线空间 | 相对"贴图所在表面"的局部坐标 | 与世界、模型姿态完全无关,同一张贴图任意复用 |
切线空间(tangent space)是为每个表面点定义的局部坐标系,三个轴:
| 轴 | 名称 | 方向 |
|---|---|---|
| 法线(normal) | 垂直于表面,即贴图的 轴 | |
| 切线(tangent) | 沿纹理 增大的方向在表面上的投影,即贴图的 轴 | |
| 副切线(bitangent,也称 binormal) | 沿纹理 增大的方向,即贴图的 轴 |
直觉:把贴图"平铺"在表面上, 是贴图上向右的方向, 是贴图上向上的方向, 是贴图垂直冒出来的方向。法线贴图里的 表示"与贴图垂直",正是平滑表面的默认法线;凹凸处的法线在切线空间里偏离 轴,产生明暗变化。
切线空间的三大好处,也是它成为工业标准的原因:
- 贴图可复用:同一张砖墙法线贴图可以贴到任何物体、任何朝向的墙上;
- 物体可变形:角色动画扭曲网格时,切线空间跟着表面走,法线始终正确;
- 可镜像:UV 翻转复用贴图时切线空间自动适配(对称贴图省一半显存)。
4. TBN 矩阵的构造
切线空间只是定义,渲染时需要把它变成一个矩阵参与计算。把 、、 三个向量按列排成 矩阵:
TBN 矩阵的几何意义是坐标变换:切线空间中的一个向量左乘 TBN,得到它在世界空间的表示。采样到的法线是切线空间坐标, 就把它变换到世界空间,参与光照计算。
4.1 从三角形顶点推导
与 的定义锚定在 UV 上,而 UV 是逐顶点属性——推导就从单个三角形出发。设三角形顶点 、、,位置为 ,纹理坐标为 。两条边可以同时用位置差和 UV 差表示:
其中 ,其余同理。写成矩阵形式:
右乘 UV 差矩阵的逆,解出 、:
// 伪代码:由三角形顶点位置与 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 推出的 、 与顶点法线 不一定严格正交(插值平均、建模误差都会破坏正交性),使用前做一次Gram-Schmidt 正交化(Gram-Schmidt orthogonalization):
由叉积直接求 ,保证三个轴两两正交且构成右手系:
// 片元着色器:由顶点属性 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
注: 的方向依赖 UV 的朝向与手性。若模型存在镜像 UV(贴图左右翻转复用,常见于对称角色),一半顶点的 会指向相反方向,正交化时统一用 会翻转这一半的切线空间。工程做法是顶点存一个手性符号 ,计算 ,或直接把 也存成顶点属性。
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 做法二:把光照搬进切线空间
反向思路:不移动法线,把光源方向、视线方向都用 变换到切线空间,整个光照计算在切线空间完成:
// 片元着色器(节选):光照在切线空间进行
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 由正交化后的正交向量构成,正交矩阵的逆等于转置(见 变换),所以 ,
transpose(TBN)就是逆,不需要真正的矩阵求逆。GLSL 里对mat3求逆也有inverse()内置函数,但转置更便宜。
6. 常见问题
| 问题 | 现象 | 原因与处理 |
|---|---|---|
| 贴图整体淡蓝变成全灰 | 没有凹凸效果 | 法线贴图被当成颜色纹理加载成了 sRGB 格式,解码扭曲了数值(见 伽马校正:数据贴图必须是线性格式) |
| 光照忽明忽暗 | 表面噪声闪烁 | 采样后忘记重新归一化 |
| 一半模型凹凸方向相反 | 镜像 UV 穿帮 | 的手性( 符号)没有处理 |
| 非等比缩放后光照错乱 | 物体压扁时明暗错位 | 切线没有像法线一样用正规矩阵(逆转置)变换 |
| 顶点没有 tangent 属性 | 无法构造 TBN | 建模导出时勾选切线,或加载时让 Assimp 计算(aiProcess_CalcTangentSpace),或由邻面 UV 现算 |
注:法线贴图是数据纹理而非颜色纹理,相关的存储与压缩也有专门格式——
BC5(双通道块压缩,见 纹理压缩格式)把 、 存进两个独立块, 由 重建,精度远高于把法线塞进普通颜色压缩格式。这也是把法线贴图与漫反射贴图分开压缩的原因。



