Unreal Engine 4 中的 UI 优化技巧

发表于2017-05-09
评论4 2.32w浏览

Unreal Open Day 2017 活动上 Epic Games 开发者支持工程师郭春飚先生为到场的开发者介绍了在 Unreal Engine 4 中 UI 的优化技巧,以下是演讲实录。 

大家好,我是 Epic Games 的开发者支持工程师郭春飚,今天给大家介绍的是 UE4 的 UI 优化经验。我们之前一直有接到国内开发者的一些抱怨,他们觉得在手机上面开了 UI 以后性能下降的很快,今天就专门给大家介绍一下怎么在 UE4 上做 UI 优化。本文介绍的 UI 优化方式,不仅适用于移动平台,在其它平台上(如 PC 和主机)对于复杂的 UI 系统也会有很大的性能提升。

文章目录:

1 UI 的基本概念

    1.1 名词解释

    1.2 渲染流程

    1.3 性能指标

2 优化方案

    2.1 游戏线程优化

        2.1.1 Invalidation Box

        2.1.2 可见性(Widget Visibility)

        2.1.3 Widget Binding

    2.2 渲染线程优化

        2.2.1 合并批次

        2.2.2 Retainer Box

        2.2.3 事件驱动的 Retainer Box

        2.2.4 切换材质

    2.3 其它优化

        2.3.1 C++ 开发

        2.3.2 Manager Class

        2.3.3 释放贴图内存

        2.3.4 3D RTT 优化

        2.3.5 新功能

3 效果测试

4 总结

 

1. UI的基本概念

1.1 名词解释

User Widget:对应一个用户界面。

Widget Tree:每一个 User Widget 都是存储成树状结构。

Panel Widget:不会渲染出来,用于对 Child Widget 进行布局,如 Canva Panel, Grid Panel, Horizontal Box 等。

Common Widget:用于渲染,会生成到最后的 Draw Elements 中,如 Button, Image, Text 等。


1.2 渲染流程

基本渲染流程示意图:

在游戏线程 (Game Thread),Slate Tick 每一帧会遍历两次 Widget Tree。

Prepass:从下到上遍历树,计算每一个Widget的理想尺寸 (Desired Size)。

OnPaint:从上到下遍历树,计算渲染所需的 Draw Elements 。这个过程中,会根据 Common Widget 的类型和参数生成相应的 Vertex Buffer,将 Common Widget 的 Render Transform 计算到 Vertex Buffer 中,并根据 Layer ID 和 Material 等信息进行批次合并。最后一个 User Widget 会生成1个或多个 Draw Element,并将 Draw Elements 传递给渲染线程进行渲染,其中每个 Draw Element 对应一个 Draw Call。

在渲染线程 (Render Thread),Slate 渲染分为两步:

Widget Render:执行 UI 的 RTT,如果使用了 Retainer Box,这里会将 Draw Elements 渲染到 Retainer Box 的 Render Target。

Slate Render:将 Draw Elements 渲染到 Back Buffer,如果使用了 Retainer Box,会将 Retainer Box 对应的 Texture Resource 渲染到 Back Buffer。


1.3 性能指标

Stat.Slate命令列举了一些主要的Slate性能参数:

Num Painted Widgets:在游戏线程执行 OnPaint 的 Widget 数量。

Num Batches:Draw Element(也即 Draw Call)数量。

Stat.Slate 会创建一个未优化的 UI,并且统计线程会将这个 UI 的性能数据算入 Slate 开销,因此表格中的时间数据和真实数据相差很大。建议通过如下命令查看统计线程变量的时间开销:

stat dumpave –num=120 –ms=0.5

三个关键指标的统计数据分别是:

Slate Tick:统计线程变量 STAT_SlateTickTime。

Slate Render:统计线程变量 STAT_SlateRenderingRTTime。

Widget Render:统计线程变量 FWidgetRenderer_DrawWindow。

如果希望在项目中实时调试性能,可以从统计线程直接获取数据,并做一个简单的调试面板进行查看。

游戏线程代码:

统计线程代码:

调试面板效果:


2 优化方案

2.1 游戏线程优化

2.1.1 Invalidation Box

使用 Invalidation Box 封装 User Widget,从而缓存 Slate Tick 数据,不需要每帧都进行计算。操作方式如下所示:

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

下图演示了一种特殊情况,英雄图标是一个重复使用的 User Widget,每个都被封装进了 Invalidation Box。整个英雄列表是一个 Scroll Box,当 Scroll Box 上下滑动时,英雄图标对应 User Widget 的 Transform 信息也会发生变化。

