3.1 法线的变换

法线不能直接用模型矩阵 M 变换:非均匀缩放 diag(2,1,1) 下 (M·t)·(M·n) 实测 +3.0000 而非 0,改用逆转置 (M⁻¹)ᵀ 后恒为 +0.0000;纯旋转时两者相等,错误只在非均匀缩放下暴露。4096 个单位法线扫描给出归一化后的模长不变量 |n| ∈ [ONE−5, ONE]。

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 切线空间 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配