西山居引擎开发苏泰梁:《剑网3:指尖江湖》客户端性能优化方案

2020-12-23 16:19:26

威狐小编:11 月 16 –20 日,中国 Unity 线上技术大会以直播形式召开,为广大开发者带来了一场有关前沿技术和优秀案例的线上盛会。在11月20日晚的游戏专场中,来自西山居的资深引擎开发工程师苏泰梁,为广大开发者详细讲解了《剑网3:指尖江湖》游戏客户端开发中所运用到的动态骨骼技术,以及性能优化方案。


以下是演讲实录:

苏泰梁:大家好,我先做一下自我介绍,我叫苏泰梁,来自西山居,现在主要负责《剑网3:指尖江湖》优化方面的工作,非常荣幸今天有机会来到Unity的线上技术大会分享。

今天主要跟大家分享的是在指尖江湖项目上的优化案例,动态骨骼DynamicBone的优化。

我们先来看看什么是动态骨骼?这里我们可以看两个视频。这是我录制的指尖江湖创建角色的视频,这是藏剑山庄门派的庄主叶英,大家可以关注一下他的衣服、头发和摆动效果和袖子,这个阶段我拖拽角色左右晃动一下,可以看到在整个过程中叶英的头发、衣服的摆动都是比较真实、自然的,这些地方都用了动态骨骼的效果,而且基本都是动态骨骼实时模拟的效果而不是美术K出来的动画。

当然,动态骨骼和动作的融合也非常好,比如这段的动作把叶英的衣服吹起来,效果也还可以。接下来我们再看另外一段视频,对比一下叶英的衣服、头发是怎样的。在这个视频中,我禁用了所有动态骨骼的效果,相信大家应该能看出差异,在整个转动的过程中,他的头发、衣服都是硬梆梆的,没有动态骨骼乐观模拟,这个效果就非常差,非常僵硬。

好,什么是动态骨骼,动态骨骼其实是一款名叫DynamicBone的插件,它一般用来模拟飘带、衣袖、裙摆、头发等的摆动效果,效果还是比较逼真的,可以大大节省动作K帧的工作量。在指尖江湖用的比较普遍,比如说玩家、NBC、坐骑、各种趣物、挂件都会用上,是一个用得非常广的功能。

右图是指尖的一个角色姬别情,我把它身上用到动态骨骼的地方都用数字标识了出来,1、2、3、4,有的长,有的短,都有动态的效果。

我们先感受一下动态骨骼的用法,假设我们要给姬别情最长的飘带加上动态骨骼效果,这时候我们先找到这根飘带的根结点的位置,找到它在对象树上的根结点,然后把它拖到动态骨骼的组件的节点上,再配置一下参数就可以了,非常简单。

下面有很多参数,比如说阻尼系数,有弹性系数,干性系数,惯性系数等等很多。阻尼的话可以理解类似一种阻力或者摩擦力,它会减少速度,弹性的话它会把你拉扯到一个目标位置,各种参数很多,感兴趣的同学可以自己研究一下。

好,我们了解了动态骨骼的用法,再来看看简化版的模拟过程,动态骨骼在整个过程中它的简化的模拟流程是怎样的。

我们还是以飘带为例假设它有5个节点,在上一帧它是处于垂直的状态,然后在当前帧它稍微往右移了一点,当然它还有一点点的偏移、旋转,一般都是由于模型的位移或者动作带来的。除了根结点,其他的节点都会从上一帧的位置模拟,然后根据每个节点的参数,比如前面提到的各种惯性系数、弹性系数、各种参数,会对每个节点进行相关的模拟运算,然后得出一个最佳的位置。

最后,会把这个位置更新到每个骨骼节点上,同时根据父子节点的关系、位置,最新的位置,然后来修正这个旋转,这样的话整体上看起来非常自然。

我们再来看看每根骨骼在这个过程中要做的事情。核心的代码主要集中在组件的Update和LateUpdate中。在Update中,它需要对每根骨骼重置一下它的位置,在LateUpdate中要做大量的模拟运算,并且最终会设置到骨骼,前面也提到。这里面列出了完整的一个模拟运算。除了前面提到的阻尼、弹性之类的,还有风力、重力各种模拟运算,印刷量是比较大的。

