Unity Shader 法线变换为什么需要逆转置矩阵?
深入理解非统一缩放场景下,直接使用模型矩阵变换法线会导致错误,以及法线矩阵的数学推导。
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
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)