LOADING...

加载过慢请开启缓存(浏览器默认开启)

loading

UMG渲染分析,规范制定,工具制作

2023/12/8

历经三天经历了对Slate流程的分析,规范指定,和简易工具落地

UMG渲染分析,规范制定,工具制作

渲染原理和流程

Slate模块:

Slate模块包括Slate,SlateCore,SlateRHIRenderer,UMG。

Slate和SlateCore共同构筑了逻辑层部分,SlateRHIRenderer则是渲染部分。

UMG是在Slate和SlateCore上面的封装层,由编辑器客户端主要使用。

UMG资产:

UWidget是UMG模块中许多控件的基类,包括UserWidget

UWidget持有SWidget,继承UObject,有UObject的GC系统,支持反射和蓝图功能

SWidget处于SlateCore模块中,控件的绘制、点击以及大部分控件逻辑都集中在这里面

每个UserWidget在计算时都会储存为WidgetTree规定的树状结构

绘制基本流程:

在游戏线程 (Game Thread),Slate Tick 每一帧会遍历两次 Widget Tree,主要负责渲染数据的收集


DrawPrepass,计算Layout:

自底向上的先计算最叶子节点UI的DesiredSize,然后之上的每一层UI根据子UI的DesiredSize计算自己的DesiredSize

这个过程发生在FSlateApplication::DrawPrepass,默认情况下会递归遍历所有的子节点。

DrawWindows,调用子控件的OnPaint,收集渲染元素:

自顶向下的过程,对于UI的叶子节点来说,这一步会输出可见图元

对于有子节点的UI(比如各种Panel),它一般会先根据子UI的期望大小和自身的逻辑,控制子UI的位置

在前面的DrawWindows阶段之前,会初始化一个DrawBuffer,其中存了绘制所需的ElementList。

开启正式绘制之前,会把UI的图元全部压入这个ElementList才行,因此执行了DrawWindowAndChildren

内部是给子UI分配特定的显示区域,再递归调用子控件的OnPaint方法,给每个控件分配LayerId,并从控件抽象出FSlateDrawElement。

子UI的OnPaint方法会将具体的图元类型添加到这个List里

Renderer->DrawWindows,生成批数据,创建渲染命令

前面一步完成之后,就可以开始正式渲染,但从调用Renderer->DrawWindows之后的过程,包括合批也发生在GameThread上

ElementList已经被填充好了这一次渲染需要提交的元素,这个列表会被送往ElementBatcher生成批数据。

渲染器把FSlateDrawElement包装成FSlateRenderBatch批数据,并根据控件的信息生成VertexBuffer,执行渲染命令

调用DrawWindow_RenderThread送往下游的渲染线程去做。


对于渲染线程(Render Thread)而言,Slate的渲染主要经历DrawWindow_RenderThread的过程。

Renderer->DrawWindows是每次渲染的入口,这里的Renderer会根据平台选择对应的渲染器

在对WindowElementList做完合批,内部生成好渲染命令之后,调用DrawWindow_RenderThread把数据送往渲染层

渲染线程主要做的是:

  1. 合批更新定点数据和顶点索引数据到GPU缓冲区
  2. 渲染操作类执行绘制:生成FRHICommand
  3. 生成任务等待RHIThread执行完上一帧的FRHICommand

UMG渲染的性能热点分析,制作规范

使用的引擎版本是UE 5.2,主要通过Unreal Insight分析Slate渲染流程,使用Stat 相关命令行查看runtime耗时数据:

主要关注Render Thread中SlateUI的渲染耗时,包括Draw Call,OverDraw现象,如果Game Thread中有造成瓶颈的部分也会关注

主线程的耗时主要为Slate::DrawWindow,其中包括了Prepass,DrawWindow,AddElement的过程。

这个过程较为耗时,平均耗时4ms左右,在一些坏帧可以到5ms,占用Game Thread一次逻辑帧的10%~15%左右,是一次绘制的主要瓶颈

