从切线空间到纹理像素,再到 Shader 中的法线重建 ------ 逐步拆解法线贴图的完整数据流
1. 法线贴图是什么
法线贴图(Normal Map)是一张存储了表面法线方向的纹理。它不存储颜色,而是将三维向量 (n⃗.x, n⃗.y, n⃗.z) 编码到 RGB 三个通道中,让低面数模型在光照计算时呈现出高面数的凹凸细节。
一张典型的法线贴图看起来是蓝紫色的 ------ 因为大多数表面法线指向"正上方"(+Z 方向),编码后对应 R=0.5, G=0.5, B=1.0,正是那种淡蓝紫颜色。

图 1:法线贴图的作用 ------ 让低面数模型获得高面数的光照效果
2. 切线空间与法线方向
法线贴图中的法线定义在切线空间(Tangent Space)中。切线空间由三个基向量构成:

图 2:切线空间的三个基向量 ------ 沿 UV 方向展开
关键性质:
- 法线是单位向量,满足 x² + y² + z² = 1
- 三个分量范围都是 -1, +1
- "平坦"表面的法线 = (0, 0, 1),指向正 Z 方向
- 切线空间跟随模型表面弯曲,所以法线贴图可以在不同模型上复用
3. 编码:从法线到颜色
纹理的 RGB 通道只能存储 0, 1 的值,而法线分量在 -1, +1 之间。编码的核心就是做一次线性映射:

图 3:编码映射 ------ -1,+1 → 0,1 的线性变换
编码公式(法线 → 纹理颜色):
// 每个通道独立编码 color.r = normal.x * 0.5 + 0.5; color.g = normal.y * 0.5 + 0.5; color.b = normal.z * 0.5 + 0.5;
常见法线值的编码结果:
| 法线方向 | n (x, y, z) | c (R, G, B) | 视觉颜色 |
|---|---|---|---|
| 平坦表面(+Z) | (0, 0, 1) |
(0.5, 0.5, 1.0) |
#8080FF |
| 朝右倾斜 | (1, 0, 0) |
(1.0, 0.5, 0.5) |
#FF8080 |
| 朝上倾斜 | (0, 1, 0) |
(0.5, 1.0, 0.5) |
#80FF80 |
| 朝左倾斜 | (-1, 0, 0) |
(0.0, 0.5, 0.5) |
#008080 |
直觉 :法线贴图中越接近蓝紫色 的区域,表面越平坦;越偏红/绿的区域,表面朝 X/Y 方向的倾斜越大。
4. 两种编码格式:RGB vs DXT5nm
Unity 支持两种法线贴图的存储方式,这是理解编码/解码的关键分歧点:


图 4:两种编码格式的通道布局与解码路径
为什么 DXT5nm 把 X 放到 Alpha 通道?
DXT5/BC3 压缩格式中,Alpha 通道有独立的 4-bit 插值,精度比 R/G/B 的共享 5-bit 更高。将 X 分量放到 Alpha 中可以在压缩后保留更多法线细节。而 Z 通道被丢弃是因为它可以由 X、Y 推导出来(单位向量约束),不存储反而节省了压缩空间。
DirectX vs OpenGL 绿通道方向
另一个常见困惑是绿通道(Y 分量)的方向:

在 Unity 的导入设置中可以通过 "Flip Green Channel" 选项来切换。两种格式互为绿通道取反关系。
5. 解码:UnpackNormal 的完整流程
Unity 内部通过两个关键函数完成法线贴图的解码。整个流程如下:

下面是 Unity 源码中两个关键解码函数的简化版:
cs
// RGB 格式解码 ------ 三个通道完整存储 float3 UnpackNormalRGB(float4 packedNormal) { float3 normal; normal.xy = packedNormal.rg * 2.0 - 1.0; // [0,1] → [-1,+1]
normal.z = packedNormal.b * 2.0 - 1.0; // 直接读 Z
return normalize(normal); }
cs
// DXT5nm 格式解码 ------ X 在 Alpha,Y 在 Green float3 UnpackNormalmapRGorAG(float4 packedNormal) { // 检测 R 通道是否被用来存 X(某些平台上 R=A) float2 rg_or_ag = packedNormal.rg; if (packedNormal.a >= 0.0) // Unity 通过编译宏决定路径 rg_or_ag = packedNormal.ag; // DXT5nm: 读 A 和 G float3 normal;
normal.xy = rg_or_ag * 2.0 - 1.0; // 解码 X 和 Y
normal.z = sqrt(1.0 - saturate( // 由单位向量约束推导 Z
dot(normal.xy, normal.xy)));
return normal; }
注意 :Unity 的 UnpackNormal() 函数会根据纹理的导入设置自动选择正确的解码路径。你通常不需要手动判断格式。只要法线贴图在 Inspector 中被设置为 "Normal Map" 类型,Unity 就会正确处理编码和色彩空间。
6. 色彩空间:sRGB vs Linear
这是法线贴图最容易出错的地方。法线贴图的数据是数学向量,不是颜色,因此它不应该经过 sRGB → Linear 的伽马校正。

图 6:sRGB 解码对法线贴图的影响 ------ 伽马校正确保暗部细节的设计初衷对法线数据是灾难性的
核心规则:
| 属性 | 漫反射贴图 | 法线贴图 |
|---|---|---|
| 数据性质 | 颜色(视觉感知) | 数学向量 |
| sRGB 标记 | true(需要伽马校正) |
false(原始数据) |
| 采样后处理 | 硬件自动 Linear 转换 | 直接使用,不做转换 |
| Unity 标记方式 | 默认 sRGB = true | 设为 "Normal Map" 类型 |
Unity 如何处理?
当你在 Inspector 中将纹理类型设为 "Normal Map" ,Unity 会:
-
自动将 sRGB 标记设为 false
-
如果源纹理是 DXT5nm 格式,在导入时重排通道(R→A 或保持)
-
在
SAMPLE_TEXTURE2D采样时,因为 sRGB=false,GPU 不会做伽马转换,直接返回原始 0,1 数据
7. 常见陷阱与检查清单
1纹理未标记为 Normal Map结果:sRGB 伽马校正被应用,法线方向偏移,表面光照异常修复:Inspector → Texture Type → Normal Map2绿通道方向错误结果:凹凸方向反转,凸起变凹陷(或反之)修复:Inspector → Flip Green Channel / 在 Shader 中 Y 取反3手动乘以 2 减 1 但忘了 Z 重建结果:DXT5nm 格式下 Z 分量来自 Blue 通道(值为 1),解码后 Z=1 而非正确值修复:使用 UnpackNormal 或手动从 xy 推导 z4切线空间未正确传递到 Fragment结果:法线贴图在模型不同部位方向不一致,出现光照条纹修复:确保 Vertex 中计算 TBN 并使用 correctTangentSpace 变体5BC5/BC7 平台差异不同平台压缩格式不同,用 #pragma multi_compile_local 处理
图 7:五大常见陷阱与修复方案
检查清单 :每次使用法线贴图时,确认以下事项:
• 纹理类型 = Normal Map(自动设 sRGB=false)
• 绿通道方向与项目约定一致(DirectX / OpenGL)
• 使用 UnpackNormal() 而非手动解码
• 切线向量在 Vertex Shader 中正确计算并传递
• TBN 矩阵的三个向量都已归一化
总结:完整数据流
