OpenGL 为什么法线矩阵定义为“模型矩阵左上角 3x3 部分的逆矩阵的转置”
在 3D 渲染中,光照计算离不开物体表面的法线(Normal)。然而,当对物体应用模型变换矩阵(Model Matrix)时,直接用该矩阵去变换法线往往会得到错误的结果。为了保证法线在变换后依然与表面垂直,我们需要使用一个特殊的矩阵——法线矩阵(Normal Matrix),其定义为模型矩阵左上角 部分的逆矩阵的转置(Transpose of the Inverse Matrix)。
本文将从数学推导、直观几何意义以及实际渲染性能优化的角度,详细解释这一设计的底层逻辑。
一、 法线变换的核心冲突
法线是一个方向向量,其根本属性是垂直于物体的表面(更精确地讲,是垂直于该点切线所在的空间平面)。
1. 旋转与平移变换
- 平移(Translation):法线代表方向,没有位置概念,因此平移不应影响法线。在齐次坐标中,我们将法线的 分量设为
0.0即可过滤平移。 - 均匀旋转(Rotation):当物体整体旋转时,表面和法线会同步旋转相同的角度。这种情况下,直接用模型矩阵的旋转部分(左上角 )乘以法线是完全正确的。
2. 核心问题:非均匀缩放(Non-uniform Scaling)
当物体在不同轴向上的缩放比例不一致时,直接使用模型矩阵变换法线就会导致法线偏离垂直方向。
【 非均匀缩放对法线的影响示意图 】
原始三角形 (等腰直角):
Y
^
|
C + (0,1)
| \
| \ <-- 法线 N (0.7, 0.7)
+-----+------> X
A(0,0) B(1,0)
在 X 轴拉伸 2 倍后:
Y
^
|
C + (0,1)
| \
| \ <-- 错误法线 N' (1.4, 0.7)(不再垂直!正确法线应该更倾向于 Y 轴!)
+------+---------> X
A(0,0) B'(2,0)如上图所示:
- 原始斜边的切线方向为 ,法线为 ,点积为 (正交)。
- 如果在 X 轴缩放 2 倍,斜边上的点 变为 ,新斜边切线方向变为 。
- 若直接对原法线 乘以相同的缩放矩阵(X 轴乘 2),得到的新法线为 。
- 验证正交性:新切线与新法线的点积为 。法线不再垂直于表面!
为了修正这种由非均匀缩放导致的畸变,必须推导出一个能让变换后的法线依然垂直于新切线的矩阵。
二、 严密的数学推导
我们采用线性代数中的标准列向量记法进行推导。
设在物体表面某点处:
- 为该点处的一个切线向量(Tangent Vector)。
- 为该点处的法线向量(Normal Vector)。
由于法线垂直于表面切线,它们的点积为零。写成矩阵乘法形式(列向量的内积为 ):
现在我们对物体施加模型变换矩阵 (在此指代去除平移后的左上角 矩阵):
- 切线的变换:切线是由表面上两个无限接近的顶点相减得到的方向向量。因为顶点是由 变换的(),所以切线也必须直接使用 进行变换:
- 法线的变换:假设我们寻找的法线变换矩阵为 ,则变换后的法线为:
为了保证变换后的法线 依然与新切线 垂直,必须满足:
将 和 代入上式:
根据矩阵转置的性质 ,展开左侧乘积:
对比初始条件 。要使上式对任意切线 和法线 都恒成立,中间的矩阵乘积 必须为单位矩阵 :
解方程求出法线变换矩阵 :
结论:用于变换法线的矩阵 必须是模型矩阵 的逆矩阵的转置。这就是“逆矩阵的转置”的数学来源。
三、 几何直观理解
为什么“逆矩阵的转置”能纠正缩放导致的倾斜?
- 逆矩阵(Inverse)的作用:
- 逆矩阵代表的是相反的变换。
- 如果我们在 X 轴方向将物体拉伸了 2 倍(缩放因子为 2),那么在逆矩阵中,X 轴方向的缩放因子就变成了 。
- 这非常符合几何直观:当表面在 X 方向被拉宽、坡度变缓时,法线为了保持垂直,应该向 Y 轴方向倾斜,也就是在 X 方向收缩。
- 转置矩阵(Transpose)的作用:
- 转置操作保证了向量在正交投影下的乘法方向正确,将“行向量式”的受力分布重新映射回列向量。
四、 两种特殊情况下的简化
由于求逆矩阵(Inverse)在计算机中是比较昂贵的操作,在实际开发中,我们不需要对所有物体都计算逆转置。
情况 1:纯旋转和平移(无缩放)
如果模型矩阵只包含旋转和平移,那么其左上角的 矩阵 是一个正交矩阵(Orthogonal Matrix)。 根据正交矩阵的定义,其转置矩阵等于逆矩阵:
优化结论:当物体没有缩放时,法线变换矩阵就是 本身。我们可以直接使用模型矩阵的 部分乘以法线。
情况 2:包含均匀缩放(Uniform Scaling)
如果物体在 X、Y、Z 轴上的缩放因子相同(设为 ),则 ( 为旋转矩阵)。它的逆转置矩阵为:
优化结论:均匀缩放只会改变变换后法线向量的长度(乘以系数 ),而不会改变其方向。因此,我们依然可以直接用 的 部分来变换法线,只需在变换后将法线重新**归一化(Normalize)**即可。
五、 GLSL 代码应用与性能优化
在编写 Shader 时,计算法线矩阵有两种选择。
方案 A:在顶点着色器(VS)中动态计算(不推荐)
# version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
uniform mat4 model;
out vec3 Normal;
void main() {
gl_Position = projection * view * model * vec4(aPos, 1.0);
// ❌ 不推荐:在 GPU 中为每个顶点重复计算矩阵的逆和转置,极度浪费算力
mat3 normalMatrix = transpose(inverse(mat3(model)));
Normal = normalize(normalMatrix * aNormal);
}方案 B:在 CPU 中计算并作为 Uniform 传入(推荐最佳实践)
因为一个 Mesh 的 Model 矩阵在一帧绘制中是恒定的,所以法线矩阵应该在 CPU 端计算完毕,然后直接传给 GPU。
C++ 端的计算(以 GLM 为例):
# include <glm/glm.hpp>
# include <glm/gtc/matrix_transform.hpp>
glm::mat4 modelMatrix = getModelMatrix(); // 获取当前物体的 model 矩阵
// 1. 提取左上角 3x3 矩阵,求逆并求转置
glm::mat3 normalMatrix = glm::transpose(glm::inverse(glm::mat3(modelMatrix)));
// 2. 传给 Shader
shader.setMat3("normalMatrix", normalMatrix);GLSL 顶点着色器:
# version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
uniform mat4 model;
uniform mat3 normalMatrix; // 直接接收 CPU 计算好的法线矩阵
out vec3 Normal;
void main() {
gl_Position = projection * view * model * vec4(aPos, 1.0);
// 直接相乘,性能最优
Normal = normalize(normalMatrix * aNormal);
}六、 总结
| 问题 | 物理/数学本质 | 解决方案 |
|---|---|---|
| 为什么普通变换矩阵不行? | 非均匀缩放会破坏切线与法线的垂直夹角。 | 必须引入与原缩放相反方向的变换。 |
| 为什么是“逆矩阵的转置”? | 基于 推导出 ,从而求得 。 | 完美保持正交性。 |
| 如何优化性能? | 逆矩阵计算量大,避免在 GPU 逐顶点计算。 | 在 CPU 算好后以 uniform mat3 形式送入 Shader。 |