« 死亡 | 返回首页 | 重新启程 »

大批量动画模型的优化

最近和公司一个开发团队探讨了一下他们正在开发的游戏中遇到的性能问题,看看应该如何优化。这个游戏的战斗场景想模仿亿万僵尸(They are billions)的场景。在亿万僵尸中,场景中描绘了上万的僵尸潮,但我们这个游戏,超过 500 个僵尸就遇到了性能问题。固然,手机的硬件性能比不上 PC ,但 500 这个数量级还是略低于预期。

对于游戏中大量类似的动画物体,肯定有方法可以优化。我们来看看渲染这些动画可行的优化方向:

常见的方式是把僵尸先预渲染成图片,而动画自然就是多个图片帧。对于亿万僵尸这个游戏来说,它本身就是基于 2D 渲染引擎的,这么做无可厚非。

如果引擎本身基于 3d 渲染管线,也可以以预渲染图片的方式去渲染它们,但图片是否比基于三角形的模型渲染有更好的性能,这个需要根据具体场景去分析。

当我们在运行时,将模型的三维顶点信息传递给 GPU ,让 GPU 做光栅化,通常可以获得两个方面的好处:

一,最终渲染出来的像素有更准确的光照信息,它是根据该像素在场景中的空间状态计算出来。而预渲染图片上的像素则是根据预渲染时的固定空间状态计算,所以不准确。

二,可以省掉顶点着色器的计算过程,对像素着色过程也简化为一个简单的复制(而不必计算光照)。

但是,渲染图片也未必一定有绝对的性能优势。这是因为,预渲染图片本质上是将渲染过程预处理,烘焙到贴图上。约等于用空间换时间的优化。图片本身相比顶点数据会消耗更大的带宽。当模型本身顶点不多,用到的贴图也不大时,这个区别会更加明显。通常模型顶点占用的带宽会比图片需要的带宽小一个数量级。当使用帧动画时,需要为每个动画帧渲染独立的图片,这个差别就更加明显。


再来看动画。通常,我们会用骨骼来描述动画。这其实也是一种数据压缩方式。一个模型的顶点可能有成千上万甚至更多。但我们只需要用几十个关键点来描述动画即可。同时,只需要建立唯一的蒙皮数据,把数千个顶点映射到几十个关键点(被称为骨骼)上,每个动画帧就不再需要重复几千顶点的空间状态。

更进一步,如果动画的时间很长,即使记录每一帧骨骼的状态也会多余。我们会记录一些关键帧(key frame)上的骨骼状态,再用插值的方式求得每个渲染帧的骨骼状态。数据就得到了进一步的压缩。

让我们回顾这个流程:

骨骼关键帧 (通过插值得到)每个渲染帧骨骼信息,(通过蒙皮按权重计算出)这帧模型每个顶点(经过光栅化)映射为屏幕上的画面。

这里的每个环节,前面的都有更少的数据,可以通过索引和计算,得到下一个环节更多的数据。因为 GPU 比较适合做简单但巨量的并行计算,所以,我们每个环节都可以考虑把提前预运算,或是实时计算;计算可以考虑放在 CPU 中,或是放在 GPU 中。每个选择必定有取舍,都可以根据实际场景考量。

当需要渲染的物件非常多时,为了提高性能,要么尽可能的把数据预预算好,而不要每帧都反复计算;要么把计算放入 GPU 中,让处理器可以尽可能的并行。

上面列出的动画流程中,中间一步“从蒙皮计算出顶点”是最有弹性的,根据不同的场合会有不同的处理方法。

早年 GPU 无法处理复杂的业务,大多数引擎会选择在 CPU 中处理蒙皮。如今,GPU 有了 compute shader ,把蒙皮放在 GPU 中处理越来越普遍。 但今天谈的这个场合,我任何并非最佳方案。

因为在 GPU 中计算蒙皮,也面临两个选择:其一,渲染每个物件,都从骨骼到顶点再到屏幕像素跑一遍完整的流程,这样带宽开销是最小的,都计算量最大;其二,把蒙皮过程分离出来,GPU 计算出蒙皮结果,然后把蒙皮后的顶点和其它静态模型顶点统一处理,这样做需要临时占用额外的显存来保存蒙皮计算结果。

如果选择方案一,我们会多出相当多的重复计算。首先,3d 管线中,每个模型都不只处理一次渲染。会有 preZ ,阴影等额外的渲染步骤,这个方案会重复计算蒙皮几次。其次,更重要的是,对于僵尸潮来说,僵尸的数量越多,在播放动画时,恰巧处于同一个动画帧的可能性就越大。当渲染数以千计的僵尸时,会做相当数量的重复蒙皮计算。

如果渲染方案二,我们会为每个僵尸对象的蒙皮结果在显存中保存一份临时顶点数据。PC 上或许有足够大的显存,但手机上非常容易显存不够。(我们过去尝试过这个方案,在手机上果真遇到了显存不足的问题)固然我们可以继续优化方案二,加一个间接层合并一些重复的数据,但渲染管线的复杂度会上升很多,暂时不往这个方向考虑。

实际上,把僵尸模型的所有动画的每一个动画帧的蒙皮结果预算计算好,在这个场景是最为简单有效的方案。

游戏中,单个僵尸只有不到 300 个顶点,以每秒 30 帧动画计,每秒动画不到 10k 顶点数据。预计算 100 秒动画数据,手机显存也能存放的下。我们只需要在 CPU 中烘培好这些数据,就可以把这些动画顶点变成静态模型一并处理。然后使用简单的 instancing 方法就能批量处理上千僵尸的渲染。

以上就是最近在 Ant Engine 中增加的特性,实测在 iPhone 12 上可以流畅渲染数千僵尸动画。不过,这个游戏本身不是 Ant Engine 制作的。虽然我和项目制作人讨论过用新引擎重新制作的可能性,因为开发计划的压力未能实施。


另外,我们在 Ant Engine 中实现这个新特性时,接口设计中用到了一个小技巧:

Ant Engine 是基于 Lua 的。Lua 的函数调用有不小的开销,所以我们在使用时应该避免每帧做过于频繁的调用。对于动画系统,底层接口需要设置每个模型每帧渲染使用的是预计算好的顶点组中的哪一段(以此为 instancing 的一个参数)。逻辑上讲,就是需要通知渲染层动画模型当前帧需要渲染的帧号。

当僵尸数以千计时,每一帧都需要修改每个僵尸的动画帧号,总共数千次。这每帧数千次的 Lua 函数调用其实是可用一个简单的技巧避免的。以走路动画为例,引擎内部其实只持有一个预渲染好的走路动画所有帧的对象。我们每帧都固定推进这个对象的 offset ,而使用这个动画的对象,它们实际保存的是一个起始相位值。在 C 层,每次渲染时都把这个相位数和动画对象中的 offset 相加再对总动画帧数取模,才得到真正的帧号。这样,我们只需要每帧去推进单个动画对象的 offset 就够了,而不需要上层通过 Lua 接口修改每个对象的动画帧。

Comments

instancing 应该是主流引擎都支持的技术吧?这个团队用的是什么引擎?
profile确认了是骨骼动画问题吗,500个角色有很多导致性能问题的可能🤔

Post a comment

非这个主题相关的留言请到:留言本