深入理解非统一缩放场景下,直接使用模型矩阵变换法线会导致错误,以及法线矩阵的数学推导。

1

什么是法线,为什么它很重要?

法线(Normal)是三维曲面在某一点处垂直于切面的单位向量。在实时渲染中,法线决定了光照计算的方向——Blinn-Phong、PBR 等光照模型都严重依赖法线的准确性。

在模型空间中定义好的法线,在渲染管线中需要随着物体的变换(旋转、缩放、平移)一同变换到世界空间或观察空间,才能与光源进行正确的点积运算。

ℹ️

关键约束:法线必须始终垂直于它对应的表面切线。一旦法线与切线不再垂直,所有依赖法线的光照计算都会产生错误的结果。

2

朴素做法:直接用模型矩阵变换法线

初学者最直觉的想法是:既然顶点坐标用模型矩阵 M变换,那法线也直接用同一个矩阵变换不就行了?

❌ 错误做法

N' = M · N

直接用模型矩阵变换法线

当 M 包含非统一缩放时,变换后的法线不再垂直于表面,导致光照错误。

✅ 正确做法

N' = (M-1)T · N

使用逆转置矩阵(法线矩阵)

法线矩阵保证变换后法线依然垂直于变换后的表面切线。

这个"直接用 M"的做法在统一缩放(或纯旋转/平移)时恰好正确,所以容易被忽视。但一旦遇到非统一缩放,问题就暴露无遗。

3

问题演示:非统一缩放下法线偏转

我们用一个具体的二维例子说明非统一缩放如何破坏法线方向。假设有一个倾斜45°的表面,其切线向量为 T = (1, 1),对应法线为 N = (-1, 1)(N⊥T)。

现在施加一个非统一缩放矩阵,X 轴缩放2倍,Y 轴不变:

⚠️

变换前:N·T = (-1)×1 + 1×1 = 0(垂直)
变换后:M·N = (-2, 1)M·T = (2, 1)
(M·N)·(M·T) = (-2)×2 + 1×1 = -4+1 = -3 ≠ 0(不再垂直!)

4

数学推导:为什么是逆转置?

现在我们从数学上严格推导出正确的法线变换矩阵。

建立约束条件

设切线向量 T,法线向量 N,它们满足 N · T = 0(垂直关系)。变换后设新切线为 T' = M·T,新法线由待求矩阵 G 给出:N' = G·N。要求 N' ⊥ T':

N' · T' = 0   ⟹   (GN)T (MT) = 0

展开转置

利用矩阵转置规则 (AB)T = BTAT

NT GT M T = 0

利用已知条件 N·T = 0

我们知道 NTT = 0。若能让 GTM = I(单位矩阵),则上式自然成立:

GTM = I   ⟹   GT = M-1   ⟹   G = (M-1)T

🎯 结论:法线矩阵

正确的法线变换矩阵是模型矩阵的逆矩阵的转置,即:

法线矩阵 = (M⁻¹)ᵀ

这就是为什么在着色器中我们通常这样写:mat3(transpose(inverse(model)))

用上面的数值例子验证:M = [[2,0],[0,1]]

5

代码实现:GLSL & C++ 示例

理论转化为实践。以下代码展示如何在 OpenGL/GLSL 中正确处理法线变换。

#version 330 core

layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
uniform  mat3 normalMatrix;// 预计算好的法线矩阵

out vec3 FragPos;
out vec3 FragNormal;

void main() {
    FragPos    = vec3(model * vec4(aPos, 1.0));
    // 使用法线矩阵变换法线,再归一化
    FragNormal = normalize(normalMatrix * aNormal);
    gl_Position = projection * view * model * vec4(aPos, 1.0);

CPU 端 — 在 C++ 中计算并传递法线矩阵

// C++ 端:使用 GLM 计算法线矩阵并传递
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/matrix_inverse.hpp>

// 每帧渲染循环中:
void render() {
    // 1. 构建模型矩阵(包含非统一缩放)
    glm::mat4 model = glm::mat4(1.0f);
    model = (2.0f, 1.0f, 0.5f));glm::scale(model, glm::vec3
    model = (0,1,0));glm::rotate(model, angle, glm::vec3

    // 2. 计算法线矩阵(CPU 端,一次性)
    glm::mat3 normalMatrix = glm::mat3(
        (model)glm::transpose(glm::inverse
    );

    // 3. 传递给着色器
    shader., model);setMat4("model"
    shader., normalMatrix);setMat3("normalMatrix"
}

片段着色器 — 使用正确变换后的法线进行光照

// 片段着色器:使用正确的法线进行 Blinn-Phong 光照
#version 330 core

in  vec3 FragPos;
in    vec3 FragNormal;// 已正确变换的法线
out vec4 FragColor;

uniform vec3 lightPos;
uniform vec3 viewPos;
uniform vec3 lightColor;

void main() {
    // 法线已归一化(顶点着色器处理),这里再保险一次
    vec3 norm    = normalize(FragNormal);
    vec3 lightDir = normalize(lightPos - FragPos);

    // 漫反射:N · L(法线准确,光照才准确)
    float dot(norm, lightDir), 0.0);diff = max(
    vec3 diffuse  = diff * lightColor;

    // Blinn-Phong 镜面反射
    vec3 viewDir  = normalize(viewPos - FragPos);
    vec3 halfDir  = normalize(lightDir + viewDir);
    float max(dot(norm, halfDir), 0.0), 32.0);spec = pow(
    vec3 specular = spec * lightColor;

    vec3 result = (diffuse + specular) * vec3(1.0, 0.8, 0.6);
    FragColor = );vec4(result, 1.0
}

6

性能优化与实践建议

在着色器中每帧为每个顶点计算 transpose(inverse(model)) 是非常昂贵的操作(矩阵求逆是 O(n³)),应尽量在 CPU 端预计算。

💡

特殊情况:如果你确定模型矩阵只包含旋转和统一缩放(即没有非统一缩放),可以直接用 mat3(model),因为旋转矩阵的逆转置就是它本身(正交矩阵性质:R⁻¹ = Rᵀ,所以 (R⁻¹)ᵀ = R)。

7

总结

🔑

核心要点回顾:
① 法线不是普通向量,不能直接用模型矩阵变换
② 非统一缩放会破坏法线与切线的垂直关系
③ 数学推导:法线矩阵 = 模型矩阵的逆矩阵的转置 (M⁻¹)ᵀ
④ 在 CPU 端预计算,以 mat3 Uniform 传入着色器,避免 GPU 端求逆
⑤ 纯旋转/统一缩放时 mat3(M) 即可,(M⁻¹)ᵀ = M

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