ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

UE5 C++基础笔记:从蓝图到代码的实战指南

UE5 C++基础笔记:从蓝图到代码的实战指南

说实话,UE5里的C++和普通桌面应用的C++完全是两种“手感”。同样是敲代码,你在控制台项目里写个vector跑得欢,到了Unreal里先要考虑UCLASS、UPROPERTY、反射、GC……这不是嘴上说说,是整个思维方式的切换。很多从蓝图转过来的人,第一周基本是懵的,不是因为语法不会,而是因为“这套引擎到底想让我怎么表达逻辑”没搞明白。

这份UE5 C++基础笔记,是我给自己这类半路转过来的开发者整理的,也是给刚入行想做引擎开发、或者想摆脱蓝图性能瓶颈的同学看的。内容覆盖环境搭建、对象模型、组件与输入、容器与委托、踩坑实录和性能习惯,全部来自我自己动手实现Demo时的一些真实体会。标题写了“持续更新”,就是因为这部分内容确实不是一遍能写全的,后面还会继续补。

1. 先把环境与工程结构弄明白:UE5 C++不是“套了个类库的C++”

很多新手犯的第一个错误,是把UE5 C++当成普通C++项目来写,结果一上来就被头文件、宏和生成代码砸懵了。其实UE5里写C++,核心目的不是“实现算法”,而是“让引擎认识你的类型”。所以第一步不是背API,而是理解Unreal的工程组织方式和构建流程。

1.1 蓝图与C++的关系:不是二选一,是分工

我先说结论:在UE5里,C++和蓝图不是竞争关系,而是“底层逻辑用C++,上层玩法用蓝图”的协作关系。

底层的角色行为、资源加载、数据解析、复杂算法,放在C++里,可靠、可版本控制、可复用。而具体的关卡事件、时间轴、动画蓝图状态切换、某些策划想频繁调整的数值,用蓝图更方便,因为改完不用编译,打开编辑器就能试。

我刚上手时有个执念,觉得“既然学了C++,所有东西都写C++才算合格”。后来发现这个思路很蠢。比如一个门被打开时的音效延时播放,用蓝图连几根线一分钟就搞定,用C++写个FTimerHandle虽然也不算复杂,但改一次要编译一次,折腾半天。反过来,如果一个角色跳跃的物理参数和状态切换全靠蓝图连线,一旦节点多了,排查问题头大得要命。

好的工程习惯是:把“机制”写在C++,把“策略”交给蓝图。机制是你确定不变的地基,策略是那些可能被反复调整的玩法参数。

1.2 理解工程目录与模块构建

正常情况下,创建一个C++项目后,Source目录下面会有一个以项目名命名的模块文件夹。里面有四个文件比较重要:

  • 项目名.h/项目名.cpp:主模块声明与实现
  • 项目名.Build.cs:依赖模块声明
  • 项目名.Target.cs/项目名Editor.Target.cs:目标平台构建配置

刚开始不用全懂,但Build.cs你得能看懂:它里面有一行PublicDependencyModuleNames.AddRange(...),后面列了一大串模块名,比如Core,CoreUObject,Engine,InputCore。你每新增一个功能领域,就要在这里加对应的依赖。比如想做AI,加AIModule;想做UMG界面,加UMG;想用增强输入,加EnhancedInput。

我见过不少同学自己在项目里加了插件,代码里把头文件都写好了,一编译报“无法打开头文件”,最后发现就是没在Build.cs里加上对应的模块依赖。这是UE工程里最基础也最容易被忽略的一步。

1.3 创建第一个C++类的正确姿势

我推荐两种常见方式:一种是直接在编辑器里右键内容浏览器,选择“Add C++ Class”,然后通过向导生成;另一种是先在编辑器里创建一个蓝图类,给类设置好默认资源和组件,之后再“基于蓝图生成C++类”把逻辑收编回代码。两种方式学习阶段都用得上。

有几个极其重要的工程细节,新手期最好先记下来:

  • 头文件里的#include "xxx.generated.h"必须放在该文件所有#include的最后一行,否则编译会报诡异错误。这是UE的生成代码机制要求的,你没法绕过。
  • 每个UCLASS类头的上方必须有UCLASS()宏,类名一般加个前导A代表Actor、U代表对象、F代表结构体、E代表枚举,这既是约定,也便于看代码时快速识别类型种类。
  • 第一次编译建议用IDE,或者用编辑器里的编译按钮,把报错信息看明白再动手改。

