返回文章列表

UMG 性能最佳实践手册

2026-05-29

UMG 性能最佳实践手册

UMG 性能最佳实践手册

适用于 Unreal Engine 5.x,基于 Slate / UMG 源码(Engine/Source/Runtime/Slate*)分析整理。 目标:在日常 UMG 制作过程中,用最小代价控制 DrawCall 数量、合批失败率和 CPU 开销


0. 阅读前必须建立的心智模型

在打开 UMG 编辑器前,先理解 Slate 的"三层帐":

LayerId (排序优先级)
  └─→ FSlateDrawElement (每个绘制原子)
        └─→ FSlateRenderBatch (合批后的 GPU DrawCall 单位)

关键事实

  1. LayerId 不直接等于 DC。LayerId 是 MergeRenderBatches 排序键;同 LayerId 且其它条件相同的多个 element 会合成 1 个 DC。
  2. 合批的硬约束SlateRenderBatch.h:145-161):
    • LayerId 完全相同
    • ShaderResource 指针相同(纹理/材质)
    • ShaderType 相同(Default / Custom / RoundedBox / GrayscaleFont / ColorFont / SdfFont …)
    • ClippingState 指针相同
    • DrawEffects / DrawFlags / ShaderParams 相同
  3. MergeRenderBatches 排序后遇到不同 LayerId 立即 breakDrawElements.cpp:260-263)—— 一旦 LayerId 不连续,永远不会跨 layer 合批
  4. 同纹理 ≠ 能合批。还要看 LayerId、ClippingState、ShaderType 是否一致。

1. 控件 LayerId 行为速查表

1.1 合批友好型(兄弟节点共享 LayerId)

控件行为推荐场景
SHorizontalBox / SVerticalBox / SBoxPanel所有子节点共享同一 LayerId首选容器,工具栏、列表行、并列图标
SUniformGridPanel / SWrapBox同上等距网格
SImage1 个 element,不 ++基础图元
SBox透传 LayerId纯尺寸约束
SScaleBox透传 LayerId缩放包装

1.2 每个子节点 +1(容器自身强制分层)

控件LayerId 增量备注
SOverlay每子 +1 + 10~100 层 paddingpadding 是为 fast path 设计的缓冲;N 个子节点至少占 10×N 层
SGridPanel每个 layer group +1不是"每子 +1",但跨 group 必断
SCanvas每子 +1UI Canvas 默认行为
SConstraintCanvas每子 MaxLayerId+1同 SCanvas

结论:用 SOverlay/SCanvas 装并列图标 = 自动放弃合批。

1.3 SCompoundWidget 派生 — 自身 +1(绝大多数 UMG 控件)

SCompoundWidget::OnPaintSCompoundWidget.cpp:46)固定走:

TheChild.Widget->Paint(..., LayerId + 1, ...);

派生类清单(每个都让自己内部 child +1):

  • SBorderSButtonSCheckBoxSComboBox
  • SUserWidgetSInvalidationPanelSRetainerWidget
  • SScrollBoxSSafeZoneSDPIScalerSExpandableArea
  • SFxWidget(再额外强制 +1)

结论:每嵌套一层 SCompoundWidget 派生 = +1 LayerId。一个典型路径 UserWidget → Border → HBox → [Image, Text] 已经 +3。

1.4 控件属性触发的额外 ++LayerId

控件触发属性LayerId 增加合批影响
STextBlock (Simple)ShadowColor.A>0 && ShadowOffset≠0+1shadow 与 main 不可合
STextBlock (默认富文本路径)总是+1(即使无 shadow)FSlateTextRun 总 ++
STextBlockOutlineSize > 0OnPaint 看不到,batcher 内部 +1outline atlas 独立,必拆 2 DC
STextBlockOutlineSize > 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

控件机制
SBackgroundBlurPostProcess pass,断后续合批序列
SViewport(嵌入 3D)Custom Drawer,ElementBatcher.cpp:3043
MakeCustomVerts 的控件(Niagara UI、Slate3D)bIsMergable=falseElementBatcher.cpp:3064

2. 文本(STextBlock / SRichTextBlock)规范

2.1 DC 帐单

配置OnPaint 增加 LayerIdBatcher 内部 batch 数
纯文本11
纯描边12(outline atlas 独立 + Layer+1)
纯阴影22
阴影 + 描边23~4

2.2 必须遵守的规则

  • 同一段 UI 内字体描边色尽量统一。每种不同描边色 = 独立 FontAtlas slot。
  • 优先用静态背景图替代 ShadowOffset。背景图能和其它 UI 合批,shadow text 不行。
  • 描边粗细使用整数像素OutlineSize=1OutlineSize=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 + 不同 ImageSize0(不同 FMaterialKey)
