Unity UI 优化最佳实践5 - 优化UI 控件

Optimizing Unity UI指南的这一部分重点讨论特定于某些类型的UI控件的问题。 尽管大多数UI控件在性能方面都比较相似,但其中两个脱颖而出,成为临近发布的游戏中遇到的许多性能问题的原因。

UI text

Unity内置的Text组件是在UI中显示光栅化文本字形的一种便捷方式。然而,有许多行为通常并不为人所知,但却常常表现为性能热点。向UI添加文本时,请始终记住文本字形实际呈现为单个四边形,每个字符一个。这些四边形通常会在字形周围留出大量空白空间,具体取决于它的形状,这种很容易定位文本的方式无意中破坏了其他UI元素的批处理。

文本网格重建(Text mesh rebuilds)

一个主要问题是重建UI文本网格。无论何时更改UI Text 组件,UI Text 组件都必须重新计算用于显示实际文本的多边形。如果UI Text组件或其任何父GameObjects被简单地禁用并重新启用而不更改文本,也会发生此重新计算。

这种行为对于任何显示大量文本标签的UI都是有问题的,其中最常见的是排行榜或统计板。由于隐藏和显示Unity UI的最常见方法是启用/禁用包含UI的游戏对象,包含大量UI Text 组件的UI在显示时通常会导致不需要的帧率问题。

有关此问题的潜在解决方法,请参阅下一章中的Disabling Canvases部分。

动态字体和字体图集

动态字体是在完整可显示字符集非常大或在运行前不知道时显示文本时很方便的方式。在Unity的实现中,这些字体基于UI Text组件中遇到的字符在运行时构建字形图集。

加载的每个不同的Font对象都会维护自己的纹理图集,即使它与另一个字体位于相同的字体系中。例如,在一个控件上使用带粗体文本的Arial,而在另一个控件上使用Arial Bold会产生相同的输出,但Unity将维护两个不同的纹理图集 - 一个用于Arial,一个用于Arial Bold。

从性能角度来看,要了解的最重要的事情是Unity UI的动态字体在字体的纹理图集中为每个不同的大小,样式和字符组合保留一个字形。也就是说,如果一个UI包含两个UI Text 组件,都显示字母’A’,那么:

  • 如果两个UI Text 组件共享相同的大小,则字体图集中将包含一个字形。
  • 如果两个UI Text 组件不共享相同的大小(例如一个是16点,另一个是24点),则字体图集将包含两个不同大小的字母“A”的副本。
  • 如果一个UI Text 组件是粗体而另一个不是,那么字体图集将包含粗体“A”和常规“A”。
    每当具有动态字体的UI Text对象遇到尚未光栅化为字体纹理图集的字形时,字体的纹理图集都必须重建。如果新的字形符合当前的图集,它将被添加并且图重新上传到图形设备。但是,如果当前的图集太小,则系统尝试重建图集。它分两个阶段完成。

首先,图集以相同的大小重新构建,仅使用当前活动的UI Text组件显示的字形。这包括启用其父级Canvas但已禁用Canvas Renderer的UI Text 组件。如果系统成功地将所有当前正在使用的字形装配到新的图集中,则它将该图集光栅化并且不会继续到第二步。

其次,如果一组当前使用的字形无法放入与当前图集大小相同的图集中,则通过将图集的较短维度加倍来创建更大的图集。例如,一个512x512的图集扩展到512x1024的图集。

由于上述算法,动态字体的图集只有在创建后才会增大。考虑到重建纹理图集的成本,在重建期间应尽量减少。这可以通过两种方式完成。

只要有可能,请使用非动态字体并预先配置对所需字形集的支持。这通常适用于使用良好约束字符集的UI,例如只有拉丁/ ASCII字符以及小尺寸范围。

如果必须支持极大范围的字符,例如整个Unicode集,则字体必须设置为动态。为了避免可预见的性能问题,请在启动时使用Font.RequestCharactersInTexture中的一组适当字符来填充字体的字形图集。