提示:不要手动编辑*.generated.h文件。这个文件是构建系统自动生成的,每次编译都可能重写。手动去改,下次编译就白写。

2. 必须分清的三个基类:UObject、AActor与UActorComponent

UE5的类体系看起来复杂,但你现阶段只需要把三条主干抓清楚:UObject是引擎万物之源,AActor是能放到关卡里的“物体”,UActorComponent是挂在Actor身上的“功能块”。

2.1 三类核心对象的定位与选择

先看一个对照表,后面展开讲:

基类能否放入关卡是否拥有Transform典型用途
UObject否否数据对象、逻辑管理器、异步任务
AActor是是角色、门、灯、触发器、可产生可销毁的实体
UActorComponent否取决于组件类型移动、渲染、碰撞、音频等附加功能

UObject本身不会出现在关卡里,它主要承担被引擎管理的数据与状态。比如你想写一个全局的数据缓存,或者一个追踪任务状态的对象,不需要它有三维位置,那用UObject就够了。

AActor是能放进关卡的实体,有Transform,有生命周期,能被生成和销毁。你在关卡里看到的绝大多数东西,什么地面、光、NPC、玩家控制器,本质都是Actor。

UActorComponent则是一块高度内聚的功能。为什么要有组件?因为一个Actor往往需要很多能力:能移动、能显示网格、能播放声音、能响应碰撞。如果全塞进Actor里,类会巨大无比。组件化之后,可以按需组合。比如一个敌人,挂载UStaticMeshComponent显示身体,挂载UCapsuleComponent做碰撞判定,挂载UAudioComponent播放脚步,挂载UCharacterMovementComponent负责移动逻辑。

选择时记住一句话:需要被放置、被生成、有独立于场景的实体感,用Actor;只是辅助能力,用Component;只是被管理的纯数据与逻辑,用UObject。

2.2 反射宏:UPROPERTY与UFUNCTION到底在干嘛

反射机制是UE C++和普通C++最大的分水岭。简单说,UE通过工具在编译前扫描你的代码,把用UPROPERTY和UFUNCTION标记的成员收集起来,生成额外的代码,让你可以在运行时查询类的属性、调用函数、序列化数据,并且把C++变量暴露给蓝图和编辑器面板。

这意味着:一个C++变量如果加了UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Movement"),它就能直接出现在细节面板里,允许你在编辑器里改数值,也能被蓝图读写。如果不写UPROPERTY,这个变量对编辑器完全不可见,甚至可能在优化中被处理掉。

我见过的误区是:新人把UPROPERTY当成“给蓝图用的标记”,只在需要暴露给蓝图时才加。这个理解不全面。实际上,它还是垃圾回收引用的重要标识,也是存档系统、网络复制的依据。哪怕你完全不用蓝图,一个UObject指针字段如果不加UPROPERTY,在GC扫描时它可能不会被正确追踪,悬垂指针问题就这么来的。

UFUNCTION同理。想让C++函数被蓝图调用,加BlueprintCallable;想监听蓝图事件,加BlueprintImplementableEvent;想在C++里调用蓝图实现的事件,用BlueprintNativeEvent。这套修饰词决定了C++与蓝图的交互边界,值得专门花时间整理一张表记牢。

2.3 生命周期与Tick

一个Actor的典型生命周期顺序大概是:构造函数 →BeginPlay→ 每帧Tick→EndPlay→ 析构。很多新手刚上手会犯一个错误:在构造函数里加载资源、绑定输入、获取其他Actor引用。

构造函数发生在引擎还未完全启动的环境中,此时做某些操作很危险,比如GetWorld()返回空、SpawnActor不能调用。构造函数里适合做的事,是为属性设置初始值、加载ConstructorHelpers中的Asset、创建组件。而运行时逻辑,比如找玩家、注册事件、初始化状态,放到BeginPlay里做。

Tick不是不能用,但每帧调用是有成本的。如果你的逻辑不需要每帧更新,完全可以用定时器、事件通知来替代。这也是后面性能章节要展开的内容。

