ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Unreal对C++做了什么:从反射到GC的五层工程化改造

Unreal对C++做了什么:从反射到GC的五层工程化改造 我在 UE 里第一次打开Engine/Source/Runtime下的头文件时第一反应是这是我认识的 C 吗满屏的UCLASS、UPROPERTY、UFUNCTION、GENERATED_BODY……看官方示例的时候每个字符都看得懂一关掉官方文档自己写就崩。不是语法不会而是每一个“工程规矩”都和你过去十多年养成的习惯不一样——标准库不能乱用虚函数不能随便加连普通成员变量的声明都要考虑要不要让 GC 看得见。后来我才慢慢明白Unreal 并不是想把 C 变成另一种语言而是为了让 C 能支撑起一个完整的大型游戏引擎工具链做了五层非常激进的工程化改造。了解这些改造是怎么来的、为什么这么设计是入门 UE C 时最值得投入的一件事。这篇文章是我打算写的连载《Unreal对C做了什么》的前言。先把全景图和路线图放在你面前把“为什么要写这个系列”讲清楚再把开坑前要准备的工具、心态和最小工程列明白。后面我们一篇一篇拆把 Unreal 对 C 动过的每一刀都翻出来看看。1. 写了十几年C在Unreal里被“打回原形”1.1 你写的C和Unreal写的C其实是两种方言先说一个很现实的问题你在学校、在工作项目里练出来的 C 习惯在 Unreal 里很大一部分要推翻重来。标准 C 里你习惯用std::vector、std::string、std::shared_ptr、std::function写业务逻辑非常顺手。但到了 UObject 体系里这些做法会一个接一个踩雷字符串用std::string做 UPROPERTY 属性时编辑器面板不认存档系统也不认用std::shared_ptr包装一个 UObject生命周期会失控GC 管不到它引擎自己也管不到用函数指针做回调在日后需要暴露给蓝图时完全没法转普通结构体写一大堆if/else逻辑虽然编译没问题但编辑器里看不到任何可调参数。这不是标准库不好。标准库在通用软件开发里非常优秀但 Unreal 要的不是“数据结构算得对”而是“运行时可以被引擎工具链观察和控制”。标准容器没有反射信息编辑器无法在 Details 面板里枚举字段标准字符串不会自动参与本地化标准智能指针不会参与 GC 的引用追踪。对一个动辄几万个 Actor、几个 G 素材、美术策划需要同时在一个项目里协作的大型游戏来说这些“不可观察性”是致命的。所以 Unreal 的做法是保留 C 的性能和底层能力但在类体系、内存管理、反射、事件、容器这些层面全面重写。你可以把它理解成两门方言——标准 C 是普通话Unreal C 是带了一整套行业黑话的普通话。你能听懂很多词但真正开口说的时候语法习惯完全不一样。1.2 Access violation c0000005我第一次遇到不是空指针说个特别典型的崩溃场景。刚接触 UE 时我写了一个AActor子类内部用一个TArray存了几个 UObject 对象的指针当时嫌麻烦没给这个数组加UPROPERTY。在编辑器里单关卡运行一切正常但我做了一个关卡 A 切关卡 B 再切回 A 的流程刚切回去程序直接崩了Windows 弹窗报的是access violation c0000005。我当时第一反应是去查空指针、越界访问把所有数组遍历都看了一遍没有发现问题。后来查了一整天才想明白那个TArray不是 UPROPERTYGC 追踪引用链时根本不知道它持有 UObject 引用。切关卡时那个被引擎认为“没人在用”的 UObject 被回收了我的数组里留着一个悬空指针再一访问当然就访问违例。这种崩溃在非游戏 C 项目里几乎不会遇到因为你手动管理生命周期对象被 delete 了你会知道。但在 UE 里对象的生死更多时候由 GC 决定而 GC 判断“谁还在用你”的依据就是 UPROPERTY 标记和引用追踪不是你的代码里是否还有指针变量的存在。这个案例让我明白了学习 Unreal 对 C 的改造不是学一堆额外的 API而是学习一套全新的对象生命周期哲学。你不理解这套哲学就会不断撞上 c0000005、空指针、内存损坏这些问题而且这些崩溃往往不是“算法错了”是你和引擎对对象的认知不一致。1.3 Unreal为什么敢这么“折腾”C可能有人会问既然标准 C 不够用为什么不直接用 C#、Lua、Python或者干脆发明一门新语言Unreal 偏要在 C 上叠这么多机制图什么答案核心是性能和生态的平衡。一个 3A 级游戏里角色移动、物理模拟、渲染提交、资源加载、网络同步每帧都要在几毫秒内完成海量计算。托管语言在这个量级下虽然也能做但你要付出额外的 GC 停顿、热点代码优化、即使 JIT 也无法完全消除的管理成本。C 在性能上仍然是工业级最可靠的选择而且全球有大量熟悉 C 的引擎、中间件、游戏客户端开发者这是一条成熟得不能再成熟的生态链。但纯 C 的问题是开发效率太低。让一个普通策划去改UCLASS里的一堆参数让他去理解虚函数和内存布局完全不可能。Unreal 需要的是底层计算用 C 保性能上层工具链用可视化方式给策划和美术用。这就必须让引擎在运行时“看得懂”C 代码里的类、字段、方法。标准 C 本身没有提供这种能力于是 Unreal 就自己造了一套用宏做标记用 UHT 做代码生成用反射注册表把 C 类暴露给编辑器用 GC 接管对象生命周期用 Delegate 让事件能被蓝图连接。所以Unreal 对 C 做的一切改造本质上是四个字工具链化。它不满足于 C 是一门“能编译成机器码的语言”而是要把 C 变成“能被可视化编辑器读懂的语言”。代价是学习曲线陡峭收益是同一个项目里程序、美术、策划可以在同一套数据体系上协作而且性能可控。2. 拆开Unreal对C动刀的五层改造2.1 反射系统先让引擎“看见”你的C类Unreal 对 C 做的第一层改造也是最根本的一层是反射系统。标准 C 里没有运行时反射——RTTI 只能告诉你某个对象是哪个类型但没法枚举这个类有哪些字段、字段名是什么、字段在内存里的偏移是多少、有没有特殊标记。而 Unreal 的编辑器要能做到这些在 Details 面板里显示一个 Actor 的Health属性、让蓝图节点能读写它、让存档系统能序列化它、让 GC 能遍历它。实现方式不是运行时动态分析而是编译前的代码生成。Unreal 自带了一个叫 Unreal Header ToolUHT的工具它在 C 编译器跑之前先扫描你写的头文件寻找UCLASS、USTRUCT、UPROPERTY、UFUNCTION这些标记宏然后生成一个.generated.h和一个.gen.cpp。这些生成文件里包含了反射注册表的注册代码编译后类信息、字段偏移、函数指针、蓝图桥接 stub 全都变成了一份可被引擎访问的元数据。一个最典型的结构大概长这样UCLASS(Blueprintable) class MYGAME_API AMyActor : public AActor { GENERATED_BODY() public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category Health) float Health; };看起来只是多了几个宏但编译器在真正编译前UHT 已经把Health这个字段的名字、类型、标记、偏移量写进了生成代码。于是编辑器才能把Health显示成可编辑参数蓝图才能读取它存档系统才能保存它GC 才有机会遍历它。为什么要这么麻烦而不是等 C23 的静态反射标准出来再适配因为 Unreal 需要的不只是反射本身还要在反射的基础上生成蓝图桥接代码、生成序列化支持、生成多种类关系的描述信息。这套东西如果等到标准库去做同步周期不可控而且编译器实现跨平台差异大。自己写一个静态头文件扫描器虽然要维护一个额外的工具链但能做到完全可控、跨编译器统一。2.2 生命周期管理从手动销毁变成托管世界C 对象管理本来是开发者自己的事情new和delete对齐shared_ptr处理共享所有权unique_ptr处理独占所有权。这套体系在服务器后台、桌面工具软件里完全够用但在游戏引擎里会出问题关卡切换时要卸载成百上千个 Actor每个 Actor 又持有几十个 Component、资源引用、动态生成的子对象手工去保证“正确顺序销毁”几乎不可能。漏一个引用就内存泄漏错一个顺序就崩溃。Unreal 的选择是UObject 体系内全面引入垃圾回收。它和 Java/C# 的 GC 思路类似从一组“根对象”比如当前 World、PlayerController、正在执行的蓝图开始遍历所有 UObject 引用被引到的对象保留引用不到的标记为待回收。这个遍历机制要能找到你类里的字段前提就是字段必须是UPROPERTY否则 GC 认为这个对象没有人引用直接回收。所以你在 UE 里写 C 时成员变量不是你想怎么声明就怎么声明。如果你有一个UObject*或TArrayUObject*成员并且希望它跟随对象被管理就必须加上UPROPERTY()。同样UFUNCTION 标记的方法会被记录到反射表里可以在运行时被蓝图调用、被控制台命令调用、被事件系统触发。那为什么不全用智能指针来管理std::shared_ptr在循环引用时会导致引用计数永不归零最后内存泄漏而 GC 不受循环引用影响。另外GC 的标记遍历可以做精确的调试可视化——编辑器里可以暂停并检查哪个对象被哪些对象引用这是引用计数很难做到的。对游戏引擎来说GC 还有另一个隐藏优势它可以区分硬引用和软引用软引用在加载资源时可以按需加载不会因为一个对象持有了资源就强制让资源一直驻留内存。这套逻辑用shared_ptr很难优雅表达。2.3 容器和字符串为什么Unreal不用std::vector和std::string容器是每个 C 开发者进入 UE 后最先察觉差异的地方。项目里到处是TArray、TMap、TSet、TQueue标准库的std::vector、std::map不是不能用而是 Unreal 刻意弱化它们。原因至少有三个方面。第一内存分配的可控性。TArray 使用引擎的分配器所有分配都能被内存分析工具精确追踪你可以快速看到哪个容器占了多大内存、在哪个 UObject 下分配。std::vector用的是默认operator new引擎的内存统计工具对它几乎是“盲区”。第二反射与编辑器集成的需求。TArrayTObjectPtrUObject作为 UPROPERTY 时编辑器能枚举元素、能拖拽资源进数组、能序列化每个元素。std::vector完全没有这些能力标准库容器无法把自己的元素信息暴露给 Unreal 的反射系统。第三生命周期和安全语义。TArray 会在对象析构时自动销毁元素并且和 UE 的委托、蓝图系统集成比如TArray的Add、Remove方法可以直接暴露给蓝图操作。TSet和TMap也有对应优化过的哈希实现性能在游戏场景下表现稳定。字符串方面Unreal 的做法更有代表性。它不是只提供一个std::string的替代品而是搞了三个FName、FString、FText各管一摊。FName是把字符串内部映射到一个全局表里的 ID比较两个名字只用比较整数大小写不敏感适合资源名、Socket 名、Tag 名这种高频比较又很少改动的场景FString才是真正可变、可拼接、可任意操作的字符串适合文件路径、日志内容FText是专门为界面显示准备的它带本地化表查询能力同一个FText在不同语言环境下显示不同文本程序里不会写死用户看到的字符串。很多新手会在该用FText的地方用FString然后在本地化阶段被迫重写一大片 UI。这种代价从根源上就来自 Unreal 对字符串的“语义细分”——它逼着你在写代码时就考虑这个字符串到底是名称、是数据、还是要给人看的文本。2.4 委托体系用宏模拟出C#的事件语法游戏开发里大量逻辑是事件驱动的角色死亡通知技能系统、血量变化刷新 UI、物品拾取触发任务更新。标准 C 里做事件通常用函数指针、std::function或者手写观察者列表但都有问题函数指针绑不了成员函数std::function很难暴露给蓝图手写观察者列表要自己管理订阅和退订容易产生野调用。Unreal 用一套宏体系解决了这件事也就是 Delegate。看名字就知道思路它把 C# 里的delegate和event搬到了 C 里用宏来声明类型。DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FHealthChanged, float, NewHealth); UCLASS() class MYGAME_API AMyCharacter : public ACharacter { GENERATED_BODY() public: UPROPERTY(BlueprintAssignable) FHealthChanged OnHealthChanged; };OnHealthChanged是一个多播委托变量加上BlueprintAssignable后蓝图中可以直接用它绑定事件节点。C 侧也可以AddDynamic(this, AMyCharacter::HandleHealthChanged)订阅在对象销毁时自动断开。这套机制解决了三类典型问题成员函数绑定、多个监听者广播、蓝图可视化连线。用宏实现而不是用模板类硬写是因为 Unreal 需要这些委托类型也能被 UHT 识别能生成蓝图桥接代码能作为 UPROPERTY 被编辑器看到。纯模板类做不到这种程度的工具链集成宏让声明看起来像一个“类型”但它既生成了 C 实体同时也被 UHT 捕获到了元数据。这种“双通道”设计是 Unreal 里所有宏的核心逻辑。2.5 数学库与组件模型为了游戏业务而重构的数据类型最后一层改造是数据类型的游戏化。标准 C 没有内置向量、四元数、矩阵Unreal 提供了FVector、FRotator、FTransform、FQuat并且做了 SIMD 优化。这里有个特别容易踩的坑Unreal 矩阵是列主序的因为底层图形 APID3D、Vulkan的内存布局约定是列主序。如果你从 OpenGL 时代习惯了行主序写 Shader 时经常把矩阵传错渲染结果莫名变形。更贴近业务的是 Actor/Component 模型。传统面向对象设计遇到“一个会飞的战斗角色”很容易写出FlyingCharacter - Character - Actor这种长继承链再加一个“会游泳的角色”时继承树就爆炸了。Unreal 的做法是把能力拆成 ComponentUCapsuleComponent负责物理碰撞UStaticMeshComponent负责显示UCharacterMovementComponent负责移动UMyInventoryComponent负责背包。Actor 只是容器把各种 Component 组合起来。这样“会飞的战斗角色”和“会游的普通角色”可以共享大量逻辑不是通过继承僵化地复制父类而是通过组件组合灵活地拼接功能。这些看起来是“数据类型差异”本质上还是工具链需求Component 能在蓝图里添加、能序列化、能被编辑器可视化而传统的 C 继承树在蓝图里很难优雅呈现。Unreal 对 C 的每一个数据层改造都在向“可以让非程序员操作”这个目标靠拢。3. 《Unreal对C做了什么》系列篇目与阅读建议3.1 读这个系列需要的前置C知识这个系列不是零基础教学它默认你已经会写一点 C不需要精通但下面这些概念最好见过指针、引用、值传递的区别栈内存与堆内存new/delete的基本行为构造函数、析构函数、访问修饰符简单的 STL 使用如std::vector、std::string模板的基本概念哪怕只是知道vectorint里的是在做什么。说实话模板深入内容在 UE 里远没有你想象的那么多你更多是在读代码、用类、继承、组合、宏。C 底子越扎实后面理解 UHT、GC、Delegate 就越轻松。但如果你完全不懂指针和内存布局建议先花一个月补补基础再来不然跟着系列看很容易被“悬空指针”“引用追踪”这些词绕进去。3.2 系列规划十篇内容对应十块硬骨头这个系列我计划分十篇每篇聚焦一个“Unreal 对 C 动过刀”的具体方向。先列一个路线图放在这里也方便你在阅读过程中建立自己的知识地图。篇目核心问题对应的Unreal改造01 反射与生成代码UCLASS/UPROPERTY的“魔法”到底怎么来的UHT、生成头文件、反射注册表02 UObject生命周期什么对象会被GC回收什么时候对象悬空垃圾回收、引用追踪、UPROPERTY标记03 容器与内存TArray/TMap和std容器该如何选择自定义分配器、反射容器、内存统计04 智能指针体系TSharedPtr/TWeakObjectPtr何时用、怎么用非UObject对象管理、异步安全05 委托与事件如何在对象间安全广播消息Delegate宏、AddDynamic、蓝图事件06 数学库与坐标系FVector/FRotator/FTransform为什么这么设计列主序矩阵、SIMD、旋转表示07 Actor与Component组合模式如何替代深继承树组件模型、蓝图可组合性08 C与蓝图互操作C代码如何暴露给非程序员反射标记、BlueprintCallable、Dynamic09 模块与构建系统为什么UE编译分模块Build.cs在做什么UnrealBuildTool、模块依赖、热重载10 调试与性能剖析UE项目出了问题怎么查崩溃日志、内存分析、性能剖析器这个顺序是刻意安排的。前两篇是地基——反射决定一切工具链能力的上限GC 决定 UObject 世界的行为规则。如果只学一两篇就上手写项目最容易踩的坑也集中在这两块。中间四篇是把日常代码里最常用、但最容易被误导的机制讲透。最后三篇偏进阶属于你想把一个项目真正推到发布质量时绕不开的部分。3.3 我定的写作原则每个机制都讲清楚“为什么”在这个系列里我不会只教你怎么写。我给自己定的写作原则是每个机制至少回答四个问题它要解决什么问题、Unreal 为什么选这个方案而不是别的、一个最小可用示例长什么样、如果按标准 C 的惯性来写会踩什么坑。只有回答了这四个问题知识才不是死记硬背的 API而是能举一反三的原理。举个例子讲TWeakObjectPtr的时候我不会只说“用它可以在不增加引用的情况下安全访问 UObject”还会解释为什么不直接用 C 原生裸指针——因为 GC 可以在对象销毁后把它自动置空为什么不直接用std::weak_ptr——因为它和 GC 标记世界不是一套系统。这样你才能在“这个对象可能随时销毁”的场景里准确判断该用哪个工具而不是死记一个结论。我还会在每篇里面放少量源码级说明。Unreal 的源码确实长但它的注释和命名非常一致读多了你会发现自己能顺着名字猜出机制。比如看到MarkAsGarbage就会知道 GC 相关看到IsValidLowLevelFast就会知道它在检查 UObject 有效性。这些“源头思维”比背任何速查表都管用。4. 正式入坑前先做三件准备工作4.1 工具链选型Visual Studio是主力VSCode做辅助工欲善其事必先利其器。第一个要装的是 IDE我的建议是 Windows 上直接上 Visual Studio 2022。安装时组件不要乱选至少勾上“使用 C 的游戏开发”工作负载它会自动带上 UE 需要的 Windows SDK、MSVC 编译器、调试工具链。装完之后去 Epic Games Launcher 下载一个 UE5 稳定版本这里我建议直接选择从源码版安装或者至少勾上编辑器的符号和调试信息。Debug 版本编译慢但跑起来能看到大量断言和内部状态对理解机制有巨大帮助。VSCode 不是不能用装上 C 扩展后编辑单文件、看小项目完全没问题。但它对 UE 这种超大工程的符号索引、代码跳转、IntelliSense 支持远不如 VS 稳定新人在 VSCode 里配置 include 路径和宏定义就可能折腾掉一整天。我的建议是主力用 VS 写代码、调试VSCode 拿来当轻量阅读器不要一上来就和工程配置较劲。提示安装 VS 时如果之前装过旧版本建议把“C 游戏开发”组件确认勾上再更新缺了 Windows SDK 会导致生成引擎项目时中途报一堆找不到头文件的错。4.2 搭一个最小工程把编译流程跑通在开始读任何深文章之前先创建一个空 C 工程并让它可以编译、运行、修改代码后热编译。过程不难打开 Launcher创建项目时选 Games 模板项目类型选 C不要选蓝图纯工程。项目创建完成后默认会生成一个空的GameMode或Character相关的 C 文件打开 VS先编译一次确认整个管线正常。这个“第一次编译通过”非常重要它会验证你的 IDE 配置、引擎版本、SDK 组件是否齐整后面写代码时报错会更容易判断是自己的问题还是环境问题。有了空工程你可以做一个属于你的最小验证实验通过编辑器菜单Tools - New C Class创建一个继承自Actor的类在头文件里加一个 UPROPERTY 浮点变量在构造函数里打印一行日志编译后把它拖进场景里运行。这个流程完整走一遍反射、编译、加载、实例化、日志输出的每一环你都亲手碰过一遍接下来的理论学习就有了落地的地方。如果你在 Windows 上还遇到过access violation c0000005这类崩溃排查时可以顺手用 VS 的“本机调试”打开调用堆栈看栈顶是在访问哪个地址、哪个对象。UE 的崩溃日志会输出LogOutputDevice: Error之类的信息善用CallStack往往比瞎改代码更快定位问题。4.3 心态准备先放弃“语言洁癖”再谈工程效率最后想说一个心态上的坎。很多从标准 C 过来的人看到 UE 里满屏宏、生成代码、各种看起来“不干净”的技巧会很抵触。我能理解这种感受但请你先放下洁癖。Unreal 的宏不是语法糟粕它是一种“让工具链能识别的标记语言”。UCLASS除了做标记还被 UHT 用于生成大量代码DECLARE_DYNAMIC_MULTICAST_DELEGATE看起来臃肿但它能让编辑器看到一个可绑定的蓝图事件。这些设计都是在“编译器能力不足以直接支持工具链需求”的现实下用工程手段换来的开发效率。你越早接受“宏也是代码生成工具”这一观念就越不容易被这些细节劝退。我在实际项目里见过太多新人因为不喜欢UPROPERTY标记就直接裸用std::vectorUObject*结果序列化失效、GC 不认、编辑器也不显示。这其实是把自己的偏好放在了引擎的正确用法之上最后受苦的是自己。我个人在写这个系列时坚持一个习惯遇到第一个不认识的宏一定跳转到它的定义读它旁边 UHT 能处理的注释。真正把 Unreal 当作一个“C 扩展框架”而不是“一个 GUI 工具”去看的时候很多事情就开始顺了。希望这份前言能让你带着同样的心态跟我一起把 Unreal 对 C 做过的每一件事看个底朝天。
返回列表