请注意,字体图集重建会针对每个更改的UI Text 组件单独触发。在填充大量UI Text 组件时,收集UI Text 组件内容中的所有唯一字符并填入字体图集可能会比较有利。这将确保字形图集只需重建一次,而不是在每次遇到新字形时重建一次。

还要注意的是,当字体图集重建被触发时,新的图集中不会包含当前包含在活动UI Text 组件中的任何字符,即使它们最初由于通过Font.RequestCharactersInTexture调用而添加到图集中。要解决此限制,请订阅Font.textureRebuilt委托并查询Font.characterInfo以确保所有需要的字符保持启动状态。

Font.textureRebuilt委托当前未公开。它是一个单参数Unity事件。参数是纹理被重建的字体。此活动的订阅者应遵循以下签名:

public void TextureRebuiltCallback(Font rebuiltFont){/ /}

专门的字形渲染器

对于字形众所周知的情况,在每个字形之间具有相对固定的位置的情况下,编写自定义组件以显示显示这些字形的sprites是明显更有利的。一个例子可能是一个分数显示。

对于得分,可显示字符是从众所周知的字形集(数字0-9)绘制的,不会跨地点改变,并且彼此之间以固定的距离出现。将整数分解为数字并显示适当的数字sprites是相对简单的。这种专门的数字显示系统可以以无分配的方式构建,并且比Canvas驱动的UI Text组件更加快速地进行计算,动画和显示。

备用字体和内存使用情况

对于必须支持大字符集的应用程序,很容易在字体导入器的“Font Names”字段中列出大量字体。如果字形不能位于主字体内,则“Font Names”字段中列出的任何字体将用作后备。备用顺序由字体在“Font Names”字段中列出的顺序决定。

但是,为了支持这种行为,Unity会将“Font Names”字段中列出的所有字体加载到内存中。如果字体的字符集非常大,则备用字体占用的内存量可能会过多。当包含象形字体(如日文汉字或中文字符)时,通常会出现这种情况。

Best Fit 和性能

通常,永远不要使用UI Text组件的“Best Fit”设置。

“Best Fit”动态调整字体的大小为最大的整数点大小,该大小可以在UI Text组件的边界框内显示,而不会溢出,并固定为可配置的最小/最大点大小。但是,由于Unity会为每个不同大小的字符显示一个独特的字形到字体图集中,因此使用“Best Fit”将迅速产生具有许多不同字形大小的图集。

截至Unity 2017.3,Best Fit所使用的尺寸检测并不理想。它会为每个测试的大小增量在字体图集中生成字形,这进一步增加了生成字体图集所需的时间。它也倾向于导致图集溢出,从而导致旧字形被踢出图集。由于“Best Fit”计算所需的大量测试,这会经常驱逐其他UI Text组件使用的字形,并且在计算适当的字体大小后强制至少重新创建一次字体图集。这个特定的问题已经在Unity 5.4中得到了纠正,并且Best Fit不会不必要地扩展字体的纹理图集,但仍然比静态大小的文本慢得多。

频繁的字体图集重建会快速降低运行时性能,并导致内存碎片。设置为Best Fit的UI Text组件的数量越多,这个问题就越严重。

TextMeshPro文本

TextMesh Pro(TMP)是Unity的现有文本组件(如Text Mesh和UI Text)的替代品。 TextMesh Pro使用有符号距离字段(SDF)作为其主要文本渲染管线,使得可以在任何点大小和分辨率下都能够清晰地呈现文本。通过使用一组旨在利用SDF文本渲染功能的自定义着色器,TextMesh Pro可以通过简单地更改材质属性以添加视觉样式(如扩展,轮廓,软阴影,斜角等)来动态更改文本的视觉外观。纹理,发光等,并通过创建/使用材质预设来保存和调用这些视觉样式。

在2018.1发布之前,TextMesh Pro作为Assets Store包装被包含在自己的项目中。截至2018年1月,TextMesh Pro将作为Package Manager软件包提供。

文本网格重建