我们假设场景里面有30个角色模型,每个模型有10条骨骼链,每条骨骼链有10根骨骼,那一共有3000根骨骼,在指尖江湖里20个玩家再加上坐骑、NBC之类的,3000根骨骼,这个数量比较正常。

这么多根骨骼,每根骨骼还要做这么多事情,性能怎么样?我们可以先看看优化前的数据。

这是小米Max2在组成动态骨骼中的CPU消耗,它占了CPU消耗大概10%的占比,这是一个非常大的开销。这个模块占了总CPU消耗的十分之一,这确实是非常大的开销。

动态骨骼为什么会这么耗?前面提到数量非常多,一共可能有3000根的骨骼。然后它的运算很复杂,需要做大量的模拟运算。值得一提的是,在整个模拟运算的过程中每根骨骼都要进行世界坐标、世界旋转、世界矩阵等世界变化的获取和设置等操作。

说到世界变化相关的操作,在Unity里面要特别注意,因为这是一个非常耗时的操作。比如说获取和设置世界变化在Unity的顶层并没有世界坐标的属性,每次只有局部坐标的属性。所以,每次获取或者设置都是根据父子节点,一层一层往上变,所以说整个过程非常耗时。

在《指尖江湖》里面,很多骨骼的层数都是非常深的,比如这张图。

我看了一下最深大概有20层,这里面确实挂了动态骨骼的效果,所以它的层级很深,在计算世界坐标、旋转的过程中开销是非常大的,层级越深,消耗就会越大。

最后一点就是它的模拟是在Update和LateUpdate中完成的,每帧都需要做,也就是说这是一个固定的常态性能开销。

我们了解了它为什么这么耗,现在介绍一下我们做的动态骨骼第一版的优化。可以先看看下面这三点,这是我们做优化的过程中经常提起的一个三原则,第一个是优先考虑,能否不做了,吃力不讨好,白白浪费工作量的事情最是不应该的,尤其是在性能上。如果不做也能达到效果,那肯定是最好的优化,都不做了,那基本上是什么优化都没有,什么开销都没有。还是得做的话,再考虑能否少做一些。最后不得不做的时候再考虑能否做得更好,这个可能有点抽象,我们还是具体分析一下。

下面看看《指尖江湖》的这张姬别情的图,我在1、2、3、4上面再用红色标出这个骨骼的长度,这个大小下我觉得四根骨骼链大家都会看得比较清晰。

好,我们再看这张图,这张图很小。

4的话这根飘带非常长,看得还是比较清晰的,但是1和2是不是已经看不大清楚了,尤其是骨骼2我用线连起来,标出来了,其实这个时候它已经很短,在这种情况下,即使生硬一点,我估计看不大出来了,尤其是在手机上就更小。所以就有了我们第一版的优化思路,根据骨骼链屏幕的投影程度,过短的骨骼链就直接关闭动态骨骼的效果。

投影长度可以使用骨骼链静态的长度,再加上游戏的FOV,一般动态变化比较少。所以计算它的屏幕投影长度可以做到几乎没有消耗。

一般手机的宽度是70毫米,大概在2毫米以下都看不大清楚。长度还可以根据机型、机器情况和性能情况分不同的画质进行定制,当然还可以实时地跟进不同的压力情况,实时调整。

来能否少做呢?我们还是利用投影长度,可以把适中的骨骼,比如说1或者3的骨骼在某些情况下只保持最基础刚性的运算,保留最基本的效果。

最后,再考虑能否做得更好。到了这步就只能是死磕算法了,在算法层面进行优化,尽可能地减少消耗。这里我们使用的局部坐标,减少世界坐标的操作,因为前面提到了世界坐标的操作是非常耗时的操作,使用一些Catche来减少重复的运算。最后就是减少Component的数量,一个角色、一个组件就可以支持多条骨骼链的配置,因为Component的数量多存在一些常态开销,这就是我们第一版的优化。

我们来看看优化后的数据,效果还是比较明显的,开销从10%直接降到6.5%,优化了大概35%的CPU开销。这里在总占比中有大概3.5%的开销,我觉得是比较可观的。

不过,6.5%的CPU开销感觉还是挺多,细心的同学我感觉已经注意到了,这是第一版优化。既然有第一版,那我们就有第二版,接下来再继续介绍一下我们第二版的优化。

第二版的优化,能否再进一步优化?最好是不做,或者是少做。这里就需要提到一个概念,就是Unity Job System,这是Unity提供的一套多线程编程框架。我们第二版优化的核心思想就是使用多线程,尽可能地减少主线程做的事情,让别人来干活。

