Unreal Engine (UE) 之所以强大,很大程度上归功于其构建在 C++ 之上的一套自定义对象系统。C++ 标准本身缺乏运行时反射(Reflection)和自动内存管理(GC),因此 Epic Games 自己实现了一套名为 Unreal Object Handling 的系统。
这套系统的核心由 Unreal Header Tool (UHT)、UObject、反射系统 和 序列化系统 组成。
一、 核心机制:Unreal Header Tool (UHT)
在深入具体功能之前,必须理解 UE 是如何“扩展”C++ 的。
实现原理:
- 宏标记:开发者在头文件(.h)中使用特定的宏,如
UCLASS(),UPROPERTY(),UFUNCTION(),GENERATED_BODY()。 - 预处理:在标准 C++ 编译器(如 MSVC, Clang)运行之前,UHT 会先解析项目中的头文件。
- 代码生成:UHT 识别出这些宏,并生成对应的
.gen.cpp和.generated.h文件。这些文件包含了注册类型、获取偏移量、构建反射数据所需的“胶水代码”。 - 编译:最后,C++ 编译器将用户写的代码和 UHT 生成的代码一起编译。
二、 类型系统 (Type System) & 反射 (Reflection)
UE 的反射系统允许在运行时查询类型信息(比如“这个对象有哪些变量?”“这个函数叫什么名字?”)。这是蓝图(Blueprints)、网络复制、编辑器属性面板和垃圾回收的基础。
1. 基础能力
- **运行时类型识别 (RTTI)**:比 C++ 标准的 RTTI 更强大,支持
Cast<T>(),IsA()等。 - 动态调用:可以通过字符串名字调用函数(
ProcessEvent)。 - 属性访问:可以通过字符串名字读写变量。
- 蓝图集成:将 C++ 暴露给脚本环境。
2. 实现方式
A. 类型层级 (UClass, UScriptStruct, UEnum)
所有参与反射的类都继承自 UObject。每个类在运行时都有一个对应的 UClass 对象(元类),它描述了这个类的结构。
- **
UClass**:存储了类的名字、父类、函数列表(UFunction)、属性列表(FProperty)以及 CDO(类默认对象)。
B. 属性描述 (FProperty)
在 UE4.25 之前叫 UProperty,后优化为 FProperty(非 UObject,轻量级)。
- 每个被
UPROPERTY()标记的变量,UHT 都会生成一个对应的FProperty实例(如FIntProperty,FObjectProperty)。 - 实现关键 - 内存偏移 (Offset):反射系统并不魔幻,它访问变量的核心原理是指针 + 偏移量。UHT 会计算出某个变量相对于对象
this指针的内存偏移量。1
2// 伪代码概念
void* ValueAddress = (uint8*)MyObjectInstance + Property->Offset_Internal;
C. 注册机制 (StaticClass)GENERATED_BODY() 宏会注入一个静态函数 StaticClass()。在引擎启动时(Main 函数之前),通过 C++ 的静态初始化机制,系统会构建出所有的 UClass 树,并将生成的 FProperty 链表挂载到对应的 UClass 上。
D. CDO (Class Default Object)
每个 UClass 在内存中都维护一个**类默认对象 (CDO)**。这是一个完全初始化好的该类的实例。
- 作用:作为模板。当
SpawnActor或NewObject时,系统会通过memcpy或序列化 CDO 来快速初始化新对象,而不是从零构造。
三、 序列化 (Serialization)
序列化是将对象的状态转换为字节流(用于存储到磁盘或网络传输),反之亦然。UE 的序列化高度依赖反射系统。
1. 基础能力
- Asset 存储:将资源保存为
.uasset/.umap。 - SaveGame:保存游戏进度。
- 网络复制:将变量同步到客户端。
- 版本控制:支持向后兼容(加载旧版本的资源)。
2. 实现方式
A. FArchiveFArchive 是核心基类,重载了 << 操作符。它既可以代表读取(Loading),也可以代表写入(Saving)。
1 | // 典型的序列化代码 |
B. 结构化序列化 (Tagged Property Serialization)
这是 .uasset 的主要存储方式。UE 不只是简单地转储二进制块,而是基于反射进行保存。
- 流程:
- 遍历对象的所有
UPROPERTY。 - 对比当前值与 CDO(默认值)是否一致。
- 只保存与默认值不同的变量(Delta Serialization)。这极大地节省了空间。
- 保存格式为:
[属性名] [类型] [大小] [值]。
- 遍历对象的所有
- 优势:因为保存了属性名,即使你后来在 C++ 中删除了中间某个变量,或者打乱了变量顺序,加载时依然能通过名字匹配正确恢复数据,不会导致错位。
C. 链接器 (LinkerLoad / LinkerSave)
对于复杂的资源加载(如加载一个引用了贴图、材质、声音的关卡),UE 使用 Linker 表。
- Import Table:记录此包依赖的外部资源。
- Export Table:记录此包内部定义的资源。
- 加载时,Linker 会先加载依赖项(Import),然后通过反射构建对象并反序列化数据。
四、 垃圾回收 (Garbage Collection)
UE 使用追踪式 GC,而不是 C++ 的 RAII 或智能指针(尽管现在也有 TSharedPtr,但 UObject 依然归 GC 管)。
1. 基础能力
- 自动管理
UObject生命周期。 - 处理循环引用。
- 防止野指针(通过
IsValid或TWeakObjectPtr)。
2. 实现方式
A. 标记-清除 (Mark-and-Sweep)
- **Root Set (根集合)**:GC 从一组根对象开始(例如被添加到 Root 的对象、当前关卡、玩家控制器)。
- **Reachability Analysis (可达性分析)**:
- 利用反射系统。GC 遍历根对象的
UPROPERTY(必须是UObject*类型),找到引用的子对象。 - 递归重复此过程,标记所有“可达”的对象。
- 利用反射系统。GC 遍历根对象的
- Sweep (清除):遍历全局
UObject列表(GUObjectArray),销毁那些未被标记的对象(即不可达对象)。
B. 增量与集群 (Incremental & Clustering)
为了避免 GC 导致游戏卡顿(Stop-the-world):
- 增量 GC:将标记过程分摊到多帧进行。
- GC Cluster:将粒子系统或材质实例等大量生命周期绑定的小对象视为一个簇,要么一起活,要么一起死,减少遍历开销。
总结:数据流向图
为了把这些串起来,想象创建并保存一个 Actor 的过程:
- 编写代码:你写了
AMyActor,里面有个UPROPERTY() int32 Health;。 - 编译时:UHT 分析代码,生成反射数据(记录
Health叫什么,偏移量是多少,类型是 Int)。 - 运行时启动:引擎利用反射数据构建
UClass,并实例化一个CDO。 - 生成对象:
SpawnActor复制 CDO 的内存来创建MyActorInstance。 - 修改属性:你在蓝图中修改
Health为 100。蓝图虚拟机通过反射找到Health的内存地址并写入值。 - 垃圾回收:GC 扫描发现这个 Actor 被关卡引用(通过反射指针),标记为存活。
- 保存游戏:
- 创建
FArchive。 - 序列化系统遍历
MyActorInstance的所有属性。 - 发现
Health(100) 与 CDO 的Health(默认值) 不同。 - 将
"Health": 100写入磁盘。
- 创建
这就是 UE 能够实现如此高度动态化、既像 C++ 又像脚本语言特性的底层奥秘。