游戏循环与更新方法
Game Loop & Update Method
游戏循环 (Game Loop) 让游戏时间的推进与玩家输入和处理器速度脱钩:每一轮非阻塞地处理输入、更新状态、渲染,并跟踪真实时间来控制节奏;最常推荐的做法是“固定更新步长、可变渲染”。更新方法 (Update Method) 是它的搭档:世界维护一组对象,每个对象实现一个模拟“一帧”行为的 update(),每帧依次调用。两者和组件模式一起常被视为游戏引擎的核心。
游戏循环:意图与机制
意图:让游戏时间的推进与用户输入和处理器速度解耦。机制:循环在游戏期间持续运行,每一轮非阻塞地处理输入、更新游戏状态、渲染,并跟踪时间流逝以控制游戏速度。(Game Programming Patterns — game-loop)
几乎所有游戏都会用到它:用引擎时你不用自己写,但它一直在那里。作者对“引擎”和“库”的区分正在于此:用库时你拥有主循环并调用库,引擎则拥有循环并调用你的代码。即便是回合制游戏,在等待玩家操作时,动画和音乐也在继续。(Game Programming Patterns — game-loop)
循环的演进(Game Programming Patterns — game-loop):
- 批处理程序跑完就停;文字冒险和 GUI 事件循环会阻塞等待输入。游戏不同,玩家不操作时世界也在动,所以游戏循环的第一个关键点是处理输入但不等待输入。
- 最简单的
while(true){ processInput(); update(); render(); }跑多快完全取决于每帧工作量和硬件速度,老游戏在更快的 PC 上会跑得太快(所以老 PC 有 turbo 按钮)。 - 睡一会儿:以 60 FPS(约 16 ms/帧)为目标,处理完一帧后
sleep到下一帧。它能防止太快,却救不了太慢。 - 可变时间步长:按上一帧经过的真实时间推进模拟。看似完美,但让游戏不确定且不稳定:快机器每秒更新 50 次、慢机器 5 次,浮点舍入误差累积不同,同一颗子弹在两台机器上落点不同;物理引擎的阻尼是按某个步长调好的,步长一变就可能“爆炸”。
- 追赶:渲染只捕捉一个瞬间,基本不受步长影响(运动模糊之类略有影响但玩家很少察觉),所以用固定步长更新、灵活地渲染,即下面的方案。
固定更新步长、可变渲染:每帧开始时,把经过的真实时间累加进 lag(游戏时钟落后真实时间多少);内层循环以固定步长 MS_PER_UPDATE 反复更新,直到追上真实时间;然后渲染。(Game Programming Patterns — game-loop)
其中 就是 MS_PER_UPDATE。它不再是可见帧率,只是更新的粒度:越短,模拟越精细,追赶真实时间的处理开销也越大;但它必须大于最慢的机器执行一次 update() 的时间,否则永远追不上(作者建议给内层循环设一个最大迭代次数作为保险:游戏会变慢,但总比卡死好)。把剩余的 传给 render(),渲染时按比例外推物体位置,画面就能平滑;外推偶尔会猜错(子弹其实撞墙了),但通常不明显。(Game Programming Patterns — game-loop)
下图就是上式对应的控制流:更新可以在一帧内执行多次,渲染每帧只执行一次。
flowchart TD
S["帧开始:lag += 经过的真实时间"] --> I["非阻塞处理输入"]
I --> Q{"lag ≥ Δt ?"}
Q -- "是" --> U["update():固定步长推进<br/>lag -= Δt"]
U --> Q
Q -- "否" --> R["render(lag / Δt):按剩余比例外推"]
R --> S慢机器上会少渲染几帧,但模拟速度不变;快机器上画面更流畅。
游戏循环:设计决策(Game Programming Patterns — game-loop)
| 决策 | 选项 | 要点 |
|---|---|---|
| 谁拥有循环 | 平台事件循环(如浏览器的 requestAnimationFrame) | 简单、与平台相处融洽,但失去对时序的控制 |
| 引擎的循环 | 不用自己写,也不能自己改 | |
| 自己写 | 完全控制,但要定期把控制权交还平台(如 Win32 用不阻塞的 PeekMessage()) | |
| 功耗 | 尽可能快地跑 | PC 常见,体验最好但最耗电 |
| 限制帧率(常为 30 或 60 FPS) | 移动端常见,做完就睡眠,省电 | |
| 控制速度 | 固定步长、不同步 | 最简单;游戏速度直接受硬件和场景复杂度影响 |
| 固定步长 + 同步(在循环末尾等待) | 简单且省电,不会太快;但会变慢,因为更新和渲染没有分开 | |
| 可变步长 | 能适应快慢两种情况,但让玩法不确定、不稳定,物理和网络尤其难做,多数开发者反对 | |
| 固定更新步长、可变渲染 | 最复杂也最能适应;步长要兼顾高端机的精细度和低端机的承受能力 |
游戏循环处在“程序 90% 的时间花在 10% 的代码上”的那 10% 里,要格外注意效率。在自带 GUI 事件循环的平台上会有两个循环并存,需要协调。作者推荐的经典延伸阅读是 Glenn Fiedler 的《Fix Your Timestep》。(Game Programming Patterns — game-loop)
更新方法:意图与机制
意图:通过让每个对象一次处理一帧的行为,来模拟一组相互独立的对象。机制:游戏世界维护一个对象集合,每个对象实现一个模拟一帧行为的更新方法,每帧世界依次更新集合中的每个对象。(Game Programming Patterns — update-method)
适用于:有许多需要同时运行的对象或系统,彼此的行为基本独立,并且需要随时间模拟。像国际象棋这种棋子不用同时模拟的游戏就不适合(不过棋子的动画仍可能需要每帧更新)。(Game Programming Patterns — update-method)
书中的动机例子:巡逻的骷髅守卫和定时放闪电的雕像。如果把它们的变量和逻辑都塞进主循环,代码会“糊”在一起;让每个实体封装自己的行为后,主循环只遍历集合调用 update(),增删实体只是增删集合元素,于是填充世界与实现行为分离,可以用数据文件或关卡编辑器来摆放实体。(Game Programming Patterns — update-method)
更新方法:取舍与坑(Game Programming Patterns — update-method)
- 把行为切成一帧一片会更复杂:原本一个简单循环就能写完的巡逻,要改成每帧让出控制权的形式。“几乎”总是必须这样:如果语言支持生成器、协程或纤程这类轻量并发结构,对象行为可以写成可暂停、可恢复的直线代码;字节码模式 也能在应用层实现这样的执行线程。
- 要保存“从哪里继续”的状态:返回之后执行位置就丢了,需要像
patrollingLeft这样的字段,状态模式与有限状态机 常能帮上忙。 - 对象依次更新,并不真正并发:A 排在 B 前面时,A 看到的是 B 上一帧的状态,B 看到的却是 A 的新状态。这通常反而是好事:世界每一步都从一个合法状态走到下一个合法状态,不会出现两个棋子同时走进同一格的歧义。需要“大家都看上一帧”时,可以配合 双缓冲。
- 更新过程中修改对象列表:新生成的对象可能在出生那一帧就行动,可以在循环开始时缓存对象数量来避免;更麻烦的是删除,可能导致跳过某个对象(例如主角杀死排在她前面的野兽,后面的农民前移一格而被跳过)。可以修正迭代下标、倒序遍历(删除只会移动已更新过的元素),或者先标记为“死亡”,遍历结束后再统一移除;多线程处理更新循环时更倾向于延后修改。
update()放在哪个类上:放在实体类上最简单,但业界正在远离“为每种行为派生一个子类”(GoF 早在 1994 年就说“优先使用对象组合而非类继承”;不过作者也认为钟摆有点摆过头,适度用继承无妨);放在组件上,可以让一个实体的各个部分(渲染、物理、AI)各自更新(见 组件模式);也可以放在委托对象(状态对象、类型对象)上。- 可变时间步长下的
update(elapsed):移动距离随经过时间增大,骷髅可能一步越过巡逻边界,需要专门处理“过冲”。 - 休眠对象:大量对象暂时不需要更新时,每帧遍历它们是浪费。它不仅浪费检查标志的 CPU 周期,跳过对象还可能打乱数据缓存。可以另外维护一个“活跃对象”集合(或只另存“不活跃对象”),代价是多占内存,还要保持两个集合同步;不活跃对象越多,这样做越值得。
- 当每帧遍历大量实体或组件的缓存效率成为问题时,见 数据局部性。
代码草图(C++)
按书中示例整理:游戏循环章的“追赶”循环,以及更新方法章的实体、世界与骷髅守卫。
// 游戏循环:固定更新步长、可变渲染
double previous = getCurrentTime();
double lag = 0.0;
while (true) {
double current = getCurrentTime();
double elapsed = current - previous;
previous = current;
lag += elapsed;
processInput(); // 非阻塞
while (lag >= MS_PER_UPDATE) { // 用一串固定步长追上真实时间
update();
lag -= MS_PER_UPDATE;
}
render(lag / MS_PER_UPDATE); // 归一化到 0..1,供渲染外推
}
// 更新方法
class Entity {
public:
Entity() : x_(0), y_(0) {}
virtual ~Entity() {}
virtual void update() = 0;
double x() const { return x_; }
double y() const { return y_; }
void setX(double x) { x_ = x; }
void setY(double y) { y_ = y; }
private:
double x_;
double y_;
};
void World::gameLoop() {
while (true) {
// 处理输入...
for (int i = 0; i < numEntities_; i++) entities_[i]->update();
// 物理与渲染...
}
}
class Skeleton : public Entity {
public:
Skeleton() : patrollingLeft_(false) {}
virtual void update() {
if (patrollingLeft_) {
setX(x() - 1);
if (x() == 0) patrollingLeft_ = false;
} else {
setX(x() + 1);
if (x() == 100) patrollingLeft_ = true;
}
}
private:
bool patrollingLeft_; // 跨帧保存“从哪里继续”
};
// 避免本帧新生成的对象立即行动:先缓存数量
int numObjectsThisTurn = numObjects_;
for (int i = 0; i < numObjectsThisTurn; i++) objects_[i]->update();
UE 中的对应
- Tick 就是引擎版的更新方法 → 委托与 Tick 机制:Tick 每帧(或按最小间隔)调用 Actor 或组件的更新函数,并按 Tick 组 (Tick Group) 与物理模拟排序。Tick 依赖(
AddTickPrerequisiteActor/Component)让对象等指定对象 Tick 完再执行,用来解决“更新顺序决定看到的是旧状态还是新状态”的问题;该页建议只对真正有依赖的对象设依赖,以保留同组内的并行,并用“差一帧”问题说明顺序的重要性。 - 休眠对象与事件优先:同一页给出的 UE 官方取舍是,能用定时器或委托就不用 Tick,必须 Tick 时用 Tick 组和依赖保证顺序。这对应“不要每帧遍历无事可做的对象”。
- 组件各自更新:UE 的 Tick 可以挂在 Actor 组件上,对应“把
update()放在组件上”,见 Gameplay 框架。 - 引擎自己拥有主循环,对应“使用引擎的循环:不用写,也不能改”。
GEA 的视角:渲染循环、游戏循环与架构风格(GEA 卷 I 8.1–8.3)
- 渲染循环(8.1):GUI 传统上用矩形失效 (rectangle invalidation) 只重画变化的小块区域,老 2D 游戏也类似;实时 3D 中摄像机一动全屏都变,失效矩形不再适用,改为像电影一样快速连续呈现静止图像。最简渲染循环:
updateCamera(); updateSceneElements(); renderScene();(渲染到离屏的后缓冲)swapBuffers();(与前缓冲交换;窗口模式下是把后缓冲 blit 到前缓冲)。 - 游戏循环(8.2):各子系统需要的服务频率不同:动画通常 30 或 60 Hz、与渲染同步;动力学模拟可能需要更高频率(如 120 Hz);AI 之类高层系统每秒一两次即可,不必与渲染同步。最简单的做法是用一个主循环更新一切。书中以 Pong 为例(该类型始于 1958 年 Higinbotham 在布鲁克海文国家实验室示波器上的模拟计算机游戏 Tennis for Two):
initGame()后循环readHumanInterfaceDevices()→ 检查退出 →movePaddles()→moveBall()(位置加上当前速度向量)→collideAndBounceBall()→ 判断球是否越过左右边界并加分、重置 →renderPlayfield()。作者说明这只为讲概念,原版 Pong 不可能每秒 30 帧重画全屏,当时常用专用 2D 精灵硬件。 - 架构风格(8.3):
| 风格 | 机制 | 取舍 |
|---|---|---|
| Windows 消息泵(8.3.1) | 外层 while(true),先用 PeekMessage() 循环 TranslateMessage() / DispatchMessage() 处理完所有待处理消息,再跑一轮游戏循环 | Windows 消息优先于渲染与模拟,拖动或缩放窗口时游戏会暂时冻结。原书问题:书中写的 PeekMessage(&msg, nullptr, 0, 0) 只有 4 个参数,而 Win32 的 PeekMessage 需要 5 个参数,最后一个 wRemoveMsg 要传 PM_REMOVE;如果传 PM_NOREMOVE,消息不会从队列中移除,内层 while 会一直循环同一条消息(见 MS Learn PeekMessageA,https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-peekmessagea) |
| 回调驱动框架(8.3.2) | 库由程序员随意调用,最灵活但要懂用法;框架是半成品应用,主循环已写好但基本是空的,程序员写回调填空,几乎无法控制整体流程。例:OGRE 既是库也包装成框架,派生 Ogre::FrameListener 并重写 frameStarted()(渲染前:手柄、玩家控制、动力学、碰撞、摄像机)与 frameEnded()(渲染后:画 HUD),框架内部循环见 Ogre::Root::renderOneFrame() | 用起来省事,但流程由框架控制 |
| 基于事件的更新(8.3.3) | 事件系统(详见 17.8 节)若允许向未来投递事件,处理函数完成周期性工作后再投递一个 1/30 或 1/60 秒后的新事件,就能实现周期更新 | 借用已有事件系统,与 GUI 消息系统类似 |
GPP 与 GEA 在时间步长上的对照
两书对「如何推进时间」的着眼点不同,各自来源所述并列如下(GEA 细节见 游戏时间线与时钟):
| 问题 | GPP(game-loop 章) | GEA(卷 I 8.5.2、8.5.5) |
|---|---|---|
| 老游戏跑太快 | 循环速度取决于硬件,老 PC 有 turbo 按钮 | 称为「依赖 CPU 的游戏」,按每帧位移而非速度指定运动;Turbo 按钮不按时模拟上一代 CPU 速度 |
| 按测得时间可变步长 | 不推荐:不确定、不稳定,物理和网络难做,多数开发者反对 | 作者认为多数引擎用上一帧测得的 ;指出的问题是用上一帧估计下一帧不准、帧率尖峰,可配合滑动平均缓解 |
| 固定频率 | 推荐「固定更新步长、可变渲染」,用 lag 累加并以固定步长追赶,渲染外推 | 推荐帧率调控(太快就睡到目标帧时间,太慢再等一整帧)与 v-sync;强调系统仍应能处理任意帧长,开发期用可变帧率、后期开启调控 |
| 追赶的风险 | 步长必须大于最慢机器一次 update() 的时间,建议限制内层循环最大迭代次数 | 「恶性循环」:一帧 57 ms 后物理走两步补时间,下一帧同样慢甚至更慢 |
| 异常大的时间差 | —— | 断点恢复后帧时间可能长达数分钟,超过上限(如 1 秒)就强制 为目标帧时间 |
两者的共同点是:都承认物理积分偏好固定步长,都提醒「补步」可能拖垮慢机器;分歧在默认推荐的方案。多核下如何把一帧拆成并行作业见 作业系统。
GEA 第 14 章的补充:物理在游戏循环中的位置(卷 II 14.5.2)
- 更新顺序:游戏驱动刚体、幻影、力、冲量和约束要在物理步进之前更新,模拟才"看得见";物理驱动对象要在步进之后回读;渲染放在最后,保证画出的是同一时刻一致的世界(卷 II 14.5.2)。
- 单线程示例:GEA 的主循环依次是:输入轮询 →
preAnimationUpdate→ 动画 →postAnimationUpdate→physicsWorld->step(dt)→ 布娃娃 →postPhysicsUpdate→ 动画定稿 → 特效 → 音频 → 渲染。游戏对象在动画前、动画后、物理后分三阶段更新,正是本页"更新方法"的一个具体变体(卷 II 14.5.2.2)。 - 固定物理步长:多数积分器、碰撞算法和约束求解器在 恒定时表现最好。GEA 建议用理想的 1/30 或 1/60 秒步进物理,再控制整个循环的帧率;掉帧时宁可让物理在视觉上变慢,也不要跟着实际帧率调整步长(卷 II 14.5.2.2)。这与 GPP 推荐的"固定更新步长、可变渲染"目标一致,但 GEA 这里没有讨论追帧(多次更新)的做法,只说让物理变慢。
- 详见 物理引擎与游戏的集成 与 数值积分。
注意:GEA 示例循环的最后一行是
dt = calcDeltaTime();,即用实测帧时间作为下一帧的 dt,并直接传给step(dt)。这与紧接其后"用恒定步长步进物理"的建议不一致;示例应理解为说明调用顺序,而不是时间步策略(卷 II 14.5.2.2)。
GEA 第 17 章的补充:游戏对象如何接入游戏循环(卷 II 17.6–17.7)
本页的更新方法是 GEA 所说"简单方法"的起点。GEA 第 17 章说明了为什么商业引擎不能停留在"每帧对每个对象调一次 Update()",详见 游戏对象的实时更新 和 游戏对象并发更新:
- 不要在对象的
Update()里替它更新子系统:底层系统要批量更新才能获得缓存一致性、避免重复计算、复用资源并做分散/汇集;对象只修改子系统中属于自己的状态(如网格实例的可见性),由主循环统一驱动子系统。 - 分阶段更新:Naughty Dog 引擎每帧在动画混合前、混合后、最终姿势后三次调用对象逻辑,更好的实现是让子系统提供阶段回调注册。
- 分桶更新:按依赖森林把对象分桶(载具/平台、角色、手持物),逐桶运行完整的分阶段更新。
- 一致性规则:"所有游戏对象的状态在更新循环之前和之后一致,但在循环之中可能不一致。"GEA 的对策是状态缓存(保留上一帧状态,与 双缓冲 同源)和时间戳/桶号断言。
- 并发:Naughty Dog 把每个桶内的对象更新作为作业并发执行,同桶读取用快照、修改用锁或请求队列。
观点并列:本页"取舍与坑"引述 GPP 的看法:对象依次更新时 A 看到 B 的旧状态、B 看到 A 的新状态,"这通常反而是好事",因为世界每一步都从一个合法状态走到下一个合法状态。GEA 则把更新循环中途的状态不一致视为业内常见的 bug 来源(一帧延迟),并主张用分桶、状态缓存和断言来约束谁能读谁。两者针对的场景不同:GPP 的例子是回合式的离散移动,GEA 关注的是多子系统、多阶段的实时帧更新。
视角差异:GPP 的更新方法以"对象各自更新"为中心,GEA 则强调对象更新只是主循环中被各子系统阶段夹在中间的一环。两者并不矛盾,前者描述对象层的接口,后者描述引擎层的调度。
Gaffer:Fix Your Timestep 与网络确定性
Glenn Fiedler《Fix Your Timestep!》把同一套固定步长讲得更偏物理积分:可变 dt 会让弹簧/碰撞行为随帧率漂移;半固定步长用上限切开大帧,但可能陷入「模拟 X 秒却花超过 X 秒真实时间」的死亡螺旋。最终推荐 accumulator:渲染产生时间,模拟以固定 dt 消费;剩余用 alpha = accumulator/dt 在上一态与当前态之间做渲染插值(四元数用 slerp)。大卡顿应钳制 frameTime(文中例 0.25 s)。(Fix Your Timestep!)
固定 dt 也是 确定性锁步同步 的前置条件:无固定步长就谈不上跨端可复现。网络快照侧的「拨回时间再插值」见 快照插值,模型总览见 多人网络游戏模型。
出处
- Game Programming Patterns — game-loop
- Game Programming Patterns — update-method
- Game Programming Patterns — game-loop(全文版)
- Game Programming Patterns — update-method(全文版)
- Robert Nystrom《Game Programming Patterns》(2014) game-loop 章,gameprogrammingpatterns.com/game-loop.html
- Robert Nystrom《Game Programming Patterns》(2014) update-method 章,gameprogrammingpatterns.com/update-method.html
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 8 章 8.1–8.3、8.5.2、8.5.5 节
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 14 章 14.5.2 节
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.6–17.7 节
- Fix Your Timestep!
- Glenn Fiedler, Fix Your Timestep! (2004), gafferongames.com/post/fix_your_timestep