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 法线贴图的解压与重建 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。