运行时对象模型架构
Runtime Object Model Architectures
玩法基础系统 (gameplay foundation system) 是位于"引擎与游戏分界线"附近、供构建游戏规则和动态元素的一组运行时组件,其中最复杂的是运行时对象模型:它在运行时实现世界编辑器展示给策划的抽象对象模型。GEA 把架构分为两大类:以对象为中心(每个工具侧对象对应一个或一小组类实例)和以属性为中心(对象只是一个 id,属性分散在按属性类型划分、以 id 为键的表里)。前者从单体类层次走向组合与组件,后者更省内存、更缓存友好、更易数据驱动,但难以表达属性间关系、也更难调试(GEA4 卷 II 17.1–17.2)。
玩法基础系统的组成
业界没有统一名称,GEA 称之为玩法基础系统。理论上可以与游戏无关,实际上几乎总带着类型或游戏特有的细节;"引擎与游戏的分界线"在这里是一片渐变的模糊地带,各引擎差异也在这里最大(卷 II 17.1)。主要子系统:
| 子系统 | 职责 | 本库页面 |
|---|---|---|
| 运行时对象模型 | 实现工具侧抽象对象模型 | 本页 |
| 关卡管理与流送 | 加载卸载世界内容,流送制造无缝大世界的假象 | 游戏世界的加载与流送 |
| 实时对象模型更新 | 周期性更新每个对象,各系统在这里汇合成整体 | 游戏对象的实时更新 |
| 消息与事件处理 | 对象间通信;消息常表示世界状态变化即"事件" | 事件与消息传递 |
| 脚本 | 提高生产力、快速迭代、把能力交给非程序员;文本(Python、Lua)或图形(UE 蓝图) | 游戏脚本系统 |
| 目标与游戏流程管理 | 管理目标序列/树/图、章节、区域门控,策划称之为游戏的"脊柱" | 游戏世界与游戏对象 |
运行时对象模型通常提供(卷 II 17.1):
- 动态生成与销毁(血包被捡走、爆炸出现又消散、增援从拐角冒出);有的引擎干脆禁止动态创建;
- 与底层引擎系统的连接:网格、粒子、声音、动画、碰撞、物理,确保每个对象能访问它依赖的服务;
- 对象行为的实时模拟:引擎本质上是基于代理模型的实时动态模拟,更新顺序受对象间依赖、对子系统的依赖和子系统之间的依赖约束;
- 定义新对象类型:理想是完全数据驱动,但很多引擎仍需程序员;
- 唯一对象 id:可读字符串方便但运行时有开销,整数高效但难用;GEA 认为哈希字符串 id 最好,见 字符串与唯一标识符;
- 对象查询与对象引用(指针、句柄、引用计数智能指针),见 游戏对象引用与查询;
- 有限状态机支持;
- 网络复制:对象状态通常归一台机器所有并复制到其他机器;
- 存档与持久化:需要运行时类型识别 (RTTI)、反射和抽象构造(不硬编码类名就能创建实例,反序列化时非常有用);语言不原生支持就手工添加。
两种基本架构
(卷 II 17.2)
- 以对象为中心 (object-centric):每个工具侧对象在运行时是一个类实例或一小组相互连接的实例,属性和行为封装在类里,世界就是对象的集合。
- 以属性为中心 (property-centric):每个工具侧对象只是一个唯一 id(整数、哈希字符串 id 或字符串);属性分布在许多数据表中,每种属性一张表、以对象 id 为键;属性本身通常是硬编码类的实例;对象的行为由它拥有的属性集合隐式决定,例如有 Health 属性就能受伤和死亡,有 MeshInstance 属性就能被渲染。
Hydro Thunder:用 C 实现的简单对象模型
街机游戏 Hydro Thunder(Midway 圣地亚哥)完全用 C 编写,对象类型很少:船、蓝色红色加速图标、环境动画对象(赛道边的动物等)、水面、坡道、瀑布、粒子、赛道扇区(相连的二维多边形区域)、静态几何、二维 HUD。World_t 结构管理一条赛道的内容,持有各类对象数组;静态几何是一个网格实例,水面、瀑布、粒子各有自定义结构;船和图标等动态对象都是通用结构 WorldOb_t 的实例(卷 II 17.2.1.1)。
WorldOb_t 含位置朝向、渲染网格、一组碰撞球、简单动画状态(只支持刚性层次动画)、速度质量浮力等通用数据,再加三个指针:void* 用户数据、自定义 update 函数指针、自定义 draw 函数指针。用户数据指针实现了"继承"(如 Banshee 和 Rad Hazard 两种船的加速器状态不同),两个函数指针相当于虚函数,实现了多态的行为和外观。
struct WorldOb_s {
Orient_t m_transform; /* 位置/旋转 */
Mesh3d* m_pMesh; /* 3D 网格 */
/* ... */
void* m_pUserData; /* 自定义状态 */
void (*m_pUpdate)(); /* 多态 update */
void (*m_pDraw)(); /* 多态 draw */
};
单体类层次及其问题
按分类学组织对象类型很自然,所以多数商业引擎采用类层次:根类 GameObject 提供 RTTI、序列化等,往下是 MovableObject(有位置朝向)、RenderableObject(能渲染),叶子是具体类型(GEA 以吃豆人为例)。层次开始时小而直观,但会同时变深变宽,几乎所有类都继承自同一个基类,GEA 称为单体类层次 (monolithic class hierarchy),并以 Unreal 引擎的对象类层次为典型(卷 II 17.2.1.2)。问题(卷 II 17.2.1.3):
- 难以理解、维护和修改:要理解一个类必须理解它所有的父类;改派生类里一个看似无害的虚函数可能破坏某个基类的假设,产生隐蔽 bug。
- 无法描述多维分类:层次每一级只能按一个"轴"分类(生物分类学按基因分 8 级,却无法同时按颜色分类)。
注:GEA 说生物分类学"也叫 alpha taxonomy"。alpha 分类学 (alpha taxonomy) 指发现、描述和命名物种(尤其是种一级)的工作,不是整个分级体系的别名(Wikipedia "Taxonomy (biology)" 中 "Alpha and beta taxonomy" 一节)。真实游戏的类层次常把多种分类标准揉进一棵树,或为未预料的新类型做让步。例:载具层次分陆地(汽车、卡车、摩托)和水上(游艇、气垫船、快艇),策划要一辆两栖车时就放不进去。
- 多重继承与致命菱形:用 C++ 多重继承解决两栖车,会导致对象含有基类成员的多份副本("致命菱形")。
注:多份副本只出现在普通(非虚)继承下。C++ 用虚继承 (
class LandVehicle : virtual public Vehicle) 可以让菱形顶端的基类只有一份子对象,代价是额外的间接访问和更复杂的构造规则(cppreference "Derived classes" 中 "Virtual base classes" 一节)。GEA 这里没有提到虚继承。构建可用、易懂、可维护的多重继承层次通常得不偿失,所以多数工作室禁止或严格限制多重继承。 - 混入类 (mix-in):有的团队允许受限的多重继承:一个类只能从主层次继承一个父类,但可以继承任意多个没有基类的独立混入类,例如给任何类加上生命值和死亡能力的
MHealth、让角色能携带的MCarryable。但 GEA 认为通常组合或聚合这些类比继承它们更好。 - 冒泡效应 (bubble-up effect):多个无关类想共享代码时,功能会"冒泡"到层次上方。例如最初只有木箱会漂浮,策划想让角色、纸片、载具也漂浮;不许多重继承,程序员就把浮力代码移到共同基类,再加个
m_bCanFloat区分,最终浮力(以及几乎所有其他功能)都到了根类。GEA 把 Unreal 的Actor类作为冒泡效应的典型:它包含渲染、动画、物理、世界交互、音效、网络复制、对象创建销毁、Actor 迭代和消息广播的数据与代码。
与 UE5 文档的对照:本库依据 UE 5.8 文档的 Gameplay 框架 把 Actor 描述为"组件的容器",移动、渲染、位置、视角等由 Actor 组件负责,Actor 自身负责变换、生成销毁和网络复制。GEA 的描述强调 Actor 承担了大量子系统职责;UE5 官方的说法则把多数子系统功能放在组件上。两种说法并列记录,GEA 没有说明它描述的是哪个版本的 Actor。
用组合简化层次
单体层次最常见的成因是滥用"是一个 (is-a)"关系:GUI 里让 Window 继承 Rectangle,但窗口不是矩形,而是有一个 (has-a) 定义边界的矩形,应该把 Rectangle 嵌入或用指针引用(卷 II 17.2.1.4)。
- 组合 (composition):A 直接包含 B 或持有 B 的指针/引用,并且拥有 B:创建 A 时自动创建 B,销毁 A 时销毁 B。
- 聚合 (aggregation):用指针或引用连接,但不管理对方的生命周期。
GEA 的例子:纯继承层次 GameObject → MovableObject → RenderableObject → CollidableObject → AnimatingObject → PhysicalObject 限制了设计选择(要物理就必须继承骨骼动画;要碰撞就必须可渲染),也难以扩展(加入 SkeletalObject 和 MorphTargetObject 后,要让两者都有物理就得复制 PhysicalObject 或使用多重继承)。解决办法是把功能拆成独立的、各提供单一明确服务的类,称为组件 (component) 或服务对象:每种对象只选需要的功能;各功能可独立维护、扩展、重构;组件解耦后更易理解和测试;很多组件直接对应一个引擎子系统,让子系统保持封装。
下图对应 GEA 图 17.7 到图 17.8 的重构:左边的继承链被右边以 GameObject 为"枢纽"、持有可选组件指针的结构取代。
flowchart LR
subgraph "重构前 纯继承"
G0[GameObject] --> MO[MovableObject] --> RO[RenderableObject] --> CO[CollidableObject] --> AO[AnimatingObject] --> PO[PhysicalObject]
end
subgraph "重构后 组合"
GO["GameObject 枢纽"] --> T[Transform]
GO --> MI[MeshInstance]
GO --> AC[AnimationController]
GO --> RB["RigidBody 碰撞与物理"]
end组件的创建与所有权:枢纽类通常拥有组件。最简单的做法是根类为所有可能的组件各持一个指针,构造时置空,派生类构造函数创建自己需要的组件,基类析构统一删除;这样从 GameObject 派生的类层次仍是主要分类,组件是可选附加功能。GEA 的示例代码(节选):
class GameObject {
protected:
Transform m_transform;
MeshInstance* m_pMeshInst = nullptr;
AnimationController* m_pAnimController = nullptr;
RigidBody* m_pRigidBody = nullptr;
public:
~GameObject() { delete m_pMeshInst; delete m_pAnimController; delete m_pRigidBody; }
};
class Vehicle : public GameObject {
protected:
Chassis* m_pChassis; Engine* m_pEngine;
public:
Vehicle() {
m_pMeshInst = new MeshInstance;
m_pRigidBody = new RigidBody;
m_pAnimController = new AnimationController(*m_pMeshInst); // 需要网格以提供矩阵调色板
m_pChassis = new Chassis(*this, *m_pAnimController);
m_pEngine = new Engine(*this);
}
~Vehicle() { delete m_pChassis; delete m_pEngine; }
};
原书问题:示例里
~GameObject()不是虚函数,而Vehicle定义了自己的析构函数来释放m_pChassis、m_pEngine。游戏对象通常经GameObject*管理和删除,delete pGameObject时若基类析构函数非虚,行为未定义,实际上~Vehicle()不会被调用,车辆专属组件会泄漏。基类析构函数应声明为virtual ~GameObject()(C++ 标准 [expr.delete]/3;cppreference "Destructors" 中 "Virtual destructors" 一节)。
通用组件:根类持有一个组件链表,组件派生自共同基类,可以遍历做多态操作(询问类型、逐个传递事件)。根类不必知道有哪些组件类型,新增组件通常不用改 GameObject,一个对象还能有同类组件的任意多个实例(硬编码指针只能有固定数量)。代价是游戏对象代码必须完全通用,组件也不能假设同一对象里有哪些其他组件。两种做法没有明显优劣,团队各有选择(卷 II 17.2.1.5)。GPP 的组件模式见 组件模式。
纯组件模型:把所有功能都移出根类,GameObject 只剩唯一 id 和一堆组件指针,那就干脆去掉它:每个组件保存对象 id,按 id 逻辑分组,只要能按 id 快速找到组件即可(卷 II 17.2.1.6)。问题:
- 仍要定义具体对象类型并在实例化时创建正确的组件:可以用工厂模式(每种类型一个工厂类,虚构造函数创建相应组件),或用数据驱动(文本文件定义类型);
- 组件间通信:没有了枢纽,可以按 id 查找其他组件,但最好用更高效的方式,例如预先把同一对象的组件连成环形链表;
- 对象间消息:无法再向
GameObject发消息,要么事先知道发给哪个组件,要么多播给对象的所有组件,都不理想。
GEA 的建议:纯组件模型在实际项目中可行,但也不是明显更好;除非在做研发,否则选自己最熟悉、最有信心、最适合当前游戏的架构。
以属性为中心的架构
面向对象程序员习惯"对象 → 属性"的视图;以属性为中心则反过来"属性 → 对象":为每种属性建一张表,存所有拥有该属性的对象的值,以 id 为键(卷 II 17.2.2):
| 以对象为中心 | 以属性为中心 |
|---|---|
| Object1:Position、Orientation | Position:Object1、Object2 |
| Object2:Position、Health | Orientation:Object1、Object3 |
| Object3:Orientation | Health:Object2 |
它更像关系数据库:每种属性像一张表,对象 id 是主键。《杀出重围 2》和《神偷》系列成功使用过这种模型。行为通常放在两个地方之一或两者:
- 属性类:每种属性实现为一个类,可以简单到一个布尔值或浮点数,也可以复杂到可渲染网格或 AI "大脑";对象的行为是其所有属性行为的聚合。属性之间可以通信合作:Health 检测到攻击时通知 AnimatedSkeleton 播放受击动画,快死时通知 RigidBodyDynamics 激活爆炸或布娃娃(卷 II 17.2.2.1)。
- 脚本:属性值作为原始数据存在类似数据库的表里,用脚本实现行为。例如特殊属性 ScriptId 指定管理该对象行为的脚本函数或脚本对象,脚本也可以响应世界中的事件。有些引擎由程序员提供一组核心硬编码属性类,同时允许完全用脚本定义新属性类型,《地牢围攻》就这样做(卷 II 17.2.2.2)。
属性与组件:GEA 引用的不少作者把它说的"属性对象"叫"组件"。两者都是一个逻辑对象由多个子对象组成;区别在于属性表示对象自身的某种特性(生命、外观、背包、某种魔法),而以对象为中心的组件往往表示到某个底层子系统的连接(渲染、动画、碰撞与动力学)。这个区别细微到很多时候无关紧要,叫纯组件模型还是以属性为中心都可以,结果本质相同(卷 II 17.2.2.3)。
优点(卷 II 17.2.2.4):
- 更省内存:只存实际用到的属性,不存在未使用的数据成员;
- 更易数据驱动:策划不重新编译就能定义新属性,因为没有游戏对象类定义要改;只有需要全新属性类型(且无法用脚本添加)时才需要程序员;
- 可能更缓存友好:同类型数据在内存中连续存放,访问一个元素时相邻元素也进了同一缓存行。GEA 指出在 PS3 上一次缓存未命中相当于执行数千条指令。这种布局叫数组结构 (struct of arrays, SoA),与传统的结构数组 (array of structs, AoS) 相对(见 数据局部性、C++ 内存布局与对齐):
static const U32 MAX_GAME_OBJECTS = 20480;
// 传统 AoS
struct GameObject { U32 m_uniqueId; Vector m_pos; Quaternion m_rot; float m_health; };
GameObject g_aAllGameObjects[MAX_GAME_OBJECTS];
// 缓存更友好的 SoA
struct AllGameObjects {
U32 m_aUniqueId[MAX_GAME_OBJECTS];
Vector m_aPos[MAX_GAME_OBJECTS];
Quaternion m_aRot[MAX_GAME_OBJECTS];
float m_aHealth[MAX_GAME_OBJECTS];
};
(GEA 说明实际不会这样实现对象模型,代码只示意以属性为中心会产生许多同类型连续数组。)
缺点:对象只是一袋属性时,难以强制属性之间的关系;只靠拼凑细粒度属性行为难以实现大尺度行为;难以调试,程序员不能把一个对象拖进调试器监视窗口一次看到它的全部属性。延伸阅读包括 Fermier、Bilas、Duran、Chatelaine(GDC 2002/2003)和 Doug Church 的演讲(卷 II 17.2.2.5)。
与 UE5 的对应
- Actor + Actor 组件是"以对象为中心 + 组件"的形态,见 Gameplay 框架;
- MassEntity 数据导向框架 中实体只是 ID + 片段、相同组成的实体按原型连续存放,属于 GEA 说的纯组件/以属性为中心一端,也采用了 SoA 式的数据布局(这是对比归纳);
- UObject 反射系统为 C++ 补上了 GEA 列出的 RTTI、反射和抽象构造,见 UObject 与反射系统。
出处
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.1–17.2 节