结论

法线要用 $M^{-\mathsf T}$ 变换,而不是 $M$。记住这一句就够了。

① 只有存在非均匀缩放或切变时,这件事才有意义。 如果变换只由平移、旋转、均匀缩放组成,直接用 $M$ 就是对的。

② 矩阵可逆时,$M^{-\mathsf T}$ 就够了。 伴随矩阵的转置 $\operatorname{adj}(M)^{\mathsf T}$ 是更一般的写法(奇异矩阵也有定义), 但可逆时它给出的方向与 $M^{-\mathsf T}$ 完全一致,只在 $\det M = 0$(退化变换)时才多出什么。 用 $M^{-\mathsf T}$ 不必额外操心符号——符号已经含在里面了。

③ 工业界的取向是"尽量不在 shader 里算"。 Unity 和 UE 靠 CPU 端预计算;Godot 给每个实例打标记,只有非均匀缩放时才在顶点着色器里算一次。 真正的效率排序是:不算 > 用 CPU 预计算 > 换乘法顺序 > 在 shader 里算。

一、问题:法线为什么不能用同一个矩阵变换

你的顶点着色器里大概率有这么一行:

mat3 normalMatrix = transpose(inverse(mat3(model)));

它是对的。但为什么位置、切线、纹理坐标都用同一个 model,法线却要单独算一个矩阵?

一个矩阵可以一致地变换点、线、三角形,甚至切向量——因为这些量都跟着几何走。

法线不行。原因在于法线的定义方式和其他量根本不同:

法线不是"空间里指向某个方向的箭头",而是"垂直于切平面的那个量"。写出来就是

$$\mathbf{n} \cdot \mathbf{t} = 0$$

对所有切于该点的向量 $\mathbf{t}$ 成立。即:

模型经过了一串变换,这些变换可以合并成一个矩阵 $M$。切线随之变成 $\mathbf{t}' = M\mathbf{t}$。 请找出一个矩阵 $N$,使变换后的法线 $\mathbf{n}' = N\mathbf{n}$ 仍然满足 $\mathbf{n}' \cdot \mathbf{t}' = 0$。

满足的解就是 $N = M^{-\mathsf T}$。

非均匀缩放破坏垂直性:同一个点上的切线与法线,左边是原始圆,中间是用 M 变换法线(歪了 32°),右边是用 M⁻ᵀ 变换法线(保持垂直)

二、推导:从"保持垂直"到 $M^{-\mathsf T}$

跟随约束

“垂直"就是点乘为零。写成矩阵乘法就是:

$$\mathbf{n}'^{\mathsf T}\mathbf{t}' = 0$$

记号约定:本文统一按列向量处理(与 RTR4 一致),所以 $\mathbf{n}^{\mathsf T}$ 是行向量, 行向量乘列向量得到一个标量——这正是点乘。这里特意用 $\mathsf T$ 写成矩阵形式,是为了下一步能直接代入矩阵。 (注意约定差异:HLSL / UE 里是行向量,代码写成 mul(v, M);GLSL 是列向量,写成 M * v。 数学内容完全一样,只是乘法顺序反过来。)

代入 $\mathbf{n}' = N\mathbf{n}$、$\mathbf{t}' = M\mathbf{t}$,并注意 $(N\mathbf{n})^{\mathsf T} = \mathbf{n}^{\mathsf T}N^{\mathsf T}$:

$$\mathbf{n}^{\mathsf T} N^{\mathsf T} M\, \mathbf{t} = 0$$

我们希望它对所有满足 $\mathbf{n}^{\mathsf T}\mathbf{t} = 0$ 的向量对都成立。取 $N^{\mathsf T}M = I$ 显然能满足,于是

$$N = (M^{-1})^{\mathsf T} = M^{-\mathsf T}$$

这就是"逆转置”。2000 组随机矩阵(含拉伸与切变)里,直接用 $M$ 变换法线的垂直性违反率是 100%, 而 $M^{-\mathsf T}$ 是 0%——因为随机矩阵几乎不会恰好落在"旋转 + 均匀缩放"那个特殊子集里。

模型经过一串变换时不必逐级求逆:合并成 $M$ 后求一次即可,因为 $(M_1M_2)^{-\mathsf T} = M_2^{-\mathsf T}M_1^{-\mathsf T}$。

但这个解是唯一的吗

