Animation Instancing 深度解析 -- Unite 2017 金晓宇 分享实录
关于Unite
Unite大会是由Unity举办的全球开发者大会,至今已有10年的历史。Unite现已成为游戏行业,VR/AR行业中最具有权威性和影响力的活动。
大家好,我叫金晓宇,是来自Unity的技术支持部门,很高兴大家来参加这次的演讲。我今天的演讲的主题是Animation Instancing。
在我们开发中经常会遇到类似这种问题,我们要在场景中比如说一个体育场中来模拟大量的观众,或者来模拟上千的僵尸在街道中游荡。对于这种需求,如果用传统的方式,cpu的开销是非常大的。即使他们播放的是同一个动画,它最终提高的CPU的数据是不同的。而使用Animation Instancing可以为我们带来更高的效率。这是我使用Animation Instancing来做的一个演示。大家可以看到场景中的角色是非常非常多的,而且仔细观察的话你可以看到这几个场景中是有不同的角色,而且角色也会播放不同的动画,并且处于不同的动画帧中。我做的这个测试所用的平台是在PC上,CPU是i-7-6700K,GPU是GTX 980Ti,有三万个角色,每角色500至1000面帧数稳定60。如果使用传统的方式放置2000个角色大概只有10帧左右了。我做这个测试是在比较强劲的平台上做的,如果换到这台电脑上,它的GPU是MX370X,同样的机器上使用传统动画的方式,放置1000个角色帧数只有10帧左右。可见,无论是高性能的平台还是低性能的平台,Animation Instancing都能够为我们带来性能上的可观的提升。
在介绍Animation Instancing之前,我想简单介绍一下GPU Instancing。Unity在5.4开始支持GPU Instancing,它可以使用一个draw call绘制多个Geometry相同的物体,这是一个大批量绘制相同物体的渲染的技术。如果你的场景中有这种类似情况,可能为我们带来性能上的提升。一个是有大量材质和相同mesh的物体。另一个是性能受制于过多的draw call,GPU Instancing比较适合用来优化的是植被系统,因为植被系统会有大量的草、树木,可以大大降低我们的draw call。因为是不同的物体,它们都有自己的独有属性,这样GPU可以通过一次draw call渲染在不同位置的物体。我们都知道GPUInstancing有一个限制,就是它是不支持带有骨骼动画的mesh的。这是为什么呢?其实原因也很简单,因为我们带有骨骼动画的mesh他们在最终通过pose的施加,它的最终顶点数据是不一样的。即使是同一个动画,因为处在不同的动画的帧数中,我们无法通过一次提交把这些物体多次画出来。
Animation Instancing可以是带有骨骼动画的mesh。既然它可以看作是一种GPU Instancing的扩展,我们如何来改进GPU Instancing来实现呢?熟悉骨骼动画的朋友应该知道,动画在每一帧对于每个角色输出的pose只有一个pose,无论通过blendtree还是ik的混合,最终只会有一个pose来施加给mesh。什么时候这个mesh都是一样的呢?也就是说如果是角色的话,它处于T-pose的状态。我们的动画怎么办?因为每个角色可能播着不同的动画或者在不同的帧数下,我们需要把这些数据一起提交给GPU,通过一个buffer来访问。也就是说相比GPU Instancing,我们独有属性还包括这些动画的独有pose。通过以上分析我们就可以找出我们需要的数据。对于我们的顶点数据除了我们写shaders所用的vertexbuffer用的位置、法线,除此之外我们还需要骨骼的索引和权重。这里骨骼索引代表了顶点受哪些骨骼的影响以及权重。另一个独有的数据,我们还需要一些骨骼的动画数据,骨骼的动画索引,还有动画的帧索引。这是为了来区别我们的角色可以播放不同的动画,以及处于不同的动画帧上面。因为Unity最大支持每顶点受四个骨骼的影响,所以我们也要支持每顶点最大四骨骼。这里是有优化空间的,特别是加入LOD之后,那些离camera非常远的物体,没必要让它受每顶点四个骨骼影响,比如我们可以让它只受一骨骼的影响。虽然这会使动画不太精确,但是可以接受的,因为离camera比较远,反正也看不清了。另外我们需要的独有数据,Instancing数据包含了有我们的世界矩阵,以及动画索引以及帧索引。大家注意看的话可以看到这里面有一些pad来对齐的,而对齐与不对齐对于性能的影响是非常大的。
我们还需要一个最终的骨骼数据。之前的顶点数据和Instance数据我们可以通过constant buffer或者compute buffer来实现。骨骼数据我选用的是Animation Texture来存储。我们平常都会使用到LOD。LOD会为我们带来性能的提升,在场景中我们会有很多物体或者是角色离camera非常远,对于这种情况他们是没必要使用高精度的模型的。没有LOD的时候,我们的顶点数据通常只有一个,如果他们的material只有一个的话,加入了LOD之后,需要每一个LOD层级输出一个顶点数据。也就是像图上这样,每一个LOD层级都有一个Instance。这样可以动态地根据camera的距离来切换Instance了。因为我们的场景中通常都会有很多角色,因此为了节省CPU的资源,这个过程也没有必要每一帧去进行,我们可以隔10帧或者20帧来计算一次就足够了。LOD会为我们带来哪些好处呢?首先很直观地可以降低我们的渲染的面数,这会为我们带来额外的好处。因为我们要处理的顶点数少了,我们蒙皮的计算量也相应地减少了。而另一个好处是,它可以为我们带来更好的overdraw,这是为什么呢?一个高精度的mesh如果离camera非常远的话会有更多的机率集中在一个像素,这会造成大量的overdraw,而使用一个低精度的模型可以大大降低这种集中在一起的概率。
我们Animation Texture中存储的是我们所需要的pose,是以四个像素来组成一个4×4的矩阵,它有这么多个像素空间,一般会把它们组织成一个方块或者是矩形。这个width和height如何来确定呢?如果一个角色50根骨骼,它所占的像素数应该是200,50×4。我们的width和height可以是20x10 或者是10x20,哪一个更好呢?这里有一个小技巧,我们要确保这个矩阵都处在同一行中,这样可以大大地减少我们的计算量。所以说,20×10是一个更好的选择。如果是那些质数个的骨骼,我们可以开大一点的width和height,确保width可以被4整除。我们选择的纹理格式是RGBAHalf,这有什么原因呢?首先是精度不够,另外一个,我们存储的矩阵数据有可能是大于一的,或者甚至是负值,所以我们选择了RGBAHalf,而它的精度是足够的。这张Filter Mode我选择了Point,像其他的模式,会采样到其他的像素,这是我们不想要的。我们也不需要mipmap。
由于我们需要在运行时需要这张Texture,所以可以事先烘焙下来,其实烘焙的过程也是比较简单的。首先需要搜索模型的动画,可以根据和模型相关的动画列在一个表里。并且可以根据我们想要的帧数来采用这个动画。我们需要对当前这个动画计算它的蒙皮矩阵,最后要把蒙皮矩阵转换为颜色存储在这张texture上。
接下来我们来重点看一下如何来计算蒙皮矩阵。首先我们要获取Bindpose和Bonepose,一个是绑定姿势,一个是当前姿势。我们可以meshSkinnedRenderer当中取出来。是这样,因为每个角色它的Bindpose都是一样的,不管播什么动画,所以Bindpose取一次就可以了。而Bonepose是根据动画的每一帧都需要采样的。之后根据蒙皮矩阵的计算公式把蒙皮矩阵计算出来。这里的k就代表了蒙皮矩阵,而B就是绑定姿势,C是当前姿势,小j是关节空间,M是模型空间。
如公式所示,大家仔细看代码,我在公式里面还乘了rootMatrix,这是因为当前姿势是处于世界坐标系下的,我们需要把它转化为模型空间下。
之后我们就可以来提交渲染了有了这些数据之后。这里简单介绍几个方法。一个是Material的SetBuffer,就是我们的顶点数据和我们的Instance数据。另一个我会用到DrawProcedural,它的作用比较方便灵活一些,通过你的顶点数和Instance数可以画出你想要的模型。这个相对于第一场我们同事介绍的那个darwmeshinstanced那个接口来说会更加方便一些。
最后我们需要计算蒙皮,首先我们需要根据动画索引,动画所处帧以及骨骼索引来计算动画数据的uv,这是一个反向的操作。你是怎么来把它烘焙在texture上,你怎么取出来就可以了。之后采样这张texture,我们需要使用到tex2Dlod来采样这张texture。采样的时候,有一个小优化,虽然我们存储的矩阵是4×4的,但是最后一维始终是(0,0,0,1),所以我们最后可以把这个省略掉,这样每个矩阵只要采样三次就可以了。最后我们需要根据权重W来计算顶点位置,最后的顶点位置就是顶点蒙皮至每骨骼的加权平均和,就是这个公式所表达的意思。
使用Animation Instancing是有很多优点的,首先它避免了骨骼动画的计算开销。我们都知道Animaor Update的开销是比较大的。我们通过bake来避免了它的开销。其次我们是用GPU计算蒙皮,这节省了在CPU计算蒙皮的开销。具体到Unity当中,我们实际上节省了meshskinning.Update的开销。gpu特别适合这种大量并行的运算。我们还通过它来降低CPU到GPU的传输,也就是降低了draw call。而它特别适合于大规模群体动画的模拟场景,比如说战争中的军队,体育场中的观众等等。
刚才吹了很多Animation Instancing的优点,下面来说一下它的局限性,虽然在效率上的提升是非常可观的,但是也有很多的缺点。首先它无法使用blend tree,blend tree是在运行时通过参数来混合不同动画。而事先bake动画就失去了这种灵活性和多样性。基于同样的原因,它也是不支持的IK的。最后它也会使动画的精确性降低,本质上讲是他们的差值方式引起的。怎么说呢?因为我们都知道我们在动画去做差值的时候,我们是用四元数差值,而我们在texture存储的是矩阵,所以我们只能使用一个顶点的线性差值。如果你烘焙的帧数比较少的话,在两帧之间差值可能会出现使动画看上去比较奇怪的效果。但是我们可以通过多烘焙动画帧数来改善这种缺陷。
最后我们来总结一下Animation Instancing的一些要点。它可以看作是GPU Instancing的一种扩展。我们要传给vertex shaderd顶点buffer是角色处于T-Pose状态的mesh数据。而独有数据除了世界矩阵之外还需要动画信息,动画数据可以事先bake到texture上。我们有一种做法是不使用bake texture这种方式。在性能上的提升只有节省从GPU到CPU传输的效率的提升了,不过带来的好处是我们可以继续使用blender tree和其他一些动画系统。最后需要在vertex shader来计算蒙皮。当前以RTS MMO为代表的游戏使用这种大规模群体的角色已经越来越多了,而宏大的场景可以为我们玩家带来一个更深刻的体验。而Animation Instancing就是非常适合来模拟大规模群体动画的一种技术。