UMG 界面一卡,很多人第一反应是"widget 太多了"。但 widget 数和 GPU 压力之间还隔着一个关键层:批处理。Slate 每帧产出几百上千个绘制图元,真正发给 GPU 的 draw call 可能只有几十个。理解合并规则,才能知道哪些写法在制造 draw call、哪些只是制造图元(图元几乎免费)。代码在 Slate/Private/Rendering/ElementBatcher.cpp。
图元生产端:DrawElement
OnPaint 递归把图元追加到 FSlateWindowElementList:MakeBox(九宫格/普通矩形)、MakeText、MakeShapedText、MakeSpline、MakeGradient……每个 FSlateDrawElement 记录:着色器类型、笔刷资源(纹理或图集槽位)、混合状态、图层ID(元素间的排序键)、顶点数据。
关键认知:Paint 阶段不碰 GPU。它只是往一个数组里追加描述结构,真正的开销在后面的批处理与顶点填充。所以"OnPaint 被调用很多次"本身不等于 GPU 贵——要分开看。
批处理:合并的三条规则
FSlateElementBatcher::DrawElements 按图层 ID 顺序处理元素,遇到可合并的就并进当前 FSlateRenderBatch。两条元素能合并,当且仅当:
- 同一着色器类型(Box / Text / ESlateShader 各变体 / UI Material);
- 同一渲染资源:普通纹理元素要求纹理指针相同;图集元素要求图集句柄相同且纹理可采样(
bRequiresTextureSampling); - 兼容的混合状态与模板参数。
合并后的 batch 一次性填顶点缓冲提交。代价模型因此很清楚:draw call 数 ≈ 不同纹理 × 着色器组合的层内切换次数。一个全用引擎默认圆角框和图集字体的 HUD,全部元素可能合进个位数 batch;一旦插进一张运行时动态纹理(RenderTarget 做的血条、SceneCapture 做的小地图),所有画在它后面的元素被迫断批。
批处理的状态机是单向的:元素按图层顺序流式处理,不能回头重排。所以纹理相同的元素在图层顺序上离得越远,断批越多。这就是为什么布局上相邻的同类元素比散落各处的同纹理元素便宜。
纹理图集:合并的财政基础
Slate 有两类图集资产,是 UI 渲染省 draw call 的基础设施:
- 笔刷图集(
FSlateTextureAtlas):Slate 笔刷引用的纹理在加载时被打进 2048×2048(Slate.AtlasSize)图集,box/border 元素引用的是图集子区域——不同笔刷纹理在图集里同源,可合并。 - 字体图集(
FSlateFontAtlas):字体栅格化的字形按字号打进图集,一段文字所有字形同图集,一次 batch 画完。动态字号(每帧不同缩放)会产生多个图集。
FSlateShaderResourceManager 管理这些图集的生命周期。它的反面是 bRequiresTextureSampling=false 的图集元素可以连纹理采样都省掉。
从数据看优化方向
看 UI 渲染成本用 stat slate(Slate 线程耗时和 update time)和 stat scenerendering 里的 Slate 项。真正要拆 batch 数,得抓一帧看 render batch 列表。经验排序:
- RenderTarget 型 UI 元素(材质 UI、动态小地图)是头号断批源,一个全屏 RT 元素前后各断一次。Retainer Box 能救一部分——它把子树画进自己的 RT,子树内部完成批处理,但对它前后的元素没帮助。
- UI Material 每个材质一个着色器变体,穿插在普通元素中间制造切换。
- 圆角/渐变这类 ESlateShader 特殊路径本身是统一着色器,不算断批源;九宫格边缘渐变参数通过顶点色携带。
实践结论
widget 数量管理的是 CPU 侧(Paint 遍历、invalidation),draw call 管理的是 GPU 侧,两套优化别混在一起。HUD 优化的起手式:stat slate 确认瓶颈在不在 Slate,在的话先数 RT 元素和 UI Material 数量——十有八九它们就是断批的元凶。把动态纹理集中到固定图层位置(比如全部放最顶层),让普通元素在下面连续成段,一次改动省下的 batch 往往比重写半个界面还多。