文 / 沙盘客 · 公众号「仿真推演框架」如果有人问你“AFSIM 里的时间是怎么流动的”大多数人的第一反应是和现实一样啊一秒一秒地走仿真跑了 60 秒墙上的钟也走了 60 秒。但这个直觉是错的。AFSIM 里没有一条匀速流动的河。它既没有操作系统里那个滴答作响的时钟中断也不是在每一帧里给一个全局计数器1。在它的内核里时间是一种被事件反复重塑的坐标——引擎从一个事件跳到下一个事件跳的时候把当前时间直接改写成下一个事件的时刻。你看到的连续推进其实是无数次离散的跳跃。这篇我们钻进 AFSIM 2.9.0 的源码swdev/src/core/wsf把这套时间管理机制一层一层拆开时钟抽象、离散事件队列、三种时间形态、实时同步与落后补偿以及墙钟事件那套独立于仿真暂停的心跳。读完你会明白为什么 AFSIM 既能 10000 倍快进回放又能严格 1:1 实时还能被外部 AI 一个事件一个事件地推着走。一、时间的唯一真相一个 double从 0 开始先说最朴素的事实。AFSIM 的当前仿真时间就是WsfSimulation里的一个成员变量// WsfSimulation.hppdoublemSimTime{0.0};// 当前仿真时间doublemEndTime;// 定义的结束时间doublemRealTime;// 最近一次采样的实时钟doublemTimeBehind;// 仿真钟落后实时钟多少doublemTimestep;// 帧步进时的步长离散事件模式下为 0doublemSyncTimestep;// DIS-NRT 模式下的同步步长对外只暴露一个取值口doubleGetSimTime()const{returnmSimTime;}注意类型是double双精度浮点单位秒不是整型 tick也不是某个从纪元起算的纳秒计数。这是个很有讲究的设计选择——用double而不是int64的纳秒意味着 AFSIM 把仿真时间建模为连续实数轴上的时刻而不是离散的整型编号。模型里写move at 300 m/s for 12.37 s时间直接参与浮点运算物理积分自然、直观。但double也有代价极大时间跨度下尾数精度会下降。AFSIM 默认把 “0.0” 当作仿真起点mSimTime{0.0}end_time默认 60.0 秒就是把仿真时间轴锚定在一个可控的小范围内避开大数吃精度的坑。那么场景里那个end_time和clock_rate是怎么进到这些变量里的答案在WsfSimulationInput的输入解析// WsfSimulationInput.cppif(commandend_time){aInput.ReadValueOfType(mEndTime,UtInput::cTIME);}elseif(commandclock_rate){aInput.ReadValue(mClockRate);}elseif(commandrealtime){mIsRealTimetrue;}elseif(commandnon-realtime){mIsRealTimefalse;}也就是说你在.txt想定里写的end_time 3600 clock_rate 1 realtime最终都落到了上面那组double上。二、时钟抽象WsfClockSource 与被请求的时间AFSIM 把时钟做成了可替换的抽象。WsfSimulation只持有一个WsfClockSource指针std::unique_ptrWsfClockSourcemClockSourcePtr{nullptr};WsfRealTimeClockSource*mRealTimeClockSourcePtr{nullptr};基类的GetClock简单到让人意外// WsfClockSource.cppdoubleWsfClockSource::GetClock(doubleaClock)const{doubleclockNowaClock;if(clockNowmMaximumClock)clockNowmMaximumClock;returnclockNow;}它几乎什么都不做——把调用者想要的时间原样返回最多被mMaximumClock截断。这正是离散事件模式的本质在默认非实时仿真里时钟就是被请求的时间。引擎说现在跳到 12.37 秒时钟就报 12.37 秒没有流逝这回事。真正让时钟活起来的是派生类WsfRealTimeClockSource。它的GetClock是这样定义的// WsfRealTimeClockSource.cppdoubleWsfRealTimeClockSource::GetClock(doubleaClock)const{doublesimulationClockmTimeAccumulated;// 上次暂停时累计的仿真时间if(!mClockPaused){simulationClockmWallClock.GetClock()*mClockRate;// 墙钟流逝 × 倍率if(simulationClockmMaximumClock)simulationClockmMaximumClock;}if(aClocksimulationClock)simulationClockaClock;// 时钟不能超前于请求时间returnsimulationClock;}这一行mWallClock.GetClock() * mClockRate就是 AFSIM 实时能力的全部秘密mWallClock是真正的墙钟基于UtWallClock可切换performance_counter/system_time/tick_count等计时源mClockRate就是你在想定里写的clock_rate。clock_rate 1是 1:1 实时clock_rate 10表示墙钟走 1 秒、仿真走 10 秒——快进 10 倍clock_rate 0.5则是半速慢放。mTimeAccumulated记录了上次暂停时刻已经累计了多少仿真时间恢复时从此接续不会清零重来。而且SetClockRate支持运行中动态改速率——它会先把当前仿真钟结算进mTimeAccumulated再重置墙钟起点。这意味着你可以让仿真先慢放定位问题再一键加速跑完长航迹全程时间连续不跳变。三、离散事件引擎时间是在跳跃不是流逝现在到了核心。AFSIM 的主循环藏在WsfStandardApplication里骨架极简// WsfStandardApplication.cppwhile(aSimPtr-IsActive()){aSimPtr-WaitForAdvanceTime();simTimeaSimPtr-AdvanceTime();// ... 日志、消息分发 ...}aSimPtr-Complete(aSimPtr-GetEndTime());每一轮循环AdvanceTime()干的事是整篇的精华WsfSimulation.cpp:186doubleWsfSimulation::AdvanceTime(){doubletimeStart0.0;if(mIsRealTime(mRealTimeClockSourcePtr!nullptr))timeStartmRealTimeClockSourcePtr-GetElapsedWallTime();WsfEvent*eventPtrmEventManager.PeekEvent();// ① 偷看队列里最早的事件if(eventPtr!nullptr)mSimTimeeventPtr-GetTime();// ② 时间直接跳到那个事件的时刻elsemSimTimeGetEndTime()0.1;// 没有事件了 → 越过结束时间mSimTimemClockSourcePtr-GetClock(mSimTime);// ③ 让时钟抽象再校准一次WsfObserver::AdvanceTime(this)(mSimTime);if(mSimTimeGetEndTime())mStatecPENDING_COMPLETE;// ④ 越过终点 → 准备收尾DispatchEvents(mSimTime);// ⑤ 把所有到点的事件都执行掉returnmSimTime;}注意第 ② 步mSimTime不是 dt而是被直接赋值为下一个事件的时刻。这就是离散事件四字的分量——仿真不会在 12.36、12.37、12.38……里一步步爬而是从 12.37 直接跳到下一个事件发生的 14.02。两个事件之间的空白里什么都没发生所以 AFSIM 根本不浪费算力去走那 1.65 秒。事件队列一个最小堆驱动这一切的下一个事件来自哪来自mEventManager它的底层是 C 标准库的最小堆// WsfEventManager.hppusingEventQueuestd::priority_queueEvent,std::vectorEvent,std::greaterEvent;// 最小元素在堆顶事件的排序键是一个三元组保证先按时间、再按稳定次序避免同刻事件乱序usingKeystd::tupledouble,int,unsignedint;// (时刻, 类型优先级, 入队序号)队列还加了std::recursive_mutex所以是线程安全的——在多核并行推演里不同线程往里塞事件也不会崩。事件是怎么跑起来的AdvanceTime最后调用的DispatchEvents才是真正执行逻辑的地方// WsfSimulation.cppvoidDispatchEventsHelper(WsfEventManageraEventManager,doubleaSimTime){WsfEvent*peekEventPtraEventManager.PeekEvent();while(peekEventPtr(peekEventPtr-GetTime()aSimTime)){autoeventPtraEventManager.PopEvent();if(eventPtr-ShouldExecute()){WsfEvent::EventDisposition dispositioneventPtr-Execute();if(dispositionWsfEvent::cRESCHEDULE)// 关键定时器就是这么实现的aEventManager.AddEvent(std::move(eventPtr));// 改个时间重新入队}peekEventPtraEventManager.PeekEvent();}}这里有一个常被忽略的妙处WsfEvent::Execute()的返回值如果是cRESCHEDULE事件会被改个时间、重新塞回队列。AFSIM 里那些每 5 秒扫描一次雷达每 1 秒广播一次位置的周期性行为本质上就是靠这个自我重排的事件实现的——并没有一个全局 ticker 在后台滴答。四、三种时间形态同一套内核三种走法光有离散事件还不够。AFSIM 通过三个WsfSimulation的派生类把同一套时钟与事件机制演绎成三种时间形态形态类时间怎么走典型用途离散事件默认WsfSimulation跳到下一个事件绝大多数作战推演帧步进WsfFrameStepSimulation固定mNextFrameTime步进需要等间隔采样的场景事件步进WsfEventStepSimulation一次只推进一个事件调试、单步、外部 lock-step 驱动帧步进把跳跃变成等间隔台阶WsfFrameStepSimulation::AdvanceTime()不再看队列里下一个事件而是看mNextFrameTimedoubleWsfFrameStepSimulation::AdvanceTime(){mSimTimemClockSourcePtr-GetClock(mNextFrameTime0.000001);if(mSimTimemNextFrameTime){mSimTimeAdvanceFrame();// 推进一帧处理这一帧内所有对象// ...}returnmSimTime;}你在想定里写的time_step 1.0对应的就是这种每隔 1 秒算一帧的等间隔模式。它牺牲了一点效率空帧也要走换来的是输出节奏规整——适合需要固定采样率做后处理的任务。事件步进一次一个事件且能对齐墙钟WsfEventStepSimulation是离散事件的精细化版它一次只弹出一个事件并处理因此可以被外部单步驱动。更关键的是它的WaitForAdvanceTime()里有一段对齐实时的逻辑// WsfEventStepSimulation.cppdoublenextEventTime1.0E30;WsfEvent*eventPtrmEventManager.PeekEvent();if(eventPtr)nextEventTimeeventPtr-GetTime();mRealTimemClockSourcePtr-GetClock(1.0E37);doublesleepTimenextEventTime-mRealTime;if(sleepTime0.0){sleepTime/GetClockRate();// 换算成墙钟该睡多久// ... 若睡得久先让出 CPU 一下提升计时精度 ...}else{mTimeBehind-sleepTime;// 仿真落后了通知观察者WsfObserver::SimulationTimeBehind(this)(mTimeBehind);}这就是 AFSIM 在实时模式下**“紧跟墙钟的算法算出下一个事件时刻和当前实时钟的差距把差距按clock_rate换算成该 sleep 的墙钟时长然后让线程睡过去睡醒了再推进。如果算出来是负数事件时刻已经早于实时钟说明仿真落后于实时**于是通过SimulationTimeBehind观察者广播我落后了”让其它组件比如渲染、外部接口决定要不要降负载追赶。五、受控步进与墙钟心跳给外部 AI 留的接口前面讲的都是 AFSIM “自己跑”。但如果你想用 Python 的强化学习智能体、或者用wsf_external_control插件从外部 TCP 接管平台时间就必须能被外部一步一步地驱动——这正是SetTimeParameters与PauseAndRequestAdvance的用武之地。// WsfSimulation.cppvoidWsfSimulation::SetTimeParameters(intaTimeScheme,doubleaSimTime,doubleaClockRate,doubleaTimeStep,boolaTimeAdvance){SetClockRate(aClockRate);mSyncTimestepaTimeStep;// ... 设定时钟 ...AddEvent(ut::make_uniqueWsfOneShotEvent(aSimTime,[](){PauseAndRequestAdvance(aSimTime);}));// 到点就暂停等外部发话if(aTimeAdvanceGetClockSource()-IsStopped()){mSyncAccumulatedTime0.0;Resume();// 需要的话恢复}}PauseAndRequestAdvance会在指定时刻塞一个暂停事件于是仿真推进到那一点就停下通过WaitForAdvanceTime()阻塞住直到外部调用Resume()才继续。这就是 AFSIM 与外部控制器之间的lock-step锁步时间协议——也是我们在《AFSIM 外部控制与 AI 作战》里讲的 PyCMO、RL 智能体能够每步观察—决策—动作的底层支点时间不是 AFSIM 独占的而是可以被协商、被暂停、被外部推进的。还有一个容易被忽视的细节墙钟事件队列mWallEventManager与仿真事件队列是分开的。voidWsfSimulation::DispatchEvents(doubleaSimTime){DispatchSimEvents(aSimTime);// 基于仿真时间DispatchWallEvents();// 基于墙钟时间}墙钟事件即便仿真被暂停只要墙钟走到点也会触发——它是仿真管理、外部心跳、连接保活这类不能因为暂停就停摆的逻辑的载体。暂停的是作战世界不停的是控制面这个分离非常工程化。六、为什么这套设计聪明把源码读到这里几个工程上的巧思值得点出来1. double 秒而非整型 tick。模型代码里时间直接参与物理运算积分器、运动学、传播延迟都能用真实物理量表达同时通过把起点锚在 0 来规避大数精度问题。2. 时钟与模型解耦。WsfClockSource是一个可替换的策略对象。同一套平台/传感器/武器模型既能在离散事件下 10000 倍快进做蒙特卡洛又能在实时模式下 1:1 接入 HLA/DIS 演练模型代码一行都不用改。3. 最小堆 稳定排序键。取下一个事件是 O(log n)同刻事件靠(时刻, 类型优先级, 入队序号)三元组稳定定序既快又确定。4. 可暂停、可协商的时间。Pause/ResumeWaitForAdvanceTime的锁步机制让 AFSIM 天然适合做人在回路、AI 在回路的推演——时间不再是一个黑盒计数器而是一个可以被外部握在手里的总线。5. 确定性回放有根基。因为时间完全由事件序列 时钟策略决定给定相同种子与事件队列仿真可以复现real-time 模式下的落后也只是一个可观测、可补偿的偏差而非不可控的混沌。七、小结与延伸把全篇串起来AFSIM 的时间管理可以浓缩成三句话时间里没有流逝只有被事件改写的坐标——AdvanceTime()把mSimTime直接跳到下一事件时刻时钟是可替换的策略——WsfClockSource基类原样返回请求时间离散事件WsfRealTimeClockSource用墙钟 × clock_rate造出实时或快进/慢放时间是可以被外部握住的——PauseAndRequestAdvance 墙钟事件队列让作战世界能暂停、控制面却始终心跳从而支撑人在回路与 AI 在回路。理解它你就握住了 AFSIM 的节拍器所有平台机动、传感器探测、武器发射本质上都是往那条最小堆事件队列里塞一个个带时刻的球然后等着引擎把它们一个个弹出来执行。系列导航本文是「AFSIM 工程精读」系列之一。前置可参看《初识 AFSIM一个让作战仿真跑起来的框架》《AFSIM 外部控制与 AI 作战》平台全景见《一张架构图看懂现代作战仿真推演平台》。如果这篇对你理清 AFSIM 的时间观有帮助点赞、在看、转发让更多做仿真推演的同行看到。关注「仿真推演框架」持续拆解作战仿真的工程内核。