UMG 性能最佳实践手册
UMG 性能最佳实践手册
UMG 性能最佳实践手册
适用于 Unreal Engine 5.x,基于 Slate / UMG 源码(
Engine/Source/Runtime/Slate*)分析整理。 目标:在日常 UMG 制作过程中,用最小代价控制 DrawCall 数量、合批失败率和 CPU 开销。
0. 阅读前必须建立的心智模型
在打开 UMG 编辑器前,先理解 Slate 的"三层帐":
LayerId (排序优先级)
└─→ FSlateDrawElement (每个绘制原子)
└─→ FSlateRenderBatch (合批后的 GPU DrawCall 单位)
关键事实:
- LayerId 不直接等于 DC。LayerId 是
MergeRenderBatches排序键;同 LayerId 且其它条件相同的多个 element 会合成 1 个 DC。 - 合批的硬约束(
SlateRenderBatch.h:145-161):- LayerId 完全相同
- ShaderResource 指针相同(纹理/材质)
- ShaderType 相同(Default / Custom / RoundedBox / GrayscaleFont / ColorFont / SdfFont …)
- ClippingState 指针相同
- DrawEffects / DrawFlags / ShaderParams 相同
MergeRenderBatches排序后遇到不同 LayerId 立即 break(DrawElements.cpp:260-263)—— 一旦 LayerId 不连续,永远不会跨 layer 合批。- 同纹理 ≠ 能合批。还要看 LayerId、ClippingState、ShaderType 是否一致。
1. 控件 LayerId 行为速查表
1.1 合批友好型(兄弟节点共享 LayerId)
| 控件 | 行为 | 推荐场景 |
|---|---|---|
SHorizontalBox / SVerticalBox / SBoxPanel | 所有子节点共享同一 LayerId | 首选容器,工具栏、列表行、并列图标 |
SUniformGridPanel / SWrapBox | 同上 | 等距网格 |
SImage | 1 个 element,不 ++ | 基础图元 |
SBox | 透传 LayerId | 纯尺寸约束 |
SScaleBox | 透传 LayerId | 缩放包装 |
1.2 每个子节点 +1(容器自身强制分层)
| 控件 | LayerId 增量 | 备注 |
|---|---|---|
SOverlay | 每子 +1 + 10~100 层 padding | padding 是为 fast path 设计的缓冲;N 个子节点至少占 10×N 层 |
SGridPanel | 每个 layer group +1 | 不是"每子 +1",但跨 group 必断 |
SCanvas | 每子 +1 | UI Canvas 默认行为 |
SConstraintCanvas | 每子 MaxLayerId+1 | 同 SCanvas |
结论:用 SOverlay/SCanvas 装并列图标 = 自动放弃合批。
1.3 SCompoundWidget 派生 — 自身 +1(绝大多数 UMG 控件)
SCompoundWidget::OnPaint(SCompoundWidget.cpp:46)固定走:
TheChild.Widget->Paint(..., LayerId + 1, ...);
派生类清单(每个都让自己内部 child +1):
SBorder、SButton、SCheckBox、SComboBoxSUserWidget、SInvalidationPanel、SRetainerWidgetSScrollBox、SSafeZone、SDPIScaler、SExpandableAreaSFxWidget(再额外强制 +1)
结论:每嵌套一层 SCompoundWidget 派生 = +1 LayerId。一个典型路径 UserWidget → Border → HBox → [Image, Text] 已经 +3。
1.4 控件属性触发的额外 ++LayerId
| 控件 | 触发属性 | LayerId 增加 | 合批影响 |
|---|---|---|---|
STextBlock (Simple) | ShadowColor.A>0 && ShadowOffset≠0 | +1 | shadow 与 main 不可合 |
STextBlock (默认富文本路径) | 总是 | +1(即使无 shadow) | FSlateTextRun 总 ++ |
STextBlock | OutlineSize > 0 | OnPaint 看不到,batcher 内部 +1 | outline atlas 独立,必拆 2 DC |
STextBlock | OutlineSize > 0 AND ShadowColor.A>0 | +2 | 最多 4 DC(shadow+shadow_outline, main+main_outline) |
SProgressBar | 多个 fill / marquee | 每 element +1,最多 +9 | |
SSlider | 总是 | +1 | |
SSplitter | 总是 | +1(画 handle 前) | |
SBackgroundBlur | 启用 blur | +1 + 强制 PostProcess pass(不可合批且打断后续合批) | 灾难级 |
SMenuAnchor | 弹出 popup 时 | +1 | |
SColorBlock / SColorWheel / 各 Gradient | 总是 | +1 |
1.5 强制断 batch(bIsMergable = false)
| 控件 | 机制 |
|---|---|
SBackgroundBlur | PostProcess pass,断后续合批序列 |
SViewport(嵌入 3D) | Custom Drawer,ElementBatcher.cpp:3043 |
用 MakeCustomVerts 的控件(Niagara UI、Slate3D) | bIsMergable=false(ElementBatcher.cpp:3064) |
2. 文本(STextBlock / SRichTextBlock)规范
2.1 DC 帐单
| 配置 | OnPaint 增加 LayerId | Batcher 内部 batch 数 |
|---|---|---|
| 纯文本 | 1 | 1 |
| 纯描边 | 1 | 2(outline atlas 独立 + Layer+1) |
| 纯阴影 | 2 | 2 |
| 阴影 + 描边 | 2 | 3~4 |
2.2 必须遵守的规则
- ✅ 同一段 UI 内字体描边色尽量统一。每种不同描边色 = 独立 FontAtlas slot。
- ✅ 优先用静态背景图替代 ShadowOffset。背景图能和其它 UI 合批,shadow text 不行。
- ✅ 描边粗细使用整数像素。
OutlineSize=1和OutlineSize=2是两套 atlas 条目。 - ✅ 简单数字/状态文本(HP/MP/计数):尽可能开启
bSimpleTextMode(构造时设_SimpleTextMode=true),省 Layer 且走快速 measure 缓存。 - ⚠️ 避免在一段文本里混用不同字号/字色:每个 ShapedTextSubSequence 切换都可能跨 atlas。
- ⚠️ 慎用 RichTextBlock:每个 run 至少 1 个 ++LayerId,富文本图标 run 必拆 batch。
- ❌ 不要给所有 TextBlock 默认开描边。这是 DC 暴涨最常见的原因。
2.3 字体材质(Font Material)
SlateRHIResourceManager.cpp:697 显示:每个 FontMaterial × 每个 atlas page 是独立的 FSlateMaterialResource。
- ❌ 不要给常用字体配 FontMaterial。一段文本跨多 atlas page 会变成多 batch。
- ✅ 仅在特效文字(标题、关键提示)上用 FontMaterial,且控制字符数。
3. 图片(SImage / UImage)规范
3.1 Brush 资源类型选择
| Brush ResourceObject 类型 | 合批潜力 |
|---|---|
UTexture2D(静态贴图) | 高:相同纹理 → 相同 ShaderResource → 同 LayerId 即可合 |
同一 UMaterial(非 MID) + 相同 ImageSize | 高 |
同一 UMaterial + 不同 ImageSize | 0(不同 FMaterialKey) |
各自的 UMaterialInstanceDynamic(MID) | 0(不同 UObject 指针) |
| 同一 MID 多处使用 | 高 |
3.2 实战规则
- ✅ 尽量用图集(Atlas/TexturePackage):UI 资源打到同一张大图,命中同一 ShaderResource。
- ✅ MID 复用:在 UserWidget 里持有 MID 实例,所有用到的地方共享同一个 MID 指针。
- ✅ 同图标的不同显示态用 color tint 而不是不同贴图:tint 是 vertex color,不影响合批。
- ⚠️ 9-Slice (
Box/BorderDrawAs):在 batcher 走Bordershader,和Defaultshader 的普通图不合批。 - ⚠️ RoundedBox(
Slate.RoundedBoxShader):用了 CornerRadius 的 brush 走RoundedBoxshader,和普通 brush 不合批。 - ❌ 不要给每个按钮都用独立 MID 控制 hover/pressed:考虑共享 MID + per-element 状态参数,或干脆切贴图。
4. 容器选择决策树
要装多个 UI 元素?
│
├─ 元素之间没有重叠,纯线性排列
│ └─→ SHorizontalBox / SVerticalBox ✅ 合批友好
│
├─ 元素有明确前后压关系(如背景+前景+图标+文字层叠)
│ └─→ SOverlay ⚠️ 每子 +10~100 层
│ 建议:背景能用静态图就别用单独 widget
│
├─ 等距网格布局(背包格子)
│ ├─ 所有格子完全相同(无独立交互)→ SUniformGridPanel ✅
│ └─ 不同行/列有不同内容 → SGridPanel ⚠️ 跨 group 必断
│
├─ 任意 2D 定位
│ └─→ SCanvas / SConstraintCanvas ⚠️ 每子 +1
│ 建议:尽量用 Box 系列对齐+padding 实现,少用绝对定位
│
└─ 多个互斥状态切换显示
└─→ SWidgetSwitcher ✅(同时只 paint 一个)
4.1 反例与正例
❌ 反例:用 SOverlay 横排 5 个状态图标
SOverlay
├─ SImage (icon1) ← layer 0
├─ SImage (icon2) ← layer 10
├─ SImage (icon3) ← layer 20
├─ SImage (icon4) ← layer 30
└─ SImage (icon5) ← layer 40
→ 5 个 DC,无论纹理是否相同
✅ 正例:用 SHorizontalBox
SHorizontalBox
├─ SImage (icon1) ← layer N
├─ SImage (icon2) ← layer N
├─ SImage (icon3) ← layer N
├─ SImage (icon4) ← layer N
└─ SImage (icon5) ← layer N
→ 若同图集:1 个 DC
5. InvalidationPanel 使用规范
5.1 它做什么 / 不做什么
| 做什么 | 不做什么 |
|---|---|
| 缓存 Draw Elements + Render Batches | 不减少 GPU DC 数量 |
| 未脏 widget 跳过 OnPaint | 不消除合批墙 |
| 间接稳定 ClippingState 指针(可能多合一些 batch) | 不改变 layer 关系 |
结论:InvalidationPanel 优化 CPU 端(OnPaint + element→batch 转换),不影响 GPU DC 总数。
5.2 何时该用
- ✅ 大型静态/低频更新面板(背包、技能树、设置界面)
- ✅ 含大量子 widget(>20 个)但每帧只有少数变化
- ❌ 高频整体动画(每帧都 dirty 全部子 widget)→ 反而徒增 fast path overhead
- ❌ 子 widget 数量很少(<5)→ 缓存收益小于管理开销
5.3 触发 SlowPath(完全重画整棵子树)的常见原因
任何一个发生都会作废整个缓存:
- 子 widget 调用
Invalidate(EInvalidateWidgetReason::Layout | ChildOrder) - 子 widget Visibility 变化
- HitTestGrid 区域变化
- 列表类控件
RequestListRefresh
5.4 实战规则
- ✅ 每个独立功能面板套一个 InvalidationPanel(边界清晰)
- ✅ 频繁变化的元素(HP 条、数字、计时器)放在 InvalidationPanel 外或独立 InvalidationPanel
- ⚠️ 嵌套 InvalidationPanel 不会叠加收益;以最外层为准
- ⚠️ Editor 下行为与 runtime 有差异(可能不缓存),优化要在 Standalone/Cooked 验证
6. RetainerWidget 使用规范
6.1 它做什么
将整棵子树烘焙到 UTextureRenderTarget2D,对外只产生 1 个 MakeBox(RT quad)。子树原有的 N 个 DC → 外部 1 个 DC + RT 内部 N 个 DC(只在 RequestRender 触发时发生)。
6.2 何时该用
- ✅ 复杂且几乎不变的装饰性 UI(HUD 边框、技能图标背景、长期不动的徽章)
- ✅ 子树 DC > 20 且内容低频更新(< 1Hz 或事件驱动)
- ❌ 每帧变化的内容(数字、动画、进度条)—— RT 每帧重画 = 雪上加霜
- ❌ 大尺寸 UI(接近屏幕)—— RT 显存代价过大
6.3 移动端特别注意
Mobile GPU 是 tile-based,每次重画 RT 都伴随:
- 当前 tile cache flush
- 完整 tile binning
- RT 作为纹理切回 sample
经验阈值:
- PC:子树内 DC > 10 且更新频率 < 10Hz → 净收益
- Mobile:子树内 DC > 30 且更新频率 < 5Hz → 净收益
- Mobile 上每帧 Render 的 RetainerWidget 几乎一定是负优化
6.4 实战规则
- ✅ 显式控制重画频率:
RetainedRenderingPhase+RequestRender() - ✅ 设置合理的 RenderTarget 尺寸(不要按屏幕全分辨率)
- ⚠️ 内部不能放需要响应式输入的 widget(hit test 复杂)
- ⚠️ 内部 ClipState 会被压平进 RT,外部无法感知
- ❌ 不要嵌套 RetainerWidget
7. 移动端额外注意事项
7.1 Atlas 大小
默认运行时:Grayscale 1024、Color 512、SDF 512。Mobile 项目通常在 [SlateRenderer] ini 中调小(512)。Atlas 越小 → 越易溢出 page → 越易切 batch。需要在项目配置中按 DeviceProfile 调整。
7.2 字体显存
字体 atlas 是 R8/BGRA8 非压缩。CJK 全集每张 1024×1024 = 1MB,多张能吃几十 MB。控制:
- 限制 UI 字号种类(每个字号是独立 atlas key)
- 大字号 + CJK 用 SDF Text(开启
Slate.EnableSDFText)—— 一张 atlas 多种字号共用,但多 1 个 shader 分支
7.3 UI Material
MD_UImaterial 在 ES3_1 上有 instruction count 限制- 不支持 SceneTexture / World Position 等节点
- 复杂 material 会回退到 fallback,UI 直接显示错误色
7.4 RetainerWidget vs InvalidationPanel
- PC:先 InvalidationPanel,必要时上 Retainer
- Mobile:先 InvalidationPanel,Retainer 仅用于极静态大块
8. 反模式清单(DC 暴涨黑名单)
| 反模式 | 后果 | 替代 |
|---|---|---|
| 默认给所有 TextBlock 加描边 | 文本 DC × 2 | 仅必要文本加,统一描边色 |
| 给所有 TextBlock 加阴影 | 文本 DC × 2 + Layer 暴涨 | 用静态背景图实现阴影效果 |
| 每个按钮都给独立 MID | 每按钮 = 1 DC | 共享 MID + 参数化 |
| 用 SOverlay 排列并列元素 | N 个元素 = N 个 DC | SHorizontalBox/SVerticalBox |
| 用 SCanvas 做整页布局 | 每子 +1 layer | 用 Box 系列组合 |
| 不同尺寸 Image 共享同一 UMaterial brush | 每尺寸 1 个 MaterialResource | 统一尺寸或换静态贴图 |
| 频繁动画的内容放 RetainerWidget | RT 每帧重画,GPU 翻倍 | InvalidationPanel 或不优化 |
| 全屏 SBackgroundBlur 当默认遮罩 | PostProcess 强制断 batch | 用半透明 Image 替代 |
| 富文本中频繁内联图标 | 每图标 = 1 run = 1 DC | 用独立 SImage 而非 inline image run |
| UMG 树嵌套 10+ 层 SCompoundWidget | 起步就 +10 Layer | 扁平化层级 |
| 用 SOverlay 给所有 Image 加 hover 高亮层 | 每图 ×2 DC | Image 的 brush + tint 实现 |
9. 性能验证手段
9.1 控制台指令
slate.ShowBatching 1 -- Widget Reflector 中显示 batch 颜色
slate.ShowOverdraw 1 -- 显示 overdraw 热点
slate.DrawToVRamInsteadOfNormalRT 1 -- 隔离 Slate DC
stat slate -- Slate CPU stats
stat slateverbose -- 详细 stats(含每 widget OnPaint)
stat slaterhi -- GPU 端 DC 数量
SlateDebugger.Start -- 启动调试器
9.2 Widget Reflector 关键信息
打开方式:Window → Developer Tools → Widget Reflector,或快捷键。
关注列:
- Layer —— 每个 widget 的 OutgoingLayerId
- InvalidationRoot —— 当前 widget 属于哪个 InvalidationPanel
- Painted Last Frame —— 是否走了 OnPaint(fast path 跳过的会显示 false)
9.3 优化验证流程
1. stat slaterhi 记录当前 DC 数(基线)
2. 局部修改(如把 SOverlay 改为 SHorizontalBox)
3. 同场景再次测量 DC
4. 用 slate.ShowBatching 1 视觉验证合批边界
5. 跨平台验证(特别是 Mobile)
10. 决策速查卡
10.1 "我要做一个 X,应该怎么做?"
| 需求 | 推荐方案 |
|---|---|
| 横排工具栏 | SHorizontalBox + 同图集图标 |
| 网格化背包 | SUniformGridPanel(单元等大) |
| 弹出菜单 | SMenuAnchor + popup 内用 Box 系列 |
| 状态指示器(图标+数字) | SHorizontalBox + Image + TextBlock(共享同 LayerId) |
| HUD 边框装饰 | RetainerWidget(运行时静态) |
| 设置面板 | InvalidationPanel 包整个面板 |
| HP/MP 条 | SProgressBar 单独使用,不要放进 RetainerWidget |
| 跳字伤害数字 | 独立 widget,不要放进 InvalidationPanel |
| 选中高亮 | Image + tint 切换(不要 SOverlay + 高亮 Image) |
| 全屏暗色遮罩 | 半透明 SImage(不要 SBackgroundBlur) |
| 弹窗动画(缩放) | 独立 widget 树 + tint,避免影响主界面 |
10.2 "我看到 DC 偏高,应该先查什么?"
1. 是否有 TextBlock 默认开了描边/阴影? → 取消或统一
2. 容器用对了吗?SOverlay/Canvas 滥用? → 改 Box 系列
3. 同图标的不同状态是不是各自一个 brush? → 改 tint
4. SCompoundWidget 嵌套是不是过深? → 扁平化
5. 是否有 SBackgroundBlur? → 评估是否能换
6. UMG 是不是给每个 element 配了独立 MID? → 共享 MID
7. 静态面板是否套了 RetainerWidget? → 视情况添加
11. 关键源码位置索引
便于后续追代码:
| 主题 | 文件 | 关键行 |
|---|---|---|
| SCompoundWidget +1 | SlateCore/Private/Widgets/SCompoundWidget.cpp | 46 |
| SPanel 共享 LayerId | SlateCore/Private/Widgets/SPanel.cpp | 33 |
| SOverlay 10 层 padding | SlateCore/Private/Widgets/SOverlay.cpp | 206, 223 |
| STextBlock SimpleMode shadow | Slate/Private/Widgets/Text/STextBlock.cpp | 269-292 |
| FSlateTextRun shadow + main | Slate/Private/Framework/Text/SlateTextRun.cpp | 174-196 |
| Outline 拆 batch | SlateCore/Private/Rendering/ElementBatcher.cpp | 1670-1688 |
| BuildShapedTextSequence 切 batch | SlateCore/Private/Rendering/ElementBatcher.cpp | 3504-3614 |
| Font Atlas Key 含 OutlineSettings | SlateCore/Private/Fonts/FontCache.cpp | 1287 |
| MergeRenderBatches 算法 | SlateCore/Private/Rendering/ElementBatcher.cpp | 171-280 |
| IsBatchableWith 合批条件 | SlateCore/Public/Rendering/SlateRenderBatch.h | 145-161 |
| Material 资源 cache key | SlateRHIRenderer/Private/SlateRHIResourceManager.cpp | 914-946 |
| Font Material cache key | SlateRHIRenderer/Private/SlateRHIResourceManager.cpp | 673-699 |
| InvalidationRoot Push/Pop | SlateCore/Private/FastUpdate/SlateInvalidationRoot.cpp | 357-440 |
| Fast Path LayerId Fixup | SlateCore/Private/FastUpdate/SlateInvalidationRoot.cpp | 557-657 |
| RetainerWidget RT 烘焙 | UMG/Private/Slate/SRetainerWidget.cpp | 592-693 |
| Atlas 默认大小 | SlateRHIRenderer/Private/SlateRHIRendererModule.cpp | 37-44 |
| SBackgroundBlur PostProcess | Slate/Private/Widgets/Layout/SBackgroundBlur.cpp | 113-169 |
| CustomDrawer 不可合批 | SlateCore/Private/Rendering/ElementBatcher.cpp | 3043, 3064 |
12. 团队协作建议
-
新增 UMG 控件评审检查表:
- 控件嵌套层级 ≤ 5
- 没有默认描边/阴影
- 容器选择经过 §4 决策树
- 共享 brush / MID 资源
- 在 Standalone 模式验证 DC 数(不是 PIE)
-
DC 预算(参考):
- HUD:< 30 DC
- 单个面板(如背包):< 80 DC
- 全屏复杂界面(菜单+背景特效):< 150 DC
- Mobile 目标:HUD < 20 DC,全界面 < 100 DC
-
代码 Review 关注点:
- 新增
SOverlay必须说明为什么不能用 Box - 新增 MID 必须说明为什么不能共享
- 新增 RetainerWidget 必须附带更新频率说明
- 新增
本文档基于 UE5 release 分支源码整理。版本升级后请核对关键源码位置是否变化。