Normal Mapping 法线贴图
平面为什么看起来还是平的
Diffuse Texture 只能改变颜色。
一块砖墙即使贴了很细的颜色纹理,如果整个平面的法线仍然只有:
N = (0, 0, 1)那么同一平面上的光照方向变化很小,Diffuse 和 Specular 仍然按“这是一个平面”计算。
Normal Mapping 不增加几何顶点,也不改变轮廓。它只把原来每个 Fragment 使用的几何法线:
interpolated vertex normal换成:
normalMap[uv]因此小砖块、凹槽和裂缝可以通过局部法线变化影响光照。
Normal Map 存的是什么
纹理通道只能直接保存 [0,1] 范围的数据,而法线分量在 [-1,1]。
写入 Normal Map 前:
encoded = normal * 0.5 + 0.5;Shader 采样后再恢复:
vec3 normal =
texture(normalMap, TexCoords).rgb;
normal =
normalize(normal * 2.0 - 1.0);RGB 分别对应法线的 XYZ。
大多数 Normal Map 看起来偏蓝,是因为切线空间中的基础法线接近:
(0, 0, 1)映射到颜色后约为:
(0.5, 0.5, 1.0)Normal Map 是数据纹理,不应该按 sRGB 颜色纹理解码。
直接使用采样法线的问题
纹理里的法线通常不是 World Space 法线,而是 Tangent Space 法线。
在 Tangent Space 中,每个表面都被当作:
T → 纹理 U 方向
B → 纹理 V 方向
N → 表面外侧Normal Map 中的 (0,0,1) 表示“垂直离开当前表面”,不是固定的 World +Z。
如果把它直接拿去和 World Space 的 lightDir 做点乘:
dot(normal, lightDir)那么只要模型旋转,两个向量就不再位于同一个坐标空间,光照会出错。
Normal Mapping 的核心不是“多采一张纹理”,而是先处理坐标空间。
T、B、N 从哪里来
N 是网格本来的几何法线。
Tangent 和 Bitangent 来自三角形的边以及 UV 的变化。
设一个三角形:
P1 ---- P2
\ /
\ /
\ /
P3位置边:
edge1 = pos2 - pos1;
edge2 = pos3 - pos1;UV 边:
deltaUV1 = uv2 - uv1;
deltaUV2 = uv3 - uv1;几何边可以写成 Tangent 与 Bitangent 的组合:
edge1 = deltaUV1.x * T + deltaUV1.y * B
edge2 = deltaUV2.x * T + deltaUV2.y * B把这组二维线性方程解出来:
float f =
1.0f /
(
deltaUV1.x * deltaUV2.y
- deltaUV2.x * deltaUV1.y
);
tangent.x =
f * (
deltaUV2.y * edge1.x
- deltaUV1.y * edge2.x
);
tangent.y =
f * (
deltaUV2.y * edge1.y
- deltaUV1.y * edge2.y
);
tangent.z =
f * (
deltaUV2.y * edge1.z
- deltaUV1.y * edge2.z
);
bitangent.x =
f * (
-deltaUV2.x * edge1.x
+ deltaUV1.x * edge2.x
);
bitangent.y =
f * (
-deltaUV2.x * edge1.y
+ deltaUV1.x * edge2.y
);
bitangent.z =
f * (
-deltaUV2.x * edge1.z
+ deltaUV1.x * edge2.z
);这里得到的不是“新的表面法线”,而是一套跟 UV 贴图方向绑定的局部坐标轴。
TBN 矩阵
三根轴组成:
T = Tangent
B = Bitangent
N = Normal写成矩阵:
mat3 TBN = mat3(T, B, N);这个矩阵把 Tangent Space 向量变到当前使用的空间。
有两种常见做法。
第一种:
Normal Map 法线
Tangent Space
↓ TBN
World Space
↓
和 World Space 的 lightDir / viewDir 一起计算Fragment Shader:
vec3 normal =
texture(normalMap, TexCoords).rgb;
normal =
normalize(normal * 2.0 - 1.0);
normal =
normalize(TBN * normal);第二种是官方最终源码采用的方向:
Normal Map 法线保持 Tangent Space
World Space:
lightPos
viewPos
FragPos
↓ inverse(TBN)
Tangent Space
↓
全部在 Tangent Space 中计算光照官方源码中的 TBN
Vertex Shader 先用 Normal Matrix 变换 T 和 N:
mat3 normalMatrix =
transpose(inverse(mat3(model)));
vec3 T =
normalize(normalMatrix * aTangent);
vec3 N =
normalize(normalMatrix * aNormal);然后重新让 T 与 N 垂直:
T =
normalize(
T - dot(T, N) * N
);这一步是 Gram-Schmidt 正交化。模型变换和顶点插值可能让 T、N 不再严格垂直。
B 再由叉乘得到:
vec3 B =
cross(N, T);得到一组正交基。
mat3 TBN =
transpose(mat3(T, B, N));这里用了 transpose,因为正交矩阵满足:
inverse(TBN) = transpose(TBN)所以这个矩阵实际用于:
World Space → Tangent SpaceVertex Shader 提前把三个位置转换过去:
vs_out.TangentLightPos =
TBN * lightPos;
vs_out.TangentViewPos =
TBN * viewPos;
vs_out.TangentFragPos =
TBN * vs_out.FragPos;这样 Fragment Shader 不需要每个 Fragment 再做矩阵转换。
Fragment Shader 的数据流
Normal Map:
vec3 normal =
texture(
normalMap,
fs_in.TexCoords
).rgb;
// [0,1] → [-1,1]
normal =
normalize(normal * 2.0 - 1.0);这时 normal 已经是 Tangent Space。
光源方向:
vec3 lightDir =
normalize(
fs_in.TangentLightPos
- fs_in.TangentFragPos
);观察方向:
vec3 viewDir =
normalize(
fs_in.TangentViewPos
- fs_in.TangentFragPos
);于是:
normal → Tangent Space
lightDir → Tangent Space
viewDir → Tangent Space三者在同一空间后,Blinn-Phong 本身不需要修改:
float diff =
max(dot(lightDir, normal), 0.0);
vec3 halfwayDir =
normalize(lightDir + viewDir);
float spec =
pow(
max(dot(normal, halfwayDir), 0.0),
32.0
);Normal Mapping 改变的是法线来源和坐标空间,不是光照公式。
完整流程
Mesh
├─ Position
├─ Normal
├─ UV
└─ Tangent
↓
Vertex Shader
↓
构造 T / B / N
↓
World → Tangent
↓
TangentLightPos
TangentViewPos
TangentFragPos
↓
Rasterization
↓
Fragment Shader
↓
Normal Map RGB
↓
[0,1] → [-1,1]
↓
Per-Fragment Tangent Normal
↓
Blinn-Phong
↓
Final Color几何表面本身仍然是平的。Normal Mapping 只让光照按照一组更细的逐片段法线变化,因此物体轮廓、遮挡关系和真实几何深度都不会被改变。