第十七章 法线贴图:切线空间,从纹理读取法线,为低模增加细节

好,咱们今天聊点实在的。

你有没有遇到过这种情况?模型面数不够,细节出不来。加面数?性能扛不住。不加?看着像块砖头。我当年做第一个手游项目时就卡在这儿了——一个角色盔甲上的花纹,硬是让我纠结了两周。

后来我老板扔过来一句话:「用 Normal Map 啊,愣着干嘛?」

嗯,法线贴图。这玩意儿说白了就是「骗眼睛」的。低模的身材,高模的质感。今天咱们就把它的原理和实现彻底讲透。

17.1 为什么需要法线贴图?

先想一个问题:一个平面,光照上去是平的。但如果我在这个平面上画一个凸起,光照应该怎么算?

答案是——改变法线方向。

法线贴图干的事,就是把高模上每个像素的法线方向,存到一张纹理里。然后低模在渲染时,不用自己的几何法线,而是从纹理里读出「假法线」来计算光照。

结果呢?低模看起来有高模的细节。性能开销几乎为零。

核心思想: 用纹理存储法线方向,替代几何法线。低模渲染,高模效果。

17.2 切线空间——绕不开的概念

你可能会问:法线方向是三维向量,纹理是二维的,怎么存?

嗯,这里就有个关键概念——切线空间(Tangent Space)

说白了,每个顶点都有一个自己的局部坐标系:

  • 法线(N):垂直于表面
  • 切线(T):沿着纹理 U 方向
  • 副切线(B):沿着纹理 V 方向,也叫双切线

法线贴图里存的就是这个局部坐标系下的法线偏移。为什么这么做?因为这样贴图可以复用——同一个砖墙纹理,贴到不同模型上,效果都正确。

我刚开始学的时候,一直搞不懂为什么不能直接存世界空间的法线。后来踩了个坑才明白:世界空间法线没法复用,换个模型方向就全错了。切线空间是「相对」的,模型怎么转,法线跟着转,永远对。

个人经验: 切线空间是法线贴图的基石。不理解它,后面所有代码都是瞎写。我建议你花点时间把 TBN 矩阵的推导亲手算一遍。

17.3 从纹理读取法线

法线贴图通常是一张 RGB 纹理。每个像素的 RGB 值对应一个法线向量 (x, y, z)。

但注意:纹理存储的范围是 [0, 1],而法线向量的范围是 [-1, 1]。所以读取时要做一个映射:

// 从纹理采样
vec3 normalSample = texture2D(normalMap, uv).rgb;

// 映射到 [-1, 1]
vec3 normal = normalize(normalSample * 2.0 - 1.0);

这个公式你肯定见过。但我要提醒你一点:法线贴图里的 z 分量通常是正的,因为法线一般指向表面外侧。如果你发现渲染结果怪怪的,先检查一下 z 是不是被压扁了。

我曾经在一个项目里,美术给的法线贴图 z 分量是反的,结果光照全反了。排查了整整一天……嗯,从那以后我每次拿到贴图,第一件事就是看它的颜色分布。

17.4 构建 TBN 矩阵

从纹理读出来的法线是在切线空间里的。要计算光照,得把它转到世界空间(或者视图空间)。

怎么做?用 TBN 矩阵。

TBN 矩阵由三个向量组成:

  • T:切线方向
  • B:副切线方向
  • N:法线方向

这三个向量构成一个 3x3 矩阵。把切线空间的法线乘以这个矩阵,就得到世界空间的法线。

// 顶点着色器中计算 TBN
vec3 T = normalize(mat3(modelMatrix) * tangent);
vec3 B = normalize(mat3(modelMatrix) * bitangent);
vec3 N = normalize(mat3(modelMatrix) * normal);

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

// 片元着色器中转换法线
vec3 normal = texture2D(normalMap, uv).rgb;
normal = normalize(normal * 2.0 - 1.0);
normal = normalize(TBN * normal);

注意:B 向量其实可以通过 N 和 T 叉乘得到,不一定非要传进来。但有些引擎会传,因为美术可能手动调整过 UV 方向。

避坑指南: 我曾经在计算 TBN 时忘了对向量做归一化,结果光照出现奇怪的条纹。记住:矩阵里的每一列都必须是单位向量,否则矩阵不是正交的,转换后的法线长度会变。

