在实时计算机图形学中,模型的细节往往受到多边形数量的限制。为了在不显著增加模型复杂度的前提下,模拟出丰富的表面细节(如凹凸、划痕、纹理),法线贴图技术应运而生。本文将详细介绍如何在基于 C++ 的软件渲染器中实现切线空间法线贴图 (Tangent Space Normal Mapping)。
1. 问题的提出:低模的局限性
传统的低多边形模型 (Low-Poly Model) 依赖于顶点法线 (Vertex Normals) 进行光照计算。通过 Gouraud Shading 或 Phong Shading,我们可以在顶点之间插值法线,获得平滑的光照过渡效果。然而,这种方法无法表现模型表面的微小几何细节。如果想要模型拥有丰富的凹凸细节,就需要极高数量的多边形,这对于实时渲染来说通常是不可接受的。
(示意图:左侧为低模+顶点法线光照,右侧为低模+法线贴图光照)
2. 解决方案:法线贴图
法线贴图的核心思想是:用一张纹理来存储模型表面各点的法线信息。这张特殊的纹理被称为“法线贴图”。在渲染时,我们不再直接使用插值得到的顶点法线,而是从法线贴图中采样对应片元 (Fragment) 的法线向量,并用这个采样得到的法线来进行光照计算。
由于纹理可以存储非常丰富的信息,即使模型本身多边形数量很少,通过法线贴图也能模拟出极其逼真的表面细节。
3. 关键概念:切线空间 (Tangent Space)
直接将世界空间 (World Space) 或模型空间 (Model Space) 的法线存储在纹理中是可行的,但这会导致法线贴图与模型的特定姿态或变换绑定,难以复用。更常用的方法是使用 切线空间 (Tangent Space)。
切线空间是一个局部坐标系,定义在模型的每个表面点上。它由三个相互正交(或近似正交)的基向量构成:
- 法线 (Normal - N): 即该点的原始顶点法线,通常垂直于表面。
- 切线 (Tangent - T): 平行于表面,通常沿着纹理坐标 U 的增加方向。
- 副切线 (Bitangent - B): 平行于表面,通常沿着纹理坐标 V 的增加方向,并且可以通过
N
和T
的叉乘得到 (B = cross(N, T)
) 来保证正交性。
(示意图:模型表面一点的切线空间 TBN 基向量)
法线贴图中存储的是相对于这个局部 TBN 坐标系的法线扰动。通常,RGB 通道对应 TBN 向量:
- R -> Tangent 方向分量
- G -> Bitangent 方向分量
- B -> Normal 方向分量
一个“平坦”表面的法线在切线空间中通常是 (0, 0, 1)
。由于颜色通道通常存储在 [0, 1]
范围内,而法线分量在 [-1, 1]
范围内,因此需要进行映射。常用的映射方式为:
存储值 = (法线分量 + 1.0) / 2.0
或者反过来,从纹理采样值恢复法线分量:
法线分量 = 采样值 * 2.0 - 1.0
因此,法线贴图中常见的“基准”蓝色 (0.5, 0.5, 1.0)
就代表了切线空间中的 (0, 0, 1)
法线,即未发生扰动的原始表面法线方向。
使用切线空间的好处:
- 解耦: 法线信息与模型的具体旋转、变形无关。
- 复用: 同一张法线贴图可以应用在不同模型或模型的不同部分(只要它们的 UV 布局允许)。
- 压缩友好: 大部分法线的 Z 分量(Normal 方向)都接近 1,可以通过优化存储。
4. 实现步骤
要在我们的软渲染器中实现切线空间法线贴图,需要修改渲染管线的多个阶段。
4.1 计算顶点切线和副切线
我们需要为模型的每个顶点计算其 TBN 基础向量。这通常在模型加载后、渲染前完成。计算方法基于构成三角形的顶点位置和纹理坐标:
对于三角形 P0, P1, P2
及其对应的纹理坐标 UV0, UV1, UV2
:
- 计算边向量:
Edge1 = P1 - P0
Edge2 = P2 - P0
- 计算 UV 差量:
DeltaUV1 = UV1 - UV0
DeltaUV2 = UV2 - UV0
- 计算系数
f
:f = 1.0 / (DeltaUV1.x * DeltaUV2.y - DeltaUV2.x * DeltaUV1.y)
- 计算切线
T
和副切线B
: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)
计算出的 T
和 B
需要累加到每个顶点上(因为一个顶点可能被多个三角形共享)。
最后,对每个顶点的 T
和 B
进行正交化和归一化处理,常用 Gram-Schmidt 方法:
T = normalize(T - N * dot(N, T))
// 使 T 正交于 N- 检查
dot(cross(N, T), B)
的符号,判断 TBN 坐标系的左右手性是否与 UV 坐标系一致,必要时翻转 T。 B = normalize(cross(N, T))
// 重新计算 B 以确保正交
代码片段 (Model::calculateTangents):
1 | // src/core/model.cpp |
将计算得到的 tangents 和 bitangents 存储在 Model 类中。
4.2 数据准备与传递
- Material: 在 Material 结构体中添加 normalTexture 成员及加载方法。
- Shader Uniforms: 在 Shader 基类中添加 uniform_NormalTexture (类型 Texture) 和 uniform_UseNormalMap (类型 bool)。
- Vertex Input: 修改 VertexInput 结构体,添加 tangent 和 bitangent 成员。
1 | // include/core/shader.h |
- Varyings: 修改 Varyings 结构体,传递世界空间下的 TBN 基向量。
1 | // include/core/shader.h |
- Renderer: 在 Renderer::drawModel 中,设置 uniform_NormalTexture 和 uniform_UseNormalMap。在构建 VertexInput 时,从 Model 获取 tangent 和 bitangent。
4.3 顶点着色器 (Vertex Shader)
顶点着色器的主要任务是将 TBN 基向量从模型空间转换到世界空间,并传递给片元着色器。
代码片段 (BlinnPhongShader::vertex):
1 | // src/core/blinn_phong_shader.cpp |
4.4 片元着色器 (Fragment Shader)
片元着色器是实现法线贴图的核心:
- 检查是否使用法线贴图: 根据 法线的 texture 是否为空为标志。
- 采样法线贴图: 如果使用,则根据插值得到的 uv 坐标采样 uniform_NormalTexture。
- 解压法线: 将采样到的 [0, 1] 颜色值转换回 [-1, 1] 的切线空间法线向量 N_{tangent}。
- N_{tangent} = normalize(Sample_{RGB} * 2.0 - 1.0)
- 构建 TBN 矩阵: 使用从顶点着色器插值得到的世界空间 TBN 基向量(需要重新归一化)。
- T = normalize(input.tangent)
- B = normalize(input.bitangent)
- N_{geom} = normalize(input.normal)
- 转换法线: 将切线空间法线 N_{tangent} 转换到世界空间。
- N_{world} = normalize(T * N_{tangent}.x + B * N_{tangent}.y + N_{geom} * N_{tangent}.z)
- 光照计算: 使用计算得到的 N_{world} (如果使用了法线贴图) 或 N_{geom} (如果未使用) 进行后续的 Blinn-Phong 或其他光照模型计算。
代码片段 (BlinnPhongShader::fragment):
1 | // src/core/blinn_phong_shader.cpp |
4.5 插值 (Interpolation)
确保 Renderer::interpolateVaryings 函数能够正确地对新增的 tangent, bitangent, normal 向量进行透视矫正插值。由于 interpolateVaryings 内部使用了模板化的 perspectiveCorrectInterpolate,只需要在 interpolateVaryings 中添加对这三个新成员的调用即可。
1 | // src/core/renderer.cpp |
5. 总结与效果
通过以上步骤,我们就成功地在软渲染器中集成了切线空间法线贴图。渲染低多边形模型时,通过在片元着色器中查询法线贴图并使用得到的法线进行光照计算,可以在几乎不增加几何复杂度的前提下,极大地提升模型的表面细节和真实感。
这项技术是现代实时渲染中不可或缺的一部分,能够以较低的性能开销实现高质量的视觉效果。
6. 注意事项
- Tangent Calculation: 上述切线计算方法比较基础,对于复杂的 UV 布局或重叠 UV 可能产生问题。更精确的方法(如 MikkTSpace)更为健壮。
- Normal Map Format: 注意法线贴图的 Y 分量(通常是绿色通道)在不同规范(如 OpenGL 和 DirectX)中可能方向相反。需要确保加载和解压时使用正确的约定。
- TBN 正交性: 插值后的 TBN 基向量可能不再严格正交,在片元着色器中重新正交化(如通过 B = cross(N, T))可以提高精度,但会增加计算量。
- sRGB: 如果法线贴图被错误地当作 sRGB 纹理处理,会导致解压出的法线不准确。应确保法线贴图作为线性数据处理。