游戏编程模式选型:解耦与性能
Choosing Game Programming Patterns
本页把《Game Programming Patterns》里的 19 个模式放到同一张取舍图上:大多数模式要么为了解耦(降低改代码时需要装进脑子里的知识量),要么为了性能(利用对数据和硬件的具体假设),而这两个目标经常互相拉扯。下面先讲全书的定位和 Nystrom 的架构观,再给出按问题选模式的路线和与 UE 的对照。
全书定位(Game Programming Patterns — introduction)
- 动机:作者 2001 年进入 EA,发现图形、AI、动画等代码很出色,但承载它们的架构常常是事后补的,模块间耦合严重;同时代码库里埋着好方案,同事却在重新发明。本书就是把这些模式挖出来、打磨好,让大家把时间花在发明而不是重新发明上。
- 单点式 (à la carte):领域专著很少讲那块代码如何与游戏其余部分衔接,整引擎书籍又过于整体、绑定特定类型(多为 3D 第一人称射击);本书每章是一个可以独立应用、自由组合的想法。
- 结构:全书三部分:引言两章;“重访设计模式”,用游戏视角重讲 GoF 中的 6 个(命令、享元、观察者、原型、单例、状态);以及 13 个新模式,分为序列 (Sequencing)、行为 (Behavioral)、解耦 (Decoupling)、优化 (Optimization) 四类。每章按意图、动机、模式、何时使用、注意事项、示例代码、设计决策、参见的固定结构写。
- 为什么特别适合游戏:时间与顺序常是架构核心;开发周期极度压缩,多人要快速迭代大量行为而互不踩脚;行为之间会大量交互(怪物咬英雄、药水混合、炸弹不分敌我),不能让代码变成一团乱麻;性能至关重要。
- 示例代码:用 C++ 是因为它是商业游戏的通用语,C 系语法也易读;但示例刻意不用 C++11 以后的风格、不用标准库、很少用模板,“读代码表达的想法,而不是表达想法的代码”。本知识库各模式页的代码草图因此也按书中原样的风格整理。
架构是为了“改代码”
Nystrom 的定义:好的设计意味着做修改时,整个程序仿佛就是为这次修改准备好的。架构的价值只在代码被修改时才体现。修改代码的流程是“理解问题 → 学习相关代码 → 写出解决方案 → 清理”,其中最慢的往往是把相关代码装进脑子。两段代码耦合,意思是不理解其中一段就无法理解另一段;解耦之后可以只理解其中一边。因此架构的关键目标是:尽量减少动手之前需要装进脑子的知识量。解耦的另一个定义是:改一段代码不必连带改另一段,耦合越少,修改波及越小。清理阶段同样重要:做得好的话,下一个人看不出哪行代码是什么时候写的;维护设计就像园艺,不仅要种,还要除草修剪,否则“就加一个小 hack”会让漂亮的程序死于千刀万剐。(Game Programming Patterns — architecture-performance-and-games)
解耦的代价与性能的代价
-
抽象是对未来的投机:每加一层抽象或扩展点,都是赌以后会用到这份灵活性;赌错了,它就变成要维护的额外代码。过度热情会导致到处是接口、插件系统和虚函数,找一段真正干活的代码要穿过层层脚手架,抽象层本身反而塞满了脑子;还有人花多年做“引擎”,却始终没想清楚它是做什么用的引擎。对抗这种投机冲动的口号是 YAGNI(You aren't gonna need it)。(Game Programming Patterns — architecture-performance-and-games)
-
性能靠的是假设:“敌人不会超过 256 个”就能把 ID 压进一个字节;“这里只会调用一种具体类型”就能静态分派或内联;“所有实体同一个类”就能放进连续数组。而解耦恰恰是减少假设:接口、观察者、消息系统都让代码适配更多情况,代价是虚分派、指针和消息这些运行时开销。一个有趣的反例是 C++ 模板:调用具体类是在编写时固定,虚函数或接口推迟到运行时,模板元编程则介于两者之间,在编译时决定,有时能提供接口式抽象而没有运行时开销。(Game Programming Patterns — architecture-performance-and-games)
-
灵活性对做出好玩的游戏仍然必要:没人(包括 Will Wright)能在纸上设计出平衡的游戏,必须反复试验,找到机制后还要大量调优。Nystrom 的经验是“让一个好玩的游戏变快,比让一个快的游戏变好玩更容易”。一种折中是:设计稳定之前保持代码灵活,之后再拆掉部分抽象来换性能。(Game Programming Patterns — architecture-performance-and-games)
-
烂代码也有它的时间和地点:原型(只为回答一个设计问题、勉强能跑的代码)是完全正当的做法,但写了要扔的代码就必须真的能扔。作者描述过常见的坏经理套路:“只是原型”,几天后变成“花几个小时清理一下就当正式代码吧”。一个技巧是用与游戏不同的语言写原型,这样它进游戏前必须重写。(Game Programming Patterns — architecture-performance-and-games)
三股力量互相牵制:长期可理解的架构、运行时性能、今天就把功能做完。作者指出它们都是某种“速度”:长期开发速度、运行速度、短期开发速度。好架构要求每次修改都多花一点力气保持整洁;最快写完的实现很少是跑得最快的实现,优化要花大量工程时间,完成后代码会固化、难以再改;而只顾塞功能,代码库会变成一堆 hack 和 bug,拖垮以后的效率。没有正确答案,只有不同口味的错误;但作者认为这正是有趣之处,就像国际象棋因为各子完美平衡而永远无法穷尽,设计糟糕的游戏才会坍缩成一招鲜。(Game Programming Patterns — architecture-performance-and-games)
下图把 19 个模式按主要目标放在这条光谱上:越靠左越偏解耦和灵活,越靠右越偏性能。
flowchart LR
subgraph "解耦与灵活"
OBS["观察者"] --- EQ["事件队列"]
CMD["命令"] --- SL["服务定位器"]
CMP["组件"] --- TO["类型对象"]
BC["字节码"] --- SS["子类沙盒"]
PRO["原型"] --- STA["状态"]
end
subgraph "时序与结构"
GL["游戏循环 + 更新方法"] --- DB["双缓冲"]
end
subgraph "性能"
DL["数据局部性"] --- OP["对象池"]
DF["脏标记"] --- SP["空间分区"]
FLY["享元"]
end注意并没有严格的界线:组件模式本是为了解耦,却恰好让数据局部性优化更容易做(按领域切开的组件天然适合按类型连续存放);享元既省内存,又保留了面向对象的接口。
按问题选模式
| 遇到的问题 | 首选 | 备选或配合 | 主要代价 |
|---|---|---|---|
| A 要通知 B,但 A 不该知道 B | 观察者模式 | 想在时间上也解耦时用 事件队列 | 运行时才能确定的通信关系难以推理;要严格注销 |
| 把“触发动作”与“执行动作”分开,要撤销、重放、改键 | 命令模式 | 排队执行时配合 事件队列 | 命令类数量多 |
| 发送方是推、接收方是拉,节奏不同 | 事件队列 | — | 全局可见;世界状态可能已变;反馈回环 |
| 很多代码都要用某个系统(日志、音频) | 先把对象传进去 | 从基类取(子类沙盒)、挂在已有全局对象上、单例与服务定位器 | 全局访问都会带来耦合 |
| 一个类横跨输入、物理、渲染等多个领域 | 组件模式 | 性能敏感时与 数据局部性 结合(实体即 ID) | 装配与通信复杂 |
| 很多“种类”,希望设计师不改代码就能加 | 类型对象 | 数据条目间复用用原型委托(原型模式);行为也要数据化时用 字节码模式 | 很难按类型定义行为 |
| 大量派生类共享一组底层操作 | 子类沙盒 | 基类过胖时拆成 组件模式 | 脆弱基类 |
| 实体行为随内部状态变化,标志位越来越乱 | 状态模式与有限状态机 | 复杂 AI 用行为树或规划系统(书中所提);UE 中对应 StateTree | FSM 表达力有限 |
| 让游戏时间与机器速度无关;许多对象同时模拟 | 游戏循环与更新方法 | 读写要一致时用 双缓冲 | 更新顺序与列表修改的坑 |
| 每帧遍历大量对象很慢,并且分析显示是缓存未命中 | 数据局部性 | 对象池 让同类对象连续存放 | 放弃部分抽象 |
| 频繁创建和销毁同类对象,分配慢或产生碎片 | 对象池 | — | 上限与溢出策略;复用时要完整初始化 |
| 派生数据计算昂贵,而原始数据频繁变化 | 脏标记 | 能增量更新时直接维护累加值 | 漏置标记 |
| 大量相同数据重复存储或传输 | 享元模式 | 在 GPU 上就是实例化渲染 | 间接访问(要先分析) |
| 按位置查找对象太慢 | 空间分区 | 静态几何用自适应结构,动态对象用网格或四叉树 | 移动时要更新;额外内存 |
| 有个类总要全局唯一 | 重新审视 单例与服务定位器 | 用静态标志在运行时断言单实例 | 单例就是全局变量 |
Nystrom 的五条建议(Game Programming Patterns — architecture-performance-and-games)
- 抽象和解耦让程序演进更快更容易,但除非确信这段代码需要那份灵活性,否则别在上面花时间。
- 在整个开发周期里思考并设计性能,但把那些会把假设锁死在代码里的底层优化尽量往后推。
- 快速探索游戏的设计空间,但别快到留下一地烂摊子,你还得在里面住。
- 确定要扔掉的代码,就别花时间把它写漂亮(“摇滚明星砸酒店房间,是因为知道第二天就退房”)。结合前文:原型代码必须真的能扔掉;只要有可能被留下来,就得防御性地写好。
- 最重要的是:想做出好玩的东西,就享受做它的过程。
他还说,如果有什么方法能缓解上述种种约束,那就是简单:写最干净、最直接的解法,读完后让人想不出别的写法;先把数据结构和算法做对(大致按这个顺序),简单的代码更少、更容易装进脑子,也常常因为开销少而更快。但简单的代码并不更快写出来,好的解决方案不是代码的堆积,而是提炼(作者说每修订一章,它就变短一些,有的章节最终压缩了 20%)。问题通常以一堆用例的形式出现,新手会逐个用条件分支实现,遇到稍有不同的输入就崩;优雅的解法是通用的:用一小段逻辑正确覆盖一大片用例,找到它像解谜一样需要看穿零散用例背后的秩序。(Game Programming Patterns — architecture-performance-and-games)
对照 UE:引擎已经替你选好的模式
UE 作为一个成熟引擎,已经内置了其中许多模式的“官方答案”。以下对应均来自本知识库的 UE 页面(详见各模式页的“UE 中的对应”一节):
| 模式 | UE 中的对应 |
|---|---|
| 游戏循环 + 更新方法 | 引擎拥有主循环;Tick、Tick 组与 Tick 依赖(委托与 Tick 机制) |
| 观察者 | 多播委托与动态多播(同一页) |
| 组件 | Actor 与 Actor 组件(Gameplay 框架);Game Feature 用 Add Components 注入组件 |
| 组件 + 数据局部性 | MassEntity:实体即 ID、原型与内存块(MassEntity 数据导向框架) |
| 单例 / 服务定位器 | 生命周期绑定宿主的子系统(子系统) |
| 状态 | StateTree、动画蓝图状态机(行为树与 StateTree、动画蓝图与动画系统) |
| 字节码 | 蓝图编译为字节码在虚拟机执行(蓝图与 C++ 协作) |
| 类型对象 | Data Asset、Gameplay Effect 资产与 Spec(资产管理与异步加载、Gameplay 能力系统) |
| 原型 | 类默认对象 CDO 与默认值传播(UObject 与反射系统) |
| 命令 | 增强输入把输入动作与按键映射分开(增强输入) |
| 脏标记 | MarkRenderStateDirty()(UE5 渲染架构) |
| 空间分区 | 世界分区网格、复制图空间网格(世界分区、复制图) |
UE 官方在多处给出的取舍,与 Nystrom 的观点一致:能用事件或定时器就不用 Tick(轮询);蓝图适合快速迭代行为,C++ 适合性能关键的系统;同质对象数量巨大时才换到数据导向的 Mass。在 UE 里自己再实现一套这些模式之前,先确认引擎的现成机制是否已经足够。
出处
- Game Programming Patterns — architecture-performance-and-games
- Game Programming Patterns — introduction
- Game Programming Patterns — architecture-performance-and-games(全文版)
- Game Programming Patterns — introduction(全文版)
- Robert Nystrom《Game Programming Patterns》(2014) introduction 章,gameprogrammingpatterns.com/introduction.html
- Robert Nystrom《Game Programming Patterns》(2014) architecture-performance-and-games 章,gameprogrammingpatterns.com/architecture-performance-and-games.html