文 / 沙盘客 · 公众号「仿真推演框架」
如果有人问你:"AFSIM 里的时间是怎么流动的?"
大多数人的第一反应是:和现实一样啊,一秒一秒地走,仿真跑了 60 秒,墙上的钟也走了 60 秒。
但这个直觉是错的。
AFSIM 里"没有"一条匀速流动的河。它既没有操作系统里那个滴答作响的时钟中断,也不是在每一帧里给一个全局计数器 +1。在它的内核里,时间是一种被"事件"反复重塑的坐标 ------引擎从一个事件跳到下一个事件,跳的时候把"当前时间"直接改写成下一个事件的时刻。你看到的"连续推进",其实是无数次离散的跳跃。
这篇我们钻进 AFSIM 2.9.0 的源码(swdev/src/core/wsf),把这套时间管理机制一层一层拆开:时钟抽象、离散事件队列、三种时间形态、实时同步与"落后"补偿,以及墙钟事件那套独立于仿真暂停的心跳。
读完你会明白:为什么 AFSIM 既能 10000 倍快进回放,又能严格 1:1 实时,还能被外部 AI 一个事件一个事件地"推"着走。
一、时间的唯一真相:一个 double,从 0 开始
先说最朴素的事实。AFSIM 的"当前仿真时间"就是 WsfSimulation 里的一个成员变量:
cpp
// WsfSimulation.hpp
double mSimTime{0.0}; // 当前仿真时间
double mEndTime; // 定义的结束时间
double mRealTime; // 最近一次采样的实时钟
double mTimeBehind; // 仿真钟落后实时钟多少
double mTimestep; // 帧步进时的步长(离散事件模式下为 0)
double mSyncTimestep; // DIS-NRT 模式下的同步步长
对外只暴露一个取值口:
cpp
double GetSimTime() const { return mSimTime; }
注意类型是 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 的输入解析:
cpp
// WsfSimulationInput.cpp
if (command == "end_time") { aInput.ReadValueOfType(mEndTime, UtInput::cTIME); }
else if (command == "clock_rate") { aInput.ReadValue(mClockRate); }
else if (command == "realtime") { mIsRealTime = true; }
else if (command == "non-realtime") { mIsRealTime = false; }
也就是说,你在 .txt 想定里写的:
yaml
end_time 3600
clock_rate 1
realtime
最终都落到了上面那组 double 上。
二、时钟抽象:WsfClockSource 与"被请求的时间"
AFSIM 把"时钟"做成了可替换的抽象 。WsfSimulation 只持有一个 WsfClockSource 指针:
cpp
std::unique_ptr<WsfClockSource> mClockSourcePtr{nullptr};
WsfRealTimeClockSource* mRealTimeClockSourcePtr{nullptr};
基类的 GetClock 简单到让人意外:
cpp
// WsfClockSource.cpp
double WsfClockSource::GetClock(double aClock) const
{
double clockNow = aClock;
if (clockNow > mMaximumClock) clockNow = mMaximumClock;
return clockNow;
}
它几乎什么都不做 ------把调用者"想要的时间"原样返回(最多被 mMaximumClock 截断)。这正是离散事件模式的本质:在默认(非实时)仿真里,时钟就是"被请求的时间"。引擎说"现在跳到 12.37 秒",时钟就报 12.37 秒,没有"流逝"这回事。
真正让时钟"活"起来的是派生类 WsfRealTimeClockSource。它的 GetClock 是这样定义的:
cpp
// WsfRealTimeClockSource.cpp
double WsfRealTimeClockSource::GetClock(double aClock) const
{
double simulationClock = mTimeAccumulated; // 上次暂停时累计的仿真时间
if (!mClockPaused)
{
simulationClock += mWallClock.GetClock() * mClockRate; // 墙钟流逝 × 倍率
if (simulationClock > mMaximumClock) simulationClock = mMaximumClock;
}
if (aClock < simulationClock) simulationClock = aClock; // 时钟不能超前于"请求时间"
return simulationClock;
}
这一行 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 里,骨架极简:
cpp
// WsfStandardApplication.cpp
while (aSimPtr->IsActive())
{
aSimPtr->WaitForAdvanceTime();
simTime = aSimPtr->AdvanceTime();
// ... 日志、消息分发 ...
}
aSimPtr->Complete(aSimPtr->GetEndTime());
每一轮循环,AdvanceTime() 干的事,是整篇的精华(WsfSimulation.cpp:186):
cpp
double WsfSimulation::AdvanceTime()
{
double timeStart = 0.0;
if (mIsRealTime && (mRealTimeClockSourcePtr != nullptr))
timeStart = mRealTimeClockSourcePtr->GetElapsedWallTime();
WsfEvent* eventPtr = mEventManager.PeekEvent(); // ① 偷看队列里"最早"的事件
if (eventPtr != nullptr)
mSimTime = eventPtr->GetTime(); // ② 时间直接跳到那个事件的时刻!
else
mSimTime = GetEndTime() + 0.1; // 没有事件了 → 越过结束时间
mSimTime = mClockSourcePtr->GetClock(mSimTime); // ③ 让时钟抽象再校准一次
WsfObserver::AdvanceTime(this)(mSimTime);
if (mSimTime > GetEndTime())
mState = cPENDING_COMPLETE; // ④ 越过终点 → 准备收尾
DispatchEvents(mSimTime); // ⑤ 把所有"到点"的事件都执行掉
return mSimTime;
}
注意第 ② 步:mSimTime 不是 += dt,而是被直接赋值为下一个事件的时刻。这就是"离散事件"四字的分量------仿真不会在 12.36、12.37、12.38......里一步步爬,而是从 12.37 直接跳到下一个事件发生的 14.02。两个事件之间的"空白"里,什么都没发生,所以 AFSIM 根本不浪费算力去"走"那 1.65 秒。
事件队列:一个最小堆
驱动这一切的"下一个事件"来自哪?来自 mEventManager,它的底层是 C++ 标准库的最小堆:
cpp
// WsfEventManager.hpp
using EventQueue = std::priority_queue<Event, std::vector<Event>,
std::greater<Event>>; // 最小元素在堆顶
事件的排序键是一个三元组,保证先按时间、再按稳定次序,避免同刻事件乱序:
cpp
using Key = std::tuple<double, int, unsigned int>; // (时刻, 类型优先级, 入队序号)
队列还加了 std::recursive_mutex,所以是线程安全的------在多核并行推演里,不同线程往里塞事件也不会崩。
事件是怎么"跑"起来的
AdvanceTime 最后调用的 DispatchEvents,才是真正执行逻辑的地方:
cpp
// WsfSimulation.cpp
void DispatchEventsHelper(WsfEventManager& aEventManager, double aSimTime)
{
WsfEvent* peekEventPtr = aEventManager.PeekEvent();
while (peekEventPtr && (peekEventPtr->GetTime() <= aSimTime))
{
auto eventPtr = aEventManager.PopEvent();
if (eventPtr->ShouldExecute())
{
WsfEvent::EventDisposition disposition = eventPtr->Execute();
if (disposition == WsfEvent::cRESCHEDULE) // 关键:定时器就是这么实现的
aEventManager.AddEvent(std::move(eventPtr)); // 改个时间,重新入队
}
peekEventPtr = aEventManager.PeekEvent();
}
}
这里有一个常被忽略的妙处:WsfEvent::Execute() 的返回值如果是 cRESCHEDULE ,事件会被改个时间、重新塞回队列。AFSIM 里那些"每 5 秒扫描一次雷达""每 1 秒广播一次位置"的周期性行为,本质上就是靠这个自我重排的事件实现的------并没有一个全局 ticker 在后台滴答。
四、三种时间形态:同一套内核,三种"走法"
光有离散事件还不够。AFSIM 通过三个 WsfSimulation 的派生类,把同一套时钟与事件机制,演绎成三种时间形态:
| 形态 | 类 | 时间怎么走 | 典型用途 |
|---|---|---|---|
| 离散事件(默认) | WsfSimulation |
跳到下一个事件 | 绝大多数作战推演 |
| 帧步进 | WsfFrameStepSimulation |
固定 mNextFrameTime 步进 |
需要等间隔采样的场景 |
| 事件步进 | WsfEventStepSimulation |
一次只推进一个事件 | 调试、单步、外部 lock-step 驱动 |
帧步进:把"跳跃"变成"等间隔台阶"
WsfFrameStepSimulation::AdvanceTime() 不再看队列里下一个事件,而是看 mNextFrameTime:
cpp
double WsfFrameStepSimulation::AdvanceTime()
{
mSimTime = mClockSourcePtr->GetClock(mNextFrameTime + 0.000001);
if (mSimTime > mNextFrameTime)
{
mSimTime = AdvanceFrame(); // 推进一帧,处理这一帧内所有对象
// ...
}
return mSimTime;
}
你在想定里写的 time_step 1.0,对应的就是这种"每隔 1 秒算一帧"的等间隔模式。它牺牲了一点效率(空帧也要走),换来的是输出节奏规整------适合需要固定采样率做后处理的任务。
事件步进:一次一个事件,且能对齐墙钟
WsfEventStepSimulation 是"离散事件"的精细化版:它一次只弹出一个事件并处理,因此可以被外部单步驱动 。更关键的是它的 WaitForAdvanceTime() 里有一段"对齐实时"的逻辑:
cpp
// WsfEventStepSimulation.cpp
double nextEventTime = 1.0E+30;
WsfEvent* eventPtr = mEventManager.PeekEvent();
if (eventPtr) nextEventTime = eventPtr->GetTime();
mRealTime = mClockSourcePtr->GetClock(1.0E+37);
double sleepTime = nextEventTime - mRealTime;
if (sleepTime >= 0.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 的用武之地。
cpp
// WsfSimulation.cpp
void WsfSimulation::SetTimeParameters(int aTimeScheme, double aSimTime,
double aClockRate, double aTimeStep, bool aTimeAdvance)
{
SetClockRate(aClockRate);
mSyncTimestep = aTimeStep;
// ... 设定时钟 ...
AddEvent(ut::make_unique<WsfOneShotEvent>(aSimTime,
[=]() { PauseAndRequestAdvance(aSimTime); })); // 到点就暂停,等外部发话
if (aTimeAdvance && GetClockSource()->IsStopped())
{
mSyncAccumulatedTime = 0.0;
Resume(); // 需要的话恢复
}
}
PauseAndRequestAdvance 会在指定时刻塞一个"暂停事件",于是仿真推进到那一点就停下,通过 WaitForAdvanceTime() 阻塞住,直到外部调用 Resume() 才继续。这就是 AFSIM 与外部控制器之间的 lock-step(锁步)时间协议 ------也是我们在《AFSIM 外部控制与 AI 作战》里讲的 PyCMO、RL 智能体能够"每步观察---决策---动作"的底层支点:时间不是 AFSIM 独占的,而是可以被协商、被暂停、被外部推进的。
还有一个容易被忽视的细节:墙钟事件队列 mWallEventManager 与仿真事件队列是分开的。
cpp
void WsfSimulation::DispatchEvents(double aSimTime)
{
DispatchSimEvents(aSimTime); // 基于仿真时间
DispatchWallEvents(); // 基于墙钟时间
}
墙钟事件即便仿真被暂停,只要墙钟走到点,也会触发------它是仿真管理、外部心跳、连接保活这类"不能因为暂停就停摆"的逻辑的载体。暂停的是"作战世界",不停的是"控制面",这个分离非常工程化。
六、为什么这套设计"聪明"
把源码读到这里,几个工程上的巧思值得点出来:
1. double 秒而非整型 tick。 模型代码里时间直接参与物理运算,积分器、运动学、传播延迟都能用真实物理量表达;同时通过把起点锚在 0 来规避大数精度问题。
2. 时钟与模型解耦。 WsfClockSource 是一个可替换的策略对象。同一套平台/传感器/武器模型,既能在离散事件下 10000 倍快进做蒙特卡洛,又能在实时模式下 1:1 接入 HLA/DIS 演练,模型代码一行都不用改。
3. 最小堆 + 稳定排序键。 取"下一个事件"是 O(log n),同刻事件靠 (时刻, 类型优先级, 入队序号) 三元组稳定定序,既快又确定。
4. 可暂停、可协商的时间。 Pause/Resume + WaitForAdvanceTime 的锁步机制,让 AFSIM 天然适合做人在回路、AI 在回路的推演------时间不再是一个黑盒计数器,而是一个可以被外部握在手里的总线。
5. 确定性回放有根基。 因为时间完全由"事件序列 + 时钟策略"决定,给定相同种子与事件队列,仿真可以复现;real-time 模式下的"落后"也只是一个可观测、可补偿的偏差,而非不可控的混沌。
七、小结与延伸
把全篇串起来,AFSIM 的时间管理可以浓缩成三句话:
- 时间里没有"流逝",只有"被事件改写的坐标" ------
AdvanceTime()把mSimTime直接跳到下一事件时刻; - 时钟是可替换的策略 ------
WsfClockSource基类原样返回请求时间(离散事件),WsfRealTimeClockSource用墙钟 × clock_rate造出实时(或快进/慢放); - 时间是可以被外部握住的 ------
PauseAndRequestAdvance+ 墙钟事件队列,让作战世界能暂停、控制面却始终心跳,从而支撑人在回路与 AI 在回路。
理解它,你就握住了 AFSIM 的"节拍器":所有平台机动、传感器探测、武器发射,本质上都是往那条最小堆事件队列里塞一个个带时刻的球,然后等着引擎把它们一个个弹出来执行。
系列导航:本文是「AFSIM 工程精读」系列之一。前置可参看《初识 AFSIM:一个让作战仿真"跑起来"的框架》《AFSIM 外部控制与 AI 作战》;平台全景见《一张架构图,看懂现代作战仿真推演平台》。
如果这篇对你理清 AFSIM 的"时间观"有帮助,点赞、在看、转发让更多做仿真推演的同行看到。关注「仿真推演框架」,持续拆解作战仿真的工程内核。