与Unity内置的UIText组件非常相似,对组件显示的文本所做的更改将触发对Canvas.SendWillRendererCanvas和Canvas.BuildBatch的调用,这可能会导致代价高昂。尽量减少对TextMeshProUGUI组件的文本字段的更改,并确保将文本更改频繁的父TextMeshProUGUI组件更改为具有其自己的Canvas组件的父GameObject,以确保Canvas重建调用尽可能高效。

请注意,对于世界空间中显示的文本,我们建议用户使用正常的TextMeshPro组件而不是使用TextMeshProUGUI,因为在Worldspace中使用Canvases可能效率低下。直接使用TextMeshPro会更有效,因为它不会导致Canvas系统开销。

字体和内存使用情况

鉴于TMP中没有动态字体功能,必须依赖备用字体。了解使用TMP时如何加载和使用备用字体对优化内存至关重要。

TMP中的字形产生是通过递归完成 - 也就是说,当TMP字体asset中缺少字形时,TMP会从列表中的第一个后备字体开始并通过自己的后备字体循环遍历当前分配或活动的后备字体Assets。如果仍未找到字形,TMP将搜索可能分配给该文本对象的任何Sprite Assets以及分配给该Sprite Asset的任何后备。如果所需的字形仍未找到,则TMP将通过在TMP设置文件中分配的一般后备列表递归搜索,然后是默认的Sprite Asset。如果仍然无法找到该字形,它将搜索在TMP设置中分配的默认字体Assets。作为最后的手段,TMP将使用并显示在TMP设置文件中定义的缺失字符替换字符。

TextMesh Pro的字体资源在场景或项目中引用时加载。它们主要由TextMeshPro Text组件,TMP设置以及字体资源本身作为后备字体引用。如果在TMP设置Asset中引用了字体Asset,那么当第一个带有TMP Text组件的场景被激活时,这些字体assets及其所有后备字体assets将被递归加载。如果引用了默认的sprite sheet asset,那么也会加载。

此外,如果某个场景中的TextMeshPro组件引用了字体Asset,并且尚未通过TMP设置加载该字体资源,则在激活组件后,引用的字体Asset及其所有后备字体Assets将被递归加载。在处理包含多种字体的项目时,记住这一过程非常重要,尤其是在可用内存不足的情况下。

由于上述原因,使用TMP时对项目进行本地化成为一个问题,因为通过TMP设置预先加载所有本地化语言的字体资源将不利于减小内存压力。如果本地化是一个必要的要求,我们建议一个潜在的策略,只有在必要时(如加载各种场景时)分配这些字体Assets或后备,或使用Asset Bundlesf以模块化方式加载字体Assets

应用程序启动时,应包含引导步骤以验证用户的区域设置并为每个字体资源设置字体asset后备:

  • 为基础TMP字体资源创建Asset Bundle(例如,每种字体的最小拉丁字形)
  • 为每种语言所需的后备TMP字体Assets创建一个Asset Bundle(例如,日语所需的每种字体的TMP字体Assets的一个Asset Bundle)
  • 在引导步骤中加载您的基础Asset Bundle
  • 根据语言环境,使用后备字体加载所需的Asset Bundle
  • 对于基础Asset Bundle中的每种字体,从本地化字体Asset Bundle中分配后备字体Assets
  • 继续引导您的游戏
    如果不使用图像,也可以从TMP设置中删除Default Sprite Asset 引用,以节省额外的内存。
Best Fit和性能

再一次,鉴于TextMesh Pro没有动态字体功能,上述UGUI UIText部分中有关最佳适配的问题不会发生。在TextMesh Pro组件上使用Best Fit时唯一要考虑的事情是使用二进制搜索来查找正确的大小。当使用文本自动调整大小时,最好测试最长/最大文本块的最佳点大小。一旦确定了最佳尺寸,就禁用给定文本对象的自动调整大小,然后在其他文本对象上手动设置该最佳点大小。这有利于提高性能,并避免使用不同视点尺寸的一组文本对象,这被认为是糟糕的视觉/印刷实践。

