基于Animation Instancing的人群模拟

此文源于我在Unite 2017上的演讲,首发于Unity公众号上,现修改更新后转载于此。


在我们的实际开发中,可能经常会遇到类似需求:一个体育场中有大量的观众,或者有成百上千的僵尸在街道上游荡。对于这些类似的需求,如果用传统动画方式的话,cpu计算骨骼和蒙皮的压力会非常大。因为每一个角色都要有一个骨骼和蒙皮的计算过程,即使他们都播放的是同一个动画;除此之外,每个角色都至少有一个drawcall,频繁的向GPU提交数据也为CPU带来了更多的压力。

这是运用Animation Instancing的一个场景截图,这个画面中大约有30000个角色,每个角色有1000——2000面,帧数可以稳定在60帧。Animation Instancing对于骨骼动画的限制较小,场景中的角色类型丰富,动画多样,是十分适合模拟大规模群体动画的。


分析

本质上讲,Animation Instancing可以看做是GPU Instancing的扩展。因为他们的基本原理是相同的,只是在实现方法和细节上有所区别。我们都知道,GPU Instancing不支持带有骨骼动画的mesh。这是因为带有骨骼动画的mesh由于骨骼驱动蒙皮的原因其顶点数据(坐标)是不同的。

如果你熟悉骨骼动画的话应该知道,动画在某一帧输出的最终Pose只有一个。然后通过这个Pose计算蒙皮,来确定最终的顶点位置。那么什么时候顶点位置是一样的呢?当然是没有动画的时候,也就是没有施加Pose的时候。即是处于绑定姿势(T-Pose)状态下的mesh。因此我们很自然的就可以想到,我们所需要的顶点数据就是处于T-Pose下的数据。那么现在还有一个问题,我们的动画该怎么处理呢?因为我们需要动画的Pose来计算蒙皮。如果我们能把Pose当做物体的独有数据提交给GPU,就可以在GPU计算蒙皮了,并且我们只需要提交给GPU一次就可以画出多个角色来了。

通过上面的分析,我们就可以组织我们所需要的数据了。

  • 顶点数据:角色处于绑定姿势的顶点数据,以及骨骼索引及其权重
    • 这些额外的顶点数据可以存储在mesh的color以及uv2通道中
  • 角色的独有数据:世界矩阵、骨骼动画数据、动画索引、当前动画播放时间(normalized time)


LOD

由于我们需要同屏绘制大量角色,所以LOD会为我们带来很可观的性能提升。这是因为通常场景中会有很多角色离camera比较远,因此这些角色没有必要使用高精度的模型,根据与camera的距离,可以动态的切换不同精度的模型。

而LOD应用在Animation Instancing上也是非常简单的。在不使用LOD时,角色只有一个顶点数据(如果这个角色只有一个材质的话)。

如果有LOD的话,只需要为每一个lod等级生成一个顶点数据。再动态的根据离camera的距离来切换顶点数据。这个过程是在CPU完成的,为了节省CPU资源,这个过程是不需要每帧进行的,比如我们可以每20帧来进行一次计算。


那么LOD会带来哪些好处呢?

首先它可以降低渲染的面数。同时这会为我们带来一个额外的好处:降低我们蒙皮的计算量。因为我们是在GPU计算蒙皮的,顶点数少了,自然蒙皮的计算量也少了。

其次它可以降低overdraw。这是因为距离越远,一个高精度的mesh就有更多的顶点集中在一个像素中,这会造成大量的overdraw,而一个低精度的mesh就可以大大降低这种可能性。


Animation Texture

为了节省在运行时计算骨骼的开销,我们可以把动画数据烘焙到texture上。之后在运行时就可以采样这张texture来获取到所需要的动画数据了。

这张texture的结构大致如下图所示:

它由4个像素作为一个矩阵。一个pose由n个矩阵(等于角色骨骼数)组成。


提交渲染

之后我们需要一个方法把我们的数据提交给GPU,这里我用了Graphics.DrawMeshInstancedIndirect()方法。注意其第4个参数Bounds,我们需要设置的足够大,因为我们会通过culling group来做剔除;而如果要用这个方法来剔除的话,需要我们来做场景规划,然后根据相近的instance分批,个人觉得这样做太麻烦了。

由于上个方法用到了compute buffer,而移动平台对于compute buffer支持不是太好,所以针对移动平台,我使用了Graphics.DrawMeshInstanced()。如此可以完全来用constant buffer来存储我们想要的数据。


GPU Skinning

最终我们需要在GPU来计算蒙皮,通过采样animation texture可以获取到我们所需要的蒙皮矩阵,再根据当前顶点所受影响的骨骼来计算蒙皮。如下面公式所示:

其中K是蒙皮矩阵,B是绑定姿势,C是当前姿势,V是顶点位置,w是骨骼权重。


优化

  • 由于我们要实现成千上万的角色,也就是有成千上万的GameObject,而通常这些GameObject都会有update方法。而这个数量级的方法调用开销已经是不可忽略得了。所以把这些GameObject的更新方法放在一个manager中来调用是十分必要的。其次还需要特别注意struct的拷贝开销。
  • Unity支持最大每顶点4根骨骼,因此我们也要支持每顶点最大4根骨骼。这里是有优化空间的,特别是加入LOD之后,对于那些离camera非常远的角色,我们可以适当减少每顶点受影响的骨骼数目。比如离camera非常远的角色我们让它每顶点只受一根骨骼影响,这虽然会使动画不太精确,但是实际影响不大。
  • 在采样texture时,虽然我们是用4个像素组成一个矩阵,但是矩阵的最后一维始终是(0,0,0,1),所以最后一维的采样是可以省略的。这可以使我们每根骨骼少采样一次。
  • 场景中的角色通常都会很多,但是某一时刻在我们相机视野中的角色却有可能只有一部分,但是我们向gpu提交数据都是成批提交,因此frustum culling就无效了。所以我们需要自己来处理culling。Unity提供了culling group,我们可以使用它来方便的进行culling。
  • 由于移动平台对于compute buffer的支持不好,因此在ios和android上推荐用constant buffer来实现


优点

  • 避免骨骼动画的计算开销(Animator.Update())
  • 在GPU计算蒙皮(MeshSkinning.Update())
  • 降低了CPU到GPU频繁的数据传输
  • 适合于大规模群体动画模拟的场景,如战争中的军队、体育场中的观众等


局限性

  • 无法使用blend tree
  • 不支持IK
  • 动画精确性降低

注:如果我们不使用baked动画的话,就没有以上的局限性了。但这也会增加骨骼动画计算开销。并且由于每帧的动画数据比较大,也不太适合移动平台;而在pc上我们通过把动画数据使用compute buffer来存储以使用animator计算动画,如此就可以复用animator的众多特性了。


目前,Animation Instancing还在开发中,未来会以插件的形式先推出。由于还需要支持一些常用的feature,比如root motion, transition等等,所以还需要一段时间。尽情期待。

编辑于 2017-08-03

文章被以下专栏收录