Unity UI优化(三) - 优化UI控件

发表于2018-09-14
评论5 1.5w浏览

Unity UI优化(三) - 优化UI控件

英文原文:https://unity3d.com/cn/learn/tutorials/topics/best-practices/optimizing-ui-controls

大部分UI控件在性能方面是相似的,本篇文章是针对出现在特定类型UI控件上的问题进行讨论。

UI文本

Unity内置的Text组件可以很方便地用于在UI中显示栅格化的文本字形。但是,在使用Text时有很多大家不了解却又经常遇到的与性能相关的因素。当想UI添加文本时,要始终记得——文本字形是作为独立的面片(quad)进行渲染的,每个字符都是一个面片。这些面片通常都含有大量的空白区域围绕着字形,空白区域的大小取决于字形的形状,在放置文本时很容易就会无意中破坏其他UI元素的批处理。

Text网格重建

UI文本的网格重建是个重点问题。当Text组件发生变化时,必须重新计算用于显示实际文本的多边形。当Text组件或它的任意级别的父节点被禁用或启用时,也需要进行重新计算。

在含有大量文字标签的UI上,这一行为可能导致问题,例如排行榜页面和统计数据页面。因为在Unity中,最常见的显示和隐藏UI的方法是启用/禁用含有UI的GameObject,含有大量文本组件的UI通常在显示时会导致帧率降低。

在下一篇文章的禁用画布一节有可能用得到的解决办法。

动态字体和字体集

当全部可现实字符集很大或者在运行时期不确定时,可以用动态字体来显示文本。在Unity的实现中,这些字体在运行时根据Text组件中出现的字符构建一个字形图集(glyph atlas)。

被加载的每个不同的Font对象会维护它自己的纹理集,即使它与其他字体属于同一个字体族。例如,在一个文本控件中使用Arial字体,并且将字体样式(Font Style)设置为粗体(Bold),在另一个文本控件中使用Arial Bold字体,这两个控件会产生一样的输出,但是Unity会维护两个不同的纹理集——一个给Arial,另一个给Arial Bold。

从性能角度看,要理解的最重要的一件事就是,动态字体为每种不同的结合(尺寸、样式&字符)在其纹理集中维护了一个字形。也就是说,如果一个UI中含有两个Text组件,都显示了字符“A”,那么:

  • 如果两个Text组件尺寸相同,那么字体图集中会有一个字形。
  • 如果两个Text组件尺寸不同,那么字体图集中会有两个不同尺寸的字母“A”。
  • 如果一个Text组件的样式是粗体而另一个不是,那么字体图集中会含有一个粗体的“A”和一个普通的“A”。

当使用动态字体的Text对象遇到了没有被栅格化到字体纹理集中的字形时,必须重建字体纹理集。如果新的字形能够加入当前图集,那么将其加入图集并重新上传到图形设备。但是,如果当前的图集太小,那么系统会尝试重建图集。这通过两步完成。

第一步,以相同的大小重建图集,只使用当前在活动的Text组件上显示的字形。这包含了父画布活动(active)但是禁用了CanvasRenderer的Text组件。如果系统成功地将当前使用的所有字形填充进新的图集中,将会栅格化此图集,不再继续进行第二步。

第二步,如果当前使用的字形不能填充进同样大小的图集中,那么会以当前图集大小的短维乘2来创建一个更大的图集。例如,一个512x512的图集或被扩充到512x1024的图集。

因为上述的算法,动态字体集只会在创建时增长一次大小。考虑到重建纹理集的开销,必须时期在重建时最小。这可以通过两种方式实现:

如果可以,使用非动态字体并预先配置对想要使用的字形集的支持。在使用具有良好的字符集约束的UI上,这样做通常效果很好,例如,只是用Latin/ASCII字符并且尺寸范围小的UI。

如果必须支持极其大量的字符,例如整个Unicode集合,那么字体必须设为动态。为了避免可预见的性能问题,使用Font.RequestCharactersInTexture在启动时填充字体字形集。

注意,每个发生变化的Text组件会单独触发字体集重建。当布置极大量Text组件时,将组件内容中全部的不重复字符收集起来并填充进字体集可能有利于提高性能。这样做能够确保字形集只需要重建一次,而不是每次出现新字形时都重建。