2.4 UE的垃圾回收机制怎么用

UObject的内存由引擎的GC系统管理,不能直接delete。GC系统会定期扫描,把不再被引用的UObject回收掉。

那垃圾回收器怎么判断“不再被引用”?主要看有哪些地方还持有这个对象的强引用。如果你在一个UObject成员变量上加了UPROPERTY(),这个引用就会告诉GC“我还指着它呢,别回收”。如果一个UObject指针既没有UPROPERTY、对象也没AddToRoot,那它是真正的“裸引用”,随时可能被GC干翻。

我踩过的坑是:我在一个不需要UPROPERTY的临时变量里保存了某个Actor指针,结果过了几秒再用时,指针悬垂了,访问就崩溃。排查了半天,发现是GC把这个Actor回收了。后来习惯改成:网络或跨帧存UObject引用时,一律写UPROPERTY()。如果你确实需要临时存一下,推荐用TWeakObjectPtr<T>,弱引用不会阻止GC,还能通过IsValid()判断是否还活着。

注意:不要对UObject调用delete。即使你确定对象不再需要,也应该通过Destroy()或让引用失效来结束生命周期。自己delete会绕过GC系统,内存状态直接错乱。

3. 动手写一个可控制的角色:组件、碰撞与输入

知识和逻辑讲再多,都不如亲手写出一个能在场景里跑起来的角色印象深刻。这个环节我建议你做一个最经典的运动能力Demo:胶囊体作碰撞、静态网格作显示、弹簧臂加摄像机跟随、键盘控制前后左右移动。

3.1 组件挂载与依赖顺序

创建角色类时,一般继承ACharacter,因为它自带UCharacterMovementComponent,省去自己写移动逻辑的麻烦。然后在构造函数里加可视与碰撞组件。

组件挂载的顺序很关键,因为后挂载的组件会默认挂在最后挂载的那个组件下面,依赖于父组件的Transform。一个比较稳的顺序是:先用CreateDefaultSubobject创建胶囊体碰撞组件,把它设为RootComponent;再创建静态网格组件,SetupAttachment到胶囊体上;再创建弹簧臂USpringArmComponent,挂到胶囊体上;最后创建摄像机组件,挂到弹簧臂上。

这种链状结构看起来简单,实际隐藏着依赖顺序的问题:如果先创建摄像机并设置SetupAttachment到一个还没创建的组件上,编译器直接报错。所以动手前先想想你的组件树是什么结构,别蒙头写。

3.2 传统输入绑定与增强输入系统

UE5里输入方案有两套并存:老派的InputComponent绑定Action/Axis,和新版增强输入系统Enhanced Input。

如果你是新建第三方模板的UE5项目,默认建议用增强输入。增强输入的优势在于:支持按键触发、组合键、输入优先级、依赖UInputAction资产和UInputMappingContext资产,配置灵活,而且和古旧的轴映射相比,能更精确表达“按住”“单次”“双击”这些手势。

增强输入的标准流程:

  1. 内容浏览器里创建两个资产:Input Action(比如IA_Move),Input Mapping Context(比如IMC_Default)。
  2. 在IMC_Default中把IA_Move和某个轴映射关联,比如W对应Y轴正方向,S对应Y轴负方向。
  3. 在角色C++头文件里声明UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input) class UInputAction* MoveAction;
  4. 在BeginPlay里获取UEnhancedInputLocalPlayerSubsystem,把IMC_Default添加到当前玩家。
  5. 在SetupPlayerInputComponent里通过UEnhancedInputComponent的BindAction绑定处理函数。

处理函数一般接收一个FInputActionValue,里面包含向量或浮点数据。比如移动函数可以这样读值:

void AMyCharacter::Move(const FInputActionValue& Value) { FVector2D InputVector = Value.Get<FVector2D>(); AddMovementInput(GetActorForwardVector(), InputVector.Y); AddMovementInput(GetActorRightVector(), InputVector.X); }

这里GetActorForwardVector()是基于角色当前朝向的单位向量,而Value.Get<FVector2D>()是增强输入传进来的二维方向输入。AddMovementInput会把这个方向交给CharacterMovementComponent处理,最终驱动角色移动。

