一提到在 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,而是默认设计——防止每个字段都白白参与反射和 GC 追踪,白白浪费性能和内存。
2.3 没有 UPROPERTY 的成员变量会造成什么后果
这里要稍微展开一下,因为它同时影响两个东西:编辑器交互 和 垃圾回收(GC)。
编辑器交互方面:蓝图里看到的变量不是靠扫描 C++ 头文件得到的,而是靠反射元数据。一个 float MoveSpeed 不加 UPROPERTY,它只在 C++ 内部可用,细节面板永远不会显示它。而垃圾回收方面更关键:UE 的 GC 是基于“可达性分析”来工作的,它的根节点是 UObject 之间的引用关系。只有通过 UPROPERTY() 维护的引用,GC 才知道这个对象还被指着,才不会被回收。
举个例子:你有两个 UObject,A 里有一个普通 C++ 指针指向 B,但 A 成员没加 UPROPERTY。在某个 GC 触发点,B 没有任何“被 UPROPERTY 追踪”的引用路径,于是被判定为无引用,直接销毁。之后 A 再访问 B,就是一个悬垂指针,轻则读到垃圾数据,重则直接崩溃。这种崩溃特别难查,因为崩溃栈可能只显示一个空指针解引用,根本看不到是谁把它提前干掉的。我在项目里排查过一例 “人物切换时随机崩溃”,最后定位到就是某个父类对象里的普通指针没加 UPROPERTY,绕了两周。
所以我的经验法则是:凡是可能指向 UObject 的成员,一律加 UPROPERTY()。 除了极少数你自己用 NewObject 创建、且能确切保证生命周期的情况,不要用裸指针脱离反射系统。
3. 一个 C++ 类从新建到跑进关卡的全过程
理论讲完,来点可以立刻上手的。我选一个最常见的场景:做一个可以在世界里移动的 Actor,暴露一个速度参数给编辑器调,再提供一个蓝图可调用的移动函数。
3.1 编辑器里创建 C++ 类是最稳的起点
我见过有人手动在工程目录下建 .h 和 .cpp 文件,然后再去改 .uproject 的模块配置,最后编译各种失败。除非你非常了解 UE 的构建系统,否则不推荐这么做。右键 Content Browser → New C++ Class,选择父类为 Actor,填好类名后,编辑器会自动把文件放到 Source/你的模块名/ 下,并处理好模块依赖。这里有个让所有人掉过坑的提醒:类名不要和别的类重名,也不要起 Actor、GameMode 这种引擎自带的名字。 UE 的反射系统对重名容忍度很低,一旦冲突,错误信息又长又绕。
3.2 头文件里的声明:核心代码示例
创建好之后,头文件会是这样的基础结构:
cpp复制// MyActor.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyActor.generated.h"
UCLASS()
class MYPROJECT_API AMyActor : public AActor
{
GENERATED_BODY()
public:
AMyActor();
protected:
virtual void BeginPlay() override;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Move")
float MoveSpeed = 100.f;
UFUNCTION(BlueprintCallable, Category = "Move")
void MoveForward(float DeltaTime);
};
注意源码文件的命名规范:类 AMyActor 对应 MyActor.h 和 MyActor.cpp。UE 的构建工具通过文件名和类名之间的映射来组织生成代码,如果你写 class AMyActor 却放在 Actor2.h 里,UHT 会直接报 “Unable to find generated code for class”。
API 那一段(MYPROJECT_API)是模块导出宏,作用是把类导出到 DLL,让其他模块能链接到。类的首字母前缀也有讲究:A 开头代表 Actor,U 开头代表普通 UObject,F 开头是纯 C++ 结构体,E 是枚举,T 是模板。这套命名习惯从一开始就遵循,后面读项目的时候会省非常多脑力。
3.3 构造函数和 BeginPlay 的职责划分
头文件声明完,接着看 cpp 文件的实现:
cpp复制// MyActor.cpp
#include "MyActor.h"
AMyActor::AMyActor()
{
PrimaryActorTick.bCanEverTick = true;
}
void AMyActor::BeginPlay()
{
Super::BeginPlay();
}
void AMyActor::MoveForward(float DeltaTime)
{
FVector Location = GetActorLocation();
Location.X += MoveSpeed * DeltaTime;
SetActorLocation(Location);
}
这里涉及 UE 里非常核心的“初始化时机”问题。构造函数执行时,关卡还没有加载完成,World 指针可能还没有,你无法在构造函数里安全地访问场景中的其他 Actor,也不能调用 GetWorld() 做动态生成。所以,构造函数只做两件事:给变量设默认值、创建组件。 运行时需要依赖外部的初始化,放在 BeginPlay() 里。BeginPlay 是在关卡已经装配好、所有 Actor 都生成完之后才被调用的,这时才能安全地去查找其他对象、启动协程或网络连接。
在实际工作中,很多“开局就崩溃”的问题,都是因为在构造函数里做了需要在 BeginPlay 做的事,比如 GetWorld()->SpawnActor 或 GetPlayerController。这里有一个另类但常见的坑:构造期间 GetWorld() 返回空指针,你以为不会,但一旦地图加载顺序稍有不同,空指针解引用就来了。凡是不确定时机的问题,先迁到 BeginPlay,宁可跑慢一点,别赌运气。
3.4 把 C++ 类变成蓝图的可继承基类
写完上面这些代码,它已经是个 C++ Actor 了,但默认情况下蓝图还不能直接“继承”它。要让蓝图能继承,需要在 UCLASS() 里加上 Blueprintable:
cpp复制UCLASS(Blueprintable)
class MYPROJECT_API AMyActor : public AActor
保存编译后,右键内容浏览器创建蓝图类,搜索 AMyActor,就能选择一个基于这个 C++ 类的蓝图。蓝图子类里可以重写原生的 C++ 函数,也可以在事件图表里直接调用 MoveForward 节点。
这也是我推荐所有人早点从纯蓝图向 C++ 项目过渡的真正原因:C++ 类提供稳定、可复用的“底座”,蓝图在它之上做差异化和调节。 同一个 C++ 类可以派生出几十个蓝图,每个蓝图只维护自己的差异性——比如速度不同、颜色不同——而不必复制整个逻辑。真实项目里到后期,同一套玩法逻辑换皮几十次是很常见的事,没有这个底座,纯蓝图会让你复制到怀疑人生。
4. C++ 与蓝图接力:属于 UE 项目的高效分工模式
有了一个基础 C++ 类之后,下一步要考虑的问题就不是 C++ 或者蓝图选哪个,而是“这段逻辑到底放哪边”。我在项目里看得最多的低效代码,是工程师把每个变量都暴露给蓝图,结果策划随手就能在蓝图里改掉核心算法参数,出问题后根本无从排查。暴露接口这件事也需要节制。
4.1 数据结构的归属:什么放 C++,什么放蓝图
我的原则非常简单:规则属于 C++,选择属于蓝图。 比如一个伤害公式,它的算法规格、上限下限、生命周期,这些属于规则,放 C++,只暴露最终的计算结果和少数调节参数;而“这个怪物用什么颜色的火球打玩家”属于选择,放蓝图,让策划在一个有限的参数面板里做组合。
具体动作还是很明确的分工:
- 数据结构和网络协议:必须 C++,Blueprint 里维护不了复杂的 USTRUCT 嵌套。
- 数值公式和算法:必须 C++,方便单测和性能调优。
- 角色移动控制:C++ 提供基础移动功能,蓝图上做场地内的表现细节。
- UI 表现、过场动画、关卡机关:蓝图优先,迭代快。
- 简单布尔条件和轻量开关:蓝图直接写更直观,不用为了用 C++ 而用 C++。
4.2 UPROPERTY 的不同暴露级别决定了谁会动你的代码
UPROPERTY 后面的修饰符不是随便写的,它就是一个“权限边界”。我见过不少项目里所有字段都是 EditAnywhere,因为写的人图省事,结果任何蓝图实例都能改任何参数,数值系统直接失控。
这里有一组常用修饰符,我以此控制谁能在哪里改动:
| 修饰符 | 谁可以修改 | 典型用途 |
|---|---|---|
EditDefaultsOnly |
仅类默认对象和蓝图默认值中可编辑 | 全局统一的 Gameplay 参数 |
EditAnywhere |
类默认值和每个实例的细节面板都能编辑 | 每个个体差异很大的参数 |
VisibleAnywhere |
只能看,不能编辑 | 运行时计算的数值、状态 |
BlueprintReadOnly |
蓝图只读,不提供修改入口 | 内部状态、临时数据 |
BlueprintReadWrite |
蓝图可读可写 | 策划需要调节的玩法变量 |
这背后是“谁负责任,谁有权限”的思路。一个变量被谁改,出了问题就应该由谁背锅。C++ 工程师在写 UPROPERTY 时必须想清楚这个字段暴露出去到底是给谁用的,而不是让所有字段都无差别开放。
4.3 BlueprintImplementableEvent 与 BlueprintNativeEvent 的用法
这两个函数宏是 C++ 与蓝图互操作里最灵活、也是很多人理解最模糊的部分。
BlueprintImplementableEvent 表示:C++ 只声明、不实现,由蓝图去实现具体逻辑。典型场景是角色死亡,C++ 负责判定血量归零并触发事件,但死的时候播什么动画、放什么特效、镜头怎么拉,这些表现完全由蓝图处理:
cpp复制UFUNCTION(BlueprintImplementableEvent, Category = "Character")
void OnDeath();
在 C++ 代码里直接调用 OnDeath(),如果蓝图子类里实现了这个事件,就会执行蓝图逻辑;如果没实现,就什么都不发生。很适合做“引擎负责逻辑、内容团队负责表现”的事件钩子。
BlueprintNativeEvent 则更进一步:C++ 提供一个默认实现,蓝图可以选择重写。这个函数会在 C++ 里自动生成一个 函数名_Implementation 版本,你在这个版本里写默认逻辑。比如所有角色都要掉血,但有一类 Boss 有减伤,于是:
cpp复制UFUNCTION(BlueprintNativeEvent, Category = "Damage")
void ApplyDamage(float Amount);
// 在 cpp 中实现:
void AMyCharacter::ApplyDamage_Implementation(float Amount)
{
Health -= Amount;
}
蓝图子类里如果实现了这个事件,就以蓝图实现为准;如果不实现,C++ 就用默认的减血逻辑。这个模式特别适合需要“多数走默认、少数走特例”的机制,避免为了一个小例外去开一大堆继承类。
4.4 用 USTRUCT 搭数据驱动,而不是用散落蓝图变量
项目规模起来之后,经验做法是把配置数据集中到 USTRUCT,再用 DataTable 或 DataAsset 承载。比如定义一个怪物属性表:
cpp复制USTRUCT(BlueprintType)
struct FMonsterData : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float MaxHealth;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float MoveSpeed;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TSoftObjectPtr<UStaticMesh> Mesh;
};
然后用 DataTable 让策划填所有怪物的数值。C++ 代码只需要读取一行配置就能生成怪物,完全不需要为了改数值去动蓝图。这个模式最大的好处是,数据是数据、逻辑是逻辑,彼此不耦合。策划在编辑器里改一张表,不会碰到你的代码;而你修逻辑,也不用重新让策划刷数据。
5. 生命周期与内存管理:莫名其妙崩溃的根源大多在这
UE 在 C++ 之上提供的垃圾回收并不是全自动的,它有一套自己的规则。如果你拿默认的 C++ 思路去管理 UE 对象,崩溃只是早晚问题。
5.1 UObject 的独特管理方式
所有 UObject(也就是 U 开头的类及其派生类)都受 UE 的 GC 管理,但 GC 只管 UObject 对象本身,不包括你在类里用 new 出来的普通 C++ 对象。因此,UE 里创建 UObject 的标准姿势不是 new,而是 NewObject<T>(),创建 Actor 时用 SpawnActor<T>()。这是两个 API 使用上的分水岭。
GC 的触发时机也不是固定的,引擎会在特定场合自动执行,比如加载新关卡、手动调用 CollectGarbage、内存压力检测到需要释放时。GC 执行时,它会从所有“根”出发,通过你在 UPROPERTY 中声明的引用关系遍历所有可达对象;不可达的、且没有 AddToRoot 保底的对象就会被标记回收。这还没算上:即使对象还在,也不一定安全。因为 UE 提供了一种“可能会被销毁但指针还不知道”的引用模式,比如一个 Actor 被销毁了,别的对象里还握着它的旧指针。
5.2 TObjectPtr、TWeakObjectPtr 与裸指针怎么选
过去大家习惯了 AActor* OtherActor 这种裸指针写法。较新版本引擎里,官方推荐对 UObject 引用使用 TObjectPtr<T> 作为成员变量类型,它能更好地处理延迟加载。但真正做项目时,我更关心“这个引用是否该阻止对方被销毁”:
TObjectPtr<T>或裸指针:强引用,GC 会因为你的引用而保留它。TWeakObjectPtr<T>:弱引用,不阻止对方被回收。访问前需要IsValid()检查,对方销毁后你得到的是空指针而不是悬垂指针。TStrongObjectPtr<T>:智能指针形式的强引用,可以得当,但用在成员变量上会让 UObject 之间的管理变复杂,我通常只用在非 UObject 容器里管理 UObject 引用。
对于缓存了某个场景里对象的引用,别立刻决定用强引用。比如一个 UI 组件持有当前选中的玩家引用,如果玩家提前死亡销毁,强引用会把这个对象“续命”到 UI 销毁为止,造成“明明角色死了但 UI 还认为它活着”;反过来,用 TWeakObjectPtr,角色销毁后,UI 下一次访问就是安全的空引用,再优雅地做默认处理即可。
5.3 几个容易掉进去的生命周期坑
第一,Tick 里的临时 UObject 创建。如果你在 Tick 里频繁创建 UObject 而不做池化或延迟销毁,GC 还没跑就已经把内存吃满了。第二,在 Lambda 里捕获 UObject 裸指针。异步任务结束后,那个 UObject 可能已经没了。正确做法是捕获 TWeakObjectPtr,回调里先 IsValid() 再访问。第三,在 C++ 里人为调用 Destroy() 后立刻访问这个 Actor。Destroy() 只是挂了待删除标记,真正从内存移除可能是几帧之后,如果你在销毁后的同一帧里继续访问它的组件,有时不会崩,有时崩得毫无规律。
我在真实项目里还遇到过一种更隐蔽的情况:一个多人同步的刷怪点,C++ 脚本里在怪物死亡时把它从数组里移除,但数组里的条目是裸指针,没有检查 IsValid();在某些网络延迟高的客户端上,怪物死亡事件会重复触发两次,第二次时指针已经悬垂,直接崩溃。改成 TWeakObjectPtr 并加有效检查后,问题彻底消失。这类问题排查起来极其伤时间,因为崩溃点根本不指向数组,而是指向下一次遍历。
6. 编译、热重载与 IDE:UE 的构建链和普通 C++ 不一样
很多从普通 C++ 开发转过来的人会折在一个很尴尬的地方:代码写得没问题,但编译不过。UE 的编译流程不是“点一下 build”那么简单,它包含 UHT、UBT、链接器等多个环节。
6.1 UBT 和 UHT 在构建链里的角色
UnrealBuildTool(UBT)负责整个项目的构建编排,它决定编译哪个模块、使用哪个目标平台、引入哪些依赖。UnrealHeaderTool(UHT)专门做反射代码生成。这两个工具会在编译前把新增的 UCLASS、UFUNCTION、UPROPERTY 等信息变成生成的代码,然后才交给常规的 C++ 编译器。
实际影响有两层。第一层:头文件被 UHT 扫描的规则很严格。 宏后的括号必须匹配,类声明里不能有现代 C++ 里允许的各种花活,一旦 UHT 解析失败,编译会直接中断,而且报错提示通常不直观。第二层:新增 UPROPERTY 和 UFUNCTION 往往意味着生成的代码需要重新走一遍 UHT。 如果你只是改了函数体,增量编译会快一些;一旦改了宏声明,全量走的概率就高很多。
想减少 UHT 报错,一个实用技巧是:在类里尽量少放复杂的模板类型到 UPROPERTY 里。比如 TMap<FString, TArray<int32>> 倒还好,但如果你声明 TPair<FString, TWeakObjectPtr<AActor>> 作为成员变量,UHT 的反射生成可能在某些引擎版本里直接报不支持的属性类型。能用简单类型就用简单类型,复杂的泛型结构放到 F 开头的普通 C++ 结构体里,用全局函数或静态接口去访问,不会拖累反射系统。
6.2 Live Coding 的边界和使用习惯
Live Coding(编辑器里直接点 Compile)确实比传统“关了编辑器再编译打开”要快太多,但它不是万能的。它依赖对旧代码打补丁、热替换的方式生效,所以以下场景下可靠性会打折:修改了 UCLASS 的名称、增删了引擎模块、修改了 GENERATED_BODY() 位置、改了继承关系、改了一些 UHT 层面的核心元数据。这些操作往往会触发“Live Coding 无法应用,请重启编辑器”。
我的习惯是:日常改函数体、改逻辑实现时用 Live Coding;涉及头文件的宏声明、类结构、模块依赖时,老实重启编辑器再编译一次。强行用 Live Coding 做结构性修改,往往会遇到编辑器状态和代码状态不一致,然后出现各种奇怪的链接错误或莫名崩溃。这种错误会在你下次启动时消失,但它已经浪费了半小时。
另外一个容易被忽略的点:Live Coding 成功后,已经在关卡里的旧实例并不会自动重新执行构造函数。需要手动把它们从关卡里删掉再拖进去,或者调用一次 ReregisterAllComponents,否则你改了默认值后,关卡里旧的 Actor 仍然保持旧值,很容易造成“代码改了但跑起来没变化”的错觉。
6.3 编辑器与 IDE 的选择
Windows 上做 UE 开发,常见选项是 Visual Studio、Rider for Unreal、VSCode 加插件。Visual Studio 兼容性最稳,只要装好对应的 Windows SDK 和 .NET 运行时,基本一路畅通。Rider 对 UE 的反射、代码补全、导航支持得更好,缺点是吃内存,16G 以下的机器跑大项目会比较吃力。
VSCode 做得好的地方是轻量,配合 clangd 或者 C/C++ 扩展也够用,但你需要手动确认 IntelliSense 的配置文件与引擎版本匹配。UE 每换一个版本,compile_commands.json 或者 Workspace 配置都可能要重新生成。我见过有人被 VSCode 的 IntelliSense 报一堆红叉吓到不敢改代码,但其实那些红叉很多是配置问题,真正的编译错误要看输出面板。
无论选哪个,务必保证同一个工程只用一个 IDE 索引目录。如果你在 VS 打开过一次这个项目,又切换到 Rider,两个工具的缓存可能同时存在,导致代码提示互相串。虽然不会影响最终构建,但排查问题时很添乱。
6.4 Build.cs 模块依赖:用不了头文件先看这里
这是一个特别常见的“编译失败”来源。你明明在 cpp 里 include 了一个头文件,代码也写得很正,但链接时报 无法解析的外部符号,或者编译时直接说找不到头文件。原因多半是 Build.cs 里没有加对应的模块依赖。
Build.cs 文件是每个模块的构建配置,里面有一个 PublicDependencyModuleNames 数组,想用某个引擎模块的类,就要把模块名加进去。比如想用 UMG 的控件,需要加 "UMG";想用 AI 相关,需要加 "AIModule";想用 Niagara,要加 "Niagara"。只 include 头文件不够,模块加载和链接还需要对应的 import 声明。
这里有个我自己的排查习惯:新用到一个模块时,先去引擎源码或者官方示例里找同类的 Build.cs 看看它加了哪些依赖,而不是凭空从网上复制。不同引擎版本的模块划分有差异,网上答案经常因为引擎版本不同而坑人。
7. 那些年我帮人排查的 UE C++“玄学报错”
写 C++ 都有排查错误的一天,但 UE 的报错信息有时候特别不适合直接阅读,尤其是几个高频报错,把很多新手吓到以为是自己代码写错了,其实根本不是。这些报错在热搜里也常年出现,可以说每个 UE 开发者的必经之路。
7.1 “Ran out of memory allocating 528384 bytes” 不一定是 C++ 代码的问题
这个报错我在不同版本引擎里见过太多次,常见于加载大型关卡或使用大量纹理资源时。它说的是某个分配请求失败,但往往不是游戏里某一个 Actor 的逻辑导致的,而是资源加载超过内存预算,尤其是纹理流送池。下面这个 r.Streaming.PoolSize 命令,是把纹理流送池限制在一个固定值,如果池撑爆就会出现类似报错。
排查顺序我从经验里总结成这样:
- 先看是不是地图加载瞬间的峰值内存,如果只是加载时偶发,可以尝试调整
r.Streaming.PoolSize。 - 再确认是否是贴图尺寸过大,或者关卡里放了几百个非流送体积导致资源常驻。
- 最后才去怀疑代码里是否有持有某个大资源引用、阻止流送对象卸载的 leak。
C++ 层要自查的方向是:是否在内存敏感的容器(如 GlobalStore)里长期持有了一堆 UObject 引用,导致本该卸载的资源无法被 GC。但这种泄漏通常不会立刻导致 out of memory,而是每次加载关卡后内存一点点涨,最终在某一次加载时爆破。
7.2 “Unreal Engine is exiting due to D3D device being lost” 的排查思路
我第一次遇到这个报错时,第一反应是去查自己的渲染逻辑、查着色器代码,结果折腾半天发现跟代码关系不大。D3D device being lost 在 Windows 上的直接诱因通常是 GPU 驱动重置或显卡执行超时,最常见的是驱动崩溃后系统把设备重置了,UE 检测到设备丢失就直接退出。
所以排查顺序应该是:
- 先排除驱动问题:更新或回滚显卡驱动,特别是刚升级引擎版本后最常见。
- 检查系统事件日志里有没有 TDR 相关的报错,TDR 是 Windows 对显卡执行超时的保护机制。如果频繁出现,说明单帧 GPU 负载过大或者显卡散热有物理问题。
- 查看图形设置:是否开了超出显卡能力的特效,比如过分高的阴影分辨率、体积云、光追。
- 如果所有关卡都出现,再怀疑引擎或代码中是否调用了什么导致渲染状态自杀的接口。
如果你用统计工具确认单帧 GPU 时间很高,那问题还是性能层面的;如果 GPU 时间正常依然掉设备,优先怀疑驱动和硬件。我见过有人为了一个 D3D 报错改了一周代码,最后只是把显卡驱动从最新版回退到稳定版就解决了。
7.3 FString、UTF-8、TCHAR:字符串相关的一连串雷
C++ 在 UE 里处理字符串的方式和标准库完全错位。UE 里的字符串类型是 FString,字符类型是 TCHAR,在 Windows 上通常对应 wchar_t。直接拿 std::string 和 FString 互拼,会得到编码错乱或者编译错误。
比较标准的转换方式这么写:
cpp复制FString UEString = TEXT("你好");
std::string StdString = TCHAR_TO_UTF8(*UEString);
// 反向
FString FromStdString = FString(UTF8_TO_TCHAR(StdString.c_str()));
注意 TEXT() 宏是把字符串字面量包裹成 TCHAR 版本;不写 TEXT() 时在区分宽窄字符的平台上编译会出错。另一个常见坑是:做 asset 路径、Actor 名字时用 FName 而不是 FString,FName 是引擎内部做了哈希管理的不可变类型,比较快;但如果要动态拼接、修改内容,需要转回 FString。FText 则是给本地化 UI 显示用的,不能拿来做路径或逻辑判断。
我以前排查过一次“文本显示乱码”,最后发现是资源文件用的是 UTF-8 编码,但引擎读取时默认按本地代码页解析,而平台上文本编码不匹配,导致显示异常。这类问题在中文环境下非常容易出现,所以项目中统一要求源码文件用带签名 UTF-8 保存,并在字符串字面量里尽量避免中英文混排。
7.4 编译错误里的“隐藏刺客”:忘了分号、Include 顺序和命名冲突
新人在 UE 里写 C++,有一类错误出现频率高得离谱:输出面板报了一长串,翻到最上面发现只是忘了分号,或者少了某个头文件。问题是 UE 的编译流程长,错误信息又杂,几十个 error 里只混着一个真正的病因。我的方法是用分段注释法缩小范围:把最近改动的代码段注释掉一部分,观察错误是否消失,从而锁定是哪一行。听起来土,但解决问题最快。
还有一点:include 头文件的顺序。UE 生成的 .generated.h 必须在头文件中最后一个 include。把它写到最后一条是标准约定,因为生成代码依赖你类的完整声明。如果 .generated.h 不是最后一个,在一些特殊情况下会出现类型未定义或宏展开顺序错误。
另外注意类和变量的命名不要和引擎自带的冲突。我曾经见过有人给自己的组件命名 Mesh,恰好和引擎里 UStaticMeshComponent 的变量名冲突,导致头文件里一处 Mesh 指向引擎自带属性、另一处指向自定义成员,编译器报警告而运行时行为完全对不上。这种问题很隐蔽,只能靠“看到奇怪的歧义警告就把它当真”来防。
7.5 蓝图类“不生效”:判断是 C++ 侧还是蓝图侧的问题
最后分享一个排错思路,它其实比具体报错更通用。当你发现一个 C++ 类和蓝图的交互不生效时,别急着怀疑引擎或编译器,按以下顺序排查更有效:
- 先确认编译是成功的,并且你刚改的代码真的编译进去了。引擎开发期间很容易出现“以为编译了但实际没有”的状态。
- 再到要排查的蓝图实例的细节面板里,看那个 V 字小图标是否正常,V 图标表示变量被覆盖。如果它显示红色或者没有版本信息,说明 C++ 类已经修改但蓝图未同步,需要重新编译甚至关闭编辑器再开。
- 用
UE_LOG在 C++ 里打点,确认函数是否被调用到。蓝图环境里经常因为节点没连对,导致你以为应该调用的函数根本没触发。 - 最后才看代码逻辑本身。我见过最多的“不生效”案例,是 C++ 函数确实执行了,但因为某个参数在蓝图里被自己的逻辑覆盖了,导致你观察到的结果像是没走 C++。
UE_LOG 记日志是我最依赖的调试手段之一。比如在函数开头加一句:
cpp复制UE_LOG(LogTemp, Warning, TEXT("MoveForward called, Speed=%f"), MoveSpeed);
运行时去看 Output Log 就知道这个函数到底有没有进来,速度值是多少。很多跨语言层面的诡异问题,靠日志打点比靠断点更快定位,因为你会立刻把嫌疑范围缩小到“逻辑没执行”还是“执行了但值不对”。
回到文档开头的问题:UE 里用 C++ 到底难不难。我的答案其实落在“你能否接受这套规则”上。C++ 语法本身不会比你在大学课程里学得更难,真正需要适应的,是它和反射系统、垃圾回收、生成代码、构建工具之间那一整套复杂规则。一旦把规则吃透,C++ 带来的收益非常直接:你能高效地造工具、写算法、支撑团队协作,不会再被蓝图的节点连线束缚住。
我自己带项目的一个经验是:不要让新人一上来就啃整个引擎源码,而是先给一个小模块,比如做一个玩家状态组件,要求包含属性、事件、数据表读取三个环节。这套流程走通后,反射、GC、蓝图交互、构建配置的坑基本都会踩一遍。踩完这些,再回头看引擎源码,一切会清晰很多。
