中EN
概念页

游戏循环与更新方法

Game Loop & Update Method

游戏循环 (Game Loop) 让游戏时间的推进与玩家输入和处理器速度脱钩:每一轮非阻塞地处理输入、更新状态、渲染,并跟踪真实时间来控制节奏;最常推荐的做法是“固定更新步长、可变渲染”。更新方法 (Update Method) 是它的搭档:世界维护一组对象,每个对象实现一个模拟“一帧”行为的 update(),每帧依次调用。两者和组件模式一起常被视为游戏引擎的核心。

游戏循环:意图与机制

意图:让游戏时间的推进与用户输入和处理器速度解耦。机制:循环在游戏期间持续运行,每一轮非阻塞地处理输入、更新游戏状态、渲染,并跟踪时间流逝以控制游戏速度。(Game Programming Patterns — game-loop)

几乎所有游戏都会用到它:用引擎时你不用自己写,但它一直在那里。作者对“引擎”和“库”的区分正在于此:用库时你拥有主循环并调用库,引擎则拥有循环并调用你的代码。即便是回合制游戏,在等待玩家操作时,动画和音乐也在继续。(Game Programming Patterns — game-loop)

循环的演进(Game Programming Patterns — game-loop):

  1. 批处理程序跑完就停;文字冒险和 GUI 事件循环会阻塞等待输入。游戏不同,玩家不操作时世界也在动,所以游戏循环的第一个关键点是处理输入但不等待输入。
  2. 最简单的 while(true){ processInput(); update(); render(); } 跑多快完全取决于每帧工作量和硬件速度,老游戏在更快的 PC 上会跑得太快(所以老 PC 有 turbo 按钮)。
  3. 睡一会儿:以 60 FPS(约 16 ms/帧)为目标,处理完一帧后 sleep 到下一帧。它能防止太快,却救不了太慢。
  4. 可变时间步长:按上一帧经过的真实时间推进模拟。看似完美,但让游戏不确定且不稳定:快机器每秒更新 50 次、慢机器 5 次,浮点舍入误差累积不同,同一颗子弹在两台机器上落点不同;物理引擎的阻尼是按某个步长调好的,步长一变就可能“爆炸”。
  5. 追赶:渲染只捕捉一个瞬间,基本不受步长影响(运动模糊之类略有影响但玩家很少察觉),所以用固定步长更新、灵活地渲染,即下面的方案。

固定更新步长、可变渲染:每帧开始时,把经过的真实时间累加进 lag(游戏时钟落后真实时间多少);内层循环以固定步长 MS_PER_UPDATE 反复更新,直到追上真实时间;然后渲染。(Game Programming Patterns — game-loop)

lag+=tnow−tprev,while (lag≥Δt) { update(); lag−=Δt },render(lagΔt)\text{lag} \mathrel{+}= t_{\text{now}} - t_{\text{prev}},\qquad \text{while }(\text{lag} \ge \Delta t)\ \{\ \text{update}();\ \text{lag} \mathrel{-}= \Delta t\ \},\qquad \text{render}\left(\frac{\text{lag}}{\Delta t}\right)

其中 Δt\Delta t 就是 MS_PER_UPDATE。它不再是可见帧率,只是更新的粒度:越短,模拟越精细,追赶真实时间的处理开销也越大;但它必须大于最慢的机器执行一次 update() 的时间,否则永远追不上(作者建议给内层循环设一个最大迭代次数作为保险:游戏会变慢,但总比卡死好)。把剩余的 lag/Δt\text{lag}/\Delta t 传给 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 速度
按测得时间可变步长不推荐:不确定、不稳定,物理和网络难做,多数开发者反对作者认为多数引擎用上一帧测得的 Δt\Delta t;指出的问题是用上一帧估计下一帧不准、帧率尖峰,可配合滑动平均缓解
固定频率推荐「固定更新步长、可变渲染」,用 lag 累加并以固定步长追赶,渲染外推推荐帧率调控(太快就睡到目标帧时间,太慢再等一整帧)与 v-sync;强调系统仍应能处理任意帧长,开发期用可变帧率、后期开启调控
追赶的风险步长必须大于最慢机器一次 update() 的时间,建议限制内层循环最大迭代次数「恶性循环」:一帧 57 ms 后物理走两步补时间,下一帧同样慢甚至更慢
异常大的时间差——断点恢复后帧时间可能长达数分钟,超过上限(如 1 秒)就强制 Δt\Delta t 为目标帧时间