上面只证明了充分性。要谈必然性,令 $A = N^{\mathsf T}M$。 固定 $\mathbf{t}$:要求 $\mathbf{n}^{\mathsf T}(A\mathbf{t}) = 0$ 对所有 $\mathbf{n} \perp \mathbf{t}$ 成立, 这意味着 $A\mathbf{t}$ 平行于 $\mathbf{t}$。

而这个性质必须对每个 $\mathbf{t}$ 都成立。一个把每个向量都映成自身倍数的矩阵,只能是标量矩阵:

$$A = \lambda I \quad\Longrightarrow\quad N = \lambda\,M^{-\mathsf T}$$

结论:法线变换矩阵唯一到一个标量因子。

这一步不是数学洁癖,它直接解释了两件事:

  1. 为什么变换后必须重新归一化——那个 $\lambda$ 让长度不可信,而方向才是法线的全部
  2. 为什么可以用伴随矩阵顶替逆矩阵——两者差的正是这样一个标量因子(见下文"更准确的答案")

三、更准确的答案:伴随矩阵

RTR4 4.1.7 提到一个更一般的写法:把 $M^{-\mathsf T}$ 换成 $\operatorname{adj}(M)^{\mathsf T}$(伴随矩阵的转置)。 它对任何方阵都有定义,不要求可逆;两者只差一个标量因子:

$$\operatorname{adj}(M)^{\mathsf T} = (\det M)\,M^{-\mathsf T}$$

但矩阵可逆时它没有任何优势——给出的方向与 $M^{-\mathsf T}$ 完全一致 (2000 组随机矩阵:$\det>0$ 时全部相同,$\det<0$ 时全部相反,乘上符号后一致); 至于性能,把两种写法都展开成实际运算后是基本持平(见附录二)。

它真正派得上用场的地方只有 $\det M = 0$(退化变换:物体被压成平面或线)。这时 $M^{-\mathsf T}$ 要除以 0、 直接报废,而伴随仍能给出方向:

M = diag(1,2,0)(把物体压成 z = 0 平面),n = (0,0,1):
  M⁻ᵀ 版     cofactor(M)/det → 除以 0 → inf / NaN          ✗ 报废
  伴随版     (det<0 ? -1 : 1)·C·n = (0,0,2) → 归一化 (0,0,1)  ✓ 正确

顺便澄清一个常见说法:投影矩阵是可逆的——四种常见配置的行列式都非零,2000 组视图空间点 正投影再反投影的最大误差是 4.6e-13。“投影不可逆"说的是透视除法那一步(NDC 只剩 3 个分量, 解不回 4 维);真正不可逆的是退化变换。

真要在 shader 里用(GPU skinning、程序化变形),可直接抄的写法在文末附录一里, 那里也有一个只在退化变换上触发的符号陷阱。

四、什么时候你根本不用这么麻烦

RTR4 4.1.7 给了一个常被忽略的省略条件:变换只由平移 + 旋转 + 均匀缩放组成时(不含拉伸、挤压、切变),

  • 平移不影响法线(法线是向量)
  • 均匀缩放只改变长度,不改变方向
  • 旋转矩阵的转置就是它的逆 → 两次转置(或两次求逆)相互抵消

所以原矩阵 $M$ 可以直接用来变换法线。

归一化同样能省:

  • 只有平移 + 旋转:长度不变,根本不需要归一化
  • 再加均匀缩放 $s$:长度变成 $s$ 倍,除以 $s$ 即可

决策表

变换组成 法线变换 归一化
平移 不需要变换 —
平移 + 旋转 直接用 $M$ 不需要
平移 + 旋转 + 均匀缩放 直接用 $M$ 除以缩放因子
含非均匀缩放 / 切变 必须 $M^{-\mathsf T}$(或 $\operatorname{adj}^{\mathsf T}$) 必须
退化变换($\det M = 0$) 必须 $\operatorname{adj}^{\mathsf T}$ 必须
切向量 T / B 永远直接用 $M$ 视情况

「退化变换」具体指什么:某个轴的缩放为 0——物体被压成一张平面或一条线。 实际会遇到的有:缩放动画的插值经过 0(不是两端为 0,而是中间插出 0 的那一帧)、 美术为了临时隐藏把某个轴设成 0、形变动画把网格压塌。 这时 $M^{-\mathsf T}$ 要除以 0、直接报废,只有 $\operatorname{adj}(M)^{\mathsf T}$ 还能给出方向。 注意它不是"无害的错”:哪怕那一帧物体小到看不见,NaN 法线照样会被写进缓冲、污染后面的 pass。

