去年评审一个 40 人项目的仓库:策划和程序在同一周改了同一张
BP_PlayerCharacter。.uasset是二进制,Git 合不了,只能对着屏幕截图手工补差异,补漏了一处,测试又搭进去两天。同一周,C++ 侧 214 个提交全部自动合入,零冲突。蓝图真正的账单从来不在运行时那几微秒——这一页才是祸首。本章把分工谈清楚:C++ 定骨骼,蓝图长血肉。
本章地图
mindmap
root((分工协议))
成本真相
运行时那点差距
审查 合并 重构
宏速查
UPROPERTY
UFUNCTION
权限收紧原则
模块划分
依赖单向无环
数量经验值
编译流水线
UHT 与 generated.h
雪崩公式
Live Coding
能换函数体
动不了反射表
团队禁令
20 节点红线
数学必须 C++4.1 蓝图慢在维护,不是慢在运行
先把运行时这笔账说清。蓝图节点在蓝图虚拟机(Blueprint VM)里解释执行,每个节点调用都有一次虚拟机调度开销。在纯数学密集循环里,蓝图比 C++ 慢 5–10 倍是常见量级,具体取决于节点类型。但游戏代码九成是"调用引擎功能":SetActorLocation、PlayAnimMontage、SpawnActor——热点在引擎内部的 C++ 里,胶水层那几次 VM 调度常常淹没在帧时间统计的噪声里,根本测不出来。
所以"蓝图性能差"是个半真半假的传言。真的那半:密集数学、大循环、每帧上千次调用——必须进 C++。假的那半:业务胶水层的运行时开销,多数项目测不出差别。
这个开销的来源值得知道:蓝图编译产物是字节码,每个节点是一条指令。执行时 VM 要查函数表、打包参数、跳转、解包返回值——一条"两个浮点相加"的节点,真实工作是几十条原生指令。调用密度越高,税越重;节点只是转发给引擎 C++ 函数时,税被被调用的重活稀释到看不见。
真正的账单在另外三处。
第一处:代码审查。 .uasset 是二进制资产,Pull Request 里它的 diff 只有一行 Binary files differ。编辑器内置 Blueprint Diff 工具,可以对比两个版本的节点差异,但它只能在本地对着版本库跑;GitHub/GitLab 的评审页面上,评审人看到的仍是一坨二进制。40 人团队里,蓝图改动的评审质量只能靠提交者自觉附截图——而自觉在 deadline 前一周是稀缺品。
第二处:合并冲突。 文本代码的三路合并是成熟技术,二进制资产没有合并这一说,只有"整个文件,选你的还是选我的"。BP_PlayerCharacter 这种公共文件,在大团队里是周一早上的堵车点。独占锁能缓解,代价是串行排队:一个人改,其他人干等。
第三处:重构。 C++ 里重命名一个函数,IDE 全局替换,编译器兜底。蓝图里改一个函数的名字或参数,所有引用它的节点变成红色断线,散落在几十张图里,编译器一次只报一张图的错。修一张,保存,编译下一张,再修。基类蓝图改一个变量类型,几百个子蓝图全部要重新打开、重新保存,构建机的资产校验能红上两天。
第四处:搜索与静态分析的盲区。 排查"谁在改这个变量",C++ 侧是 grep 加 Find References,五秒出全量结果。蓝图侧只能对单个资产 Find References,跨资产的引用要全量加载资产库慢慢扫,还经常漏掉通过接口、事件派发器间接连上的调用方。至于"找出所有在 Tick 里做字符串拼接的蓝图"这种静态分析问题,文本世界一条脚本的事,二进制世界里是一个周末。
给一个真实量级:某项目策划把 BP_CharacterBase 上一个浮点变量改成整数,300 多个子蓝图引用断掉,两个人修了一天半;版本库因为批量重新保存二进制资产,一天涨了 4 GB。同样的改动在 C++ 侧是一次全局重命名加一次编译,二十分钟收工。
UE4 时代还有一条退路叫蓝图 Nativization,把蓝图烘焙成 C++。它在 UE5 已经移除——别再往计划里写"先蓝图后转正"。
也有三个场景里蓝图的慢是真的,别替它辩护:动画蓝图每帧更新的复杂图(几百个角色叠加时 VM 开销会上帧时间统计)、UMG 里频繁刷新的属性绑定、以及任何写了 ForLoop 的蓝图——循环是 VM 税的复利。这三处看见就直接转 C++,不用开会。
"薄胶水"给个数:单张蓝图资产 50 个节点以内、单函数 20 个节点以内、继承链 3 层以内——这是经验上"维护成本测不出来"的安全线。线的另一边,每一百个节点大约对应评审时多十分钟、重构时多一处断线。
结论一句话:单人原型,蓝图随便画;50 人项目里,每张超过 100 个节点的蓝图都是定时炸弹,区别只在引爆时间。
quadrantChart
title "性能与维护成本坐标:四种写法各就各位"
x-axis "维护成本低" --> "维护成本高"
y-axis "性能低" --> "性能高"
quadrant-1 "快但难养"
quadrant-2 "理想区"
quadrant-3 "慢但省心"
quadrant-4 "又慢又难养"
"C++函数库": [0.32, 0.88]
"蓝图胶水20节点内": [0.45, 0.30]
"蓝图巨型函数50节点": [0.90, 0.18]
"C++公共头文件": [0.70, 0.85]这张图故意不画"C++ 一个点、蓝图一个点",因为二选一是个假命题,真正决定位置的是写法。C++ 函数库落在左上理想区;控制在 20 个节点以内的蓝图胶水在左下,慢但省心,可以接受;50+ 节点的巨型蓝图函数在右下,又慢又难养,是本章要消灭的对象;"谁都能 include 的 C++ 公共头文件"在右上——性能没问题,但一改全楼重编,这是 C++ 自己的坑,4.4 节细说。
四个维度打分(5 分制),每条给理由:
| 维度 | C++ | 蓝图 | 打分理由 |
|---|---|---|---|
| 运行时性能 | 5 | 2 | VM 调度单次开销数倍于原生调用,密集循环差一个数量级 |
| 迭代速度 | 2 | 5 | 蓝图保存即生效;C++ 改函数体靠 Live Coding 数秒到数十秒,改头文件以分钟计 |
| 可读性/可审查 | 5 | 2 | 文本 diff 进 CI;蓝图只能在编辑器里开 Blueprint Diff |
| 大团队并行 | 4 | 1 | 文本可三路合并;.uasset 冲突只能整个文件二选一 |
看出来了吗:蓝图赢的那一项(迭代速度)是单人体验,输的三项全是团队属性。规模越大,越要把蓝图往"薄胶水"压。
C++ 的"大团队并行"只给 4 分不给 5 分,是因为文本世界也有自己的堵车点:公共头文件、共享的宏定义、Build.cs。区别是这些冲突能以行为单位自动合并,实在不行还有编译器当场抓包——合并冲突在文本里是减速带,在二进制资产里是断头路。
4.2 宏是给 UHT 的工单
UCLASS、UPROPERTY、UFUNCTION 这些宏,C++ 编译器本身看不懂,它们展开后是空或者普通声明。真正读它们的是 Unreal Header Tool(UHT):编译前它扫描所有头文件,按宏生成反射代码,写进 .generated.h。蓝图能找到你的函数、GC 知道成员变量引用了谁、序列化知道怎么存盘——全靠这张反射表(反射系统的完整故事见第 2 章)。
所以写宏的本质是"给引擎登记"。登记错了,引擎层面找不到东西,C++ 编译器却不一定报错——这就是新手玄学 bug 的头号来源。
UPROPERTY 常用说明符速查
| 说明符 | 一句话人话 |
|---|---|
EditAnywhere | 默认值和关卡实例都能改,权限放得最宽 |
EditDefaultsOnly | 只能改类默认值,关卡里摆好的实例不给动 |
EditInstanceOnly | 反过来:默认值锁死,只有关卡实例能调 |
VisibleAnywhere | 细节面板看得见,但灰着,改不了 |
BlueprintReadOnly | 蓝图能读不能写——保住不变量的底线 |
BlueprintReadWrite | 蓝图能读能写,慎用,等于放弃 C++ 侧的控制权 |
Category = "..." | 细节面板里的分组名,不写就堆在 Default 里 |
Replicated | 参与网络复制(第 21 章展开) |
Transient | 不存盘不序列化,运行期临时数据 |
SaveGame | 标记进存档槽,配合 SaveGame 系统使用 |
meta=(ClampMin="1", ClampMax="10") | 面板输入框的数值范围钳制 |
meta=(Units="seconds") | 面板显示单位,防"毫秒当秒"事故 |
meta=(ToolTip="...") | 悬停提示,写给人看的 |
UFUNCTION 常用说明符速查
| 说明符 | 一句话人话 |
|---|---|
BlueprintCallable | 蓝图图里能调用它 |
BlueprintPure | 无执行引脚的纯查询节点,别在里面改状态 |
BlueprintImplementableEvent | C++ 只声明,实现完全交给蓝图 |
BlueprintNativeEvent | C++ 给默认实现,蓝图可覆盖,覆盖时记得调父类 |
CallInEditor | 细节面板多一个按钮,点一下在编辑器里执行 |
Category = "..." | 蓝图右键菜单里的分组 |
meta=(WorldContext="...") | 静态函数拿不到 World 时,告诉它从哪借 |
meta=(DefaultToSelf="...") | 参数默认填调用者自己,少连一根线 |
meta=(ExpandEnumAsExecs="...") | 枚举返回值拆成多个执行引脚,省掉 switch 节点 |
组合的潜规则:默认收得最紧,需要再放。EditAnywhere + BlueprintReadWrite 意味着策划能改、蓝图能写——你对这个变量的所有假设都不成立。反过来,VisibleAnywhere + BlueprintReadOnly 的变量,改它的入口只有一个,出 bug 时排查范围也只有一个。
三个常用配方直接抄:
| 用途 | 配方 | 效果 |
|---|---|---|
| 策划调参的数值 | EditDefaultsOnly + BlueprintReadOnly | 面板能调,蓝图能读,运行时没人能改 |
| C++ 管、蓝图看的状态 | VisibleAnywhere + BlueprintReadOnly | 状态公开透明,写入路径唯一 |
| 表现层扩展点 | BlueprintImplementableEvent 或 BlueprintNativeEvent | C++ 定时机,蓝图填特效音效 |
ImplementableEvent 和 NativeEvent 二选一的判断标准是"蓝图不实现行不行":不行就用前者,C++ 侧没有函数体;行就用后者,UHT 会为它生成 _Implementation 后缀的 C++ 函数,蓝图覆盖时记得在蓝图里调父类事件,否则 C++ 的默认行为被静默吞掉。
BlueprintPure 单独警告一句:它没有执行引脚,蓝图每次需要它的值都会重新调一遍。在纯函数里偷偷改了状态,或者塞了重活(比如每帧被引脚求值十次的射线检测),bug 和性能问题会同时找上门,而且排查时图上一根白线都没有,极难看穿。Pure 的承诺是"无副作用且廉价",做不到就老实改回 BlueprintCallable。
下面这段代码是「回响峡谷」里篝火 Actor 的登记样本:类可蓝图继承、属性权限收紧、事件留扩展点。
// EchoBonfire.h —— 可蓝图继承的 C++ 基类样板
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "EchoBonfire.generated.h" // 必须是最后一个 include
UCLASS(Blueprintable, ClassGroup=(EchoCanyon))
class ECHOCANYON_API AEchoBonfire : public AActor
{
GENERATED_BODY()
public:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
TObjectPtr<UStaticMeshComponent> Mesh;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="Bonfire",
meta=(ClampMin="1", ClampMax="10", Units="seconds"))
float IgniteDuration = 3.0f;
UFUNCTION(BlueprintCallable, Category="Bonfire")
void Ignite();
// C++ 负责触发时机,蓝图负责表现:粒子、音效、镜头抖动
UFUNCTION(BlueprintImplementableEvent, Category="Bonfire")
void OnIgnited();
UFUNCTION(BlueprintPure, Category="Bonfire")
bool IsBurning() const { return bBurning; }
private:
bool bBurning = false;
};三行要点:Blueprintable 决定这个类能不能派生蓝图子类;BlueprintImplementableEvent 是 C++ 给蓝图留的"表现层插座",逻辑触发在 C++,特效音效在蓝图;.generated.h 那行必须压在所有 include 的最后,违反就是编译错误。
宏写错时的报错也值得认一遍脸:UHT 报错指向的头文件和行号通常是对的,但错误信息里的"Missing '*' in property declaration"之类措辞是按反射系统的口味写的,翻译成人话往往只是"这个类型 UHT 不认识"。排查顺序固定三步:类型有没有 UCLASS/USTRUCT 登记、include 有没有写全、.generated.h 是不是最后一个 include。九成宏玄学在这三步里现形。
这些宏最终去了哪?看这张对照图:你写的每个宏,UHT 都翻译成一段注册代码。看懂这张图只要一次,之后每条宏报错你都读得懂。
4.3 模块是文件夹?说到底是编译边界
一个 UE 工程至少有一个模块。模块是三种边界的合一:编译边界(单独编成一个 DLL)、依赖边界(Build.cs 里显式声明)、加载边界(运行时按需加载)。把全部代码堆在一个模块里能跑,但任何头文件改动都会炸全模块——下一节的公式会告诉你这有多贵。
什么时候该从单模块拆出来?看三个信号:编译时间开始影响迭代心情(增量编译稳定超过一分钟);头文件目录里出现"谁都在 include 谁"的蜘蛛网;第二个程序入职,两个人开始在同一批文件上打架。三个信号中了一个,拆分的收益就超过成本——拆模块本身也要花一到两天,别在只有五个类的时候折腾。
健康划分只有一条铁律:依赖单向无环,下层不知道上层的存在。
classDiagram
class EchoCore
note for EchoCore "日志 / 存档 / 数学工具
不依赖任何游戏代码"
class EchoGameplay
note for EchoGameplay "角色 / 战斗 / 交互
只依赖 EchoCore"
class EchoUI
note for EchoUI "UMG 界面
走接口访问 Gameplay"
class EchoEditor
note for EchoEditor "编辑器扩展
仅 Editor 目标编译"
EchoGameplay --> EchoCore : 依赖
EchoUI --> EchoGameplay : 依赖
EchoEditor --> EchoGameplay : 依赖「回响峡谷」竖切阶段就这么分:EchoCore 放日志、存档、数学工具,不依赖任何游戏代码;EchoGameplay 放角色、战斗、交互,只依赖 EchoCore 和引擎模块;EchoUI 只做界面,通过接口访问 Gameplay,禁止直接摸角色成员;EchoEditor 是编辑器扩展,只在 Editor 目标下编译,打包时整个剥掉。
模块数量给个经验区间:竖切原型 1–2 个,中型项目 3–6 个,百人级项目按系统拆 10 个以上。但模块不是越多越好——每个模块都有 UHT 扫描、PCH 和链接的固定开销,拆 30 个模块的项目,全量编译时间反而比 10 个的更长。拆分的判据是"这块代码有没有独立的变更频率和负责人",不是"看起来整齐"。
引擎自己就是活教材:Engine/Source/Runtime 下躺着几百个模块,渲染、物理、音频各占山头,Core 在最底层,谁都能用、谁也不被它用。照着这个形状画自己的依赖图,不会错得离谱。
划分翻车的典型症状是循环依赖:UI 想读角色的血量,角色受击时又想刷 UI,两边互相 include,UBT 直接报循环依赖错误。解法是接口下沉——在 EchoCore 或一个独立的接口模块里定义纯虚接口,EchoGameplay 实现它,UI 只依赖接口。这和普通 C++ 工程的依赖倒置是同一件事,只是 UE 把它变成了模块级硬约束。
顺带分清一对概念:模块是编译单元,插件是发行单元。插件是一组模块加内容的可插拔包裹,能被别的工程整包复用。竖切阶段别想插件,先把一个 runtime 模块的依赖关系养干净。
Build.cs 是模块的户口本,最小可用版本长这样:
// EchoGameplay.Build.cs —— 依赖声明的最小集
public class EchoGameplay : ModuleRules
{
public EchoGameplay(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{ "Core", "CoreUObject", "Engine", "EchoCore" });
PrivateDependencyModuleNames.AddRange(new string[]
{ "UMG", "Slate", "SlateCore" });
}
}Public 与 Private 的区别会传导:Public 依赖的头文件对"依赖我的人"可见,Private 不可见。把依赖尽量压进 Private,就是在给下一节雪崩公式里的 M 做减法。
4.4 改一行头文件,为什么全楼重编
从保存到生效,中间站着三个工具:UHT 生成反射代码,UBT 决定编什么,编译器和链接器干活。完整流水线如下:
flowchart TD
A[保存 .h 或 .cpp] --> B{含反射宏的头文件变了?}
B -- 是 --> C[UHT 解析宏
重写 .generated.h]
B -- 否 --> D[跳过 UHT]
C --> E[UBT 依赖扫描
列出受影响的 TU]
D --> E
E --> F[并行编译脏 TU 得到 .obj]
F --> G[链接成模块 DLL]
G --> H{编辑器开着?}
H -- 是 --> I[Live Coding 注入补丁]
H -- 否 --> J[下次启动加载新 DLL]雪崩的机制有三层。第一层是 C++ 的 #include 本质:文本包含。头文件 H 改了,所有包含 H 的翻译单元(TU,粗略理解为一个 .cpp 的编译任务)全部失效。第二层是 UHT:H 里只要有 UCLASS/USTRUCT/UENUM,UHT 就重写对应的 .generated.h——等于又改了一个被广泛包含的头文件,扇出翻倍。第三层是 PCH(预编译头):如果 H 被打进共享 PCH,整个模块的 TU 全部间接失效。这就是"改了一行项目公共头,去泡了杯茶,回来还没编完"的由来。
增量编译时间可以估成一个式子:
是包含头文件 H 的翻译单元数,也就是扇入; 是单个 TU 的平均编译时间,UE 模块里常见 10–40 秒,取决于 include 深度与优化档位; 是并行编译核数; 是 UHT 重新生成反射代码的固定开销; 是模块重链接时间——大模块的链接可以单独吃掉一分钟。
这个式子的信息量全在 M 上。一个只被 3 个 .cpp 包含的私有头,改它就是十几秒的事;一个被 800 个 TU 包含的项目公共头,16 核机器上也要以十分钟计。模块化的全部意义,就是把 M 关进小笼子:头文件扇入不出模块边界,雪崩就埋不到别人。前向声明代替 include、实现挪进 .cpp、公共头里只放稳定接口——全是给 M 做减法。
给一组对照数字感受量级(Ryzen 7950X 16 核、SSD、Development 档位的中型项目实测区间):改项目级公共头 EchoTypes.h,扇入约 600 个 TU,增量编译 8–12 分钟;改只有 4 个 .cpp 包含的 EchoBonfire.h,30 秒以内;只改 EchoBonfire.cpp 的函数体,Live Coding 补丁 10 秒上下。三者之间隔着两个数量级,而代码上只是一行之差。
PCH 那层再补一句:预编译头是把一批"几乎不变"的头文件(引擎头、STL)预先编译成共享快照,让每个 TU 省下重复解析的开销。它是全量编译的加速器,却是增量编译的放大器——凡是进了 PCH 的头,改动等于宣告整个模块重新来过。所以项目自己的高频头文件,永远别塞进 PCH。
给 M 做减法的三个具体手段,按性价比排序:一是前向声明——头文件里只用到指针或引用的类型,写 class UFooComponent; 就够了,include 挪进 .cpp;二是接口与实现分家,别的模块只需要调用的东西,头文件里只留纯虚接口,实现类整个藏进 .cpp;三是 PImpl,对扇入特别大又爱变的类,把成员藏进不透明指针。第三种有运行时间接寻址的小成本,前两种没有借口不用。
UBT 实际的判断逻辑可以抽象成这段伪代码:
# 伪代码:头文件变更 → 重编译范围
def rebuild_scope(changed_files, dep_graph):
dirty_tus = set()
for f in changed_files:
if f.endswith(".h"):
if has_reflection_macros(f): # UCLASS/USTRUCT/UENUM
uht_regenerate(f) # 重写 .generated.h
dirty_tus |= dep_graph.users_of(f.generated)
# 反向闭包:直接+间接 include 它的所有 TU
dirty_tus |= dep_graph.reverse_include_closure(f)
else: # .cpp 只脏自己
dirty_tus.add(f)
for module in affected_modules(dirty_tus):
compile_in_parallel(module.dirty_tus) # 按核数并行
link(module) # 模块级重链接
return dirty_tus要点注释:.cpp 改动的影响面永远是 1 个 TU,这就是"能写进 .cpp 的别留在头文件"成为 UE 工程第一诫的原因;reverse_include_closure 是雪崩的数学本体;链接发生在模块粒度上,模块越大,链接税越重。
再补一个 UE5 的默认行为:UBT 的 adaptive unity 机制会把变动频繁的 .cpp 自动移出合并编译单元,全量编译吃 unity 的红利,增量编译少受牵连。别手动关它。
4.5 Live Coding 能救命,也能埋雷
UE5 默认的热重载机制是 Live Coding,老的 Hot Reload 已被取代:编辑器开着,IDE 里编译,改动以补丁形式注入运行中的编辑器进程,不用重启。触发方式是编辑器里按 Ctrl+Alt+F11,或在 IDE 里直接编译——UE5 会走后端补丁流程。
Hot Reload 被换掉的原因值得记一笔:它走整体重链接模块 DLL 的路子,模块大了之后每次都要等完整链接,还经常和编辑器里的实例状态打架。Live Coding 改成函数级补丁——只替换被改动的函数入口,其余原封不动,所以能把等待压到十几秒。理解这一点,后面的"能与不能"无需背规则。顺理成章就懂了。
sequenceDiagram
participant IDE as 你的 IDE
participant UBT as UBT与LiveCoding
participant CC as 编译器/链接器
participant ED as 编辑器进程
IDE->>UBT: 保存后按 Ctrl+Alt+F11
UBT->>UBT: 依赖扫描,列出脏 TU
UBT->>CC: 只编译脏文件
CC-->>UBT: 生成增量补丁模块
UBT->>ED: 注入补丁(不重启编辑器)
ED->>ED: 暂停 PIE → 替换函数入口 → 重建 CDO
ED-->>IDE: 输出日志:成功 / 失败原因它能做什么:改函数体、调逻辑、加日志、改公式系数。从按快捷键到 PIE 里生效,快的时候十几秒。
它不能做什么——做了就必须重启编辑器,别赌:增删 UPROPERTY/UFUNCTION、改 UCLASS 说明符、改反射结构体的内存布局、改枚举。这些改动动的是反射表,而反射表在模块加载时就固化了,补丁换不掉。Live Coding 会直接拒绝并提示你重启编译,听话照做就是。
灰色地带是构造函数:改了成员初始值,Live Coding 会重建类默认对象(CDO),之后新 spawn 的实例用新值;但关卡里早已摆好的实例是序列化数据,保留旧值。调试时"我明明改了默认值怎么没生效",八成踩在这里——把那个 Actor 删掉重摆一个就好了。
还有一个教科书不常提的失败模式:给类增删虚函数。虚函数表布局变了,而补丁是按旧布局链接的,运气好的版本直接拒编,运气差的编译过了、运行时调错函数,崩在一个八竿子打不着的调用栈里。遇到这种崩溃,第一反应应该是全量重编再复现,别顺着调用栈去查逻辑。
Live Coding 收益最大的场景是调参迭代:伤害公式的系数、移动的加速度、相机的回弹强度——这些改动全在函数体里,改完按一下快捷键,PIE 里立刻感知。一个下午调五十次参数,省下的编辑器重启时间按小时计。把它当调参器用,它是神器;把它当常规编译通道用,它是雷区。
团队纪律三条:反射结构改动直接关编辑器编译,不用补丁;每天下班前做一次全量重编,清掉补丁累积——补丁叠补丁的编辑器状态不可信;打包永远从干净编译出包,Live Coding 的产物不进构建管线。
"补丁累积不可信"值得展开半句:每次 Live Coding 编译都在进程的函数入口表上盖一层跳转,盖了十几层之后,编辑器里运行的代码和磁盘上的源码已经不是同一个东西——行为对不上、断点打不中、崩溃栈乱跳,都是这层夹生的产物。全量重编一次,世界恢复一致。这和数据库的 WAL 需要定期 checkpoint 是同一个道理。
4.6 团队规则:这些东西禁止进蓝图
前五节是原因,这一节是结论——一份可以直接贴进团队 wiki 的规则清单。判定逻辑先给:
flowchart TD
A[要写一段新逻辑] --> B{数学密集或每帧执行?}
B -- 是 --> Z[放 C++]
B -- 否 --> C{被多个蓝图复用?}
C -- 是 --> Z
C -- 否 --> D{节点会超过 20 个?}
D -- 是 --> Z
D -- 否 --> E{定义了 struct 或接口?}
E -- 是 --> Z
E -- 否 --> F{涉及 RPC 或属性复制?}
F -- 是 --> Z
F -- 否 --> G[放蓝图: 表现 / 配置 / 胶水]禁令清单(评审时逐条过)
| # | 规则 | 理由 |
|---|---|---|
| 1 | 单个蓝图函数 ≤ 20 个节点,超出必须拆函数或转 C++ | 20 节点是人眼一屏的极限,也是有效评审的极限 |
| 2 | 禁止蓝图单例:不许在 GameInstance 上挂全局状态,不许用 GetAllActorsOfClass 找"唯一管理器" | 蓝图里的全局状态无法追踪写入方;改用引擎的 Subsystem 体系(C++ 侧,如 GameInstanceSubsystem) |
| 3 | 数学密集逻辑必须 C++:向量运算链、伤害公式、评分函数 | VM 开销在这里是真的;C++ 写好再暴露成 BlueprintPure 节点给蓝图调用 |
| 4 | Tick 事件里禁止超过 5 个节点的逻辑;能事件驱动就不 Tick | Tick 是帧预算的常驻税,蓝图 Tick 还缺性能分析粒度 |
| 5 | struct 与 interface 只在 C++ 定义 | 蓝图 struct 改字段名,所有序列化引用会静默错位,资产级灾难 |
| 6 | 蓝图继承链 ≤ 3 层 | 第 4 层出现的代价是"改基类炸一片",见 4.1 的翻车现场 |
| 7 | RPC 与复制逻辑只在 C++ 写 | 网络语义需要编译器把关,卷三第 21 章展开 |
| 8 | 单个 EventGraph 节点总数 ≤ 50 | 事件图表是布线板,不是主程序 |
决策树走不通的灰色地带,给几个判例:拾取道具的交互反馈——判定在 C++,飘字和音效在蓝图;Boss 的阶段切换,阶段条件与数值在 C++,演出序列在蓝图或 Sequencer;背包排序,排序算法在 C++ 暴露成函数,蓝图只负责把结果摆进格子。判例的共同原则:决策归 C++,表现归蓝图;会变的规则归 C++,一次性的编排归蓝图。
放行区——这些本来就该在蓝图里:动画蓝图的状态机与混合逻辑、UMG 的布局与界面动画、关卡里一次性的触发脚本、纯数据资产(继承 C++ 数据类的调参实例)、材质图。规则的目的,准确讲不是消灭蓝图——是把它锁在擅长的事情上:表现、配置、胶水。
执行层面两条:CI 里跑蓝图静态检查(节点数、命名规范、未使用变量,社区有现成 lint 方案,自己写脚本扫导出的资产数据也行);评审蓝图改动必须附节点截图,不接受"二进制改了就改了"。
这份清单本身也要当代码养:写进版本库的 Docs/CodingStandard.md,改动走评审,新人入职第一周读完并在一张真实蓝图上演练一遍。规则没有版本化,三个月后就会变成口口相传的玄学,每个人背出来的版本都不一样。
老项目落地这套规则,别指望一夜清空蓝图。可行的节奏是:新功能一律按规则走;存量资产每周统计一次节点数 Top 10,按周还两张的速率往下削;继承链超过 3 层的区域,下次有人动到相关功能时顺手重构。三个月下来,债务曲线是肉眼可见往下走的。一刀切重写通常重写不完,还顺手引入一批新 bug。
和策划、美术沟通时换个说法:收走工具是误解,减少返工才是本意。蓝图里写的逻辑进了 C++,他们拿到的是更稳的体验——以后改需求不用在红线丛林里重新排雷,编译错误提前在文本阶段爆出来,而不是打包前夜。
「回响峡谷」竖切阶段按这套规则走下来的结果:C++ 侧 12 个类承担全部游戏逻辑,蓝图侧 40 多张资产全是动画、界面、调参和一次性脚本,最大的一张 47 个节点。两个人并行开发两个月,蓝图合并冲突零次——分歧一直存在,只是它们都落在能合并的文本里。
小结
蓝图的真实成本是维护:二进制资产不可合并、不可评审、难重构;运行时性能只在数学密集场景才算问题。宏是给 UHT 的工单,产物是 .generated.h 反射表;说明符默认收紧,需要再放。模块是编译边界加依赖边界;拆分判据是变更频率与负责人,依赖必须单向无环。头文件雪崩公式里,M(扇入)是唯一的大数;所有头文件纪律都是给 M 做减法。Live Coding 只换函数体;动了反射结构就老实重启编辑器。
上手任务
- 在「回响峡谷」工程里新建
AEchoBonfireC++ 基类并派生蓝图子类,逐个切换EditAnywhere/EditDefaultsOnly/EditInstanceOnly,观察细节面板差异。验收:能用一句话说清三者差异,蓝图里能读到IgniteDuration,OnIgnited事件能在子类里挂特效。 - 找出当前项目里节点数最多的 5 个蓝图函数(编辑器内统计或脚本扫资产均可)。验收:给出清单与节点数,超过 20 节点的逐条写出拆分或转 C++ 的方案。
- 做一次对照实验:改一个被 10 个以上 TU 包含的头文件,记录增量编译时间;再只改对应 .cpp 的函数体,记录时间。验收:两组数字写进笔记,差距应在一个数量级上下;若差距不足 5 倍,检查是否改的是被 PCH 间接包含的头。
下一章
骨骼和血肉分完了,接下来给「回响峡谷」装神经:第 5 章 Enhanced Input,把"按 W"翻译成"向前移动"的语义层——手感从这一章开始。
延伸阅读
- 官方文档:Unreal Engine → Programming → Compiling Game Projects(UHT 与 UBT 词条,以 5.8 版本为准)
- 官方文档:Unreal Engine → Programming → Live Coding(能力边界与限制清单)
- 官方文档:Unreal Engine → Programming → Modules(Build.cs 字段全集)
- 源码阅读:
Engine/Source/Programs/UnrealHeaderTool(看它如何解析宏、生成反射代码) - 源码阅读:
Engine/Source/Programs/UnrealBuildTool(依赖扫描与 adaptive unity 的实现)