此时可以勾选 Invalidation Box 对应的 Cache Relative Transforms,如下所示:

那么当 User Widget 的位置变化时,引擎不会去更新所有的 Draw Element(即 Vertex Buffer ),而会通过修改 Shader 参数(View * Projection Matrix)来反应位置变化。这种方式仅适用于位置变化,如果缩放发生变化,仍然需要重新计算 Draw Element。Cache Relative  Transforms 会在 Game Thread 增加少量额外的计算,确保需要使用时才勾选。

当某个 Widget 的渲染信息变化时,会通知所在的 Invalidation Box 重新缓存 Vertex Buffer。在一个复杂的 User Widget 中,Invalidation Box 频繁缓存整个 Widget Tree 会带来很高的性能开销,有两种方式可以解决这个问题。

第一种方式是拆分 Invalidation Box,根据 Widget 变化是否频繁将它们拆分到不同的 Invalidation Box 中。

有时由于布局的原因,不是很方便的划分不同的 Invalidation Box,那么可以使用第二种方式,将 Widget 设定成 Is Volatile,这样上层的 Invalidation Box 在缓存时就会排除这个 Widget,该 Widget 每帧都会 Tick 并计算 Prepass 和 OnPaint,但整体 Widget Tree 的缓存不会受到影响。

上图中的 LevelUpIcon,平时处于隐藏状态,当角色升级时会显示出来, LevelUpAnim 通过改变 Widget 的位置实现动画效果。当渲染这个 Image 时,由于位置一直在变化,会导致 Invalidation Box 每帧都在重新计算整个 Widget Tree 的 Cache,性能比较低。此时可以将这个 Widget 设定成 Is Volatile,从而提高性能。

编辑器中 Is Volatile 选项可以用于显式地设置 Volatile,用于提高 Invalidation Box 的性能。有时 Widget Binding 会隐式地将 Widget 标记成 Volatile,导致这个 Widget 每帧都会 Tick,从而降低性能。

每个 Widget 在 ComputeVolatility 函数中详细列举了哪些属性会导致影响 Draw Element(Vertex Buffer)。

文本 Widget 影响 Draw Element 的属性:

进度条 Widget 影响 Draw Element 的属性

如果在影响 Draw Element 的属性上使用了 Widget Binding,会导致引擎每帧都要 Tick 查询是否属性发生变化,从而判断是否需要更新 Draw Element,因此应该避免使用 Widget Binding。

可以通过 Slate.InvalidationDebugging 查看是否正确地设置了 Invalidation Box 和 Volatile。

绿线框使用 Invalidation Box 缓存的 Widget。

蓝线框Invalidation Box 勾选了 Cache Relative Transforms。

虚线框 标记为 Volatile 的 Widget。

红线框:没有使用 Invalidation Box 的 Widget。

Slate.AlwaysInvalidate 命令可以强制 Invalidation Box 每帧更新缓存,可以用于测试是否会造成突然的卡顿。如果一个 User Widget 过于复杂,可以拆分成多个 Invalidation Box,将 Widget 按照更新频率的高低放入不同的 Invalidtion Box。


2.1.2 可见性(Widget Visibility)

Widget 可见性有 5 种:

Visible: 可见、可点击

HitTestInvisible: 可见、当前 Widget 不可点击、所有 Child Widget 不可点击

SelfHitTestInvisible: 可见、当前 Widget 不可点击、不影响 Child Widget

Hidden: 不可见、占用布局空间

Collapsed: 不可见、不占用布局空间

很多 Widget 默认属性是 Visible,需要手动设置成 HitTestInvisible 和 SelfHitTestInvisible。如果大量 Widget 设置成 Visible,那么引擎在点击响应时的效率就会大大下降,这也会增加游戏线程的开销。

Collapsed 不占用布局空间(Layout Space),因此在隐藏后不会进行 Prepass 的计算,性能优于 Hidden。

可以使用 Widget Reflector 帮助检查是否有错误设置的 Visibility 属性。


2.1.3 Widget Binding

在分析 Volatile 时提到过 Widget Binding 会导致 Volatile 从而降低 UI 性能。另外 Widget Binding 是每帧 Tick 执行,性能比较低。不建议在项目中使用这个功能,建议通过 C++(或蓝图)调用函数的方式传值。

RemoveFromViewport/AddToViewport 会销毁以及重新构建 User Widget,使用 Collapsed/SelfHitTestInvisible 可以得到更好的性能。

