GameTimer 创建与解析:Windows 游戏高精度计时与主循环时间轴

发布时间:2026/10/27 3:23:13
GameTimer 创建与解析:Windows 游戏高精度计时与主循环时间轴

同样一份角色移动代码,放在自己的机器上跑得顺滑,换到另一台机器上角色就像踩了油门或者拖了铅块;打开垂直同步时一切正常,关掉之后速度直接翻倍。这类问题十有八九出在计时环节,跟渲染、物理、资源加载都没关系。我见过不少初学 Windows 游戏开发的朋友,把 GameTimer 当成一个"拿当前时间"的工具函数随手糊上去,结果后面调手感、做回放、做网络同步的时候全线返工。游戏计时器看着只有几十行代码,但它决定了整个主循环的时间轴是否可信,而这根轴一旦歪了,上面盖多少层都是歪的。

这篇内容围绕 GameTimer 的创建与解析展开,把 QueryPerformanceCounter 这套高精度计时机制讲透,把 GameTimer 类的字段设计、Tick 计算、暂停恢复、DeltaTime 的使用方式一步步拆开,再补上我在实际项目里踩过的几类坑。适合已经能写出一个能跑起来的窗口和消息循环、准备把主循环正规化的读者,也适合那些帧率数字看着正常但手感就是不对劲的人回头对照检查。

1. 从帧率漂移说起:为什么游戏需要一个专门的计时器

1.1 同一份移动代码在两台机器上的不同表现

最典型的症状是这样的:写了一句position.x += speed;,每帧执行一次。在自己的开发机上跑,角色每秒移动 60 个 speed 单位;换到一台高刷屏的机器上,客户端跑在 144 帧,角色每秒移动 144 个 speed 单位,直接快了一倍多。反过来如果机器性能差只跑 30 帧,角色又慢了一半。这种写法叫"按帧更新",本质上是把帧数当成了时间单位,而帧数是个和硬件性能强绑定的量,完全不可控。

修正思路很直接:让每一帧的更新量乘上这一帧实际消耗的时间,也就是 DeltaTime。position.x += speed * dt;,这里的 speed 单位从"每帧多少"变成了"每秒多少",于是不管这一帧是 16 毫秒还是 7 毫秒,一秒累积下来位移都一样。这个改动听起来是常识,但它对计时器提出了硬性要求:dt 必须足够准,而且必须反映真实的墙钟时间,不能是估算值。

问题在于,Windows 上随手能拿到的几个时间接口,精度差得很远。有些接口的粒度是 10 到 16 毫秒,有些虽然名义上是毫秒但实际会被系统调度拉长。如果你用一个粒度 15 毫秒的时钟去算 dt,在一帧只有 7 毫秒的情况下,你会连续拿到 0、0、15、0、0、15 这样的序列。位移曲线立刻变成一顿一顿的阶梯,肉眼可见地抖。

1.2 WM_TIMER 为什么不适合驱动游戏主循环

很多人的第一反应是:Windows 不是有现成的定时器吗,SetTimer 设个 16 毫秒,收到 WM_TIMER 就更新一帧,多省事。这条路我试过,结论是它能做出一个"能动"的东西,但做不出手感对的东西。

WM_TIMER 的消息优先级在所有消息里排得很低。它是低优先级合成消息,只有当消息队列里没有别的消息时才会被投递。这意味着只要你窗口上有鼠标移动、有重绘请求、有任何一个按键消息,WM_TIMER 就会被往后压。用户快速拖动窗口的时候,游戏会直接卡住不更新,因为拖动窗口本身会阻塞消息泵。更麻烦的是它的精度,SetTimer 的最小粒度受系统时钟节拍限制,常见的默认值在 15.6 毫秒附近,即使你传 1,实际触发间隔也远大于 1 毫秒。你无法用它做出稳定的 60 帧。

还有一点容易被忽略:WM_TIMER 不累积。如果系统忙,错过了三次触发,它不会补发三条消息,只会合并成一条。也就是说时间信息在消息层就被丢弃了,你拿到消息的时候已经无从知道到底过了多久。对于需要"补齐"逻辑的游戏循环来说,这是致命的。

WM_TIMER 有它合适的场合,比如每隔几秒刷新一下状态栏文字、定期检查一下某个配置文件有没有变。用它驱动游戏主循环,等于把时间轴交给了一个不保证精度的调度器。

