3.2 切线空间

切线空间从三角形的三个顶点位置与三个 UV 解出:标准 UV 下 denom=65536、T=(2,0,0)、B=(0,0,2),手性 w=-1;把 u 反向令 denom=-65536 后 T 翻成 (-1,0,0)、w 变 +1。相邻面平均出的切线 T_avg=(1,0.5,0) 模长 1.1180、n·T_avg=0.5,Gram-Schmidt 后回到正交单位基。

3.2 切线空间

站内的 glTF 资产管线 里,切线(TANGENT)是和 NORMAL、TEXCOORD_0 并列的顶点属性,由导出器算好、打包上传;本节要回答的是这些切线究竟怎么从几何算出来,以及算错一个符号会怎样。法线贴图里存的不是世界空间的绝对方向,而是相对于表面局部坐标系的偏移,那个局部坐标系就是切线空间。上一节把法线变换解决了,这一节把「法线相对于什么」补上。

从三角形的顶点位置与 UV 解出 T、B

一个三角形给出三个顶点位置 $p_0,p_1,p_2$ 与三个 UV。把「UV 沿 $u$ 增大的方向」当作切向量 $T$、「沿 $v$ 增大的方向」当作副切线 $B$;记 $e_1 = p_1 - p_0$、$e_2 = p_2 - p_0$,就有

$$ e_1 = \Delta u_1, T + \Delta v_1, B,\qquad e_2 = \Delta u_2, T + \Delta v_2, B $$

这是关于 $T,B$ 的 $2\times2$ 线性系统。解出来(令 $\text{denom} = \Delta u_1 \Delta v_2 - \Delta u_2 \Delta v_1$,$r = 1/\text{denom}$):

$$ T = (e_1 \Delta v_2 - e_2 \Delta v_1), r,\qquad B = (e_2 \Delta u_1 - e_1 \Delta u_2), r $$

全整数实现,$r$ 用一次 fdiv(向零截断)。注意 $\text{denom}$ 的符号就是 UV 朝向的符号,后面还要用它。

两个必须处理的坑

坑一:逐顶点切线是平均出来的。 单个三角形解出的 $T,B$ 本来就在切平面内,但真实网格里同一条边被两个三角形共用,切线要在相邻面上累加再平均才存进顶点缓冲。平均之后的 $T$ 不再严格垂直于 $n$,所以必须用 Gram-Schmidt 把法线分量削掉:$T’ = \text{normalize}\big(T - n,(n\cdot T)\big)$。

坑二:手性。 镜像 UV($u$ 反向)会让 $\text{denom}$ 变号、切线反向,切线空间从右手系翻成左手系。约定用一个标量 $w$ 记下符号:$w = \text{sign}\big((n\times T’)\cdot B\big)$;重建时用 $B’ = w,(n \times T’)$ 把翻转补回来。漏掉 $w$,法线贴图左右就反了——而且反得很隐蔽,因为凹凸感还在。

S 层实跑

脚本三个算例:标准 UV、镜像 UV、需要正交化的平均切线。

