搜索Unity

Unity不断演化的最佳实践

上次更新时间:2018年12月

本页内容简介:Ian Dundore提供的更新脚本性能和优化技巧,反映了Unity架构的演化以支持面向数据的设计。

随着Unity的不断演化,之前的方法可能已不再是最大限度利用引擎的最佳方式。在本文中,将概述Unity的一些变化(从Unity 5.x到2018.x)以及利用它们的方法。所有这些实践都源自Ian Dundore的谈话,因此如果您需要,可直接访问信息来源。

脚本性能

优化过程中最困难的一项任务是选择如何在发现热点之后优化代码。这涉及到许多不同的因素:操作系统、CPU和GPU的平台特定内容;线程;内存访问等。很难预先知道哪种优化方式可以产生最大的实际收益。

通常,最好先在小型测试项目中构建优化原型,这样您可以迭代得更快。然而,将代码分解成测试项目也会带来挑战:只是分割一段代码就会改变其运行的环境。线程计时也会改变;托管堆可能变得更小或更分散。因此,在设计测试时一定要小心。

首先考虑代码的输入,以及更改输入时代码产生的反应。

  • 它如何对串联地位于内存中的高度相关的数据作出反应?
  • 它如何处理缓存无关的数据?
  • 您从您代码的循环体中删除了多少代码?您是否更改了处理器的指令缓存使用情况?
  • 它用什么硬件运行?此硬件如何实现分支预测?它如何执行无序的微操作?是否支持SIMD?
  • 如果您有任务繁重的多线程系统,并在具有更多核而非更少核的系统上运行,系统会如何反应?
  • 您代码的缩放参数是什么?它是否随输入集增大而线性缩放,或者不止是线性?

您必须思考自己的测试工具具体要测量什么,这很有用。

例如考虑简单运算的以下测试:比较两个字符串。

当C# API比较两个字符串时,它们进行区域特定的转化以确保来自不同文化的不同字符之间能够匹配,您会发现速度非常慢。

尽管C#中的大多数字符串API都对区域敏感,但有一个不是:String.Equals

如果您从GitHub打开String.CS并查看String.Equals,将会看到:它是一个非常简单的函数,在将控制权交给名为EqualsHelper的函数之前进行几项检查,后者为私有函数,在没有反射的情况下无法直接调用。

lp MonoString CS1

EqualsHelper操作简易。其每次遍历字符串中的4个字节,再比较已输入字符串的原始字节。如果发现不匹配,则会停止并返回"false"指令。

不过,还有其他方法可以检查字符串是否相等。其中看起来最无伤大雅的方式是过载String.Equals,它可以接受两个参数:要比较的字符串和一个名为StringComparison的枚举。

我们已经确定,String.Equals的单参数重载在将控制权传递给EqualsHelper之前所做的工作很少。双参数的重载会做什么?

如果查看双参数重载的代码,它在进入大型switch语句之前进行少量额外检查。此语句测试输入StringComparison枚举的值。由于我们希望通过单参数重载进行奇偶校验,我们需要依序的比较 - 逐字节比较。在这种情况下,控制权将在到达StringComparison.Ordinal之前经过4次检查,其中代码看上去将和单参数String.Equals重载非常相似。这意味着如果您使用了String.Equals的双参数重载,而非单参数重载,处理器将执行一些额外的比较运算。这样可能会让速度变慢,但是值得测试的。

如果您需要比较字符串的相等性,将不希望在测试String.Equals之后就停止。String.Compare也有一项重载,它可执行依序的比较以及名为String.CompareOrdinal的方法,此方法自身有两个不同的重载。

作为参考实现,这里提供了简单的手动编码的示例。它只是一个具有长度检查功能的小函数,在两个输入字符串的每个字符上迭代,并检查它们。

在检查所有这些的代码之后,下面四个不同的测试案例可能会立即有用:

  • 两个一致的字符串,用于测试最坏情况下的性能。
  • 长度相等的两个随机字符的字符串,用于绕过长度检查。
  • 两个长度一样的随机字符串,首字符相同,用于绕过仅存在于String.CompareOrdinal中的有用优化。
  • 长度不同的两个随机字符的字符串,用于测试最佳情况的性能。

在运行一些测试之后,String.Equals明显胜出。无论平台版本、脚本运行时版本或我们是在使用Mono还是IL2CPP,都是如此。

值得注意的是String.Equals是字符串相等性运算符==使用的方法,请勿在您的代码中全部将a == b更改为a.Equals(b)

