【游戏架构】【笔记】序列型模式——《游戏编程模式》学习笔记002
该笔记中有AI辅助内容。
“作为世界的建造者,我们必须创造时间并打磨用来驱动游戏巨大时钟的齿轮。
本篇中的模式便是用来做这些打磨工作的工具。游戏循环是时钟旋转的中心轴,对象通过建立在游戏循环之上的更新方法来更新自身。我们可以通过双缓冲来及时地将计算机的时序性隐藏在时间快照之后,从而使得游戏世界能够同步更新。”
双缓冲模式
使用的原因
因为我们需要游戏的加载给玩家看到的是快速而平滑的,而不是断断续续的。
首先我们要大致了解计算机图形学的基础知识,屏幕从左上角,一行行扫到右下角,一般每秒60次(刷新率),到右下角时重定位回左上角,从帧缓冲区读取(每次读取1字节),了解到当前像素的颜色后,进行喷墨染色。
但是会有一个问题,当显卡正在读取的帧缓存正是我们正在写入的那块。随着它扫描过那些我们已经写入的数据,会显示我们想要的画面,但它渐渐超过我们的写入速度并访问了帧缓存中那些未写入的部分。结果就是渲染出现了撕裂,屏幕上会留下了一个半成品,丑陋的bug暴露无遗。
为了解决这个问题,我们就会用到双缓冲模式。
双缓冲中的一个缓存用于展示当前帧。它就是显示设备读取像素数据来进行渲染的地方,GPU可以随时对其进行任意数据量的扫描。
与此同时,我们的渲染代码正在另一个帧缓冲区中写入数据,它处于黑暗中。当渲染代码完成场景2的绘制时,它通过交换两个缓冲区来切换画面。这使得显卡驱动开始从第一个缓冲区转向第二个缓冲区以读取其数据进行渲染。只要它掌握好时机在每次刷新显示结束时进行切换,我们就不会看到任何衔接的裂隙,且整个场景能一次性在瞬间显示出来。
模式详解
定义一个缓冲区类来封装一个缓冲区:一块能被修改的状态区域。这块缓冲区能被逐步地修改,但我们希望任何外部的代码将对该缓冲区的修改都视为原子操作。
原子操作是指在多线程环境中不可被中断的操作,要么完全执行,要么完全不执行,不存在中间状态。
为实现这一点,此类中维护两个缓冲区实例:后台缓冲区和当前缓冲区。
当要从缓冲区读取信息时,总是从当前缓冲区读取。当要往缓冲区中写入数据时,则总在后台缓冲区上进行。当改动完成后,则执行“交换”操作来将当前缓冲区与后台缓冲区进行瞬时的交换,以便让新的缓冲区为我们所见,同时刚被换下来的当前缓冲区则成为现在的后台缓冲区以供复用。
应用场景
- 我们需要维护一些被逐步改变着的状态量。
- 同个状态可能会在其被修改的同时被访问到。
- 我们希望避免访问状态的代码能看到具体的工作过程。
- 我们希望能够读取状态但不希望等待写入操作的完成。
不止在渲染中,还有角色状态中,对状态进行缓冲,一个状态用于读取,一个状态用于写入,在第一帧先只记录下一帧该有的状态,然后下一帧再进行更新。
双缓冲的一个经典应用是处理动态模糊。当前帧与先前渲染帧的一部分进行混合,以便让产生的图像更接近于真实摄像机拍摄产生的效果。
在 Unity / UE 以及 Direct3D / Vulkan 的底层 交换链(SwapChain) 中:
- 引擎每帧渲染前必须调用 Present() 交换缓冲区。
- 如果需要做“基于上一帧画面”的后处理(如 TAA 抗锯齿、Motion Blur 运动模糊),引擎不能直接用 Back Buffer,而是必须单独开一张独立的 Render Texture(渲染目标纹理) 专门存储上一帧的历史数据(History Buffer)。**
注意事项
1. 交换本身需要时间
双缓冲模式需要在状态写入完成后进行一次交换操作,操作必须是原子性的:也就是说任何代码都无法在这个交换期间对缓冲区内的任何状态进行访问。通常这个交换过程和分配一个指针的速度差不多,但如果交换用去了比修改初始状态更多的时间,那这模式就毫无助益了。
2. 增加了内存的使用
因为我们必须要有两份缓冲区,所以增加了内存的使用。此模式要求你在任何时刻都维护着两份存储着状态的内存区域。在内存受限的硬件上,这可是个很苛刻的要求。假如你无法分配出两份内存,你就必须想出其他办法来避免你的状态在修改时被访问。
4. 缓冲区如何交换
- 使用指针或者引用
- 这样子很快,而且外部代码无法储存指向某块缓冲区的持久化指针。
- 这对于那些显卡希望帧缓冲区在内存中固定地址的系统来说尤其会造成麻烦。如果是那样,我们就不能采用这种办法。
- ❗缓冲区中现存的数据会来自两帧之前而不是上一帧。
- 在两个缓冲区之间进行数据的拷贝
- 位于后台缓冲区里的数据与当前数据只差一帧时间。
- 交换操作花费的时间可能会很高。
5. 缓冲区的粒度
- 缓冲区是单个整体
- 交换操作简单,只需进行一次交换
- 假如许多对象都持有一块数据
- 交换较慢,需要遍历对象集合并通知交换
- 在不需要访问缓冲状态的情况,可以使用“当前”和“下一个”指针的概念并将它们作为对象内部的成员——相对偏移量,来优化。
游戏循环
“实现用户输入和处理器速度在游戏行进时间上的解耦。”
核心定义:解耦“用户输入与硬件渲染速度”与“游戏逻辑行进时间”,让游戏在不同的 CPU/GPU 性能下保持一致的物理与逻辑运行速率。
1. 游戏循环的演进模型
| 模式 | 运行机制 | 优缺点 / 应用场景 |
|---|---|---|
| 硬件死锁循环 (Old FPS-Dependent) | 跑多快更新多快,不记录 \(\Delta t\) | ❌ CPU 升级后游戏速度变快几倍(早期 DOS 游戏)。 |
| 变长时间步 (Variable Delta Time) | Update(dt),按每帧真实耗时推进 |
⚠️ 画面流畅,但物理模拟极不稳定,易产生穿模。 |
| 定频步长 + 累加器 (Fixed Step + Accumulator) | 逻辑固定 \(16\text{ms}\) 迭代,渲染随帧率自由 | ✅ 工业界标准。逻辑与渲染彻底解耦,保证物理确定性。 |
2. 核心机制与极端情况处理
-
追赶机制 (Catch-up / Substepping):
当某帧发生高负载卡顿(如掉帧至 \(50\text{ms}\)),累加器积攒了过多拖欠时间。游戏会在该渲染帧内连续触发多次固定逻辑更新(如连续执行 3 次 \(16\text{ms}\) 更新),瞬间追平现实时间。 -
死亡螺旋 (Spiral of Death):
- 问题:如果追赶更新本身耗时太长,会导致下一帧卡顿更严重,从而陷入无限追赶直至崩溃。
- 解法:设置最大允许步长限制(Max Allowed Timestep / Max Sub-steps)。丢弃超额的拖欠时间,宁可让游戏短暂变慢,也不能让游戏卡死。
-
渲染帧插值 (Render Interpolation):
- 问题:当累加器剩余的时间不足以跑完一次完整的固定逻辑步(如剩下 \(8\text{ms}\))时,画面可能产生微小抖动。
- 解法:计算余数比例 \(\alpha = \text{余数} / \text{固定步长}\),在渲染层对上一帧与当前帧的变换数据进行
Lerp插值。
3. 引擎架构落地对标
-
Unity (C#):
- 变长渲染帧:
MonoBehaviour.Update(),处理输入、UI 与非物理视觉逻辑。 - 定频逻辑帧:
MonoBehaviour.FixedUpdate(),默认周期 \(0.02\text{s}\) (\(50\text{Hz}\)),物理引擎与碰撞逻辑在此迭代。 - 死亡螺旋防御:
Time.maximumDeltaTime(防卡死单帧最大耗时)。
- 变长渲染帧:
-
Unreal Engine (C++):
- 变长渲染帧:
AActor::Tick(float DeltaTime),每帧根据硬件速率平滑更新。 - 定频物理子步:启用 Physics Substepping (
bSubstepping = true)。当DeltaTime变大时,UPhysicsSettings会自动将物理模拟拆分为多个微小子步(MaxSubstepDeltaTime)。
- 变长渲染帧:
更新方法
“通过对所有对象实例同时进行帧更新来模拟一系列相互独立的游戏对象。”
核心定义:在每个对象上定义一个 Update() 方法,由游戏循环(Game Loop)在每一帧依次调用,模拟出所有对象同步、独立运行的假象。
假如在实体自己写上与游戏循环无关的循环来实现,会没有效果,程序只是一直在运算实体的循环,所以我们在每个实体上加上update()方法,在游戏循环中每次循环调用一次update()。
解决的问题:
- 将“单个实体的行为逻辑”与“全局游戏循环的调度与时间推进”彻底解耦。
- 避免每个实体各自编写死循环而阻塞主线程。
使用环境
- 你的游戏中含有一系列对象或系统需要同步地运转。
- 各个对象之间的行为几乎是相互独立的。
- 对象的行为与时间相关。
要注意,虽然玩家眼中看着是同时发生的,但实际上核心还是回合制一样的进行更新操作的,只不过间隔仅一帧。(若需要回避这种状态,就需要双缓冲模式)
1. 设计中的决策
update方法应该依存于什么类中- 可在实体类中,但是这样子就要超级多的子类了
- 可在组件类中
- 可在代理类中
- 未被利用的对象该如何处理
- 单个集合储存所有对象
在所有情况都要进行检查是否激活。 - 单独维护一个需要被更新的“存活”对象表
这将需要额外的内存来维护 - 维护两个集合,一个是被激活的对象集合,一个是未被激活的对象集合
- 必须保持两个集合同步
- 单个集合储存所有对象
2. 两大核心工程陷阱与解决方案
-
顺序依赖与数据不同步(Tick Order Dependency):
- 问题:虽然视觉上是“同时发生”,但代码执行是有先后顺序的。如果 A 在 B 前更新,A 拿到的就是 B 的上一帧数据,而 B 拿到的却是 A 的当前帧数据。
- 解法:对顺序敏感的逻辑(如相机跟随),使用明确的分阶段更新;若要彻底消除影响,可使用双缓冲模式(读写分离)。
-
遍历中增删对象的安全隐患(Safe Collection Modification):
- 问题:在
foreach (var obj in objects)遍历调用Update()时,如果某个对象在Update()里销毁了自己或生成了新对象,会导致迭代器失效(抛出异常)或跳过某些对象的更新。 - 解法:延迟队列(Pending Add/Remove Queue)。在
Update()期间只将要增删的对象记入暂存队列,待本帧遍历完成后,在帧末统一修改对象列表。
- 问题:在
3. 对象激活/非激活(Active/Inactive)管理策略
| 方案 | 运行机制 | 优缺点 / 适用场景 |
|---|---|---|
| 单列表 + 标记位 | 一个 List 存所有对象,每次循环检查 if (obj.IsActive) |
结构最简单,但灭活对象多时浪费大量 CPU 迭代开销。 |
| 活跃对象动态列表 | 仅 List 保存活跃对象,灭活时从列表移除 |
无无效迭代,但频繁增删会导致动态数组移位开销(O(n))。 |
| 双列表/对象池交换 | 一个 ActiveList,一个 InactiveList,两边按需转移 |
工业推荐。完美契合对象池(Object Pool),无内存分配。 |
4. 商业引擎中的架构落地与工业优化
-
Unity (C#):
- 机制:
MonoBehaviour.Update()/LateUpdate()/FixedUpdate()。 - 性能陷阱:Unity 底层(C++)遍历所有 C# 的
Update()时,每一帧都会产生 C++ 到 C# 的跨语言调用开销(Interop Overhead)。如果场景有 10000 个带Update()的小对象,帧率会暴跌。 - 工业优化:使用 Manager 统一调度。只保留一个
GameManager.Update(),内部用纯 C#for循环遍历调用自定义的IUpdatable列表,性能可提升数倍。
- 机制:
-
Unreal Engine (C++):
- 机制:
AActor::Tick(float DeltaTime)/UActorComponent::TickComponent()。 - Tick 组(ETickingGroup):UE 原生在底层解决了“顺序依赖问题”。引擎将一帧划分成了多个 Tick 阶段(如
TG_PrePhysics物理前、TG_PostPhysics物理后)。 - 依赖锁:可以通过
AddTickPrerequisiteActor()显式指定“Actor A 必须在 Actor B 之后执行 Tick”,彻底杜绝顺序错误。
- 机制:
[1] Robert Nystrom. 游戏编程模式[M]. 微信读书版

浙公网安备 33010602011771号