动画同步:复制播放命令不等于复制动画状态
自动关联目录:动画同步:复制播放命令不等于复制动画状态
核心判断:网络动画的复制单位应是可重建的播放快照,而不是一次性的 Play RPC。快照必须钉住稳定轨道身份、单调代际、资产版本、权威时钟和停止阶段;动画事件默认只是本地表现事实,只有带幂等键并经服务端验证的语义事件才能进入玩法。
一、业务冲突:命令送达了,远端仍可能永远不一致
播放命令有三个天然的不可靠前提:远端动画宿主可能尚未创建,晚加入玩家没见过过去的 Play,拥有者预测后的服务端回声还可能把动画倒回网络往返前的位置。若动画资源在等待期间发布了新版本,晚加入再按“当前版本”解析相同逻辑 ID,甚至会与房间内其他玩家播放不同内容。
停止也不是一个瞬时布尔值。带淡出的 Stop 到达后,轨道仍在贡献姿势;若服务端立刻从活动集合删除它,晚加入者会看见该动作完全不存在。反过来,若没有最终删除标记,结束轨道会永久残留在重建快照里。宿主晚到时还有更细的竞态:Seek、调速或调权重可能覆盖队列里的 Play,最后只应用调整命令,却从未创建轨道。
因此“RPC 可靠、有序”并不能解决问题。RPC 只保证消息路径,动画需要的是在任意加入时间、任意宿主创建顺序和重复投递下都能恢复同一个播放会话。
二、实际实现:稳定 Track、Generation、版本 Pin 与 Tombstone 组成快照
当前协议为每条网络轨道分配稳定 ID,并为每次权威变更递增 Generation。客户端只接受严格大于本地已应用代际的快照,旧包和重复包直接拒绝。Play 与重建 Snapshot 必须携带不可变的动画身份和版本;晚加入沿用已经在播的版本,不能查询当前注册表后重新选版。
服务端保存活动轨道快照。Stop 只把快照标记为 Stopping 并保留版本与权重目标,直到淡出结束或 Ended 才广播 Tombstone 并从活动集合删除。新玩家 Ready 后收到所有非墓碑快照;客户端依据服务端时间和速度追赶相位,再执行 Play、Seek 和必要的 Stop。
宿主未创建时,快照按“宿主身份 + Track”暂存,只保留最高 Generation。后到的 Seek/Speed/Weight 不得抹掉尚未物化的 Play;第一次应用若发现只有调整命令但携带完整内容身份,会先提升为 Play。对象出现事件和短周期重试共同推动队列,销毁时用本地生命周期代际使旧重试失效。
拥有者先本地播放,再向服务端请求。确认回来后,本地预测 Track 原子映射到权威 Track ID,事件、查询、停止和销毁都改走新身份,但不会重放 Play,否则会按上行延迟倒带。Pending 请求有超时清理;同一确认若代际已过期也不会重新挂载。
sequenceDiagram
participant O as 拥有者客户端
participant S as 服务端快照库
participant R as 观察者客户端
O->>O: 预测 Play(local track)
O->>S: Request(asset pin, request id)
S->>S: 分配 stable track + generation
S-->>O: Ack(snapshot)
O->>O: Remap,不重放 Play
S-->>R: Snapshot
R->>R: 宿主缺失则按最高 generation 暂存
R->>R: 宿主就绪后 Play + phase catch-up
S-->>R: Stop(stopping=true)
S-->>R: Tombstone(new generation)三、值得保留:协议开始围绕“可重建状态”而不是 API 调用
资产版本 Pin 是关键。逻辑资产 ID 只能说明“想播什么”,不能说明本次多人会话实际使用哪个制品;快照沿用已播放版本,避免发布更新改变进行中的房间。缺少版本的晚加入快照选择失败关闭,也比悄悄选当前版本更可靠。
Generation 同时解决乱序、重复和超时确认。稳定 Track ID 又把本地预测对象、服务端对象和重建后的运行轨道连接起来,使事件与 Stop 不会继续发往已经废弃的本地 ID。Stop 与 Tombstone 分离则保留了淡出阶段的可重建性。
延迟物化队列也不是简单缓存原消息,而是按 Track 合并状态并保护创建命令;这直接修复了“最后一条调整消息覆盖 Play”的竞态。动画实例内对 Stopped、Ended、Loop 和 Marker 使用边沿标志和区间扫描,包含正放、倒放与跨多次循环,避免低帧率跳过 Marker 或重复结束事件。
四、结构性缺陷:播放状态可恢复了,事件权威仍不完整
相位追赶直接使用“本地当前时间 - 服务端发包时间”,隐含两端时钟可比较。没有经过同步的网络时间或估计偏移时,相位误差会被当成真实播放时长。正确输入应是权威时间域中的起播时间,再通过连接维护的时间偏移映射到本地表现时钟。
协议状态分散在脚本快照库、Actor Pending 队列和 AnimInstance Track 中,三者都保存一部分时间、停止和身份。出现问题时需要跨层拼接日志,且某层清理成功、另一层残留就会产生幽灵轨道。固定超时只能回收请求,不能告诉上层这是被拒绝、宿主永久缺失还是会话已经销毁。
更重要的是 Marker 上行只有 Track、名称和载荷,缺少播放代际、循环序号、Marker 序号或唯一事件 ID。重试、重连或恶意客户端都可能重复发送同一 Marker;服务端也无法判断它是否对应当前权威播放窗口。动画 Marker 可以驱动脚步声和特效,但不能直接成为伤害、掉落或状态转换的唯一依据。
五、目标设计:快照恢复表现,语义事件用独立的幂等协议
目标协议把动画会话定义为 {EntityId, TrackId, PlayEpoch}。权威快照包含资产 Pin、起播权威时间、速度、循环、权重、阶段和 Revision;调整命令只产生新的 Revision。客户端应用前先检查会话身份和单调 Revision,再把权威时间映射为本地相位。宿主不可用时保存最新完整快照,而不是保存一串必须重放的 API。
ApplyResult Apply(const AnimSnapshot& s, LocalReplica& r, NetworkClock clock)
{
if (!s.ValidPin() || s.Revision <= r.LastRevision(s.Session))
return Rejected;
r.RememberRevision(s.Session, s.Revision); // Tombstone 即使宿主缺失也要记住
if (s.Phase == Tombstone)
return r.Remove(s.Session);
if (!r.HostReady())
return r.DeferLatest(s);
float phase = s.PhaseAt(clock.ToLocal(s.AuthorityStartTime));
r.MaterializeOrUpdate(s.Session, s.AssetPin, phase, s.Weight, s.Phase);
return Applied;
}
EventId MakeEventId(SessionId session, int loop, int markerOrdinal);表现事件在本地按区间扫描产生,可以丢弃不可见或过期轨道的事件。需要进入玩法的事件必须由服务端模拟直接产生,或携带 EventId + PlayEpoch + 发生相位 上行,由服务端验证当前会话、允许窗口和幂等账本。晚加入只恢复当前姿势和阶段,不补发已经过去的一次性玩法事件。
六、决策规则:状态用快照恢复,事件按权威后果分类
| 场景 | 协议行为 | 不变量 |
|---|---|---|
| 拥有者预测 Play 后收到确认 | 原子 Remap,禁止重放 | 相位不因 RTT 倒退 |
| 观察者宿主晚于快照创建 | 保存最新完整快照,宿主 Ready 后物化 | 调整消息不会覆盖创建事实 |
| 晚加入时资产已有新版本 | 使用会话 Pin,缺 Pin 失败关闭 | 同一会话内容一致 |
| Stop 正在淡出 | 快照保留 Stopping,结束后发 Tombstone | 可重建且最终清理 |
| Tombstone 先于宿主到达 | 仍记录 Revision 并清 Pending | 旧 Play 永不复活 |
| 低帧率跨过多个 Marker | 在时间区间内按序枚举 | 不漏不重,顺逆播一致 |
| Marker 触发玩法后果 | 服务端验证 EventId 或直接权威产生 | 重复上行至多生效一次 |
这段实践的提升点很明确:播放同步已经从“广播调用”演进到“复制会话状态”,但事件仍容易被动画 API 的便利性诱导成玩法协议。真正可靠的边界应是:动画快照负责远端看起来一致;玩法后果由权威语义事件负责。两者可以共享播放会话身份,不能共享权威假设。