Root Motion 撞墙后谁决定位置:动画只提交期望位移
冲刺动画在这一帧给出 1.20 m 的 Root Motion,角色前方 0.75 m 就是墙。若动画系统先把 Transform 推到终点,角色控制器再尝试修正碰撞,渲染、玩法与网络快照可能在同一帧读到三个位置:动画看到 1.20 m,碰撞结果只允许 0.75 m,下一次同步又把角色拉回。脚底滑动只是可见症状,更严重的问题是没有一个明确对象拥有“这一帧实际移动了多少”。

本文固定一个一维切片,把旋转、台阶和斜坡暂时拿掉。动画采样帧 100 提出 1.20 m,碰撞 Sweep 返回最多 0.75 m,于是角色位置只能增加 0.75 m,剩余 0.45 m 作为动画与实际运动的差额保留下来。这个差额可以交给步幅缩放、Motion Warping 或下一段动画决策,却不能绕过墙面悄悄补到 Transform。
Root Motion 是提案,不是已经发生的移动
动画图拥有 Clip 时间与姿态采样,因此它有资格产生 MotionSample;碰撞世界拥有可通行空间,因此只有角色运动提交层能把样本变成 MotionCommit。关键边界是 Position、LastCommittedFrame 和 LastCommit 必须一起更新:Sweep 失败时三者都保持旧值,不能出现位置已变而提交帧仍旧的半状态。
探针的入口为 $LAB_ROOT/RootMotionCommitter.cs 中的 RootMotionCommitter.TryCommit。完整核心实现如下:
public readonly record struct MotionSample(long Frame, float DesiredDelta);
public readonly record struct SweepResult(bool Succeeded, float AllowedDelta);
public readonly record struct MotionCommit(
long Frame,
float DesiredDelta,
float ActualDelta,
float ResidualDelta,
float Position);
public enum CommitResult
{
Committed,
StaleFrame,
SweepFailed
}
public sealed class RootMotionCommitter(float initialPosition = 0)
{
public float Position { get; private set; } = initialPosition;
public long LastCommittedFrame { get; private set; } = -1;
public MotionCommit? LastCommit { get; private set; }
public CommitResult TryCommit(
MotionSample sample,
Func<float, SweepResult> sweep)
{
if (sample.Frame <= LastCommittedFrame)
return CommitResult.StaleFrame;
SweepResult result;
try
{
result = sweep(sample.DesiredDelta);
}
catch
{
return CommitResult.SweepFailed;
}
if (!result.Succeeded || result.AllowedDelta < 0 ||
result.AllowedDelta > sample.DesiredDelta)
{
return CommitResult.SweepFailed;
}
Position += result.AllowedDelta;
LastCommittedFrame = sample.Frame;
LastCommit = new MotionCommit(
sample.Frame,
sample.DesiredDelta,
result.AllowedDelta,
sample.DesiredDelta - result.AllowedDelta,
Position);
return CommitResult.Committed;
}
}
这里把负位移留给另一条方向合同,因此当前切片要求 AllowedDelta 位于 [0, DesiredDelta]。若游戏允许后退 Root Motion,应把方向投影与长度约束分开验证,而不是简单删除范围检查。帧号也不是渲染帧计数的别名:在固定步长、预测或回放系统中,它应来自能稳定重演的运动 Tick。
接入 Unity 时,OnAnimatorMove 适合读取 Animator.deltaPosition 与 deltaRotation,却不应在回调里直接宣布最终 Transform;Godot 或自研动画图也面临同样边界。宿主可以把采样值缓存为当前运动 Tick 的候选,随后由 CharacterController、CharacterBody 或自研 Kinematic Motor 完成 Sweep,再一次性公开运动结果。这样相机跟随、攻击判定和复制系统读取的是提交后的状态,而不是谁先执行回调就读谁的临时值。
碰撞失败不能消费动画帧
最容易被忽略的失败顺序是:动画已经采样,碰撞场景却在切换,Sweep 抛出异常。如果此时仍推进 LastCommittedFrame,重试会被误判为旧帧;如果先写 Position 再记录结果,读者会观察到没有来源的位移。探针让所有写入都发生在 Sweep 成功与范围校验之后,所以失败样本保持 position=0.00、committedFrame=-1。
同一门禁也处理异步重排。帧 101 已提交 0.40 m 后,迟到的帧 100 即使能完成 Sweep,也只能返回 StaleFrame,位置仍是 0.40 m。这不是说引擎一定异步计算 Root Motion,而是提交合同不能依赖回调永远按期到达;动画任务、角色物理和网络校正一旦分线程,这个保护就会成为真实边界。

固定输入的决定性运行结果如下:
$ dotnet build $LAB_ROOT/RootMotionProbe.csproj -c Release
exit_code=0 warnings=0 errors=0 duration=7.86s
$ dotnet run --project $LAB_ROOT/RootMotionProbe.csproj -c Release --no-build
exit_code=0 pass=11 fail=0 skip=0 duration=2.24s
blocked: frame=100 desired=1.20 allowed=0.75 residual=0.45 position=0.75
clear: frame=100 desired=1.20 allowed=1.20 residual=0.00 position=1.20
failure: frame=100 result=SweepFailed position=0.00 committedFrame=-1
stale: incoming=100 committed=101 result=StaleFrame position=0.40
这些耗时只证明小型进程内探针在当天完成,不代表动画或碰撞性能。当前实现每次提交只做一次 Sweep 和常数次字段更新;Sweep 的实际成本取决于引擎碰撞世界,本文没有做基准测试。
残差要进入表现修正,不能重新拥有位置
ResidualDelta 的用途是解释动画步幅与真实位移为什么不同。若连续多帧残差增大,可以降低动画播放速度、选择原地受阻状态,或用 Motion Warping 把脚步目标收敛到接触点。无论采用哪种表现方案,最终位置仍取自角色运动提交结果。让动画层在 Late Update 把残差加回来,会重新制造开篇的越墙与回拉。
扩展到三维时,float 会变为位移向量与旋转增量,Sweep 需要处理滑墙、台阶、地面法线和多次接触。提交单位也应包含实际旋转、接地状态与碰撞摘要,使玩法、相机和网络快照读取同一份运动结果。若网络预测需要回滚,MotionCommit 还要进入可重放状态,并由确认 Tick 决定淘汰窗口;本文没有覆盖这部分。
墙前的 0.45 m 不应消失,也不应直接发生。它是动画提案没有被碰撞世界接受的证据。只要动画负责提出期望位移,角色运动层负责验证并原子发布实际位移,渲染、玩法与同步系统就能围绕同一个位置工作;碰撞失败或旧帧到达时,角色也仍停在最后一个可以解释的提交点。