3.2.1 为什么我建议尽早切到增强输入

刚入门的人可能会觉得老的BindAxis更简单,毕竟代码量少。但我的建议是,除非你要维护一个老项目,否则新项目直接从增强输入开始。原因是:老输入系统的键盘和手柄支持、组合动作、UI防冲突这些都要自己想办法,而增强输入把这些问题用资产化配置一次性解决了。你后续做复杂动作系统时,会感谢一开始就选择了这套方案。

3.3 编辑器与C++类联动时的注意事项

类写好后,编译完成,如果你想在关卡里放置这个角色,有两种路径:直接拖C++类到场景,或者先创建基于它的蓝图类再拖。学习阶段我建议用后者。

一旦你从C++类派生了蓝图类,后面如果修改了C++类里的类名、构造函数结构或者组件结构,蓝图可能需要重新生成,有时会导致蓝图引用的默认组件丢失。遇到这种情况不用慌,先看输出日志,一般会提示“Missing root component”或者“The class does not have a root component”。解决思路是:检查C++构造函数中是否已经把某个组件设置成了RootComponent,确认后重新生成蓝图。

4. 常用容器、智能指针与委托:代码层面的“日常食粮”

当你开始写有一定复杂度的C++逻辑时,会发现整天在和容器、内存、回调打交道。UE提供了一套自己的容器和智能指针,和STL长得像,但细节不一样,踩坑点也不少。

4.1 TArray、TMap与TSet的使用取舍

TArray是最常用的动态数组,支持索引访问和范围for循环。TMap是键值映射,底层类似哈希表。TSet是集合,用于快速判断“是否存在”且不保存重复元素。

很多新人会问:为什么不用std::vector和std::map?技术上能用,但UE容器在设计时就考虑了内存跟踪、类型安全和与蓝图/反射的互操作。比如UPROPERTY()支持TArray<T>和TMap<T,T>,但不支持std::vector。在UE环境里,保持使用UE容器是更顺手的做法。

一个实际经验:在循环里要“边遍历边删除”时,不要直接在范围for里执行Remove,会导致迭代器失效。标准做法是倒序遍历,或者先收集待删除索引、循环结束之后再统一删除。类似的坑在TMap上也有,删除元素前先考虑是否会影响当前迭代状态。

4.2 智能指针家族:什么时候用哪个

UE里有TSharedPtr、TWeakPtr、TUniquePtr,还有一套UObject体系下的TWeakObjectPtr。很多人容易混淆。

核心记忆方法:

  • 操作非UObject的对象,比如纯C++计算类、音频缓冲区、某个复杂的数据分析器,用TSharedPtr,它允许共享所有权,引用计数自动管理。
  • 不拥有资源、只需要旁观引用的,用TWeakPtr,它不会增加引用计数,使用前需要Pin()升级为强指针。
  • 独占所有权的对象,用TUniquePtr。
  • 操作UObject对象时,不要用TSharedPtr去管理UObject生命周期,UObject的生死归GC管,不是引用计数。想安全引用UObject,用TWeakObjectPtr。

这个分界线很重要。我见过有人用TSharedPtr<UStaticMeshComponent>去管理组件,结果GC和智能指针双管齐下,状态乱成一团,调试了整整一天。UObject体系和非UObject体系,内存管理模型是两套,不能混用。

4.3 委托系统:C++世界的“事件广播”

UE的委托(Delegate)是整个引擎解耦的基础。类型上分单播和多播,也可以分为静态绑定和动态绑定(可暴露给蓝图)。

单播委托DECLARE_DELEGATE_...只能绑定一个函数,多播委托DECLARE_MULTICAST_DELEGATE_...可以绑多个,广播时按绑定顺序执行。动态委托DECLARE_DYNAMIC_MULTICAST_DELEGATE_...则多了一个UPROPERTY能力,可以在蓝图上绑定事件,比如一个门打开后广播“打开完成”,蓝图里可以监听这个事件播放动画。

绑定方式也比较丰富:可以绑定到成员函数AddUObject、绑定到Lambda、绑定到静态函数。在组件和Actor通信时,委托比直接调用AClass的公开函数更松散,两边不需要强依赖对方的具体类型,只需要约定好委托签名。

