游戏开发 · Game DevelopmentNote
游戏时间线与时钟
Game Timelines & Clocks
游戏引擎要同时处理多种「时间」:真实时间、游戏时间、动画片段的局部时间、CPU 周期等。GEA 用抽象时间线 (abstract timeline) 统一看待它们,并讨论帧时间 的测量方法、高精度计时器、时间单位与时钟变量类型的取舍,以及断点恢复后的异常 处理。(卷 I 第 8 章 引言、8.4–8.5 节)
抽象时间线(卷 I 8.4)
时间线是一条连续的一维轴,原点 可以相对系统中其他时间线放在任意位置;实现上就是一个存放绝对时间值(整数或浮点)的时钟变量。
- 真实时间线 (real time)(8.4.1):直接用 CPU 高精度计时器寄存器测得的时间,原点是 CPU 上次上电或复位的时刻,单位是 CPU 周期(或其倍数),乘以计时器频率的倒数即可换成秒。
- 游戏时间线 (game time)(8.4.2):技术上独立于真实时间。正常情况下两者重合;暂停就停止更新游戏时钟,慢动作就让游戏时钟走得比真实时钟慢,各种效果都可以通过时间线之间的缩放与扭曲实现。
- 调试用途:暂停游戏时钟冻结画面,而渲染引擎和调试飞行摄像机由另一只时钟(真实时钟或单独的摄像机时钟)驱动继续运行,开发者可以飞到任意角度检查;还可以在暂停状态下按键让游戏时钟前进一个目标帧间隔(如 1/30 秒)实现单步。
- 注意:暂停时游戏循环仍在运行,只是游戏时钟停了;这种单步与在主循环打断点后反复按 F5 单步一轮循环不是一回事,两者适合排查不同问题。
- 局部与全局时间线(8.4.3):动画或音频片段有自己的局部时间线,原点是片段起点,记录它当初被制作或录制时的时间流逝。播放就是把局部时间线映射到全局时间线(真实时间或游戏时间)上:把局部 对到全局起点 ;再记录一个播放速率 ,半速播放相当于把局部时间线拉长到两倍再映射(书中图 8.2 标注为「按 1/R = 0.5 缩放 t」〔原文如此〕), 即倒放(图 8.3: 翻转 t)。
下图示意:片段 A 的局部时间线(0–5 秒)映射到全局时间线的 102–107 秒处开始播放。
flowchart LR L["局部时间线 t: 0 → 5 s"] -- "起点映射到 τ_start=102 s,速率 R" --> G["全局时间线 τ"]
动画系统中局部时钟的细节在后续动画章节展开。
帧率与 Δt(卷 I 8.5.1–8.5.2)
- 帧率用 FPS 衡量(与 Hz 实质相同)。电影传统为 24 FPS;北美和日本的游戏常为 30 或 60 FPS(NTSC 制式的自然刷新率),欧洲等地为 50 FPS(PAL / SECAM)。
- 帧之间的时间叫帧时间 / delta time()。严格说应叫帧周期 ,但游戏程序员几乎不这么叫。30 FPS 时 = 33.3 ms,60 FPS 时 16.6 ms。毫秒是常用单位:动画系统耗时 4 ms 即约占 30 FPS 一帧的 12%(4/33.3 ≈ 0.12)。
- 从帧率到速度:以速度 移动的飞船,每帧位移 ,。这是最简单的数值积分——显式欧拉法 (explicit Euler)(详见 14.4.4 节),速度大致恒定时效果好;所有数值积分都会用到 ,所以物体的感知速度依赖帧时长,如何确定合适的 是游戏编程的核心问题之一。
确定 的几种做法(与「游戏循环」页里 GPP 的方案对照见 游戏循环与更新方法):
| 做法 | 机制 | 问题 |
|---|---|---|
| 依赖 CPU 的老游戏(8.5.2.1) | 忽略 ,直接按「每帧多少米/像素」指定速度,即实际指定的是 而非 | 速度完全取决于硬件帧率,在更快的机器上像快进;老 PC 的 Turbo 按钮就是为此:不按时模拟上一代 CPU 的速度 |
| 按测得的上一帧时间更新(8.5.2.2) | 帧首帧尾各读一次高精度计时器相减,把 传给各子系统(参数、全局变量或单例);作者认为多数引擎这么做 | 用第 k 帧的 估计第 k+1 帧并不准,会受帧率尖峰 (frame-rate spike) 影响;还可能陷入恶性循环:物理按 33.3 ms 最稳定,一帧耗时 57 ms 后下一帧物理走两步去「补」,两步耗时约翻倍,下一帧同样糟甚至更糟 |
| 滑动平均(8.5.2.3) | 利用帧间相关性(摄像机这帧对着昂贵的走廊,下帧大概还对着),取最近若干帧的平均作为下一帧估计 | 平均窗口越长,对帧率变化越不敏感,但尖峰影响也越小 |
| 帧率调控 (frame-rate governing)(8.5.2.4) | 反过来保证每帧恰好 33.3 ms(或 16.6 ms):本帧太快就让主线程睡到目标时间;太慢则「认栽」,再等一整帧 | 只在平均帧率接近目标时有效,在 30 与 15 FPS 之间来回跳会明显降质。建议所有系统都能处理任意帧长,开发期用可变帧率模式,接近目标帧率后再开启调控 |
帧率稳定的好处(8.5.2.4):物理数值积分器在固定频率下最好用;画面更好看;能避免撕裂;录制与回放更可靠——记录每个相关事件及精确时间戳,用相同初始条件和相同随机种子按相同时序重放,理论上可完全复现(也是复现难找 bug 的好工具);帧率不稳时事件顺序可能变化而产生「漂移」,AI 本该撤退却在包抄。
画面撕裂与垂直同步(卷 I 8.5.2.5)
- 撕裂 (screen tearing):显示硬件只「画」了一部分屏幕时就交换了前后缓冲,屏幕一部分是新图一部分是旧图。很多渲染引擎因此等待显示器的垂直消隐期 (v-blank) 再交换。
- CRT 用电子束从左到右、从上到下扫描,v-blank 是电子枪关闭并回到左上角的时间;LCD、等离子、LED 不再需要它,但因为视频标准制定于 CRT 时代、且要兼容旧显示器,v-blank 仍然存在。
- 等待 v-blank 叫 v-sync,实质是另一种帧率调控:主循环帧率被量化为刷新率的整数分之一。60 Hz 下超过 1/60 秒就要等到下一个 v-blank,即 2/60 秒(30 FPS);错过两个就是 3/60 秒(20 FPS)。别假设帧率:PAL/SECAM 是 50 Hz 而非 60 Hz。
- 可变刷新率 (VRR):显示设备的刷新率跟随游戏出帧速度同步,消除撕裂并提高渲染效率。
前后缓冲交换本身见 双缓冲。
用高精度计时器测真实时间(卷 I 8.5.3)
- C 标准库
time()返回 1970-01-01 午夜以来的整数秒,分辨率 1 秒,远不够用。 - 现代 CPU 都有高精度计时器:通常是一个统计上电/复位以来 CPU 周期数(或其倍数)的硬件寄存器。3 GHz Pentium 上每周期加一,分辨率 1/30 亿 ≈ 0.333 ns。
- 读取方式因平台而异:Pentium 用
rdtsc指令,Win32 包装为QueryPerformanceCounter()(读 64 位计数)与QueryPerformanceFrequency()(每秒计数次数);Xbox 360 / PS3 的 PowerPC 用mftb读两个 32 位时基寄存器,其他 PowerPC 用mfspr。 - 寄存器多为 64 位:最大值 ≈ 个 tick,3 GHz 下约 195 年才回绕;32 位在 3 GHz 下约 1.4 秒就回绕。
- 时钟漂移(8.5.3.1):某些多核处理器每个核的计时器相互独立且会漂移,比较不同核上的绝对读数可能得到奇怪结果,甚至负的 。
- 补充(新硬件上的情况与书中说法并列):书里以“3 GHz Pentium 每周期加一”为例。现代 x86 处理器大多带恒定/不变 TSC (invariant TSC),按固定频率计数,与核心实际频率(睿频、降频)无关,所以它不再严格等于“CPU 周期数”;在这类处理器上各核的 TSC 也是同步的,跨核漂移主要是旧硬件的问题。Windows 上推荐用
QueryPerformanceCounter(),不要直接用rdtsc(MS Learn《Acquiring high-resolution time stamps》,https://learn.microsoft.com/en-us/windows/win32/sysinfo/acquiring-high-resolution-time-stamps)。
- 补充(新硬件上的情况与书中说法并列):书里以“3 GHz Pentium 每周期加一”为例。现代 x86 处理器大多带恒定/不变 TSC (invariant TSC),按固定频率计数,与核心实际频率(睿频、降频)无关,所以它不再严格等于“CPU 周期数”;在这类处理器上各核的 TSC 也是同步的,跨核漂移主要是旧硬件的问题。Windows 上推荐用
时间单位与时钟变量(卷 I 8.5.4)
选择单位(秒、毫秒、机器周期……)和数据类型,取决于需要的精度和数值范围:
| 表示 | 特点 | 用法与坑 |
|---|---|---|
| 64 位整数、机器周期 | 精度极高(3 GHz 下 0.333 ns),范围大(约 195 年) | 最灵活,只要能负担 64 位存储 |
| 32 位整数、机器周期 | 适合高精度测短时长,如代码段性能剖析 | 原始读数仍存 64 位,先相减再截断为 32 位 ;否则如 begin = 0x12345678FFFFFFB7、end = 0x1234567900000039 各自截断为 32 位再相减会得到负数 |
| 32 位浮点、秒 | 存较小的时间差:周期数除以计时器频率(秒 = ticks ÷ ticks/秒) | 同样要先在 64 位里相减再转浮点,避免把过大数值塞进 32 位浮点 |
| 自定义单位,如 1/300 秒整数 | 足够细;32 位约 165.7 天回绕;同时是 NTSC 与 PAL 刷新率的整数倍:60 FPS 一帧 = 5 单位,50 FPS = 6 单位 | — |
| 毫秒整数 | 比 1/300 秒分辨率高;无符号 32 位约 49.7 天回绕 | 范围对多数用途足够 |
- 原文说明分歧:8.5.4.3 正文写「把周期数乘以 CPU 时钟频率」,但同节代码与注释是除以
getHiResTimerFrequency()(「seconds = ticks / (ticks/second)」),按量纲应为除;8.4.1 也写「乘以频率」。此处以代码为准,原文并列记录。 - 浮点时钟的局限(8.5.4.4):32 位 IEEE 浮点的 23 位尾数由指数在整数和小数部分间动态分配,数值越大小数精度越低,最后连整数的最低位都变成隐含的 0。所以浮点时钟一般只存较短的时间差(最多几分钟,更多是一帧以内);若用浮点存绝对时间,需要定期清零。浮点精度原理见 数值表示与浮点数。
处理断点(卷 I 8.5.5)
游戏停在断点时,若调试器与游戏在同一台机器上,CPU 和真实时钟仍在走,继续运行后测得的帧时间可能是几秒、几分钟甚至几小时;把它传给子系统轻则游戏一下子「跳」很多秒,重则崩溃。简单办法:测得的帧时间超过某个上限(如 1 秒)就认为刚从断点恢复,把 人为设为目标帧时间(1/30 或 1/60 秒),即本帧帧锁定 (frame-locked)。
F32 dt = 1.0f / 30.0f; // 先假设理想 dt
U64 begin_ticks = readHiResTimer();
while (true) {
updateSubsystemA(dt); updateSubsystemB(dt); // ...
renderScene(); swapBuffers();
U64 end_ticks = readHiResTimer();
dt = (F32)(end_ticks - begin_ticks) / (F32)getHiResTimerFrequency();
if (dt > 1.0f) dt = 1.0f / 30.0f; // 太大:刚从断点恢复,锁帧一帧
begin_ticks = end_ticks;
}出处 · Sources
- Game Engine Architecture(第 4 版)卷 I
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 8 章 8.4–8.5 节