那Job System是Unity提供的一套多线程编程框架,跟一般的多线程有什么不同?为什么Unity需要额外提供一套多线程框架?这个问题在Unity中写过多线程的人可能都遇到过一个坑,一般的线程没办法操作Unity对象,这是Unity的强制限制了。Unity会报错,并且告诉你说这个只能在主线程访问,直到Job System出现,才使得这成为可能。虽然说现在局限挺大,但是起码有可能。这就是第一个不同,它使得多线程中操作Unity对象成为可能。

第二个,它有强大的线程安全检测机制,这个非常重要。比如主线程和Job线程之间一些数据的读写安全问题,有强大的检测机制,保证的数据不会写坏。

学过多线程的人我估计都清楚,线程数据安全是一个非常头疼的事情,比如C++里面如果一块数据在多线程里被写坏了,它可能不会立即出现问题,不知道跑到什么时候突然就宕机了,这个时候你再查就非常困难,因为它已经不是第一现场,很早之前就已经被写坏了,这是一个非常头疼的事情。但是在Job System上可能就不存在这个问题,它的安全检测机制非常简单。

第三点,它还有非常高的性能,可以充分地利用多核的CPU。Job System跟Unity引擎顶层的C++共享work线程池,work线程池会通过一个Job队列来减少上下文的切换和竞争问题,这样就可以充分地利用多核CPU的资源,从而提高性能。当然,它还有别的优势,我们后面再继续介绍。

接下来感受一下Job System简单的用法。在这里,并行计算两个数据原始物中的一个值相加,然后复制到另外一个数据。首先我们要生成一个Job,然后集成Unity I Job相关的接口,只用特定的数据结构证明自己的数据,就可以在Execute函数中写需要在Job中运行的逻辑。这个例子也非常简单,Execute里面就是把A和B的数据元素直接相加,然后复制到C的数据中。

最后,写的Job是可以通过Schedule这个函数把自己的Job推到work线程进行执行,使用上非常简单和直观。

我们再来看看动态骨骼Job化的示意。这个是简化后伪代码,是按原算法直接转化后的一个示意。我这里会将耗时的所有操作都Job化。比如Update中的InitTransform操作,它会需要从这几步坐标。还有就是lateupdate中的各种模拟运算,它对应的就是update particle1函数,update particle2、ApplyParticles ToTransforms,它都会提取到对应的Job中。

但实际上直接转化成Job是存在不少问题的。我们在直接转换的基础上做了很多的加速优化,这个后续会有介绍。

先来看看Job优化后的数据,这是经过很多版优化后的数据,相对于上一版的话优化了84%的CPU时间,效果是非常非常明显的。

左边是优化前,右边是优化后的Profiler数据,我现在已经用红线把work线程的执行情况标识出来。我们可以看到左侧优化前的work线程一直处于idol状态,什么事都没做。但是,主线程其实压力是非常大的,因为所有的东西都放在了主线程来做。

右边的话是我们Job化后的情况,可以看到所有的Job都在紧密地连接,紧密地执行,而且很好地分布到了所有work线程,充分利用了多核的并行加速的效果。

当然,这两幅图的时间轴单位是不一样的,右侧是我们不断地放大后的数据,主要是为了让大家看清楚Job的执行情况。实际上相对左图的话,如果按单位来算,大概只有1毫米那么宽,大概是0.15毫秒左右。

这个是我们在原版直接转化到Job的基础上经过多版优化后的数据。我们用到了一些比较关键的加速手段。接下来给大家介绍一下最关键的几个加速手段。

第一个,让Job真正并行起来。这个不知道大家看到后有什么感觉,为什么说让Job真正并行起来呢?难道Job不是并行吗?我们先看一段我们曾经一版的数据。我们这一版数据说实话优化完后发现优化后的数据比优化前的数据还差。大家可以看到上面是主线程,下面是Job线程,主线程一直在等待Job线程执行。为什么?这是因为Unity有一个问题,Transform相关的Job只要Transform都在同一个根结点下,它都是没办法进行的。这是所有Transform Job绕不开的一个话题。

