1. 从"能跑"到"跑得对":UE实战里最容易被忽视的工程化断层
很多人学UE的路径都差不多:跟着教程拖几个Actor,连几条蓝图线,做个能跑能跳的角色,然后觉得自己"会UE了"。但真正进项目之后,第一个让人懵的场景往往不是渲染管线有多复杂,而是——为什么我的C++类改了一行代码,编辑器要编译三分钟?为什么GameplayTag在蓝图里搜不到?为什么打包出来的版本和编辑器里跑的表现完全不一样?
这些问题的共同点是:它们都不属于"引擎原理"层面,而属于工程化实践层面。市面上的UE教程大多集中在两个极端——要么是纯蓝图入门,要么是直接啃渲染源码。中间这一层"用C++正经做UE项目"的实战经验,反而很少有人系统讲。
这篇内容就是冲着这个断层来的。我会围绕UE的C++工程实践、Gameplay框架的落地用法、渲染管线的可干预点、以及打包与性能调优这几个方向,把我在实际项目中踩过的坑和总结出来的做法摊开讲。适合已经能写基本C++、用过一段时间UE蓝图、但一到C++和引擎交互就发怵的开发者。如果你还在纠结"UE到底该用蓝图还是C++",那这篇也能帮你把边界划清楚。
需要提前说明的是,UE的版本差异很大,我下面提到的内容以UE5.x为主线,涉及UE4的地方会单独标注。另外,所有代码和配置都是基于实际项目验证过的思路,但具体到你的项目可能需要微调——引擎这东西,没有银弹。
2. UE的C++工程结构:为什么你的编译总是那么慢
2.1 模块划分不是可选项,是必选项
刚接触UE C++的人最容易犯的一个错误,就是把所有代码都塞进一个Game模块里。项目小的时候没问题,一旦代码量上去,编译时间会呈指数级增长。原因在于UE的编译模型:每个模块是一个独立的编译单元,模块内的源文件在修改后需要重新编译整个模块。
正确的做法是从项目一开始就做模块拆分。一个典型的UE项目至少应该有这么几个模块:
- Core模块:放基础类型、工具函数、接口定义,不依赖任何其他游戏模块
- Gameplay模块:放Actor、Component、GameMode等游戏逻辑
- UI模块:放UMG的C++基类和ViewModel
- Editor模块:放编辑器扩展工具,只在编辑器构建时编译
模块之间的依赖关系通过.Build.cs文件里的PublicDependencyModuleNames和PrivateDependencyModuleNames来控制。这里有个关键细节:Public依赖会传递给依赖你的模块,Private依赖不会。所以头文件里用到的类型放Public,只在cpp里用到的放Private。这个区分做好了,能显著减少不必要的重编译。
// Source/MyGame/MyGame.Build.cs PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "GameplayTags", "GameplayTasks", "EnhancedInput" }); PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore", "UMG", "Niagara" });2.2 头文件里少include,多用前向声明
UE的编译慢,很大一部分原因是头文件包含链太深。Engine.h这种万能头文件在项目里出现一次,就会把大半个引擎拖进编译单元。我的习惯是:
- 头文件里能用前向声明的,绝不include
- 需要UCLASS/UPROPERTY的指针类型,前向声明就够了
- 只有在头文件里需要完整类型(比如按值成员、模板参数)时才include
- cpp文件里再统一include需要的头文件
// 好的做法 #pragma once #include "CoreMinimal.h" #include "GameFramework/Actor.h" #include "MyActor.generated.h" class UStaticMeshComponent; class UMyDataAsset; UCLASS() class MYGAME_API AMyActor : public AActor { GENERATED_BODY() public: AMyActor(); protected: UPROPERTY(VisibleAnywhere) TObjectPtr<UStaticMeshComponent> MeshComp; UPROPERTY(EditAnywhere) TObjectPtr<UMyDataAsset> DataAsset; };注意UE5推荐用TObjectPtr<T>替代裸指针作为UPROPERTY成员,这是为了支持增量垃圾回收和更安全的引用追踪。UE4项目里用裸指针也没问题,但新项目建议直接上TObjectPtr。
2.3 编译加速的实操手段
除了模块拆分和头文件管理,还有几个立竿见影的加速手段:
启用Unity Build。UE默认开启Unity Build,会把多个cpp合并成一个编译单元。这能大幅减少编译时间,但代价是容易出现符号冲突。如果遇到"重复定义"的报错,可以在对应的cpp文件顶部加:
#include "MyFile.h" // 在文件末尾加这个,把这个文件从Unity Build中排除或者在.Build.cs里针对特定文件禁用:
bUseUnity = false;使用Live Coding。UE5的Live Coding可以在编辑器运行时热重载C++代码,改函数体不用重启编辑器。但它有局限:不能加新的UCLASS/UPROPERTY,不能改头文件里的反射声明。我的用法是:小改动用Live Coding,涉及反射的改动老老实实关编辑器重编译。
Incremental Compilation。UE5.3之后对增量编译做了不少优化,确保你的编译工具链是最新的。另外,把项目放在SSD上,这个不用多说。
注意:不要为了编译快就把
bUseUnity全局关掉,那样单个文件编译变快但总体编译时间会暴涨。Unity Build的问题应该通过排除个别冲突文件来解决,而不是一刀切。
3. Gameplay框架的落地:GAS不是万能药,但不用它你会更痛苦
3.1 什么时候该上GAS
Gameplay Ability System(GAS)是UE里最强大也最容易被滥用的框架。我见过两种极端:一种是什么项目都硬上GAS,做个走路模拟器也要搞AbilitySystemComponent;另一种是死活不用GAS,用蓝图硬搓技能系统,最后搓出几千个节点的意大利面。
我的判断标准很简单:如果你的项目有"状态"和"效果"的概念,且这些状态需要被网络同步、被UI查询、被其他系统响应,那就该用GAS。具体来说:
- 有技能/法术/攻击概念的动作游戏、RPG、MOBA——必须用
- 有Buff/Debuff系统的任何游戏——建议用
- 纯解谜、纯叙事、无战斗的游戏——可以不用
- 单机小项目——看复杂度,简单的话手搓也行
GAS的核心价值不在于"放技能",而在于它提供了一套属性-效果-标签的统一模型。AttributeSet管数值,GameplayEffect管数值变化,GameplayTag管状态标记。这三者组合起来,能覆盖绝大多数游戏逻辑需求。
3.2 AttributeSet的初始化时机陷阱
AttributeSet的初始化是GAS新手最容易踩的坑。很多人把初始属性写在构造函数里:
UMyAttributeSet::UMyAttributeSet() { Health = 100.0f; // 这样写有问题 MaxHealth = 100.0f; }这样写在单机下可能没问题,但在网络环境下会出大问题。因为AttributeSet在客户端和服务端都会构造,构造函数里的值会被后续的复制覆盖,导致属性不一致。
正确的做法是用PreAttributeChange或者通过GameplayEffect来初始化:
void UMyAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) { Super::PreAttributeChange(Attribute, NewValue); if (Attribute == GetHealthAttribute()) { NewValue = FMath::Clamp(NewValue, 0.0f, GetMaxHealth()); } }然后在角色初始化时,通过一个InitGameplayEffect来设置初始值。这个GE的Duration设为Instant,Modifier用Override类型。
3.3 GameplayTag的组织策略
GameplayTag用得好不好,直接决定GAS项目的可维护性。我的经验是:
- 层级不要超过4层。
State.Character.Burning可以,State.Character.Elemental.Fire.Burning.Stacking就太深了 - 用Tag做查询,不要用Tag做数据存储。Tag只回答"是不是"的问题,不回答"是多少"的问题
- 在项目设置里预先定义好Tag树,不要等到用的时候随手加。UE的GameplayTag编辑器支持从DataTable导入,团队协作时这个功能很关键
一个常见的Tag组织方式:
Ability.Character.Dash Ability.Character.Attack.Light Ability.Character.Attack.Heavy State.Character.Stunned State.Character.Invulnerable State.Character.Burning Effect.Damage.Fire Effect.Buff.Speed查询的时候用HasMatchingGameplayTag配合FGameplayTagContainer,比字符串比较高效得多。
3.4 GAS与蓝图的边界
GAS的C++部分和蓝图部分怎么分工,也是个常见困惑。我的原则是:
- AttributeSet、AbilitySystemComponent的配置、GameplayEffect的C++基类——用C++
- 具体的Ability逻辑、GE的参数配置——用蓝图
- UI绑定——用蓝图,通过AttributeChange委托来更新
这样分工的好处是:核心框架稳定,策划可以在蓝图里调数值和逻辑,不用每次改都编译C++。
// C++里暴露给蓝图的Ability基类 UCLASS(Abstract, Blueprintable) class MYGAME_API UMyGameplayAbility : public UGameplayAbility { GENERATED_BODY() public: UMyGameplayAbility(); protected: UFUNCTION(BlueprintCallable, Category = "MyAbility") void ApplyDamageToTarget(AActor* Target, float Damage); };4. 渲染管线的可干预点:不是所有东西都要改源码
4.1 先搞清楚哪些能改、哪些不该改
一提到"渲染管线",很多人的第一反应是"要改引擎源码"。但实际上,UE提供的渲染扩展点已经覆盖了90%的需求。在动源码之前,先确认这些机制能不能满足你:
| 需求类型 | 推荐方案 | 是否需要改源码 |
|---|---|---|
| 自定义材质效果 | Material Editor + Custom Node | 否 |
| 后处理效果 | PostProcessMaterial | 否 |
| 自定义渲染Pass | SceneViewExtension | 否 |
| 修改GBuffer布局 | 引擎源码 | 是 |
| 自定义光照模型 | ShadingModel + 源码 | 部分 |
| 全局着色器修改 | 引擎源码 | 是 |
SceneViewExtension是UE5里最被低估的扩展点。它允许你在渲染管线的任意阶段插入自定义Pass,而且不需要改引擎源码。用法是实现FSceneViewExtensionBase的子类,然后在StartupModule里注册:
void FMyModule::StartupModule() { FSceneViewExtensionManager::Get().AddSceneViewExtension( FSceneViewExtensionManager::FSceneViewExtensionCreatedDelegate::CreateLambda( [](const FAutoRegister& AutoRegister) { return MakeShareable(new FMySceneViewExtension(AutoRegister)); } ) ); }然后在PrePostProcessPass_RenderThread或者SubscribeToPostProcessingPass里插入你的渲染逻辑。这个机制在UE5.1之后稳定了很多,做描边、做自定义雾效、做屏幕空间效果都够用。
4.2 Nanite和Lumen的实战取舍
Nanite和Lumen是UE5的两大卖点,但它们不是免费的。我在项目里的实际经验:
Nanite适合:高面数静态几何体、建筑场景、岩石地形。不适合:植被(除非用Nanite Foliage)、骨骼网格体(UE5.4之前不支持)、需要顶点动画的物体。
Lumen适合:室内场景、需要动态GI的项目。不适合:开放世界大场景(性能吃不消)、移动端(UE5.4之前基本不可用)、风格化渲染(Lumen的物理正确性和风格化有冲突)。
如果项目是移动端或者VR,我的建议是直接关掉Lumen,用Baked Lighting + Reflection Capture。Nanite在移动端UE5.4之后有支持,但限制很多,要仔细评估。
4.3 自定义ShadingModel的实操路径
如果确实需要自定义光照模型(比如做卡通渲染、做皮肤SSS),路径是这样的:
- 在
Engine/Shaders/ShadingModels.ush里添加你的ShadingModel ID - 在
MaterialShared.cpp里注册ShadingModel名称 - 在
BasePassPixelShader.usf里添加你的光照计算分支 - 在材质编辑器里就能选到你的ShadingModel了
这个过程需要改引擎源码,而且每次引擎升级都要重新merge。所以我的建议是:能用Custom Node在材质里解决的,就不要改ShadingModel。Custom Node可以写HLSL,虽然不能访问所有引擎内部数据,但做大部分风格化效果足够了。
5. 打包与性能调优:编辑器里跑得好不代表打包后没问题
5.1 打包配置的常见坑
UE的打包配置项很多,但真正影响大的就那么几个:
Shipping vs Development。Shipping去掉了所有调试信息和大部分检查,性能最好但出问题难排查。我的做法是:内部测试用Development,对外发布用Shipping,但保留一份Shipping+DebugSymbols用于崩溃分析。
Pak文件配置。默认情况下UE会把所有资源打包进Pak。如果项目资源量大,加载时间会很长。可以通过Asset Manager做资源分块,按需加载:
// DefaultGame.ini [/Script/Engine.AssetManagerSettings] +PrimaryAssetTypesToScan=(PrimaryAssetType="Map",AssetBaseClass="/Script/Engine.World",bHasBlueprintClasses=false,bIsEditorOnly=false,Directories=((Path="/Game/Maps")),Rules=(Priority=-1,ChunkId=-1,bApplyRecursively=true,CookRule=AlwaysCook))Shader编译。打包时Shader编译是最耗时的环节。确保开启了Shader Cache,并且团队共享同一个DDC(Derived Data Cache)。UE5的Zen Store比UE4的本地DDC好用很多,有条件的话一定要上。
5.2 性能分析的顺序
性能优化最忌讳的就是"凭感觉优化"。正确的顺序是:
- 先用Unreal Insights抓一帧,看时间花在哪里
- 区分Game Thread、Render Thread、GPU的瓶颈
- 针对瓶颈做优化,而不是全面优化
常见的瓶颈和对应手段:
- Game Thread瓶颈:看Tick函数、看蓝图逻辑、看Actor数量。用
stat game和stat scenerendering定位 - Render Thread瓶颈:看Draw Call数量、看材质复杂度。用
stat rhi和profilegpu - GPU瓶颈:看Overdraw、看Shader复杂度。用
ProfileGPU和RenderDoc
5.3 蓝图性能的真相
"蓝图慢"这个说法对也不对。蓝图的执行确实比C++慢,但慢多少取决于你怎么用。一个每帧Tick的复杂蓝图,确实会成为瓶颈。但一个事件驱动的简单蓝图,和C++的差距可以忽略。
我的经验是:
- 每帧Tick的逻辑尽量用C++,特别是涉及大量计算的
- 事件驱动的逻辑用蓝图没问题,比如UI响应、一次性触发
- 蓝图里的ForEachLoop嵌套是性能杀手,超过两层就要考虑移到C++
- 蓝图Cast节点有开销,频繁Cast的地方用接口或者缓存指针
如果确实需要蓝图和C++混合,用UFUNCTION(BlueprintImplementableEvent)和UFUNCTION(BlueprintNativeEvent)来做桥接,比纯蓝图或者纯C++都灵活。
6. 那些文档里不会写的实战教训
6.1 热重载的边界比你想的窄
UE的热重载(Hot Reload)在UE5里已经被Live Coding取代,但两者的限制类似:改函数体可以,改类结构不行。具体来说:
- 加/删UPROPERTY——不行,需要重启
- 加/删UFUNCTION——不行,需要重启
- 改函数实现——可以
- 改默认值——可以(但有时不生效)
我的习惯是:改头文件就老老实实关编辑器重编译,别跟热重载较劲。省下的那点时间,不够你排查热重载带来的诡异bug的。
6.2 垃圾回收和UPROPERTY的关系
UE的GC只追踪被UPROPERTY标记的引用。这意味着:
// 这个指针会被GC回收,因为没有被UPROPERTY标记 UMyObject* MyObj = NewObject<UMyObject>(); // 这个不会 UPROPERTY() UMyObject* MyObj = NewObject<UMyObject>();但UPROPERTY只能用在UCLASS/USTRUCT的成员上。如果是局部变量或者非UObject类里的成员,需要用FGCObject或者TStrongObjectPtr来保持引用。这个坑我在项目里见过不止一次——对象莫名其妙变成null,查半天发现是被GC了。
6.3 网络复制的属性同步时机
UE的属性复制不是实时的,而是按NetUpdateFrequency来同步的。默认是100Hz,但实际项目中往往需要调整。对于位置这种高频变化的属性,用ReplicatedMovement;对于状态这种低频属性,用普通的Replicated就行。
还有个容易忽略的点:属性复制是在PostNetReceive里生效的,如果你在属性复制后需要做额外处理,要重写OnRep_函数,而不是在Tick里检查。
UPROPERTY(ReplicatedUsing = OnRep_Health) float Health; UFUNCTION() void OnRep_Health() { // 属性同步后触发,在这里更新UI或者做其他响应 UpdateHealthBar(); }6.4 资源引用的硬引用和软引用
UE里资源引用分硬引用(TObjectPtr、UStaticMesh*)和软引用(TSoftObjectPtr、TSoftClassPtr)。硬引用会在加载时把资源一起加载,软引用只在需要时加载。
我的原则是:
- 核心资源用硬引用:角色模型、常用材质、UI贴图
- 大资源用软引用:关卡、过场动画、可选内容
- 不确定的用软引用:特别是策划配置的资源,用软引用避免加载时卡顿
软引用的加载是异步的,用StreamableManager来管理:
FStreamableManager& Streamable = UAssetManager::GetStreamableManager(); Streamable.RequestAsyncLoad(SoftMeshPath, FStreamableDelegate::CreateUObject(this, &AMyActor::OnMeshLoaded));6.5 版本控制里不该提交的东西
UE项目用Git或者Perforce管理时,有几类文件绝对不该提交:
Binaries/、Intermediate/、Saved/——这些是生成物.vs/、.vscode/——IDE配置,因人而异DerivedDataCache/——本地缓存,巨大且无用
该提交的是:Source/、Content/、Config/、.uproject、.Build.cs、.Target.cs。.gitignore用GitHub上UE官方模板就行,别自己写。
7. 从"会用"到"用得好"的分水岭
写到这里,我想聊一个更本质的问题:UE的C++实战能力,分水岭到底在哪里?
我的观察是,分水岭不在于你懂多少引擎源码,而在于你对"边界"的理解。知道什么该用蓝图、什么该用C++;知道什么该改源码、什么该用扩展点;知道什么该硬引用、什么该软引用;知道什么该Tick、什么该事件驱动。这些判断力,才是区分"会用UE"和"用得好UE"的关键。
而这些判断力,没有捷径,只能靠项目喂出来。我上面写的每一条,背后都是至少一次踩坑或者返工。你可能不会全部遇到,但遇到了能想起来"好像有人说过这个",就值了。
最后分享一个我自己的习惯:每做一个新项目,我都会建一个Docs/目录,把踩过的坑、做过的决策、验证过的方案记下来。不是为了给别人看,是为了下一个项目少走弯路。UE这东西,版本更新快,但底层的那些坑,换汤不换药。