3.1 法线的变换
站内的 glTF 资产管线 关心的是法线从 DCC 导出、进引擎、再上传 GPU 的流转;本节只问一个更窄的问题:一条法线从模型空间搬到世界空间,该乘哪个矩阵?直觉答案「乘模型矩阵 $M$」是错的,而错在哪里、错多少,都能用整数实跑出来。
法线不跟着顶点走
顶点位置 $p$ 是点,用 $M$ 变换:$p’ = M p$。法线 $n$ 不是点,它是一条约束——垂直于切平面。切平面里的任意切向量 $t$ 满足 $n \cdot t = 0$。变换后切向量变成 $M t$(这没错,切向量跟着几何走)。若法线也乘 $M$:
$$ (M n)\cdot(M t) = n^T M^T M, t $$
只有 $M^T M = I$(即 $M$ 是正交阵,纯旋转或反射)时它才等于 $n^T t = 0$。一旦有缩放或错切,$(M n)\cdot(M t) \neq 0$,法线不再垂直于它本该垂直的平面,光照随之偏掉。
逆转置的来历
要找 $n’$ 使得 $(M t)\cdot n’ = 0$ 对所有切向量 $t$ 都成立。代入 $n’ = (M^{-1})^T n$:
$$ (M t)^T (M^{-1})^T n = t^T M^T (M^{-1})^T n = t^T (M^{-1} M)^T n = t^T n = 0 $$
恒成立,与 $t$ 取什么无关。所以法线矩阵是逆转置 $(M^{-1})^T$。
实现上不必真去求逆再转置。设 $A$ 为 $M$ 的左上 $3\times 3$,有恒等式 $\operatorname{inv}(A)^T = \operatorname{adj}(A)/\det(A)$,而伴随矩阵 $\operatorname{adj}(A) = C^T$($C$ 是余子式矩阵),于是 $\operatorname{inv}(A)^T = C/\det(A)$:算九个 $2\times2$ 余子式、再逐项除以行列式即可。下面 inv_transpose3 干的就是这件事,全程没有一次浮点运算。
非均匀缩放:反例真的跑出来了
取最小反例,$x$ 方向拉伸两倍:$M = \operatorname{diag}(2, 1, 1)$。取切向量 $t=(1,-1,0)$、法线 $n=(1,1,0)$,原空间里两者点积为 0。S 层脚本把两条路都算一遍,并把结果写进 JSON:
#!/usr/bin/env python3
# file: lantern/sim/cg_shade_03_1_normal_transform.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_03_1_normal_transform.py
"""3.1 法线的变换:为什么法线要用逆转置 (M^-1)^T,而不是 M 本身。
设切平面由切向量 t 张成、法线 n 满足 n·t = 0。物体被 M 变换后切向量是 M·t;
若法线也用 M,(M·n)·(M·t) 一般不为 0,光照立刻错。取 n' = (M^-1)^T·n,则
n'^T · M^T · M · t = n^T · M^-1 · M · t = n^T · t = 0 恒成立。
数学事实:inv(A)^T = adj(A)/det(A),而 adj(A) = C^T(C 是余子式矩阵),
所以实现「法线矩阵」只需算余子式、再逐项除以行列式,不必真去求逆再转置。
产物:out/cg_shade_03_1_normal_transform.json。
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
ONE = cglib.ONE
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def inv_transpose3(m):
"""4x4 左上 3x3 的逆转置:inv(A)^T = C / det(A),C 为余子式矩阵。"""
a = [m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10]]
def d2(p, q, r, s): # 2x2 行列式 [[p, q], [r, s]]
return cglib.fmul(p, s) - cglib.fmul(q, r)
m00, m01, m02 = d2(a[4], a[5], a[7], a[8]), d2(a[3], a[5], a[6], a[8]), d2(a[3], a[4], a[6], a[7])
m10, m11, m12 = d2(a[1], a[2], a[7], a[8]), d2(a[0], a[2], a[6], a[8]), d2(a[0], a[1], a[6], a[7])
m20, m21, m22 = d2(a[1], a[2], a[4], a[5]), d2(a[0], a[2], a[3], a[5]), d2(a[0], a[1], a[3], a[4])
det = cglib.fmul(a[0], m00) - cglib.fmul(a[1], m01) + cglib.fmul(a[2], m02)
if det == 0:
raise ValueError("singular matrix")
c = [m00, -m01, m02, -m10, m11, -m12, m20, -m21, m22]
return tuple(cglib.fdiv(v, det) for v in c), det
def mat3_apply(a, v):
"""3x3(行主序)作用到 Q16.16 向量。"""
return (cglib.fmul(a[0], v[0]) + cglib.fmul(a[1], v[1]) + cglib.fmul(a[2], v[2]),
cglib.fmul(a[3], v[0]) + cglib.fmul(a[4], v[1]) + cglib.fmul(a[5], v[2]),
cglib.fmul(a[6], v[0]) + cglib.fmul(a[7], v[1]) + cglib.fmul(a[8], v[2]))
def m3(m):
return (m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10])
def sweep_normals(n):
"""确定性单位法线序列(整数哈希 -> 分量 -> v_norm),与 TS 侧逐字一致。"""
out = []
for i in range(n):
h = (i * 2654435761) & 0xFFFFFFFF
v = (((h & 0xFFFF) - 32768) << 1,
(((h >> 16) & 0xFFFF) - 32768) << 1,
(((h * 40503) & 0xFFFF) - 32768) << 1)
out.append(cglib.v_norm(v if any(v) else (ONE, 0, 0)))
return out
def main():
# 非均匀缩放 diag(2, 1, 1):M != (M^-1)^T 的最小例子
M = (2 * ONE, 0, 0, 0, 0, ONE, 0, 0, 0, 0, ONE, 0, 0, 0, 0, ONE)
invT, det = inv_transpose3(M)
t, n = (ONE, -ONE, 0), (ONE, ONE, 0) # n·t = 0
mt, mn, rn = mat3_apply(m3(M), t), mat3_apply(m3(M), n), mat3_apply(invT, n)
dot_wrong, dot_right = cglib.v_dot(mt, mn), cglib.v_dot(mt, rn)
# 纯旋转(正交阵):(M^-1)^T == M,用错矩阵也看不出问题
R = (0, 0, ONE, 0, 0, ONE, 0, 0, -ONE, 0, 0, 0, 0, 0, 0, ONE)
invT_r, det_r = inv_transpose3(R)
# 模长不变量:变换后未归一化模长会变,归一化后落在 ONE 附近
normals = sweep_normals(4096)
lens = [cglib.v_len(mat3_apply(invT, v)) for v in normals]
worst = max(abs(cglib.v_len(cglib.v_norm(mat3_apply(invT, v))) - ONE) for v in normals)
stats = {"det": det, "invT": list(invT), "M_dot_tangent": list(mt), "M_dot_normal": list(mn),
"invT_dot_normal": list(rn), "dot_Mt_Mn": dot_wrong, "dot_Mt_invTn": dot_right,
"rotation_invT_equals_M": invT_r == m3(R), "rotation_det": det_r,
"sweep_n": len(normals), "raw_len_min": min(lens), "raw_len_max": max(lens),
"norm_delta_max": worst, "example_raw_len": cglib.v_len(rn)}
with open(os.path.join(ROOT, "out", "cg_shade_03_1_normal_transform.json"), "w",
encoding="utf-8") as fh:
json.dump(stats, fh, ensure_ascii=False, indent=2)
def f(v):
return "(" + ", ".join(f"{x / ONE:+.4f}" for x in v) + ")"
print(f"M : diag(2, 1, 1) det = {det} ({det / ONE:.4f})")
print(f"(M^-1)^T : {f(invT[:3])} / {f(invT[3:6])} / {f(invT[6:])}")
print(f"tangent t : {f(t)} normal n : {f(n)} n·t = {cglib.v_dot(n, t)}")
print(f"M·t : {f(mt)}")
print(f"M·n (错) : {f(mn)} (M·t)·(M·n) = {dot_wrong / ONE:+.4f} <- 应为 0,实际不为 0")
print(f"(M^-1)^T·n (对) : {f(rn)} (M·t)·(invT·n) = {dot_right / ONE:+.4f} <- 恒为 0")
print(f"纯旋转 R_y(90) : (M^-1)^T == M ? {invT_r == m3(R)} det = {det_r}")
print(f"不归一化模长 : 例 |(M^-1)^T·n| = {cglib.v_len(rn) / ONE:.4f}")
print(f"扫描 {len(normals)} 个单位法线:")
print(f" 变换后未归一化模长范围 : [{min(lens) / ONE:.4f}, {max(lens) / ONE:.4f}]")
print(f" 归一化后 | |n| - ONE | 最大值 : {worst} ({worst / ONE:.6f})")
print(f" -> 不变量 |n| in [ONE - {worst}, ONE]")
return 0
if __name__ == "__main__":
sys.exit(main())
M : diag(2, 1, 1) det = 131072 (2.0000)
(M^-1)^T : (+0.5000, +0.0000, +0.0000) / (+0.0000, +1.0000, +0.0000) / (+0.0000, +0.0000, +1.0000)
tangent t : (+1.0000, -1.0000, +0.0000) normal n : (+1.0000, +1.0000, +0.0000) n·t = 0
M·t : (+2.0000, -1.0000, +0.0000)
M·n (错) : (+2.0000, +1.0000, +0.0000) (M·t)·(M·n) = +3.0000 <- 应为 0,实际不为 0
(M^-1)^T·n (对) : (+0.5000, +1.0000, +0.0000) (M·t)·(invT·n) = +0.0000 <- 恒为 0
纯旋转 R_y(90) : (M^-1)^T == M ? True det = 65536
不归一化模长 : 例 |(M^-1)^T·n| = 1.1180
扫描 4096 个单位法线:
变换后未归一化模长范围 : [0.5001, 1.0017]
归一化后 | |n| - ONE | 最大值 : 5 (0.000076)
-> 不变量 |n| in [ONE - 5, ONE]
盯住三行:$(M\cdot t)\cdot(M\cdot n) = +3.0000$ 是错的,$(M\cdot t)\cdot((M^{-1})^T n) = +0.0000$ 才是对的。注意行列式 $131072 = 2.0000$ 是个正数,$M$ 完全可逆、也没翻转朝向——错的是方向本身,不是符号。
为什么纯旋转会掩盖这个错
同一份脚本对绕 $Y$ 轴 90° 的旋转 $R$ 再算一次逆转置,输出 (M^-1)^T == M ? True。这不是巧合:正交阵满足 $A^{-1} = A^T$,于是 $(A^{-1})^T = A$。只要场景里没有非均匀缩放,用错矩阵也看不出任何问题——这正是这个 bug 能长期潜伏的原因。反过来说,法线一旦遇上「拉长的墙面」「压扁的地面」就立刻露馅,而这类形变在场景里随处可见。
归一化与模长不变量
即使方向对了,$(M^{-1})^T n$ 的模长一般也不再是 1:$M$ 的逆带着 $1/2$ 的因子,上面例子里 $|(M^{-1})^T n| = 1.1180$。所以变换后必须再归一化一次。
归一化之后模长落在哪里?脚本用整数哈希生成 4096 个确定性单位法线扫一遍:
- 变换后未归一化的模长范围是 $[0.5001, 1.0017]$——下界只有 0.5,差得远;
- 归一化后 $|,|n| - \text{ONE},|$ 最大只有 5,即 $7.6\times10^{-5}$。
于是本章的判据可以写成一条硬不变量:$|n| \in [\text{ONE}-5,\ \text{ONE}]$。5 这个数来自实测而不是拍的,它比「逐位相等」宽松、又比「看着差不多」严格得多。
P 层复现
TypeScript 侧用 BigInt 重写同一套整数运算,金标准是 S 层落盘的九个余子式、三个方向向量与这条不变量:
// file: lantern/core/test/cg_shade_03_1_normal_transform.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_03_1_normal_transform.test.ts'
// 3.1 法线的变换的 P 层:与 S 层 sim/cg_shade_03_1_normal_transform.py 逐项一致。
//
// 金标准整数由 S 侧独立产出:
// /opt/homebrew/bin/python3 sim/cg_shade_03_1_normal_transform.py
// 两侧是两份独立代码(Python int vs BigInt),逆转置的每一项与模长不变量必须相同。
import assert from "node:assert/strict";
import { test } from "node:test";
import { ONE } from "../src/lantern/fixed.ts";
import { type Vec3, vDot, vLen, vNorm } from "../src/lantern/vec.ts";
import { type Mat4 } from "../src/lantern/mat.ts";
import { invTranspose3, mat3Apply, normDelta } from "../src/lantern/cg_shade_03_normal.ts";
// ---- 由 S 侧产出的金标准(Q16.16 整数) ----
const GOLDEN_DET = 131072n;
const GOLDEN_INVT = [32768n, 0n, 0n, 0n, 65536n, 0n, 0n, 0n, 65536n];
const GOLDEN_MT = [131072n, -65536n, 0n];
const GOLDEN_MN = [131072n, 65536n, 0n];
const GOLDEN_INVT_N = [32768n, 65536n, 0n];
const GOLDEN_DOT_WRONG = 196608n;
const GOLDEN_RAW_LEN_MIN = 32776n;
const GOLDEN_RAW_LEN_MAX = 65648n;
const GOLDEN_NORM_DELTA_MAX = 5n;
/** 非均匀缩放 diag(2, 1, 1)。 */
const M: Mat4 = [2n * ONE, 0n, 0n, 0n, 0n, ONE, 0n, 0n, 0n, 0n, ONE, 0n, 0n, 0n, 0n, ONE];
/** 绕 Y 轴 90 度(正交阵)。 */
const R: Mat4 = [0n, 0n, ONE, 0n, 0n, ONE, 0n, 0n, -ONE, 0n, 0n, 0n, 0n, 0n, 0n, ONE];
const TANGENT: Vec3 = [ONE, -ONE, 0n];
const NORMAL: Vec3 = [ONE, ONE, 0n];
/** 与 S 层 sweep_normals 逐字一致的确定性单位法线序列。 */
function sweepNormals(n: number): Vec3[] {
const out: Vec3[] = [];
for (let i = 0; i < n; i += 1) {
const h = (BigInt(i) * 2654435761n) & 0xffffffffn;
let v: Vec3 = [
((h & 0xffffn) - 32768n) << 1n,
(((h >> 16n) & 0xffffn) - 32768n) << 1n,
(((h * 40503n) & 0xffffn) - 32768n) << 1n,
];
if (v[0] === 0n && v[1] === 0n && v[2] === 0n) v = [ONE, 0n, 0n];
out.push(vNorm(v));
}
return out;
}
test("3x3 逆转置:det 与每一项与 S 层相同", () => {
const { invT, det } = invTranspose3(M);
assert.equal(det, GOLDEN_DET);
assert.deepEqual(invT, GOLDEN_INVT);
});
test("非均匀缩放反例:法线用 M 变换后不再垂直于切向量", () => {
const { invT } = invTranspose3(M);
const m3 = [M[0], M[1], M[2], M[4], M[5], M[6], M[8], M[9], M[10]];
const mt = mat3Apply(m3, TANGENT);
const mn = mat3Apply(m3, NORMAL);
const invTn = mat3Apply(invT, NORMAL);
assert.deepEqual(mt, GOLDEN_MT);
assert.deepEqual(mn, GOLDEN_MN);
assert.deepEqual(invTn, GOLDEN_INVT_N);
assert.equal(vDot(NORMAL, TANGENT), 0n, "原空间里 n 垂直于 t");
assert.equal(vDot(mt, mn), GOLDEN_DOT_WRONG, "(M·t)·(M·n) 不为 0 —— 用 M 变换法线是错的");
assert.equal(vDot(mt, invTn), 0n, "(M·t)·((M^-1)^T·n) 恒为 0 —— 这才是对的");
});
test("纯旋转下 (M^-1)^T == M:错误只在非均匀缩放下暴露", () => {
const { invT, det } = invTranspose3(R);
assert.equal(det, ONE);
assert.deepEqual(invT, [R[0], R[1], R[2], R[4], R[5], R[6], R[8], R[9], R[10]]);
});
test("模长不变量:4096 个单位法线变换 + 归一化后 |n| 落在 [ONE-5, ONE]", () => {
const { invT } = invTranspose3(M);
const normals = sweepNormals(4096);
let rawMin: bigint | null = null;
let rawMax: bigint | null = null;
let worst = 0n;
for (const v of normals) {
const raw = vLen(mat3Apply(invT, v));
if (rawMin === null || raw < rawMin) rawMin = raw;
if (rawMax === null || raw > rawMax) rawMax = raw;
const d = normDelta(vNorm(mat3Apply(invT, v)));
if (d > worst) worst = d;
}
assert.equal(rawMin, GOLDEN_RAW_LEN_MIN);
assert.equal(rawMax, GOLDEN_RAW_LEN_MAX);
assert.equal(worst, GOLDEN_NORM_DELTA_MAX);
assert.ok(worst < 8n, `归一化模长偏差 ${worst} 应远小于 1 LSB 的几十倍`);
});
✔ 3x3 逆转置:det 与每一项与 S 层相同
✔ 非均匀缩放反例:法线用 M 变换后不再垂直于切向量
✔ 纯旋转下 (M^-1)^T == M:错误只在非均匀缩放下暴露
✔ 模长不变量:4096 个单位法线变换 + 归一化后 |n| 落在 [ONE-5, ONE]
ℹ tests 4 ℹ pass 4 ℹ fail 0
两份独立代码(Python int 对 BigInt)给出逐项相同的整数,这比「两边抄同一段公式」有说服力得多。
方向这一环闭环了:法线矩阵是 $(M^{-1})^T$ 而非 $M$,实现成「余子式除以行列式」,变换后归一化到 $[\text{ONE}-5,\text{ONE}]$。但法线贴图里的法线存的是相对量,它相对于哪个坐标系?下一节给出答案。
阅读导航:上一节:2.3 材质的组合与混合 · 下一节:3.2 切线空间 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。