虚幻4UI优化和扩展指南

写这篇文章本质工作过于疲劳,快干不下去了。怀疑自己精神状态有问题,在我觉得我自己很正常和我觉得我自己很不正常之间反复横跳。
也许是对UE4产生了心理阴影,也许是对写代码产生了心理阴影。

以下是提纲:
1.InvalidationBox和林林总总的坑。
2.Prepass和LayoutCaching。
3.慎用UMG的相关接口。
4.如果你想要尽可能降UI的DC的话,我没啥好建议。
5.扩展UI的功能,譬如显示模型?

我印象中我两年来在Slate里面修的bug数起来有几十个了……关于怎样修的本文不详述,都是一个个心理阴影。

InvalidationBox

所有的移动端优化建议里都提到了InvalidationBox,但如果你两年前就激进地用了,然后你开心地发现UI的CPU开销降低了一半,然后第二天你哭着发现有几十个bug等你修。
a.checkbox状态不刷新。
b.scrollbox滚到一半再也滚不动了。
c.……
我没有一一确认这些bug在最新的版本里是否存在,不过总归是两类问题。
a.invalidationBox没在数据更新时触发刷新。
b.有些刷新(譬如播放动画)会导致整个控件处于volatile状态。如果这个状态在最终没有刷新回来,那将是很麻烦的结果。以下详述。
Volatile控件的使用
这是一个标记控件为不可cache的flag。但是如果你遇上控件不能刷新的bug,你还是考虑优先修复bug,而不是用这个flag避过bug。
Volatile控件的鼠标消息会在最上层,优先盖过invalidationbox内的所有其他控件。本质问题在于为了优先保证整个控件其他元素可以cache,volatile会在invalidationbox最后绘制。而引擎的点击信息(HittestGrid)的生成是顺序的,是没有层级关系的。
不只在编辑器中勾选了Volatile会使控件处于Volatile状态。动画播放的时候,对应的SObjectWidget是Volatile的;ScrollBox在滚动的过程中,它自己也是Volatile的;对嵌套的InvalidationBox来说,内部的box对父级的box来说也是volatile的。
播放动画导致的层级问题
这是一个无解的问题。如果一个invalidationbox内有两个UserWidget在同时播放动画,那这两个控件的内容就很可能穿插。

PREPASS
这个在移动端中低端机上能占1.6ms以上。当然,视你们UI的复杂度而定。
这个东西意义在于计算布局。譬如你用了SizeToContent,AutoSize之类,需要每帧算大小。
现在引擎有一个consolevariable叫Slate.EnableLayoutCaching可以去掉它。编辑器别急着开,一堆控件的显示会炸裂。
这玩意主要有两个问题:
1.有东西的刷新依赖Prepass。
这个问题方面,文字控件是重灾区。
你开了EnableLayoutCaching,逻辑里设置文字的时候,它可能不刷新——你肉眼可见你设置的文字属性都没刷新上去,而不是文字布局等等没刷新。主要STextBlock写得有问题,它内部数据的更新(ComputeDesizedSize,很多参数靠这个函数传递)实际是由Prepass进去的。
我曾经尝试过把Text的更新放到Tick里,把控件设成Tickable,但实测发现这么干会吃掉一部分时间(没记错的话0.3ms左右),最终作罢,将数据更新分散在每个Setter里写了一份。
2.使用了Binding的东西不刷新布局。
编辑器如果全局开了EnableLayoutCaching,很多不刷新的问题都是文字控件,很多都是Binding导致,例如UI编辑器里显示隐藏控件的眼睛图标(没错它是个字符- -|!)。

UMG接口频繁调用
关于UMG相关接口,我想说的不是说这些函数的设计有性能问题,是不要频繁调用。
我曾经在优化人物名字板的时候发现我们游戏的逻辑在每帧的tick里去频繁通过UUserWidget的接口去给50个左右的名字板SetVisibility。这个逻辑在移动平台空跑占了0.3ms。我看了一下SetVisibility的逻辑,发现最费的地方在IsValid。这里不确定是TWeakPtr的解引用(里面有个原子变量)导致的性能问题,还是Cache miss。
解决方案很简单:不要写类似的逻辑。如果要更新屏幕中控件的可见性,加一个数组用来缓存控件已有的可见性,用它来拦调频繁的调用。

DC合并
合并DC的前提是你用Paper2D解决了贴图合并的问题。
如果追求低DC的话,可以考虑在FSlateBatchData::Merge里面尝试让相邻层级中前一个层级最后一个Element和后一个层级中第一个Element的batchkey相同的合在一起。但这种方法在CPU方面是有开销的……毕竟所有层级遍历了一遍。
这是最flexible的方法。该方法并不能一蹴而就地将DC优化直最优,但能保证结果正确。
至于展平LayerId这件事,我没有好的方案。因为展平LayerId是一件违背了设计原则的事。UE4为了贴图合并引入了TextureAtlas,然后为了保证合并贴图后层级的正确性引入了ZOrder。ZOrder就是LayerId不一致的罪魁祸首之一,但如果不是层级不对,谁会想起调整ZOrder呢?
绘制顺序能保证同一层级上Element的组织顺序是对的。但一旦不同控件共享了同一张贴图,则另当别论。展LayerId的问题就在于此。开发过程中若你无法控制又无法保证不同的控件对应的Sprite具体位于哪一张贴图中,那强行展LayerId永远会有未知的隐患。

UI上显示模型
理论上是可以显示模型的——在不用rendertarget的前提下。
使用SMeshWidget。
这个东西貌似是他们在开发Paragon的时候为了解决头顶血条的效率问题引入的。有一些instance的功能,但限制也挺大。
关于这东西可以做到什么效果。Spine的UE4插件就是基于这个写的(这玩意在移动端巨坑,Index硬编码成了32位unsigned int,都上天了)。理论上你可以在UE4的UI这块屏幕空间里画任意网格。
一般在示例代码里,会使用一个类型为FSlateResourceHandle的对象和一个FSlateBrush指针来管理材质。但这些东西析构的时候是有问题的,不知最新版引擎改进了没有,这个问题会导致崩溃。
如果你在使用Spine的过程中遇到了疑似Slate相关渲染的崩溃,请避免使单个UMaterialInterface对应多个FSlateMaterialBrush,并使用DeferredCleanup来析构FSlateMaterialBrush。也就是说不能有同名的FSlateMaterialBrush。Slate内部使用FName作为key,一个FSlateMaterialBrush析构的时候会析构掉内部的东西,这样另一个渲染的时候会崩溃。