#!/usr/bin/env python3
# file: lantern/sim/cg_shade_03_2_tangent_space.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_03_2_tangent_space.py
"""3.2 切线空间:从三角形的顶点位置与 UV 求切线/副切线,并做 Gram-Schmidt 正交化。

一个三角形给出三个顶点位置 p0/p1/p2 与三个 UV。把「UV 沿 u 增大的方向」当作切向量 T、
沿 v 增大的方向当作副切线 B,可解出 2x2 线性系统(e1 = p1-p0,e2 = p2-p0):

    e1 = duv1.x * T + duv1.y * B
    e2 = duv2.x * T + duv2.y * B

令 denom = duv1.x*duv2.y - duv2.x*duv1.y、r = 1/denom,则

    T = (e1 * duv2.y - e2 * duv1.y) * r
    B = (e2 * duv1.x - e1 * duv2.x) * r

两个坑:
  1. 单个三角形解出的 T、B 本来就在切平面内,但**逐顶点切线是在相邻面上平均出来的**
     (要存进顶点缓冲),平均后的 T 不再严格垂直于 n —— 所以必须 Gram-Schmidt 削掉 n 分量。
  2. **手性**:镜像 UV(u 反向)会让切线空间变成左手系。约定 w = sign((n x T)·B) 记下符号,
     重建时用 B = w * (n x T) 把翻转补回来,否则法线贴图左右会反。

全整数(Q16.16),复用 sim/cglib.py 的 fmul / fdiv / v_dot / v_cross / v_norm。
产物:out/cg_shade_03_2_tangent_space.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
HALF = ONE >> 1
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def tangent_frame(p0, p1, p2, uv0, uv1, uv2):
    """从三角形解出未正交化的 (T, B),返回 (T, B, denom, r)。运算顺序与 TS 侧逐字一致。"""
    e1, e2 = cglib.v_sub(p1, p0), cglib.v_sub(p2, p0)
    d1 = (uv1[0] - uv0[0], uv1[1] - uv0[1])
    d2 = (uv2[0] - uv0[0], uv2[1] - uv0[1])
    denom = cglib.fmul(d1[0], d2[1]) - cglib.fmul(d2[0], d1[1])
    if denom == 0:
        raise ValueError("degenerate UV mapping")
    r = cglib.fdiv(ONE, denom)
    t = tuple(cglib.fmul(e1[i], d2[1]) - cglib.fmul(e2[i], d1[1]) for i in range(3))
    b = tuple(cglib.fmul(e2[i], d1[0]) - cglib.fmul(e1[i], d2[0]) for i in range(3))
    return (tuple(cglib.fmul(x, r) for x in t),
            tuple(cglib.fmul(x, r) for x in b), denom, r)


def orthonormalize(n, t_raw):
    """Gram-Schmidt:T' = normalize(T - n (n·T)),返回 (T', n·T)。"""
    proj = cglib.v_dot(n, t_raw)
    t_orth = cglib.v_sub(t_raw, tuple(cglib.fmul(n[i], proj) for i in range(3)))
    return cglib.v_norm(t_orth), proj


def handedness(n, t_hat, b_tri):
    """w = sign((n x T')·B_tri):+1 右手系、-1 左手系(镜像 UV)。"""
    c = cglib.v_dot(cglib.v_cross(n, t_hat), b_tri)
    return 1 if c > 0 else (-1 if c < 0 else 0)


def bitangent(n, t_hat, w):
    """B' = w * (n x T'),保证 {T', B', n} 正交且模长均为 ONE。"""
    c = cglib.v_cross(n, t_hat)
    return tuple(c[i] * w for i in range(3))


def main():
    p0, p1, p2, p3 = (0, 0, 0), (2 * ONE, 0, 0), (2 * ONE, 0, 2 * ONE), (0, 0, 2 * ONE)
    n = (0, ONE, 0)
    uv_a = ((0, 0), (ONE, 0), (ONE, ONE), (0, ONE))     # 标准:u 随 x 增大
    uv_b = ((0, 0), (-ONE, 0), (-ONE, ONE), (0, ONE))   # 镜像:u 反向

    Ta, Ba, den_a, r_a = tangent_frame(p0, p1, p2, uv_a[0], uv_a[1], uv_a[2])
    Tb, Bb, den_b, r_b = tangent_frame(p0, p1, p2, uv_b[0], uv_b[1], uv_b[2])
    Ta2, Ba2, _, _ = tangent_frame(p0, p2, p3, uv_a[0], uv_a[2], uv_a[3])

    def frame(T, B):
        th = cglib.v_norm(T)
        w = handedness(n, th, B)
        return th, bitangent(n, th, w), w

    tha, bha, wa = frame(Ta, Ba)
    thb, bhb, wb = frame(Tb, Bb)
    tha2, bha2, wa2 = frame(Ta2, Ba2)

    # 需正交化的算例:相邻面的平均切线带了 0.5 的法线分量
    t_avg = (ONE, HALF, 0)
    t_hat_c, proj_c = orthonormalize(n, t_avg)
    w_c = handedness(n, t_hat_c, Ba)
    b_hat_c = bitangent(n, t_hat_c, w_c)

    def chk(t, b):
        return {"n_dot_t": cglib.v_dot(n, t), "n_dot_b": cglib.v_dot(n, b),
                "t_dot_b": cglib.v_dot(t, b), "len_t": cglib.v_len(t), "len_b": cglib.v_len(b)}

    chk_a, chk_c = chk(tha, bha), chk(t_hat_c, b_hat_c)
    stats = {
        "case_standard": {"T_raw": list(Ta), "B_raw": list(Ba), "denom": den_a,
                          "T_hat": list(tha), "B_hat": list(bha), "w": wa},
        "case_mirrored": {"T_raw": list(Tb), "B_raw": list(Bb), "denom": den_b,
                          "T_hat": list(thb), "B_hat": list(bhb), "w": wb},
        "case_triangle2": {"T_hat": list(tha2), "B_hat": list(bha2), "w": wa2},
        "case_gram_schmidt": {"T_avg": list(t_avg), "n_dot_T_avg": proj_c, "T_hat": list(t_hat_c),
                              "B_hat": list(b_hat_c), "w": w_c, "len_T_avg": cglib.v_len(t_avg)},
        "check_standard": chk_a, "check_gram_schmidt": chk_c,
    }
    with open(os.path.join(ROOT, "out", "cg_shade_03_2_tangent_space.json"), "w",
              encoding="utf-8") as fh:
        json.dump(stats, fh, ensure_ascii=False, indent=2)

    def fv(v):
        return "(" + ", ".join(f"{x / ONE:+.4f}" for x in v) + ")"

    print(f"n                : {fv(n)}")
    print(f"[标准 UV]  denom = {den_a} ({den_a / ONE:.1f})  r = {r_a} ({r_a / ONE:.1f})")
    print(f"  T_raw          : {fv(Ta)}   B_raw : {fv(Ba)}")
    print(f"  T_hat          : {fv(tha)}   B_hat : {fv(bha)}   w = {wa:+d}")
    print(f"[镜像 UV]  denom = {den_b} ({den_b / ONE:.1f})  r = {r_b} ({r_b / ONE:.1f})")
    print(f"  T_hat          : {fv(thb)}   B_hat : {fv(bhb)}   w = {wb:+d}   <- u 反向后 w 翻转")
    print(f"[第 2 个三角形]  T_hat : {fv(tha2)}   B_hat : {fv(bha2)}   w = {wa2:+d}")
    print(f"[Gram-Schmidt]  T_avg = {fv(t_avg)}  |T_avg| = {cglib.v_len(t_avg) / ONE:.4f}")
    print(f"  n·T_avg        = {proj_c / ONE:+.4f}  <- 平均切线带法线分量")
    print(f"  T_hat          : {fv(t_hat_c)}  |T_hat| = {cglib.v_len(t_hat_c) / ONE:.4f}")
    print(f"  B_hat          : {fv(b_hat_c)}   w = {w_c:+d}")
    print("正交性检查(标准):n·T={n_dot_t}  n·B={n_dot_b}  T·B={t_dot_b}".format(**chk_a))
    print("正交性检查(G-S)  :n·T={n_dot_t}  n·B={n_dot_b}  T·B={t_dot_b}".format(**chk_c))
    return 0


if __name__ == "__main__":
    sys.exit(main())
n                : (+0.0000, +1.0000, +0.0000)
[标准 UV]  denom = 65536 (1.0)  r = 65536 (1.0)
  T_raw          : (+2.0000, +0.0000, +0.0000)   B_raw : (+0.0000, +0.0000, +2.0000)
  T_hat          : (+1.0000, +0.0000, +0.0000)   B_hat : (+0.0000, +0.0000, +1.0000)   w = -1
[镜像 UV]  denom = -65536 (-1.0)  r = -65536 (-1.0)
  T_hat          : (-1.0000, +0.0000, +0.0000)   B_hat : (+0.0000, +0.0000, +1.0000)   w = +1   <- u 反向后 w 翻转
[第 2 个三角形]  T_hat : (+1.0000, +0.0000, +0.0000)   B_hat : (+0.0000, +0.0000, +1.0000)   w = -1
[Gram-Schmidt]  T_avg = (+1.0000, +0.5000, +0.0000)  |T_avg| = 1.1180
  n·T_avg        = +0.5000  <- 平均切线带法线分量
  T_hat          : (+1.0000, +0.0000, +0.0000)  |T_hat| = 1.0000
  B_hat          : (+0.0000, +0.0000, +1.0000)   w = -1
正交性检查(标准):n·T=0  n·B=0  T·B=0
正交性检查(G-S)  :n·T=0  n·B=0  T·B=0

三处值得盯:

  • 标准 UV 下 $\text{denom} = 65536$、$T=(2,0,0)$、$B=(0,0,2)$,归一化后 $T’=(1,0,0)$、$B’=(0,0,1)$,$w = -1$;
  • 把 $u$ 反向,$\text{denom}$ 变成 $-65536$,$T’$ 翻成 $(-1,0,0)$,$w$ 从 $-1$ 变成 $+1$——这就是镜像 UV 必须记 $w$ 的理由;
  • Gram-Schmidt 算例里平均切线 $T_{avg}=(1,0.5,0)$ 模长 1.1180、$n\cdot T_{avg} = 0.5$,正交化后 $T’=(1,0,0)$、模长 1.0000、$n\cdot T’ = 0$。

三个算例的正交性检查都是 n·T=0 n·B=0 T·B=0,说明解出的基确实是正交单位基。

P 层复现

// file: lantern/core/test/cg_shade_03_2_tangent_space.test.ts
// tier: P
// verify: node --test 'core/test/cg_shade_03_2_tangent_space.test.ts'
// 3.2 切线空间的 P 层:与 S 层 sim/cg_shade_03_2_tangent_space.py 逐项一致。
//
// 金标准整数由 S 侧独立产出:
//   /opt/homebrew/bin/python3 sim/cg_shade_03_2_tangent_space.py
// 重点断言三件事:① 三角形公式解出的 T/B;② 镜像 UV 让手性 w 翻转;
// ③ Gram-Schmidt 把平均切线里的法线分量削掉,得到正交基。

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 { bitangent, handedness, orthonormalize, tangentFrame } from "../src/lantern/cg_shade_03_normal.ts";

const HALF = ONE >> 1n;

// 平面四边形(XZ 平面,法线 +Y)
const P0: Vec3 = [0n, 0n, 0n];
const P1: Vec3 = [2n * ONE, 0n, 0n];
const P2: Vec3 = [2n * ONE, 0n, 2n * ONE];
const P3: Vec3 = [0n, 0n, 2n * ONE];
const N: Vec3 = [0n, ONE, 0n];

const UV_A: [bigint, bigint][] = [[0n, 0n], [ONE, 0n], [ONE, ONE], [0n, ONE]];
const UV_B: [bigint, bigint][] = [[0n, 0n], [-ONE, 0n], [-ONE, ONE], [0n, ONE]];

// ---- S 侧金标准 ----
const GOLDEN_T_RAW = [131072n, 0n, 0n];
const GOLDEN_B_RAW = [0n, 0n, 131072n];
const GOLDEN_DENOM_A = 65536n;
const GOLDEN_T_HAT = [65536n, 0n, 0n];
const GOLDEN_B_HAT = [0n, 0n, 65536n];
const GOLDEN_W_STD = -1;
const GOLDEN_DENOM_B = -65536n;
const GOLDEN_T_HAT_MIRROR = [-65536n, 0n, 0n];
const GOLDEN_W_MIRROR = 1;
const GOLDEN_T_AVG = [65536n, 32768n, 0n];
const GOLDEN_N_DOT_T_AVG = 32768n;
const GOLDEN_LEN_T_AVG = 73271n;

test("三角形公式:标准 UV 下解出 T、B 与 S 层相同", () => {
  const { T, B, denom } = tangentFrame(P0, P1, P2, UV_A[0], UV_A[1], UV_A[2]);
  assert.equal(denom, GOLDEN_DENOM_A);
  assert.deepEqual(T, GOLDEN_T_RAW);
  assert.deepEqual(B, GOLDEN_B_RAW);
});

test("正交化后的切线基:单位长度、两两垂直", () => {
  const { T, B } = tangentFrame(P0, P1, P2, UV_A[0], UV_A[1], UV_A[2]);
  const tHat = vNorm(T);
  const w = handedness(N, tHat, B);
  const bHat = bitangent(N, tHat, w);
  assert.deepEqual(tHat, GOLDEN_T_HAT);
  assert.deepEqual(bHat, GOLDEN_B_HAT);
  assert.equal(w, GOLDEN_W_STD);
  assert.equal(vDot(N, tHat), 0n);
  assert.equal(vDot(N, bHat), 0n);
  assert.equal(vDot(tHat, bHat), 0n);
  assert.equal(vLen(tHat), ONE);
  assert.equal(vLen(bHat), ONE);
});

test("镜像 UV:切线反向,手性 w 翻转", () => {
  const { T, B, denom } = tangentFrame(P0, P1, P2, UV_B[0], UV_B[1], UV_B[2]);
  assert.equal(denom, GOLDEN_DENOM_B);
  const tHat = vNorm(T);
  const w = handedness(N, tHat, B);
  assert.deepEqual(tHat, GOLDEN_T_HAT_MIRROR);
  assert.equal(w, GOLDEN_W_MIRROR, "u 反向后 w 从 -1 翻到 +1");
  assert.equal(vDot(N, tHat), 0n);
});

test("同一四边形第二个三角形给出同一组切线基", () => {
  const { T, B } = tangentFrame(P0, P2, P3, UV_A[0], UV_A[2], UV_A[3]);
  const tHat = vNorm(T);
  assert.deepEqual(tHat, GOLDEN_T_HAT);
  assert.equal(handedness(N, tHat, B), GOLDEN_W_STD);
});

test("Gram-Schmidt:平均切线带法线分量,正交化后削平", () => {
  const tAvg: Vec3 = [ONE, HALF, 0n];
  const { tHat, proj } = orthonormalize(N, tAvg);
  assert.equal(vLen(tAvg), GOLDEN_LEN_T_AVG, "平均切线模长 1.1180");
  assert.equal(proj, GOLDEN_N_DOT_T_AVG, "n·T_avg = 0.5,不为 0");
  assert.deepEqual(tAvg, GOLDEN_T_AVG);
  assert.deepEqual(tHat, GOLDEN_T_HAT);
  assert.equal(vDot(N, tHat), 0n, "正交化后 n·T' = 0");
  assert.equal(vLen(tHat), ONE);
});
✔ 三角形公式:标准 UV 下解出 T、B 与 S 层相同
✔ 正交化后的切线基:单位长度、两两垂直
✔ 镜像 UV:切线反向,手性 w 翻转
✔ 同一四边形第二个三角形给出同一组切线基
✔ Gram-Schmidt:平均切线带法线分量,正交化后削平
ℹ tests 5  ℹ pass 5  ℹ fail 0

到这里切线空间就闭环了:$T,B$ 从三角形几何解出,平均后的偏差用 Gram-Schmidt 修正,镜像 UV 用手性 $w$ 补回。下一步是让这份局部坐标系真正派上用场——把法线贴图里那三个 8 位通道还原成世界空间的方向。

阅读导航:上一节:3.1 法线的变换 · 下一节:3.3 法线贴图的解压与重建 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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