实际上,如果检查结果,手动编码的参考实现如此糟糕让人奇怪。检查IL2CPP代码,我们可看到当我们的代码是交叉编译时,Unity注入了一些数组边界检查以及null检查。

这些内容可以禁用。在您的Unity安装文件夹中,找到IL2CPP子文件夹。在此IL2CPP子文件夹中,您将找到IL2CPPSetOptionAttributes.cs。将它拖动到您的项目中,即可访问Il2CppSetOptionAttribute

您可以使用此属性修饰类型和方法。您可以对其进行配置以禁用自动null检查、自动数组边界检查,或禁用两者。这样可加快代码速度 - 有时可大幅增加。在此特定测试用例中,可将手动编码的字符串比较速度提升大约20%!

lp null检查技巧

变换

如果只在Unity编辑器中查看层级将无法了解它,但变换组件在Unity 5和Unity 2018之间已经有了明显变化。这些变化也对改进性能带来了值得关注的可能。

在Unity 4和Unity 5.0中,一旦您创建变换,对象将被分配在Unity的本地内存中的某处。此对象可在本地内存堆中的任何位置 - 不能保证两个顺序分配的变换的位置会彼此靠近,并且不能保证子变换将靠近其父变换的位置分配。

这意味着当线性地通过变换层级迭代时,我们没有通过邻近内存区域线性迭代。这导致处理器在等待从L2缓存或主内存获取变换数据时反复停止。

在Unity后端,每次变换的位置、旋转或缩放更改时,此变换将发送OnTransformChanged消息。此消息必须由所有子变换接收,从而它们可更新其自己的数据,并通知变换更改中相关的其他组件。例如附加了碰撞体的变换必须在子变换或父变换更改时更新物理系统。

这个不可避免的消息导致大量的性能问题,尤其是因为没有内建的方式来避免假消息。如果您要更改变换,并且要更改其子项,则没有办法阻止Unity在每次更改后发送OnTransformChanged消息。这样浪费了大量CPU时间。

由于这个细节,在较旧版本的Unity中一个最为常见的建议是将变换的更改组成一批。即在帧开始时,获取变化组件的位置和旋转一次,并在帧的过程中使用并更新这些缓存的值。在帧结束时向位置和旋转应用更改一次。这是不错的建议,一直到Unity 2017.2都适用。

幸运地是,从Unity 2017.4和2018.1开始,OnTransformChanged不再存在。新的系统TransformChangeDispatch已将之取代。

TransformChangeDispatch最初是在Unity 5.4中引入。在此版本中,变换不再是可放在Unity的本地内存堆中任何位置的单独对象。相反,每个场景中的根变换将由连续的数据缓存区表示。此缓冲区名为TransformHierarchy结构,包含根变换下所有变换的所有数据。

此外,TransformHierarchy也存储有关其内每个变换的元数据。此元数据包括位掩码,指示给定变换是否为“脏”– 其位置、旋转或缩放自上次变换标记为“洁净”时是否已经更改。它还包括位掩码来跟踪Unity其他系统中的哪一个与特定变换的更改相关。

借助此数据,Unity现在可为其每个其他内部系统创建脏变换的列表。例如,物理系统可查询TransformChangeDispatch以获取变换的列表,这些变换的数据自物理系统上次运行FixedUpdate之后已经更改。

但为了构建此更改的变换的列表,TransformChangeDispatch系统不应当在场景中的所有变换上迭代。如果场景包含大量变换,速度将变得很慢 - 尤其是因为在大多数情况下发生更改的变换很少。

为了解决这个问题,TransformChangeDispatch跟踪脏TransformHierarchy结构的列表。一旦变换更改,则会将自身标记为脏,将其子项标记为脏,然后向TransformChangeDispatch系统注册其中存储它的TransformHierarchy。当Unity内的另一个系统请求更改的变换的列表时,TransformChangeDispatch在存储于每个脏TransformHierarchy结构内的每个变换上迭代。具有适当脏和相关位集合的变换被添加至列表,并且此列表被返回给提出请求的系统。

由于此架构,拆分层级的程度越大,就能越好地利用Unity的能力以细致的级别跟踪更改。场景的根存在的变换越多,则在查找更改时要检查的变换就越少。

但还存在另一个作用。在检查TransformHierarchy结构时,TransformChangeDispatch使用Unity的内部多线程系统来拆分其需要进行的工作。此拆分以及结果的合并,在系统每次需要从TransformChangeDispatch请求更改的列表时会增加一点开销。

大多数Unity的内部系统会刚好在其运行之前每帧请求一次更新。例如,动画系统会在即将估算您场景中所有活动的Animator之前请求更新。相似地,渲染系统在其开始选择可见对象的列表之前,请求您场景中所有活动Renderer的更新。

