本文并非一篇“教你如何用虚幻引擎写游戏”的教程,而是旨在探讨该引擎最初为何采用这样的结构设计。如果你真正理解了下文所述的原理,那么即便面对尚未学习过的引擎子系统,你也能凭此推演出其架构,而无需每次都从头死记硬背。
大家好,我使用虚幻引擎C++已经有一段时间了,期间一直困扰于同一个问题:大多数教程只会告诉你如何使用某个功能,却从不解释引擎为什么要那样设计。
为了弄清楚UObject、反射(Reflection)、垃圾回收(Garbage Collection)背后的设计逻辑,以及为什么虚幻引擎属于框架(Framework)而非库(Library),我专门写了这篇深入剖析的文章。
01. 它是什么?
虚幻引擎并非单一的个体,而是一套层次分明的复合型工程解决方案,每一层都对应解决一个不同的问题域:
● 引擎(Engine) —— 引擎本身作为一个程序及一套现成的基础设施;
● 框架(Framework) —— 一种执行模型,你的代码被嵌入其中运行;
● UObject —— 在C++与编辑器之间共享的对象模型;
● 反射(Reflection) —— 引擎在运行时“窥见”类结构布局的能力;
● 垃圾回收(Garbage Collector) —— 对象的自动化生命周期管理。
本文旨在说明,这五个部分并非随意堆砌的功能集合,而是一条完整的因果链:每一个后续的解决方案之所以存在,都是因为前一个方案制造了需要被解决的新问题。只要你有意识地将这条因果链走一遍,你收获的就不再是五个需要死记的知识点,而是一种可以迁移到任何其他引擎子系统上的思维方式。
02. 它为何存在?
问题背景:1997年,一个正在制作射击游戏的工作室
设想一个团队在1997年坐下来开发一款3D射击游戏。仅仅为了让游戏能够启动并响应任何操作,他们就不得不从头编写大量底层代码:在特定厂商显卡之上实现3D图形渲染、从磁盘文件加载3D模型和纹理的加载器、键盘鼠标输入处理、碰撞物理和重力、声音引擎、带延迟补偿的网络多人代码、一个关卡编辑器(让策划能用鼠标放置对象而非手写代码),以及用于游戏状态的存盘/读盘系统。
以上这八项工作,没有哪一项与你正在制作的究竟是哪款游戏有关——它们纯粹是基础设施,没有它们游戏根本无法运行,但它们本身并非游戏玩法。
问题究竟出在哪里?
团队将绝大部分时间花在了重新发明渲染器、资源加载器和编辑器上——也就是那些无法区分不同游戏的内容——而只有很小一部分时间留给了玩家真正能看见和感受到的东西:武器平衡、关卡设计、动画节奏、射击手感。
根源:基础设施无法在项目之间自动迁移
问题并不在于编写基础设施有多难(尽管确实很难)——问题在于,如何编写这些基础设施的知识无法以结构化的方式在不同项目之间传递。如果一个工作室没有共享的引擎,那么下一款游戏几乎又要从零开始:即便是同一个程序员为第一款游戏写了渲染器,再为第二款游戏写渲染器时,他们还是要从头再写一遍,因为不存在一份可共享、可复用的代码——只有存在某个人头脑中的经验,而这种经验既难以传递给团队,也无法为新的任务快速调整适配。
首次尝试的解决方案——购买各自独立的第三方库
自然而言的下一步不是什么都自己写,而是采用现成的第三方解决方案:一个第三方渲染器、一个像早期PhysX那样的第三方物理引擎、一个第三方音频SDK——然后自己写代码分别调用每个库。
为什么这没能彻底解决问题?
每一个这样的库都不过是一套供你调用的函数集合。库对你的游戏对象一无所知:它不知道什么是"Actor",不知道玩家刚刚进入了关卡或是捡起了某个物品。将多个独立库粘合在一起的工作——包括共享的游戏对象模型、一个能同时操作所有系统的编辑器、状态保存系统、以及通过网络同步这一切——你还是得自己再写一遍。而这一粘合工作最终成为了整个开发中最困难的部分,远非最简单的那块,因为现在你不得不去协调那些原本并非为彼此而设计的API。
03. 它解决了什么问题?
Epic针对这一问题的答案,并非提供一个“更便捷的库”,而是彻底改变了你的代码与基础设施之间的交互模式。你不再需要调用一组各自独立的库并充当中整个项目的集成中心——相反,引擎本身成为这个中心:它掌控主程序循环、掌控共享对象模型、掌控编辑器、掌控存盘/读盘系统,而你的代码则变成一组扩展类,在引擎预定的、可预测的时机点被调用。
这种方式解决最初的问题,靠的不是“更快地编写基础设施”,而是让绝大部分基础设施根本无需再写第二遍:Epic写了一次,此后在引擎上的每一个项目中都被复用,因此团队真正把时间花在了那些能让自己的游戏区别于其他游戏的独特内容上。
04. 什么是引擎,以及为什么这还不足以回答"虚幻是什么"
"引擎"(Engine)这个词本身描述的只是整幅图景中的一部分:它是一系列技术子系统的集合,这些子系统共同将数据(模型、纹理、声音、代码)转化为一个可运行的交互式模拟环境——包括渲染器、物理系统、音频系统、网络系统和资源系统。
仅此一项已经是巨大的工程投入,但如果虚幻引擎"仅仅"是一套通过API暴露出来的这样的子系统,那么它本质上仍然是第02节中描述的那些各自独立的库——只不过是由一家公司统一编写,而非从不同来源拼凑而来。将一个技术子系统的集合,转变为一个开发团队能够高效协作的整体系统,关键并不在于渲染或物理本身,而在于谁控制谁。这个问题的答案,正是由下一个概念——框架(Framework)——来回答的。
05. 什么是框架——以及为什么虚幻引擎是框架,而非库
库(Library)与框架(Framework)之间的区别,不在于规模大小或功能多寡,而在于谁掌控主程序循环、谁决定何时调用你的代码。
当你使用一个常规库编写程序时(例如,一个用于读取PNG文件的库),流程是这样的:你拥有自己的main()函数,你编写自己的程序循环,然后在某个时刻,你主动调用库函数——比如“请为我读取这个文件”。控制权始终在你手中:你决定什么函数在什么时机被调用、以什么顺序调用,以及如何处理返回结果。
而当你在虚幻引擎内部编写一个类时,情况恰恰相反。你没有main()函数——程序入口点归属于引擎,不由你编写。你编写的是一个派生类(例如AActor的子类),在其中实现某些具有约定名称的函数——BeginPlay、Tick、EndPlay——而引擎自己决定在什么时机精确地调用它们。你的函数不再是入口点,而是他人预制生命周期中的“挂钩点”,由引擎在恰当的时机触发。
这被称为控制反转(Inversion of Control,IoC):控制权不再属于你的代码,而是属于框架;你的代码只是一组扩展点,由框架按自身的调度机制来调用。
常规使用库的程序(控制权在你手中):
● 你决定了什么函数在什么时候被调用。
● PNG库根本不知道你的循环是否存在——你只在需要的时候自己调用 LoadPNG()。
在虚幻引擎内部编写的类(控制权归属于框架):
main() 归属于引擎,你并没有自己的 main()。引擎自身来决定:何时调用 BeginPlay(在Actor被完整放置到世界中且其所有组件完成初始化之后)、每秒调用多少次 Tick,以及在你的代码执行完毕之后该做什么——更新渲染器、通过网络发送复制数据、处理延迟到帧末执行的任务。
你的函数是一个扩展点(extension point),而非入口点(entry point)。
为什么这是根本性的差异,而非"只是代码风格不同"
控制反转并非Epic的美学偏好,而是让共享代码(引擎本身)能够在具有不同游戏逻辑的各项目之间复用的唯一途径。如果引擎不掌控控制权,那么每个项目都得自行解决渲染、物理、网络以及游戏逻辑的初始化顺序问题——而该顺序中的任何差错(例如,在关卡尚未加载完成时就试图访问它)都会导致游戏崩溃。通过掌控整个主循环,引擎为建立在它之上的任何项目保证了完全一致的正确初始化顺序,而你的责任则缩小到只需填充那些已经安全的、特定的扩展点即可。
06. 为什么选择纯C++作为基础——以及为什么仅有它还不够
限制一:帧预算(Frame Budget)
一款实时游戏必须在一个单帧之内重新计算物理、AI、动画、渲染和游戏逻辑。在每秒60帧的条件下,以上所有内容的合计处理时间只有16.6毫秒。那些通过垃圾回收器进行自动内存管理的语言(早期Java的经典模型,以及大多数托管运行时的实现),其GC可能在任意时刻不可预测地“暂停”并扫描整个堆——而这一暂停恰恰发生在回收器认为合适的时候,而非你希望它发生的时候。在帧预算本就捉襟见肘的情况下,这是不可接受的:在战斗场景中途发生一次这样的暂停,就会造成肉眼可见的帧率卡顿(即所谓的微卡顿,micro-stutter)。C++提供了对对象生命周期和内存布局的直接控制,这是逐帧稳定达成预算目标的必要条件。
限制二:数千个互相关联的对象,且不仅由程序员管理
但原始的、手动管理内存的C++也并非完整方案。
在一个拥有数千个Actor、彼此持有指针的项目中——一个Actor持有指向其目标的指针,一个组件持有指向其所有者的指针,一个AI持有指向最后发现之敌人的指针——如果完全靠手动管理(对每个游戏对象手动调用new/delete),那么释放后使用(use-after-free)的bug将成为持续不断的隐患。而问题更加复杂的是,在虚幻引擎中,对象的创建和销毁不仅来自你的代码,还来自编辑器、关卡加载系统,以及玩家断线时的网络代码——这意味着,作为某个特定类的编写者,你实际上无法穷举追踪所有可能指向你对象的指针来源。
从上述两个限制中得出的结论同时指向:
我们需要一门能手动控制执行时机的语言(即C++,而非带有不可预测GC的托管语言),但同时我们也需要针对那些被众多不同、不相关的引擎系统所引用的对象,提供一种免受use-after-free问题困扰的保护机制(即某种形式的自动生命周期管理,但仅针对这一特定类别的对象,而非程序中的所有内存)。
这一限制直接催生了本文后续所述的一切:虚幻引擎并非在“快速但危险的C++”和“安全但会暂停的托管语言”之间做选择——它构建了第三种方案:以高性能C++为核心,并在此基础上针对某一特定类别的对象叠加一层轻量且可预测的垃圾回收机制。
能解释很多问题的历史背景
虚幻引擎中的反射(Reflection)和垃圾回收器(GC)并非现代引擎版本的发明。该引擎的早期版本(Unreal,1998年,以及随后的虚幻引擎2和3)使用了一门独立的语言来编写游戏逻辑——UnrealScript:一种解释型语言,从一开始就内置了垃圾回收和反射功能,而底层引擎核心(渲染器、物理系统)则用C++编写。
虚幻引擎4(2014)移除了UnrealScript这一独立语言,将其职责转移到了“真正的”C++上,并通过Unreal Header Tool(UHT) 进行代码生成来增强其能力——但保留了相同的核心理念:游戏对象必须是可管理的、可反射的、便于快速编写的,即便引擎核心仍然保持底层化且不带有这些开销。换言之,现代虚幻引擎C++中的反射和GC,并非在“普通”C++之上叠加的新创意,而是将一套已有近二十年历史、久经考验的架构,迁移到一门更新、更高性能的实现语言上。
07. 为什么UObject会出现
上一节所述的局限性界定了问题的范围:我们需要一个独立的对象类别,让引擎为其承担自动生命周期管理、编辑器集成以及序列化等职责——但与此同时,引擎的绝大部分代码(数学库、容器、底层系统)仍然保持为普通的、"裸"的C++,无需承担这些额外开销。
Epic的解决方案是引入一条显式的边界:任何需要这些能力的类都必须继承自一个公共基类——UObject——而这一继承行为本身,就是向引擎其余基础设施(垃圾回收器、编辑器、序列化系统)发出的信号,表明该特定对象应按照特殊规则来处理。不满足这些要求的普通C++类(例如像向量这样的简单数学结构)则被排除在这个系统之外——它们无需为不需要的能力付出代价。
本节核心要点
UObject并非“虚幻引擎中一切事物的基类”。它是一条刻意划定的边界:边界的一侧是由引擎基础设施所管理的对象(生命周期、GC、编辑器、网络、序列化),另一侧则是没有这些附加机制的普通C++。是否继承自UObject,是一个具有实际成本代价的架构决策,而非每个新类的自动默认选择。
关于UObject内部机制的详细拆解——构造函数、类默认对象(CDO)、UObject与AActor之间的区别——由于主题体量较大,值得单独成文详细阐述,故不在此展开。此处只需牢固记住一点:UObject是对特定工程约束条件的回应,而非随意的架构设计选择。
08. 为什么反射会出现
UObject作为边界的引入回答了“哪些对象是特殊的”这一问题,但并未回答下一个同样重要的问题:引擎基础设施——垃圾回收器、编辑器、存档系统——如果它本身是在你编写自己的类之前很久就已经被写好的一次性代码,那么它究竟如何得知你的特定UObject派生类中存在哪些具体字段?
普通C++在编译之后几乎会擦除所有关于类型结构的信息(这种现象称为类型擦除,type erasure)——在编译后的二进制文件中,并不存在像“类ASwordActor在内存的某某偏移处有一个名为BaseDamage的float字段”这样的、可在运行时访问的内置列表。编译器在构建时确实精确地知道这些信息,但并不会将这些信息保留给程序在运行时使用。
对于编写游戏代码来说,这通常已经足够——你在写代码时就知道自己字段的名称。但对于编写一个通用的编辑器来说,它必须能够为任何程序员在此引擎上编写的任何类(包括在该编辑器版本发布多年之后才编写的类)显示Details面板——这一限制是致命的。编辑器在物理上就不可能被写成“预先知道”所有未来项目中的所有未来类。
解决方案——在主编译之前,对你的类进行代码生成
Epic解决此问题的方式并非修改C++语言本身,而是在主编译之前增加一个额外的构建步骤:Unreal Header Tool(UHT)——一个独立的工具程序,它会扫描你的头文件(.h),找到其中的特殊宏(UCLASS、UPROPERTY、UFUNCTION),并生成额外的C++代码——即一个.generated.h文件——该文件负责将你的类结构注册到引擎的运行时系统中:包括字段列表、字段类型、内存偏移量、函数列表等。
这段生成的代码,就是虚幻引擎中的反射(Reflection)——即关于类结构的、可在运行时访问的信息,由你的源代码在普通C++编译器看到文件之前就自动合成出来了。
普通C++项目的编译流程:
虚幻引擎项目的编译流程:
为什么选择宏,而非其他方式
宏(UCLASS()、UPROPERTY()、GENERATED_BODY())并非风格上的偏好,而是Epic在普通、有效的C++文件中直接标记所需类和字段的唯一可行方式,从而做到:(a)一个独立的程序(UHT)可以在完全编译之前通过对头文件进行简单的文本解析来找到这些标记,同时(b)该文件本身仍然可以被普通的C++编译器编译——预处理阶段会将那些宏展开为空,或展开为服务代码。
另一种方案——使用独立的接口描述语言(IDL)文件——会强制开发者同时维护两个需要同步的真实来源(C++类及其IDL描述),而非只维护一个。宏的方式允许将全部描述信息放在同一个地方——即紧挨着它所属的字段或类声明之上。
引擎源码内部一瞥:编译期间UPROPERTY()到底发生了什么
在UObject/ObjectMacros.h中,这些宏的定义看起来出奇地简单:UPROPERTY(...)和UFUNCTION(...)实际上展开后什么都没有——#define UPROPERTY(...)右侧没有任何符号。引擎开发者在其正上方的注释中写道:“这些宏用于包裹供Unreal Header Tool解析的元数据,当包含它们的代码被C++编译器编译时,这些宏会被忽略。”
而UCLASS()和GENERATED_BODY()则并非展开为空,而是展开为由文件名和行号拼接而成的标记,在编译时由UHT在.generated.h中生成的代码来替代该标记。
另外还有一个细节,经常让那些从旧资料中学习引擎的人感到意外:在当前引擎版本中,UHT本身已不再像UE4中那样是一个C++程序,而是一个C#工具链(在源码树中位于Engine/Source/Programs/Shared/EpicGames.UHT,与Unreal Build Tool放在一起)。这并不影响上述描述的整体模型——“先由UHT读取宏,再由普通编译器构建最终结果”——只是该工具自身的实现语言发生了变化。
09. 为什么需要垃圾回收器
反射解决了“引擎能在运行时获知你的类结构”这一问题——而恰恰是这一能力,正好成为解决第06节中所述安全内存管理问题的缺失拼图:既然反射系统已经能够找到你类内部所有指向其他UObject的指针字段(通过UPROPERTY来精确标记这类字段),那么引擎就可以构建出游戏中所有存活UObject对象之间的完整引用图(reference graph)——谁指向谁——并周期性地遍历这张图,从已知的“存活”根对象(世界、GameInstance等)出发,将所有可达对象标记为存活,然后删除所有不可达的对象。
这就是虚幻引擎的垃圾回收器(Garbage Collector)——一个恰恰因为反射的存在才得以实现的机制,而非独立于反射之外凭空出现的东西。
简化的GC遍历模型(标记-清扫,Mark-and-Sweep):
为什么这只对 UPROPERTY 引用有效
GC只能遍历那些它知道存在的引用——而它只能通过反射注册的字段来获知这些引用,也就是说,只有用 UPROPERTY 标记过的字段才会被纳入遍历范围。
一个普通的"裸"C++指针指向一个UObject(例如 AActor* CachedTarget;,不加 UPROPERTY)对于GC来说是不可见的:它不属于遍历图的一部分,它所指向的对象即使该"裸"指针仍然存在于内存中的某处,也可能被回收器删除。于是你拿到一个看起来有效(非 nullptr)但实际指向已释放内存的指针——这就是典型的 use-after-free,只是它伪装成了表面上正常工作的引擎,因为代码能编译通过,乍一看似乎也没问题。
这就是为什么在实际项目中,任何指向UObject的指针字段几乎都必须用 UPROPERTY 标记的直接原因——不是为了在编辑器中可见,而是为了内存的物理安全性。
源码内部一瞥:UObject上的 UPROPERTY 字段已经不再完全是"裸"指针了
从UE5开始,当UHT看到 UPROPERTY() AActor* Target; 时,生成的代码中该字段不再声明为裸的 AActor*,而是 TObjectPtr (位于 UObject/ObjectPtr.h)——一个围绕对象"句柄"(UObject/ObjectHandle.h)的封装。
Epic在 TObjectPtr 上方的注释直接解释了原因:"旨在作为裸指针成员属性的即插即用替代品……支持访问追踪以及在编辑器构建中的可选延迟加载行为……在解析后,其参与垃圾回收的方式与裸指针完全相同。"
对于日常代码(Target->DoSomething())来说,这没有任何变化——同样的大小、同样的直接地址访问、同样参与GC遍历。但对字段的每一次访问都成为了引擎可以追踪的点,而非内存中一个不透明的地址——这被用于GC屏障,以及在编辑器构建中用于尚未加载对象的延迟加载。
需要特别指出的是,这个GC的工作方式与C#或Java等托管语言不同——它不会在任何时刻不可预测地扫描,而是按照可控的调度来运行(通常每隔几秒执行一次,具体间隔可配置),并且遍历本身被设计为能够适应帧预算之内,而非暂停整个游戏。这正是第06节中约束条件#1的直接后果——可预测性比即时响应内存释放更为重要。
10. 这一切如何串联——一条完整的因果链
现在每个部分都已单独拆解完毕,值得明确地将整条链条梳理一遍——因为链条本身的完整性,而非孤立的知识点,才是本文的主要收获。
问题: 为每个项目从头重写整个游戏基础设施,在经济上不可行。
解决方案1 —— 框架(Framework): 引擎接管主循环的控制权(控制反转);你的代码变成一组扩展点。
由此带来的约束: 实时引擎需要一门可预测、高性能、且无托管运行时暂停的语言——因此选择了C++。
这一选择带来的新问题: 在C++中手动管理数千个相互引用的游戏对象的内存,而这些对象的创建和销毁不仅由程序员控制——这是use-after-free的源头。
解决方案2 —— UObject: 明确划出一类特殊的对象,由引擎为其承担自动管理和基础设施集成;其余的C++代码保持"普通"状态,不承担这些额外开销。
这一选择带来的新问题: 通用编辑器和其他基础设施必须"知道"任意未来类的结构——但C++在编译后会擦除这些信息。
解决方案3 —— 通过UHT和宏实现反射(Reflection): 在独立构建步骤中,从带宏注解的C++源代码生成类结构的运行时描述信息。
由此解锁的能力: 知道了对象之间通过UPROPERTY构成的引用图,引擎就能自动找出不可达的对象。
解决方案4 —— 垃圾回收器(Garbage Collector): 按可控调度周期性地遍历该引用图,释放不可达的UObject。
为什么你应该把它当作一条链条来记,而非一堆零散的知识点
如果你单独记住五个事实——"虚幻是框架""有UObject""有反射""有GC"——那你只是知道了API层面的东西。但如果你牢牢把握住这条因果依赖链,那么面对任何新的、不熟悉的引擎系统(Gameplay Ability System、Niagara、Enhanced Input)时,你都能问出同样的问题:"这个系统解决的是上一层留下的什么问题?它自己又带来了什么新的约束?" ——即便在你读完本文之后Epic才加入到引擎中的新系统,这套思考方式同样适用。
11. 对蓝图(Blueprint)的影响
蓝图——虚幻引擎的可视化脚本语言——并非一个独立构建在C++之上的系统。它直接依附于上述反射基础设施:当UHT根据UPROPERTY/UFUNCTION为你的类生成运行时描述信息时,蓝图的编辑器所使用的正是同一份描述——用来将你的C++字段显示为节点上可配置的引脚(pin),或将你的C++函数显示为图表中可调用的节点。
蓝图并非通过任何独立的路径来"了解"你的类——它看到的,与GC、编辑器Details面板、序列化系统所看到的,是完全相同的运行时结构描述。
这也解释了一个在实践中看似武断的规则:如果你希望策划(设计师)在蓝图中看到你的字段或函数,唯一的途径就是通过这套反射机制(UPROPERTY(BlueprintReadWrite)、UFUNCTION(BlueprintCallable)),而非某个独立的"蓝图API"。
12. 何时该用这套思维框架——以及何时不必过度分析
值得——用“问题→约束→解决方案”链条来思考:
● 你正在设计一个新的引擎系统/插件,不确定某个类应该继承自UObject还是保持为普通C++类
● 你看到一个不熟悉的宏或说明符(specifier),想要理解其架构层面的含义,而不只是从教程里复制粘贴
● 你在向团队新人解释为什么项目采用这种方式而非另一种方式
不值得——过度推理没有必要:
● 你只是在按常规用途使用一个已有的、成熟的引擎类(例如 UStaticMeshComponent)
● 某个任务完全可以由现有的、有文档记载的标准系统组合来完成
● 只是在单个函数内部进行修改,不涉及架构层面的影响
13. Epic自己如何使用这套机制
一个颇具说服力的事实:虚幻引擎编辑器本身,也并非一个独立于这套架构之外编写的程序,而是与你的游戏一样,构建在完全相同的框架之上的虚幻应用程序。
例如 UCharacterMovementComponent(负责角色移动的组件),它并没有自己独有的"特权"途径来在每个帧获得控制权——它同样是通过重写 TickComponent 来实现的,而这个扩展点与你自己编写的组件所能使用的完全一样。
当你打开这个类的源代码,看到熟悉的 TickComponent 函数签名时,你看到的并非"引擎魔法",而是与你自己所使用的完全相同的机制——只不过Epic将其应用在了更复杂的任务上。这正是控制反转的直接后果:引擎没有任何后门或封闭的、绕开它向所有开发者提供的公开系统的特权通道来获取控制权。
14. 常见错误
初学者的错误:寻找main()并编写顺序脚本
从常规程序世界过来的初学者,本能地会去寻找一个"入口点",并试图在一个地方写出整个游戏的顺序脚本——"先加载菜单,然后等待按钮按下,然后启动关卡"。这种错误源于此前所有的编程经验都围绕着一个单一入口点构建,而虚幻引擎在物理层面上并没有给你这样一个入口点——框架期望游戏状态通过类以及类之间的显式转换来表达(GameMode/GameState改变比赛状态,UI响应事件),而非通过一个手写的、命令式的脚本来驱动。
中级开发者的错误:将UPROPERTY仅仅理解为"在编辑器中显示字段的方式"
中级开发者通常已经知道要写UPROPERTY,但会用错误的原因来解释其必要性——"好让策划在编辑器中看到这个字段"——因此有时会跳过那些"策划不需要"的字段上的宏,包括指向其他UObject的指针。危险恰恰就隐藏在这个错误的心智模型中:真正的原因是对垃圾回收器可见,而编辑器中的可见性仅仅是同一反射机制的副产品。在开发者认为"无关紧要"的指针上跳过UPROPERTY,是一条可能数月都不会暴露的use-after-free的直接通道。
资深开发者如何思考
资深开发者不会问"我该怎么让这个功能跑起来"——他们会问:"框架已经为这个问题提供了哪个扩展点,以及它为什么被设计成那样而非别样。"
任务:为游戏添加背包系统
● 初学者按对象思维来思考:"我需要一个背包——所以需要一个UInventoryComponent类,里面放一个物品数组。"然后去找一个专门针对这个任务的现成教程,把找到的解决方案复制过来。
● 资深开发者不会去找背包教程——他们会沿着已知的因果链条走一遍:
○ 背包数据必须在角色死亡后仍然存在,而Pawn本身在死亡时会被销毁——所以背包在物理上不能存放在Pawn内部。PlayerState正是为这种情况而存在的——它被特意设计为比特定角色实体存活得更久。
○ 如何在PlayerState中存储物品列表?TArray是元素顺序重要时的典型选择,而TMap则针对键值查找进行了优化。
○ 物品应该如何将其不变的特性(伤害、重量、图标)与可变的状态(当前数量、耐久度)分离开来?不可变的物品数据应交由独立的DataAsset来承载,而可变状态则作为普通字段保留在背包槽位结构体中。
○ 当只有一个物品发生变化时,如何通过网络同步该列表,而不必发送整个背包?只复制足以重建状态的最小信息即可;对于数组,引擎有一套独立的、专门为此设计的机制来实现高效复制。
15. 练习
练习1 —— 用自己的话解释控制反转(Inversion of Control)
请说明"调用库函数"与"被框架调用"之间的区别,以 SpawnActor 和 BeginPlay 这一对为例:在这两种情形中,分别是谁调用了谁,以及为什么调用方向不同?
提示:考虑一下谁拥有 main()、谁控制生命周期、谁决定时机。
练习2 —— 将因果链应用于一个新示例
选取一个本文未曾提及的系统——例如 Enhanced Input(增强输入系统)或 Gameplay Ability System(GAS,技能系统)。请提出一个假设:这个系统很可能解决了之前更简单方案中的什么问题?根据你的假设,它又引入了什么新的约束?
16. 常见问题解答
问:如果C#或Java已经"开箱即用"地提供了反射和GC,为什么Epic不直接用其中一种语言来写引擎?
答:因为托管语言中的反射和GC是为通用场景设计的——它们无法让你控制何时发生GC暂停,而实时引擎对每一帧的可预测性有着至关重要的要求。虚幻引擎采用了一种不同的解法:选用一门天生不施加GC的语言(C++),然后仅针对一个明确界定的对象类别(UObject)添加自己可控调度的回收器,而底层引擎的其余所有部分则完全免于这些开销。
问:我的每个类都必须继承自UObject吗?
答:不必,而且这是一个刻意的架构决策,而非疏漏。不需要GC、编辑器或序列化的简单数据结构(例如数学向量、仅在单个函数内部使用的辅助工具结构体),绝大多数情况下应保持为普通的C++类/结构体——它们不需要反射/GC的开销,因此就不必为此付出代价。
问:控制反转是否意味着我完全无法控制自己代码的执行顺序?
答:不是——你完全控制自己函数内部的执行顺序(BeginPlay内部发生什么,由你逐行决定)。你只是无法控制框架在何时相对于引擎的其他部分和你的其他类来调用这些函数——而对此,框架有明确文档化的顺序保证。
问:反射会拖慢普通读写UPROPERTY字段的代码吗?
答:不会。在编译后的代码中,像 Health = 100.f; 这样的字段值访问仍然是按偏移量进行的普通直接内存访问操作,速度和普通C++字段完全一样。反射的开销并非出现在从C++代码正常读写字段的过程中,而是出现在专门使用运行时结构描述的操作中(例如序列化、编辑器Details面板、蓝图调用)。
问:有说法称蓝图最终会在虚幻引擎中完全取代C++,这是真的吗?
答:不是,而且本文分析的架构本身就能解释为什么:蓝图在技术上无法脱离它所可视化的C++层而存在——蓝图表编辑器所看到的那份反射信息,恰恰就是从标记了UPROPERTY/UFUNCTION的C++类中生成的。
17. 读者应该开始留意到什么
读完本文之后,当你在他人代码中看到一个不熟悉的宏或说明符时,不再会因为急于让它跑起来而去简单地照着教程复制粘贴——取而代之的,你会自然而然地产生一种反应:去追问这个宏所挂接的是哪一块引擎基础设施(GC、编辑器、蓝图、序列化),以及这笔开销是否确实有必要。
当你在他人或自己的代码中看到指向UObject却没有加 UPROPERTY 的指针时,现在你不再会将其视为一个小疏忽,而是会将之视为一个潜在的 use-after-free——因为对GC不可见,正是引用图遍历方式所带来的直接后果。
而当你遇到任何一个尚未研究过的新引擎系统(Niagara、Mass Entity、Chaos)时,你的第一反应自然而然将不再是"我该怎么用这个",而是:"它解决的是前一层架构留下的什么问题?它自己又带来了什么新的约束?"
18. 总结
● 虚幻引擎是一个框架(Framework),而非库(Library): 它拥有主程序循环,你的代码是一组在预定时机由引擎调用的扩展点(控制反转),而非由你自行按所选顺序调用的函数集合。
● 控制反转并非美学偏好,而是让同一套引擎能在具有不同游戏逻辑的各项目之间复用的唯一途径: 引擎为任何项目保证了唯一正确的初始化顺序,而你的责任被缩减到只需填充已经安全的扩展点即可。
● 选择C++作为实现语言,是因为严苛的帧预算约束: 带有不可预测GC的托管语言会引入实时引擎无法承受的暂停——但原始的手动new/delete的C++也无法扩展到数千个相互引用的对象规模。
● UObject并非"一切事物的基类",而是一条刻意划定的边界: 边界一侧是由引擎基础设施(GC、编辑器、序列化、网络)承担职责和开销的对象;另一侧是不为此付出代价的普通C++。
● 反射之所以存在,是因为通用引擎基础设施需要"知道"任意类的结构: 普通C++在编译后会擦除这些信息——Unreal Header Tool在独立构建步骤中将其恢复,从UCLASS/UPROPERTY/UFUNCTION生成运行时的类描述信息。
● 垃圾回收器之所以成为可能,正是得益于反射: 通过UPROPERTY得知哪些字段是指向其他UObject的引用,引擎能够构建可达性图,并按可控的、可预测的调度释放不可达对象。
● 蓝图并没有自己独立的关于你的类的知识来源: 它读取的正是GC和编辑器Details面板所使用的同一份反射描述信息——因此,要将字段或函数暴露给策划,唯一的途径就是同样的UPROPERTY/UFUNCTION。
● 以上并非五个孤立的知识点,而是一条完整的因果链条: "这个系统解决的是上一层留下的什么问题?它自己又带来了什么新的约束?"——这是一个适用于任何尚未研究过的引擎系统的问题。
19. 结语:资深工程师的思维方式
当你遇到一个不熟悉的引擎系统时,不要问"我该怎么用这个"——而要问:"它解决的是上一层架构留下的什么问题?以及,以它的解决方案为代价,又带来了什么新问题?"
● 框架(Framework)解决了基础设施复用的问题,付出的代价是控制反转。
● UObject解决了use-after-free的问题,付出的代价是在C++的"昂贵"部分与"廉价"部分之间划出了一道边界。
● 反射(Reflection)解决了类型擦除的问题,付出的代价是宏和一个额外的构建步骤。
● 垃圾回收器(GC)解决了引用图安全的问题,付出的代价是周期性的性能开销。
虚幻引擎的架构中没有任何银弹——只有前一层解决方案所付出的、尚未被你发现的那份代价。
原文作者系Unreal Engine C++开发者,UE C++ Academy Founder
热门跟贴