另外,在移动平台上建议将蓝图 Tick 中复杂的运算逻辑移动到 C++ 中。


2.2 渲染线程优化

2.2.1 合并批次

随着 GPU 的发展,Draw Call 的数量对于性能的影响也越来越小,很多情况下减少 Draw Call 并不能带来 FPS 的提升。但减少 Draw Call 可以减少对 GPU 的 API 调用,在移动端有助于控制手机发热。


A. Panel Widget

在 4.15 之前的引擎版本,Canvas Panel 不支持批次合并,建议不要使用 Canvas Panel,尽量使用 Grid Panel、Vertical Box、Horizontal Box 等支持合并批次的容器。

4.15 增加了对 Canvas Panel 合并批次的支持,开启方式位于 Project Settings 中:"Engine->Slate Settings->Constraint Canvas->Explicit Canvas Child ZOrder"。接着可以通过设定 Canvas Panel 的 Child Widget 的 ZOrder 属性,ZOrder 相同(渲染参数也相同)的会合并批次,比起 Grid Panel 和 Horizontal Box,Canvas Panel 没有额外的布局计算,OnPaint 效率会稍微高一些(游戏线程)。


B. 合并贴图

在 UE4 中的 Sprite 很方便地支持合并贴图的编辑和使用。

如果需要在逻辑代码中切换独立贴图和合并贴图,在 Manager Class 中,初始化独立贴图 (UTexture2D) 和合并贴图资源 (UPaperSprite),并创建 FSlateBrush,通过 SetResourceObject 将资源设置给 FSlateBrush。接着就可以通过开关变量控制传入 UImage::SetBrush 的参数。

在项目后期,如果需要将 User Widget 中的贴图全部替换成合并贴图,是一项很繁琐的工作。Epic Games 的 Dmitriy Dyomin 提供了一个思路方便快速地进行替换。

首先实现一个 Commandlet:

可以使用如下命令运行这个 Commandlet:

Commandlet 的具体功能:遍历所有的 Widget Blueprint Asset,使用 AssetRegistry 加载 Asset,并检查其中 UImage 和 UBorder 使用的 Texture,根据命名规则判断是否有对应的 Sprite Asset 存在。使用 AssetRegistry 将 Texture 替换成 Sprite,最后保存 Widget Blueprint Asset。


2.2.2 Retainer Box

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

在很多情况下,UI 不需要每帧都渲染,因此可以通过 Retainer Box 缓存渲染结果,每隔几帧更新一次。Retainer Box 的原理就是将 UI 渲染缓存在 Render Target上,再将  Render Target 渲染到屏幕。

下图中,我们将主界面的 UI 划分到 4 个 Retainer Box 中,通过间隔3帧更新一次的方式来渲染。

Retainer Box 区域应该尽量小,有助于提高渲染效率、降低显存使用。通常 Retainer Box 都应该包含 User Widget 的背景图,因为背景图有很大的像素填充率。

Retainer Box 会为每个 User Widget 实例创建一个 Render Target, 因此在不改动代码的情况下,重复使用的 User Widget 不要使用 Retainer Box。例如下图中,我们应该为 Scroll Box 所在的 User Widget 创建 Retainer Box,而不应该为 Scroll Box Item 所在的 User Widget 创建 Retainer Box。

下图演示了另外一种情况,B_HeroIcon 这个 User Widget 被重复用到了 HEROS 和 SOCIAL 等多个主界面中。Battle Breakers 是一个重 UI 的手机游戏,因此很难为所有的主界面分配 Retainer Box,这会占用大量的显存,当然我们也不希望为每个 B_HeroIcon 创建一个 Retainer Box。

此时可以通过扩展代码的方式实现更好的 Retainer Box 效果,假设我们知道该 B_HeroIcon 在画面中同时出现的上限是 20,那么可以创建一个包含 20 个 Render Target 的 Render Target Pool,使得不同的 Retainer Box 可以共享同一个 Render Target。

Retainer Box 会占用额外的显存,因此要控制使用量,将它优先分配给性能提升最大的 User Widget。一种情况是主界面的 User Widget,另一个种情况是使用共享 Render Target 后的大量频繁使用的 User Widget。

使用 Retainer Box 不但能提高渲染线程的效率,游戏线程的 Tick 也会相应的隔几帧执行一次。如果 Retainer Box 内部包含了可以点击的 Widget,那么需要将 Retainer Box 设置成 Visible,这样引擎会将点击测试区域映射到 Retainer Box 上。