1.3 GameTimer 在游戏主循环里应该站在哪个位置

一个基本成形的主循环长这样:先处理消息,再 Tick 计时器拿到这一帧的 dt,然后用 dt 去更新逻辑,最后渲染并呈现。顺序不能乱。

计时器的 Tick 必须放在消息处理之后、逻辑更新之前。原因是消息处理里可能有耗时的操作,比如窗口尺寸变化引发的交换链重建、设备丢失后的资源重建。这些操作消耗的时间应该被算进这一帧的 dt 里,否则你会看到窗口一拖动、画面就跳一下。同理,呈现(Present)之后到下一帧 Tick 之前的那段等待时间,也要被算进下一帧的 dt。所以 Tick 的位置实际上定义了"这一帧的时间窗口从哪里切到哪里"。

标准的落点是:消息处理 → Tick → 更新 → 渲染 → Present → 循环回到消息处理。这样每一帧的 dt 覆盖的是"上一帧 Tick 到这一帧 Tick"的完整区间,包含了渲染、呈现、以及呈现时的等待。这个窗口是完整的,不会漏掉任何一段时间。

我见过有人把 Tick 放在更新之后,用"上一帧的 dt"来更新"这一帧的逻辑",本质上把逻辑延后了一帧。单机游戏里这通常看不出来,做联机的时候就会发现输入和状态的对应关系总是错一拍,排查起来非常难受。

2. QueryPerformanceCounter 这套机制到底准不准

2.1 频率常量从哪来,为什么绝对不能写死

QueryPerformanceFrequency 返回的是计数器每秒的刻度数。这个值在不同硬件上完全不一样,老机器上可能是 3579545(约 3.58 MHz,来自早期的定时器芯片),新一点的平台上常见 10000000(10 MHz),虚拟化环境里又是另一个数。所以代码里绝对不能写const double freq = 10000000.0;然后拿计数器差值直接除。这种硬编码在开发机上可能刚好对,换台机器就全乱,而且乱得非常隐蔽——帧率看着正常,只是所有东西都慢了几倍或快了几倍。

正确的做法是在计时器构造时查询一次,存成m_SecondsPerCount = 1.0 / freq。之后所有的刻度差值乘上这个系数就得到秒数。查询一次就够,这个值在进程生命周期内不变,不要每帧去查,那是一次系统调用,虽然不贵但完全没必要。

GameTimer::GameTimer() : m_SecondsPerCount(0.0), m_DeltaTime(-1.0), m_BaseTime(0), m_PausedTime(0), m_StopTime(0), m_PrevTime(0), m_CurrTime(0), m_Stopped(false) { __int64 countsPerSec = 0; QueryPerformanceFrequency(reinterpret_cast<LARGE_INTEGER*>(&countsPerSec)); m_SecondsPerCount = 1.0 / static_cast<double>(countsPerSec); }

有个细节值得说:构造时把 m_DeltaTime 初始化成 -1.0 而不是 0,这是刻意的。第一帧的 dt 一定是无效的(因为没有"上一帧"),把它设成负数,任何在后面误用它的地方都会立刻暴露成诡异的表现,而不是悄悄按 0 处理掉。这种"让错误尽早显形"的小手段在实际调试里很省事。

2.2 几种常见计时方案的横向对比

为了说清楚为什么最终选了 QueryPerformanceCounter,把 Windows 上几个可选方案摆在一起看会更直观:

方案典型精度是否单调主要问题适合场景
timeGetTime1~15 毫秒,取决于系统设置基本单调精度受系统时钟节拍影响,长时间运行会漂粗粒度日志打点
GetTickCount / GetTickCount6410~16 毫秒单调精度太差,无法用于帧级计时超时判断、简单耗时统计
std::chrono::steady_clock通常纳秒级单调底层仍映射到系统高精度计数器,跨平台友好跨平台项目首选
QueryPerformanceCounter亚微秒级单调(现代平台)需要配对查询频率,Windows 专属Windows 原生游戏主循环

在纯 Windows 项目里,QueryPerformanceCounter 是最直接的选择,它绕开了系统时钟节拍的限制,直接读取硬件计数器。跨平台项目用 steady_clock 也完全够用,在 Windows 上它底层基本就是同一套东西,包装了一层而已。