渲染线程虽然有时会出现用时大于逻辑帧的情况,但有大部分时间都是在Wait Game Thread Task,瓶颈的可能性较小

而对于一次渲染线程的DrawWindow而言,平均耗时不到1ms,做一次Slate绘制只占了Render Thread一次渲染帧的2~3%

Slate作为绘制瓶颈的可能性较小,且Unreal本身对于Slate已经做了合批,剔除的操作。

RHI Thread基本上在等待Render Thread分配任务,几乎没有这方面的瓶颈。


Stat Slate相关性能参数,主要关注:

Total Slate Tick Time:GameThread FSlateApplication::Tick总时间

SlatePrepass: DrawPrepass自底向上计算Size时间 SWidget::SlatePrepass

Draw Window And Children Time:自顶向下tickwidget paintUI的总时间

Num Layers : 总的批次层级

Num Batches: 总的批次


因此对UMG做Runtime时期的优化,主要聚焦于Game Thread上的CPU瓶颈优化,能减轻一次Slate绘制的总体压力。

这方面应该从Widget Tree本身的遍历耗时,重建操作去分析,优化Widget Tree的耗时。

对于渲染而言,UE自己本身对Slate进行了合批处理,但Runtime时GPU如果进行Bind的操作,就会打断合批,显著提高DrawCall的次数

从Batch,DrawCall角度入手,考虑静止不动的静态资产是否会打断引擎合批。

对于UMG资产中的资源(texture)方面的使用,也应该加以规范限制。

由于UI的开销是线性增长的,哪怕制定了每个UI制作时的规范,如果同屏UI的数量同时出现过多,也可能导致性能异常

这一点不便于做离线的规范和扫描,但也需要在开发时好好注意维护UI的生命周期。

以下小标题是制作中需要遵守的具体规范,底下是对规范进一步的具体分析,讲清为什么这么做能达到优化目的


Game Thread

对于Game Thread而言,静态UI可以着重去降低Prepass,OnPaint这样的Tick耗时,手段可以通过缓存Element数据,

WidgetTree是Slate模块用来管理UI资产,统计图元绘制信息的容器结构。对Widget的重建操作,会导致额外的CPU消耗。

对于动态变化的UI,应该入手Widget Tree相关的开销,分为降低Widget Tree重构耗时和避免Widget Tree重构两个方向来分析。


对于静态UI,通过使用InviladitionBox来开启FathPath,加速整棵Widget Tree的PrePass和OnPaint:

结论:最好的使用方式是根据Widget更新变化的频率,将Widget拆分到不同的 Invalidation Box 中

对于布局需要,不太方便划分Invalidation Box的Widget的,可以将Widget设为Is Volatile易变的,

这样上层在缓存时就会跳过这个Widget,这个Widget可能每次都会在Prepass 和 OnPaint被重新计算,但不会影响整棵Widget Tree的缓存

由于两个InvalidationBox中的内容不能合批,所以具体怎么使用需要测试和权衡

目前分析来看,没有添加InviladitonBox的UI走的都是SlowPath,这意味着每次Tick都是从头到尾将所有的节点都遍历了一遍

使用InviladitonBox将子控件收录之后,UE就可以通过FastPath索引到变化的UI,当ui需要被更新的时候,才会重新计算UI大小。

所有 Prepass 和 OnPaint 计算结果,也就是DrawElements都会被缓存下来

如果某个 Child Widget 的渲染信息发生变化,就会通知 Invalidation Box 重新计算一次 Prepass 和 OnPaint 更新缓存信息。

由于计算DesiredSize是其中比较耗时的操作,可以尝试更进一步采用更加激进的做法重写一些Widget的ComputeDesiredSize函数

在成功开启了FastPath之后,在UI被更新但大小不改变的情况下,做一个强制缓存的机制跳过Widget的重新计算大小,继续使用缓存