各自的 UMaterialInstanceDynamic(MID)0(不同 UObject 指针)
同一 MID 多处使用

3.2 实战规则

  • 尽量用图集(Atlas/TexturePackage):UI 资源打到同一张大图,命中同一 ShaderResource。
  • MID 复用:在 UserWidget 里持有 MID 实例,所有用到的地方共享同一个 MID 指针。
  • 同图标的不同显示态用 color tint 而不是不同贴图:tint 是 vertex color,不影响合批。
  • ⚠️ 9-Slice (Box/Border DrawAs):在 batcher 走 Border shader,Default shader 的普通图不合批
  • ⚠️ RoundedBox(Slate.RoundedBoxShader:用了 CornerRadius 的 brush 走 RoundedBox shader,和普通 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_UI material 在 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 个 DCSHorizontalBox/SVerticalBox
用 SCanvas 做整页布局每子 +1 layer用 Box 系列组合
不同尺寸 Image 共享同一 UMaterial brush每尺寸 1 个 MaterialResource统一尺寸或换静态贴图
频繁动画的内容放 RetainerWidgetRT 每帧重画,GPU 翻倍InvalidationPanel 或不优化
全屏 SBackgroundBlur 当默认遮罩PostProcess 强制断 batch用半透明 Image 替代
富文本中频繁内联图标每图标 = 1 run = 1 DC用独立 SImage 而非 inline image run
UMG 树嵌套 10+ 层 SCompoundWidget起步就 +10 Layer扁平化层级
用 SOverlay 给所有 Image 加 hover 高亮层每图 ×2 DCImage 的 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 +1SlateCore/Private/Widgets/SCompoundWidget.cpp46
SPanel 共享 LayerIdSlateCore/Private/Widgets/SPanel.cpp33
SOverlay 10 层 paddingSlateCore/Private/Widgets/SOverlay.cpp206, 223
STextBlock SimpleMode shadowSlate/Private/Widgets/Text/STextBlock.cpp269-292
FSlateTextRun shadow + mainSlate/Private/Framework/Text/SlateTextRun.cpp174-196
Outline 拆 batchSlateCore/Private/Rendering/ElementBatcher.cpp1670-1688
BuildShapedTextSequence 切 batchSlateCore/Private/Rendering/ElementBatcher.cpp3504-3614
Font Atlas Key 含 OutlineSettingsSlateCore/Private/Fonts/FontCache.cpp1287
MergeRenderBatches 算法SlateCore/Private/Rendering/ElementBatcher.cpp171-280
IsBatchableWith 合批条件SlateCore/Public/Rendering/SlateRenderBatch.h145-161
Material 资源 cache keySlateRHIRenderer/Private/SlateRHIResourceManager.cpp914-946
Font Material cache keySlateRHIRenderer/Private/SlateRHIResourceManager.cpp673-699
InvalidationRoot Push/PopSlateCore/Private/FastUpdate/SlateInvalidationRoot.cpp357-440
Fast Path LayerId FixupSlateCore/Private/FastUpdate/SlateInvalidationRoot.cpp557-657
RetainerWidget RT 烘焙UMG/Private/Slate/SRetainerWidget.cpp592-693
Atlas 默认大小SlateRHIRenderer/Private/SlateRHIRendererModule.cpp37-44
SBackgroundBlur PostProcessSlate/Private/Widgets/Layout/SBackgroundBlur.cpp113-169
CustomDrawer 不可合批SlateCore/Private/Rendering/ElementBatcher.cpp3043, 3064

12. 团队协作建议

  • 新增 UMG 控件评审检查表

    1. 控件嵌套层级 ≤ 5
    2. 没有默认描边/阴影
    3. 容器选择经过 §4 决策树
    4. 共享 brush / MID 资源
    5. 在 Standalone 模式验证 DC 数(不是 PIE)
  • DC 预算(参考):

    • HUD:< 30 DC
    • 单个面板(如背包):< 80 DC
    • 全屏复杂界面(菜单+背景特效):< 150 DC
    • Mobile 目标:HUD < 20 DC,全界面 < 100 DC
  • 代码 Review 关注点

    • 新增 SOverlay 必须说明为什么不能用 Box
    • 新增 MID 必须说明为什么不能共享
    • 新增 RetainerWidget 必须附带更新频率说明

本文档基于 UE5 release 分支源码整理。版本升级后请核对关键源码位置是否变化。