只想拿结论的话,这张表就够了。

五、工业界怎么做

三个主流引擎的实现都能对上,思路也一致:想办法不算,而不是想办法算得更快。

Unity(CGIncludes/UnityCG.cginc,UnityObjectToWorldNormal):

inline float3 UnityObjectToWorldNormal(in float3 norm) {
#ifdef UNITY_ASSUME_UNIFORM_SCALING
    return UnityObjectToWorldDir(norm);                     // 假设均匀缩放 → 直接变换
#else
    // mul(IT_M, norm) => mul(norm, I_M) => {dot(norm, I_M.col0), ...}
    return normalize(mul(norm, (float3x3)unity_WorldToObject));
#endif
}

注意那行官方注释:他们把"用逆转置"改写成了"用逆矩阵、把向量放到乘法左边"。两者等价 ($\mathrm{mul}(\mathbf{n}, I_M) = I_M^{\mathsf T}\mathbf{n}$),但省掉一次显式转置—— 3×3 转置要做 component shuffle,而换乘法顺序是免费的。UNITY_ASSUME_UNIFORM_SCALING 走的 就是前面那条"可以直接用 $M$“的捷径。

Godot(renderer_rd/shaders/forward_clustered/scene_forward_clustered.glsl:255):

mat3 model_normal_matrix;
if (bool(instances.data[instance_index].flags & INSTANCE_FLAGS_NON_UNIFORM_SCALE)) {
    model_normal_matrix = transpose(inverse(mat3(model_matrix)));   // 只有这时才算
} else {
    model_normal_matrix = mat3(model_matrix);                       // 否则直接用
}

那 flag 由 CPU 侧设置(INSTANCE_DATA_FLAGS_NON_UNIFORM_SCALE = 1 << 4,定义在 render_forward_clustered.h:276,设置处 render_forward_clustered.cpp:974)。三家里唯一真在 shader 里算的——因为绝大多数物体 根本走不到那个分支。

UE(Engine/Shaders/Private/VertexFactoryCommon.ush:134):

float3 RotateLocalToWorld(float3 LocalDirection, float3x3 LocalToWorld, float3 InvScale)
{
    return
        InvScale.x * LocalToWorld[0] * LocalDirection.xxx +
        InvScale.y * LocalToWorld[1] * LocalDirection.yyy +
        InvScale.z * LocalToWorld[2] * LocalDirection.zzz;
}

InvScale 是每实例预计算好的逆非均匀缩放(InvNonUniformScale)。这行同时做两件事: 用"按行加权求和”(等价于乘转置)省掉转置,再用逐分量的 InvScale 抵消矩阵里的缩放。

真正的效率排序是:不算 > 用 CPU 预计算 > 用乘法顺序代替转置 > 在 shader 里算。

什么时候躲不掉

“想办法不算"有个前提:CPU 得先拿到那个矩阵。下面这些情况没这个前提:

  1. 变换在 GPU 侧合成(GPU-driven 渲染、把层级关系放进 compute shader 逐级相乘)。 矩阵从没到过 CPU,也就无从预计算它的逆转置。

  2. 程序化变形(绕轴扭转、风吹弯折、顶点位移)。这类变形不是一个矩阵,而是"位置 → 新位置” 的函数;法线要乘它雅可比矩阵的逆转置,而雅可比逐顶点都不同——没有常量矩阵可以先算好。

  3. 顶点动画带非均匀缩放(骨骼缩放、morph target 的缩放通道)。混合出来的矩阵是逐顶点的:

    M = Σ wᵢ·Mᵢ        而      invT(Σ wᵢ·Mᵢ) ≠ Σ wᵢ·invT(Mᵢ)
    

    实测最大方向差 179°(随机矩阵的极端情形;实际问题通常小得多,但两者不是同一个量,没有"误差可忽略"的保证)。想用对,就必须先混合、再求逆转置。 (UE 的 GPU skinning 实际上把法线当切线一起变换,等于默认骨骼不带非均匀缩放; 一旦骨骼上有缩放,这个误差就会显出来。)

  4. 实例数据只在 GPU 上可见(indirect draw、GPU culling 之后才知道要画哪些实例)。

