一提到在 Unreal Engine 里用 C++,我碰到最多的一个反应就是:“是不是蓝图写不明白才去碰 C++?”这个观念在圈子里流传太久,导致很多新人把 C++ 当成 UE 的第二选择,先学蓝图,实在不行了再回头啃 C++。但只要你真正把一个小功能分别用纯蓝图和纯 C++ 各实现一遍,再进入实际项目做一两轮优化,你就会发现这两者之间的关系根本不是“难和易”的上下位,而是“快速迭代”和“稳定可控”之间的取舍。
这篇文章我想从实际项目经验出发,讲清楚在 UE 里写 C++ 到底在写什么。它适合两类人:一类是完全没接触过 UE 的 C++ 程序员,想快速理解“用 C++ 做游戏”和“做传统应用软件”之间的差别;另一类是已经用蓝图做了不少功能、但总觉得天花板很明显、想往 C++ 迁移的开发者。我会把引擎机制、代码套路、常见的坑和排查思路放在一起讲,尽量少讲教科书结论,多讲实际操作时能直接用上的东西。
1. 在 UE 里写 C++,先把蓝图和 C++ 的分工搞清楚
很多教程一上来就让你建一个 Actor、写一个 MoveForward 函数,但没说清楚这东西在什么场景下该用蓝图、什么场景下不得不碰 C++。如果这个认知不建立,你后面写出来的代码大概率是一堆“用 C++ 写出来的蓝图”——该省的没省,该封装的没封装,性能和可维护性都不上不下。
1.1 蓝图不是 C++ 的低配,C++ 也不是蓝图的终结者
蓝图本质是一套可视化脚本,但它在引擎层面是被编译成字节码执行的,和 C++ 编译出的本地代码不是同一个量级。我自己做过一次不算严谨的测试:在一个空关卡里循环生成 10000 个 Actor,蓝图实现和 C++ 实现,生成耗时差出 5 到 10 倍。所以结论很简单:高频调用、数值计算、网络同步、资源加载这类对稳定性有硬要求的逻辑,放 C++;UI 表现、关卡流程、简单的交互反馈、策划需要频繁调参的玩法逻辑,放蓝图。
用一张表把这层关系总结清楚:
| 维度 | 蓝图 | C++ |
|---|---|---|
| 迭代速度 | 改完立刻运行,适合试错 | 编译有等待时间,但可配热重载 |
| 运行性能 | 适合低频逻辑、流程编排 | 适合每帧调用、密集计算 |
| 调试体验 | 断点、单步都很直观 | 断点更底层,需要看寄存器/内存 |
| 代码复用 | 继承结构也支持,但不好审阅 | 模板、多态、跨模块复用更成熟 |
| 团队协作 | 策划、关卡美术可以直接改 | 需要程序员维护,门槛更高 |
| 版本合并 | 二进制资产,合并困难 | 文本代码,容易 merge |
实际项目里最常见的分工是:程序员用 C++ 把带性能要求的底层系统、游戏数据结构、网络协议全部铺好,然后暴露成蓝图节点;策划和关卡设计在上面拼流程、配数值、写表现。C++ 是地基,蓝图是墙和装修,不存在谁替代谁。
1.2 为什么引擎选了 C++,而不是更“安全”的语言
这个问题的答案其实决定了你在 UE 里写代码时的心态。很多人刚接触 UE 时最大的困惑是“为什么这个引擎的 C++ 这么别扭”——到处都是宏,成员变量还要加 UPROPERTY,类里还要写 GENERATED_BODY()。这些别扭并不是为了惩罚开发者,而是 C++ 本身没有反射能力,引擎需要自建一套元数据系统来支撑编辑器、蓝图、序列化、垃圾回收这些功能。
UE 选择 C++ 的根本原因是它和引擎底层、平台底层交互时几乎没有中间层。Windows、PlayStation、Xbox、Switch 这些平台的 SDK 都是用 C 或 C++ 接口暴露的。用 C++ 写游戏性代码,意味着从引擎源码到平台调用之间没有 GC 暂停、没有虚拟机翻译层、没有运行时解释开销。代价就是:**所有“安全”都需要你自己构建。**边界检查、内存释放、类型转换、生命周期管理,这些在 C# 或 Java 里由运行时托管的事,在 C++ 里全都摆在你面前。
在 UE 里写 C++ 和写纯应用层 C++ 还有一个显著差异:你写的类大多要交给反射系统去管理,好让编辑器能识别它、蓝图能调用它、存档系统能序列化它。所以开写之前,必须先理解那套“别扭”的宏和生成代码到底在做什么。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. UObject、反射和 GC:UE 的 C++ 和教科书里的 C++ 是两种东西
如果你之前只是用 C++ 写过命令行工具或后端服务,第一次打开 UE 的工程时看到 .generated.h 大概率是懵的。这个文件不是手写的,是 Unreal Header Tool(UHT) 在编译前自动生成的。它负责把你类里的宏信息转换成引擎能读的元数据:类的名字、父类链、有哪些属性、哪些函数可以被蓝图调用、哪些被标记为序列化字段。
2.1 GENERATED_BODY() 和 .generated.h 在编译前发生了什么
在头文件里每定义一个 UCLASS(),都要在这个类声明内部的最后一行写上 GENERATED_BODY()。这个宏展开后,就是 UHT 生成的一大段代码。如果你的类名是 AMyActor,对应生成的头文件就叫 MyActor.generated.h,这行宏会声明反射需要的各种函数和静态结构。
这带来的第一个实际影响是:手写一个 UE 类不能像普通 C++ 类那样随便调整声明顺序。GENERATED_BODY() 必须写在类的开头位置(通常在 public: 之前或紧接着构造函数之前的区域),因为生成的代码需要引用你类里的成员。很多人第一次编译报一堆莫名其妙的错误,最后发现只是把 GENERATED_BODY() 写到了类的最下面。
UHT 不是编译器,它是一个独立的工具,在真正调用 C++ 编译器之前运行。它扫描所有头文件,抽取出 UCLASS、UPROPERTY、UFUNCTION 等信息,生成 .generated.h 和 .generated.cpp,然后才进入正常的编译流程。所以你会观察到:改头文件里的宏声明后,有时明明没写错语法,还是会先卡在 UHT 阶段报错。
2.2 UCLASS、UPROPERTY、UFUNCTION 这三大宏的含金量
这三个宏可以说是 UE 反射系统的“身份证”。没有它们,C++ 类就只是普通 C++ 类,引擎完全不认识。
UCLASS()告诉引擎“这是一个可被反射的类”。它还能带别的标识,比如UCLASS(Blueprintable)允许这个类被蓝图继承,UCLASS(NotBlueprintType)禁止在蓝图中作为变量类型。UPROPERTY()标记成员变量。后面可以跟EditAnywhere(在细节面板可编辑)、BlueprintReadWrite(蓝图可读取可修改)、VisibleAnywhere(只显示不能改)、SaveGame(存档时序列化)等标识。UFUNCTION()标记成员函数。BlueprintCallable允许蓝图调用,BlueprintImplementableEvent表示蓝图负责实现、C++ 只管调用,BlueprintNativeEvent表示蓝图和 C++ 都能实现但 C++ 有默认版本。
我见过最典型的新手错误:写了一个 C++ 类,成员变量没加 UPROPERTY,然后在蓝图的 BeginPlay 里读这个变量,发现永远是默认值;或者在存档系统里字段总是不保存。原因都一样:没有 UPROPERTY,反射系统根本不知道这个字段存在。 这不是 UE 的 bug,而是默认设计——防止每个字段都白白参与