但使用时也需要注意,InviladitonBox渲染信息更新时,都会重新缓存Vertex Buffer,频繁的缓存Widget Tree也会造成很大的开销。

备注:

  1. InviladitonBox的Cache Relative Transform功能可以达到缓存相对坐标,从而在更新位置不更新大小时继续使用缓存,

但这个功能在我使用的UE 5.2已经被移除了,类似机制需要自己手动实现

  1. 命令Global Invalidation能够直接启用整个Swindow的Inviladiton功能,将整个UI封在一个InviladitonBox中,

但会遇到上述所说的有Widget改变就会重复更新缓存,导致开销更大的问题,不建议使用。

  1. 对于控件中引用的,使用动画编辑器制作的Sequencer动画效果,在动画播放时引起渲染更新也会导致Layout失效,进而导致重新计算。

    为了避免这种情况,也应该将类似的Widget设为Is Volatile。对于循环动画,应考虑使用材质来实现,因为纯材质动画本身不产生CPU开销。


开发时尽可能避免Widget Tree的层数(layer)太多,对层级进行合并,Layer尽可能扁平化

因为使用Widget Tree时来管理子控件时,Widget Tree的Layer数量直接影响到整颗Widget Tree的计算复杂度。

由于PrePass和DrawWindow阶段都需要遍历整棵树,因此整棵树的Layer增加时,SlateTick的耗时因此增加。

制作时应该尽可能保证Widget Layer扁平,可以将相邻的Layer重新合并成到同一个Layer。

这样能限制整颗树Runtime时期的递归总体深度,来减少Widget Tree每次Rebuild和遍历所需要的耗时。


对于动态UI做生命周期维护,避免Runtime做会导致重构Widget Tree的操作(插入和删除):

可以参考UE Common UI插件中的UCommonActivatableWidget的优化思路

Common UI单独了实现一套机制,让UI控制自身在生命周期内被激活和停用而非删除创建,能避免Runtime时期Widget Tree被重建。

我们自己也可以通过类似对象池这样的结构来维护UI的生命周期,避免在Runtime时期对控件进行删除和动态创建并插入操作,从而重建Widget Tree。


提高UMG逻辑性能本身(驱动方式),做更新频率划分:

控件尽量使用事件驱动UI更新,本身尽可能少绑定(将属性绑定到UI字段上会触发轮询)或者避免使用Tick,避免不必要的开销

逻辑部分迁移到C++或者脚本中,避免使用蓝图,以获取更高的性能。

根据Widget的使用和更新频率,将Widget拆分为:始终可见的,必须尽快显示的,可以承受在显示时略有延迟的

对于不同更新频率的Widget,放入不同的InviladitonBox,且采用不同的加载策略,

需要快速响应,使用频繁的的UI,例如竞技游戏中的物品栏和技能栏,最好将其保持在后台加载但不可见的状态。

长时间不出现也没有快速响应需求的Widget,可以尝试使用异步加载策略,消除时评估对Widget Tree的影响选择销毁或者停用。

这样一来可以避免一次性加载所有资源,加快启动时间,同时降低初始的内存占用。


Render Thread

对于Render Thread而言,减少Batch Draw Call的次数,不一定可以显著提高帧率,但可以减少对GPU的API调用,在移动端上有助于控制发热。

这部分主要聚焦于规范那些会打断合批的操作,同时也会列举一些制作规范来提高渲染的效率。


检查可能打断合批的操作或者配置:

控制降低整体最终的Layer ID在一个理想的数目,设置合理的配置参数,是提高合批效率降低Draw Call的有效手段

手动是否检查合批较为繁琐,应进一步开发自动化工具,检查资产的合批情况。

绘制是从SWindow::Paint开始,LayerId初始为0,在绘制中开始传递和更新;

