Unreal Engine 里 Blueprint 还是 C++:实测 7 毫秒开销与混合工作流的边界
Blueprint 虚拟机的开销在大多数场景里都看不见,但当每个 tick 要跑几万个节点时,它一个人就吃掉了 7 毫秒。这篇文章用实测数字讲清楚混合工作流的边界:哪些逻辑该交给 C++,哪些留在 Blueprint 里,UPROPERTY 该暴露到什么程度,以及团队变大之后合并冲突该怎么收场。
当一个 tick 里跑了 4.1 万个节点
2023 年底的一个塔防项目,进入第 14 波之后,帧时间从 11.2 ms 一路涨到 19.4 ms。在编辑器里看,一切都还算可以接受;打包成 Development build 之后,差距就非常明显了。Unreal Insights 里排在最上面的一行是 BlueprintTime,它一个人就吃掉了 7.1 ms。
罪魁祸首是 BP_TowerBase 里的 Event Tick。每一座塔都用 ForEachLoop 把场上全部 220 个敌人走一遍,算出距离的平方,再挑出最近的那一个。24 座塔乘以 220 个敌人,再加上每个节点上的若干次运算,折算下来每帧大约要执行 4.1 万个节点。
问题不在于“Blueprint 很慢”。问题在于我们把一个 O(n·m) 的搜索放进虚拟机里,每一帧都跑一遍。把这段代码搬到 C++ 确实能把开销降下来,但真正的错误出在算法上;而在 Unreal Blueprint 与 C++ 的争论里,这两件事一直被混为一谈。
那时候团队只有三个人,没有任何人在剖析 Blueprint 这一侧。眼看帧时间滑下去,我们先怀疑 draw call,再去翻阴影设置,然后是贴图分辨率。白白搭进去两天之后,打开 Insights 读对那一行就够了。这篇文章剩下的部分,就是我为了不再重来一遍那两天而写下的规则。
Blueprint 的性能从哪里开始变得可测
为了拿到一个数字,我做了个很朴素的测试:把一个空函数调用 10 万次。C++ 这边总耗时是 0.4 ms;完全相同的函数放在 Blueprint 里,要 6.8 ms。折算下来,每次调用大约多出 68 ns 的开销。
68 ns 看上去什么都不是,大多数时候也确实如此。一个 200 个节点的开门流程,或者背包界面背后的那套逻辑,这点开销始终压在噪声之下;在每帧大致不到 2000 个节点的量级上,我做过的所有测量里,总影响从来没有超过 0.15 ms。
它不再是噪声的那个点很清楚:每帧几万个节点。我用的粗略阈值是这样的——如果一个 Blueprint 每帧都在跑,而且里面还带着循环,那这段逻辑现在就是 C++ 的候选。至于由一次性事件触发的流程,虚拟机开销根本不值得拿出来讨论。
这里还有一个测量上的陷阱。在 PIE 里,因为调试插桩的存在,Blueprint 的调用看起来比实际更贵,所以拿编辑器里的数字下判断会把人带偏。在用 stat game 和 Insights 检查过打包的 Development build 之前,我不会把任何东西挪进 C++。
混合工作流:内核交给 C++,调参留给 Blueprint
目标选择、伤害计算、冷却计时和状态机,都以 C++ 的形式搬进了 AOFKTowerBase。目标搜索不再每帧执行,而是按 0.2 秒的定时器,在一张空间网格上跑。留在 Blueprint 里的是:特效时机、音效触发、UI 反馈,以及策划一直在调的那些数值。
帧时间从 19.4 ms 降到 11.8 ms,BlueprintTime 从 7.1 ms 掉到 0.9 ms。这些收益并不全来自虚拟机:大约 5 ms 要算在算法改动头上,只有 2.6 ms 来自迁到原生代码。不把这笔账拆开就说“C++ 让它快了 40%”,那是不诚实的。
有两个方案被我们否掉了。全部改成 C++ 在技术上最快,但策划为了试一个伤害数值,就得干等 90 秒编译再重启编辑器;对一天要试 40 次的人来说,这不可接受。而全部留在 Blueprint 里、只把 tick 关掉,则什么也没解决,因为它并没有去掉那个造成开销的循环。
划这条线的时候,我问的问题很简单:改这个值需要重新编译吗?如果需要,而策划一周还要改上好几次,那这个值就该放到 Blueprint 或者一份数据资产里。我们把塔的平衡表做成 UDataAsset:C++ 负责读,策划在编辑器里改,没有人需要等构建。
用 UPROPERTY 暴露出正确的那一面
一套混合方案的质量,取决于 C++ 究竟向 Blueprint 暴露了什么。可调的数值一律以 UPROPERTY(EditAnywhere, BlueprintReadOnly) 的形式放出去:策划能在编辑器里改这个值,却没法在运行时写它。再加上 meta = (ClampMin, UIMax),“有人手滑填了个 0”这类问题,有一半在出现之前就已经不存在了。
在函数这一侧,三个说明符之间的区别很要紧。BlueprintCallable 是 Blueprint 向 C++ 提一个问题;BlueprintImplementableEvent 是 C++ 告诉 Blueprint“这件事刚刚发生了,长什么样你来管”;BlueprintNativeEvent 则是在 C++ 里给出一个默认实现,Blueprint 在需要的时候可以覆盖它。规则很短:C++ 决定什么时候发生,Blueprint 决定看起来是什么样。
我见得最多的错误,是给每一个字段都盖上 BlueprintReadWrite。一旦放开,策划就会开始往状态变量里写,你在 C++ 里守着的那些不变量会悄无声息地被破坏;在我们这边,它最后表现为弹药计数变成了负数。第二个陷阱是改名:改掉一个 UPROPERTY 的名字,会无声地打断 Blueprint 里的引用,所以不先补上一条 Core Redirects,我们绝不会去改字段名。
这件事还有编译时间的一面。动一下头文件里的 UPROPERTY,所有包含了这个头文件的东西都要重编,在我们一台还不错的机器上,这个时间能到 4 分钟。把经常改动的设置收进一个独立的小结构体,既让策划的迭代快了起来,也肉眼可见地减少了我们每天做全量重编的次数。
nativization 被移除之后,有什么变了
4.26 前后,有些团队相当依赖 Blueprint nativization,我们也试着用了一阵子。在重场景里实测到的收益大约是 1.6 ms;作为交换,打包时间多出了 18 分钟,还撞上三个只在 nativized build 里才出现的问题。UE5 把这个选项整个移除了。
现实的后果是:到了后期,已经没有“编译器会替你兜住”这条退路了。把热路径放在 C++ 里,不再是一个优化步骤,而是项目一开始就要做出的架构决策。把 UE5 C++ 当成后面再贴上去的补丁,正是很多团队在制作中期被绊倒的原因。
这里也有好的一面。nativization 还在的时候,人们会在 Blueprint 里写下很重的逻辑,然后计划“以后再 nativize”,而那个以后从来没有来过。选项没了,一开始就把边界划清楚变成了必答题,时间久了,反倒给我们留下了一个更干净的代码库。
我们拿来替代 nativization 的东西是测量。每次发版之前,都会从打包版本里抓一份 Insights trace;一旦 BlueprintTime 越过 1 ms,就去追是哪一个 Blueprint 的责任。这项检查花十五分钟。过去一年里,它有三次在版本发出去之前抓到了严重的性能回退。
团队变大之后的合并冲突
四个人的时候,Blueprint 是二进制资产这件事并不构成问题。到了十一个人,每周总有两三次,两个人同时动了同一个 .uasset,而 .uasset 是合并不了的;输的那一方只能把活重做一遍。我们估算过,一个迭代周期里这样损失掉的时间,大约是两天。
我们在 Perforce 里配上了强制签出和独占锁。冲突停了,取而代之的是等待:一个 Blueprint 被锁住的时候,第二个人要么等着,要么去换别的任务。C++ 没有这个问题——文本能逐行合并,在 pull request 里也真的读得懂。而评审一个 Blueprint,实际做起来就是互相发截图。
今天我在 Unreal Engine 开发里执行四条规则。每帧都会运行的逻辑,一律不留在 Blueprint 里,而且我们新建的每一个 Blueprint,tick 默认都是关掉的。一个 Blueprint 一旦超过 150 个节点,就要拆一块出去放到 C++。还有,如果在同一个迭代周期里有两个人需要改同一个 Blueprint,那这段逻辑本来就属于 C++。
这几条规则关乎性能,也同样关乎团队的产出速度。让 Blueprint 去服务策划的快速迭代,让 C++ 去撑起系统的骨架,并且要在写下第一行代码之前就把这条线划好。以后再迁移永远更贵——我们付出的账单,是三周的重构。