要注意的是"名义精度"和"实际分辨率"是两回事。计数器本身可以给出很小的差值,但两次调用之间的真实时间间隔受 CPU 调度、缓存、分支预测等影响,会有几十到上百纳秒的抖动。对于 16 毫秒一帧的游戏来说,这个量级的抖动完全可以忽略。真正需要警惕的是毫秒级以上的误差,那来自于接口选错了,不是硬件的问题。

2.3 关于多核偏移和降频的那些老说法

早年间有个流传很广的说法:QueryPerformanceCounter 在多核机器上,如果线程在不同核心之间迁移,读到的值会跳变,甚至倒退。这个问题确实在非常古老的硬件和驱动组合上出现过,当时的计数器没有做跨核同步。但那是二十多年前的事了,现代平台上的实现已经保证了跨核心的一致性。

不过这并不意味着可以放松警惕。降频和升频是真实存在的:笔记本在省电模式下 CPU 频率会掉,某些计数器是基于固定频率的晶振,不受影响,另一些实现则可能受到电源状态切换的影响。我在实际项目里遇到过一次,某台机器切换电源方案时,计数器会出现一个几十毫秒的小跳变。这种情况概率很低,但也不能完全不管——所以代码里还是应该加一道保险:算出来的 dt 如果小于 0,直接钳到 0。

m_DeltaTime = (m_CurrTime - m_PrevTime) * m_SecondsPerCount; if (m_DeltaTime < 0.0) m_DeltaTime = 0.0;

这两行看起来可有可无,但它把"时间倒退"这种理论上的异常压缩成了一个无害的零帧。与其相信硬件永远正确,不如花两行代码把边界兜住。

3. GameTimer 类的字段设计与 Tick 实现

3.1 六个 64 位时间戳各自承担什么职责

一个能正确处理暂停和恢复的 GameTimer,需要维护六个__int64类型的计数器值,每一个都不是多余的:

  • m_BaseTime:计时起点的绝对刻度。Reset 时被设为当前值,代表"游戏时间 0"。
  • m_PausedTime:累计的暂停总时长。每次从暂停恢复时,把这段暂停的刻度差累加进来。
  • m_StopTime:进入暂停那一刻的刻度值。恢复时用它来计算这次暂停了多久。
  • m_PrevTime:上一帧 Tick 时的刻度值,用来算 dt。
  • m_CurrTime:当前帧 Tick 时的刻度值,也就是最新读到的那个。
  • m_Stopped:暂停标志位。

关键在于把"绝对时刻"和"累计暂停"分开存。游戏时间不等于墙钟时间,暂停期间游戏时间应该冻结。如果只存一个起始时刻然后拿当前时刻减,暂停期间游戏时间就会照常走,恢复之后所有基于总时间的动画、技能冷却、AI 状态机全部错位。

用生活化的说法:m_BaseTime 是秒表按下那一刻,m_PausedTime 是这块秒表中间被按停的总时长,m_StopTime 是最近一次被按停的时刻。真实的"跑了多久"就是当前时刻减去起点,再减去所有停顿。

3.2 Reset、Start、Stop 的调用时机与不变量

这四个方法的调用时机很容易搞混,我按实际使用顺序说。

Reset 在进入主循环之前调用一次,作用是清空所有历史、把起点设在当下。它会把 m_Stopped 置回 false,所以 Reset 之后计时器是运行状态,不需要再额外调 Start。如果你在游戏内做"重新开始一局",重新调一次 Reset 就能让总时间归零。

Stop 在窗口失去焦点、用户按下暂停键、或者需要挂起的时候调用。它的实现就是读一次当前刻度存进 m_StopTime,然后把 m_Stopped 置 true。注意它会把 m_Stopped 打开,所以重复调用是幂等的,开头加个if (m_Stopped) return;就行。

Start 在恢复的时候调用,做三件事:读当前刻度、把这次暂停的时长累加进 m_PausedTime、把 m_PrevTime 重设为当前刻度。第三件事最容易被漏掉,漏了会怎样?恢复后的第一帧,dt 会等于整个暂停时长。如果你暂停了五分钟去吃个饭,恢复瞬间角色会瞬移出去五秒钟的路程。这个 bug 我在项目里见过不止一次,表现极其诡异,因为只有暂停之后的第一帧会出错。

