字节码模式
Bytecode Pattern
字节码模式 (Bytecode Pattern) 把行为编码成虚拟机 (VM) 能执行的指令序列,让行为拥有数据一样的灵活性:可以热加载、与引擎核心隔离(沙盒化),还可以由非程序员编写。它是全书最复杂的模式,代价是要写编译器或创作工具,调试器也用不上了,而且这类小语言容易无节制地膨胀。
意图
把行为编码为虚拟机指令,使行为获得数据的灵活性。(Game Programming Patterns — bytecode)
书中的动机是法术系统:法术要能被设计师频繁修改、快速重载,要和核心游戏安全地隔离,还要在物理上与可执行文件分开。这些要求听起来就像数据,只要能让引擎“执行”数据即可。此外还要支持发售后更新内容和玩家模组 (modding):写死在代码里的法术意味着打补丁要改可执行文件,模组作者需要完整编译工具链,而他们的 bug 可能让别的玩家崩溃。(Game Programming Patterns — bytecode)
机制
指令集定义可以执行的底层操作;一串指令编码为字节序列;虚拟机逐条执行这些指令,并用一个栈保存中间值;组合这些指令就能表达复杂的高层行为。(Game Programming Patterns — bytecode)
与 GoF 解释器 (Interpreter) 模式的区别:解释器把表达式表示为对象树,并递归地对树求值;字节码把同样的东西压平成线性的紧凑指令序列,执行起来更快、更省内存。解释器模式的问题在于代码变成一棵由小对象组成的“分形树”:加载时要创建并连接大量小对象;对象和指针很占内存(书中说那个小小的算术表达式在 32 位机器上至少占 68 字节);沿指针遍历子表达式伤害数据缓存,大量虚调用伤害指令缓存。机器码则致密、线性、底层、快,但让用户提供机器码等于自找安全问题(许多主机和 iOS 不允许执行运行时加载或生成的机器码,也就禁止了 JIT)。字节码就是二者之间的折中:自己定义“虚拟机器码”,再写一个小模拟器去执行它。两者也常一起出现:生成字节码的工具内部有一棵语法树(这正是解释器模式需要的结构),编译就是递归遍历这棵树,只不过不是立即执行,而是输出指令。(Game Programming Patterns — bytecode)
下图表示完整的数据流:作者在高层工具里编写行为,工具编译成字节码,游戏运行时由虚拟机逐条执行,并通过“外部原语”调用引擎功能。
flowchart LR A["创作端:文本语言或图形化工具"] --> C["编译器 / 生成工具"] C --> B["字节码:一串字节指令"] B --> VM["虚拟机:取指 → 解码 → 执行,栈保存中间值"] VM -->|"外部原语"| E["引擎:播放音效、改属性、生成粒子"]
图中真正进入游戏的只有中间的字节码和右侧的虚拟机;左侧的创作工具不可省略,它决定了这个模式能不能用。
书中示例的构建步骤(Game Programming Patterns — bytecode):
- 先把法术需要的引擎能力当作 API 来想:
setHealth/setWisdom/setAgility(wizard, amount),以及只影响体验的playSound、spawnParticles。 - 每个 API 对应一个枚举指令,一条指令一个字节,所以叫“字节码”;VM 就是一个循环加一个
switch。 - 加入栈来传参:指令从栈上弹出参数;
INST_LITERAL把字节流里紧跟的下一个字节当数值压栈。 - 加入读属性和算术指令(
GET_HEALTH、ADD等)后才有了组合能力,能表达规则而不只是数值。例如“把生命值增加敏捷与智慧的平均值”:在生命 45、敏捷 7、智慧 11 时,栈依次变化,最后ADD得到 54,SET_HEALTH写回;最开始压入的巫师下标 0 一直待在栈底,直到最后才被用到。 - 沙盒是天然的:字节码只能通过少数几条外部指令接触游戏;栈的大小限制了内存;还可以统计执行的指令数,超限就中止,以限制时间。示例没有循环指令,所以也不是图灵完备的。
何时使用
当需要定义大量行为,而游戏的实现语言不合适时:语言太底层,写起来繁琐易错;编译慢或工具链问题导致迭代太慢;语言过于信任代码,而你需要沙盒化以保证行为不会搞垮游戏。注意字节码比原生代码慢,不适合引擎中性能关键的部分。(Game Programming Patterns — bytecode)
取舍与坑
- 会疯长:每个人都说“放心,它会很小”,然后不断加功能,直到变成一门结构像棚户区的完整语言。要做完整语言就有意识地去做,否则就给它拴上短绳。(Game Programming Patterns — bytecode)
- 需要前端:没人会直接写二进制字节码,必须有生成字节码的工具,也就是编译器。没有资源做创作工具,就不要用这个模式。工具不一定是文本编译器:作者更推荐给非技术用户做图形化工具,界面可以主动禁用按钮、提供默认值,让用户根本造不出“非法”程序(作者为《Henry Hatsworth in the Puzzling Adventure》写的脚本系统就是这样)。他还强调,要接纳用户会犯错,用撤销之类的功能优雅地处理错误。(Game Programming Patterns — bytecode)
- 会怀念调试器:在调试器里单步只能看到 VM 自己在做什么,看不到字节码在做什么,更映射不回源码。内容规模大了以后,要投入时间做调试辅助功能。(Game Programming Patterns — bytecode)
设计决策(Game Programming Patterns — bytecode)
| 决策 | 选项 | 要点 |
|---|---|---|
| 指令如何访问栈 | 基于栈 | 指令小(常为一字节),代码生成简单,但指令数量多。作者推荐 |
| 基于寄存器(如 Lua) | 指令大(Lua 一条指令 32 位,6 位操作码),条数少;是否更快取决于很多细节 | |
| 指令种类 | 外部原语 / 内部原语 / 控制流(跳转)/ 抽象(调用与返回栈) | 外部原语决定字节码能表达的“真实行为” |
| 值的表示 | 单一类型 | 最简单,能用就用 |
| 带标签的变体 (tagged union) | 值知道自己的类型,多占一点内存;几乎所有语言解释器都这样做,是作者的次选推荐 | |
| 无标签联合 | 紧凑、快,但不安全,坏字节码可能把数字当指针用 | |
| 接口 / 多态对象 | 开放、面向对象,但啰嗦且低效(小值也要堆分配和虚调用) | |
| 如何生成字节码 | 文本语言 | 要设计语法(语法设计就是 UI 设计,很难),写解析器(不难),处理语法错误(难);非技术用户可能望而却步 |
| 图形化创作工具 | 要做 UI,但用户逐步构建,错误更少;跨平台更难 |
代码草图(C++)
按书中示例整理(书中各 case 分散在多个片段里,这里合到一个 interpret() 中):
enum Instruction {
INST_SET_HEALTH = 0x00,
INST_SET_WISDOM = 0x01,
INST_SET_AGILITY = 0x02,
INST_PLAY_SOUND = 0x03,
INST_SPAWN_PARTICLES = 0x04,
INST_LITERAL,
INST_GET_HEALTH,
INST_GET_WISDOM,
INST_GET_AGILITY,
INST_ADD
};
class VM {
public:
VM() : stackSize_(0) {}
void interpret(char bytecode[], int size) {
for (int i = 0; i < size; i++) {
char instruction = bytecode[i];
switch (instruction) {
case INST_SET_HEALTH: { // 外部原语:从栈上取参数
int amount = pop();
int wizard = pop();
setHealth(wizard, amount);
break;
}
case INST_PLAY_SOUND: playSound(pop()); break;
case INST_SPAWN_PARTICLES: spawnParticles(pop()); break;
case INST_LITERAL: { // 内部原语:下一个字节就是数值
int value = bytecode[++i];
push(value);
break;
}
case INST_GET_HEALTH: {
int wizard = pop();
push(getHealth(wizard));
break;
}
case INST_ADD: {
int b = pop();
int a = pop();
push(a + b);
break;
}
// INST_SET_WISDOM、INST_GET_AGILITY 等同理...
}
}
}
private:
void push(int value) {
assert(stackSize_ < MAX_STACK); // 检查栈溢出
stack_[stackSize_++] = value;
}
int pop() {
assert(stackSize_ > 0); // 栈不能为空
return stack_[--stackSize_];
}
static const int MAX_STACK = 128;
int stackSize_;
int stack_[MAX_STACK];
};
UE 中的对应
- 蓝图 → 蓝图与 C++ 协作:该页的对比表写明,蓝图“编译为字节码在虚拟机执行,有额外开销”,C++ 则编译为机器码。Epic 官方建议 C++ 做“编程”(定义系统),蓝图做“脚本”(在已有系统上定义具体行为),与本模式“性能关键部分别用字节码”“用它给非程序员快速迭代行为”的定位一致。蓝图编辑器正是“图形化创作工具”这一选项。Nystrom 在“延伸阅读”里提到的 Kismet,就是 Unreal 编辑器 UnrealEd 内置的图形化脚本工具。(Game Programming Patterns — bytecode)
- Niagara 的 CPU 虚拟机 → Niagara 粒子系统:Niagara 在 CPU 上用一个模仿 GPU SIMD 的虚拟机运行脚本,因此同一套脚本默认能在 CPU 和 GPU 上跑;每个发射器都有固定开销。这同样体现了“行为作为数据交给 VM 执行”的取舍。
GEA 的视角:一个真实的脚本虚拟机(卷 II 17.9.2、17.9.5.1)
GEA 第 17 章以 Naughty Dog 的 DCScript(Scheme 的变体,由数据编译器 DC 编译成字节码)为例,展示了生产环境中的字节码虚拟机,可与本页的玩具 VM 对照,完整内容见 游戏脚本系统:
- 寄存器机而非栈机:DCScript VM 有一组用 Variant 实现的寄存器;调用栈"字面上就是寄存器组的栈",每个被调函数有自己私有的寄存器组,因此调用前后不必保存恢复寄存器。本页的 VM 采用栈式设计,两者是"基于栈还是基于寄存器"这一设计决策的两个选项。
- 按名字查找:script lambda 有全局唯一名字,调用指令按名字(字符串 id)查找被调字节码,压入新栈帧;返回时弹出栈帧,返回"地址"只是调用方中下一条指令的索引。
- 原生函数表:调用原生函数时按名字查一张硬编码的全局表,参数和返回值一律是 Variant,原生函数必须自己校验参数个数和类型(Naughty Dog 写了参数迭代器)。这对应本页"VM 只能通过有限的指令访问引擎"的沙盒思想。
- 为什么游戏用解释型脚本:平台无关的字节码可以像资产一样载入内存,不需要操作系统帮忙加载 DLL/PRX;VM 让引擎能灵活控制脚本何时运行;改脚本的周转比改原生代码快得多。代价是 VM 执行字节码通常远慢于 CPU 执行机器码,与本页"性能关键部分别用字节码"的结论一致。
出处
- Game Programming Patterns — bytecode
- Game Programming Patterns — bytecode(全文版)
- Robert Nystrom《Game Programming Patterns》(2014) bytecode 章,gameprogrammingpatterns.com/bytecode.html
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.9.2 节
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.9.5.1 节