世界块数据格式与生成器
World Chunk Data Formats & Spawners
世界块中的动态部分要为每个游戏对象记录两样东西:属性初始值和对象类型。GEA 比较了三种存储方式:二进制对象映像(生成最简单,但要处理指针、虚表和字节序,且对变化很脆弱)、序列化描述(XML/JSON/私有二进制格式,配合反射与类工厂),以及生成器 (spawner) + 类型模式 (type schema):生成器是只含类型 id 和键值对的轻量数据块,彻底切断世界编辑器与运行时代码的耦合;模式里的默认值配合"与默认值相同就省略键"可以让默认值的修改自动传播到旧实例(GEA4 卷 II 17.3)。
世界块里有什么
静态部分:一张大三角网格或多块小网格(同一网格可多次实例化,例如所有门口共用一个门网格)、以三角形汤或凸形/平面/盒/胶囊/球表示的碰撞数据、检测事件或划分区域的体积、AI 导航网格、可供玩家角色攀抓的边缘线段等;GEA 说这些格式在前面章节已讨论过(卷 II 17.3)。
动态部分是游戏对象。对象的行为直接或间接由类型决定:以对象为中心时,类型直接决定实例化哪些类;以属性为中心时,行为是属性行为的合成,但类型仍决定对象有哪些属性(也可以说属性定义了类型)。所以每个对象要存:
- 属性初始值:对象第一次生成到世界中时的状态;
- 类型说明:以对象为中心时可以是字符串、哈希字符串 id 或其他唯一类型 id;以属性为中心时可以显式存储,也可以由属性集合隐式定义。
架构分类见 运行时对象模型架构。
二进制对象映像
把每个对象在运行时内存里的样子原样写入文件,加载后对象"现成"可用(卷 II 17.3.1)。问题:
- 需要特殊处理指针和虚表,可能还要做字节序交换(卷 I 7.2.2.9,见 资源交叉引用与加载后初始化);
- 不灵活、对修改不健壮,而玩法是项目中最易变的部分。
所以 GEA 认为它通常不适合存游戏对象,但适合网格、碰撞几何等更稳定的数据结构。
序列化的游戏对象描述
写出时让对象产生足以重建自身的数据流;读入时先创建相应类的实例,再读属性流初始化内部状态。它比二进制映像更可移植、更容易实现(卷 II 17.3.2)。
- 语言支持:C# 和 Java 原生提供到 XML 的标准序列化,C++ 没有标准设施,但业内外有许多成功的 C++ 序列化系统。
- 格式:XML 标准、较易读、擅长层次数据,但解析出了名地慢、会拉长加载时间,所以有引擎用更快更紧凑的私有二进制格式;很多引擎转向 JSON(GEA 举例说 Facebook API 只用 JSON 通信)。
- 实现方式:(1) 基类提供
SerializeOut()/SerializeIn()虚函数,派生类各自实现;(2) 为 C++ 类实现反射,再写通用的序列化系统。反射数据是类内容的运行时描述:类名、数据成员及其类型和在对象内存映像中的偏移,以及所有成员函数。难点是为所有相关类生成反射数据:用#define宏包裹数据成员、由派生类重写返回反射数据的虚函数、为每个类手写反射结构,或其他巧妙方法。 - 类工厂:数据流总会包含对象的类名或类 id(字符串、哈希字符串 id 等),用于读入时实例化正确的类。C++ 无法仅凭名字或 id 创建实例,类名必须在编译期硬编码(如
new ConcreteClass),所以 C++ 序列化系统总会带一个类工厂:最简单是一张数据表,把类名/id 映射到硬编码实例化该类的函数或函数对象。
UE 的对应:UE 的反射由 UHT 根据
UCLASS/UPROPERTY宏生成代码,序列化、编辑器详情面板和蓝图都建立在它之上,见 UObject 与反射系统。这属于 GEA 列举的"用宏提取反射信息"一类的实践(对比归纳)。
生成器与类型模式
二进制映像和序列化有同一个"阿喀琉斯之踵":它们都由运行时对象实现来定义,所以世界编辑器必须深入了解引擎运行时实现,要么链接运行时代码,要么精心手写与运行时布局一致的字节块;序列化耦合较松,但仍需访问 SerializeIn/Out 或反射信息(卷 II 17.3.3)。
生成器 (spawner) 用与实现无关的方式描述对象,从而切断这种耦合:它是轻量的、只有数据的对象表示,包含工具侧类型 id 和一张描述初始属性的简单键值对表,通常含模型到世界的变换。生成时按类型实例化相应的类,运行时对象再从键值字典初始化数据成员。
- 可以加载后立即生成,也可以休眠,等游戏中稍后被要求时再生成;
- 可以实现为一等对象,有函数接口,还能存元数据;
- 还能用于生成对象以外的用途:Naughty Dog 引擎里策划用位置生成器/定位器 (locator) 定义重要的点或坐标轴,用于 AI 兴趣点、让一组动画完美同步播放的参考坐标系、粒子或音效的发出位置、赛道路点等。
对象类型模式 (type schema):在基于生成器的编辑器里,对象类型可以用数据驱动的模式来表示,定义该类型在编辑器中可见的属性集合;运行时再以硬编码或数据驱动的方式把工具侧类型映射到要实例化的类(卷 II 17.3.3.1)。模式可以存为简单文本文件,GEA 的示例:
enum LightType { Ambient, Directional, Point, Spot }
type Light {
String UniqueId;
LightType Type;
Vector Pos;
Quaternion Rot;
Float Intensity : min(0.0), max(1.0);
ColorARGB DiffuseColor;
ColorARGB SpecularColor;
// ...
}
type Vehicle {
String UniqueId;
Vector Pos;
Quaternion Rot;
MeshReference Mesh;
Int NumWheels : min(2), max(4);
Float TurnRadius;
Float TopSpeed : min(0.0);
// ...
}
(注:PDF 提取文本把这段的类型列和名字列拆开了,这里按语义重排。)
要点:每个属性有名字和数据类型,类型可以是简单类型,也可以是向量、四元数、ARGB 颜色、网格/碰撞等资产引用,还可以定义枚举;模式还给编辑器提供额外信息,如用什么 GUI 控件编辑(字符串用文本框,布尔用复选框,向量用三个文本框或专门的 3D 操纵控件)、数值的最小最大值、下拉框的可选项。有的引擎允许模式像类一样继承,例如类型和唯一 id 放在顶层模式里,其他模式都从它派生。
默认属性值
模式里属性很多,每放一个实例都要填大量数据,所以在模式里定义默认值很有帮助:策划可以轻松摆放"原味"实例,需要时再对单个实例微调(卷 II 17.3.3.2)。
难点在默认值改变时:例如兽人 (Orc) 原定 20 点生命,几个月后改为默认 30 点;新放的兽人会是 30,那之前放下的呢?理想是让默认值的变化自动传播到所有没有显式覆盖该值的已有实例。简单做法:与默认值相同的属性就不写键值对,生成器缺这个键时就用默认值。这要求引擎能读到模式文件里的默认值;也可以由工具来填,这种情况下传播新默认值需要重新构建受影响的世界块。于是多数旧兽人生成器根本没有 HitPoints 键,默认值从 20 改为 30 后它们自动用新值。
有的引擎还允许派生类型覆盖默认值:Vehicle 模式的默认 TopSpeed 为 80 英里/小时,派生的 Motorcycle 模式可以改成 100。
与 UE 的对照:本库 UObject 与反射系统 依据 UE 文档记录,UProperty 序列化时等于默认值的属性不写出,新增属性取新 CDO 默认值,删除的属性被静默忽略;关卡中已放置的实例,属性仍等于旧 CDO 值的加载时会更新为新值,单独改过的保留(官方例子:敌人血量由 100 改为 150)。这与 GEA 的"与默认值相同就省略键""不认识的键忽略、缺少的键用默认值"是同一思路,CDO 扮演了类型模式中默认值的角色(对比归纳),另见 原型模式 的 UE 对应一节。
生成器与类型模式的好处
(卷 II 17.3.3.3)
- 简单:管理键值对表比管理需要指针修正的二进制映像或自定义序列化格式简单得多。
- 灵活、健壮:对象遇到不认识的键可以忽略,缺少需要的键可以用默认值,所以对策划和程序员的修改都很健壮。
- 简化编辑器:编辑器只需要会管理键值对列表和类型模式,不必与运行时引擎共享任何代码,与引擎实现只是很松的耦合(编辑器的整体设计见 数据驱动与游戏世界编辑器)。
- 解耦进度:策划几乎不需要程序员介入就能在编辑器里定义新类型模式,程序员按自己的排期再实现运行时版本;新对象数据可以没有运行时实现而安全地存在于世界块中,运行时实现也可以没有对应数据。
GEA 在这里写"生成器与原型 (archetype)",原型概念与 类型对象 和 原型模式 讨论的"用数据定义类型"相通。
出处
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.3 节