void GameTimer::Start() { if (!m_Stopped) return; __int64 currTime = 0; QueryPerformanceCounter(reinterpret_cast<LARGE_INTEGER*>(&currTime)); m_PausedTime += (currTime - m_StopTime); m_PrevTime = currTime; m_StopTime = 0; m_Stopped = false; }

Stop 状态下 Tick 的行为也要定义清楚:m_DeltaTime 直接置 0 并返回,不去读计数器。这样暂停期间即使有人调用 Tick,也不会污染 m_PrevTime。这两处配合起来,才构成一个完整的暂停语义。

3.3 总时间的算法与浮点精度的取舍

TotalTime 返回的是从 Reset 开始、扣掉所有暂停之后累计的游戏时间。分暂停和非暂停两种情况:

float GameTimer::TotalTime() const { if (m_Stopped) return static_cast<float>(((m_StopTime - m_PausedTime) - m_BaseTime) * m_SecondsPerCount); return static_cast<float>(((m_CurrTime - m_PausedTime) - m_BaseTime) * m_SecondsPerCount); }

暂停状态下不能用 m_CurrTime,因为暂停之后没有再 Tick,m_CurrTime 是旧值,应该用冻结住的 m_StopTime。

这里有一个值得单独拎出来说的精度问题:总时间用 float 存,能撑多久?float 有 24 位有效位,当数值大到 16777216 之后,加 1 就已经无法表示了。换算成秒,大约是 194 天。游戏总时长当然不会跑那么久,但如果把"已经过了多少秒"当成 float 用在大量物体的动画相位上,长时间运行后低位的抖动会累积出来,表现是某些东西轻微地抖。更稳的做法是内部全用 double 保存,只在传给渲染或物理时按需降成 float。这个改动成本极低,收益是彻底不用担心长跑稳定性。

另外,TotalTime 里所有的减法都要在__int64域内完成,最后一步才转成浮点。如果先把三个 int64 都转成 float 再减,精度会白白损失一大截。

4. 拿到 DeltaTime 之后:固定步长、可变步长与累加器

4.1 可变步长下物理为什么不可复现

最直接的做法是把 dt 一路传下去,所有更新都用它:velocity += acceleration * dt; position += velocity * dt;。这叫可变步长。它写起来最省事,但有两个躲不开的问题。

第一个是数值稳定性。当某一帧特别长(比如切出去看了一下别的窗口再切回来),dt 突然变成 0.5 秒,欧拉积分在这种大步长下会产生明显的误差,弹簧系统会直接炸开,碰撞检测会因为物体在这一帧里穿过了墙而漏判。第二个是不可复现。同样的输入序列,在不同帧率下跑出来的轨迹不完全一致,因为积分的切分点不同。做回放、做录像、做联机状态同步的时候,这个差异会直接表现为"我的画面和服务器不一样"。

所以现代游戏引擎的主流做法是:物理和核心逻辑跑固定步长,渲染跑可变帧率。逻辑的时间轴是一格一格的,每格宽度恒定;渲染则可以在两格之间插值。这样物理行为完全确定,跟帧率解耦。

4.2 累加器模式的具体写法

固定步长的实现核心是一个累加器。每帧把真实的 dt 累加进去,只要累加值够一个固定步长,就执行一次逻辑更新,同时把累加值减掉一个步长。一帧里可能需要执行零次(帧太快)或多次(帧太慢)更新。

const double kFixedDt = 1.0 / 120.0; // 逻辑每秒推进 120 格 const double kMaxFrameTime = 0.25; // 单帧最多补 0.25 秒 double accumulator = 0.0; void FrameUpdate(GameTimer& timer) { double frameTime = timer.DeltaTime(); if (frameTime > kMaxFrameTime) frameTime = kMaxFrameTime; // 见 4.3 的说明 accumulator += frameTime; while (accumulator >= kFixedDt) { FixedUpdate(kFixedDt); accumulator -= kFixedDt; } double alpha = accumulator / kFixedDt; // 0 到 1 之间的插值系数 Render(alpha); }

