paint-brush
如何优化 Unity 中的 UI:性能缓慢的原因及解决方案经过@sergeybegichev
641 讀數
641 讀數

如何优化 Unity 中的 UI:性能缓慢的原因及解决方案

经过 Sergei Begichev9m2024/08/25
Read on Terminal Reader

太長; 讀書

使用此详细指南了解如何优化 Unity 中的 UI 性能,其中包含大量实验、实用建议和性能测试作为支持!
featured image - 如何优化 Unity 中的 UI:性能缓慢的原因及解决方案
Sergei Begichev HackerNoon profile picture

使用此详细指南了解如何优化 Unity 中的 UI 性能,其中包含大量实验、实用建议和性能测试作为支持!


大家好!我是 Pixonic (MY.GAMES) 的客户端开发人员 Sergey Begichev。在这篇文章中,我将讨论 Unity3D 中的 UI 优化。虽然渲染一组纹理看似简单,但它可能会导致严重的性能问题。例如,在我们的 War Robots 项目中,未优化的 UI 版本占总 CPU 负载的 30% — 这是一个惊人的数字!


通常,这个问题在两种情况下会出现:一是存在大量动态对象;二是设计师创建的布局优先考虑跨不同分辨率的可靠缩放。在这些情况下,即使是小型 UI 也会产生明显的负载。让我们探索一下这种情况的工作原理,找出负载的原因,并讨论可能的解决方案。

Unity 的建议

首先,让我们回顾一下Unity 的建议对于UI优化,我总结为六个重点:


  1. 将画布拆分为子画布
  2. 删除不必要的 Raycast Target
  3. 避免使用昂贵的元素(大列表、网格视图等)
  4. 避免布局组
  5. 隐藏画布而不是游戏对象(GO)
  6. 充分利用动画师


虽然第 2 点和第 3 点直观上很清晰,但其余建议在实践中可能难以想象。例如,“将画布拆分为子画布”的建议当然很有价值,但 Unity 并未提供关于这种划分背后原则的明确指导。就我个人而言,从实际角度来说,我想知道在哪里实现子画布最有意义。


考虑一下“避免使用布局组”的建议。虽然布局组会导致较高的 UI 负载,但许多大型 UI 都带有多个布局组,重新设计所有内容可能非常耗时。此外,避免使用布局组的布局设计师可能会发现自己在任务上花费了更多时间。因此,了解何时应避免使用此类布局组、何时使用布局组有益以及如果无法消除布局组应采取哪些措施将大有裨益。


Unity 建议中的这种模糊性是一个核心问题——我们常常不清楚应该对这些建议应用什么原则。

UI 构建原则

