4.3 朝向的插值
两帧之间朝向怎么过渡,是动画与相机控制的日常问题。最自然的想法是「起点朝向和终点朝向之间线性插值」,但朝向住在单位四元数球面上,直线插值会切过球内、速度不均;更麻烦的是四元数有双覆盖——q 与 -q 表示同一朝向,不处理符号就会绕远路。本节把 nlerp 与 slerp 的差别量化,把双覆盖的必要性演示出来,并给出一个全整数的插值实现,断言「t=0 得起始朝向、t=ONE 得终止朝向、中间时刻累积转角单调」。四元数插值在骨骼动画里是每帧都在跑的代码,工程侧的开销与取舍见 骨骼动画里的四元数
;本节只把它压成可复算的整数。
为什么不直接插值欧拉角
最省事的做法是把起止朝向都转成欧拉角,三个角度各自线性插值。它在大多数姿态下看起来没问题,却有三个隐患。第一,上一节的死锁:当 β 经过 ±90°,插值路径会穿过退化点,(γ - α) 的符号突变,朝向在中间帧突然翻面。第二,非唯一性:同一朝向有无穷多组欧拉角(α+360°、(α+180°, 180°-β, γ+180°) 等都表示同一朝向),从两帧各自解出的欧拉角可能分属不同分支,插值便走上完全错误的路径。第三,非均匀:三个角度线性变化,映射到世界角速度上并不是常速的。四元数把这三个问题一次消掉——表示唯一(只差一个符号)、没有退化点、插值走球面测地线。这也是本卷把「朝向」的运行时表示定为四元数的理由。
nlerp 与 slerp:弦与弧
nlerp(normalized linear interpolation)在四维向量空间里做线性插值再归一化,等于沿单位球面上的弦走;slerp(spherical linear interpolation)沿大圆弧走。弦短于弧,所以同样把 t 从 0 扫到 ONE,nlerp 在两端慢、中间快——它不是匀速的。
slerp 的公式用半角 Ω = acos(dot(q0, q1)):
q(t) = (sin((1-t)·Ω) · q0 + sin(t·Ω) · q1) / sin(Ω)
两个 sin 都查上一节的表,Ω 由 acos_bam 二分得到,系数用 fdiv 求商,全程定点。nlerp 更便宜(只有一次归一化),slerp 更均匀(恒定角速度),取舍取决于你更在意开销还是速度一致性。
双覆盖:q 与 -q 是同一朝向
单位四元数到朝向的映射是二对一的:q 与 -q 给出完全相同的旋转。这带来一个陷阱——若起点与终点的点积 dot(q0, q1) < 0,直接插值会沿着「远弧」走。q0 是单位四元数、q1 表示绕 Y 转 350° 时,q1 的实部 cos(175°) < 0,与 q0 点积为负;正确的做法是先把 q1 翻转成 -q1 再插值,这样插值走 10° 的近弧,而不是 350° 的远弧。翻转必须发生在插值之前,不能事后修正。
归一化与误差累积
nlerp 每步都要归一化,q_norm 用 fsqrt(q_dot(q, q)) 求模长再逐分量 fdiv。为什么不省掉这一步?因为线性插值的结果 (1-t)·q0 + t·q1 严格落在球内(除非 t=0 或 t=ONE),模长小于 1;不归一化地一路累积,四元数会越缩越小,最终不再表示纯旋转,q_to_mat 展开出的矩阵也不再正交。归一化把点重新拉回球面,代价是一次平方根(math.isqrt)加四次除法。这里的取舍与上一节一致:定点数允许几个 LSB 的模长误差(fsqrt 本身向下取整),只要不跨过「不再是单位四元数」这条线。整段插值里没有浮点,isqrt 是精确整数平方根,所以「漂移」是被钳住的、不是无限累积的。
插值参数 t 的整数化
t 也是 Q16.16:t = i * ONE // N(N 为帧数)。它天然落在 [0, ONE],于是 t=0 与 t=ONE 是两个精确端点,不需要任何「epsilon 近似相等」的判断——q_nlerp(q0, q1, 0) 逐位等于 q0、q_nlerp(q0, q1, ONE) 逐位等于 q1,断言可以直接写 deepEqual 而不是「距离小于某个阈值」。这是把插值参数也纳入定点的一个额外红利:浮点实现里 t 从 0.0 累加到 1.0 常因累加误差到不了 1.0,只能判断「接近 1」;而整数 i * ONE // N 在 i = N 时恰好是 ONE。slerp 里 (1-t) 与 t 也各是精确整数,sin_bam 的输入始终是合法的二进制角度。
实跑输出
$ /opt/homebrew/bin/python3 sim/cg_raster_04_3_interpolation.py
端点误差 nlerp=0 BAM slerp=0 BAM(0 表示 t=0/ONE 精确命中)
累积转角单调 nlerp=True slerp=True
逐帧步长 max/min nlerp=1.784 (668~1192 BAM) slerp=1.004 (964~968 BAM)
双覆盖:最短路径总转角=1792 BAM 绕远路=64044 BAM 终点同朝向=0 BAM
断言全部通过 (exit 0)
读数。第一行 端点误差 0 说明 t=0 与 t=ONE 精确命中起止朝向(归一化后正好落回原值);第二行 单调=True 说明累积转角严格递增、没有回退。第三行是 nlerp 与 slerp 的核心差别:绕 Y 转 170°、32 帧,slerp 的逐帧步长在 964968 BAM 之间(max/min = 1.004,几乎恒定),nlerp 在 6681192 BAM 之间(max/min = 1.784,两端慢、中间快)。第四行是双覆盖:取最短路径的总转角 1792 BAM(约 9.8°),不取的总转角 64044 BAM(约 351.8°)——差了一个数量级还多,而两者终点朝向完全相同(差 0 BAM),因为 q 与 -q 本就是同一朝向。
一段可复算的插值时间线
out/cg_raster_04_3_interpolation.csv 逐帧记录了 t_q16、nlerp_step_bam、slerp_step_bam 三列,共 32 行。把它读一遍就能看到两条曲线的形状:nlerp_step_bam 从两端的 668 爬到中间的 1192 再落回 668,是一条中间鼓起的弧;slerp_step_bam 几乎是一条水平线(964~968)。两列的比值就是上文报出的 1.784 与 1.004。这种「把中间过程也落盘」的做法与本卷其它章节一致:断言给结论,CSV 给过程,二者对不上就说明断言写错了。
整数插值实现
nlerp 是四元数模块里的 q_nlerp,slerp 在本节脚本里,两者都只依赖上一节的 sin_bam / acos_bam:
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_04_3_interpolation.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_04_3_interpolation.py
"""4.3 朝向的插值 —— S 层:整数 nlerp / slerp、双覆盖与最短路径。
三件事:
1. nlerp 与 slerp 的差别:nlerp 沿弦(速度不均匀,180° 附近 max/min≈1.9),
slerp 沿大圆弧(速度恒定,max/min≈1.0)。两者端点都精确。
2. 双覆盖:q 与 -q 表示同一朝向。不取最短路径时,插值会绕远路
(350° 而不是 10°)。必须**先**翻转符号再插值。
3. 整数 nlerp 的断言:t=0 得起始朝向、t=ONE 得终止朝向、累积转角严格单调。
产出:out/cg_raster_04_3_interpolation.csv + out/cg_raster_04_3_interpolation.json
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
import cg_raster_04_quat as quat # noqa: E402
import cg_raster_04_trig as trig # noqa: E402
from cglib import ONE, fdiv, fmul # noqa: E402
OUT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "out")
AX_Y = (0, ONE, 0)
def deg2bam(d):
return (d * ONE) // 360
def q_slerp(q0, q1, t):
"""整数 slerp(大圆弧匀速)。Ω = acos(dot) 为半角,全用定点与查表。"""
q1 = quat.q_shortest(q0, q1)
d = max(-ONE, min(ONE, quat.q_dot(q0, q1)))
om = trig.acos_bam(d) # 半角 Ω(BAM)
if om == 0:
return q0
s = trig.sin_bam(om)
if s == 0:
return quat.q_nlerp(q0, q1, t)
w0 = fdiv(trig.sin_bam(fmul(ONE - t, om)), s)
w1 = fdiv(trig.sin_bam(fmul(t, om)), s)
return quat.q_norm(tuple(fmul(w0, q0[i]) + fmul(w1, q1[i]) for i in range(4)))
def q_nlerp_naive(q0, q1, t):
"""不取最短路径的 nlerp(保留原始符号),用于对照双覆盖的必要性。"""
lerp = tuple(q0[i] + fmul(q1[i] - q0[i], t) for i in range(4))
return quat.q_norm(lerp)
def profile(qs):
return [quat.q_angle_bam(qs[i], qs[i + 1]) for i in range(len(qs) - 1)]
def cumulative(steps):
out, acc = [0], 0
for s in steps:
acc += s
out.append(acc)
return out
def main():
os.makedirs(OUT, exist_ok=True)
N = 32
q0 = quat.q_from_axis_angle(AX_Y, deg2bam(0)) # 单位四元数
q1 = quat.q_from_axis_angle(AX_Y, deg2bam(170)) # 绕 Y 转 170°
ts = [i * ONE // N for i in range(N + 1)]
nl = [quat.q_nlerp(q0, q1, t) for t in ts]
sl = [q_slerp(q0, q1, t) for t in ts]
nl_st, sl_st = profile(nl), profile(sl)
nl_end = quat.q_angle_bam(nl[0], q0) + quat.q_angle_bam(nl[-1], q1)
sl_end = quat.q_angle_bam(sl[0], q0) + quat.q_angle_bam(sl[-1], q1)
nl_mono = all(x < y for x, y in zip(cumulative(nl_st), cumulative(nl_st)[1:]))
sl_mono = all(x < y for x, y in zip(cumulative(sl_st), cumulative(sl_st)[1:]))
nl_ratio = max(nl_st) / min(nl_st)
sl_ratio = max(sl_st) / min(sl_st)
# 双覆盖:q1 取「远」表示(绕 Y 转 350°,w<0),最短路径应走 10°
q_far = quat.q_from_axis_angle(AX_Y, deg2bam(350))
near = [quat.q_nlerp(q0, q_far, t) for t in ts] # 内部会翻符号 -> 走 10°
naive = [q_nlerp_naive(q0, q_far, t) for t in ts] # 不翻 -> 走 350°
near_total = cumulative(profile(near))[-1]
naive_total = cumulative(profile(naive))[-1]
same_orient = quat.q_angle_bam(near[-1], naive[-1]) # 终点同朝向
assert nl_end == 0 and sl_end == 0, (nl_end, sl_end)
assert nl_mono and sl_mono
assert sl_ratio < 1.05 and nl_ratio > 1.5, (sl_ratio, nl_ratio)
assert near_total * 10 < naive_total, (near_total, naive_total)
assert same_orient == 0, same_orient
cglib.write_csv(os.path.join(OUT, "cg_raster_04_3_interpolation.csv"),
["i", "t_q16", "nlerp_step_bam", "slerp_step_bam"],
[(i, ts[i], nl_st[i], sl_st[i]) for i in range(N)])
summary = {
"nlerp_end_err_deg": nl_end * 360 // ONE,
"slerp_end_err_deg": sl_end * 360 // ONE,
"nlerp_monotone": nl_mono,
"slerp_monotone": sl_mono,
"nlerp_step_ratio": round(nl_ratio, 3),
"slerp_step_ratio": round(sl_ratio, 3),
"nlerp_step_min_bam": min(nl_st),
"nlerp_step_max_bam": max(nl_st),
"slerp_step_min_bam": min(sl_st),
"slerp_step_max_bam": max(sl_st),
"shortest_total_bam": near_total,
"naive_total_bam": naive_total,
"same_orientation_bam": same_orient,
"n_frames": N,
}
with open(os.path.join(OUT, "cg_raster_04_3_interpolation.json"), "w", encoding="utf-8") as fh:
json.dump(summary, fh, ensure_ascii=False, indent=2)
print(f"端点误差 nlerp={nl_end} BAM slerp={sl_end} BAM(0 表示 t=0/ONE 精确命中)")
print(f"累积转角单调 nlerp={nl_mono} slerp={sl_mono}")
print(f"逐帧步长 max/min nlerp={nl_ratio:.3f} ({min(nl_st)}~{max(nl_st)} BAM) slerp={sl_ratio:.3f} ({min(sl_st)}~{max(sl_st)} BAM)")
print(f"双覆盖:最短路径总转角={near_total} BAM 绕远路={naive_total} BAM 终点同朝向={same_orient} BAM")
print("断言全部通过 (exit 0)")
if __name__ == "__main__":
main()
slerp 的退化与边界
slerp 的公式在两端有奇点:Ω → 0(起止朝向几乎相同)时 sin Ω → 0,系数 sin((1-t)·Ω)/sin Ω 变成 0/0;Ω → 180° 时也一样。脚本里对此做了保护——Ω 为 0 时直接返回起点,sin Ω 为 0 时退回 nlerp。另一处边界是 dot 的钳位:acos_bam 只接受 [0, ONE] 的输入,而定点舍入可能让 dot(q0, q1) 略微超过 ONE,所以取 acos 之前先做 max(-ONE, min(ONE, d))。这些保护不是可选的健壮性装饰:没有它们,某些输入会直接抛 fdiv by zero,或让 acos_bam 走出定义域。四元数插值的实际代码里,这类边界分支往往比公式本身还长。
P 层:端点、中点与负例
TypeScript 侧断言 nlerp 的端点与中点与 Python 侧逐位相同,并验证双覆盖:qShortest 必须把「远」表示翻成相反数,取最短路径与不取的中点在四元数上必须不同。
// snippet: 4.3 P 层插值断言(完整文件 core/test/cg_raster_04_orientation.test.ts)
test("nlerp:端点精确命中,中点与 Python 侧逐位一致", () => {
assert.deepEqual(qNlerp(q0, q1, 0n), q0);
assert.deepEqual(qNlerp(q0, q1, ONE), q1);
assert.deepEqual(qNlerp(q0, q1, ONE / 2n), [60548n, 0n, 25079n, 0n]);
});
test("双覆盖:q 与 -q 同朝向,最短路径必须翻转符号", () => {
assert.ok(qDot(q0, qFar) < 0n);
assert.deepEqual(qShortest(q0, qFar), qNeg(qFar));
assert.notDeepEqual(qNlerp(q0, qFar, ONE / 2n), naiveMid);
});
$ node --test 'core/test/cg_raster_04_orientation.test.ts'
✔ nlerp:端点精确命中,中点与 Python 侧逐位一致
✔ 双覆盖:q 与 -q 同朝向,最短路径必须翻转符号
✔ 负例:更粗的表(N_SEG=64)指纹不同,说明金标准确实在约束实现
ℹ tests 7 pass 7 fail 0
小结
朝向插值的两条主线是速度与符号。nlerp 走弦、便宜但两端慢中间快(170° 行程 max/min = 1.784),slerp 走大圆弧、恒定角速度(max/min = 1.004),两者端点都精确命中。四元数双覆盖要求插值前先按点积符号把 q1 翻到近弧,否则会绕 350° 的远路——终点朝向一样,过程却完全错误。至此第 4 章收束:定点三角函数表是地基,旋转矩阵与欧拉角是第一层,四元数与万向节死锁是第二层,朝向插值是第三层,三层都跑在整数上、都有可复算的断言。下一章转入相机本身:针孔模型。
阅读导航:上一节:4.2 四元数与万向节死锁 · 下一节:5.1 针孔相机模型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。