Scroll View

填充率问题之后后,Unity UI的Scroll View 是第二个最常见的运行时性能问题。Scroll View 通常需要大量UI元素来表示其内容。有两种填充Scroll View 的基本方法:

  • 用所有表示整个Scroll View 内容的元素填充它
  • 共享元素,根据需要重新定位元素以表示可见内容。
    这两种解决方案都有问题。

第一个解决方案需要更多时间来实例化所有UI元素,因为要表示的项目数量会增加,并且还会增加重建Scroll View 所需的时间。如果在Scroll View 中只需要少量元素,例如在Scroll View 中只需要显示少量的文本组件,则该方法因其简单性而受到青睐。

第二种解决方案需要大量代码才能在当前UI和布局系统下正确实现。下面将进一步详细讨论两种可能的方法。对于任何非常复杂的滚动用户界面,通常需要某种池来避免性能问题。

尽管存在这些问题,但可以通过向Scroll View 添加RectMask2D组件来改进所有方法。当重绘Canvas时必须生成、排序和分析其几何图形的可绘制元素列表时该组件确保Scroll View 视口之外的Scroll View 元素不包含在其中。

简单的Scroll View 元素池

使用Scroll View 实现对象池的最简单方法同时也保留了使用Unity内置Scroll View 组件的原生便利性,这是采用混合方法:

为了在UI中布局元素,这将允许布局系统正确计算Scroll View 内容的大小并允许滚动条正常工作,将 Layout Element组件的GameObjects用作可见UI元素的“占位符”。

然后,实例化足以填充Scroll View 的可见区域的可见部分的可见UI元素池,并将这些元素父对象定位到占位符。当Scroll View 滚动时,重用UI元素以显示滚动到视图中的内容。

这将大大减少必须批量处理的UI元素的数量,因为批处理的成本只会根据Canvas内Canvas Renderer的数量而不是Rect Transforms的数量而增加。

问题与简单的方法

目前,只要任何UI元素重新设置父节点或其兄弟顺序发生更改,该元素及其所有子元素都将被标记为“脏”,并强制重建其Canvas。

其原因是Unity没有将回调分开来重新设置根Transform和更改其兄弟顺序。这两个事件都会触发一个OnTransformParentChanged回调。在Unity UI的Graphic类的源代码中(请参阅源代码中的Graphic.cs),实现了该回调并调用SetAllDirty方法。通过标记Graphic为脏,系统确保Graphic将在下一帧被渲染之前重建其布局和顶点。

可以将Canvas分配给Scroll View 中每个元素的根RectTransform,然后将该重建限制为仅重设父节点的元素,而不是Scroll View 的全部内容。但是,这往往会增加呈现Scroll View 所需的draw call次数。此外,如果Scroll View 中的各个元素很复杂,并且包含十几个图形组件,特别是在每个元素上有大量布局组件的情况下,那么重建它们的成本通常会高得足以显着降低低端设备的帧率。

如果Scroll View UI元素没有可变大小,那么完全重新计算布局和顶点是不必要的。但是,避免这种行为需要实现基于位置更改的对象池解决方案,而不是父级或同级顺序更改。

基于位置的Scroll View 池

为了避免上述问题,可以创建一个Scroll View ,通过简单地移动其包含的UI元素的RectTransforms来对其对象进行汇总。 这样可以避免重建已移动的RectTransforms的内容(如果它们的尺寸未更改),从而显着提高Scroll View的性能。

要做到这一点,通常最好写一个Scroll View 的自定义子类或编写自定义Layout Group组件。 后者通常是更简单的解决方案,可以通过实现Unity UI的LayoutGroup抽象基类的子类来完成。

自定义布局组可以分析底层源数据以检查必须显示多少数据元素,并可以适当调整Scroll View的内容RectTransform的大小。 然后,它可以订阅Scroll View change events,并使用它们相应地重新定位其可见元素。

翻译自:https://unity3d.com/cn/learn/tutorials/topics/best-practices/optimizing-ui-controls?playlist=30089