· 5 分钟阅读 · 1926 字
附加动画抬错了轴:旋转增量必须与基础姿态共用坐标合同
给角色叠加一段抬臂动画,权重已经到 1,手臂却绕另一根轴摆动。参考姿态绕 Z 轴转了 90°,采样在该姿态上增加局部 X 轴 60°,基础姿态则绕 Y 轴转了 90°。同样的三个四元数,只交换一次乘法顺序,骨骼向上方向就会从 (0.866026, 0.5, 0) 变成 (0, 0.5, 0.866025)。角度没有丢,旋转轴的归属变了。

这个切片只处理单根骨骼的旋转附加层。参考旋转 R、采样旋转 S 与基础旋转 B 都描述同一根骨骼相对父骨骼的朝向,使用同一轴向约定。它们不包含平移或缩放,也没有混入世界旋转。探针使用 C# System.Numerics,通过 Vector3.Transform 确认乘积 A * B 对方向的作用顺序是先 B、后 A;不能把矩阵库的行向量记法直接套到这里。
参考姿态决定增量存在哪一侧
把采样写成 S = R * D,右侧增量就必须是 D = inverse(R) * S。这段增量记录采样相对参考的局部变化;把同一局部变化迁移到新的基础姿态,输出为 Q = B * D。固定样本因此能精确恢复 D = X(60°),再交给新的基础姿态携带。
另一种分解 S = P * R 同样合法,此时 P = S * inverse(R) 表达父坐标系中的左增量。问题发生在提取时采用 P、应用时却把它当作 D 右乘。两个量通过共轭关系 P = R * D * inverse(R) 相连,不能因为分量都能装进一个 Quaternion 就认为可互换。
若必须用左乘表达相同的局部附加效果,需要随基础姿态重新表达轴:P_B = B * D * inverse(B),再求 P_B * B,仍然得到 B * D。直接把旧参考下的 P 套到新基础上,则是在保留父坐标系增量;它可以服务另一种动画语义,但不再保证局部抬臂轴随基础姿态移动。资源导入与运行时求值必须约定这层语义,变量名里的 delta 不足以建立合同。
探针入口 .tmp/daily-labs/2026-10-28-2000/AdditiveRotation.cs 的 TryApply 把提取、加权和应用固定在同一条调用链:
using System.Numerics;
public static class AdditiveRotation
{
public static bool TryApply(Quaternion reference, Quaternion sample,
Quaternion basis, float weight, out Quaternion result)
{
result = Quaternion.Identity;
if (!float.IsFinite(weight) || weight < 0 || weight > 1 ||
!TryUnit(reference, out reference) ||
!TryUnit(sample, out sample) || !TryUnit(basis, out basis))
return false;
var delta = Quaternion.Normalize(Quaternion.Conjugate(reference) * sample);
if (delta.W < 0)
delta = -delta;
var weighted = Quaternion.Slerp(Quaternion.Identity, delta, weight);
result = Quaternion.Normalize(basis * weighted);
return true;
}
private static bool TryUnit(Quaternion value, out Quaternion unit)
{
unit = Quaternion.Identity;
if (!float.IsFinite(value.X) || !float.IsFinite(value.Y) ||
!float.IsFinite(value.Z) || !float.IsFinite(value.W))
return false;
var scale = MathF.Max(MathF.Max(MathF.Abs(value.X), MathF.Abs(value.Y)),
MathF.Max(MathF.Abs(value.Z), MathF.Abs(value.W)));
if (scale == 0)
return false;
value = new Quaternion(value.X / scale, value.Y / scale,
value.Z / scale, value.W / scale);
unit = Quaternion.Normalize(value);
return true;
}
}
TryUnit 先按最大分量缩放,再单位化,避免直接计算长度平方时上溢或下溢。测试覆盖了整体乘以 1e30 与 1e-30 的有限输入。零四元数、非有限分量、非有限权重和区间外权重返回 false,输出保持单位四元数。调用者必须根据布尔值决定是否提交,失败时保留原基础姿态;不能把失败输出当作有效的新姿态写进骨骼缓冲区。
权重只能缩小增量,不能纠正旋转轴
加权发生在单位旋转与 D 之间,Slerp(I, D, w) 得到当前权重的增量,再由 B 携带。权重 0 时基础姿态保持不变;权重 1 时得到完整局部增量;固定样本的权重 0.5 对应局部 X 轴 30°,方向结果为 (0.5, 0.866025, 0)。若先把错误轴上的增量插值到一半,只会得到更小的错误。
四元数 q 与 -q 表达同一个旋转。代码将 D 放到 W >= 0 的半球,再从单位旋转出发走短弧,防止资源中等价符号改变中间姿态。精确 180° 时两条半弧都成立,W == 0 无法选出唯一方向;跨越该边界时,仅让相邻四元数符号连续仍不足以保留长弧或绕行方向:这里每次都会重新选择最短弧。需要跨帧方向连续时,求值器还须保存上一帧状态,并明确方向、绕行或展开角政策。本探针未实现这类带状态采样。
这一实现还主动拒绝超出 [0,1] 的权重。某些动画系统允许负权重或过驱动,但那需要独立定义旋转外推、轴向和多层叠加规则,不能默默依赖某个插值函数是否夹取参数。多个附加层本身也不交换,B * D1 * D2 与 B * D2 * D1 通常不同;求值器必须保存明确层序,不能随容器遍历顺序组合。

