最近我们的任务是制作一个需要实时支持数千个动画角色的,同时还要为场景中的多个动态灯光、密集粒子效果以及照片级环境和角色预留足够的性能。最终我们实现了单一场景中超过20万个动画角色的流畅运行。

1、问题

任何使用过大量骨骼动画组件的人都很可能会遇到性能问题。在撰写本文时,UE4中没有对实例化骨骼网格体的原生支持,因此每个角色至少会产生一个绘制调用的成本。最重要的是,在 CPU 上变形蒙皮几何体本身就比仅仅提供一个良好的老式静态网格体要慢。

因此,当我们开发这个系统时,决定将角色渲染为实例化静态网格体,并使用将动画数据烘焙到纹理并在顶点着色器中对角色进行动画处理的流行技术。

然而,这一决定带来了一些新的问题。传统上,对于骨骼网格体的每一帧动画数据,都要将每个顶点位置烘焙到纹理中。这意味着动画纹理和模型的拓扑之间存在直接关系。

由于我们需要支持多种角色变体,这意味着每个变体都有自己的拓扑,需要为同一动画单独烘焙的顶点位置纹理。

更糟糕的是,每个LOD(细节级别)都需要自己独特的动画纹理。最终,我们得到了 19 个不同的动画循环和 4 个唯一的角色(每个角色都有三个 LOD 状态),因此,如果我们使用这种方法,则需要 228 个唯一的顶点动画纹理。这需要大量的纹理内存,特别是当你考虑到数据需要采用未压缩的HDR格式时,这个问题是在我们考虑使用其他纹理进行动画混合之前,稍后会谈到这一点!

2、解决方案

很明显,我们需要一个更轻量级和灵活的解决方案,支持在具有不同拓扑结构的角色之间共享动画数据。

早在2016年,我们在REWIND上为一个未发布的项目也做了类似的事情,其中角色由刚性的块状关节组成。这使我们能够将每个关节视为刚性对象,因此,我们只需要存储每个关节的位置和旋转值,而不是存储每个顶点的位置值。

对于任何给定的动画,我们将组件位置和每帧每个关节的旋转写入纹理。然后,我们在顶点着色器中对此纹理进行采样,以置换顶点,从而解析每帧的每个姿态。

作为一个前期过程,我们还将角色分成了单独的四肢,并将它们的转换归零。这意味着每个肢体的枢轴与组件的枢轴共享,因此为了解决姿态问题,我们可以围绕组件的枢轴旋转每个肢体,然后将其转换为位置。

以下是角色的几何形状未受干扰的样子...

...下面是如何在顶点着色器中解析姿势的步骤拆解。当然,在实践中,在提供栅格化之前,每个帧都会立即解决此问题。

通过将动画数据与拓扑分离,我们能够将多个网格重新定位到同一个装备,并让它们使用相同的动画数据。

另一个好处是,纹理的垂直度不是由网格中的顶点数定义的,而是由装备中的关节数(乘以2,因为我们需要一个条目来定位和旋转)。几乎可以肯定的是,关节计数将比顶点计数小得多。事实上,这个装备只有16个关节,使动画纹理高32像素,而不是等效的每顶点烘焙的大约2000像素高。

不幸的是,我们负责创建的角色系统不是由方块人体组成的,因此需要调整这个系统以支持更自然的动画角色。

3、为顶点动画构造静态网格体

我们能够通过设置一个约束来轻松地使此工作流程适应我们的需求。支持蒙皮网格的每个顶点的关节影响。

将平移和旋转编码为绑定姿态的增量,并在网格的UV中序列化关节的绑定姿势位置,而不是将动画数据写入组件空间并分离关节,这样会更优雅 - 就像我们对方块人体所做的那样。这意味着我们的网格边界框更能代表我们的角色,而不受干扰的网格只是一个T姿态。

我们编写了一个脚本,用于评估蒙皮网格,并将其所有顶点设置为受单个关节的影响。然后,对于每个顶点,脚本查询单个影响关节在场景空间中的位置,并在给定边界比例的情况下将此位置重新映射到 0 和 1 之间。我们确定的边界尺度为"200",因为它足够大,可以轻松地包含整个网格,但又不会大到引入精度误差的程度。

然后,我们将此位置写入两个 UV 集的 UV 坐标 — 出于本文的目的,让我们将它们称为"A""B"。X 和 Y 场景位置分别写入A UV 集的 U 和 V 坐标,Z 场景位置写入B UV 集的 U 坐标。

额外的好处是,我们还获得了UV视口内联合层次结构的可视化效果!

然后保留 Z UV 集的 V 分量,以便将顶点组放置在其数据行中。

上面的示例显示了位置数据中的顶点索引。要索引到旋转数据中,只需从着色器中的 V 坐标中减去 0.5。

然后,这个自动 Maya 程序可以应用于共享同一装备的任何其他角色,以允许其使用相同的动画数据。

设置了为顶点动画创作静态网格的过程后,我们将注意力转向了将动画数据写入纹理。

4、编写动画循环纹理

我们在UE4中将所有顶点动画纹理生成为离线编辑器程序。我们构建了一个简单的蓝图,可以采用骨骼网格体和动画数组,并为每个动画导出 16 位 HDR。

首先,我们以绑定姿态缓存关节的所有组件空间位置和旋转数据,并启动一个临时渲染目标缓冲区,其尺寸由骨架中的关节数(高度)和动画中的帧数(宽度)决定。

其次,我们按给定的帧速率(在本例中为 30fps)擦除动画数据,并计算每个关节的新组件空间位置和旋转与我们在上一步中缓存的数据之间的增量。

然后,我们将此数据写入渲染目标,其中每行表示一个关节,每列表示一帧动画。旋转数据被写入纹理的上半部分,位置数据被写入底部。将此数据写入单个纹理会使交换动画变得微不足道,因为所有动画数据存储在单个图像中。