我建议新手至少熟练掌握:多播委托的声明、Broadcast、AddDynamic和RemoveDynamic这几个接口。因为后面做项目,大量交互都会用到这套机制。

4.4 现代C++特性在UE里的落地

UE5的C++标准已经支持到C++20级别,你用auto、lambda、结构化绑定都没问题。但有几个地方需要特别注意:

  • constexpr和consteval等编译期特性,在UCLASS类里使用要小心,因为有些宏展开后的代码不一定符合这些特性的要求。我遇到过在UPROPERTY修饰的成员附近写constexpr变量,导致编译生成文件报错的情况。解决方法是不要让这类现代C++特性与UCLAS S宏在同一个声明上下文里混用。
  • std::function可以用,但如果在UObject上存std::function,要注意生命周期;更推荐用UE自身的TFunction或委托体系。
  • Lambda非常好用,比如在异步操作、定时器、排序算法里,直接一个[]捕获状态,比传统写法简洁得多。但Lambda捕获UObject指针时要小心悬垂,优先捕获WeakPtr,或者在回调里用IsValid()检查。

5. 踩坑实录与排查技巧

下面这部分,我尽量挑一些“没人提醒过、但早晚会让你抓狂”的问题来写。

5.1 编译报错:一切源头都是“生成代码”在作怪

UE的编译报错里,最大的套路就是错误信息看起来不在你写的代码里,而是指向某某.generated.h。这时候先别慌,大概率是你头文件某个宏写错了、UPROPERTY参数不合法,或者UFUNCTION()前没有正确宏。

我遇到过的一个高频报错:“Unrecognized type ...”,通常是因为某个类型在头文件里用了但没#include对应头文件,或者只前置声明了而UE生成代码需要完整定义。解决办法:在头文件顶部补上对应头文件的#include,比如使用USceneComponent就必须#include "Components/SceneComponent.h"。

另一个极高频率的坑是:头文件里少写了#include "文件名.generated.h",编译器报的错误却是“缺少结束标记”或者一大堆无关错误。我建议在创建新类时,老是反复检查这个include,它花不了两秒,却能省下半小时的排查时间。

5.2 运行时崩溃:最常见的是“空指针”与“GC悬垂”

在UE里崩溃,排查思路比普通C++多一步:先看崩溃调用栈是否包含UObject相关代码,再看是否存在“已销毁”的对象。

我自己比较常用的排查手段:

  • 在可能悬垂的地方,先执行if (MyActor && IsValid(MyActor))再访问,能挡住一部分情况。
  • 输出日志,在关键调用点打印__FUNCTION__和对象名,用二分法缩小崩溃范围。
  • 对UObject,用IsValid()比直接判空更安全,因为一个UObject可能被标记为PendingKill。

5.3 热重载与Live Coding的坑

UE5的Live Coding允许你在不重启编辑器的情况下编译C++代码,听起来很方便,但实际操作中极易埋雷。比如你在C++类里增加了一个UPROPERTY,热重载后,旧蓝图的实例可能没有同步新属性,导致编辑器上显示默认值不对。

我的习惯是:如果是改头文件结构、增加新的反射成员、修改组件类型,就直接关闭编辑器重新编译;只有改纯函数实现、改.cpp里的小逻辑,才用Live Coding。这不是保守,是节省整体时间。

5.4 蓝图与C++同名类的混淆

这个问题非常隐蔽。你创建了一个C++类AMyActor,然后又在蓝图里创建一个叫BP_MyActor,如果在某个数据资产里引用的名称拼错了,编辑器不一定会立刻报错,运行时却找不到类。

排查方式是:检查该类的引用是否真的指向C++类,或者指向了派生的蓝图类。函数GetClass()返回的UClass*可以通过比较GetName()和预期名称来验证。养成从C++侧追踪蓝图的习惯,避免名字带来的精神内耗。

5.5 中文路径与命名问题

项目或文件使用了中文路径、中文项目名,在编译时会产生一些奇怪的错误,比如Target.cs找不到文件、生成的Binaries路径无法识别。UE历来的稳定建议都是:工程路径不要有中文,类名用英文,资源名用英文+数字。这个限制虽然烦人,但越早遵守越省事。