Render 里的 alpha 用来做插值:物体上一格的位置和这一格的位置都留着,按 alpha 在两者之间线性插值,画到屏幕上。这样一来,即使逻辑是 120Hz,画面依然可以在 144Hz 的屏幕上平滑地画出来,不会被逻辑步长卡成 120 帧的节奏。这一步很多人省掉了,结果就是高刷屏上的画面反而看着不如 60Hz 顺,原因就在这。

固定步长取多少合适?常见的落点是 60Hz、120Hz 或者 240Hz。120 是个不错的折中:单步 8.3 毫秒,常见的物理参数在这个尺度下足够稳定,同时一帧 16 毫秒里最多跑两次,计算量可控。如果你的游戏有高速弹道或者很硬的碰撞,可以往上加;如果只是简单的 2D 移动,60 就够。

4.3 单帧时间钳制的阈值怎么定

上面代码里的kMaxFrameTime = 0.25不是随便写的。它的作用是防止"死亡螺旋":如果某一帧耗时特别长,累加器里积了一大坨时间,下一帧就要补跑很多次逻辑更新,逻辑更新本身又耗时,导致这一帧更长,再下一帧要补得更多,最后游戏彻底卡死。

阈值的选择要跟固定步长配合看。按 120Hz 的单步 8.3 毫秒算,0.25 秒对应最多补 30 次更新。这个次数不算多,跑得动,同时又能覆盖掉大部分偶发的卡顿(比如一次小的资源加载)。如果阈值设成 1 秒,上限就是 120 次,补跑本身就会造成明显掉帧。

但钳制本身是有代价的:被砍掉的那部分时间不会补回来,游戏内时间会比墙钟时间慢。如果卡顿持续发生,慢的累积会变得可感知。所以这个值要按你的游戏类型调:单机动作游戏可以设得激进一点(0.1 到 0.25 秒),保证卡顿之后不会出现一秒钟的瞬移;回合制或者策略游戏无所谓,可以设大一点,保证时间不丢。

我第一次写这块的时候没做钳制,测的时候手贱在更新里打了个断点,继续运行之后游戏直接卡死十几秒,最后靠强制结束进程才出来。那个下午基本就在查这个。

5. 实测中跳出来的几类异常与处理手法

5.1 断点、拖动窗口、最小化造成的时间跳变

这是调试期最常见的一类问题,而且极容易被误判成"物理引擎有 bug"。现象是:打断点停一会儿,继续运行,角色瞬间飞出去很远;或者是按住标题栏拖两下窗口,松手后画面里所有东西猛地窜一段。

原因在于这些操作期间主循环被阻塞了,没有任何 Tick 发生。等到恢复,第一次 Tick 算出来的 dt 是整段阻塞的时长。物理更新如果用的是可变步长,直接一个超大积分步,位移就爆了。

纯粹的断点调试没法避免这个问题——程序确实停了那么久,计时器读数确实跳了那么多。能做的就是让后果可控:一是 4.3 里的帧时间钳制,二是调试构建里额外加一个"异常检测",dt 超过 0.1 秒就在输出窗口打一行警告。后者在排查其他问题时非常好用,因为它能告诉你"这一帧出问题了",而不是让你对着一堆莫名其妙的位移瞎猜。

拖动窗口的情况还可以更精细地处理:在窗口进入拖拽或尺寸调整的模态循环时,主动调 Stop,退出模态循环后再 Start。这样这段时长会被当成正常的暂停,不计入游戏时间。

5.2 FPS 数字是怎么骗人的

很多人用"一秒内数了多少帧"来显示 FPS,也就是每秒清零一次计数器。这个数字从定义上没错,但它会剧烈跳动:切换场景那一秒可能只有 42,下一秒稳定了又跳到 143。看着非常闹心,而且看不出真实趋势。

比较实用的做法是滑动窗口,或者按固定时间片统计。我用得多的是 0.5 秒一个统计片:片内累加帧数和时长,片结束时算一次平均帧率,然后用指数平滑跟上一个值混合。

struct FpsMeter { int frames = 0; double elapsed = 0.0; double fps = 0.0; void Add(double dt) { ++frames; elapsed += dt; if (elapsed >= 0.5) { double instant = frames / elapsed; fps = (fps > 0.0) ? (fps * 0.7 + instant * 0.3) : instant; frames = 0; elapsed = 0.0; } } };