我们为动画的每一帧编写了一个新的数据列。用尽所有帧后,我们将渲染目标导出到磁盘。最后,一旦所有的动画都烘焙好了,就能够将它们导入回编辑器中,作为标准的UTexture2D使用。

5、编写顶点动画着色器

一旦我们获得了让角色移动所需的所有数据,就必须编写着色器来适当地解压缩所有内容。

任何玩过Houdini的UE4或Unity顶点动画流程的人可能已经熟悉本节中介绍的大部分概念。

6、动画播放

我们通过标量参数将动画位置馈送到材质中。AnimPosition被解释为秒。然后,我们按录制动画的帧速率 (FPS) 缩放此值,并为动画变化添加随机偏移量,这一点稍后会详细介绍!

最后,我们按动画的长度缩放此值,以便可以索引到纹理的给定动画位置的正确帧中。

7、读取烘焙顶点动画数据

接下来,我们需要制作一个 UV 以索引到顶点动画纹理中,以便为正在评估的顶点输出正确的值。我们通过创建一个 Float2 来做到这一点,其中 X(或 U)是动画位置,Y(或 V)是数据所在的行。

如前所述,我们将位置和旋转数据存储在单个纹理中。但是,在合并结果之前,需要对每个纹理执行单独的纹理查找。

8、从UV通道解包关节枢轴

一旦我们查询了正确的动画数据,只需要用它来转换顶点。我们从纹理中获得了旋转数据,但需要检索旋转顶点的数据。作为 Maya 自动化过程的一部分,我们将其编码到网格的 UV 中,因此只需将归一化的 UV 坐标转换为正确的比例即可。

请注意,Maya使用Y-Up坐标系,而UE4使用Z-Up,这就是我们在MakeFloat3节点中交换这两个值的原因。

接下来,我们解决了围绕枢轴的四元数δ旋转(转换为局部空间),并将其与平移δ的结果相结合。

最后,我们需要使用TransformVector节点将其从本地空间转换为世界空间,并将其传递到结果节点上的"世界位置偏移"引脚中,并且 - 成功!

一旦有了一个高性能的动画角色,我们就可以使用虚幻的层级实例化静态网格体组件来生成数千个实例。

9、变化

没有什么比20万人完美地团结一致地移动更令人不安的了,所以我们很快就增加了一些变化。

我们大量使用实例随机变量(在着色器中由实例化的静态网格体组件提供)来进行其他变体。这是一个介于 0 和 1 之间的随机值,对于网格的每个实例都是唯一的。我们使用此值在角色的不同比例之间进行插值,并对其进行了评估,也进行水平翻转,以便在相同的绘制调用中产生更多变化。

最重要的是,我们用它来抵消动画位置值,以便角色在不同的时间开始动画循环。

这为我们提供了相当多的单网格绘制调用。以牺牲更多的绘制调用为代价,我们增加了一些角色变体。

对于其他变化,我们为每个实例使用了不同的动画循环。为每个网格实例承担了了唯一绘制调用的成本,那么为什么不提供不同的动画呢?

虽然这意味着同一类型的所有角色都将播放相同的动画,但在我们的应用场景中这一点影响不大。

10、LOD

由于我们的顶点动画纹理与顶点数量解耦,因此添加网格LOD支持就很简单了 — 只需要为Maya自动化程序提供低模,就得到了LOD。

对于最低LOD,考虑采用久经考验的面向相机的精灵方法。但是,这带来了一些问题:

  • 该技术无法很好地扩展到VR平台,因为当以立体方式渲染时,感觉会出错。
  • 我们不局限于从单一方向查看角色,因此需要采用"冒名顶替者"广告牌方法来为每个角色提供多个视角。
  • 角色仍然需要表达运动,这必须与完全实现的角色相匹配,因此我们需要一组动画循环 - 全部来自多个角度 - 在巨大的精灵表中。
  • 密集的角色组会导致大量半透明过度绘制。

相反,我们在 Maya 中为一个只有 70 个三角形的角色制作了一个"样条曲线"表示,该角色使用的动画数据与 LOD 0 角色完全相同。我们将宽度值编码到顶点颜色的G通道中,并将其输入到简化版本的"SplineThicken"节点中,这使我们能够将几何体的面定向到相机。该效果并非没有伪影,但在远处保持良好状态,最重要的是,在LOD状态之间无缝混合,而无需任何额外的动画逻辑/数据。

11、未来工作与系统扩展

我们还实现了一个混合系统,以允许在运行时在循环状态之间平滑转换。作为UE4中的离线流程,骨骼网格体在两种动画状态之间执行程序动画混合,并将其烘焙成定制的一次性混合纹理。然后,当需要从一个动画循环切换到另一个动画循环时,我们在运行时读取这个预烘焙的纹理。

这种方法的主要问题是它没有很好地扩展。事实上,它在存储占用空间方面呈指数级增长,并且使用我们的19个动画循环,必须编写462个过渡纹理,总计不到60Mb的数据。

我们可以通过在运行时将动画数据烘焙到动态渲染器目标来缓解这种情况。这将使我们能够在运行时执行更复杂的动画蓝图驱动的混合逻辑,而无需一组混合纹理,但会以牺牲一些运行时性能为代价。

扩展系统的另一种方法是通过将变形目标编码为几何图形的顶点颜色和/或法线来为每个网格实例引入更多变化,类似于"静态网格体变形目标"系统的工作方式。

总之,我们能够创建广泛重用动画数据的系统,这些数据可以很好地扩展并以较低的,基本上固定的性能开销运行。


原文链接:How To Populate Real-Time Worlds With Thousands Of Animated Characters

BimAnt翻译整理,转载请标明出处