另一点需要注意的是,当触发字体集重建时,所有不在当前活动的Text组件中的字符都不会包含进新的图集中,即使它们原来通过调用Font.RequestCharactersInTexture加入到了图集中。要绕过这一限制,订阅 Font.textureRebuilt 委托并查询 Font.characterInfo 来确保所有期望的字符正确留存。

目前 Font.textureRebuilt 没有文档,它是一个单参数的UnityEvent。它的参数是纹理被重建的字体。订阅这一事件应该遵守下面的方法签名:

public void TextureRebuiltCallback(Font rebuiltFont) { /* ... */ }

专用字型渲染器

对于那些每个字形已知并且每个字形之间位置相对固定的字形集,最好编写一个自定义组件来显示这些字形。例如分数显示。

显示分数时,每个可显示的字符都在固定的字形集中(数字0-9),不会发生重叠,而且字形间距离固定。将整数拆解成逐个的数字并显示成相应的图片的过程很繁琐。与画布驱动的Text组件相比,这个专用的数字显示系统可以做到无分配并且更加容易计算、制作动画和显示。

备用字体与内存使用

对于必须支持大量字符集的程序,最容易采用的做法是在字体导入设置的“Font Name”输入框中列出大量的字体名称。当首选字体无法显示字形时,会使用“Font Name”中列出的字体作为备用字体。备用字体的选择顺序取决于“Font Name”中字体的排列顺序。

但是,为了支持这一行为,Unity必须将所有列出的字体加载到内存中并且使用保留它们。如果字体字符集很大,那么备用字体可能会占用很多的内存。这种情况经常出现在含有象形文字时,比如日文汉字和中文字符。

Best Fit与性能

通常情况下,永远不要使用Text组件的 Best Fit 设置。

启用“Best Fit”后,字号会在设定的最大字号和最小字号之间动态调整,使文字内容在不益出文本框的前提下尽量填充文本框。但是,因为Unity会每种不同尺寸的字符将字形渲染到字体集中,使用“Best Fit”会迅速产生很多不同尺寸的字形覆盖图集。

从Unity 2017.3开始,Best Fit没有使用最佳的尺寸检测方式。它生成字体集所需的时间更长,并且可能导致图集溢出。

频繁地重建字体集会迅速降低运行时性能,并且会导致内存碎片化。

TextMeshPro Text

TextMeshPro(TMP)可以作为Unity中已有的文本组件(例如TextMesh和UI Text)的替代方案。TMP使用Signed Distance Field(SDF)作为其首选文本渲染管线,使其可以在任意尺寸和分辨率下清晰的渲染文本。使用一系列自定义的着色器来提升SDF文本渲染的能立后,TMP可以简单的通过修改材质属性来动态地改变视觉效果,例如,放大、外边框、软阴影等,并且可以通过创建材质预设来保存这些效果,在以后重新调用。

在Unity 2018.1之前,TMP可以从Asset Store下载,从2018.1版本开始,可以从Package Manager中添加TMP包。

Text网格重建

和Unity内置的UI Text组件很像,更改组件中已显示的文本会触发对Canvas.SendWillRendererCanvases和Canvas.BuildBatch的调用,这会产生开销。将TextMeshProUGUI组件中的文本变动最小化并且将其常发生变化的组件放置到专门的画布上,使画布重建的效率达到最高。

注意,对于在世界空间显示的文本,推荐使用普通的TMP组件而不是使用TextMeshProUGUI,直接使用TMP效率更高,因为它不会导致画布系统开销。

字体与内存使用

心累,翻译不动,这段省略了好多解释性内容,只记录了一些优化做法。 
原文:https://unity3d.com/cn/learn/tutorials/topics/best-practices/optimizing-ui-controls

TMP不支持动态字体功能。

TMP的字体在被场景或项目引用时加载。如果字体资源被TMP Setting资源引用,那么这些字体资源及其全部备用字体资源会在第一个含有TMP组件的场景激活时被递归加载。

如果字体资源被TMP组件引用,并且没有通过TMP Setting加载,那么被引用的字体资源及其全部备用字体资源会在TMP组件被激活时加载。当项目中有很多字体时,需要留意这一过程,尤其是在可用内存不足时。

如果需要进行本地化工作,建议在应用程序启动时,执行一个引导步骤,来检测用户区域并为每个字体资源设置备用字体资源:

  1. 为基础TMP字体资源创建AssetBundle
  2. 为每种语言所需的备用TMP字体资源创建AssetBundle(区域字体AssetBundle)
  3. 在引导过程中加载基础AssetBundle
  4. 根据区域,加载备用资源的AssetBundle
  5. 为基础AssetBundle中的每个字体分配区域字体AssetBundle中的字体资源
  6. 继续游戏