另一个坑是"用 FPS 去反推 dt"。有人图省事,写dt = 1.0 / fps;,然后拿这个 dt 去更新。这是错的:fps 本身是从 dt 算出来的,反推回去等于把自己的输出当输入,一旦有卡顿,fps 掉下去,反推的 dt 就变大,位置就跳,形成一个正反馈。dt 必须来自计时器的原始测量值,不能被任何平滑处理污染。

还有一点:显示 FPS 的文本更新本身也要节流。如果每帧都去格式化字符串、重建顶点缓冲、提交绘制,这个开销在小场景里可能占到几毫秒,直接把你正在测量的东西干扰了。测量行为影响被测对象,这在性能分析里是个经典陷阱。

5.3 帧率限制和 Sleep 精度的配合

不限制帧率的时候,一个简单场景能跑到七八百帧,显卡风扇狂转,笔记本电池飞速掉。合理做法是加一个帧率上限。

最简单的实现是在帧尾比较一下本帧耗时,如果比目标间隔短,就 Sleep 差值。但 Sleep 的实际睡眠时长不精确,它会向上取整到系统时钟节拍。默认节拍下,你要睡 3 毫秒,实际可能睡 15 毫秒,于是你本来想锁 144,结果锁成了 60。

对付这个有两个办法。一是把系统时钟节拍临时调细,进入游戏循环前调用一次把粒度设到 1 毫秒,退出时恢复原值。注意这是个全局设置,会影响同机器上其他进程的功耗表现,所以一定要成对调用,退出路径上(包括异常退出)都要能恢复。二是混合策略:先 Sleep 一个比目标略小的整数毫秒,剩下的零点几毫秒用一段紧凑的轮询等待(读计数器直到差值达标)。轮询会占满一个核,所以只适合最后那一小段,不能整个间隔都轮询。

void WaitUntil(const GameTimer& timer, double targetDt) { double elapsed = timer.DeltaTime(); if (elapsed >= targetDt) return; double remainMs = (targetDt - elapsed) * 1000.0; if (remainMs > 2.0) Sleep(static_cast<DWORD>(remainMs - 1.0)); // 留 1 毫秒余量 while (timer.DeltaTime() < targetDt) { // 空转收尾,通常不到 1 毫秒 } }

还有个更顺手的方案:如果已经开了垂直同步,Present 本身就会把帧率锁在屏幕刷新率上,根本不需要额外限帧。这时候再加 Sleep 反而会干扰 Present 的队列节奏,造成帧时间不均匀。

5.4 垂直同步开关和计时器的联动

开着垂直同步和关掉垂直同步,两者对 dt 的分布影响差别巨大。开启时,Present 会阻塞到屏幕刷新,帧间隔被强行拉平到 16.67 毫秒(60Hz 屏),dt 序列非常整齐,全都是 0.01667 附近的值。关掉时,Present 立刻返回,帧率飙到几百甚至上千,dt 变成 0.001 到 0.003 这种小值,而且抖动明显变大。

这两种模式下,固定步长逻辑的表现是一致的(因为累加器会自己调节),但渲染插值的 alpha 分布不同,某些插值实现的瑕疵在一种模式下看得见、另一种看不见。所以测试时一定要两种模式都跑一遍,不能只测开发时习惯的那一种。

另外有个细节:开启垂直同步时,某些驱动会在帧率达不到刷新率时启用半刷新率的自适应策略。这时候帧间隔会突然从 16.67 变成 33.3,而游戏本身的耗时并没有变化。看到 dt 突然整体翻倍又找不出原因,先去看看垂直同步的设置。

还有窗口模式和无边框模式的差异。独占全屏下 Present 的行为最规整;窗口模式下,桌面合成器会插一脚,Present 可能变成异步的,dt 的方差明显增大。如果你的游戏对时间很敏感,这一点要在设计初期就定下来。

我在几个项目里反复验证过一个经验:把 GameTimer 写得再准,也比不上在主循环里把时间窗口切得干净。Tick 的位置对、暂停的语义清、钳制的阈值合理,这三件事做扎实了,后面手感调试、录像回放、联机同步的工作量会成倍下降;反过来,这几件事里任何一件偷懒,后面都要花好几倍的时间去补。计时器这种基础设施,改起来成本最低的时间点永远是项目刚开始的那一周。