骨骼动画四元数插值:同一旋转的负号为何会绕长弧

两段姿态只相差 20 度,混合到一半时,骨骼却几乎转了 170 度。关键帧本身没有错:动画压缩或导入流程只是把终点四元数从 q 写成了 -q。这两个四维向量符号相反,却描述同一个三维旋转;如果混合器直接对四个分量做线性插值,表示层的负号就会变成一条真实的长路径。

骨骼关节的短弧与错误长弧插值

固定样本从单位旋转走到绕 Z 轴 20 度,终点故意使用 -q 保存。起点与终点的点积是 -0.9848077。在 t=0.5 处,朴素 NLERP 得到的旋转角为 169.99997 度;先统一半球后,中点为 9.99997 度。视觉上的翻转并非欧拉角回绕,也不是骨骼层级矩阵出错,它在姿态进入层级计算之前已经发生。

旋转相同,不代表插值端点相邻

单位四元数位于四维单位球面,q-q 是球面上的对跖点。三维旋转把这两个点识别为同一个姿态,但线性插值只看四维坐标。负点积表示两个端点落在相反半球;此时从 from 向原始 to 混合,会沿较远的表示方向接近终点。

修正只需要在插值前选择终点的等价表示。若 dot(from, to) < 0,把终点四个分量同时取反,再执行分量插值与归一化。QuaternionBlend.cs 的关键实现如下:

public static Quaternion ShortestNlerp(Quaternion from, Quaternion to, float t)
{
    Validate(from, nameof(from));
    Validate(to, nameof(to));
    if (!float.IsFinite(t) || t < 0f || t > 1f)
        throw new ArgumentOutOfRangeException(nameof(t));

    from = Quaternion.Normalize(from);
    to = Quaternion.Normalize(to);
    if (Quaternion.Dot(from, to) < 0f)
        to = new Quaternion(-to.X, -to.Y, -to.Z, -to.W);

    var mixed = new Quaternion(
        from.X + (to.X - from.X) * t,
        from.Y + (to.Y - from.Y) * t,
        from.Z + (to.Z - from.Z) * t,
        from.W + (to.W - from.W) * t);
    if (mixed.LengthSquared() < 1e-12f)
        throw new InvalidOperationException("Quaternion blend collapsed to zero length.");
    return Quaternion.Normalize(mixed);
}

输入归一化属于接口合同,而不只是数值清理。动画资产、网络姿态或程序化 IK 可能交付带缩放误差的四元数;点积符号应在单位四元数之间判断。输出仍需归一化,因为 NLERP 的线段中间点不在单位球面上。这里把权重限定在 [0,1],避免外插进入另一套未经验证的退化条件。

负点积端点在朴素 NLERP 与半球修正后的角度路径

q 混到 -q 时,朴素算法没有可归一化的中点

长弧还不是最坏情况。如果两个关键帧分别保存同一旋转的 q-q,朴素分量插值在 t=0.5 正好得到 (0,0,0,0)。零四元数不能归一化,后续矩阵可能出现非有限值,也可能由库函数静默返回无意义结果。半球修正会先把 -q 翻回 q,整个区间保持同一姿态。

点积恰为零时,两条半球路径长度相等,没有数学上的唯一短弧。当前实现固定在 dot < 0 时才翻转,因此零点积保留输入符号。这个规则不声称选择了更自然的动作,却能避免不同平台因 <= 0、符号位或近零扰动产生不同结果。若动画系统需要跨帧符号连续,还应在导入或采样缓存写入时,让每个新关键帧与前一帧保持非负点积;运行时检查仍应保留,防止多个动画源在混合边界重新引入异号表示。

多路混合还需要一个明确参考。若四个姿态各自同第一个输入比较符号,结果会依赖输入排序;权重变化导致参考源切换时,符号也可能跳变。更稳妥的做法是由姿态混合节点保存本次求值的参考四元数,所有有效输入先对齐到它的半球,再按确定顺序累加并归一化。参考权重降为零时,应按稳定的输入编号选择下一个参考,而不是依赖容器遍历顺序。这个约束属于动画图求值器,资产节点只交付姿态与权重,不拥有混合历史。

半球修正也不能掩盖坏数据。零长度、NaN 与无穷分量没有合法旋转含义,继续归一化只会把故障扩散到整条骨骼链。探针选择在接口边界抛出异常;实时发行版本可以改为记录骨骼编号并回退到绑定姿态,但回退必须同时标记当前姿态无效,避免下一层把替代值误认为连续历史。

测试证明的是路径合同,不是角速度质量

探针覆盖负点积中点、相反表示塌缩、零点积确定性、端点保持、非单位输入,以及零值、NaN 和越界权重。执行结果为:

$ cd $WORK_DIR/.tmp/daily-labs/quaternion-blend && dotnet build -c Release --no-restore
Build succeeded. 0 Warning(s), 0 Error(s). Exit code: 0

$ dotnet run -c Release --no-build
passed=15 failed=0 skipped=0 elapsedMs=52.0169
fixed: dot=-0.9848077 naiveDegrees=169.99997 correctedDegrees=9.99997 targetDegrees=10
boundary: oppositeRepresentationNaive=zero-length zeroDotPolicy=keep-endpoint-sign
Exit code: 0

NLERP 的代价低,端点正确,加入半球修正后也能保证走短表示路径,但它不提供恒定角速度。大角度镜头动画、武器瞄准或需要精确时间参数化的关节,应在同一半球选择规则之后使用 SLERP;若误差预算允许,也可以按点积阈值在两者之间切换。无论采用哪种插值器,半球选择都应位于动画混合边界,由姿态系统统一拥有,不能让每根骨骼、每个调用点自行决定。

当前探针只验证单个关节的局部旋转,没有覆盖双四元数蒙皮、加法动画、多路权重归一化与根骨骼位移。它仍给出了可以直接进入动画运行时的约束:缓存和传输可以自由选择 q-q,插值接口必须消除这种表示自由度;否则资产内容没有改变,压缩、重采样或跨系统拷贝也足以让骨骼突然绕远路。