有一个系统有一点不同:物理。

在Unity 2017.1(以及更旧版本中),物理更新是同步的。在您用其附加的碰撞体移动或旋转变换时,会立即更新物理场景。这确保碰撞体更改的位置或旋转反映在物理世界中,从而射线透射和其他物理查询将变得准确。

在Unity 2017.2中,当我们将物理迁移为使用TransformChangeDispatch时,尽管必须进行更改,但也会造成问题。任何时候您进行射线透射时,我们都必须查询TransformChangeDispatch以获得更改的变换的列表并将这些变换应用至物理世界。这样成本可能较高,具体取决于您的变换层级的大小,以及您的代码如何调用物理API。

此行为由新设置Physics.autoSyncTransforms管理。从Unity 2017.2到Unity 2018.2,此设置默认设置为"true",并且每次您调用RaycastSpherecast这样的物理查询API时,Unity将自动把物理世界与变换更新同步。

可在您的Unity编辑器的物理设置中或在运行时,通过设置Physics.autoSyncTransforms属性来更改此设置。如果您将其设置为"false"并禁用自动物理同步,则对于特定时间的更改物理系统将仅查询TransformChangeDispatch系统:刚好在运行FixedUpdate之前。

如果在调用物理查询API时发现性能问题,则有另外两个方式来处理它们。

首先,可以将Physics.autoSyncTransforms设置为"false"。这将消除来自物理查询的TransformChangeDispatch和物理场景更新造成的高峰。

然而,如果您这样操作,在下个FixedUpdate之前,对于碰撞体的更改将不会同步到物理场景中。这意味着如果您禁用autoSyncTransforms,移动碰撞体,然后用在碰撞体的新位置取向的射线调用射线透射,射线透射可能不会碰到碰撞体 - 这是因为射线透射在物理场景的最后更新版本上作用,并且物理场景尚没有通过碰撞体的新位置更新。

这可能导致奇怪的错误,并且您应当仔细测试自己的游戏以确保禁用自动变换同步不会造成问题。如果您需要通过变换更改强制物理更新物理场景,可以调用Physics.SyncTransforms。此API较慢,因此您不需要每帧调用它多次!

注意,从Unity 2018.3起,Physics.autoSyncTransforms将默认设置为"false"。

优化查询TransformChangeDispatch所用时间的第二种方式是重新安排查询和更新物理场景的顺序,使其对新系统更加友好。

记住:在Physics.autoSyncTransforms设置为"true"的情况下,每次物理查询将检查对于TransformChangeDispatch的更改。但是,如果TransformChangeDispatch没有任何要检查的脏TransformHierarchy结构,并且物理系统没有更新的变换要应用至物理场景,则将几乎不会对物理查询增加开销。

因此,您可以批量执行您的所有物理查询,然后批量应用所有变换更改。最好是不要将变换的更改与物理查询API混合。

此示例示出了差异:

lp示例变换

两个示例之间的性能差异很明显,并且在场景仅包含小型变换层级时尤为明显。

lp示例变换结果

音频系统

在内部,Unity使用名为FMOD的系统播放AudioClips。FMOD以其自身的线程运行,并且这些线程负责解码并且音频混合到一起。但音频播放并非完全无偿。对于场景中活动的每个音频源,存在对主现场执行的一些工作。另外在核心数更少的平台上(诸如手机),FMOD音频线程可能会与Unity的主和渲染线程竞争处理器核心。

在每个帧,Unity在所有活动的音频源上循环。对于每个音频源,Unity计算音频源和活动的音频监听器之间的距离,以及其他一些参数。此数据用于计算音量衰减、多普勒频移和可能影响各个音频源的其他效果。

在音频源上的“静音”复选框上存在一个常见问题。您可能认为将“静音”设置为true将消除与静音的音频源相关的任何计算 - 但实际并非如此!

lp音频复选框

相反,“静音”设置只是在执行其他所有音量相关的计算之后(包括距离检查),将音量参数固定为零。Unity还将把静音的音频源提交至FMOD,然后后者将把它忽略。音频源参数的计算以及音频源到FMOD的提交在Unity性能分析器中将显示为AudiosSystem.Update

如果您注意到将大量时间分配给此性能分析器标记,则进行检查,确定是否有大量被静音的活动音频源。如果有,则考虑将静音的音频源组件禁用,而不是将它们禁音,或者将其GameObject禁用。您也可调用AudioSource.Stop,其将停止播放。

