动画根位移混合:坐标原点不应变成角色运动
两条向前移动的动画,在同一个采样区间内分别前进 0.10 米和 0.30 米。淡化到中间权重时,角色却被算出前进 2.22 米。两条轨道都没有这段运动,问题可以在不加载骨骼、不接入碰撞系统的情况下复现:第一条根轨道从 0 开始,第二条从 10 开始,混合器把资产的坐标原点差也当成了角色应该走过的路。

这里讨论的是一个明确的动画合同:根轨道提供运动增量,角色控制器累计这些增量,资产中的绝对平移原点不拥有角色的世界位置。若系统本来就在两个世界空间目标之间驱动位置,混合绝对坐标可以是正确需求;但把同样的操作用于根运动提取,就改变了原有语义。
2.22 米来自哪里
用 A、B 表示两条根轨道,所有位置单位为米,沿同一参考方向。起点和终点分别为 A0=0, A1=0.1、B0=10, B1=10.3。两条轨道覆盖同一个模拟时间区间,B 的权重从 w0=0.4 线性增加到 w1=0.6,A 的权重始终为 1-w。这是独立 C# 项目的固定输入,没有宣称任何引擎默认采用了下面的错误算法。
先混合位置,会得到起点 0.6×0 + 0.4×10 = 4.00,终点 0.4×0.1 + 0.6×10.3 = 6.22,两者相减就是 2.22。把运算展开,可以看到多出来的项:
P1 - P0 = (1-w1) × (A1-A0) + w1 × (B1-B0)
+ (w1-w0) × (B0-A0)
最后一项在本例中为 0.2×10 = 2.00 米,只取决于权重变化和轨道起点间距。如果把 B 的整条轨道减去 10 米,它仍前进 0.30 米,错误算法却只输出 0.22 米。仅改变资产的平移原点,就改变了角色运动,这已经足以否定它在当前合同下的正确性。
即便两条轨道起点对齐,0.22 也不等于本例连续混合速度的积分。因为前两项用了终点权重 0.6;两条轨道在区间内各自恒速、权重线性变化时,正确积分使用平均权重 0.5,得到 0.5×0.10 + 0.5×0.30 = 0.20 米。原点污染和权重积分误差因此需要分别识别。
给混合器位移,而不是给控制器混合后的位置
$PROBE_PROJECT 表示本篇独立 RootMotionLab 项目目录。RootMotion.cs 中的 RootMotion.BlendDelta 是完整核心实现;调用链由 Program.Main 进入各项回归,再调用这个纯函数。它只处理一个平移分量,不包含 Unity 或 Godot 的动画采样接口。
public readonly record struct RootInterval(double Start, double End);
public static class RootMotion
{
public static double BlendDelta(RootInterval a, RootInterval b, double weight)
{
if (!double.IsFinite(a.Start) || !double.IsFinite(a.End) ||
!double.IsFinite(b.Start) || !double.IsFinite(b.End) ||
!double.IsFinite(weight) || weight < 0 || weight > 1)
throw new ArgumentOutOfRangeException(nameof(weight), "Invalid sample or weight");
double da = a.End - a.Start;
double db = b.End - b.Start;
double delta = (1 - weight) * da + weight * db;
if (!double.IsFinite(da) || !double.IsFinite(db) || !double.IsFinite(delta))
throw new ArithmeticException("Non-finite displacement");
return delta;
}
}
对 A 的起点和终点同时加上任意有限偏移,差值中的偏移就会抵消;B 可以使用另一份偏移。这个性质比“结果看起来不跳”更适合成为导入和混合模块之间的检查条件。实际浮点数只能在可表示精度内满足它,因此测试采用有限坐标范围与 1e-10 的绝对容差,并没有声称任意巨大坐标都能保留微小运动。
weight 是调用者已经确定的区间代表权重,函数不会自动猜测它应取起点、终点还是平均值。在当前恒速、线性淡化样本中传入 0.5,有明确的积分依据。输入为 NaN、无穷或越界权重时直接拒绝;有限端点相减发生溢出也不会返回位移。函数无内部状态,异常之后如何维持角色状态由调用者决定,不能把抛异常描述成已经实现完整回退。
工程接入时,轨道采样、运动混合与世界位置提交应保持三个清楚的职责。采样端按同一模拟区间取得两条轨道的增量,转换到共同方向和单位;混合端输出候选位移;角色控制器经过碰撞等约束后只累计一次。骨骼表现仍可执行自己的姿态混合,但已经提取的根平移若继续完整留在可见根骨骼中,又被对象变换累计一次,就会产生另一种重复运动。本文没有实现根骨骼剥离或碰撞,只限定进入这些步骤前的位移语义。

用原点平移做回归,再检查积分粒度
Program.WrongOrder 把错误结果也锁进回归,确保失败样本没有被“换个输入”悄悄抹掉:
Near(PositionDifference(A, B, 0.4, 0.6), 2.22);
Near(PositionDifference(A, new(0, 0.3), 0.4, 0.6), 0.22);
Near(RootMotion.BlendDelta(A, B, 0.5), 0.2);
PositionDifference 就是上述两个混合位置相减,Near 检查结果有限且绝对误差不超过容差。Program.OriginOffsets 分别给两轨道施加 -100、-10、0、10、100 米偏移,25 组组合都必须保留 0.20 米。另有零权重、满权重、不同原点的静止轨道、反向位移、非法权重、非有限样本和溢出检查。
2026-09-05 使用 .NET SDK 9.0.202,在 Release 配置下构建成功,退出码 0,0 警告、0 错误;测试命令退出码 0。下列耗时为单次检查程序内部计时,包含测试输出,不能用于判断动画系统性能。
dotnet build "$PROBE_PROJECT/RootMotionLab.csproj" -c Release
dotnet run --project "$PROBE_PROJECT/RootMotionLab.csproj" -c Release --no-build
wrong_delta=2.22 correct_delta=0.20 shifted_wrong=0.22
origin_pairs=25 coarse_delta=0.20 split_delta=0.30
passed=10 failed=0 skipped=0 elapsed_ms=41.474
最后一项 Program.NonconstantVelocity 刻意检验平均权重的局限。另取一个归一化时间区间:A 始终静止,B 前半段不动,后半段匀速前进 0.40 米;权重从 0 线性升到 1。整个区间只调用一次、传入平均权重 0.5,结果为 0.20 米。按速度变化点拆成两半,前半权重均值为 0.25、后半为 0.75,结果为 0×0.25 + 0.40×0.75 = 0.30 米。这才对应该分段恒速样本的积分。
两种计算都不受轨道原点影响,但只有足够细的时间分段保留了速度与权重的相关变化。一般情况下需要计算混合速度在时间上的积分,或采用有误差约束的子步采样;不能从“先求差”进一步推出“每帧只采两次永远足够”。
根轨道跨循环时,采样端必须先恢复跨圈累计位移;把循环后的局部坐标直接减去上一帧末尾坐标,仍会制造倒退。旋转、不同朝向和非均匀缩放也超出这个标量探针,不能逐分量照搬为完整刚体变换混合。对于同方向平移且增量语义明确的动画系统,值得固定在接口上的判断很具体:改变某条资产的平移原点,不应改变角色这一段走了多远;要调整步幅和淡化速度,应调整运动与时间积分,而非让资产起点间距参与驱动。