OpenGL 为什么法线矩阵定义为“模型矩阵左上角 3x3 部分的逆矩阵的转置”
在 3D 渲染中,光照计算离不开物体表面的法线(Normal)。然而,当对物体应用模型变换矩阵(Model Matrix)时,直接用该矩阵去变换法线往往会得到错误的结果。为了保证法线在变换后依然与表面垂直,我们需要使用一个特殊的矩阵——法线矩阵(Normal Matrix),其定义为模型矩阵左上角3×33\times33×3部分的逆矩阵的转置(Transpose of the Inverse Matrix)。
本文将从数学推导、直观几何意义以及实际渲染性能优化的角度,详细解释这一设计的底层逻辑。
一、 法线变换的核心冲突
法线是一个方向向量,其根本属性是垂直于物体的表面(更精确地讲,是垂直于该点切线所在的空间平面)。
1. 旋转与平移变换
- 平移(Translation):法线代表方向,没有位置概念,因此平移不应影响法线。在齐次坐标中,我们将法线的www分量设为
0.0即可过滤平移。 - 均匀旋转(Rotation):当物体整体旋转时,表面和法线会同步旋转相同的角度。这种情况下,直接用模型矩阵的旋转部分(左上角3×33\times33×3)乘以法线是完全正确的。
2. 核心问题:非均匀缩放(Non-uniform Scaling)
当物体在不同轴向上的缩放比例不一致时,直接使用模型矩阵变换法线就会导致法线偏离垂直方向。
【 非均匀缩放对法线的影响示意图 】 原始三角形 (等腰直角) 在 X 轴拉伸 2 倍后 Y Y ▲ ▲ │ │ C ┼ (0,1) C ┼ (0,1) │ \ ◄── 法线 N (0.7, 0.7) │ \ ◄── 错误法线 N' (1.4, 0.7) (不再垂直!) │ \ │ \ └────┴─────► X └─────┴──────────► X A(0,0) B(1,0) A(0,0) B'(2,0) ▲▲▲ 正确法线应该更倾向于 Y 轴!如上图所示:
- 原始斜边的切线方向为(1,−1)(1, -1)(1,−1),法线为(1,1)(1, 1)(1,1),点积为000(正交)。
- 如果在 X 轴缩放 2 倍,斜边上的点B(1,0)B(1,0)B(1,0)变为B′(2,0)B'(2,0)B′(2,0),新斜边切线方向变为(2,−1)(2, -1)(2,−1)。
- 若直接对原法线(1,1)(1, 1)(1,1)乘以相同的缩放矩阵(X 轴乘 2),得到的新法线为(2,1)(2, 1)(2,1)。
- 验证正交性:新切线与新法线的点积为2×2+(−1)×1=3≠02 \times 2 + (-1) \times 1 = 3 \ne 02×2+(−1)×1=3=0。法线不再垂直于表面!
为了修正这种由非均匀缩放导致的畸变,必须推导出一个能让变换后的法线依然垂直于新切线的矩阵。
二、 严密的数学推导
我们采用线性代数中的标准列向量记法进行推导。
设在物体表面某点处:
- TTT为该点处的一个切线向量(Tangent Vector)。
- NNN为该点处的法线向量(Normal Vector)。
由于法线垂直于表面切线,它们的点积为零。写成矩阵乘法形式(列向量的内积为TTNT^T NTTN):
TTN=0T^T N = 0TTN=0
现在我们对物体施加模型变换矩阵MMM(在此指代去除平移后的左上角3×33\times33×3矩阵):
- 切线的变换:切线是由表面上两个无限接近的顶点相减得到的方向向量。因为顶点是由MMM变换的(P′=MPP' = M PP′=MP),所以切线也必须直接使用MMM进行变换:
T′=MTT' = M TT′=MT - 法线的变换:假设我们寻找的法线变换矩阵为GGG,则变换后的法线为:
N′=GNN' = G NN′=GN
为了保证变换后的法线N′N'N′依然与新切线T′T'T′垂直,必须满足:
(T′)TN′=0(T')^T N' = 0(T′)TN′=0
将T′=MTT' = M TT′=MT和N′=GNN' = G NN′=GN代入上式:
(MT)T(GN)=0(M T)^T (G N) = 0(MT)T(GN)=0
根据矩阵转置的性质(AB)T=BTAT(A B)^T = B^T A^T(AB)T=BTAT,展开左侧乘积:
TTMTGN=0T^T M^T G N = 0TTMTGN=0
对比初始条件TTN=0T^T N = 0TTN=0。要使上式对任意切线TTT和法线NNN都恒成立,中间的矩阵乘积MTGM^T GMTG必须为单位矩阵III:
MTG=IM^T G = IMTG=I
解方程求出法线变换矩阵GGG:
G=(MT)−1=(M−1)TG = (M^T)^{-1} = (M^{-1})^TG=(MT)−1=(M−1)T
结论:用于变换法线的矩阵GGG必须是模型矩阵MMM的逆矩阵的转置。这就是“逆矩阵的转置”的数学来源。
三、 几何直观理解
为什么“逆矩阵的转置”能纠正缩放导致的倾斜?
- 逆矩阵(Inverse)的作用:
- 逆矩阵代表的是相反的变换。
- 如果我们在 X 轴方向将物体拉伸了 2 倍(缩放因子为 2),那么在逆矩阵中,X 轴方向的缩放因子就变成了12\frac{1}{2}21。
- 这非常符合几何直观:当表面在 X 方向被拉宽、坡度变缓时,法线为了保持垂直,应该向 Y 轴方向倾斜,也就是在 X 方向收缩。
- 转置矩阵(Transpose)的作用:
- 转置操作保证了向量在正交投影下的乘法方向正确,将“行向量式”的受力分布重新映射回列向量。
四、 两种特殊情况下的简化
由于求逆矩阵(Inverse)在计算机中是比较昂贵的操作,在实际开发中,我们不需要对所有物体都计算逆转置。
情况 1:纯旋转和平移(无缩放)
如果模型矩阵只包含旋转和平移,那么其左上角的3×33\times33×3矩阵MMM是一个正交矩阵(Orthogonal Matrix)。
根据正交矩阵的定义,其转置矩阵等于逆矩阵:
MT=M−1⇒(M−1)T=(MT)T=MM^T = M^{-1} \Rightarrow (M^{-1})^T = (M^T)^T = MMT=M−1⇒(M−1)T=(MT)T=M
优化结论:当物体没有缩放时,法线变换矩阵就是MMM本身。我们可以直接使用模型矩阵的3×33\times33×3部分乘以法线。
情况 2:包含均匀缩放(Uniform Scaling)
如果物体在 X、Y、Z 轴上的缩放因子相同(设为sss),则M=sRM = s RM=sR(RRR为旋转矩阵)。它的逆转置矩阵为:
(M−1)T=((sR)−1)T=(1sR−1)T=1s(R−1)T=1sR(M^{-1})^T = \left((s R)^{-1}\right)^T = \left(\frac{1}{s} R^{-1}\right)^T = \frac{1}{s} (R^{-1})^T = \frac{1}{s} R(M−1)T=((sR)−1)T=(s1R−1)T=s1(R−1)T=s1R
优化结论:均匀缩放只会改变变换后法线向量的长度(乘以系数1s\frac{1}{s}s1),而不会改变其方向。因此,我们依然可以直接用MMM的3×33\times33×3部分来变换法线,只需在变换后将法线重新**归一化(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. 传给 Shadershader.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); }六、 总结
| 问题 | 物理/数学本质 | 解决方案 |
|---|---|---|
| 为什么普通变换矩阵不行? | 非均匀缩放会破坏切线与法线的垂直夹角。 | 必须引入与原缩放相反方向的变换。 |
| 为什么是“逆矩阵的转置”? | 基于(MT)T(GN)=0(M T)^T (G N) = 0(MT)T(GN)=0推导出MTG=IM^T G = IMTG=I,从而求得G=(M−1)TG = (M^{-1})^TG=(M−1)T。 | 完美保持正交性。 |
| 如何优化性能? | 逆矩阵计算量大,避免在 GPU 逐顶点计算。 | 在 CPU 算好后以uniform mat3形式送入 Shader。 |