大部分控件使用参数中的LayerId,少部分控件改变LayerId,并作为返回值传递给父节点

  1. 引擎生成的LayerID如果不同则不能合批
  2. ShaderResource,使用Texture、图集不同的Image控件不能合批
  3. Tiling,设置Tiling的不能和普通控件合批
  4. ShaderType,DrawAs选了Border和文本控件,不能和普通控件合批
  5. DrawEffects,自己和父控件不能去掉IsEnable
  6. ClippingState, 设置了裁剪的不能参与合批

常用的Widget操作对LayerID的影响如下:


使用Retainer Box提高渲染效率,加速OnPaint过程,动静分离降低OverDraw:

结论:对于静态的UI,可以使用RetainerBox,在参数中设置每几帧会触发一次重新绘制,控制每个像素的整体绘制频率。

Retainer Box的使用区域应该尽量小,有助于提高渲染效率、降低显存使用。重复使用的 User Widget 不要使用 Retainer Box

使用Retainer Box优化渲染需要创建Render Target占用内存,具体怎么使用需要测试和权衡

Invalidation Box 放置在 Retainer Box 上方没有意义,通常做法是在 Retainer Box 下层放一个 Invalidation Box。

可以拓展URetainerBox 和 SRetainerWidget,将Retainer Box改为事件驱动而非Tick,进一步优化。

通过合并批次和合并贴图的方式,UI 的 Draw Call 数量可以减少到比较低,但仍然会有很高的像素填充率。

在很多情况下,静态UI 不需要每帧都重新渲染,因此可以通过 Retainer Box 缓存渲染结果,设置每隔几帧更新一次,

PhaseCount表示多少帧绘制一次,Phase表示在第几帧绘制。

Retainer Box 的原理是将 UI 渲染缓存在 Render Target上,在OnPaint的时候可以直接提交,但由于需要缓存Render Target,所以会带来一定的内存消耗

本质上还是用空间预计算来换时间,因此对于经常需要改变的UI不适用RetainerBox,且如果UI的覆盖面越大,RT的内存费用就会越高,需要权衡

主要是优化了OnPaint的流程,渲染层也可以直接拿RT去做了,对于Game Thread和Render Thread都能起到帮助


尽可能的对UI元素进行合并(图集,材质):

结论:将多个小的UI纹素合并成一个大图集,使用一张大图集而不是多张小纹理可以降低纹理切换的开销。

同材质的component也可以合并成一个元素,这样也可以起到降低Draw Call的作用。

切换材质,切换纹理,都会触发GPU Bind,从而打断合批,增加Draw Call的次数。

这对于减少内存开销,提高纹理的采样效率也能起到帮助。可以使用一些图集工具来合并纹理,在材质和 UMG 中使用这个图集


引擎中开启优化项使用Canvas Panel的合批功能:

结论:对于Canvas Panel的Child Widget,在项目设置中开启Explicit Canvas Child ZOrder,设定好ZOrder 属性,ZOrder 相同的可以自动合批

尽可能使用Size Box,Horizontal Box、Vertical Box,Grid Box 结合使用来处理布局,Overlay也应该减少使用。

Slate中的Draw Call按Widget的Layer ID分组,Vertical Box或Horizontal Box等其他容器Widget会将其子Widget的Layer ID合并,从而减少Draw Call的数量。

不开启优化设置的情况下,Canvas Panel会递增其子Widget的Layer ID,以便它们可以在必要时相互叠加渲染,这会造成Canvas Panel使用多个Draw Call。

使用Overlay时,也会递增LayerID导致更多的Draw Call,也应该加以限制。


Texture分辨率检查:

结论:对于常规的UI,纹理的Texture Group应该设置为在UI,压缩格式应该为应该为User Interface 2D (RGBA)。

对于3D的场景UI,所有纹理尺寸必须是2的乘数:2,4,8,16,32,64,128,256,512,1024,2048,4096,8193.

纹理尺寸是2的乘数引擎会在导入Texture时自动创建这个Texture的Mipmap,尺寸不是必须为正方形,4x16都是可以的,只要保证是2的乘数就好。