持续表示的效果(如3D 角色、材质特效)可以从 Retainer Box 中分离出来,但需要注意像素填充率,也可以从特效设计的方面解决。

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

在设定 Retainer Box 的 Phase Count 时需要全局考虑。例如下图表示每隔3帧更新一次 Retainer Box,并在第 0 帧更新:

下图表示每隔 5 帧更新一次,并在第 2 帧更新:

那么每隔15帧这两个 Retainer Box 就会在一帧内同时更新,导致帧数下降。


2.2.3 事件驱动的 Retainer Box

目前 Retainer Box 需要指定每隔几帧强制更新一次,但某些情况下 User Widget 不需要按照固定频率更新,只会在用户操作(且操作不频繁)时才更新。这种情况下就可以通过扩展 Retainer Box 来支持事件驱动的方式。

实现思路是继承 URetainerBox 和 SRetainerWidget,并在 PaintRetainedContent(在 4.16 之前的版本函数名是 OnTickRetainers)中判断是否有事件触发更新,如果需要更新则调用父类的 PaintRetainedContent,否则 return。


2.2.4 切换材质

UE4 提供了丰富的材质效果,在低端机上可以考虑关闭这些效果、或切换到低配材质以提升性能。

可以使用引擎提供的 DYNAMIC_MULTICAST 框架,将所有受影响的 Widget 绑定到一个开关变量上,实现整体切换。


2.3 其它优化

2.3.1 C++ 开发

除了 UI 动画这块存储结构设计的原因不能使用 C++ 实现,其它 UI 功能都可以用 C++ 实现。

第一步,实现一个 C++ 类 UWExpHeroIcon 继承自 UUserWidget

 

第二步,使用 Reparent Blueprint 修改父类为 UWExpHeroIcon

第三步,在编辑器中找到需要暴露的变量以及类型

第四步,在 C++ 中声明 BindWidget 变量,引擎会自动关联数据


2.3.2 Manager Class

建议在项目中创建一个 Manager Class,统一管理所有的 User Widget,并且统一管理所有的 UI 资源,比如 Brush、Font 等。Manager Class 可以是 C++ 或蓝图的形式。


2.3.3 释放贴图内存

释放贴图内存的一个前提是不要在编辑中设置贴图(下图中的 Image 项),而是通过程序进行手动的贴图加载、贴图设置、以及贴图销毁。不在编辑器中设置贴图,可以避免在 CDO(Class Default Object)中引用这个贴图对象。CDO 的引用会使得 SharedPtr 的引用计数至少为1,并且退出应用前不会销毁。

如果在 Editor 中设置了 Image 属性,同时又希望销毁这个贴图,Epic Games 的王弥提供了一个思路,可以在 Cook 阶段解除 UImage 和 UTexture 的引用关系,从而这个 User Widget 的 CDO 不会引用到 UTexture。

解除 Cook 阶段引用关系的代码如下所示:

加载贴图的代码如下所示:

释放贴图的代码如下所示:



2.3.4 3D RTT 优化

默认 SceneCaptureComponent2D 是每帧 Tick 的,通常情况下可以取消每帧更新图像:

动画的 Update 频率在手机上每秒 30 次就够了,因此可以通过蓝图设置 SceneCaptureComponent2D 的 Tick 间隔设置:

接着在蓝图里手动调用 Capture 即可:


另外 SceneCaptureComponent2D 的 Render Target 的尺寸不要太大,有助于提高性能。


2.3.5 新功能

我们在 Battle Breakers 中新增了两个调试命令,可能会在 4.17 版本合并到主干上。游戏界面:

使用 Slate.ShowOverdraw 查看 Pixel Overdraw:

使用 Slate.ShowBatching 查看批次:


3 效果测试

我们做了一个测试工程用于测试优化效果,下图中的 UI 有 800 多个 Widget:

测试机器是千元机,机器参数如下:

 


开启 Invalidation Box 后,Slate Tick 时间大幅降低,由于应用程序开启了 Mobile HDR,瓶颈在 GPU 上,因此 FPS 提升不大,如下所示:


下图可以方便对比 Invalidation Box, Retainer Box, 事件驱动的 Retainer Box 开启后性能参数的变化(可以看到渲染线程的提升对于 FPS 提升很大): 



4 总结

大部分的 UI 优化工作(比如说 Invalidation Box, Retainer Box)都是在项目后期( UI 基本开发完成后)再进行的。UE4 提供了很丰富的功能和调试工具,熟练掌握这些功能能够帮助开发者实现高性能的UI。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引