9. Game Loop
9. Game Loop:把游戏时间推进与输入到达、处理器速度和渲染频率解耦,通过可复位因果实验和反例证据验收。
9. Game Loop
学习目标
- 能画出游戏循环三阶段
- 能对比可变与固定时间步
- 能解释累积器与死亡螺旋
为什么"9. Game Loop"从问题证据开始
游戏和办公软件的根本区别在于:游戏世界在玩家不操作时也必须继续运转。敌人仍在巡逻、天气仍在变化、NPC 仍在生活——所以游戏不能像 Word 那样"等用户动作才醒",它必须有个永不停歇的循环在后台推进世界。
假设你写了一个最简单的循环:每次循环角色向前移动固定距离。问题立刻出现——处理器越快,每帧跑得越快,角色就跑得越快。同一款游戏在 60Hz 老机器上是"散步",在 144Hz 新机器上就成了"冲刺"。这既不公平也不可复现:玩家的通关录像在另一台机器上完全对不上。本页要解决的,正是"如何让游戏世界的推进速度与机器速度无关"。
我们先用"无模式基线"(帧率相关的移动量)做实验,记录真实时间、模拟时间、更新次数三个量,再逐级引入候选机制(固定步长、累积器、插值),验证它们是否真的改变了可观察结果。通过条件是:相同真实时长内的模拟推进量与机器速度无关——而不是类或接口数量增加。
🔮 猜一猜:固定步长单独用,帧率低时世界会怎样?(提示:欠账)
来源、版本与独立重写边界
本页用作者完整在线正文核对正式标题、设计分叉和时代语境,并以作者源码仓库交叉检查结构。仓库许可证明确正文、HTML与样式为 CC BY-NC-ND 4.0,示例程序等其他文件为 MIT;因此下列中文解释、图示、交互和代码均为独立教学重写,不翻译、拼接或改写受 ND 限制的原文表达。
本章机制与术语
游戏循环涉及五个关键术语,理解它们才能看懂后面的推导:
- ↡:墙上时钟走的绝对时间,用
gettimeofday之类系统调用读取,游戏无法控制它。 - ↡:游戏世界内部的时钟。模拟推进了多少步、每步多长,由循环决定——这正是我们要控制的变量。
- ↡:每次更新模拟都推进同样的时间量(如 16ms)。帧率只影响"一帧内更新几次",不影响"单步走多远"。
- ↡:把真实时间流逝量"存起来",凑满一个固定步长就消耗掉执行一次更新,实现"更新频率与渲染频率解耦"。
- 插值(interpolation):渲染时刻通常落在两次更新之间,用相邻两次更新的状态按比例混合,消除画面抖动。
五个术语共同约束"把游戏时间推进与输入到达、处理器速度和渲染频率解耦",任何结论都必须回到真实时间、模拟时间、更新次数、插值比例与帧分位数。
第 1 / 4 步 · ① ProcessInput:读取键盘/鼠标/手柄输入
三个步骤每帧循环;Update 与 Render 分离让渲染频率可独立控制。
💡 对着动画看:先播一遍了解 ProcessInput → Update → Render 的三步循环。然后反复"下一步"停在 Update 阶段,想象它变慢(拖累整帧):如果单步移动量固定,帧率一降,角色每秒移动的总距离就变短——这就是帧率依赖问题。记住这个画面,读下面各节时你会看到三种解法依次登场。
官方结构逐项深读
9. Game Loop
游戏循环把平台事件、模拟步长、渲染、功耗与追赶策略组织成稳定的实时调度器。它的形状是固定的:采集输入 → 累积真实时间 → 执行固定模拟步 → 插值并渲染 → 节流,然后回到开头。任何游戏引擎——Unity 的 Update/FixedUpdate、Unreal 的 Tick、网页的 requestAnimationFrame——都是这张图的不同变体。
Intent
本模式的意图是让模拟推进速度独立于机器性能:在慢机器上世界不应变慢,在快机器上世界不应加速。实现方式是把"模拟推进"与"帧渲染"解耦成两条独立节奏,让渲染尽力追赶,而模拟永远走自己的固定节拍。这正是下文三种时间步方案的共同目标。
Motivation
想象经典 2D 横版游戏:每次循环角色向右移动一个固定像素。在 60fps 机器上角色每秒走 60 像素;换成 120fps 的机器,直接翻倍到 120 像素/秒——关卡设计、碰撞判断、物理手感全部失真。更糟的是多人对战:两台机器帧率不同,两个玩家的角色速度就不同,游戏在出生前就不公平。这就是"帧率依赖(frame-rate dependent)"的代价,也是我们必须引入时间步概念的原始动机。
Interview with a CPU
把 CPU 想象成一位面试官:它告诉你,每秒它只有约 60 个"时间片",每个时间片(约 16ms)内要完成:读取输入、更新 AI、跑物理、渲染整个场景。如果任何一个任务超时,就会吞掉下一个时间片,帧率下跌。这位面试官真正想说的是:你的循环必须学会在有限预算内工作——要么每帧干得足够快(快循环),要么干不完就平滑地"欠着"(累积器),绝不能因为这一帧慢了就让整个世界加速或减速。
Event loops
办公软件和服务器走的是另一条路:事件循环——平时阻塞等待,有事件(鼠标、网络包)才醒来处理。这很适合"没事件就没事做"的程序,但对游戏是灾难:玩家放下手柄的瞬间世界就冻结了。游戏循环则相反,永不阻塞等待,每帧都主动推进世界。两者的关系是:游戏循环可以寄生在平台事件循环里(用平台的定时器驱动),但"世界持续运转"这件事必须由游戏自己保证。
A world out of time
引入时间的游戏同时持有三个时钟:
- 真实时间:外部世界流逝的绝对时间,是唯一"权威"的进度标尺。
- 模拟时间:游戏世界自己的时钟,走多快由循环决定。
- 渲染时刻:屏幕真正显示某一瞬间世界状态的时刻。
三者的矛盾在于:模拟走固定节拍(每步 16ms),渲染却跟着显示器刷新率走(可能 60Hz、75Hz、144Hz 各不相同)。两个频率不匹配时,就必须有人让步——这就是后面累积器和插值要解决的问题。
Seconds per second
"每秒多少秒"听起来绕,其实在问:一秒钟真实时间,模拟世界应该走多少? 最自然的答案是一比一(1 秒真实 = 1 秒模拟)。但一比一会带来麻烦:游戏没有暂停键(暂停 = 停止模拟但游戏循环仍在跑);减速/加速效果(子弹时间)无法实现;更关键的是联网游戏——两台机器必须对同一段真实时间产生完全相同的模拟,任何帧率差异都会让双方世界分叉。这迫使我们把"模拟时间的推进量"变成可配置、可确定的量。
The Pattern
模式结构可压缩为一个最小合同:采集平台事件 → 累积真实时间 → 执行固定模拟步 → 插值并渲染 → 节流与记录长尾。其中"累积真实时间"和"固定模拟步"是最核心的两环:前者回答"这帧我欠了多少模拟量",后者回答"欠多少我就补多少,每次补固定大小"。整张循环图的所有参与者、消息与时序,最终都服务于"模拟推进量只由真实时间决定,与帧率无关"这一条不变量。
When to Use It
几乎每个实时游戏都需要游戏循环,但时间步方案的选择要按问题频率定:如果游戏对确定性无要求(单机、无物理依赖模拟结果)、帧率稳定,最简单的"每帧固定移动量"也够用;一旦出现多人同步、物理确定性、回放录像、慢动作特效这些需求,就必须升级到固定步长方案。写下你的不适用条件:没有上述需求、且能接受不同机器上体验不一致时,可以不引入复杂方案。
Keep in Mind
固定时间步并非免费:累积器带来额外间接层(状态分离为 previous/current 两份)、内存翻倍(插值需要保留上一帧状态)、调试变复杂(同一个"时刻"出现在两帧之间)。死亡螺旋是最大的坑:当一次更新耗时超过一个步长时,累积器追不上真实时间,越攒越多,循环陷入"永远在追赶"的死循环——必须给累积器设上限(如最多追赶 5 步),超出的真实时间直接丢弃。
You may need to coordinate with the platform's event loop
在浏览器或移动端,你无法独占主线程——系统的事件循环(DOM 事件、触摸、页面生命周期)与你共享时间。此时循环所有权被平台部分接管:requestAnimationFrame 会按屏幕刷新率驱动你的帧;页面切到后台时平台会暂停或节流你的定时器,游戏必须响应 visibilitychange 暂停模拟,否则回来时会经历一次"时间跳跃"式的追赶风暴。
Sample Code
示例 C++ 用于表达意图而不是可直接复制的现代生产代码——理解每个演进阶段的"为什么",比背诵代码本身更重要。完整代码段从最简单开始,逐步加上睡眠、固定步长、累积器和插值,每一步对应原书的一个小节,也对应上方动画里的一环。
Run, run as fast as you can
最原始的循环:没有睡眠、没有时间步,while (true) { processInput(); update(); render(); }。它能跑,但有两个致命缺陷:一是空转烧 CPU(100% 占用、发烫、笔记本风扇狂转);二是帧率完全失控——跑多快全看机器心情,正是本章开头那个"固定移动量"问题的最直观形态。它是基线,也是后面所有改进的对照物。
Take a little nap
给循环加一个睡眠:sleep(33ms) 把帧率限制到约 30fps,sleep(16ms) 限制到 60fps。帧率被钉住了,但问题没解决——只是把"机器越快跑越快"换成了"机器越快睡越久":在 60Hz 机器上睡 16ms 正好,在 30Hz 机器上睡 16ms 会漏掉半帧的更新,世界照样时快时慢。睡眠只治标(功耗与帧率),不治本(模拟速度与帧率解耦)。
One small step, one giant step
真正的转折点:每次更新都推进固定时间量 dt(如 16ms),不关心真实时间过了多久。帧率只影响"这一秒内执行了几次 update",不影响"每次 update 走多远"。于是角色在 60fps 机器上一秒走 60×16ms=960ms 的模拟量,在 30fps 机器上一秒走 30×32ms——等等,这里 update 的 dt 还是固定 16ms,30fps 一秒只执行 30 次 = 480ms 模拟量。慢了!固定步长单独用还不够,它保证了"单步确定性",但没解决"更新频率与真实时间挂钩"的问题——所以还需要累积器。
Play catch up
累积器解决"欠多少补多少":每帧把真实流逝时间加进 accumulator,然后
while (accumulator >= dt) {
update(dt);
accumulator -= dt;
}
只要累积器够一次步长就补一次更新——更新频率彻底与渲染频率解耦,欠的账慢慢还。但还账有上限:如果 update 本身太慢(一次要 50ms,而 dt 只有 16ms),累积器永远还不清,循环死循环式追赶,死亡螺旋(spiral of death)。解法是一句守卫:
accumulator = min(accumulator, MAX_FRAME_TIME); // 例如最多攒 5 步
超出的时间直接丢弃——宁可让世界短暂"变慢",也不让引擎永远卡在追赶里。
Stuck in the middle
累积器让更新节奏对了,但渲染仍会抖动:显示器在 60Hz 刷新,而两次更新之间隔着 16ms,渲染时刻几乎总是"卡在两次更新中间"。直接画最新的世界状态,就会看到运动一卡一卡(肉眼可见的步进)。解法是插值:渲染时用"上一次更新"和"下一次更新"两个状态按比例混合:
alpha = accumulator / dt; // 本帧在两步之间的进度 0~1
render(state_prev * (1 - alpha) + state_next * alpha);
alpha 越接近 1 说明越接近下一次更新,画面越平滑。这就是为什么前面说"Keep in Mind 要保留上一帧状态"——插值需要两份状态。动画里的 Render 阶段,实际渲染的就是这个混合结果。
Design Decisions
设计决策应列出互斥选项、选择依据、不可逆成本和触发重新评估的指标。游戏循环的三个关键决策都围绕"谁控制节奏"展开:循环归谁所有、功耗怎么管理、游戏速度怎么控制——下面逐项展开。
Do you own the game loop, or does the platform?
选项 A:自己持有循环(桌面游戏主流)——完全掌控更新节奏,但移植到网页/移动端困难(平台事件循环会与你竞争时间)。
选项 B:寄生平台事件循环(浏览器/移动端)——用 requestAnimationFrame 或定时器驱动你的帧,换来平台兼容,代价是失去对功耗和精确节奏的控制。
选择依据:目标平台是否允许独占主循环;触发重新评估的指标:后台节流导致模拟追不上真实时间时。
How do you manage power consumption?
三个互斥手段,常组合使用:
- 睡眠(sleep):帧内工作完成后睡掉剩余时间——省电但精度受操作系统调度影响(可能多睡 1-10ms)。
- 垂直同步(vsync):让帧与显示器刷新对齐,避免撕裂——把节奏交给显卡,卡顿时会帧率腰斩。
- 帧率上限:主动限制(如锁 60fps),在"够流畅"与"够省电"间取平衡;手机游戏常按电池状态动态降帧率。
选择依据:设备是插电桌面机(可满帧跑)还是电池移动设备(省电优先);不可逆成本:vsync 引入的输入延迟在竞技游戏中不可接受。
How do you control gameplay speed?
这是本章的最终答案,三种方案:
- 可变时间步:
update(deltaTime)每次更新走deltaTime这么多模拟量——实现最简单,但物理确定性全无(不同帧率下模拟结果不同),回放/联机直接失效。 - 固定时间步:
update(16ms)恒定——确定性最好(相同输入序列必产生相同结果),是回放、联机、物理模拟的基石,需要累积器配合。 - 混合方案:模拟用固定步长,渲染用插值——兼顾确定性(模拟)与平滑性(渲染),这也是当前引擎的主流选择。
选择依据:是否需要确定性(回放/联机/物理可复现);触发重新评估的指标:网络延迟抖动导致累积器频繁触发追赶上限时。
See Also
- Update Method:本模式的天然搭档——循环负责"每帧调一次 update",Update Method 决定"每个实体怎么更新自己",两者组合构成游戏世界的运转骨架。
- Event Queue:循环里的"采集输入"环节可以升级为事件队列,让输入到达与处理解耦,避免某帧输入风暴卡住主循环。
- 注意:模式常一起出现不等于必须同时引入——只在问题证据出现时才引入。
可迁移实现或计算骨架
initial state -> 采集时间 -> 处理输入 -> 累积步长 -> 更新模拟 -> 渲染插值
fault injection -> 长帧让累积器无限追赶形成死亡螺旋
pass condition -> 相同真实时长内的模拟推进量与机器速度无关
reset -> initial state对应实现要点:采集时间用 elapsed = now() - lastFrame;累积用 accumulator += elapsed;更新用 while (accumulator >= dt) update(dt);插值用 alpha = accumulator / dt 混合两份状态;最后 accumulator = min(accumulator, MAX_FRAME_TIME) 防螺旋。该骨架只保存实验合同;真实项目还要把平台、构建、场景、输入和统计窗口固定下来,并保留基线实现以便回退。
本章练习与节点验证矩阵
练习
问题 1:帧率依赖实验。
操作:在上方动画中想象 Update 变慢,或用可变步长代码分别以 30/60/144fps 跑同一场景
问题 2:累积器实验。
操作:给循环加 accumulator += elapsed; while (accumulator >= dt) update(dt);
问题 3:死亡螺旋注入。
操作:故意让 update 耗时超过 dt(如 sleep 20ms 而 dt=16ms)
问题 4:插值平滑实验。
操作:在固定步长基础上对渲染做 alpha 混合
术语复核
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 真实时间
- 固定步长
- 累积器
术语复核与本章回顾
掌握"9. Game Loop"意味着能从"每次循环固定移动量会让更快处理器上的游戏运行得更快"出发,解释循环非阻塞处理输入,按时间步更新状态并渲染,可用累积器协调频率,再用真实时间、模拟时间、更新次数、插值比例与帧分位数推翻或保留实现。若"相同真实时长内的模拟推进量与机器速度无关"不能稳定复现,本章仍未通过。
一句话回顾:可变步长让世界随机器脾气走(坏)→ 固定步长保单步确定性(好但欠账)→ 累积器还账(好但怕螺旋)→ 插值抹平渲染抖动(最终答案)。四个机制层层递进,缺一环,游戏的时间就不属于玩家而属于硬件。