这个规则只需要在制作3DUI的时候需要考虑,2DUI需要拥有保持最高分辨率不需要考虑Mipmap,3DUI有距离原因所以需要考虑引擎这个关于贴图优化的设置。

3DUI的纹理应该要有一个TerxtureGroup,能更好的管理这些纹理的压缩设置,引擎内部管理TerxtureGroup时能有更好的渲染性能。


UMG资产开发时定期清理没有引用到的Widget。


Editor下的UMG资产检查工具

检查项,可能有哪些配置会打断合批?

由于LayerID不好控制,影响因素多,且不直观,和在Widget Tree上的层级没有直接关系,因此这里统计的层级都指的是Widget Tree上的层级。

主要检查静态资产中,可能导致导致GPU Bind的操作或配置,这些操作或配置可能会打断UMG的合批

从资产配置角度,对常用的非结构性需要渲染的UWidget做检查,包括UImage,UButton,UText,UProgressBar等。

这些设置通常是在美术制作时就可以很容易注意的。

  1. 因为不同Layer下的Widget会导致LayerID不同,所以一定不能合批,因此对同Layer下的常见Widget的一些配置参数做检查。

  2. 检查同一层级下UWidget是否使用同样的Texture,使用Texture不同,则不能合批。

  3. 检查同一层级下UWidget的Till属性是否被设置,如果被设置则不能和普通控件合批。

  4. 检查同一层级下UWidget的DrawAs选项是否为Border或者Text,如果被设置则不能被合批

  5. 检查Enable属性是否被勾选,如果没被勾选,则自己和自己的Child Widget都无法合批。


工具主要思路为,从需要检查的资产开始作为根节点,创建一个UMG实例,递归遍历整棵Widget Tree,对需要检查的UWidget做属性设置的检查。

触发检查的入口,可以是当前Asset进行保存前进行检查,重写Uobject的PreSave函数,对于检查到可能导致合批失败的行为,弹出弹窗,提示不规范的行为。

由于Widget Tree只在Runtime时存在,无法直接在Editor获得

所以这里还需要获取对应的包目录,通过AssetRegistryModule搜索资产,然后转为UWidgetBlueprint以获取Widget Tree

递归遍历每层Widget Tree,对处于同一Layer下的Widget做检查,检查通过就返回True

检查失败需要向容器中添加对应的Widget名称和不规范的行为,在检查结束后同一打印

检查通过:

检测到不规范的行为:


最终结论(规范+工具思路)

针对UI开发人员:

  1. 对于静态UI,通过通过使用InviladitionBox来开启FathPath。
  2. 避免Widget Tree有太多层级,扁平化管理Widget
  3. 使用Retainer Box提高渲染效率,加速OnPaint过程,动静分离降低OverDraw:

针对美术人员:

  1. 美术填充资源时,尽可能避免导致合批会被打断的设置(通过合批检查工具)

  2. 确保Texture的分辨率和压缩格式合规,尽量使用同样的大图集

针对程序人员:

  1. UMG逻辑性能本身:逻辑迁移到C++中或者脚本,采用事件驱动UI。根据更新频率和响应效率划分不同的UI,采用不同的策略,InviladitionBox的分区也可以参考这个。

  2. 对于动态UI做生命周期维护,避免Runtime时期重构Widget Tree的操作。


UMG合批检查工具开发思路:

  1. 对于常用被渲染的UWidget进行配置检查,检查项是可能打断合批的不合理设置
  2. 从资产保存作为触发检查的入口,获取对应目录下的资产转为UMG蓝图资产(UWidgetBlueprint)
  3. 获取对应UWidgetBlueprint的Widget的RootWidget,自顶向下查找对应UWidge不规范的配置,记录违规Widget的名称和不规范原因
  4. 继续保存流程(不阻断),输出违规Widget的名称和不规范原因,辅助相关人员进行排查