用方向链检验乘法,而非重复计算同一个四元数
Program.cs 的 multiply-applies-right-first 先固定库语义,reference-reconstructs-sample 再验证把增量应用回参考姿态能重建采样。same-reference-no-change 约束采样等于参考时不会改变任意基础姿态。仅检查四元数长度为 1 无法发现顺序错误:三条路径都能输出合法单位四元数。
固定失败样本用向量 (0,1,0) 观察朝向,完整权重下对比如下。数值来自当天执行结果,保留六位小数:
| 组合方式 | 方向输出 |
|---|---|
B * (inverse(R) * S) |
(0.866026, 0.5, 0) |
(inverse(R) * S) * B,错误左乘 |
(0, 0.5, 0.866025) |
B * (S * inverse(R)),错误提取 |
(0, 1, 0) |
10,000 组固定种子样本不再只比较两个四元数乘积,而是逐次对方向执行 S、逆 R、B 三次变换,与输出 Q 的一次变换比较。每组还检查中性增量与 q/-q 的中间权重等价性。它能暴露因排列顺序或符号处理造成的错误;没有覆盖完整骨架重定向、引擎动画图或美术资源的实际导入链。
2026-10-28 在 .NET 9 Release 下执行:
$ cd "$WORK_DIR/.tmp/daily-labs/2026-10-28-2000"
$ dotnet build AdditiveRotationLab.csproj -c Release --nologo
Build succeeded. 0 Warning(s), 0 Error(s).
Time Elapsed 00:00:08.50
Exit code: 0
$ dotnet run --project AdditiveRotationLab.csproj -c Release --no-build --nologo
{"passed":23,"failed":0,"skipped":0,"samples":10000,"elapsed_ms":120.7863,"max_vector_error":6.9829076E-07,"fixed_input":{"reference_z":90,"delta_x":60,"basis_y":90,"vector":[0,1,0]},"correct":[0.866026,0.5,0],"wrong_left":[0,0.5,0.866025],"wrong_extraction":[0,1,0],"half_weight":[0.5,0.866025,0]}
Exit code: 0
错误路径被明确写进回归,正常路径之外还覆盖零值、NaN、无穷大、极端有限幅值及范围外权重。这些统计是数值合同验证,不是动画系统的性能评测。
对于这类局部附加层,导入产物应保存参考姿态及增量侧别,运行时接收同骨架、同父局部约定的基础姿态,并在全部旋转层完成后再计算层级世界变换。若上游只有世界姿态,或参考来自另一副骨架,必须先完成坐标转换或重定向。否则即使每个四元数都已单位化,最终手臂仍可能绕着错误的轴旋转。