可能这里有点抽象,我们再看一下我们的使用情况。我们所有的player都放在了一个PlayerSet的分节点上,在同一个根结点下的对象Transform,它们之间的操作都是不能并行的,所以即使我们每个player对应一个Job,但是实际上跑起来是没办法并行的,因为他们都在同一个根结点上。

这会带来什么问题呢?主要有两个问题。一个是主线程跟Transform Job是没办法并行的,会出现Wait的情况。对应到左下图,可以看到红框1里的函数就是WaitForJob GroupID。这个是因为主线程中有一个跟Transform相关的操作,大家应该可以看到这上面Transform.Get_hasChange。它的核心就是因为他们这个操作的Transform和Job中的Transform是属于一个根结点,这个时候主线程就需要等待相关的Job线程,相当于没有在并行,因为主线程一直在等待。

第二个问题就是Transform Job之间也是没办法并行的。即使你把Transform相关的几个Job推到Job线程来执行,但是Job线程之间的Transform如果还是属于一个根结点,它们自己也没有办法并行。

对应到左下图红框2的Profiler数据,这一段数据大家可以看到其实它只在一个Job线程中执行,并没有像我们前面看到的Profiler数据一样会分散到所有的work线程进行执行,还是只在一个Job线程上执行,就是它没有充分利用多核的并行,相当于是一个单线程的Job,这样效率是非常低的。

为什么会有这个限制呢?实际上父子节点Transform操作存在关联性,前面提到了Transform的世界坐标、操作指令,所以说它可能存在一些安全问题,这样的话这个线程还是比较能理解。但是非父子关系的节点,我猜也许为了逻辑统一和简单。

那怎么样让Transform Job并行起来?其实方法也很简单,我们可以将所有的模型都评估到顶层,比如我们可以把PlayerSet去掉,直接把player全部平铺到顶层,这样所有的player之间都可以并行。但是,一个player可能会有十多根骨骼链,每根骨骼链上面还有十几个节点,这些都没办法并行。当然,我们还可以再进一步,将每条骨骼链平铺到顶层,比如说player有个尾巴,我直接把这条尾巴拉到顶层。这样的话,如果它有多条骨骼链,所有的骨骼链之间每个角色都是可以运行的,当然极限情况是我们还可以把这个骨骼链中的每个节点都平铺到顶层,这样的话所有的骨骼节点在整个过程中都是可以并行。

这很完美,但骨骼平铺可能会导致关联的骨骼动作失效。这是什么问题,什么意思?在某些情况下,动作就会失效,没动作。为什么会出现关联的动作失效的问题?我们先来看看这幅图。

左边是角色的对象数,右边是一个动作文件。这个动作文件它绑定的tail4、tail5这两根骨骼。Unity在Animator中会根据动作文件中对应的每根骨骼的名字在左边的对象数中进行查找,然后一层一层往上找,找到对应的节点就可以绑定成功了,如果没有找到,它就绑定失败。

当然,实际上是用的名字的哈修,效率肯定会高不少。然后Animator在每次Enable都会做一次绑定,还有一些别的情况下也会触发绑定。所以说如果在触发绑定的时候把左边的节点移走,这个时候动作的绑定可能就会找不到而失败,动作就没有效果。

那怎么解决?我们通过源码发现可以在引擎顶层加用Catche或者是指定映射关系来解决。什么意思,它还是通过左侧对象数查找的时候可以优先使用映射的关系或者是Cache中的数据,这样的话即使我们把它的骨骼链或者节点平铺到顶层,这个时候它重新绑定不会有任何的影响。

当然最后哪一种平铺策略更合适?具体还是根据实际情况定的。在《指尖江湖》模型级别的平铺并行效果已经非常好。当然,这跟后续的各种加速手段的优化也是分不开的。

接下来我们继续介绍下一个加速手段。

这里再介绍让Job真正并行起来的另外两个非常重要的加速手段。我们可以先看看左侧两个Job代码。上面的Job代码是从Transform中获取局部坐标和局部旋转的代码。下面那个Job代码是局部坐标和局部旋转映射回Transform中,这两个Transform就是我们跟Transform相关的Job的代码,已经优化到了一个极其简单的程度。

我们为什么要把这两个Transform相关的代码优化到这么简单?前面提到Transform Job在相同的root点下是没办法并行的,很容易跟主线程出现一些wait,所以我们减少Transform相关Job的逻辑,这个时候它跟主线程出现wait的可能性就会降低。当然,即使出现wait,可能wait的时间也不会太长,因为它非常简单,执行起来非常快。

