动画生命周期:Play 是时间意图,不是对 Sequence 的调用
自动关联目录:动画生命周期:Play 是时间意图,不是对 Sequence 的调用
核心判断:
Play应立即创建一条带独立时钟和播放代际的逻辑会话;Mesh、UAnimInstance和UAnimSequence都只是可能晚到或被替换的执行器。资源空窗可以追赶时间,显式暂停必须冻结时间,任何晚到结果都只能提交给发起它的那一代播放。
一、业务冲突:Play、Mesh 和动画资源没有可靠的先后顺序
角色由脚本和场景数据驱动时,Play 经常早于 Skeletal Mesh、动画实例或 Sequence。与此同时,Stop、Seek、变速和调权重仍可能继续到达;Clone、属性重放和换装又会重复设置 Mesh。业务要的不是“资源到了再播”,而是无论异步顺序如何,最终姿势都等价于按命令原始顺序执行。
工程中出现过两条直接说明问题的故障链。第一条是角色恢复时 Sequence 先到,恢复逻辑先尝试绑定资源,再重放 Play;因为 Track 尚不存在,绑定没有落点,随后 Play 只创建了一条空 Track,角色一直没有姿势。第二条是复制数据重复下发相同 Mesh 与 Skeleton,绑定层仍推进 Generation 并重建 UAnimInstance,正在播放的状态被替换,画面短暂回到参考姿势。
更隐蔽的问题来自时间和代际。如果资源空窗期间时钟不走,两个下载速度不同的客户端会从不同的现实时间开始动作;如果显式暂停期间仍按墙上时间追赶,恢复后又会跳过玩家本应看到的部分。如果 Stop 后的下载回调仍可绑定,旧动作会“复活”;如果同一 Track 再次 Play,旧请求或旧 Pending Sequence 还可能被误认为本次播放结果。
因此真正的问题不是“怎样异步加载动画”,而是:在执行器缺席、替换和晚到的情况下,谁拥有播放事实、时间事实与提交资格。
二、实际实现:先建立逻辑 Track,再按固定顺序补齐执行器
现有实现已经把 Play 与 Sequence 解耦。宿主身份可解析但 Skeletal Mesh 或动画实例尚未建立时,命令按到达顺序进入 Actor 侧队列,并记录基于表现时钟的入队时间;动画实例存在时,Play 立即创建 Track,初始 Sequence=null、Length=0,但 TimePosition 每帧仍按 DeltaTime * Speed 推进。资源后来绑定时,循环动画按时钟对片长取模;一次性动画若仍在片内则追赶到对应位置,若已错过整段,当前实现选择从头补播,并在可见空窗后做一次短淡入。
当动画实例晚于命令创建时,恢复顺序被固定为“命令队列 -> 资源队列”。先重放 Play/Stop/Seek/Speed/Weight,其中早到的 Play 用“当前表现时间 - 入队表现时间”恢复进度;再绑定 Pending Sequence。Sequence 已到但 Track 未创建时不会被消费,而是继续保留。显式试玩暂停或角色禁用则冻结表现时钟,恢复后从原相位继续,不把暂停时长算成资源延迟;单 Track 速度为零又是更细粒度的暂停。
sequenceDiagram
participant S as 脚本/状态层
participant H as 稳定宿主
participant T as 逻辑 Track
participant L as 资源协调器
participant E as UE 动画执行器
S->>H: Play(track, asset)
H->>T: 建立播放意图并启动表现时钟
par 执行器尚未就绪
S->>H: Seek / Speed / Stop(FIFO)
and 资源异步到达
H->>L: 请求 exact asset revision
end
H->>E: 创建或替换 AnimInstance
H->>T: 先归约 Pending Commands
L-->>H: Sequence + request epoch
H->>T: 校验 host generation / play epoch
T->>E: 绑定内容并恢复时钟位置
Note over L,E: Stop、换宿主或新 Play 后,旧完成只能丢弃重复 Play 同一 Track 会重置时间、停止态和事件边沿,并保留已绑定 Sequence,以便同一动作立即重播;重复 Stop 只刷新淡出,不重复发 Stopped。Stop 后调权重不会反向拉回正在淡出的 Track。资源层把同一物理资产的多个请求合并,但每个等待者保存 World、Host、Track、锁定版本和捕获的宿主 Generation。等待者取消只是撤销自身的绑定资格:底层资产请求还可能被别的消费者共享,不能做全局取消;完成回调通过共享请求 epoch、等待者存在性和宿主 Generation 三重检查拒绝旧结果。
相同 Mesh/Skeleton 的重复绑定现在是 no-op,不再替换动画实例。真实换 Mesh 时,旧实例用强引用跨越 SetAnimInstanceClass,Track、时间、混合、过渡和事件状态被迁入新实例;绑定内容被清空后重新请求,并在新骨架上重新做兼容校验。Game Thread 负责改 Track,动画线程只读取已冻结的代理快照;没有可求值内容的逻辑 Track 不进入姿势贡献。
三、值得保留:把“现在不能执行”与“这条意图不存在”分开
第一,Track 拥有时间,Sequence 不拥有时间。资源未到仍可推进逻辑进度,避免下载速度决定动作相位;UE 的 Sequence 只在真正可求值时进入动画代理。
第二,缺资源与显式冻结是两种语义。前者继续走表现时钟,后者保留相位。试玩暂停、角色禁用和 Track 速度为零也没有被压成同一个布尔量,这避免恢复时错误补播。
第三,恢复顺序来自一次真实故障,而不是约定俗成。命令必须先建立 Track,资源才能绑定;资源先到时保留而不消费。这个顺序同时覆盖首次加载、Clone 恢复和 Mesh 晚到。
第四,取消被定义成资格撤销。共享下载未必能物理停止,但取消后的等待者、已废弃的请求 epoch 和旧宿主 Generation 都不能提交。这比把 CancelRequest 等同于“网络一定停止”更符合共享资产系统。
第五,相同绑定幂等,真实替换迁移状态。它保留了重复属性重放的低成本路径,也承认 UAnimInstance 会因 Mesh 或 Class 变化被 UE 重建,状态必须显式搬迁。
第六,Game Thread 写、动画线程读快照。生命周期判断、UObject 绑定与命令归约停留在 Game Thread,Worker 只消费不可变姿势输入,避免把异步资产状态直接暴露给并行动画求值。
四、结构性缺陷:已有防线保护了宿主代际,却没有完整保护播放代际
当前播放事实分散在四处:脚本层记录“是否播放”,Actor 保存 Pending Commands 与 Pending Sequence,UAnimInstance 保存 Track 和时钟,全局协调器保存等待者与加载状态。恢复正确性依赖手写的 Flush 顺序,而不是由一份状态机自然保证。新增一种命令、资源后端或 Mesh 恢复路径,都可能再造一次“先绑定还是先建 Track”的竞态。
最关键的缺口是只有 Host Generation,没有独立的 Play Epoch。Pending Sequence 以 TrackId + Host Generation 为主要身份;同一宿主、同一 Track 在资源到达前再次 Play 时,宿主 Generation 不变。全局协调器会替换同一 Host/Track 的 waiter,共享请求 epoch 也能挡住被取消后重建的旧请求,但 Actor 内已经缓存的 Sequence 没有播放代际,仍可能落到新一次 Play。这说明请求代际、宿主代际和播放代际解决的是三个不同问题,不能互相替代。
重复 Play 还有一个具体风险:实现按 TrackId 复用并保留旧 Sequence,却先覆盖新的 AssetId。若调用方允许同一 Track 换动作,资源空窗内旧内容会以新会话的时间和事件继续求值。对“同资产重播”这是低延迟优化,对“换资产重播”却是错误回退;当前边界隐含在上层调用约定中,没有由运行时强制。
Mesh 恢复路径也没有完全收敛。正常加载经过共享协调器,具备版本锁定、等待者状态、请求合并和统一诊断;换 Mesh 后的恢复却直接向 Bundle 层重新请求,只校验弱宿主、资产 ID、结果和 Host Generation。它绕开了正常路径的版本 pin、waiter 取消和状态观测。同一件“重新绑定动画”因此有两套生命周期协议。
此外,清空绑定内容会把 Length 归零但保留逻辑时钟;下一次绑定又被识别为“首次内容绑定”。对一次性动画,当前全局策略可能从头补播。这能避免一个用户明确触发的短动作被下载吞掉,却也可能在换 Mesh 后重演已经过期的状态动作。更早的设计曾选择“超过片尾直接 Ended”,后来的实现改成“从头补播”,说明这里不是技术定理,而是尚未进入类型系统的业务政策。
最后,Started 在逻辑 Play 时就产生,而第一帧可见姿势可能晚很多。若上层把 Started 理解成“玩家已经看到”,统计、连招窗口或音效都会提前。逻辑事件与表现事件需要分开,不能靠命名猜测。
五、目标设计:用 Playback Session 统一命令、时间与提交资格
目标架构应把播放会话放在可替换的 Actor、UAnimInstance 和 Sequence 之上。每次 Play 都生成新的 PlayEpoch;会话至少持有 HostId、TrackId、HostGeneration、锁定的资产 Revision、逻辑时钟、Freeze Reasons、命令序号、晚绑定策略和终止状态。Actor 只是命令入口,UAnimInstance 是当前 Evaluator,资源协调器只返回带 Ticket 的候选内容。
晚绑定策略必须成为每次播放的显式选择,而不是由“是否循环”间接决定:循环待机适合 PhaseAligned,资源到达后按相位追赶;必须让玩家看到的一次性动作适合 ReplayOnce;只代表当前状态的短动作适合 ExpireIfMissed。策略随播放意图冻结,Mesh 替换和资源重试不能偷偷改变。
Result Reduce(Session& s, Event e)
{
if (e.hostGeneration != s.hostGeneration)
return Stale("HostReplaced");
if (e.type == Play) {
s.playEpoch++;
s.assetRevision = e.exactRevision;
s.clock.Reset(e.presentationNow);
s.policy = e.lateBindPolicy;
s.state = WaitingForEvaluator;
CancelBindingInterest(s.previousTicket); // 不承诺取消共享下载
return RequestContent(MakeTicket(s));
}
if (e.playEpoch != s.playEpoch || s.state == Terminal)
return Stale("SupersededPlayback");
if (e.type == PauseReasonAdded) return s.clock.Freeze(e.reason);
if (e.type == PauseReasonRemoved) return s.clock.Thaw(e.reason);
if (e.type == Seek || e.type == Speed || e.type == Weight)
return ApplyInSequence(s, e.commandSequence);
if (e.type == Stop) {
s.state = Terminal;
CancelBindingInterest(s.ticket);
return FadeOutCurrentEvaluator(s);
}
if (e.type == ContentReady) {
if (!e.ticket.Matches(s.hostGeneration, s.playEpoch, s.assetRevision))
return Stale("LateCompletion");
ClipTime t = ResolveLateBindTime(s.policy, s.clock.Now(), e.clipLength);
return t.expired ? FinishWithoutBinding(s) : BindAtomically(s, e.clip, t);
}
return Ignored();
}同一 Mesh 身份重复到达仍应 no-op;真实换 Mesh 只推进 Evaluator Generation,不结束 Playback Session。新实例建立后从会话快照恢复 Track,再经同一个资源协调器重绑 exact revision。若资产未变且新骨架兼容,可以复用已驻留内容;否则产生新 Ticket。任何提交都同时比较 World/Host、Host Generation、Play Epoch、资产 Revision 和 Evaluator Generation。
事件也应拆成两个平面:PlayAccepted/Stopped/Superseded 描述逻辑会话,FirstPosePresented/LoopPresented/PresentationEnded 描述实际可见求值。DS 只需消费权威动作意图、时钟和玩法标记,不必构造渲染用 UAnimInstance;客户端再把同一会话投影为姿势。这既避免视觉加载时序污染玩法,也为后续网络校正保留明确边界。
六、取舍与验证:追赶、补播或过期必须是业务决策
统一会话并不意味着所有动画都用同一时间策略。循环动作如果从头播,会让不同客户端长期错相;必须看见的一次性动作如果直接过期,会让资源抖动吞掉反馈;状态型短动作如果总补播,又会表现已经失效的历史。正确做法是由动作语义选择策略,再由运行时保证顺序、代际和可观测性。
| 验证场景 | 必须断言 | 失败时应看到什么 |
|---|---|---|
Play 早于 Mesh 和 Sequence | 会话立即存在,命令按序归约 | 不出现空 Track 永久无内容 |
Sequence 早于 Play | 资源保留到 Track 建立后再绑定 | 不提前消费,不丢失强引用 |
| 资源空窗后绑定循环动作 | 按表现时钟对片长取模 | 记录 late-bind 时差与相位误差 |
| 显式暂停期间资源到达 | 暂停时长不计入追赶 | 恢复帧等于暂停前相位 |
| 一次性动作错过片尾 | 严格执行该会话的 late-bind policy | Replay、Expire 不可由后端随机决定 |
Stop 后回调晚到 | waiter 无提交资格,Track 不复活 | stale/cancelled 计数增加,绑定数为零 |
同 Track 连续两次 Play | 旧 PlayEpoch 的 Pending 与回调全部拒绝 | 第二次只绑定自己的 exact revision |
| 同资产重播与换资产重播 | 前者可复用内容,后者不得展示旧动作 | 资产身份变化触发候选内容隔离 |
| 相同 Mesh 重复绑定 | Generation、AnimInstance、时钟均不变 | 零重建、零参考姿势闪帧 |
| 真实换 Mesh | 会话不丢,Evaluator 换代并统一重绑 | 旧实例回调不能写入新实例 |
| Actor/World 已销毁 | 所有晚到完成均不可提交 | 零 Track resurrection,零 UObject 越界 |
验收不应只看“最后播出来了”。至少要记录 Pending 命令深度与滞留时长、资源空窗、绑定时逻辑时钟误差、各 late-bind policy 命中、旧 epoch 丢弃、重复 Mesh no-op、Evaluator 重建次数和 Stop 后复活数;其中旧会话复活必须为零。自动化还要排列 Play/Seek/Speed/Stop、Mesh 到达和资源完成的主要顺序,而不是只测理想顺序。
我的反思是:最初把虚拟时钟放进 Track,解决了“下载慢导致动作晚开始”;命令先于资源、显式 Freeze、共享 waiter、Generation、相同 Mesh 幂等和 AnimInstance 状态迁移又逐步堵住了真实故障。但这些修复仍围绕 Actor、Track 和请求各自加保护,缺少一次播放自己的代际与政策。更好的动画生命周期不是把回调写得更谨慎,而是让每个回调都必须证明:它仍属于当前这一次 Play。