4.3 朝向的插值

把 nlerp 与 slerp 的差别量化:nlerp 走弦、速度不均(170° 行程逐帧步长 max/min=1.784),slerp 走大圆弧、恒定角速度(max/min=1.004);演示四元数双覆盖——q 与 -q 同朝向,不取最短路径会绕 350° 远路,并给出全整数插值实现与端点、单调性断言。

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 针孔相机模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

  1. 14.3 中间缓冲的可视化
  2. 14.2 覆盖率与线框
  3. 14.1 深度图与法线图