Best Fit与性能

再次说明,因为TMP不支持动态字体,所以上文中提到的Text组件的“Best Fit”带来的问题不会再TMP中出现。在使用TMP的Best Fit时唯一要注意的是系统使用了二分搜索来寻找合适的字体大小。在使用自动文字尺寸时,最好对最长/最大文本块进行测试。一旦确定了合适的尺寸,就禁用该文本组件的自动尺寸并手动设置其他文本对象的最佳字号。这样做可以提升性能,并且可以避免因一组文本组件中字号不一致而导致的不良视觉/排版体验。

ScrollView

ScrollView是Unity中另一个常见的容易引起性能问题的UI组件。在ScrollView中通常会含有大量的UI元素。有两种基本方式可以用来填充ScrollView:

  • 将所有要在ScrollView中显示的内容一次性填充进ScrollView中
  • 缓存要显示的元素,并按需重新设置元素位置来显示它们

这两种方式都会带来问题。

在第一种方式中,随着元素数量的增长,实例化元素所需的时间会增加,重建ScrollView所需的时间也会增加。如果ScrollView中只有少量元素需要显示,这样做简单可行。

第二种方式在当前UI和布局系统中实现起来需要编写相当多的代码。在下文中会进一步讨论两种可行的方法。一般来说,为复杂的滚动UI编写存取池能够帮助避免性能问题。

尽管两种方式都存在问题,但是为ScrollView添加RectMask2D组件还是能够起到改善作用。这一组件能够确保ScrollView中位于显示区域之外的那些元素不在可绘制元素列表中,这样在重建画布时系统就不必对这些元素进行几何生成、排序和分析。

简单的ScrollView元素存取池

要为Unity内置的ScrollView组件实现对象池,且同时保留ScrollView的原生便利性,最简单方法是采用混合做法:

为了在UI中布置元素,使布局系统正确地计算滚动视图内容的大小,并允许滚动条正常工作,需要使用具有LayoutElement组件的GameObject作为可见UI元素的“占位符”。

然后,为ScrollView中可见部分的UI元素实例化一个足够大的UI元素池,并将占位符设置为这些元素的父节点。当ScrollView滚动时,重用UI元素以显示滚动到视图中的内容。

这将大大减少必须批处理的UI元素的数量,因为批处理的成本仅随画布内的CanvasRenderer的数量而增加,而不是随Rect Transforms的数量增加。

简单方法存在的问题

目前,任何被重新设置父节点或者改变顺序(与同级兄弟相比)的UI元素和这个元素的所有子元素都会被标记为脏元素,并且会强制重建它们的画布。

出现这种情况是因为Unity并没有把重设父节点和改变同级顺序的回调分开。这两个事件都会触发OnTransformParentChanged回调。在Unity UI系统的Graphic类源代码中实现了这一回调,并且调用了SetAllDirty。通过将Griphic标记为脏,系统可以确保Graphic在下一帧被渲染前重建其布局和顶点。

可以为ScrollView中的每个元素的根RectTransform分配画布,这样就可以限制系统只重建那些改变了父节点的元素而不是整个ScrollView。如果ScrollView中的元素结构复杂,由多个子元素组成,那么重建的开销可能很大,尤其是在每个元素上都含有多个Layout组件时。

如果ScrollView中的元素不具有可变尺寸,就没必要重新计算整个ScrollView的布局和顶点。但是,要避免全部重新计算,需要实现一个与位置改变相关联的对象池,而不是与重设父节点或改变同级顺序相关的对象池。

基于位置的ScrollView池

为了避免上述问题,可以简单地通过移动ScrollView中的UI元素的RectTransform来创建对象池。这样做之后,如果被移动的RectTransform的尺寸没有发生变化,就不需要重建其内容,可以显著提升性能。

要实现这一功能,最好编写一个自定义的ScrollView子类或者自定义的LayoutGroup组件。后者实现起来更加简单,而且可以通过实现Unity UI系统的LayoutGroup抽象类来完成。

在自定义的LayoutGroup中可以对底层数据进行分析,来判断有多少数据元素必选显示和如何对ScrollView Content的RectTransform进行适当的缩放。可以通过订阅ScrollRect.onValueChanged事件来按需重新设置可见元素的位置。

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