Transform优化,性能超乎想象 -- 《ScriptableObject提高开发效率》 - Unite2017-Ian Dundore分享
我(Ian Dundore)是一个面向开发者的工程师,我的工作是帮助我们的大客户解决他们可能会遇到的技术问题。我希望今天能够尽可能准时完成我的演讲,并在演讲当中给到大家有用的信息。
今天第一个主题是优化,可能这个话题非常空泛,但是我会在后面提到一系列具体目标。我们首先讨论CPU的优化,先从比较简单的开始,包括我们的组件,之后会提到动画以及物理属性;最后会讲内存。我们会讨论发现的问题以及解决方法。在这里也要提醒一下大家,在优化之前不要盲目去做。如果你做的是手游,你肯定要使用原生工具。对于有些系统,使用内部用性能分析和在编辑器外独立使用性能分析器,结果是不一样的。
在所有的Unity开发中,我们都会使用Transform组件,虽然Transform看起来不是很复杂,但是非常重要:每个游戏的对象都会使用到Transform组件。Transform会决定游戏对象在游戏世界里面的位置,旋转,缩放比例。这里也许有人听说过Transform有变化的时候会发送信息。另外当Transforms复合对象发生变化,可能子对象也会发出信息,就是在复合对象发出对象之前,还有OnTransforms,每一次它的位置,它的旋转,它的缩放比例发生了变化的时候,都会发出这个信息。Transform是一个层级结构,如果这个Transform发生移动的时候,它的子对象也会发生这样的变化。因此这个子对象也会接收到信息,这个信息也会立即传递出去,有一次变化一次移动就会有这样的信息,这些信息本身是非常重要的:他们必须要通知物理的对象,用来更新物理场景,bounding boxes渲染技术未来会做不同的工作。因为Transform有不同的分层,它发送信息的过程非常耗时。如果你要创建某个游戏的对象,这个对象有10个子对象,打开性能分析窗口,把复合对象的游戏对象放到场景里面,你可以看到CPU占用量瞬间就上去了。在这里,你发现额外CPU的使用量上升,在这里我们看到它到底意味着什么,差别在哪里。发送那些信息,发送一大组对象的性能消耗对我们来说是个大问题。那么作为开发商,开发者我们有什么可以做的呢?
举一个简单的例子,代码很简单:在每一帧里面进行位移和旋转操作。从性能的角度看,这样做并不好,我们设置了位置,就会发送Transform变化的信息。第二行我们会进行旋转,你可能又发出另外一条Transform改变的信息。假设有一万个子对象,你要为每一个对象都发送消息,这真的很耗时。但是我们有一个很好的方法解决它:在Unity5.6当中我们增加了这个方法SetPositionAndRotation,这是一个新的API,这个新的API可以帮助你在一次发出信息当中发出两个值,包括位置和旋转。API的改变不大,但是在性能方面,跟前面一页代码相比可以节省50%的时间,这是非常简单的例子。我们经常会看到,在同一个项目中,我们使用不同的类、组件中操作Transform的变化。如果你代码有很多地方改变的是相同Transform组件,那就有必要创建一个系统,把这些变化汇总起来,当你的系统运行的时候,能够使用一条指令来操作综合汇总的变化,这能够节约下来很多额外不必要的计算。你能够设置Transforms组件,在全局坐标的位置。在系统中如果有多个不同Transforms组件,我们再选择,这是一个商店里面的模型,如果它有超过150多个Transform组件,我们在场景中复制这个模型,从一个变成一百个,对应Transforms组件也会上升到15000个,最后有一个15000个Transforms,每一帧里面你要做这样海量的更新,它的性能问题可想而知。
这里我们可以看一下,按照毫秒来进行的动画渲染。在这里只是做网格扫描,还不包括游戏代码、AI部分、渲染部分。我在iPad上面和新的笔记本电脑上做对话,发现使用新的API速度会比较快。我做了一些对比,你在做动画编辑的时候,我们在底层优化后,它的速度在iPad上面有20%的提升,但在我的笔记本电脑上快了50%,我到底是如何实现这个优化的呢?上面是未优化,下面是优化。我首先点击这个框,在建模的过程中,你可以选择优化游戏对象,我强烈建议大家使用优化游戏对象,并且要在开发过程当中尽早使用,如果你要改变模型上面的设置,你也可以用那个模式来实现任何再创造。
它到底有哪些影响?有三个影响。首先对于动画数据可以带来更好的多线程操作,尤其在笔记本电脑上有很好的体现。另外它的位置旋转,动画编辑的信息可以得到很好的更新。最后你所有的输入端信息,它的Transforms对应的我们需要及时把它的位置、缩放比例、旋转在每一帧上面进行更新,这是我们动画编辑器上面来做。这里可能会发生我们的主线程上,在这里我们在模型里面不需要每个骨架都是由这样一个Transforms组件进行代表。但是其中的一些我们是需要的。所以你需要手在上面拿着武器或者跟踪它的出拳,也许你头上要戴一个帽子这样的一些情况。
在这里,我们要把你需要的Transforms组件放在一个额外的Transforms组件列表。
另外还有很大一个Unity大系统就是物理系统,可能不一定每个人都会用3Dphysics概念,但是3Dphysics概念也可以用到2D里面。首先它的用法非常简单,它包括刚体,每一个刚体都是要进行仿真,还会有一些查询操作,这也是非常常见的API,包括Raycast。了解物理查询函数和场景的复杂度对我们如何能做更好性能的动画模拟非常重要。如果在场景里面Collider越多,那么发现哪些碰撞器相互之间有作用并受Raycast影响所需要的计算也越多。为了完成这些计算,系统要做很多工作。我们要考虑到场景的密度:如果你小空间里面有很多Collider,系统的工作量就会很大。
我们可以通过一个小测试来验证这一结果:我们用过提高场景的复杂度,提高Raycast的成本。这个测试会不断创建10个、100个、1000个碰撞器,同时会以3种不同碰撞器进行测试,这些测试的时间值是以毫秒统计。大家可以看到随着数量提高,计算的代价变得越来越高。大家可以看到不同类型Collider带来不同的结果,球形是代价最低,胶囊体速度会慢一点,网格形速度最慢。我在这里选择正方形的网格碰撞器。如果网格非常复杂,例如人物角色,那么速度会更慢。我再改变一下对象的密度,我弄了1000个对象,而且会在越来越大面积内把它铺开,随着我创建物体的个数增加,Raycast的性能消耗也在增大。
当我们进行查询的时候,系统会做三件事:第一步它会把你所相关的对象收集在一起,找到你会影响到的碰撞器,这取决于你这个Ray会经过的部分;第二步如果碰撞层和你查询层之间没有作用就会被剔除。最后一步会测试剩下的Collider,看是不是有碰撞。这些步骤到底是在哪里进行呢?非常复杂:PhysX第一步拿到物体列表,第二步PhysX会将列表交给Unity来做过滤,最后Unity会把所有可能的Collider交回给PhysX,PhysX也会进行实际的相交测试。对于网格碰撞器,它要尝试每一个三角,这是最消耗时间的。对于所有的碰撞器,如果我们在前面早一点的步骤中丢弃碰撞器,那我们的查询就会节省一部分时间?考虑我们前面的步骤,PhysX有一个分治的过程,将场景分成不同的区域,然后测试碰撞查询是不是与这些区域相交,这些区域知道所包含的刚体和碰撞机。如果一个光线通过一个区域,PhysX就会将区域内的碰撞器加入列表,这就是为什么密度如此重要。如果你的光线穿越了拥挤的空间,PhysX就要追踪和测试很多的碰撞体。这个列表的物体就会很多。
有一个比较简单的方式限定你的查询,通过最大距离的参数,这个也是依赖场景。这是我们之前的测试,我创建了1000个球体。我们来看一下第一行,我把这个查询限定为1米,大家要记住这些纵列代表更大的区域,而这些区域将会产生更密集的碰撞体。当我们的光线投射距离短的话,查询就省时一些,因为系统可以忽略很多碰撞体。当我们进行光线投射的时候,它总是能够提供一个合理性的距离限制。但是还有其他的方式可能会影响到性能,大家是不是还记得查询第二部分,Unity会看这两层是不是交叉,你可以在Unity里面增加Layer,你也可以编辑,如果你有特定种类是你经常使用的,你可以为这个查询添加Layer。比如说要从角色投射光来看是不是能看到,我们可以做一个可视化查询,这个可视化的查询只与地形Layer交互。
还有一个优化方法,是我手机项目中都会遇到的,很多用主机和手机的游戏,都希望能够一秒30帧。默认Unity是一秒50帧,这对于一个fps游戏来说是不需要的,它只会让物理的系统,在绝大多数时候以两倍的速率运行。绝大多数的游戏会降低它的FixedTime设置到0.04,以秒25帧的速度去跑物理系统。但是这样就会降低了物理系统的准确性。因此开发者要衡量,这样降帧会不会影响你的游戏,如果你的游戏是经营类游戏,你可以考虑把时间降低到0.08或更低。
还有一个常见的问题,如果你的对象附着于一个刚体,你是不是通过改变Transforms来移动刚体呢?不要这么做。有一个更好的方式,你可以使用刚体类改变位置和旋转。你们可能会觉得很奇怪,刚刚说不要,但这是一个特例。比如我创建了500个刚体,然后把上一张PPT当中的编码复制,如果我们使用改变位置和改变旋转,就比直接改变Transforms运行速度快50%。
现在来说内存,我们在优化内存之前,也要了解Unity内存里面有些什么呢?首先我们有一个内存分析器,这个是能够在IL2CPP平台上面运行。幸运的是,这个IL2CPP是覆盖了所有的主机和设备,这个工具非常有用。它能够让你看到内存当中所有Unity对象,如果要使用的话,你可以从这里下载,直接拖曳到你的项目当中,这是一个编辑器的脚本,你不需要重建任何东西。它使用编辑器来获取数据,从截图可以看到,你在内存分析器中看到,这些色块有标签,这些就显示着你内存的使用分区。内存最大消耗这是紫色的,这个动画器是蓝色的,也很大,这就是我说的100个对象在运行的情况。这是什么?这2兆的内存用在了什么方面呢?我没有做任何的图像特效的编码,它怎么用在了我的渲染纹理之上呢?
如果我点一下渲染纹理的话,你就可以看到细节了。我可以使用这个信息来确认,这一大堆的内存到底用在哪了。从名字我们可以看到,是阴影贴图。在我质量的设置当中,我忘记把阴影贴图给关掉了。如果我的游戏不需要阴影的话,我可以纠正这个问题,然后节约两个内存。我们可以看到阴影贴图的标签是设置为隐藏不保存,这意味着什么呢?就是说隐藏标签类目有三种不同标签的结果,首先是HidelnHierarchy,在编辑器等级窗口隐藏,第二是DontSave,第三是DontUnloadUnusedAsset,这意味着对象不会被卸载,这个在两个模式下都可以使用。在这个情况下,我们希望阴影贴图能够被保存在内存中。有的时候我可以看到,编码把HideAndDontSave的标签放在纹理或者网格上,有的时候我们可以看到,在我们生成运行纹理图集的时候,可以看到这样一个标签。但是它也包括了DontUnloadUnusedAsset的资源,这些纹理将永远不会消失。除非你使用Destroy。如果你在对象上面重新设置标签的话,要记住如果你不用这个对象的话,要去调取Destroy。
在真实游戏当中,往往纹理占到内存大部分。我们经常看到一个游戏内存中超过50%用在了纹理上,我们也可以使用这个分析器,看一下我们在内存中纹理的情况。我再来点一下箭头旁边的色块,我们就可以得到了内存分析的报告。你可以看到纹理的名字都出现了,意味着是一个压缩过的纹理。我们可以看到它头发的反射贴图和法线贴图中也是有大量的纹理,当我们想要和美术团队沟通的时候,我们会问一个角色的头发我们需要三张纹理吗?在这个情况下,我们说是的。如果这个角色不是重要的角色,或者不是主角,也许我们可以把纹理的质量降低或者移除一些。我们内存分析器,对于你游戏尺寸的检查是特别好用的。同时,也能够让你发现一些极易纠正的错误,比如尺寸过大的纹理集。另外使用内存的分析器,也是很适用于检查是不是有重复的资源。有时候开发商会复制资源,然后又忘记把多余的拷贝删掉。一般来说,复制的资源包另外一个来源是Resources文件夹。如果你在Resources文件夹有一个资源,在你资源包里面也有一个资源,那么就会产生重复。当我们在建立多个Resources文件夹的时候,如果你没有很清晰的界定其独立性的话,资源的重复性也会很高。
什么意思呢?我给大家举一个例子,假设我们有两种不同的材质,每一个材质都会用不同的着色器,也就是紫色的盒子,但是会他们会使用相同的纹理。Shader着色器不一样,箭头就代表着独立性,如果我们要把它置于我们Resources当中,应该怎么做呢?最简单就是把这个材质直接放到这个橘黄色的框里面,我们包括了这个资源所有独立性。唯一我们不这么做的时候,是如果某一独立性是专门指定给另外一个不同的橘黄色框。Shader会被自动包含到材质所隶属的资源包当中,那么这个纹理的属性在哪里呢?它就会被复制,每一个资源包里面就会出现第二个拷贝。如果我们在运行的时候加载这些材质,会出现什么问题呢?Unity认为每一种纹理都是独立的,所以每一个材质都会加载它自己的纹理拷贝。就等于我们在内存当中有两个重复的拷贝,只有一个方法来纠正这个问题。我们要把共有的纹理放入它专属的橘黄色框当中,这个材质就可以以正确的材质分享这个纹理。
进入到最后一点,对于项目我们经常发现,我们在下载纹理的时候,或者按照一定程序创建它们的时候,使用过多的内存。我也发现,这种情况当我们在多用户、多玩家当中,尤其常见。我们是需要关闭默认读写纹理,读写纹理本身会产生纹理数据。与此同时,你能够在CPU上面做,但是在GPU上面也可以进行渲染。如果你使用UntiyWeb,这个事情就不会发生。另外如果你需要从代码中修改纹理的时候,有可能一开始设置为可读写,当你完成修改之后,一定要设置为不可读写。
以上就是这次的内容了,我的演讲信息量比较大,希望能有对大家有用的信息。我稍微休息一会儿,再跟大家分享我第二部分的演讲,谢谢!