6. 性能习惯与调试基本功:从“能跑”到“跑得稳”

基础笔记如果只讲语法,不涉及习惯,很容易让人陷入“写出来能跑就行”的假象。UE项目的后期瓶颈通常不在逻辑复杂度,而在糟糕的每帧行为和不必要的分配。

6.1 日志是游戏开发者的第一调试工具

UE里打印日志最常见的是UE_LOG(LogTemp, Warning, TEXT("..."))。里面LogTemp是日志类别,可以自定义;Warning是日志级别;TEXT("...")是宽字符字符串宏。

一个小技巧:在排查问题时,日志要敢写、多写,关键函数入口、分支条件、数值变化都打出来,但正式发布前记得清理或降级。别用PrintString,它面向运行时调试,刷屏快且影响性能。UE_LOG可以输出到编辑器输出日志和日志文件,适合复杂的运行期分析。

6.2 Tick的替代方案

Tick每帧执行,但很多东西其实不需要每帧执行。比如门每隔10秒自动关闭、敌人每3秒检查一次玩家是否可见、小怪死亡后2秒销毁,这些都可以禁用Tick。

改用定时器:

GetWorldTimerManager().SetTimer( TimerHandle, this, &AMyActor::OnDelayHandleTimer, 2.0f, false );

这样可以显著减少每帧开销。角色控制、平滑插值、相机跟随确实需要每帧Tick,但每帧里也不要频繁创建对象、调用SpawnActor、扫描遍历场景中所有Actor。如果必须扫描,优先用TInterval或触发器事件,而不是每帧ForEachActor。

6.3 避免频繁创建与销毁对象

游戏运行中最容易卡顿的一个行为是“同一时间大量Spawn和Destroy”。比如连续开枪,每次打出一个子弹Actor,飞行几百毫秒,然后生成弹壳,又销毁弹壳。粒子效果多、Actor数量上升、GC频繁触发,帧率瞬间掉下来。

一个简单可行的思路:用对象池。提前生成一定数量的子弹Actor或弹壳Actor,隐藏起来,需要时取出、设置位置、显示,使用完再隐藏回池子。这能极大减少GC压力。C++侧实现对象池并不复杂,基础版本就是维护一个TArray<AActor*>列表,取出第一个可用的,用完还回去。

6.4 编辑器与运行时性能观测

我建议从开始就养成查看“Stat”命令的习惯。在编辑器运行状态下,按~键打开控制台,输入Stat Unit可以看帧耗时,Stat Game看游戏逻辑耗时,Stat Memory看内存情况。如果游戏代码突然变卡,Stat基本能直接告诉你瓶颈在逻辑线程还是渲染线程。

如果帧率低,先别急着优化单个函数,先用Stat和Profiler定位热点再动手。性能优化最忌讳“感觉哪里慢就改哪里”,没有数据支撑的优化常常是负优化。

7. 一些长期有效的工程习惯

最后再分享几个我实际操作中觉得值得长期坚持的习惯,也算“持续更新”这个笔记系列里的固定栏目。

第一,每次写完一个C++类,先问自己三个问题:这个类的职责是否单一?是否需要暴露给蓝图?是否明确加上了必要的UPROPERTY?这三个问题是代码质量的入门门槛。

第二,把引擎版本相关的内容单独开一个文件记录。UE5的小版本更新,比如从5.0升到5.3,某些API会标记废弃或行为变化。我遇到过因为FName的构造函数变化导致整个项目编译警告爆炸的情况。版本变更笔记,后期价值极高。

第三,遇到报错不要先猜,先看Output Log的最上面和调用栈的顶部。UE的报错信息有时候很长,但关键的往往是第一行。把第一行读懂,搜索引擎都有答案,比瞎猜高效得多。

这个笔记内容是从我自己的学习和踩坑过程中沉淀出来的,相比官方文档,更偏“上手就能用”的路线。后面“持续更新”计划里,我准备重点补充材质与渲染接口、多线程任务、网络同步、UMG与Slate这几个方向。如果你在这个基础篇里有什么实际体验或者想让我展开的方向,也可以在交流区留言,这些反馈会直接成为下一期笔记的素材。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进