17.5 完整的法线贴图着色器

好了,理论说完了,上代码。这是一个完整的法线贴图示例:

// 顶点着色器
#version 300 es
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
layout(location = 3) in vec3 aTangent;

uniform mat4 uModelViewProjection;
uniform mat4 uModelMatrix;

out vec2 vTexCoord;
out mat3 vTBN;

void main() {
    gl_Position = uModelViewProjection * vec4(aPosition, 1.0);
    vTexCoord = aTexCoord;

    vec3 T = normalize(mat3(uModelMatrix) * aTangent);
    vec3 N = normalize(mat3(uModelMatrix) * aNormal);
    vec3 B = cross(N, T);

    vTBN = mat3(T, B, N);
}

// 片元着色器
#version 300 es
precision mediump float;

in vec2 vTexCoord;
in mat3 vTBN;

uniform sampler2D uNormalMap;
uniform vec3 uLightDirection;

out vec4 fragColor;

void main() {
    vec3 normalSample = texture(uNormalMap, vTexCoord).rgb;
    vec3 normal = normalize(normalSample * 2.0 - 1.0);
    vec3 worldNormal = normalize(vTBN * normal);

    float diff = max(dot(worldNormal, normalize(uLightDirection)), 0.0);
    fragColor = vec4(vec3(diff), 1.0);
}

这段代码只做了漫反射光照。你可以自己加上镜面光和环境光,效果会更真实。

17.6 法线贴图的常见问题

做多了你就会发现,法线贴图虽然好用,但坑也不少。我列几个常见的:

问题 现象 原因 解决方法
法线方向反了 光照凹陷变凸起 切线空间方向不对 检查 TBN 矩阵的符号
接缝明显 模型 UV 接缝处光照不连续 切线不连续 使用平滑切线或调整 UV
法线贴图模糊 细节丢失 纹理压缩太狠 使用 ETC2 或 ASTC 保留精度
性能下降 帧率降低 片元着色器计算量过大 考虑在顶点着色器预计算 TBN

嗯,这些坑我基本都踩过。尤其是接缝问题,当年做一个角色模型,肩膀处的法线接缝怎么都修不好,最后发现是建模软件导出的切线方向不一致。重新烘焙了一次才解决。

17.7 知识体系总览

说了这么多,咱们用一张图把整个流程串起来:

法线贴图核心流程 高精度模型 (高模,百万面) 烘焙 法线贴图 (RGB 纹理) 采样 切线空间法线 (范围 [-1, 1]) 低精度模型 (低模,几千面) 提供 TBN 矩阵 (切线、副切线、法线) 转换 世界空间法线 (用于光照计算) 最终渲染结果 低模 + 法线贴图 = 高模细节 光照计算 核心:切线空间法线 → TBN 矩阵 → 世界空间法线 → 光照计算

这张图把整个流程分成了两条线:上面是法线贴图的生成和采样,下面是低模提供几何信息构建 TBN 矩阵。两条线在光照计算时汇合。

17.8 性能优化建议

法线贴图虽然高效,但也不是没有代价。我总结几条优化经验:

  1. 纹理压缩:Android 上推荐用 ETC2 或 ASTC。不要用 RGBA8888 裸奔,除非你纹理很小。
  2. 预计算 TBN:如果模型不形变,TBN 矩阵可以在顶点着色器里算好,传到片元着色器。别在片元里算,太贵了。
  3. 减少采样次数:如果场景里有很多小物体,法线贴图的细节人眼根本看不清。这时候可以降分辨率或者干脆不用。
  4. 注意精度:低端 GPU 上,mediump 精度可能不够。如果出现条纹,试试 highp。

我的习惯: 在开发阶段用 uncompressed 纹理方便调试,发布前再压缩。另外,我会在渲染器里加一个开关,可以一键关闭法线贴图,方便对比效果和排查问题。

17.9 小结

法线贴图是图形学里「性价比」最高的技术之一。它用一张纹理,换来了几何级别的细节提升。而理解切线空间,是掌握它的关键。

说白了,你只要记住三件事:

  • 法线贴图存的是切线空间的法线
  • TBN 矩阵负责把切线空间法线转到世界空间
  • 光照计算用世界空间法线

嗯,就这些。代码跑起来,效果出来了,你就懂了。


公众号:蓝海资料掘金营,微信deep3321