导语:远港 Online 上线前最后一次压力测试,主程老周盯着 stat unit 看了半分钟。Game 线程稳稳趴在 11ms,GPU 也争气,问题出在 SlateUI 这一项:4.2ms。对于 60fps 目标来说,UI 一个系统就吃掉了 25% 的帧预算。更让人难受的是,这个数值在打开背包后会跳到 7.8ms,团队框架进本后常年 6ms 以上。UI 组组长小陈的第一反应是:界面也没多复杂,怎么就这么贵?两周后,我们把 SlateUI 压到了 0.8ms。这篇文章把那段踩坑路重新走一遍。
1. 先定位:Widget Reflector 是 UI 性能的第一把刀
事情得从一把工具说起。UE 编辑器里按 Ctrl+Shift+W 调出 Widget Reflector,或者控制台输入 WidgetReflector,就能打开这面照妖镜。它有三个常用模式:Widget Tree、Live 和 Snapshot。
Widget Tree 列出当前屏幕上所有 UMG/Slate 控件,每个节点旁标注着 Preposs、Arrange、Paint 三项耗时。Live 模式可以实时框选屏幕上的元素,看它在树里的位置和当前帧的各项开销。Snapshot 则把某一帧的树结构拍下来,方便事后对照。
小陈第一次打开 Widget Reflector 时,背包界面开着,他框了一下背包左上角的标题栏。结果 Preposs 0.01ms,Arrange 0.01ms,Paint 0.05ms。再框一下背包里 200 个格子组成的 Scroll Box,Paint 累计 2.1ms。这就是第一个线索:背包的标题栏不贵,贵的是那 200 个格子。每个格子单独看只有 0.01ms,但数量乘以单价,总价就上去了。
定位这一步给后面所有优化定了调。不做测量就动手,很容易陷入两个误区。要么把便宜的系统改十遍,帧时间纹丝不动;要么把复杂的 UI 砍到面目全非,结果真正的罪魁是某个每帧都在刷新的进度条。Widget Reflector 的价值在于把 UI 开销拆到了控件粒度,而不是给出一个笼统的 SlateUI 数字。
下面这张图是远港 Online 那次定位时的链路:
flowchart TD
A["玩家打开背包,帧率从 60 掉到 48"] --> B["stat unit 看到 SlateUI 7.8ms"]
B --> C["打开 Widget Reflector Live 模式"]
C --> D["框选背包标题栏:0.07ms"]
C --> E["框选 200 个物品格子:2.1ms"]
C --> F["框选右侧属性面板:1.4ms"]
E --> G["假设:格子数量是首要开销"]
F --> H["假设:属性面板绑定刷新过频"]
G --> I["先做列表虚拟化,再验证"]
H --> J["检查 Bind 轮询与事件驱动"]2. 理解 UMG 的帧成本:Prepass、Arrange、Paint 与 Draw Call
UMG 建立在 Slate 之上。Slate 每帧对控件树做三件事:Prepass 算 DesiredSize,Arrange 算最终位置和大小,Paint 把每个控件画到屏幕上。三件都做完,渲染线程还要把它们合批成 Draw Call 提交给 GPU。开销不是只在 GPU 侧,CPU 侧的遍历和计算同样占帧时间。
Widget Reflector 里那三列数,对应的就是 Slate 主线程的三阶段。Prepass 和 Arrange 通常和控件数量成正比。Paint 除了控件数量,还和每个控件的可视复杂度有关。一个纯色图片几乎不花钱,一个带渐变、遮罩、描边的材质球就贵得多。
Draw Call 是另一笔账。Slate 会把相邻的、同材质的控件合并成一次绘制调用。理想情况下,一整个界面只用几个 Draw Call。但如果控件之间互相遮挡、材质频繁切换、透明度穿插,合批就会被打破。背包 200 个格子如果每个格子都用不同的图标材质,Draw Call 就从几个暴涨到几百。
Overdraw 是 GPU 侧的隐蔽开销。屏幕上同一个像素被多次绘制,就会累加计算。UI 是 Overdraw 的重灾区:半透明底图、模糊背景、叠加的边框、阴影、渐变遮罩,都会让同一个像素被反复着色。背包界面如果底层有一张全屏半透明模糊背景,上面再铺 200 个带半透明底框的格子,每个格子的像素可能被涂三四次。
Overdraw 的成本可以用一个简化模型估算。假设屏幕分辨率 1920x1080,每个像素被重绘 次, fragment shader 单次开销为 秒,GPU 频率为 。则 Overdraw 带来的额外 GPU 时间约为:
看起来很小,但乘以 200 多万像素和 之后,就是毫秒级的数字。把 从 4 降到 2,理论上就能省下一半的 Overdraw 时间。工程中常用 Unreal 的 Shader Complexity 视图和 Quad Overdraw 视图来可视化 hotspots。
sequenceDiagram
participant G as "Game 线程"
participant S as "Slate 控件树"
participant R as "渲染线程"
participant GPU
G->>S: Tick:数据更新、动画推进
S->>S: Prepass:自顶向下算 DesiredSize
S->>S: Arrange:自底向上排最终位置
S->>S: Paint:生成 DrawElements
S->>R: 推送渲染命令
R->>GPU: Draw Call 批次
GPU->>GPU: Fragment Shader 多次写入同一像素
Note over GPU: Overdraw 集中区域发热3. Invalidation Box:让静态控件停止重复劳动
远港 Online 的 HUD 上有一大块区域几乎不变:顶部中央的任务追踪、左下角的聊天频道标签、右下角的技能栏底框。这些控件每帧都被 Prepass、Arrange、Paint 一遍,但实际内容一整局可能只变几次。
Invalidation Box 就是用来解决这种浪费的。把它包在几乎不变的控件外层,Slate 会把这个子树缓存成一张 render target。后续帧只要子树内部没有标记为 dirty,就直接贴这张缓存,跳过 Prepass、Arrange、Paint 的全部计算。
Invalidation Box 的使用条件很具体。子树内部不能有任何每帧都变的东西,比如动态数字、旋转图标、滚动条。一旦有子控件调用了 Invalidate,整个缓存失效,重新绘制一次。所以它适合包静态背景、静态装饰、静态文本。远港的聊天频道标签被包进 Invalidation Box 后,这部分从每帧 0.4ms 降到了几乎测不出来。
Invalidation Box 还有一个隐形好处:减少 Draw Call。因为缓存成一张图,后续帧不管原控件树有多少节点,都只提交一次绘制。对于层级深、控件多的静态装饰,这笔收益很明显。
但用错了会反过来吃性能。如果子树内部频繁失效,每次失效都要重新渲染到 render target,比普通路径多一步 RT 写入。小陈第一次尝试时把血条也包进去了,结果血条每帧都变,Invalidation Box 成了负优化,帧时间反而涨了 0.3ms。这个教训后来被写进了组内规范:只有变化频率低于每秒一次的控件,才允许放进 Invalidation Box。
下面这张图是 HUD 中 Invalidation Box 的划分:
flowchart TD
subgraph ui ["HUD 界面"]
task["任务追踪 静态文本"]
chatlabel["聊天频道标签 静态按钮"]
skillbg["技能栏底框 静态图片"]
hpbar["血条 每帧变化"]
buffrow["Buff 图标 高频刷新"]
end
subgraph cache ["Invalidation Box 缓存区"]
end
task --> taskcache
chatlabel --> chatcache
skillbg --> skillcache4. Retainer Box:把动态但稳定的区域也缓存下来
Invalidation Box 缓存的是完全静态的内容。UI 里还有一类控件:内容在变化,但变化频率不高,比如小地图、角色头像、技能冷却遮罩。Retainer Box 比 Invalidation Box 更灵活,它允许以固定频率刷新子树,而不是每帧都刷。
Retainer Box 有几个关键参数。Phase 控制首次渲染偏移,避免多个 Retainer Box 同一帧刷新造成尖峰。Period 控制刷新间隔,单位是帧。Render Target 可以指定尺寸,决定缓存分辨率。Far Up 选项可以让子树按 RT 分辨率渲染,再缩放上去,适合需要降低分辨率以节省开销的场景。
远港的小地图被放进了 Retainer Box,Period 设为 4,也就是每 4 帧刷新一次。小地图本身带模糊、遮罩、多个图标,原来每帧 1.2ms,改后平均 0.35ms。玩家几乎看不出 15fps 和 60fps 的小地图区别,因为地图移动本身就很慢。
Retainer Box 的代价是内存:它需要一张 render target。移动端尤其要当心显存。远港在 PC 端用 512x512 的 RT,Switch 分档降到了 256x256。如果多个 Retainer Box 叠在一起,还要注意刷新相位错开,否则某一帧会集中爆发多张子树的渲染。
另一个常见用法是技能冷却的扇形遮罩。这种效果通常用材质实现,每帧改一个角度参数。如果把它放进 Retainer Box,Period 设为 1 虽然没省计算,但可以把复杂的材质渲染缓存下来,减少 Draw Call 和 Overdraw。实际收益取决于具体材质复杂度。
5. 消灭 Bind 轮询:UMG 更新频率的源头治理
远港属性面板那 1.4ms,Widget Reflector 定位到了具体控件:一个每秒刷新 10 次的角色属性列表。列表里每一项都用 UMG 设计器里的 Bind 绑了函数。Bind 的默认行为是每帧轮询,通过反射调用 getter,拿到值再和旧值比较。
反射调用本身不慢,但乘以控件数量和帧率,就成了稳定的税。假设一个界面有 150 个 Bind,每个 getter 平均 0.003ms,60fps 下每帧就是 0.45ms。这还没算旧值比较失败后的无效开销。如果 getter 里又读了游戏对象、做了字符串拼接,数字会更难看。
解决方式不是禁用 Bind,而是把更新改成事件驱动。数据变化时主动调用 UUserWidget 的更新函数,或者直接监听 ViewModel 的 FieldNotify。UE5 的 MVVM 插件在 5.8 里已经比较成熟,适合做这层解耦。
事件驱动的一个简单模式是:后端数据组件维护一份脏标记,游戏线程 Tick 里检测脏标记,发现有变化就广播委托。UI 层订阅委托,收到通知后更新对应控件。这样不需要每帧轮询,没有变化时 UI 完全不参与计算。
小陈把属性面板的 36 个 Bind 全部改成事件驱动后,属性面板从每帧 1.4ms 降到了 0.2ms。改动量不小,但收益明确。后来组里立了一条规矩:Bind 只允许在原型验证阶段存在,任何进入主干的 UI 都必须有事件驱动或 MVVM 方案。
下面是一段简化的事件驱动更新示例:
// 数据组件:只标记脏,不主动推
UCLASS()
class UPlayerAttributeCache : public UObject
{
GENERATED_BODY()
public:
void SetHealth(float NewHealth)
{
if (!FMath::IsNearlyEqual(Health, NewHealth))
{
Health = NewHealth;
bDirty = true;
}
}
void Flush()
{
if (bDirty)
{
OnAttributesChanged.Broadcast(Health, MaxHealth, Shield);
bDirty = false;
}
}
FAttributeChangedDelegate OnAttributesChanged;
private:
float Health = 0.f;
float MaxHealth = 0.f;
float Shield = 0.f;
bool bDirty = false;
};这段代码不长,但把“数据变更”和“UI 刷新”拆成了两个步骤。Flush 可以在游戏线程 Tick 末尾调用,也可以按固定频率调用,取决于项目对延迟的容忍度。
6. 复杂列表虚拟化:背包 200 格只画 12 个
Widget Reflector 最早抓到的 2.1ms,根源是背包 200 个格子同时存在。Scroll Box 默认会把所有子项一次性创建出来,不管它们是否在视口内。对于 200 格的背包,只有 12 格看得见,剩下 188 格都在白白吃开销。
UE 的 List View 和 Tile View 就是用来解决这个问题的。它们只创建视口内可见的子项,滚动时回收并复用已经创建的 widget 实例。List View 适合一维列表,Tile View 适合二维网格。背包从 Scroll Box + 200 个 Uniform Grid Slot 改成 Tile View 后,同时存在的格子从 200 个降到了 15 个左右,Paint 开销从 2.1ms 降到了 0.3ms。
虚拟化的关键是 UUserObjectListEntry 接口。数据项实现这个接口,List View 在创建条目时调用 NativeOnListItemObjectSet,回收时调用 NativeOnItemSelectionChanged。数据层只保留一份轻量结构,UI 层负责把数据渲染成控件。
虚拟化还有几个细节要注意。条目高度必须固定或预先计算,否则 Slate 没法快速算出视口内有哪些条目。滚动条 thumb 的大小需要基于总条目数估算,这个估算要轻,不能遍历所有数据。条目内部如果有图片异步加载,要处理复用时的取消和重载,否则快速滚动会出现头像错乱。
远港的背包虚拟化改造中,最大的坑是拖拽。玩家可以拖拽格子整理背包,而拖拽的源控件可能被虚拟化回收掉。解决方式是拖拽开始时把数据复制到拖拽句柄,UI 上显示一个独立的拖拽预览控件,不再依赖原条目。这个改动让拖拽逻辑复杂了不少,但保住了虚拟化的收益。
虚拟化带来的收益可以用一个粗略公式描述。设总条目数为 ,视口内可见条目数为 ,单个条目开销为 ,则非虚拟化列表每帧开销约为 ,虚拟化后约为 ,其中 是回收和滚动计算的固定开销。比值近似为:
远港背包里 ,,比值约 0.06,也就是理论上能省 94%。实际因为条目复用、滚动计算、拖拽预览等开销,只压到了原来的七分之一,但已经是从根本上改变了成本结构。
graph LR
subgraph 非虚拟化
A1["格子 1"] --> A2["格子 2"]
A2 --> A3["格子 3"]
A3 --> A4[...]
A4 --> A200["格子 200"]
end
subgraph 虚拟化
B1["可见 1"] --- B2["可见 2"]
B2 --- B3["可见 3"]
B3 --- B12["可见 12"]
B12 -.->|"滚动回收"| B1
end7. World Space Widget 优化:血条不是贴个 3D UI 就完事
远港的怪物头顶有血条和名字,一开始全用 World Space Widget Component 实现。进本后同屏 40 个怪,每个怪头顶一个 UUserWidget,SlateUI 直接从 2ms 飙到 6ms。更可怕的是这些 widget 还投影到世界空间,缩放、遮挡、半透明排序都要参与。
World Space Widget 的第一条优化是裁剪距离。超过 15 米的怪不显示血条,只显示名字;超过 30 米的怪什么都不显示。这个规则用距离平方比较,避免开方。裁剪后同屏需要渲染的血条从 40 个降到了 12 个。
第二条是聚合。同一模型类型的怪,如果距离很近,可以把它们的血条合并成一个 billboard。远港里成群的小怪用了这个方案,5 个一组共用一个聚合血条,上面用不同颜色段表示各怪血量。聚合血条不是 UMG 做的,而是直接用材质和 Custom Mesh Component 画在世界里,绕开 Slate 管线。
第三条是降低刷新频率。怪物血条不像玩家血条那样需要每帧精确,可以每 4 帧更新一次。远的怪甚至可以每 16 帧更新一次。这种 time-sliced 更新对 World Space Widget 特别有效,因为每个 widget 都对应一个 Slate 控件树,刷新一次就要遍历一次树。
第四条是控制尺寸。World Space Widget 在屏幕上的像素尺寸会随距离变化。远的怪如果 widget 尺寸还大,就会浪费大量 Overdraw。远港给每个血条设了屏幕空间最大像素尺寸,远的怪血条自动缩小,避免远处像素被反复涂抹。
最后一条是慎用半透明。怪物血条的底框、血量条、边框如果都是半透明材质,Overdraw 会叠加。改成不透明或 masked 材质后,同屏血条的总 Draw Call 和 Overdraw 都明显下降。
8. 性能分析工具与检查清单
两周优化能收敛到 0.8ms,不是靠某一项奇技淫巧,而是每一步都有工具验证。下面把用到的工具和它们的适用场景列出来。
Widget Reflector 负责控件级开销定位。看到 SlateUI 高,先用它框选高耗时区域,确定是某个面板、列表还是 World Space Widget。
stat unit 和 stat slate 负责整体感知。stat unit 看 Game、Draw、GPU、Frame 四项,确认瓶颈落在哪条线程。stat slate 看 Slate 控件数量、无效化数量、缓存命中数量,用来验证 Invalidation Box 和 Retainer Box 是否生效。
Unreal Insights 负责抓帧级细节。用 trace.start cpu,gpu,frame 录制一段有问题的场景,回头在 Insights 里看 Slate 相关的 scope。可以看到具体哪个函数、哪次 Tick 占时间。
Shader Complexity 视图和 Quad Overdraw 视图负责 GPU 侧可视化。Shader Complexity 用颜色表示像素着色器开销,Quad Overdraw 显示每个 2x2 像素块被重绘的次数。两者结合,能快速找到 UI 材质和叠加层的问题。
RenderDoc 和 PIX 负责底层 GPU 抓帧。当 stat gpu 和 SlateUI 对不上时,用 RenderDoc 抓一帧,看真正的 Draw Call 顺序、RT 切换、Blend State。
Memory Profiler 和 rhi.memorydump 负责内存侧。Retainer Box 和 Invalidation Box 都会增加 render target 内存,移动端和主机端要专门统计。远港在 PS5 上给每个 Retainer Box 做了预算登记,超过 8MB 就要审批。
flowchart LR
subgraph 工具链
WR["Widget Reflector"] --> SU["stat unit / stat slate"]
SU --> UI["Unreal Insights"]
UI --> SC["Shader Complexity"]
SC --> RD["RenderDoc / PIX"]
RD --> MP["Memory Profiler"]
end下面这张检查清单被贴在远港 UI 组每位同事的显示器边上。新界面合入主干前必须逐项打勾。
静态控件
- [ ] 不随数据变化的背景、边框、图标已用 Invalidation Box 包裹
- [ ] Invalidation Box 内部没有每帧刷新的子控件
- [ ] 变化低频的区域已用 Retainer Box,Period 已按重要性分级
动态绑定
- [ ] 没有遗留 UMG Bind 轮询
- [ ] 数值更新使用事件驱动或 MVVM FieldNotify
- [ ] 动画推进使用 UMG 动画系统或材质参数,不在 Tick 里手工设值
列表与网格
- [ ] 条目数可能超过 30 的列表使用 List View / Tile View 虚拟化
- [ ] 虚拟化条目高度固定或已预计算
- [ ] 异步加载的图片在回收时正确取消
World Space Widget
- [ ] 已按距离裁剪,远的怪不显示血条
- [ ] 同类型近距离目标已考虑聚合
- [ ] 刷新频率已按距离分级
- [ ] 半透明材质已评估 Overdraw 代价
Draw Call 与 Overdraw
- [ ] 相邻同材质控件已合并成 Atlas 或 Nine-Slice
- [ ] 全屏半透明模糊背景已评估必要性
- [ ] Shader Complexity 视图下没有深红色区域
验证流程
- [ ] 在打包版而非编辑器中抓取数据
- [ ] 测试场景覆盖背包全开、团队框架满员、副本 HUD
- [ ] 优化前后用同一路线复测三次取中位数
9. 两周后的数字
回到开头那个 4.2ms。两周优化后,SlateUI 在 HUD 常态下稳定在 0.8ms。具体分解如下:
- 顶部任务追踪和聊天标签放进 Invalidation Box:省下 0.4ms
- 小地图放进 Retainer Box,Period=4:省下 0.85ms
- 属性面板从 Bind 轮询改为事件驱动:省下 1.2ms
- 背包从 Scroll Box 200 格改为 Tile View 虚拟化:省下 1.8ms
- 怪物血条按距离裁剪、聚合、降频:省下 0.9ms
- 合并 UI Atlas、去掉多余半透明层:省下 0.25ms
这些数字是远港在 PC 高端档上的结果。Switch 分档下,Retainer Box 分辨率更低,World Space Widget 裁剪距离更近,最终 SlateUI 控制在 1.4ms 以内。
这个过程中最值钱的不是具体技术,而是测量纪律。每次只改一个变量,改完用同一场景复测三次。Widget Reflector 和 stat slate 成了每天的早餐。没有这两样,优化会变成玄学;有了它们,优化就是一笔笔能算清的账。
UI 性能不是一次性的工作。新界面不断加进来,老界面不断改,昨天的优化可能今天就失效。远港把检查清单放进了代码审查模板,把性能基线放进了 CI。只要还有人往界面里加东西,这套纪律就得继续跑下去。