概念页
游戏时间线与时钟
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;
}出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 8 章 8.4–8.5 节
相关页面
游戏开发游戏循环与更新方法Game Loop & Update Method游戏循环 (Game Loop) 让游戏时间的推进与玩家输入和处理器速度脱钩:每一轮非阻塞地处理输入、更新状态、渲染,并跟踪真实时间来控制节奏;最常推荐的做法是“固定更新步长、可变渲染”。更新方法 (Update Method) 是它的搭档:世界维护一组对象,每个对象实现一个模拟“一帧”行为的 update(),每帧依次调用。两者和组件模式一起常被视为游戏引擎的核心。游戏数学数值表示与浮点数Numeric Representation & Floating Point游戏引擎里到处是数值,所以要清楚它们在机器中如何存储:二进制和十六进制、补码整数、定点数、IEEE-754 浮点数(量级与精度此消彼长、次正规数、机器 epsilon、ULP),以及多字节值的大小端问题和安全的类型双关 (type punning) 方法。书中一个直观的例子是:用 32 位浮点数记录以秒为单位的游戏时钟,大约 12.14 天后,加 1/30 秒就不再改变它的值。游戏开发计算机硬件与内存层次Computer Hardware & Memory Hierarchy从冯·诺依曼结构讲起,依次是 CPU 的组成(ALU、VPU、寄存器、控制单元、时钟)、总线与字长、机器语言、汇编和寻址方式,再到内存体系:内存映射、虚拟内存和 TLB,以及为了缩小“内存鸿沟”而设计的寄存器文件、多级缓存(缓存行、标签、组相联、替换和写策略、一致性协议)和 NUMA(PS3 的 SPU 本地存储、PS2 的 scratchpad)。对游戏程序员最重要的结论是:数据要连续、紧凑、顺序访问;热循环的代码要小。综合帧时间预算与渲染性能Frame Budget & Rendering Performance本页把 wiki 中分散在 RTR4 第 18~23 章的性能知识串成一条线:一帧的时间预算由最慢的管线阶段决定,先用毫秒帧时间度量并定位瓶颈,再按瓶颈所在位置——CPU/驱动、几何、像素、带宽/内存——选用对应手段;最后从 GPU 硬件机制(占用率、HiZ/early-z、压缩、分块架构)理解这些手段为何有效。各结论的细节与出处见所链接的概念页。游戏开发作业系统Job System多核硬件上,游戏循环的工作要分解成可并行执行的子任务。GEA 依次比较「每子系统一个线程」「分散/汇集 (scatter/gather)」和通用的作业系统 (job system),并给出典型作业 API、基于线程池的简单实现及其局限、用协程或纤程让作业可以睡眠、作业计数器、作业同步原语与可视化剖析工具,最后介绍 Naughty Dog 基于纤程的作业系统。(卷 I 8.6 节)游戏开发引擎输入系统Game Engine HID System多数引擎不直接使用「原始」HID 输入,而是先加工(死区、滤波)使游戏内行为平滑直观,并在 HID 与游戏之间加至少一层间接(如按键映射表把原始按钮翻译成逻辑游戏动作)。GEA 列出引擎输入系统的典型需求并逐一讲解实现:死区、模拟信号滤波、按下/松开事件、组合键与序列手势、多设备多玩家、跨平台抽象、输入重映射、上下文相关控制与输入禁用。(卷 I 9.5–9.6 节)游戏开发游戏内调试工具In-Game Debugging Tools各子系统(渲染、动画、碰撞、物理、音频、网络、玩家机制、AI……)都有大量配置项,能在游戏运行时调整而无需改代码、重新编译链接再运行,可大幅节省调试和搭建关卡/玩法的时间。GEA 介绍支撑这一点的几类游戏内工具:游戏内菜单(及 Dear ImGui 等第三方 GUI)、游戏内控制台、调试摄像机与暂停/慢动作、作弊功能、截图与录像。(卷 I 10.3–10.7 节)游戏开发游戏内性能与内存剖析In-Game Profiling & Memory Tracking游戏是实时系统,要保持 30 或 60 FPS,同时受目标硬件内存限制。第三方剖析工具有局限、在主机上未必可用,所以许多引擎自带游戏内剖析器(手动插桩的代码块计时、层级关系、调用次数、导出 CSV)和内存统计与泄漏检测工具。GEA 讲解它们的实现要点和常见限制。(卷 I 10.8–10.9 节)游戏开发骨骼、姿势与动画片段Skeletons, Poses & Animation Clips角色动画系统的数据核心是三样东西:骨骼(关节层级)、姿势(每个关节的 SRT 变换)和动画片段(姿势随局部时间变化的采样)。GEA 把各种动画方法看成对"逐点控制表面"这一理想的不同压缩方式;骨骼蒙皮用少量关节驱动大量顶点,是今天最主流的方案(GEA4 卷 II 13.1–13.4)。游戏开发游戏对象的实时更新Updating Game Objects in Real Time游戏对象更新就是根据上一时刻状态 Si(t − Δt) 求出当前状态 Si(t)。"每帧对每个对象调一次虚函数 Update(),并在里面顺带更新它的动画、物理、渲染组件"的简单做法在商业引擎里行不通:底层子系统需要批量更新才能高效,而对象之间、子系统之间又有依赖顺序。GEA 介绍的对策依次是:子系统由游戏循环直接批量更新、对象只操纵子系统中的"代理状态";在帧内多个阶段挂回调;按依赖森林把对象分桶,逐桶运行完整的分阶段更新(Naughty Dog 的做法);并用状态缓存和时间戳对付更新过程中的状态不一致和一帧延迟(GEA4 卷 II 17.6)。综合引擎运行时架构总览Engine Runtime Architecture Overview本页把 wiki 中分散在 GEA 第 1、6、8、17 章及 GPP、UE5 相关页面里的内容串成一条线,回答"一个游戏引擎在运行时是怎样组织起来的":静态结构上是自下而上的分层,上层依赖下层;启动关闭按依赖顺序显式进行;每一帧由主循环驱动各子系统批量更新,游戏对象的逻辑被安排在子系统阶段之间,并按依赖分桶或设先后关系;多核上一帧被拆成成百上千个作业;玩法基础系统(对象模型、事件、脚本、世界加载流送、高层流程)位于引擎与游戏的分界线附近。各结论的细节与出处见所链接的概念页。游戏开发双缓冲Double Buffer双缓冲 (Double Buffer) 让一系列按顺序执行的修改,在外界看来像是瞬间、同时完成的:类内部保留“当前缓冲”和“下一缓冲”两份状态,读取总是读当前缓冲,写入总是写下一缓冲,改完后用一次原子交换让新状态整体生效。图形里的前后缓冲是最经典的例子;同样的思路也能让一帧内依次更新的游戏对象互相看到的都是上一帧的一致状态。