中EN
概念页

字节码模式

Bytecode Pattern

更新于 2026-10-13 约 11 分钟设计模式字节码虚拟机脚本数据驱动GPP

字节码模式 (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):

  1. 先把法术需要的引擎能力当作 API 来想:setHealth/setWisdom/setAgility(wizard, amount),以及只影响体验的 playSound、spawnParticles。
  2. 每个 API 对应一个枚举指令,一条指令一个字节,所以叫“字节码”;VM 就是一个循环加一个 switch。
  3. 加入栈来传参:指令从栈上弹出参数;INST_LITERAL 把字节流里紧跟的下一个字节当数值压栈。
  4. 加入读属性和算术指令(GET_HEALTH、ADD 等)后才有了组合能力,能表达规则而不只是数值。例如“把生命值增加敏捷与智慧的平均值”:在生命 45、敏捷 7、智慧 11 时,栈依次变化,最后 ADD 得到 54,SET_HEALTH 写回;最开始压入的巫师下标 0 一直待在栈底,直到最后才被用到。
  5. 沙盒是天然的:字节码只能通过少数几条外部指令接触游戏;栈的大小限制了内存;还可以统计执行的指令数,超限就中止,以限制时间。示例没有循环指令,所以也不是图灵完备的。

何时使用

当需要定义大量行为,而游戏的实现语言不合适时:语言太底层,写起来繁琐易错;编译慢或工具链问题导致迭代太慢;语言过于信任代码,而你需要沙盒化以保证行为不会搞垮游戏。注意字节码比原生代码慢,不适合引擎中性能关键的部分。(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 执行机器码,与本页"性能关键部分别用字节码"的结论一致。
  1. Game Programming Patterns — bytecode
  2. Game Programming Patterns — bytecode(全文版)
  3. Robert Nystrom《Game Programming Patterns》(2014) bytecode 章,gameprogrammingpatterns.com/bytecode.html
  4. Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
  5. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.9.2 节
  6. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.9.5.1 节

相关页面

游戏开发类型对象Type Object类型对象 (Type Object) 用“一个类的不同实例”来表示不同的逻辑类型:例如不为每种怪物写一个子类,而是一个 Monster 类加一个 Breed(种族)类,每个 Breed 实例描述一种怪物,怪物只持有指向自己 Breed 的引用。这样新增或修改种类只需要改数据,不必重新编译;代价是要自己管理这些类型对象,而且给每种类型定义不同的“行为”会变难。游戏开发子类沙盒Subclass Sandbox子类沙盒 (Subclass Sandbox) 让基类提供一组受保护的“提供的操作”和一个抽象的沙盒方法,派生类只用这些操作来实现沙盒方法。这样大量派生类(例如几十种超能力)只耦合到基类,不直接接触引擎的各个系统;代价是基类会越来越臃肿,变成脆弱基类。游戏开发 · UE5蓝图与 C++ 协作Blueprint & C++ InteropEpic 官方建议:多数项目混用蓝图 (Blueprint) 与 C++,以 C++ 作为基础、在其上构建蓝图类。C++ 适合“编程”——定义系统;蓝图适合“脚本”——在已有系统上定义具体行为。两者通过反射说明符互相调用:C++ 用 BlueprintCallable/BlueprintReadWrite 等暴露给蓝图,用 BlueprintImplementableEvent/BlueprintNativeEvent 让蓝图实现或覆盖 C++ 声明的函数。综合游戏编程模式选型:解耦与性能Choosing Game Programming Patterns本页把《Game Programming Patterns》里的 19 个模式放到同一张取舍图上:大多数模式要么为了解耦(降低改代码时需要装进脑子里的知识量),要么为了性能(利用对数据和硬件的具体假设),而这两个目标经常互相拉扯。下面先讲全书的定位和 Nystrom 的架构观,再给出按问题选模式的路线和与 UE 的对照。游戏开发游戏脚本系统Game Scripting脚本语言是主要用来让用户控制和定制软件行为的编程语言;在游戏引擎中,它是高层、相对易用、能方便访问引擎大多数常用功能的语言,程序员和非程序员都能用它开发新游戏或给现有游戏做 mod。GEA 先区分数据定义语言和运行时脚本语言,梳理语言分类与游戏脚本的典型特征(解释执行、轻量、快速迭代、方便),介绍 QuakeC、UnrealScript、Lua、Python、Pawn,列出从"脚本回调"到"脚本驱动整个游戏"的六种架构,再以 Naughty Dog 的 DCScript 为例讲虚拟机与原生函数接口,最后是对象引用、事件收发、面向对象脚本、脚本状态机和协作式多线程脚本(GEA4 卷 II 17.游戏开发 · UE5Niagara 粒子系统Niagara VFX SystemNiagara 是 UE5 的 VFX 系统,由系统 (System) → 发射器 (Emitter) → 模块 (Module) 的栈式结构组成:发射器按 Emitter Spawn/Update、Particle Spawn/Update、Event、Render 分组,模块是自上而下执行、基于 HLSL 的「读-算-写」单元。继承、动态输入、事件和数据接口 (Data Interface) 提供复用与外部数据访问;发射器可选 CPU 或 GPU 计算着色器模拟。官方 5.8 文档未找到 CPU/GPU 对比页,相关取舍按通识标注。游戏开发游戏循环与更新方法Game Loop & Update Method游戏循环 (Game Loop) 让游戏时间的推进与玩家输入和处理器速度脱钩:每一轮非阻塞地处理输入、更新状态、渲染,并跟踪真实时间来控制节奏;最常推荐的做法是“固定更新步长、可变渲染”。更新方法 (Update Method) 是它的搭档:世界维护一组对象,每个对象实现一个模拟“一帧”行为的 update(),每帧依次调用。两者和组件模式一起常被视为游戏引擎的核心。

输入关键词开始搜索

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