两者的共同点是:都承认物理积分偏好固定步长,都提醒「补步」可能拖垮慢机器;分歧在默认推荐的方案。多核下如何把一帧拆成并行作业见 作业系统。

GEA 第 14 章的补充:物理在游戏循环中的位置(卷 II 14.5.2)

  • 更新顺序:游戏驱动刚体、幻影、力、冲量和约束要在物理步进之前更新,模拟才"看得见";物理驱动对象要在步进之后回读;渲染放在最后,保证画出的是同一时刻一致的世界(卷 II 14.5.2)。
  • 单线程示例:GEA 的主循环依次是:输入轮询 → preAnimationUpdate → 动画 → postAnimationUpdate → physicsWorld->step(dt) → 布娃娃 → postPhysicsUpdate → 动画定稿 → 特效 → 音频 → 渲染。游戏对象在动画前、动画后、物理后分三阶段更新,正是本页"更新方法"的一个具体变体(卷 II 14.5.2.2)。
  • 固定物理步长:多数积分器、碰撞算法和约束求解器在 Δt\Delta t 恒定时表现最好。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 也是 确定性锁步同步 的前置条件:无固定步长就谈不上跨端可复现。网络快照侧的「拨回时间再插值」见 快照插值,模型总览见 多人网络游戏模型。

  1. Game Programming Patterns — game-loop
  2. Game Programming Patterns — update-method
  3. Game Programming Patterns — game-loop(全文版)
  4. Game Programming Patterns — update-method(全文版)
  5. Robert Nystrom《Game Programming Patterns》(2014) game-loop 章,gameprogrammingpatterns.com/game-loop.html
  6. Robert Nystrom《Game Programming Patterns》(2014) update-method 章,gameprogrammingpatterns.com/update-method.html
  7. Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
  8. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 8 章 8.1–8.3、8.5.2、8.5.5 节
  9. Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
  10. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 14 章 14.5.2 节
  11. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.6–17.7 节
  12. Fix Your Timestep!
  13. Glenn Fiedler, Fix Your Timestep! (2004), gafferongames.com/post/fix_your_timestep

相关页面