前两条是"矩阵拿不到",后两条是"矩阵逐顶点不同"。出路是同一个:在 shader 里算, 但把范围压到最小——Godot 那套标记就是这个思路:分支在 CPU 侧设好,只有真正需要的物体才走进去。

“手写余子式更省"这个说法并不成立。 两者的真实执行成本基本持平,而手写版还多担一个坑。 把两种写法各自原样编译(顶点着色器)看:

写法 SPIR-V 指令数 关键指令
transpose(inverse(mat3(M))) 952 字节 17 MatrixInverse ×1(语义指令)+ 矩阵×向量 + Normalize
手写 cofactorMatrix + det + 判断 2072 字节 54 Cross ×3 + Determinant ×1 + Select ×1

注意第一行那个 17 条是假象:MatrixInverse 把展开工作推给了驱动,执行前还得展开成 $C/\det M$(约 52 条),和手写版的 54 条几乎一样——手写省下的那次除法,被多出来的判断指令抵消了。

所以写 transpose(inverse(...))。 它更短、意图更清楚,而且把"怎么展开"的决定留给驱动 (驱动可能用上硬件特性或更合适的算法)。手写展开等于把这个决定从编译器手里拿走。

(顺带:$C/\det M$ 编译出来只有 1 次除法——编译器先算一次倒数再做向量乘标量; 另外 transpose(inverse(m)) * v 里的转置是免费的,被 MatrixInverse 一起吸收了。)

小结

  • 法线的身份由"与切向量的关系"定义,所以它按 $M^{-\mathsf T}$ 而不是 $M$ 变换
  • 这个解唯一到一个标量因子——于是伴随矩阵能顶替逆矩阵,而归一化不能省
  • 但在"平移 + 旋转 + 均匀缩放"下原矩阵就是对的(均匀缩放会改长度,除一下缩放因子即可)

下次写下 transpose(inverse(...)) 之前先问一句:这个物体的变换里有非均匀缩放吗? 没有就可以省掉;有,就知道为什么省不掉。


附录一:可直接抄的实现

日常写法:法线矩阵每个物体算一次,CPU 端算好传 uniform。绝大多数项目到此为止。

// 顶点着色器
vec3 n_world = normalize(normalMatrix * n_object);   // normalMatrix 由 CPU 传入
// CPU 端(GLM)
glm::mat3 N = glm::transpose(glm::inverse(glm::mat3(model)));

要在 GPU 上算时(GPU skinning 里每根骨骼一个矩阵、程序化变形):3×3 的 $\operatorname{adj}(M)^{\mathsf T}$ 就是余子式矩阵 $C$,它的三列是原矩阵两两列的循环叉积。

// GLSL 的 mat3(a,b,c) 是「列为 a,b,c」;m[0] 是第一列
mat3 cofactorMatrix(mat3 m) {
    return mat3(cross(m[1],m[2]), cross(m[2],m[0]), cross(m[0],m[1]));
}

mat3 M = mat3(model);
float det = determinant(M);
// ⚠ 只有"负"才取反 —— 不能写成 sign(det):
//   det = 0 时 GLSL 的 sign 返回 0,会把法线乘成零向量,normalize 之后就是 NaN。
float s = (det < 0.0) ? -1.0 : 1.0;
vec3 n_world = normalize(s * cofactorMatrix(M) * n_object);

两个注意点:三对叉积必须循环移位(顺序写错会拿到转置或错的列);叉积含大数相减, 极端缩放下会掉精度。

附录二:这条链上的常见追问

为什么法线不能直接用模型矩阵变换
  → 非均匀缩放破坏垂直性(用 n·t = 0 这个约束论证)
  → 那正确的矩阵是什么(M⁻ᵀ,并指出它唯一到标量因子)
  → 为什么既要求逆又要转置(切线是"跟着坐标系走"的量,法线是"反着走"的量)
  → 什么时候可以偷懒(平移 + 旋转 + 均匀缩放)
  → 矩阵奇异、逆不存在怎么办(伴随矩阵:$​\det M = 0$ 时它仍能给出方向)
  → 切向量为什么不一样(同一个变换,两类量的变换规则相反:逆变 / 协变)
  → 负缩放下法线贴图为什么会错

参考:Tomas Akenine-Möller 等,《Real-Time Rendering》第 4 版,4.1.7 节 Normal Transform(p.65–67)。 文中所有算例与压力测试数据均可用 3×3 矩阵运算复现。