ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

实时/回放双模式播放控制引擎的设计与实现(AFSIM + Cesium + ACMI)

实时/回放双模式播放控制引擎的设计与实现(AFSIM + Cesium + ACMI) 从修一个 bug 引入三个 bug到统一架构实时/回放双模式播放控制引擎的设计与实现本文以一个 AFSIM Cesium ACMI 战术分析系统toTacview的真实演进为例讲述如何从实时和回放两套逻辑反复踩坑的泥潭中走出设计出一套两类模式共享 90% 代码、各自只实现状态机语义的统一时间轴架构。一、背景我们在解决什么问题ACMI 是战术仿真领域的事实标准数据格式AFSIM/DCS 均支持。一套合格的的 ACMI 可视化系统需要同时支持两种使用模式回放模式打开一个 ACMI 文件像看视频一样播放、暂停、拖拽进度条、倍速快进实时模式通过 WebSocket 连接仿真服务器实时接收平台位置/姿态流跟随数据到达渲染场景同时支持时移回看拖到 30 秒前看历史再回到实时两种模式看似简单但在工程实现中却暗藏大量边界条件回放是推模型数据全在手主动推进时间实时是拉模型数据逐帧到达被动跟随回放的 simTime 可以自由推进实时的 simTime 不能超过最新数据时间回放倍速 64x 时要跳帧但不能丢事件实时只有 1:1实时数据可能中断、重连、突发大量数据两种模式都需要帧间插值数据 10Hz 但渲染 60Hz两个采样点之间要 lerp slerp二、至暗时刻单对象 标志位 hack最初的实现是一个AcmiTimelineEngine类用一组布尔标志位区分模式// 最初的简单设计classAcmiTimelineEngine{this._isPlayingfalsethis._isLiveModefalse// 实时模式this._wasFollowingLivefalse// 之前在跟随实时this._isTimeshiftfalse// 时移回看// ... 更多标志位}这套设计在初期跑通了但随着功能迭代进入了修一个 bug 引入三个 bug的泥潭典型 bug 链条实时模式对象不动了 → 发现seekTo(maxTime)导致插值 alpha 0 → 改成seekTo(maxTime - renderDelay)改完后回放模式倍速卡顿 →_wasFollowingLive标志在回放和实时间含义漂移 → 加补丁重置标志补丁导致暂停后恢复播放从头开始 →play()恢复策略判断错误 → 再加补丁补丁导致时移回看后无法回到实时 → …根因分析_wasFollowingLive这类跨模式标志位在不同上下文下含义不同修改一处会波及所有模式。每次修 bug 都在打地鼠——按下一个冒出来另一个。三、破局继承拆分状态机隔离3.1 核心思想把两种模式共用一个类 标志位 hack重构为共享基类 两个子类各自实现状态机AcmiTimelineEngineBase 抽象基类共享状态 共享管线 ├── PlaybackEngine 回放引擎rAF 主动推进 └── LiveEngine 实时引擎被动跟随 timeshift 子模式基类承载所有与模式无关的逻辑帧消费、插值计算、快照管理、预读机制。子类只实现各自的状态机语义play / pause / seek / handleAppend。3.2 基类共享管线基类的核心是一条拉取式单循环管线simTime 推进 → _processFramesUpTo(simTime) // 消费 [lastTime, simTime] 区间帧 → _processEntry(entry) // 处理单个 entryobject/removal/event → _updateSnapshot(id, obj) // 预计算 Cartesian3/Quaternion → _prefetchNextFrame(nextFrame) // 预读下一帧填充插值锚点 → getInterpolatedState(id) // 渲染层查询插值结果 → _computeInterpolatedState() // lerp slerp关键设计这条管线实时和回放完全共用同一份代码。不是复制粘贴然后各自维护而是字面意义上的同一个方法。修改一处两种模式同时受益。3.3 子类状态机PlaybackEngine回放的状态机很简洁paused ──play──→ playing(rAF) ──pause──→ paused playing ──seekTo──→ paused(重建) ──wasPlaying?──→ playing playing ──_tickBody到达终点──→ pause回放结尾LiveEngine实时的状态机多了 timeshift 子模式followLive ──pause──→ paused ──play──→ 落后≤阈值 → followLive 落后阈值 → timeshift(追赶) ──追上──→ followLive followLive ──seekTo(历史点)──→ timeshift ──play──→ 追上 ──→ followLive followLive ──seekTo(接近maxTime)──→ followLive时移回看是实时模式的精髓用户拖到 30 秒前看历史timeshift看完点回到实时自动追赶最新数据。追赶策略用落后量而非暂停时长分流——数据正常流动时两者相等而暂停期间数据中断时落后不增长能正确直接回实时。四、重难点一simTime 推进策略4.1 问题描述回放模式很简单rAF 60Hz 推进 simTimesimTime wallDelta * speed。simTime 连续变化插值自然生效。实时模式却经历了几次反复第一版simTime maxTime用最新数据时间→ 对象在最新位置静止没有插值第二版simTime maxTime - renderDelay滞后 100ms落在两个采样点之间→ 看起来对但有隐藏 bug4.2 隐藏的10Hz 跳变bug第二版的问题是maxTime只在数据到达时10Hz变化rAF tick 之间60HzsimTime不变数据到达(10Hz): maxTime 跳进 → simTime maxTime - 0.1 跳进 rAF tick(60Hz): maxTime 不变 → simTime 不变 → alpha 不变 → 插值结果不变视觉效果对象 10Hz 跳变而非 60Hz 平滑。更极端的情况——当renderDelay正好等于帧间隔100ms 1/10Hz时新数据到达: maxTime 0.1 newSimTime (maxTime 0.1) - 0.1 maxTime ← 不变simTime 永远不变对象完全静止。这是一个数值巧合 bug只在特定参数下触发极难复现。4.3 解决方案wall clock 推进 落后下界核心洞察simTime 应该随 wall clock 推进像回放一样而不是直接跟随 maxTime。renderDelay仅用于初始锚定和落后下界。_followLiveTick(){// 1. wall clock 推进60Hz 平滑插值constwallDelta(now-this._lastWallTime)/1000this._simTimewallDelta// 2. 落后下界simTime 不应落后 maxTime - renderDelayconstfloorthis._maxTime-this._renderDelayMs/1000if(this._simTimefloor)this._simTimefloor// 3. 上界 clampsimTime 不超过 maxTimeif(this._simTimethis._maxTime)this._simTimethis._maxTime// 4. 消费帧 预读this._processFramesUpTo(this._simTime,true)}为什么有效rAF tick 之间16mssimTime 0.016 → alpha 平滑变化 → 60Hz 平滑插值数据到达时maxTime 抬升下界 floor 抬升但 simTime 已被 wall clock 推进到接近 floor不会跳变初始连接/断线重连时 simTime 远落后 floor → 直接跳到 floor追赶handleAppend数据到达和_tickrAF复用同一个_followLiveTick()共用_lastWallTimewallDelta 不重复计算。五、重难点二帧间插值的锚点管理5.1 双插值路径插值需要两个锚点。我们维护三组锚点prev、curr、next对应两条插值路径路径 1[curr, next] 拉取式回放 实时沿 - next 由 _prefetchNextFrame 预读填充 - alpha (simTime - currTime) / (nextTime - currTime) ∈ [0, 1) 路径 2[prev, curr] 回退式实时帧末 / 数据中断 - prev 由 _updateSnapshot 滚动保存 - alpha (simTime - prevTime) / (currTime - prevTime) ∈ [0, 1]为什么需要两条路径回放模式 simTime 永远在两个已消费帧之间next 帧一定存在。实时模式 simTime 可能到达 maxTime最新数据没有 next 帧需要回退到 [prev, curr]。5.2 预读的稀疏更新难题_prefetchNextFrame要为每个活跃对象找下一个含位置更新的帧。但 AFSIM 对恒定字段省略输出某些对象更新间隔可达93.8 秒实测数据。最初的设计固定时间窗口扫描2 秒内找 next 位置更新→ 稀疏对象 nextPosition 长期为 null → 插值失效静止 → 93.8 秒后突然跳变。解决方案无时间窗口向前扫描直到找到 next 位置更新或数据末尾_prefetchNextFrame(frame){constpending/* 活跃且 nextPosition 未填充的对象 */for(letkthis._currentFrameIndex;kframeCount;k){this._scanFrameEntriesForAnchors(this._frameIndex.getFrameAt(k),pending)if(pending.size0)break// 全部找到}// 扫描到末尾仍 pending → 标记静止记忆for(const[,snap]ofpending)snap._nextResolvedtrue}静止记忆_nextResolved真静止对象如 SAM 阵地扫描一次后标记跳过后续重复全量扫描。当对象再次收到位置更新时_updateSnapshot重置标记。5.3 同帧 memo多读者共享一次计算一个平台实体有多个渲染读者位置 CallbackProperty、姿态 CallbackProperty、翼带头段、标签……每个读者每帧都查询getInterpolatedState。如果每次都做 lerp slerp60Hz × N 读者 × M 对象 大量重复计算。解决方案同帧 memo键为(_stateEpoch, _simTime)getInterpolatedState(acmiId){constsnapthis._snapshots.get(acmiId)// 同帧 memo纪元和 simTime 均未变 → 复用if(snap._interpEpochthis._stateEpochsnap._interpTimethis._simTime){returnsnap._scratchResult}constresultthis._computeInterpolatedState(snap)snap._interpEpochthis._stateEpoch snap._interpTimethis._simTimereturnresult}暂停时 simTime 不变纪元不变 → 所有读者永久命中 → 拖拽视角零插值开销。六、重难点三环形裁剪与游标免疫6.1 环形 O(1) 裁剪实时模式数据持续到达内存不可能无限增长。环形缓冲区用 head/count 替代 spliceO(1) 裁剪_getFrameAt(i){constphysIdx(this._frameOffseti)%this._frames.lengthreturnthis._frames[physIdx]}_trimHistory(){if(this._frameCountthis._maxFrames){consttoDiscardthis._frameCount-this._maxFramesthis._frameOffset(this._frameOffsettoDiscard)%this._frames.lengththis._frameCount-toDiscardthis._trimmedCounttoDiscard}}6.2 游标漂移问题环形裁剪平移了逻辑下标——_currentFrameIndex指向的帧被裁剪后物理位置变了。如果直接用旧游标会跳过未消费帧或重消费旧帧。解决方案用_lastConsumedTime时间锚而非下标锚。裁剪后按时间二分重定位游标_resyncCursorForTrim(){consttrimmedthis._frameIndex.trimmedCountif(trimmed!this._seenTrimCount){this._seenTrimCounttrimmedthis._currentFrameIndexthis._findFrameIndex(this._lastConsumedTime)}}_lastConsumedTime是裁剪免疫的权威字段——它是时间而非下标裁剪平移下标不影响时间值。6.3 LivePagedFrameIndex全局下标稳定对于需要无限历史的实时模式我们在环形缓冲区外接了磁盘分页WebSocket → LivePagedFrameIndex ├─ 环形缓冲区RAM 窗口 └─ 被裁剪帧打包为页 {anchor, frames} 落盘 IndexedDB全局下标三段[0, 已页化 | 待页化缓冲 | 环形)。帧的全局下标在三段迁移中保持稳定trimmedCount恒 0 → 引擎游标天然免裁剪漂移_resyncCursorForTrim退化为空操作。七、重难点四增量 seek 与事件零丢失7.1 增量 seekseek 到新时间点时不能全量销毁/重建所有 Cesium Entity太慢。P7 增量 seek只增删改变化对象_seekTo(targetTime){constsnapshotthis._frameIndex.getSnapshotAtTime(targetTime)constoldIdsnewSet(this._currentObjects.keys())constnewIdsnewSet()for(const[id,obj]ofsnapshot.objects){newIds.add(id)if(oldIds.has(id))this._onObjectUpdated(id,obj)// 更新elsethis._onObjectAdded(id,obj)// 新增}for(constoldIdofoldIds){if(!newIds.has(oldId))this._removeObject(oldId)// 移除}}7.2 事件零丢失高倍速64x时一帧 rAF 可能跨越多个数据帧。如果跳过中间帧的事件导弹发射/命中事件会丢失。解决方案状态可跳帧事件始终触发constskipStatebatchCount0!isLastInBatch!hasEventsfor(entryofframe.entries){if(entry.kindevent)/* 始终触发 */elseif(entry.kindremoval)/* 始终处理 */elseif(!skipState)this._processEntry(entry)}7.3 8ms 预算守卫大量帧处理可能阻塞主线程。8ms 预算守卫超时 yield 到下一 rAFif(!isLastInBatchperformance.now()deadline)break// yield这个守卫曾经被误删过一次导致回放卡顿回归——144fps 高刷屏下每帧处理时间变短但帧数增多没有守卫会累积抖动。现在有测试守护禁止删除。八、架构全景┌─ 数据源 ─────────────────────────────────────────────┐ │ 回放ACMI 文件 → PagedFrameIndex页自足 │ │ 实时WebSocket → LivePagedFrameIndex环形磁盘分页│ └────────────────────┬─────────────────────────────────┘ ▼ ┌─ 引擎层共享基类─────────────────────────────────┐ │ _processFramesUpTo → _processEntry → _updateSnapshot│ │ _prefetchNextFrame → 填充 snap.next* │ │ getInterpolatedState → lerp slerp同帧 memo │ │ │ │ PlaybackEngine: rAF 主动推进 simTime │ │ LiveEngine: wall clock 推进 落后下界 上界 clamp │ └────────────────────┬─────────────────────────────────┘ ▼ ┌─ 渲染层Cesium───────────────────────────────────┐ │ CallbackProperty → getInterpolatedState → scratch │ │ 尾迹线 LINE_STRIP / 翼带 TRIANGLES │ └─────────────────────────────────────────────────────┘共享 / 差异对比维度回放实时simTime 推进wallDelta × speedwallDelta 落后下界 上界 clamp数据到达N/AhandleAppend→_followLiveTick倍速支持固定 1:1到达终点pause继续 rAF 等待新数据帧索引PagedFrameIndexLivePagedFrameIndex共享管线_processFramesUpTo_computeInterpolatedState_prefetchNextFrame同左九、经验总结9.1 架构层面标志位 hack 是技术债的起点。当你发现自己在用_wasFollowingLive这类跨模式标志位时停下来想想这个标志位在所有上下文下含义一致吗如果不是该拆类了。共享基类 子类状态机是处理多模式 大量共享逻辑的经典模式。关键是找到共享和差异的边界与模式无关的逻辑放基类状态机语义放子类。9.2 实时插值层面simTime 必须随 wall clock 推进不能直接跟随数据时间。这是实时插值的核心。maxTime - renderDelay看起来很直觉但 maxTime 是离散的数据帧率simTime 跟随它必然离散跳变。renderDelay 是初始锚定和落后下界不是每帧计算。每帧用simTime maxTime - renderDelay会在renderDelay 帧间隔时产生数值巧合 bugsimTime 永远不变。9.3 工程层面每个不变量都要有测试守护。我们有 144 个测试覆盖各种边界条件。8ms 预算守卫被误删后是测试发现的simTime 推进 bug 也是测试先复现的。文档记录决策原因不只是结果。docs/下有完整的修复历程和架构文档记录了每个设计决策的 why。当半年后有人问为什么 simTime 不直接用 maxTime - renderDelay文档能回答。9.4 踩过的坑坑根因教训修一个 bug 引入三个标志位含义漂移拆类隔离状态机实时模式对象静止simTime maxTime - renderDelay数值巧合simTime 随 wall clock 推进倍速卡顿8ms 预算守卫被误删测试守护不变量稀疏对象跳变固定窗口预读无窗口扫描 静止记忆seek 后特效风暴重放全部历史事件只重放途经区间事件十、写在最后这套架构不是一次性设计出来的而是在反复踩坑中逐步演进的。从单对象 标志位到共享基类 子类状态机从simTime maxTime到wall clock 推进 落后下界每一步都是被真实 bug 倒逼的。好的架构不是设计出来的是从泥潭里爬出来的。关键是每次爬出来后要想清楚为什么会掉进去怎么保证不再掉进去——这才是架构决策的来源。项目地址toTacview基于 Cesium 的 ACMI 战术可视化系统架构文档docs/实时回放统一架构 — simTime推进与插值保障.md
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进