为了优化 UI 性能,了解 Unity 如何构建 UI 至关重要。了解这些阶段对于在 Unity 中有效优化 UI 至关重要。我们可以大致确定此过程中的三个关键阶段:


  1. 布局。最初,Unity 根据所有 UI 元素的大小和指定位置对其进行排列。这些位置是相对于屏幕边缘和其他元素计算的,形成依赖链。


  2. 批处理。接下来,Unity 将各个元素分组为批处理,以提高渲染效率。绘制一个大元素总是比渲染多个小元素更有效率。(有关批处理的更多内容,请参阅本文。


  3. 渲染。最后,Unity 绘制收集到的批次。批次越少,渲染过程就越快。


虽然该过程还涉及其他要素,但这三个阶段解决了大多数问题,因此现在我们只关注它们。


理想情况下,当我们的 UI 保持静态时(意味着没有任何移动或变化),我们可以构建一次布局,创建一个大批次,并有效地渲染它。


但是,如果我们修改哪怕一个元素的位置,我们都必须重新计算其位置并重建受影响的批处理。如果其他元素依赖于此位置,那么我们也需要重新计算它们的位置,从而在整个层次结构中产生级联效应。需要调整的元素越多,批处理负载就越高。


因此,布局的更改会在整个 UI 中引发连锁反应,我们的目标是尽量减少更改次数。(或者,我们可以隔离更改以防止连锁反应。)


举个实际的例子,这个问题在使用布局组时尤其明显。每次重建布局时,每个 LayoutElement 都会执行一次 GetComponent 操作,这会非常耗费资源。

多重测试

让我们检查一系列示例来比较性能结果。(所有测试均在 Google Pixel 1 设备上使用 Unity 版本 2022.3.24f1 进行。)


在这个测试中,我们将创建一个包含单个元素的布局组,并分析两种情况:一种是我们改变元素的大小,另一种是我们利用 FillAmount 属性。


RectTransform 的变化:


FlllAmount 变化:


在第二个示例中,我们将尝试执行相同的操作,但布局组中有 8 个元素。在这种情况下,我们仍然只更改一个元素。


RectTransform 的变化:


FlllAmount 变化:


如果在上例中,对 RectTransform 的更改导致布局负载为 0.2 毫秒,则这次负载将增加到 0.7 毫秒。同样,批处理更新的负载从 0.65 毫秒增加到 1.10 毫秒。


尽管我们仍然只修改一个元素,但布局尺寸的增加会严重影响重建期间的负载。


相反,当我们调整元素的 FillAmount 时,即使元素数量增加,我们也不会观察到负载增加。这是因为修改 FillAmount 不会触发布局重建,因此批量更新负载只会略有增加。


显然,在这种情况下使用 FillAmount 是更有效的选择。但是,当我们改变元素的比例或位置时,情况会变得更加复杂。在这些情况下,很难取代 Unity 内置的不触发布局重建的机制。


这就是 SubCanvas 发挥作用的地方。让我们检查一下将可变元素封装在 SubCanvas 中时的结果。


我们将创建一个包含 8 个元素的布局组,其中一个元素将位于 SubCanvas 内,然后修改其变换。


SubCanvas 中的 RectTransform 变化:


结果表明,将单个元素封装在 SubCanvas 中几乎消除了布局上的负载;这是因为 SubCanvas 隔离了所有更改,从而防止在层次结构的较高级别中进行重建。


但需要注意的是,画布内的更改不会影响其外部元素的定位。因此,如果我们将元素扩展得太多,则存在与相邻元素重叠的风险。


让我们继续将 8 个布局元素包装在 SubCanvas 中:


上例表明,虽然布局负载保持较低水平,但批处理更新却增加了一倍。这意味着,虽然将元素分成多个 SubCanvas 有助于减少布局构建负载,但会增加批量组装负载。因此,这可能会导致总体净负面影响。


现在,让我们进行另一个实验。首先,我们将创建一个包含 8 个元素的布局组,然后使用动画器修改其中一个布局元素。


动画师会将 RectTransform 调整为新值:


这里,我们看到的结果与第二个示例相同,在第二个示例中,我们手动更改了所有内容。这是合乎逻辑的,因为我们使用什么来更改 RectTransform 都没有区别。


动画师将 RectTransform 更改为类似的值:


动画师以前面临的一个问题是,他们会在每一帧中不断覆盖相同的值,即使该值保持不变。这会无意中触发布局重建。幸运的是,新版本的 Unity 已经解决了这个问题,无需切换到替代补间方法仅用于提高性能。


现在,让我们检查一下在包含 8 个元素的布局组中更改文本值的行为,以及它是否会触发布局重建:


我们看到重建也被触发了。


现在,我们将改变 8 个元素的布局组中 TextMechPro 的值:


TextMechPro 还会触发布局重建,并且它甚至看起来比常规文本在批处理和渲染上施加了更大的负载。


在 8 个元素的布局组中更改 SubCanvas 中的 TextMechPro 值:


SubCanvas 有效地隔离了更改,防止了布局重建。然而,虽然批量更新的负载已经减少,但仍然相对较高。这在处理文本时会成为一个问题,因为每个字母都被视为单独的纹理。修改文本会影响多个纹理。


现在,让我们评估在布局组内打开和关闭游戏对象(GO)时产生的负载。


打开和关闭 8 个元素的布局组内的 GameObject:


我们可以看到,打开或关闭 GO 也会触发布局重建。


打开 SubCanvas 中的 GO,其布局组包含 8 个元素:


在这种情况下,SubCanvas 也有助于减轻负载。


现在,让我们检查一下如果我们使用布局组打开或关闭整个 GO,负载是多少:


结果显示,负载达到了迄今为止的最高水平。启用根元素会触发子元素的布局重建,进而导致批处理和渲染负载显著增加。


那么,如果我们需要启用或禁用整个 UI 元素而不产生过多负载,我们该怎么办?您可以简单地禁用 Canvas 或 Canvas Group 组件,而不是启用和禁用 GO 本身。此外,将 Canvas Group 的 alpha 通道设置为 0 可以实现相同的效果,同时避免性能问题。



当我们禁用 Canvas Group 组件时,负载会发生以下变化。由于禁用画布时 GO 仍处于启用状态,因此布局会保留,但不会显示。这种方法不仅可以降低布局负载,还可以显著减少批处理和渲染的负载。


接下来,让我们检查一下改变布局组内的 SiblingIndex 的影响。


更改 8 个元素的布局组内的 SiblingIndex:


可以看出,负载仍然很大,更新布局需要 0.7 毫秒。这清楚地表明对 SiblingIndex 的修改也会触发布局重建。


现在,让我们尝试一种不同的方法。我们不改变 SiblingIndex,而是交换布局组内两个元素的纹理。


在 8 个元素的布局组中交换两个元素的纹理:


我们可以看到,情况并没有改善,事实上,情况变得更糟了。替换纹理也会触发重建。


现在,让我们创建一个自定义布局组。我们将构造 8 个元素,然后简单地交换其中两个元素的位置。


包含 8 个元素的自定义布局组:


负载确实显著减少了 — 这是意料之中的。在此示例中,脚本只是交换了两个元素的位置,从而消除了繁重的 GetComponent 操作以及重新计算所有元素位置的需要。因此,批处理所需的更新更少。虽然这种方法看起来像是灵丹妙药,但需要注意的是,在脚本中执行计算也会增加总体负载。


随着我们在布局组中引入更多复杂性,负载将不可避免地增加,但由于计算发生在脚本中,因此它不一定反映在布局部分中。因此,我们自己监控代码的效率至关重要。但是,对于简单的布局组,自定义解决方案可能是一个很好的选择。

结论

重建布局是一项艰巨的挑战。要解决这个问题,我们必须找出其根本原因,而原因可能各不相同。以下是导致布局重建的主要因素:


  1. 元素动画:移动、缩放、旋转(变换的任何变化)
  2. 更换精灵
  3. 重写文本
  4. 打开或关闭 GO、添加/删除 GO
  5. 更改兄弟索引


需要强调的是,在较新版本的 Unity 中不再存在问题,但在早期版本中却存在问题的几个方面:覆盖相同的文本并使用动画器重复设置相同的值。


现在我们已经确定了触发布局重建的因素,让我们总结一下解决方案选项:


  1. 将触发重建的 GameObject (GO) 封装在 SubCanvas 中。这种方法可以隔离更改,防止它们影响层次结构中的其他元素。但是,请谨慎 - 太多的 SubCanvas 会显著增加批处理的负载。


  2. 打开和关闭 SubCanvas 或 Canvas Group,而不是 GO。使用对象池,而不是创建新的 GO。此方法将布局保留在内存中,允许快速激活元素而无需重建。


  3. 利用着色器动画。使用着色器更改纹理不会触发布局重建。但是,请记住,纹理可能会与其他元素重叠。此方法实际上与使用 SubCanvas 的用途类似,但它确实需要编写着色器。


  4. 将 Unity 的布局组替换为自定义布局组。Unity布局组的一个关键问题是,每个 LayoutElement 在重建期间都会调用 GetComponent,这会占用大量资源。创建自定义布局组可以解决这个问题,但它也有自己的挑战。自定义组件可能有特定的操作要求,您需要了解这些要求才能有效使用。尽管如此,这种方法可能更有效,尤其是对于更简单的布局组场景。