近日,在中国Unity线上技术大会游戏专场中,西山居资深引擎开发工程师以“《剑网3:指尖江湖》客户端性能优化案例分享-动态骨骼(DynamicBone)优化”为主题发表演讲,介绍了DynamicBone优化、通过Burst Compiler和Mathematics数学库进行加速、以及动静分离的数据等,帮助各位开发者制作出更逼真的角色摆动效果。
以下是演讲内容,有删减:
大家好,我叫苏泰梁,来自西山居,现在主要负责《剑网3:指尖江湖》优化方面的工作。今天主要分享游戏动态骨骼DynamicBone的优化。
什么是动态骨骼?当使用动态骨骼时,拖拽角色左右晃动,头发、衣服的摆动都是比较真实自然的,动态骨骼和动作的融合也非常好。如果禁用所有动态骨骼的效果,在整个转动的过程中,头发、衣服都是硬梆梆的,非常僵硬。
动态骨骼是一款名叫DynamicBone的插件,一般用来模拟飘带、衣袖、裙摆、头发等的摆动效果,效果比较逼真,可以大大节省动作K帧的工作量。《剑网3:指尖江湖》的NPC、坐骑、各种挂件等都有用上,是一个使用非常广的功能。
右图的角色,我把它身上用到动态骨骼的地方都用数字标识了出来,1、2、3、4,有的长,有的短,都有动态的效果。
假设我们要给角色最长的飘带加上动态骨骼的效果,先找到这根飘带在对象树上的根结点,把它拖到动态骨骼的组件节点上,再配置一下参数就可以了,非常简单。
有很多参数,比如说阻尼系数、弹性系数、干性系数、惯性系数等等。阻尼系数可以理解为一种阻力或者摩擦力,节省速度。弹性系数会把你拉扯到一个目标位置。
接下来看动态骨骼模拟简化过程。
还以飘带为例,假设它有5个节点,在上一帧处于垂直的状态,在当前帧稍微往右移了一点,有一点点的偏移、旋转,一般都是由于模型的位移或者动作带来的。除了根结点,其他的节点都会从上一帧的位置模拟,然后根据每个节点的参数,比如惯性系数、弹性系数等,对每个节点进行相关的模拟运算,得出一个最佳的位置。
最后,会把这个位置更新到每个骨骼节点上,同时根据父子节点的关系、位置,来修正这个旋转,这样的话整体上看起来就非常自然了。
每根骨骼在这个过程中要做什么事?核心的办法主要集中在组件的Update和LateUpdate。在Update中,每根骨骼需要重置位置,在LateUpdate中要做大量的模拟运算,并且最终会设置到骨骼。这里列出了完整的一个模拟运算。除了阻尼、弹性之类的,还有风力、重力各种模拟运算,印刷量比较大的。
然后,我们假设场景里面有30个角色模型,每个模型有10条骨骼链,每条骨骼链有10根骨骼,那一共有3000根骨骼,在《剑网3:指尖江湖》里20个玩家再加上坐骑、NBC之类的,3000根骨骼,数量是比较正常的。
这么多根骨骼,每根骨骼还要做这么多事情,性能怎么样?先看看优化前的数据。这是小米Max2在组成动态骨骼中的CPU消耗,占整个CPU消耗大概10%,这是一个非常大的开销。
动态骨骼为什么会这么耗?首先它的数量非常多,一共有3000根骨骼。二是它的运算很复杂,需要做大量的模拟运算。这里值得一提的是,在整个模拟运算的过程中,需要对每根骨骼进行世界坐标、世界旋转、世界矩阵等世界变化的获取和设置等操作。
说到世界变化相关的操作,在Unity里面要特别注意,因为这是一个非常耗时的操作。比如说获取和设置世界变化,在Unity的顶层并没有世界坐标的属性,只有局部坐标的属性,所以每次获取或者设置都是通过局部坐标一层一层地往上变,非常耗时。
在《剑网3:之间江湖》里面,很多骨骼的层数都是非常深的,比如下图,这里面挂了动态骨骼的效果,所以它的层级很深,大概有20层,它在计算世界坐标、旋转的过程中开销是非常大的。层级越深,消耗就会越大。
最后一点就是它的模拟是在Update和LateUpdate中完成的,每帧都需要做,也就是说这是一个固定的常态性能开销。
第一版优化
了解了它为什么这么耗,现在介绍一下我们做的动态骨骼第一版优化。
下面这三点是我们做优化的过程中经常提起的一个三原则,第一个是能否不做,如果说不做也能达到效果,那肯定开销都没有了。二是必须做的话能否少做一些。最后不得不做的时候,再考虑能否做得更好。
再看看这张图,在1、2、3、4上面用红色标出骨骼的长度。下面还有张小图,已经很小了,4这根飘带还是非常长,看得还比较清晰,但是1和2已经看不大清楚了。所以就有了我们第一版优化思路,根据骨骼链屏幕投影程度,过短的骨骼链直接关闭动态骨骼效果。
投影长度可以使用骨骼链静态的长度。再加上游戏的FOV,一般动态变化比较少,所以计算它的屏幕投影程度可以做到几乎没有消耗。一般手机的宽度都是70毫米,2毫米以下就看不大清楚了。这个长度可以根据机型、机器情况和性能情况分不同的画质进行定制,还可以跟进不同的压力情况,实时调整。
能否少做呢?我们还是利用投影长度,把适中的骨骼,比如说1或者3的骨骼在某些情况下只保持最基础的刚性运算,保留最基本的效果。
再考虑能否做得更好。到了这步就只能死磕算法,在算法层面进行优化,尽可能地减少消耗。我们使用局部坐标,减少世界坐标,使用一些Catche来减少重复运算。最后减少Component数量,一个角色、一个组件就可以支持多条骨骼链的配置。这就是我们第一版的优化。
优化后的数据效果还是比较明显的,开销从10%直接降到6.5%,优化了大概35%的CPU开销。
不过,6.5%的CPU开销还是挺多的。既然有第一版优化,那就有第二版,接下来介绍一下我们第二版优化。
第二版优化
第二版的优化最好是不做,或者是少做。这里需要提到一个概念,就是Unity Job System,这是Unity提供的一套多线程编程框架。我们第二版优化的核心思想就是使用多线程,尽可能地减少主线程做的事情。
Job System是Unity提供的一套多线程编承框架,它跟一般的多线程有什么不同呢?为什么Unity需要额外提供一套多线程框架?在Unity中写过多线程的人可能都遇到过一个坑,一般的线程是没办法操作Unity对象的,这是Unity的一个强制限制,并且告诉你这个只能在主线程访问。直到Job System出现,才使得这成为可能。
虽然现在局限还是挺大的,但是它使得多线程中操作Unity对象成为可能。这就是第一个不同。
第二是它有强大的线程安全检测机制。这个是非常重要的,比如说主线程和Job线程之间一些数据的读写安全问题,都有强大的检测机制,保证你的数据不会写坏。
第三点,它有非常高的性能,可以充分地利用多核的CPU。Job System跟Unity引擎顶层的C++共享work线程池,work线程池会通过一个Job队列来减少上下文的切换和竞争问题,这样就可以充分地利用多核CPU的资源,从而提高性能。
接下来感受一下Job System的简化例子。在这里,并行计算两个数据原始物中的一个值相加,然后复制到另外一个数据。首先我们要声明一个Job,然后集成Unity Job相关的接口,只用特定的数据结构证明自己的数据,就可以在Execute函数中写需要在Job中运行的逻辑了。这个例子也非常简单,Execute里面就是把A和B的数据元素直接相加,然后复制到C的数据中。
最后,写的Job可以通过Schedule这个函数推到work线程进行执行,使用上还是非常简单和直观的。
再来看看动态骨骼Job化的示意。我这里会将耗时的操作都Job化,比如Update中的Transform操作,还有lateupdate中的各种模拟运算,提取到对应的Job中。但实际上把直接转化成Job是存在不少问题的,我们在直接转换的基础上做了很多的加速优化。
Job优化后的数据,相对于上一版优化了84%的CPU时间,效果还是非常非常明显的。
左边是优化前,右边是优化后的Profiler数据,我用红线把work线程的执行情况标识出来了。可以看到左侧优化前的work线程一直处于idol状态,什么事都没做。但是,主线程压力是非常大的,所有东西都放在了主线程来做。右边是Job化后的情况,所有的Job都在紧密地连接,紧密地执行,而且很好地分布到了所有work线程,充分利用了多核的并行加速效果。
当然,这两幅图的时间轴单位是不一样的,右侧是我们不断地放大后的数据,主要是为了让大家看清楚Job的执行情况。实际上相对左图的话,按单位来算,大概只有1毫米那么宽。
这个是我们在原本直接转化到Job的基础上经过多版优化后的一个数据。
加速手段1
我们用到了一些比较关键的加速手段。
第一个就是让Job真正并行起来。为什么说让Job真正并行起来呢?先看一段我们曾经一版的数据,这一版数据比优化前还差,可以看到上面是主线程,下面是Job线程,主线程一直在等待Job线程执行。为什么?因为Unity有一个问题,Transform相关的Job只要都在同一个根结点下,它都是没办法进行的。这是所有Transform Job绕不开的一个话题。
可能有点抽象,再看一下我们的使用情况——所有的player都放在了一个PlayerSet的分节点上,在同一个根结点下的Transform,它们之间的操作都是不能并行的。
这会带来什么问题呢?一个是主线程跟Transform Job没办法并行,会出现WaitForJob GroupID。这是因为主线程中有一个跟Transform相关的操作——Transform.Get_hasChange,它的核心是因为Transform和Job中的Transform是属于一个根结点的,这个时候主线程就需要等待相关的Job线程,所以没有在并行。
第二个问题是Transform Job之间也没办法并行。即使你把Transform相关的几个Job推到Job线程来执行,但Job线程之间的Transform如果还是属于一个根结点,它们自己也没有办法并行。
看左下图红框2的Profiler数据,它只在一个Job线程中执行,并没有分散到所有的work线程进行执行,没有充分利用多核的并行,效率非常的。
为什么会有这个限制呢?实际上父子节点Transform操作存在一个关联性,前面提到了Transform的世界坐标、操作指令,它可能存在一些安全问题。
那怎么样让Transform Job并行起来呢?将所有的模型都评估到顶层,比如说可以把PlayerSet去掉,直接把player全部平铺到顶层,这样所有的player之间都可以并行。但是,一个player可能会有十多根骨骼链,每根骨骼连上面还有十几个节点,这些都是没办法并行的。
当然,我们还可以再进一步,将每条骨骼链平铺到顶层。比如说player有个尾巴,直接把这条尾巴拉到顶层,这样即使它有多条骨骼链,所有骨骼链之间每个角色都是可以运行的。当然极限情况是我们还可以把这个骨骼链中的每个节点都平铺到顶层,这样的话所有的骨骼节点在整个过程中都可以并行。
这是很完美的,但是,骨骼平铺可能会导致关联的骨骼动作失效。
为什么会出现关联动作失效的问题?先来看看下图。左边是角色的对象数,右边是动作文件,这个动作文件绑定的tail4、tail5两根骨骼。Unity在Animator中,会根据动作文件中对应的每根骨骼的名字,在左边的对象数中一层一层往上找,找到对应的节点就可以绑定成功,如果没有找到,就绑定失败。
那怎么解决呢?我们通过源码发现,可以在引擎顶层加用Catche,或者指定映射关系来解决。什么意思呢?通过左侧对象数查找的时候,可以优先使用映射的关系或者是Cache中的数据,这样即使我们把它的骨骼链或者节点平铺到顶层,它重新绑定不会有任何的影响。
最后哪一种平铺策略更合适?具体还是根据实际情况定的。在《剑网3:指尖江湖》模型级别的平铺并行效果已经非常好了。当然,这跟后续各种加速手段的优化也分不开。
再介绍让Job真正并行起来的另外两个非常重要的加速手段。
可以看看左侧两个Job代码。上面的Job代码是从Transform中获取局部坐标和局部旋转的代码,下面的Job代码是局部坐标和局部旋转映射回Transform中的代码。这两个Transform就是我们跟Transform相关的Job的代码,已经优化到了一个极其简单的程度了。
为什么要把这两个Transform相关的代码优化到这么简单?前面提到Transform Job在相同的root点下是没办法并行的,很容易跟主线程出现一些wait,所以,我们减少Transform相关Job的逻辑,这个时候它跟主线程出现wait的可能性就会降低。当然,即使出现wait,时间也不会太长,因为它非常简单,执行起来非常快。
还有就是非Transform相关的Job没有Transform并行的限制,我们可以把几乎所有的逻辑全部移出来,放到非Transform一般的Job中执行。一般的Job没有限制,可以充分地利用work多核的性能,充分地并行,这样它就会大大提速,因为它没有一个跟Transform或者是root节点的限制。这就是我们为什么要把这个东西单独拎出来并且把它简化到极致。
再看看右侧的加速手段。这里主要是利用脚本的执行顺序做到一些真正并行加速的效果。比如上面的1就是让Job在所有脚本最开始执行,Job线程可以跟主线程有最大的并行时间,横宽的话就是update相关的Job,把它放到了所有脚本的最前面。
另外就是我们可以通过脚本执行顺序,决定Job具体和哪些脚本或者哪些模块并行。这样的好处,可以巧妙地避开这个wait。比如说下面的红框,我把动态骨骼放在了UI Pannel的前面,UI Pannel是NGY中管理所有UI组件的一个模块,在它的lateupdate中会做大量的核批和填充的操作,这些操作是非常耗时的。但是,在这个过程中它没有用到动态骨骼的效果。
另外,它有充分的执行时间,动态骨骼跟它并行的话可以完美地错开。因为它不用到动态骨骼的组件,并且在我们游戏中又是独立的根结点,不会跟别的玩家或者NPC组一个同样的根结点,这样它就可以完美地跟DynamicBone并行,不出现wait。
这些加速可以大大地缓解Transform Job可能出现的wait的情况。
加速手段2
接下来再介绍一下加速手段2,它可以大大提速Job的代码效率。
这里的核心是使用Burst Compiler和Mathematics数学库的加速。Burst是Unity的一个代码编译优化工具,可以针对目标机器进行专门的优化。数学库支持SMD的加速,SMD是一条指令可以同时操作多个数据,一般一条指令只操作一个数据,它可以同时操作多个数据,在3D运算中比如向量或者矩阵运算的加速效果是非常非常明显的。
看一下两者数据的差别。上图是没有开Burst的效果,下面是开了Burst的效果。提升非常的夸张,起码50倍以上的性能差异。
使用上也是非常简单的。Burst和Mathematics都是属于Unity Pacage中的一个包,需要在Package Manager中把这两个导入,Burst Compiler需要优选相应的包,然后在自己的Job上声明一个Burst Compiler。
数学库也只需要把它的库引用进来,然后再使用它的类型就好了。比如说使用Float3来替换我们平时用的Vector3,这两个加速就可以用起来了,效果极其明显。
最后再介绍一些加速手段。
一个是利用面向数据的设计减少Cache Missing。核心还是利用Unity ECS的思想,尽可能让Job操作的数据是连续的。优化前其实每个动态骨骼都是个组件,数据在内存里面都是分散的。每根骨骼更新的时候都需要从不同的内存位置来拿数据,所以Cache Missing非常严重。
用Job的话,可以把游戏中所有动态骨骼数据都存在一个连续的数据中,Job就会逐个处理数字中的数据。因为数字的内存是连续的,可以大大地降低Cache Missing,提高数据访问的性能。
第二点就尽量减少Job的数量,Schedule也需要时间开销。我们曾经拆分了很多的Job,最后发现Schedule的时间也需要很多,有点得不偿失。
第三点是尽可能减少数据拷贝,可拆分动态数据和静态数据。前面也看到了Transform相关的Job拆分到Job之后,所有的数据在主线程做的事情非常少,经验、核心的逻辑和计算都放到了Job上进行,主线程只剩下更新数据,并且把更新数据到Job,把Job推向work线程。但是,在动态骨骼上的一些数据更新还是比较频繁的。
Job只支持Structs数据类型,没办法使用Calsses,就是它只支持子类型,不能支持引用类型。Structs类型的数组没办法单独修改元素中某一项属性,类似于修改Transform.Position一样,只能整体布置。比如直接修改,比如Transform.Position.x,这是没有效果的,因为position返回的是一个Structs。
所以,我们这里只能用Structs存储数据,并且它还需要存在数据中。Structs数据结构如果全部塞在一起,结构是很大的,因为前面各种参数数据量非常大,频繁复制、拷贝需要很多时间,而且这个是实打实的在主线程需要做的事情。
所以,这个就需要做到数据分离,拆动态数据和静态数据,动态数据更新的数据尽可能地少一些,静态数据基本上都不需要更新,就可以大大地提升性能。
这就是我们最核心的一些加速手段。
最后看一下整个Job优化后数据的总结。
假设原版的数据是100%,我们做第一版优化之后能减35%,最后通过Job优化相对原版可以减90%的优化。减90%的话还是非常明显的,它意味着优化前和优化后有10倍的性能差异。
今天的分享就到这儿,谢谢大家。
元宇宙数字产业服务平台
下载「陀螺科技」APP,获取前沿深度元宇宙讯息
110777025(手游交流群)
108587679(求职招聘群)
228523944(手游运营群)
128609517(手游发行群)