循环动画的根位移:相位归零时,角色不该倒退
一段向前行走的动画,每秒循环一次,每圈前进两米。播放时间从 0.9 秒走到 1.1 秒,角色本该继续向前,根位移提取却返回了 -1.80 米。给结果取绝对值只能把倒退变成一次冲刺;真正丢失的是播放时钟跨过了一个完整周期这件事。
将根轨道限制为同一方向的平移,可以单独验证这个问题。固定关键帧为 (0秒,0米)、(0.25秒,0.2米)、(0.75秒,1.7米)、(1秒,2米),关键帧之间线性插值。它刻意采用变速步态:三段速度分别为 0.8、3、1.2 米每秒,不能用“平均每秒两米”代替局部运动。

主图只表达相位复位与持续平移的区别,不表示实测骨骼姿态。本文使用独立 C# 研究工程,未接入引擎动画播放器。
两个相位不足以描述这一段运动
0.9 秒处的轨道值为 1.7+1.2×0.15=1.88 米;1.1 秒被折回相位 0.1 后,轨道值为 0.8×0.1=0.08 米。直接相减得到 0.08-1.88=-1.80,把轨道回到起点的坐标变化也当成了角色运动。
按时间拆开就很清楚:0.9 到 1.0 秒前进 0.12 米,1.0 到 1.1 秒前进 0.08 米,合计 0.20 米。这里不能删除动画末帧来“修复接缝”,因为末帧记录的两米正是一个周期应交付的平移。
设时长为 T,展开时间为 t,周期编号 k=floor(t/T),相位 u=t/T-k,因此 0≤u<1。轨道采样记为 p(u),每圈净位移为 D=p(1)-p(0)。相对于初始轨道原点的累计位移可以写成 F(t)=kD+p(u)-p(0),两个时间端点相减后:
Delta(t0,t1) = (k1-k0) D + p(u1) - p(u0)
本例的周期差为 1,补回两米以后得到 0.20 米。若终点改为 3.1 秒,局部相位依旧是 0.1,但周期差变成 3,位移为 4.20 米。只保存上一帧与当前帧的相位,即便检测到了回绕,也无法区分跨过一圈还是三圈。
负时间同样使用向下取整。t=-0.1 应拆成 k=-1,u=0.9;截断到零会留下负相位。反向播放则交换区间方向,Delta(1.1,0.9)=-0.20,无需另加一次补圈判断。这里的负时间是周期轨道的数学延拓,不表示播放器必须允许播放开始前的内容。
采样器接收展开时间,消费端接收增量
$PROBE_PROJECT 表示本篇 RootMotionLab 项目目录。RootMotion.cs 是完整核心实现,调用链从 Program.Main 的测试进入 Delta,再进入 Split 与 Sample。时间以秒输入,相位无量纲,返回位移以米计。固定轨道的 T 与 D 显式保存;换资产时必须从同一条根轨道重新建立这两个值。
namespace LoopLab;
public static class RootMotion
{
public const double Duration = 1;
public const double Stride = 2;
public const double MaxCycles = 1_000_000;
public static (double Cycle, double Phase) Split(double time)
{
if (!double.IsFinite(time) || Math.Abs(time / Duration) > MaxCycles)
throw new ArgumentOutOfRangeException(nameof(time));
double cycle = Math.Floor(time / Duration);
return (cycle, time / Duration - cycle);
}
public static double Sample(double phase)
{
if (!double.IsFinite(phase) || phase < 0 || phase > 1)
throw new ArgumentOutOfRangeException(nameof(phase));
if (phase <= 0.25) return 0.8 * phase;
if (phase <= 0.75) return 0.2 + 3 * (phase - 0.25);
return 1.7 + 1.2 * (phase - 0.75);
}
public static double Delta(double from, double to)
{
var a = Split(from);
var b = Split(to);
return (b.Cycle - a.Cycle) * Stride + Sample(b.Phase) - Sample(a.Phase);
}
}
Sample(1) 用来验证末帧和整圈净位移;正常拆分只产生 [0,1) 内的相位。在恰好一秒处,表示从 k=0,u→1 切换为 k=1,u=0,但 F 的左右极限一致,因为 D 正好连接了轨道两端。这个条件保证位置连续,并不保证接缝速度连续:当前样本末段速度 1.2,首段速度 0.8,速度跳变仍由动画资产处理。
实现限制展开时间不超过正负一百万圈,并拒绝非有限输入。这个上限是本探针的防失控合同,不是通用播放器标准;它也不保证任意微小时间差都能表示。若长期运行需要更大的时间跨度,应由播放器分别保存整数圈数和有界相位,或使用明确频率的整数时钟,重新验证精度与溢出。单纯扩大 double 的允许范围会逐渐丢失小数相位。
计算使用周期差,再加局部差,避免先形成两个很大的累计位置再相减;输入时钟已经舍入掉的部分仍然无法恢复。采样器是纯函数,不持有角色位置,也不会防止同一区间被重复消费。播放器拥有展开时钟与上次已消费游标,运动控制器负责实际提交;只有这两个职责明确,正确增量才不会被重复累计。

用分段速度累计核对跨圈结果
测试没有只复制一次周期差公式。Program.Integrate 枚举区间经过的每个周期和每段关键帧,以“重叠时间长度乘该段速度”求和;反向区间取相反数。Program.Sweep 对 [-5,5] 秒内间隔为 1/16 秒的 161 个端点两两组合,共核对 25,921 组区间,并检查在中点拆分后的两段之和是否等于整段。
跨圈失败样本也保留明确断言,Near 要求有限结果且绝对误差不超过 1e-9 米:
Near(RootMotion.Delta(0.9, 1.1), 0.2);
Near(RootMotion.Sample(0.1) - RootMotion.Sample(0.9), -1.8);
Near(RootMotion.Delta(0.9, 3.1), 4.2);
Near(RootMotion.Delta(1.1, 0.9), -0.2);
另外覆盖整周期、负时间、零区间、边界两侧、非法相位和时钟越界。2026-09-15 在 .NET 9 Release 配置执行下列命令,构建退出码 0、零警告零错误,运行退出码 0。耗时为单次测试程序内部计时,包含测试输出,不代表引擎动画采样性能。
cd "$PROBE_PROJECT"
dotnet build RootMotionLab.csproj -c Release
dotnet run --project RootMotionLab.csproj -c Release --no-build
correct_delta=0.200000 wrong_delta=-1.800000 multi_delta=4.200000 reverse_delta=-0.200000
passed=12 failed=0 skipped=0 samples=25921 elapsed_ms=140.7003
分段可加性成立的依据是同一个 F 在中间端点抵消。对固定轨道,不同分帧可以得到相同的未受约束平移;这不意味着碰撞后的结果也分帧无关,更不意味着跨越几个周期时可以跳过途中的碰撞检查。
时间跳转还需要明确语义。正常播放从 0.9 到 3.1 秒,4.20 米是应提取的运动;编辑器拖动游标、切换动画或传送后重定位,往往只需要重设采样基准,不能把同样的时间差送给角色。此时应由时钟所有者重置已消费端点,不能让采样函数猜测“大时间差是否应该忽略”。
开篇的倒退在采样端即可修复:保留周期差,按同一轨道补回整圈净位移,再把增量交给混合与运动约束。该式适用于各圈沿同一参考方向重复的平移轨道。若每圈还会转向,后续平移的方向也随之改变,必须按约定组合完整刚体变换,不能继续用圈数乘固定向量。恢复这两米并未实现完整根运动系统,但已经给下游留下可检验的输入:同一段连续播放走过的距离,不因相位归零而凭空反向。