Normal Mapping - 法线贴图

项目目标

实现法线贴图(Normal Mapping)技术,在光线追踪器中渲染带有法线贴图的球体,展示如何在不增加几何复杂度的情况下增加表面细节。

实现过程

技术背景

法线贴图(Normal Mapping) 是一种重要的图形学技术,通过扰动表面法线方向来模拟凹凸细节,而不需要增加几何复杂度。这项技术广泛应用于游戏和电影 CG 中,能够在保持高性能的同时大幅提升视觉效果。

核心技术

  1. 程序化法线贴图生成 - 生成砖块图案的法线贴图
  2. 切线空间(Tangent Space) - TBN 矩阵的构建
  3. 法线变换 - 从切线空间到世界空间
  4. Phong 光照模型 - 使用扰动后的法线计算光照
  5. 球面 UV 映射 - 将 2D 纹理映射到 3D 球体

迭代历史

迭代 1: 初始实现

创建基础框架,实现法线贴图的加载、切线空间变换和光照计算。

迭代 2: 修复编译错误

问题: Vec3 operator* 类型不匹配

1
error: no match for 'operator*' (operand types are 'Vec3' and 'const Vec3')

原因: Phong 光照计算中尝试将两个 Vec3 相乘,但只定义了 Vec3 * double 的运算符。

解决方案: 添加 mul() 方法用于逐元素乘法(Hadamard product)

1
2
3
Vec3 mul(const Vec3& v) const { 
return Vec3(x * v.x, y * v.y, z * v.z);
}

迭代 3: 警告清理

清理未使用的参数和变量,确保编译时无警告。

最终版本

✅ 编译通过(0 错误 0 警告)
✅ 运行成功(渲染时间 ~3 秒)
✅ 量化验证通过

核心代码

1. 程序化法线贴图生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Vec3 procedural_normal_map(double u, double v) {
// 生成砖块图案的法线贴图
const int brick_rows = 6;
const int brick_cols = 12;

double brick_u = u * brick_cols;
double brick_v = v * brick_rows;
int row = static_cast<int>(brick_v);

// 交错砖块
if (row % 2 == 1) {
brick_u += 0.5;
}

double local_u = brick_u - std::floor(brick_u);
double local_v = brick_v - std::floor(brick_v);

// 砖块边界(灰缝)
const double mortar_width = 0.05;
bool is_mortar = (local_u < mortar_width || local_u > 1.0 - mortar_width ||
local_v < mortar_width || local_v > 1.0 - mortar_width);

Vec3 normal;
if (is_mortar) {
// 灰缝区域:向内凹陷
normal = Vec3(0, 0, -0.3).normalize();
} else {
// 砖块区域:添加微小的随机凹凸
double noise = sin(local_u * 20) * cos(local_v * 20) * 0.1;
normal = Vec3(noise, noise, 1.0).normalize();
}

return normal;
}

2. 切线空间到世界空间的转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Vec3 tangent_to_world(const Vec3& tangent_normal, const Vec3& world_normal) {
// 构建 TBN 矩阵(Tangent, Bitangent, Normal)
Vec3 N = world_normal;

// 构造切线向量
Vec3 up = (std::abs(N.y) > 0.999) ? Vec3(1, 0, 0) : Vec3(0, 1, 0);
Vec3 T = up.cross(N).normalize();
Vec3 B = N.cross(T);

// 从切线空间转换到世界空间
Vec3 world = T * tangent_normal.x +
B * tangent_normal.y +
N * tangent_normal.z;
return world.normalize();
}

3. 渲染流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 获取几何法线
Vec3 geometric_normal = get_normal(hit_point);
Vec3 shading_normal = geometric_normal;

// 如果使用法线贴图
if (material.use_normal_map) {
double u, v;
get_uv(hit_point, u, v);
Vec3 tangent_normal = procedural_normal_map(u, v);
shading_normal = tangent_to_world(tangent_normal, geometric_normal);
}

// 使用扰动后的法线计算 Phong 光照
Vec3 color = phong_lighting(hit_point, shading_normal, view_dir, ...);

运行结果

法线贴图对比

对比说明

  • 左侧球体: 平滑表面(无法线贴图)
  • 右侧球体: 砖块纹理(使用法线贴图)

量化验证结果

1
2
3
4
5
6
7
8
9
左侧(平滑) 球体:
平均颜色: RGB(186.4, 210.8, 247.6)
标准差: RGB(29.6, 35.9, 42.2)
✅ 正常渲染

右侧(法线贴图) 球体:
平均颜色: RGB(188.7, 211.7, 248.5)
标准差: RGB(18.5, 31.1, 37.3) ← 更均匀(砖块效应)
✅ 正常渲染

观察: 右侧球体的标准差略低,这是因为砖块图案使表面光照更加均匀。

技术总结

法线贴图的优势

  1. 低几何成本: 不增加三角形数量
  2. 高细节表现: 能模拟凹凸、裂纹、织物纹理等细节
  3. 动态光照响应: 随光源变化而变化(区别于纹理贴图)
  4. 易于实现: 只需要在着色器中调整法线方向

与其他技术对比

技术 几何复杂度 细节表现 性能 光照响应
高精度建模 极高 完美 完美
纹理贴图 静态
法线贴图 动态 完美
视差贴图 更真实 完美
位移贴图 完美 完美

应用场景

  • 游戏实时渲染: 降低模型复杂度,提高帧率
  • 电影 CG: 增加细节密度,减少渲染时间
  • VR/AR: 减少几何数据传输,降低延迟
  • 移动设备: 在低性能硬件上实现高质量渲染

局限性

  1. 轮廓边缘: 无法改变几何轮廓
  2. 遮挡关系: 无法产生真实的遮挡效果
  3. 极端视角: 低角度观察时效果不佳

解决方案: 视差贴图(Parallax Mapping)或位移贴图(Displacement Mapping)

学到的坑

  1. 向量运算: 注意区分标量乘法(Vec3 * double)和逐元素乘法(Vec3 mul Vec3)
  2. 法线归一化: 每次变换后都要归一化,否则光照计算会出错
  3. 切线空间构建: 需要处理法线接近 (0,1,0) 的特殊情况
  4. UV 映射: 球面 UV 映射需要正确处理 atan2 和 asin 的值域

下一步探索

  • 视差贴图(Parallax Mapping) - 更真实的深度效果
  • 位移贴图(Displacement Mapping) - 真实改变几何形状
  • PBR 材质 - 基于物理的渲染
  • 法线贴图压缩 - BC5/BC7 压缩格式

代码仓库

GitHub: daily-coding-practice/2026/02/02-23-Normal-Mapping


完成时间: 2026-02-23 05:40
迭代次数: 2 次编译修复
编译器: g++ 12.3.1 (C++17)
性能: 800×600 图像,渲染时间 ~3 秒