还有就是非Transform相关的Job是没有Transform并行的限制,这样我们就可以把大量的逻辑,几乎所有的逻辑全部移出来,然后放到非Transform一般的Job中执行,一般的Job没有这个限制,它可以充分地利用work多核的性能,然后充分地并行,这样它就会大大提速,因为它没有跟Transform或者是root节点的限制,这就是我们为什么要把这个东西单独拎出来并且把它简化到极致。

当然,这里面可能细心的同学还注意到设置的时候最后把它设置到Transform也是用了局部坐标,其实在前面也介绍过,官方在整个代码中用了大量的世界坐标、世界旋转、世界矩阵的获取和设置的操作。其实这些都是非常耗时的操作,我们只获取了设置局部坐标和旋转。我们也对比过相应的性能,世界坐标跟局部坐标设置的对比,我们对比了两者的性能,局部坐标大概有80%以上的性能的提升,提升效果非常明显。

我们再看看右侧的加速手段。这里主要是利用脚本的执行顺序可以做到一些真正并行加速的效果。比如上面的1就是让Job在所有脚本中开始执行,这样的话,这个Job线程就可以跟主线程有最大的并行的时间,横宽的话就是update相关的Job,把它放到了所有脚本的最前面。

另外一个我们可以通过脚本执行顺序决定Job具体和哪些脚本或者哪些模块并行。这样有什么好处?我们可以巧妙地避开这个wait。比如这里下面的红框我把动态骨骼放在了UI Pannel的前面,用过NGY的人都知道UI Pannel是NGY中管理所有UI组件的一个模块,在它的lateupdate中会做大量的核批和填充的操作,这些操作非常耗时。但在这个过程中它肯定没有用到动态骨骼的效果,基本上不会在界面上画动态骨骼的效果。

它也是一个非常耗时的操作,这样动态骨骼跟它并行的话就可以完美地错开。因为它不用到动态骨骼的组件,并且它在我们游戏中又是独立的根结点,不会跟别的玩家或者是NPC组一个同样的根结点,这样它就可以完美地跟DynamicBone进行并行,不出现wait。这些加速就可以大大地缓解Transform Job可能出现的wait的情况。

接下来我们再介绍一下加速手段2,它可以大大提速Job的代码效率。这里的核心是使用Burst Compiler和Mathematics数学库的加速。Burst是Unity的一个代码编译优化工具,它可以针对目标机器进行专门的优化。数学库的话它是支持SMD的加速,SMD就是一条指令可以同时操作多个数据。一般一条指令只操作一个数据,它因为可以同时操作多个数据,那在3D运算中,比如向量或者矩阵运算的加速效果非常明显。

我们看一下两者数据的差别,上图是没有开Burst的效果,下面是开了Burst的效果。这里的提升是不是非常的夸张,我第一次也被震惊了,这里起码50倍以上的性能差异,提升效果非常明显。

前面我们看数据实际上给过一张图,其实是下面这张图放大后的效果,它确实只有这么一点点,当然这完全得益于这两者的加速,如果没有这两者的加速,前面即使搞定了并行,估计Job线程还是算不出来,可能主线程还需要一些等待。

使用上非常简单,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%的优化。这个效果还是非常明显的,它意味着优化前和优化后有10倍的性能差异,效果很好。但实际上这一版数据是直接跳过了第一版优化的数据。

实际上我们这版数据还没有考虑到屏幕的裁减等优化的数据,当然结合第一版优化的数据,我们在游戏中已经有了,但是由于时间关系,这个数据没有准备上。

最后,我们再来回顾一下今天讲的内容。这里讲了DynamicBone两版的优化,第一版优化主要是常规的优化手段,根据骨骼链的屏幕投影长度来决定它是关闭还是减少运算。第二是在算法上优化,局部坐标替代世界坐标或者是加入Cache等等。

第二版优化主要是使用多线程,使用Job System,然后可以通过平铺顶层来解决Transform Job无法并行的问题。另外就是可以简化和拆分Job和脚本的执行顺序,解决Job的并行问题。另外,我们可以通过Burst Compiler和Mathematics数学库进行加速。最后是介绍了面向数据的设计和节省Job输入量,还有动静分离的数据。

今天的分享就到这,谢谢大家,大家有任何问题可以在评论区提问和讨论。

404726