游戏开发多人网络游戏模型Multiplayer Networking Models多人游戏并不是共享同一份物理现实,而是每位玩家各自看到的近似。历史上主要两条路:对等锁步 (Peer-to-Peer Lockstep)(RTS 仍常用)与权威客户端/服务器 (Authoritative Client/Server)(动作/FPS 主流);后者再叠加客户端预测、插值与延迟补偿,把延迟藏进体验里。游戏开发快照插值Snapshot Interpolation发送端按固定频率拍下世界(或相关子集)的快照,接收端不跑权威模拟,而是把渲染时刻拨回一小段,在缓冲里的两份快照之间插值,用额外延迟换平滑,并扛住抖动与偶发丢包。Source 的实体插值与 clinterp 是同一思想的产品化。游戏开发确定性锁步同步Deterministic Lockstep锁步不同步物体状态,只同步驱动模拟的输入;各方从相同初态用相同固定步长推进。带宽与对象数量无关,但要求比特级确定性,且每人延迟受最慢输入到达时间约束,通常适合约 2–4 人。游戏开发双缓冲Double Buffer双缓冲 (Double Buffer) 让一系列按顺序执行的修改,在外界看来像是瞬间、同时完成的:类内部保留“当前缓冲”和“下一缓冲”两份状态,读取总是读当前缓冲,写入总是写下一缓冲,改完后用一次原子交换让新状态整体生效。图形里的前后缓冲是最经典的例子;同样的思路也能让一帧内依次更新的游戏对象互相看到的都是上一帧的一致状态。游戏开发组件模式Component Pattern组件模式 (Component Pattern) 让一个实体横跨多个领域(输入、物理、图形、AI……),同时不让这些领域彼此耦合:每个领域的代码放进自己的组件类,实体本身退化为组件的容器。它是现代引擎实体模型的基础,代价是对象变成需要正确装配的一组对象,组件之间如何通信也成了要做的设计决策。游戏开发数据局部性Data Locality数据局部性 (Data Locality) 是一种优化思路:CPU 读取内存时会把相邻的一整条缓存行一起载入,所以把数据按处理顺序连续存放,就能大幅减少缓存未命中。具体手段包括:组件按类型存进连续数组,不再到处跟随指针;把活跃对象排在前面;把冷热数据拆开。代价是要放弃一部分继承、接口与指针带来的抽象。游戏开发状态模式与有限状态机State Pattern & Finite State Machines状态模式 (State Pattern) 把对象“依赖状态的行为”分散到各个状态类中,对象只持有指向当前状态的指针并把调用委托给它,状态切换就是换指针。它是实现有限状态机 (Finite State Machine, FSM) 的一种方式。FSM 能用很强的结构约束理清一团乱麻的标志位代码;状态组合爆炸、需要复用和需要“回到之前状态”时,分别用并发状态机、分层状态机和下推自动机来扩展。游戏开发 · UE5委托与 Tick 机制Delegates & TickUE 有两种驱动游戏逻辑的方式:事件驱动——委托 (Delegate) 在某事发生时回调绑定的函数;轮询驱动——Tick 每帧(或按最小间隔)调用 Actor/组件的更新函数,并按 Tick 组 (Tick Group) 与物理模拟排序。官方建议能用定时器、委托就不用 Tick,必须 Tick 时用 Tick 组和 Tick 依赖保证顺序,避免“差一帧”问题。综合游戏编程模式选型:解耦与性能Choosing Game Programming Patterns本页把《Game Programming Patterns》里的 19 个模式放到同一张取舍图上:大多数模式要么为了解耦(降低改代码时需要装进脑子里的知识量),要么为了性能(利用对数据和硬件的具体假设),而这两个目标经常互相拉扯。下面先讲全书的定位和 Nystrom 的架构观,再给出按问题选模式的路线和与 UE 的对照。游戏开发子类沙盒Subclass Sandbox子类沙盒 (Subclass Sandbox) 让基类提供一组受保护的“提供的操作”和一个抽象的沙盒方法,派生类只用这些操作来实现沙盒方法。这样大量派生类(例如几十种超能力)只耦合到基类,不直接接触引擎的各个系统;代价是基类会越来越臃肿,变成脆弱基类。游戏开发运行时引擎分层架构Runtime Engine ArchitectureGregory 把游戏视为“软实时、交互式、基于代理的计算机模拟”,把游戏引擎定义为数据驱动、可复用来做多款游戏的软件。运行时引擎按层组织:从目标硬件、驱动、操作系统、第三方 SDK、平台无关层、核心系统、资源管理器,到渲染、物理、动画、音频、网络等子系统,再到游戏性基础层和游戏专属子系统;上层依赖下层,避免循环依赖。游戏开发进程、线程与纤程Processes, Threads & Fibers并发编程依赖操作系统提供的服务:内核态与用户态、中断、系统调用、抢占式多任务。进程只是为线程提供运行环境(虚拟地址空间与资源),线程才是操作系统的基本执行单位(调用栈 + 寄存器)。线程由内核抢占调度;纤程 (Fiber) 由程序协作调度,适合实现引擎任务系统;用户级线程与协程完全在用户空间切换上下文,更轻量。游戏开发游戏时间线与时钟Game Timelines & Clocks游戏引擎要同时处理多种「时间」:真实时间、游戏时间、动画片段的局部时间、CPU 周期等。GEA 用抽象时间线 (abstract timeline) 统一看待它们,并讨论帧时间 \Delta t 的测量方法、高精度计时器、时间单位与时钟变量类型的取舍,以及断点恢复后的异常 \Delta t 处理。(卷 I 第 8 章 引言、8.4–8.5 节)游戏开发作业系统Job System多核硬件上,游戏循环的工作要分解成可并行执行的子任务。GEA 依次比较「每子系统一个线程」「分散/汇集 (scatter/gather)」和通用的作业系统 (job system),并给出典型作业 API、基于线程池的简单实现及其局限、用协程或纤程让作业可以睡眠、作业计数器、作业同步原语与可视化剖析工具,最后介绍 Naughty Dog 基于纤程的作业系统。(卷 I 8.6 节)游戏开发人机接口设备Human Interface Devices人机接口设备 (HID) 是玩家向游戏提供输入、游戏向玩家提供反馈的硬件:手柄、键鼠、摇杆、Wii 遥控器、方向盘、跳舞毯、吉他等。GEA 从三个层面介绍它们:游戏如何与设备通信(轮询、中断、无线协议),设备提供哪几类输入,以及设备能输出哪些反馈。引擎如何加工这些原始输入见 引擎输入系统。(卷 I 9.1–9.4 节)图形学实时渲染Real-Time Rendering实时渲染是在计算机上快速生成图像、并让图像随用户输入即时变化的技术。RTR4 认为它由三个要素共同定义:交互性、三维空间感,以及图形加速硬件 (GPU)。游戏开发物理引擎与游戏的集成Integrating a Physics Engine into a Game碰撞/物理世界里的刚体只是抽象的数学描述,要通过游戏对象这个中枢,间接连到屏幕上的视觉表现。刚体按谁驱动分三类:物理驱动、游戏驱动(Havok 称 key framed)、固定;Havok 用可在运行时切换的"运动类型"统一表示。每帧更新有固定顺序:先更新游戏驱动刚体、幻影、力、冲量和约束,再步进模拟,然后回读物理驱动对象、查询幻影、执行投射查询。物理宜用恒定的 1/30 或 1/60 秒时间步。GEA 还逐一讨论了子弹、手雷、爆炸、可破坏物、角色移动、相机碰撞和布娃娃的实际做法(GEA4 卷 II 14.5–14.6)。游戏数学数值积分Numerical Integration游戏中的力是交互的、无法事先预测的,运动方程(常微分方程)几乎没有解析解,所以物理引擎采用数值积分:以大致恒定的时间步长 \Delta t 逐步推进,由当前时刻 t1 的 \mathbf r(t1)、\mathbf v(t1) 和力函数 \mathbf F(t,\mathbf r,\mathbf v) 求出 t2=t1+\Delta t 时的位置与速度。衡量方法好坏看收敛性、阶、稳定性三项。显式欧拉最简单但误差大、不稳定;GEA 认为交互游戏中最常用的大概是 Verlet,尤其是速度 Verlet(GEA4 卷 II 14.4.4)。游戏开发游戏对象的实时更新Updating Game Objects in Real Time游戏对象更新就是根据上一时刻状态 Si(t − Δt) 求出当前状态 Si(t)。"每帧对每个对象调一次虚函数 Update(),并在里面顺带更新它的动画、物理、渲染组件"的简单做法在商业引擎里行不通:底层子系统需要批量更新才能高效,而对象之间、子系统之间又有依赖顺序。GEA 介绍的对策依次是:子系统由游戏循环直接批量更新、对象只操纵子系统中的"代理状态";在帧内多个阶段挂回调;按依赖森林把对象分桶,逐桶运行完整的分阶段更新(Naughty Dog 的做法);并用状态缓存和时间戳对付更新过程中的状态不一致和一帧延迟(GEA4 卷 II 17.6)。游戏开发游戏对象并发更新Concurrent Game Object Updates游戏对象模型要和几乎必然是多线程的底层子系统打交道,所以对象代码必须异步思考:耗时操作用非阻塞请求尽早发起、尽晚取结果,必要时容忍一帧延迟。作业之间的依赖决定了并行度 (DOP),每个依赖都引入同步点,"优化并发设计的秘诀是延迟"。对象模型本身也能并行:Naughty Dog 在把引擎从 PS3 移植到 PS4(《最后生还者 重制版》)时,让每个桶内的对象更新成为作业;桶间访问无需加锁,桶内读取用对象快照,桶内修改尽量避免,或加锁、或放进请求队列、或交给下一桶中的同步作业(GEA4 卷 II 17.7)。综合引擎运行时架构总览Engine Runtime Architecture Overview本页把 wiki 中分散在 GEA 第 1、6、8、17 章及 GPP、UE5 相关页面里的内容串成一条线,回答"一个游戏引擎在运行时是怎样组织起来的":静态结构上是自下而上的分层,上层依赖下层;启动关闭按依赖顺序显式进行;每一帧由主循环驱动各子系统批量更新,游戏对象的逻辑被安排在子系统阶段之间,并按依赖分桶或设先后关系;多核上一帧被拆成成百上千个作业;玩法基础系统(对象模型、事件、脚本、世界加载流送、高层流程)位于引擎与游戏的分界线附近。各结论的细节与出处见所链接的概念页。游戏策划Machinations 图示语言Machinations DiagramsMachinations 是 Joris Dormans 设计的可视化语言与在线模拟工具,用于创建、记录、模拟和测试游戏的内部经济。其理论前提是:玩法最终由有形、无形与抽象资源在游戏系统中的流动决定。图由存放资源的节点 (Nodes)、搬运资源的实线资源连接 (Resource Connections)、以及用当前状态去修改别处的虚线状态连接 (State Connections) 组成;语法足够严格,可被计算机按时间步执行,接近一种专门表示游戏机制的可视化编程语言。本页按第 5 章整理全部基本元素与《吃豆人》建模示例,并给出本 wiki 用 Mermaid 改画 Machinations 图游戏策划模拟试玩与人工玩家Simulated Playtests & Artificial Players简单的赌博游戏(21 点、轮盘)可以直接算胜率;复杂而带随机的游戏必须玩很多次才知道是否平衡。Machinations 工具提供人工玩家 (Artificial Player, AP):用简单脚本代替人点击节点,配合快速运行与批量运行收集上千局统计。Adams & Dormans 用它做了两个完整案例:给两人版《大富翁》加入房屋与房产税(动态摩擦)来遏制动态引擎;给 Will Wright 的假想 RTS SimWar 调参,使"乌龟流"与"速攻流"从 929:68 调到 500:500。核心纪律:人工玩家要做成始终坚持一种策略的漫画式角色;调机制,不调人工玩家;一次只改一处并先大幅改动。游戏开发 · UE5Gameplay 框架Gameplay FrameworkGameplay 框架 (Gameplay Framework) 是 UE 提供的一组相互配合的类,用来搭建游戏的基本结构:Actor 与组件 (Component) 构成场景中的一切;GameMode 在服务器上定规则并生成玩家;玩家由“控制器 (Controller) + 棋子 (Pawn)”组成;GameState 与 PlayerState 负责把全局与每个玩家的状态复制给所有客户端;GameInstance 跨关卡存活。游戏开发字节码模式Bytecode Pattern字节码模式 (Bytecode Pattern) 把行为编码成虚拟机 (VM) 能执行的指令序列,让行为拥有数据一样的灵活性:可以热加载、与引擎核心隔离(沙盒化),还可以由非程序员编写。它是全书最复杂的模式,代价是要写编译器或创作工具,调试器也用不上了,而且这类小语言容易无节制地膨胀。

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原