1. 为什么游戏循环里非要自己造一个计时器
做 Windows 游戏开发,绕不开的第一块基础设施就是 GameTimer。它不负责渲染,不负责逻辑,只在每一帧开头告诉你"距离上一帧过去了多久"。就这一个数字,决定了你的角色移动是否跟手、动画播放是否匀速、物理模拟是否稳定。很多刚上手的朋友习惯性地抄一段timeGetTime()或者直接调GetTickCount()就开工,跑起来看着也挺正常,直到某天在低帧率场景下发现角色穿墙、跳跃高度随帧率变化、慢动作播放时动画抖成幻灯片——回头一查,八成是计时器的锅。这篇东西就是我这些年把 GameTimer 从"能跑"打磨到"敢用"的全过程记录,从 API 选型、类结构设计、逐函数实现,一直讲到怎么把它塞进游戏循环、怎么处理那些只有真正跑起来才会暴露的坑。
先把定位说清楚:你如果是刚接触 Windows 平台 C++ 游戏开发、能看懂 Win32 消息循环但还没自己写过一个完整的时间源抽象,这篇能让你直接抄出一份可用的GameTimer类;你如果已经写过几个小 Demo、但总感觉帧率统计不对劲或者物理更新偶尔抽风,这里关于负 delta、时间钳制、暂停语义的几段应该能对上你的症状。核心关键词就三个:高精度计时、帧间隔、固定步长,后面的所有细节都是围绕它们转的。
为什么强调"自己造"?因为 Windows 上现成的时间接口虽然多,但没有一个能同时满足"高精度、单调递增、不受系统时间调整影响、可暂停、可求总时长"这几条。系统时间会被用户改,会被 NTP 校时拉回去,甚至倒流;WM_TIMER的精度是跟着系统时钟节拍走的,默认 15.6ms 一档,你要做 144Hz 的帧循环,它连门都摸不到。真正靠得住的只有QueryPerformanceCounter这一族,但它返回的是"计数",不是秒,中间还要自己做频率换算、自己做状态管理、自己处理暂停。这些活儿攒在一起,正好就是一个 GameTimer 类的体量。
1.1 现有时间接口的精度实测对比
在动手写类之前,先把候选接口摆出来横向比一遍,知道各自的边界在哪里,后面选型就不用纠结了。
| 接口 | 典型精度 | 单调递增 | 受系统时间调整影响 | 是否适合做帧计时 |
|---|---|---|---|---|
QueryPerformanceCounter/Frequency | 亚微秒级(多数机器频率在 10MHz 量级,即 100ns 一档) | 是 | 否 | 首选 |
timeGetTime | 1ms 标称,实际受时钟节拍影响,常见 1~16ms 抖动 | 是 | 否 | 勉强,低帧率够用 |
GetTickCount/GetTickCount64 | 10~16ms | 是 | 否 | 不够,跳变太粗 |
std::chrono::steady_clock | 底层通常就是 QPC | 是 | 否 | 可用,但不如直接控 QPC 透明 |
std::chrono::system_clock | 纳秒声明,实际毫秒级 | 否 | 是 | 绝对不能用于帧计时 |
WM_TIMER消息 | 约 15.6ms,且消息队列忙时会被合并丢弃 | 否 | 是 | 只能做 UI 定时刷新 |
表里最关键的一列是"单调递增"。system_clock的精度声明得漂亮,但它本质是墙上时间,用户手动改一下系统时钟、或者后台校时服务拉一下,你的 delta 立刻变成负的或者几万秒,物理引擎当场爆炸。我做压力测试的时候故意在跑 Demo 的过程中改系统时间,用system_clock的版本直接让角色瞬移到地图外,用 QPC 的版本纹丝不动。
timeGetTime和GetTickCount这一档,很多人觉得"1ms 精度够了",实际上问题不在分辨率而在抖动。系统默认时钟节拍是 15.6ms,GetTickCount的返回值会成块地跳。你如果拿它算 delta,会出现连续七八帧 delta 为 0、然后突然来一个 15ms 的情况,任何基于 delta 的插值都会肉眼可见地卡顿。想要改善得调timeBeginPeriod(1),但这是全局设置,会影响整机功耗,现代 Windows 上还常常被系统忽略,不是靠谱方案。
至于std::chrono那条路,说实话steady_clock是可以用的,MSVC 下它就是 QPC 的包装。但我仍然倾向于手写 QPC,原因很实际:你要控制的是"计数怎么变成秒"这一步。自己拿着mSecondsPerCount这个缩放因子,什么时候换算、换算成 float 还是 double、要不要缓存当前计数,全都在自己手里。用chrono的duration_cast一层套一层,调试的时候得扒开好几层模板才能确认到底有没有精度损失。
提示:QPC 的频率不保证在所有机器上一致,也不保证每次开机不变,所以
QueryPerformanceFrequency必须在程序启动时调用并缓存,绝对不能在每帧的 Tick 里反复查询。
1.2 固定步长与可变步长的取舍逻辑
计时器本身只负责"报时",真正决定它怎么被用的是游戏循环的驱动方式。这块必须在写类之前想明白,因为类里要不要暴露额外接口、要不要做累积,都取决于这个选择。
可变步长(variable timestep)最直观:每帧拿到真实 delta,直接乘进速度、加速度、角速度里。代码短,物理表现平滑,帧率越高越顺。但它的致命伤是不确定性——同一段跳跃,在 60 帧和 144 帧下算出来的轨迹不一样,因为积分误差会随步长变化。更要命的是当某一帧卡了 200ms,delta 突然变成 0.2,角色会一下子冲出去一大截,直接穿过薄墙。这个问题在垂直同步开启、窗口被拖动、或者切出到桌面再切回来的时候尤其明显。
固定步长(fixed timestep)把逻辑更新和渲染解耦:不管这一帧实际花了多久,逻辑层始终以固定的 1/60 秒(或 1/120 秒)推进,多出来的时间攒起来,够一个步长就跑一次,不够就留到下一帧。好处是确定性极强,物理表现可复现、可回放、可录像,网络同步也容易做。代价是要么渲染插值,要么接受逻辑与画面之间最多一个步长的延迟。
我的实际做法是混合:逻辑用固定步长跑,渲染用可变步长做插值,GameTimer 同时提供DeltaTime()(真实帧间隔)和给累积器用的原始时间。这样类本身保持通用,不把策略焊死在里面。后面第 4 章会把两种驱动方式的完整代码都写出来。
2. GameTimer 的类骨架与成员设计
2.1 成员变量的职责划分
类不大,但每个成员都有明确分工,写之前先在纸上画一遍状态流转,能省掉后面大量调试时间。我的版本是七个成员变量加五个公开方法,结构清清爽爽。
class GameTimer { public: GameTimer(); double TotalTime() const; // 从 Reset 起累计的有效运行秒数(不含暂停) double DeltaTime() const; // 上一帧到这一帧的秒数 double FrameTime() const; // 与 DeltaTime 等价,语义更贴近渲染侧 void Reset(); // 清零:建立新的时间基准 void Start(); // 从暂停中恢复(或首次启动) void Stop(); // 暂停,暂停期间累计时间不增长 void Tick(); // 每帧调用一次,刷新内部状态 private: double mSecondsPerCount; // 每个计数对应多少秒 double mDeltaTime; // 本帧耗时(秒) __int64 mBaseTime; // Reset 时刻的计数,所有累计时间的原点 __int64 mPausedTime; // 累计暂停时长(计数单位) __int64 mStopTime; // 进入暂停瞬间的计数 __int64 mPrevTime; // 上一帧的计数 __int64 mCurrTime; // 当前帧的计数 bool mStopped; // 当前是否处于暂停状态 };mBaseTime是所有累计时间的原点,只在Reset()里写一次;mPausedTime是"欠账",把所有暂停过的时长加起来,算总时长的时候减掉;mStopTime记录进入暂停那一瞬间的计数,用于在暂停状态下也能算出正确的总时长。这三个变量配合起来,才能在"运行—暂停—运行—暂停"反复横跳的情况下,TotalTime()依然连续。
mPrevTime和mCurrTime是帧间隔计算的双指针,Tick()每跑一次就把 curr 挪到 prev 上。这里有个细节值得单独说:Start()里必须把mPrevTime重置为当前计数,否则从暂停里恢复的第一帧,delta 会把整个暂停时长算进去,角色瞬间冲出去老远。我第一版就是漏了这一步,测试时按空格暂停去泡了杯咖啡,回来一按继续,角色直接飞出场景,排查了一下午才发现是这里。
mSecondsPerCount用 double 存。有人图省事用 float,这在计数很大的时候会出事——float 只有 24 位尾数,大概 1600 万,QPC 计数一秒就涨 1000 万,跑两秒之后精度就掉到无法接受的地步。这个坑后面第 5 章细讲。
2.2 单位换算与浮点类型的选择
mSecondsPerCount的计算只有一行:
__int64 countsPerSec = 0; QueryPerformanceFrequency((LARGE_INTEGER*)&countsPerSec); mSecondsPerCount = 1.0 / static_cast<double>(countsPerSec);看着简单,但有两个地方要注意。第一,QueryPerformanceFrequency理论上可以失败(极老的硬件或者某些虚拟化环境),返回值是 BOOL,稳妥起见应该检查一下,失败就退回到一个保守值或者直接报错退出。第二,LARGE_INTEGER是个联合体,直接强转指针虽然能跑,但严格来说要有对齐保证,用LARGE_INTEGER类型的临时变量接收更干净:
LARGE_INTEGER freq; QueryPerformanceFrequency(&freq); mSecondsPerCount = 1.0 / static_cast<double>(freq.QuadPart);为什么用 double 而不是 float,这里给个量化对比。假设 QPC 频率是 10MHz,程序连续跑一天,计数大约是 8.64×10¹¹。float 的有效位数约 7 位十进制,在这个量级上相邻两个可表示值之间的间隔已经到了 65536,也就是 6.5 毫秒——你算出来的 delta 会像台阶一样跳。double 有 15~16 位有效数字,同样的量级下间隔只有 0.0001 左右,完全够用。所以从"计数"到"秒"这一步的中间量,以及最终对外暴露的 delta、总时长,统一用 double,别在类型上省钱。
算 delta 时的顺序也有讲究。常见写法是先减再乘:
mDeltaTime = (mCurrTime - mPrevTime) * mSecondsPerCount;先做整数减法得到差值(一个很小的数),再乘以缩放因子,精度损失最小。反过来先各自乘再相减,两个大数相减会产生严重的有效位抵消,尤其用 float 的时候几乎必然出错。这个习惯建议从一开始就养成。
注意:
mSecondsPerCount一旦算出来就是个常量,不要在 Tick 里重新查询频率。有些虚拟机和云环境里频率会变,但那是极边缘情况,用一次查询加缓存换取每帧少一次系统调用,这笔账怎么算都划算。
3. 逐个函数的实现细节
3.1 Reset:把基准时间钉死
Reset()的职责是把计时器拉回初始状态,建立新的时间原点。它的实现很短,但每个赋值都不能漏:
void GameTimer::Reset() { LARGE_INTEGER curr; QueryPerformanceCounter(&curr); mBaseTime = curr.QuadPart; mPrevTime = curr.QuadPart; mStopTime = 0; mPausedTime = 0; mDeltaTime = 0.0; mStopped = false; }为什么mPrevTime也要设成当前计数?因为它代表"上一帧的时刻",如果 Reset 之后第一帧的mPrevTime还是 0,算出来的差值就是"从 1970 年到现在"那么大的一个数,乘上缩放因子直接是天文数字。所以 Reset 必须把 prev 和 base 都钉在当前这一刻。
mStopped设为 false 意味着 Reset 之后计时器处于运行态,不需要额外调Start()。这个语义我用下来最顺手——游戏开始菜单点"新游戏"、关卡切换、从存档恢复,都直接调Reset(),一行搞定,不用关心之前是跑着还是停着。
还有一种设计是把 Reset 拆成"初始化"和"重新计时"两个函数,我觉得没必要。这个类没有需要昂贵初始化的资源,Reset 本身就是完整的一次重新初始化,语义统一反而好理解。
3.2 Start 与 Stop:暂停语义的两种写法
暂停这块,业界有两种常见实现,各有取舍,我把两种都写出来,你自己挑。
写法 A:维护累计暂停时长。就是我上面类骨架用的那套,mPausedTime累加所有暂停过的时长,算总时长时减掉。
void GameTimer::Stop() { if (mStopped) return; LARGE_INTEGER curr; QueryPerformanceCounter(&curr); mStopTime = curr.QuadPart; mStopped = true; } void GameTimer::Start() { if (!mStopped) return; LARGE_INTEGER curr; QueryPerformanceCounter(&curr); mPrevTime = curr.QuadPart; // 关键:避免暂停时长泄漏进 delta mPausedTime += curr.QuadPart - mStopTime; // 把这一段暂停记进欠账 mStopTime = 0; mStopped = false; }注意Stop()里要先判空再操作,Start()同理。这两个函数会被 UI 事件、焦点切换、暂停菜单反复调用,重复调用必须是无害的,也就是所谓的幂等。少了这两个 if,一次误触发就会让mPausedTime多记一段,累计时长从此永久偏移,而且很难查。
写法 B:直接平移基准时间。不维护mPausedTime,暂停恢复时把mBaseTime往后推同样的量。
void GameTimer::Start() { if (!mStopped) return; LARGE_INTEGER curr; QueryPerformanceCounter(&curr); mBaseTime += curr.QuadPart - mStopTime; // 基准整体后移 mPrevTime = curr.QuadPart; mStopTime = 0; mStopped = false; }B 版少一个成员变量,总时长的公式也更简单(直接curr - base)。但它有个副作用:mBaseTime不再是"Reset 那一刻",而是"Reset 那一刻加上所有暂停时长",如果你哪天想拿mBaseTime去做别的用途(比如和外部时间戳对齐),语义就模糊了。我最后选了 A 版,纯粹是因为它把"原点"和"欠账"两个概念分得很清楚,调试的时候一眼就能看出各是多少。
还有个容易被忽略的场景:窗口失去焦点要不要暂停。我的建议是逻辑层暂停、计时器也暂停,但要给个宽限,比如失焦超过 0.5 秒才真正 Stop,避免 Alt+Tab 切一下回来角色就瞬移。宽限的判断可以放在消息处理里,用一个独立的计数器,不要塞进 GameTimer 内部——计时器应该只关心时间,不关心业务策略。
3.3 Tick:最核心的几行代码
整个类最核心的就是Tick(),它每帧被调用一次,刷新当前时刻并算出 delta。
void GameTimer::Tick() { if (mStopped) { mDeltaTime = 0.0; return; } LARGE_INTEGER curr; QueryPerformanceCounter(&curr); mCurrTime = curr.QuadPart; mDeltaTime = (mCurrTime - mPrevTime) * mSecondsPerCount; mPrevTime = mCurrTime; // 防御:多核或虚拟化环境下计数偶发回退,钳死负值 if (mDeltaTime < 0.0) mDeltaTime = 0.0; }暂停状态下直接返回并把 delta 置 0,这是刻意为之。如果暂停时还返回真实 delta,那么恢复后的第一帧会拿到一个巨大的值,所有的插值、动画、物理都会跳一下。置 0 意味着"这一帧什么都没发生",渲染层用上一次的状态重绘即可,视觉上就是完全静止。
那行负值防御看起来多余,实际很有必要。QPC 底层在不同硬件上可能是 TSC、HPET 或者别的计数器,多核 CPU 上不同核心的 TSC 可能有微小偏移,线程被调度到另一个核心后读出来的计数有可能比上一次小一点点。正常情况下这个量级极小(纳秒级),但一旦出现负 delta,后面的开方、对数、除法都可能出问题。直接钳到 0,代价是丢一帧时间,收益是彻底杜绝一类随机崩溃。
另外提一句:Tick()不是线程安全的,它读写多个成员变量,只能在一个线程里调用。如果你的架构里有渲染线程和逻辑线程,计时器要么归主线程独占、结果通过原子量分发给其他线程,要么每个线程各持有一个实例。千万别两个线程共用一个实例,那不叫优化,那叫给自己挖坑。
3.4 TotalTime 与 FPS 平滑统计
TotalTime()的公式要按当前是否暂停分两种情况:
double GameTimer::TotalTime() const { if (mStopped) { return (mStopTime - mPausedTime - mBaseTime) * mSecondsPerCount; } return (mCurrTime - mPausedTime - mBaseTime) * mSecondsPerCount; }暂停时用mStopTime而不是mCurrTime,因为暂停期间mCurrTime不会刷新(Tick 提前返回了),拿它算会得到一个过时的值。这个分支如果漏写,表现就是"暂停的时候画面上的计时数字不动",看着像 bug,其实是逻辑上就该不动——但如果你需要暂停时 UI 依然能显示准确的累计时长,就必须走mStopTime这个分支。
DeltaTime()和FrameTime()就是直接返回成员,顺手加个const,编译器能帮点忙。
帧率统计我单独做了一层,不塞进 Timer 里。理由很简单:Timer 的职责是提供时间,把统计逻辑混进去会让类越来越臃肿,而且不同项目对"帧率"的定义不一样(瞬时、1 秒平均、滑动窗口)。我的做法是在外面写一个小的环形缓冲:
class FrameStats { public: void Push(double dt) { mBuffer[mIndex] = dt; mIndex = (mIndex + 1) % kWindow; if (mCount < kWindow) ++mCount; } double AverageFps() const { if (mCount == 0) return 0.0; double sum = 0.0; for (int i = 0; i < mCount; ++i) sum += mBuffer[i]; return mCount / sum; // 帧数除以总时长 } private: static constexpr int kWindow = 60; double mBuffer[kWindow] = {}; int mIndex = 0; int mCount = 0; };用滑动窗口而不是"每秒重置一次计数器",是因为后者在秒边界上数字会跳,调试信息看着很难受。60 帧的窗口在 60Hz 下正好是一秒,在 144Hz 下是 0.4 秒,响应快且稳定。mCount / sum这个算法比"对每帧的 1/dt 求平均"更准,因为后者会被极小的 dt 严重拉高(比如某一帧 dt 是 0.0001,瞬时帧率算出 10000,一下就把平均值带偏了)。
4. 接进游戏循环:两种驱动方式
4.1 固定步长累加器的完整写法
固定步长是逻辑层最稳的选择。核心思想是把真实帧时间攒起来,够一个固定步长就跑一次更新,不够就存着等下一帧。
const double kFixedDt = 1.0 / 60.0; // 逻辑步长 double accumulator = 0.0; GameTimer timer; timer.Reset(); MSG msg = {}; bool running = true; while (running) { while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) { if (msg.message == WM_QUIT) { running = false; break; } TranslateMessage(&msg); DispatchMessage(&msg); } if (!running) break; timer.Tick(); double frameTime = timer.DeltaTime(); // 上限钳制:防止断点调试或窗口拖动后的超大帧把逻辑跑爆 if (frameTime > 0.25) frameTime = 0.25; accumulator += frameTime; int steps = 0; while (accumulator >= kFixedDt && steps < 8) { Update(kFixedDt); // 逻辑更新,步长恒定 accumulator -= kFixedDt; ++steps; } // 极端情况下丢弃剩余的累积时间,避免"死亡螺旋" if (steps == 8) accumulator = 0.0; double alpha = accumulator / kFixedDt; // 渲染插值系数 [0,1) Render(alpha); }这里有三处防身设计,每一个都是踩过坑之后加的。
第一处是frameTime > 0.25的钳制。调试时打个断点停十秒,返回后第一帧的 delta 就是 10 秒,累加器一下子攒了 600 个步长,如果没有步数上限,while会连续跑 600 次Update,画面卡死几十秒,你还以为是死锁。0.25 秒这个值是个经验值,够容纳绝大多数正常卡顿(比如加载资源时的短暂停顿),又能挡住断点、休眠、窗口拖动这类异常。
第二处是steps < 8。就算钳了 0.25,0.25 / (1/60) ≈ 15 步,还是太多。限制最多补 8 步,剩下的直接丢弃,让游戏"跳过"那段时间而不是"追赶"它。玩家感知到的是掉帧,而不是卡死。这个取舍很关键——追赶会引发连锁反应:补的步数越多单帧越慢,单帧越慢累积越多,最后就是经典的死亡螺旋(death spiral),掉帧掉到永久卡住。
第三处是alpha的插值系数。固定步长跑逻辑,渲染时用alpha在两个逻辑状态之间做线性插值,画面就不会有一帧一档的顿挫感。这个细节很多人省掉,觉得 60Hz 逻辑配 60Hz 显示器看不出差别,但一旦显示器是 144Hz 或者开了可变刷新率,不插值的话肉眼可见地"重影"。
4.2 可变步长与上限钳制
如果项目是 2D 小游戏、物理简单、对确定性没要求,可变步长写起来最省事。
timer.Tick(); double dt = timer.DeltaTime(); // 同样是钳制,但阈值可以更小 if (dt > 0.1) dt = 0.1; if (dt < 0.0) dt = 0.0; Update(dt); Render();dt < 0这一行看着多余,因为 Tick 里已经防过了。但在多线程架构里,如果你的 delta 是通过原子量从别的线程传过来的,或者你用别的实现替换了 Timer,这个防御就很有价值。几纳秒的开销换一个稳定的下界,我认为值得。
可变步长的真正门槛不在写,而在调。同样的移动速度,帧率 30 和帧率 144 下的手感差异会很明显,尤其是涉及加速度、阻尼、弹簧的这些非线性环节。我的经验是:如果用了可变步长,所有涉及积分的参数都要按"每秒"来标定,然后在代码里显式乘dt,绝不能出现"每帧加 0.1"这种写法。听着像常识,但我见过的实际项目里,这种硬编码比比皆是,一旦帧率波动就开始作妖。
还有一种介于两者之间的做法:半固定步长。逻辑还是用可变 dt,但把 dt 量化到 1ms 的整数倍(dt = floor(dt * 1000) / 1000.0)。这样能消除大部分浮点抖动带来的不确定性,又不用引入累加器和插值。适合原型阶段,真要做确定性回放的时候再换成严格固定步长。
提示:垂直同步开启时,
Present会阻塞到下一个刷新周期,这段时间不计入你的 delta 计算窗口内(取决于你在循环的哪个位置调 Tick)。所以 Tick 的位置要固定——永远在帧开头调一次,别在中间穿插调用,否则同一帧内多次 Tick 会让 delta 变成碎片。
5. 踩坑实录与排查清单
5.1 常见问题速查表
下面这张表里的每一条,我或者身边的同行都真实遇到过,症状和对策可以直接对照着查。
| 症状 | 最可能的原因 | 排查与修复 |
|---|---|---|
| 角色移动速度随帧率变化 | delta 没乘进速度,或乘了两次 | 全局搜速度相关代码,确认只有一处乘dt |
| 暂停恢复后角色瞬移 | Start()里没重置mPrevTime | 恢复时把mPrevTime设为当前计数 |
| 帧率显示 10000 以上 | 用1/dt求平均,被极小 dt 拉高 | 改用"帧数 / 总时长"的滑动窗口算法 |
| 断点调试后画面卡住 | 超大 delta 导致补帧次数爆炸 | 钳制frameTime上限并限制单帧最大步数 |
| 计时偶尔倒退,出现负 delta | 多核 TSC 不同步 | Tick 里钳负值为 0,必要时给线程设亲和性 |
| 长时间运行时间越走越偏 | 用 float 存计数或缩放因子 | 全部换成 double |
| 窗口拖动时逻辑乱跳 | 拖动期间消息循环被占用,帧间隔异常拉长 | 失焦暂停 + 上限钳制双保险 |
| 睡眠唤醒后时间错乱 | 系统休眠期间 QPC 的跨睡眠行为因平台而异 | 监听电源事件,唤醒后调一次Reset() |
| 总时长比实际长 | 暂停时长没扣掉 | 检查mPausedTime的累加和减法分支 |
| 两线程共用一个 Timer 崩溃 | Tick()非线程安全 | 每线程独立实例,或加锁 + 原子量分发 |
举两个我自己踩得最深的例子。
一个是用mCurrTime算暂停时长。我的第一版TotalTime()没写暂停分支,暂停时界面上的计时数字还在涨——因为mCurrTime停留在暂停前的最后一帧,减去 base 得到的是"暂停开始时的时长",看着像涨其实是停住了,但我又加了别的逻辑让它继续读,结果就是两个时间源打架。后来把分支写全,问题立刻消失。这类 bug 的恶劣之处在于它不崩溃、不报错,只是数字悄悄不对,不做长时间对照测试根本发现不了。
另一个是垂直同步下的 delta 分布。开了 VSync 之后,Present会阻塞到下一个刷新点,等于把每帧的耗时人为对齐到刷新周期。如果你的显示器是 60Hz,理论 delta 应该是 16.67ms,实测下来大部分帧是 16.6~16.8ms,但每隔一段时间会冒出一个 33.3ms(丢了一帧)后面紧跟着一个 0.2ms(追回来了)。这种锯齿状的 delta 序列如果用固定步长跑,累加器会频繁地在 1 步和 2 步之间切换,逻辑更新节奏抖动,手感发飘。解决办法是把固定步长设得比刷新周期小一档,比如 60Hz 显示器配 1/120 的逻辑步长,让累积器的溢出更平滑。
5.2 几条压箱底的实操经验
第一条:把 Timer 的调试输出做成可开关的。我习惯在 Tick 末尾加一段#ifdef TIMER_DEBUG的日志,打印当前计数、delta、总时长,输出到调试窗口。平时关着零开销,出问题的时候打开跑几十帧,时间序列一目了然。比在调试器里单步看变量快得多,尤其是那种"跑十几分钟才出现一次"的问题。
第二条:给 delta 加一条"异常告警线"。除了钳制,我还会统计"超过 0.05 秒的帧"出现的次数。如果这个计数在正常运行五分钟内超过某个阈值,说明有隐蔽的性能问题(比如某处每帧加载资源,或者内存泄漏导致 GC 压力),值得回头看。这个计数器在性能优化阶段比平均帧率有用得多——平均帧率会被少数好帧掩盖问题,异常帧计数不会。
第三条:别指望 QPC 在所有环境下都完美。极少数云主机、虚拟机、老硬件上,QPC 的行为会有偏差。稳妥的做法是在启动时做一次自检:连续读两次计数,确认第二次大于第一次,且差值不为零;再Sleep(100)之后读一次,确认差值落在合理范围内(比如换算后在 90~110ms 之间)。自检不过就降级到timeGetTime并给出日志警告。这套自检代码不到二十行,但能省掉"为什么在我的机器上时间走得更快"这种无从下手的用户反馈。
第四条:时间缩放(time scale)单独留一个乘数。慢动作、快进、子弹时间这类效果,不要改固定步长的常量,而是在 Tick 之后对 delta 乘一个缩放系数。这样逻辑层拿到的还是"游戏内时间",动画、物理、AI 全部同步变速,不需要每个系统单独改。我的习惯是在GameTimer外面包一层ScaledTimer,暴露SetScale(double)和DeltaTime(),内部做乘法并做上下界钳制(比如限制在 0.1~4.0),防止缩放参数写错导致整个游戏时间崩掉。
第五条:每帧只调一次 Tick,位置固定在循环最开头。我见过有人在渲染前调一次、物理更新前又调一次,结果同一帧里物理用的 delta 和渲染用的是两个值,表现是动画和碰撞不同步。这个规矩写在类的注释里,比写在文档里管用。
最后分享一个我最近才用上的小技巧:在GameTimer里加一个double ElapsedSinceReset() const,返回从 Reset 到现在的墙钟秒数(不做暂停扣除)。它和TotalTime()的区别是,暂停期间它依然在涨。用途是给那些"不管游戏是否暂停都要继续"的东西计时,比如 UI 动画、加载界面的进度条、日志时间戳。两个接口并存,各用各的场景,比改一个通用接口加参数干净。
后续想扩展的话,还可以往这个类里加帧序号、加一个环形记录最近 N 帧 delta 的调试缓冲、或者把 Tick 返回的 delta 拆成"原始值"和"钳制后值"两份暴露出去。这些都不难,关键是先把最核心的那几行写对——毕竟整个游戏的时序感,最后都压在mDeltaTime = (mCurrTime - mPrevTime) * mSecondsPerCount;这一行上。