您可进行的另外一项操作是将Unity的音频设置中的语音计数固定。要执行此操作,您可以调用AudioSettings.GetConfiguration,其将返回包含两个相关值的结构:虚拟语音计数和实际语音计数。

在确定实际要播放的音频源时,减少虚拟语音的数目将减少FMOD将检查的音频源的数目。减少实际语音计数将减少FMOD实际混合到一起以生成您游戏的音频的音频源的数目。

要更改FMOD使用的虚拟或实际语音的数目,您应当更改AudioSettings.GetConfiguration返回的AudioConfiguration结构中的适当的值,然后通过将AudioConfiguration结构作为参数传递至AudioSettings.Reset来用新配置重置音频系统。请注意,这会中断音频播放,因此建议在播放器不会注意到更改时进行此操作,例如在加载屏幕时或启动时间。

动画

有两个不同的系统可用于Unity中的动画播放:Animator系统和动画系统。

“Animator系统”是指包含Animator组件和AnimatorController资源的系统,其中,Animator组件附加至GameObjects以将其生成动画,AnimatorController资源则由一个或多个Animator引用。此系统过去称为Mecanim,功能非常丰富。

在Animator Controller中,可定义状态。这些状态可为动画剪辑或混合树。状态可组织为层。在每个帧,会对每个层上活动的状态进行估算,并且来自每个层的结果被混合到一起,并应用至动画化的模型。在两个状态之间过渡时,会估算两个状态。

另一个系统是我们称为“动画系统”的系统。其由动画组件表示,并且非常简单。每个帧、每个活动动画组件通过其附加的动画剪辑中的所有曲线线性地迭代,估算这些曲线,并应用结果。

这两个系统之间的区别不仅在于功能,还在于底层的实现细节。

Animator系统是重型多线程。其性能对于核心数不同的不同CPU明显不同。一般而言,在动画剪辑中的曲线数目增多时,它并非线性地缩放。因此,在估算具有大量曲线的复杂动画时,它的效果很好。

动画系统很简洁,几乎没有开销。其性能随着播放的动画剪辑中曲线的数目线性缩放。

在播放同一动画剪辑的情况下比较两个系统时,这种差异尤为明显。

lp动画剪辑测试

在播放动画剪辑时,尝试选择最适合您内容复杂度的系统,以及您要用来运行游戏的硬件。

另一个常见的问题是Animator Controller中层的过度使用。当Animator运行时,其将每帧估算其Animator Controller中的所有层。这包括层权重设置为零的层,意味着对于最终动画结果没有可见的贡献。

每个额外的层每帧将向每个Animator Controller增加额外的计算。因此,一般而言,会尝试保守地使用层。如果您在Animator Controller中有调试、演示或电影层,可尝试重构它们。将它们合并到现有层中,或在交付游戏之前消除它们。

通用和人形骨架

默认设置下,Unity通过通用骨架导入动画模型,但在动画化角色时,开发人员也常切换为人形骨架。但这并非无偿的。

人形骨架向Animator添加两个额外功能:反向运动学和动画重定向。动画重定向很有用,可让您跨不同头像重新使用动画。

但是,即使您没有使用IK或动画重新定向,人形骨架的角色的Animator仍将每帧计算IK并重新定向数据。这会比通用骨架多消耗30-50%的CPU时间,通用骨架不会进行这些计算。

如果您未使用人形骨架的功能,则应当使用通用骨架。

Animator池

对象池是避免游戏期间性能高峰的关键策略。但是,Animator在过去难以使用对象池。一旦启用了Animator的GameObject,则它必须重构中间数据的缓存区以供估算Animator的Animator Controller时使用。这称为Animator重新绑定,并且在Unity性能分析器中显示为Animator.Rebind

在Unity 2018之前,唯一的解决办法是禁用Animator组件,而非GameObject。这样做有副作用:如果您在角色上有任何MonoBehavior、网格碰撞器或网格渲染器,则也希望将它们禁用。由此,可以节省您的角色使用的所有CPU时间。但是这会让您的代码复杂度提升并且易于崩溃。

对于Unity 2018.1,我们引入了Animator.KeepControllerStateOnEnable API。此属性默认设置为false,意味着Animator将以始终不变的方式作用 - 在禁用它时取消分配中间数据缓冲区并在启用它时重新分配它们。

但是,如果您将属性设置为true,Animator将在它们被禁用时保留其缓存区。这意味着当重新启用Animator时,将不存在Animator.Rebind。Animator可最终被合并!

更多资源

您喜欢本文吗?请告诉我们!

喜欢。继续发送 还行。有待改进