查看原文
其他

如何快速大量绘制游戏对象?这个方法值得一试

枸杞忧天 腾讯GWB游戏无界 2022-08-30

编者按 如何快速将大量游戏对象呈现给玩家?本文将介绍一种通过GPU实现骨骼动画的实例化绘制方法,并简述其实现原理。


本文内容由公众号“偶尔学学Unity”提供,转载请征得同意。


有多种通过GPU实现骨骼动画的实例化绘制方法,本文介绍的是其中的一种:将顶点信息逐帧写入纹理后,在顶点着色器中通过读取动画纹理,提取顶点位置并变换,最终实现角色动画的方法。

 

本文将简述其实现原理,并分享一个(完成了一半的)网格合并及实例化绘制工具。


如何提高绘制效率


当产生了“要将大量游戏对象呈现给玩家”的需求时,我们就会碰到这样一个问题:如何才能提高GPU的绘制效率?

 

批量绘制较多的骑兵

 

通常情况下CPU对GPU发起的绘制命令,才是性能的瓶颈所在。CPU为绘制准备数据、显存加载数据、为GPU设置渲染状态等行为所花费的时间,通常比GPU绘制所花费的时间要多。这也就是为什么我们经常会把DrawCall次数当成快速评判渲染效率的“KPI”。

 

反观Unity提供的Static batching(静态合批)和Dynamic batching(动态合批),也都是从减少CPU到GPU的调用次数为出发点,尽量一次发送一个大的网格(一大堆顶点数据),以减少CPU和GPU的通信次数,提高彼此的工作效率。

 

但是无论静态还是动态合批,在大量游戏对象绘制的需求面前,都不太合适。

 

静态合批从名字上就知道不能用来绘制移动物体,而且其本身还会产生非常大的内存开销(它需要额外的内存空间来存储合并的网格);动态合批也有自己的问题,如顶点数量的限制、材质球限制、无法作用于蒙皮网格(SkinnedMeshRenderer)等,还会对CPU产生不小的压力(因为它要不停地去动态计算并合并网格)。


实例化绘制


实例化绘制技术的出现,就是为了在不提高CPU负担的基础之上,解决CPU到GPU调用开销大的问题。对于相同的物体(同一个网格),只需一次调用,GPU就会根据我们想要绘制的次数,啪啪啪一通画,非常的高效。

 

但是简单重复绘制一个物体多次(比如重复绘制1000次小兵),并没有任何意义。为了能够绘制出1000个不同的小兵,我们还需要提前为GPU准备一些额外的数据,比如1000个转换矩阵(画在不同的位置)、1000个混合色(呈现不同的颜色)等,最终在屏幕上呈现出千军万马的画面。


如果我们想要在游戏世界上呈现非常多相同的、静止不动的石头,那到此为止就可以了。我们使用Unity提供的手动实例化绘制接口Graphics.DrawMeshInstanced,通过传入同一个石头的网格和每一个石头的转换矩阵,就可以实现需求(其实Unity也会自动为添加了MeshRenderer组件的单位尝试使用实例化绘制以提高效率)。

 

但是对战场中的小兵做这种简单地操作就不太合适了。


这是因为小兵通常是采用骨骼动画来实现动作的,而骨骼动画对于蒙皮网格的驱动,是CPU即时计算出来的。每个小兵相同时刻的状态可能都不同,也就是说相同网格同一时刻的顶点位置会有很大差别,因此无法直接进行实例化绘制。

 

既然CPU上即时计算的骨骼动画无法进行实例化绘制,我们就不让CPU计算,而让这些计算发生在GPU上,便可将问题解决。


它的原理很简单


1、将骨骼动画每一帧对网格各个顶点的变化结果存在一张纹理中,其中纹理的横坐标是顶点索引,纵坐标是时间,而横纵相交对应的值,是这一时刻该顶点在本地空间下的坐标。

 

2、有了这张“顶点动画纹理”,在顶点着色器中,我们就可以忽视传入顶点着色器的顶点位置信息;而以当前所处理的顶点索引为U,以动画播放至此的时间刻度为V,从上一步的纹理坐标中采样。而采样到的结果,就是当前这个顶点此时的位置。

 

3、接下来的步骤便与传统绘制一样,与MVP矩阵相乘做空间变换,传入片段着色器中着色等...可以很容易的想象到,连续为网格上所有顶点设置不同时间下的空间位置,最终绘制到屏幕上时,就能呈现出动画效果了。


一些相对重要的细节


1、用实例化ID来获取差异实例单位的属性


由于我们的最终目标是绘制多个不同动画状态的单位,因此从动画纹理中,用于采样信息的时间刻度值,是根据实例化ID,从保存实例化属性的数据块中获取到的,这样就可以实现每个实例化单位的动画播放进度的差异。


2、合并多个不同的网格


手动调用实例化绘制接口时,只能传入一个网格。而我们平时使用的游戏对象,通常是由若干个蒙皮网格和若干个普通网格组成。比如一个骑兵模型:士兵和马匹分别是两个蒙皮网格;而士兵手持的武器通常是一个普通网格,以方便后期做武器替换。

 

一个游戏对象可能会由两种、多个网格组合而成

 

因此我们会在编辑器模式下,将整个对象包含的网格合并成一个网格,并将这个网格保存成资源,以便后面调用绘制命令时作为实参传入。

 

合并成为一个网格

 

3、多贴图时处理UV


此外,有些模型上不同的网格还对应了不同的贴图,比如网格Mesh_0,使用了贴图Texture_0,网格Mesh_1使用了贴图Texture_1,由于网格进行了合并,如果针对合并后的网格使用同一张贴图,便会出现错误。

 

胯下战马错误的颜色采样

 

针对这种情况我们要在合并时做特殊处理,一种处理方式是合并多张贴图,如将Texture_0与Texture_1合并,然后偏移原本Mesh_1的uv坐标,但是这要求两张贴图都不能太大,否则无法合并到一张贴图中;另一种方法是仍然保留两张贴图Texture_0和Texture_1,但是对Mesh_0和Mesh_1的uv2做特殊处理,如使用uv2的x保存两张贴图的Lerp值。这样片段着色器中对两张贴图的采样结果做二次计算后,就可以得到正确的颜色了。

 

为战士和战马分别替换贴图

 

4、动画的混合


通过纹理实现的动画也可以实现简单的混合效果,它是通过在顶点着色器中对多个动画纹理进行采样,然后根据一个混合比例,对多个位置信息进行计算以实现的。

 

根据速度一维向量进行的Locomotion状态混合

 

5、脱离了Renderer的渲染


由于是直接调用了Graphics.DrawMeshInstanced进行的绘制,因此并没有GameObject被创建出来,减少了对象的创建数量,一定程度上也减少了内存及CPU的开销;但是需要自己在loop中组织数据的更新及渲染的更新。


脱离了GameObject+Renderer的绘制

 

使用动画纹理的优缺点


优点


1、易于理解、易于实现;


2、CPU的计算(合并网格、记录动画信息)发生在编辑器阶段,游戏运行时CPU没有额外的开销;


3、可以实现实例化绘制,充分发挥GPU的绘制效率。

 

缺点


1、记录顶点动画的纹理大小,一方面取决于模型的顶点数量,另一方面取决于动画的长度,如果顶点数量过多,或动画过长,生成的纹理就会很大,对显存的占用量也会上升;


2、实现动画混合,需要从多个动画纹理中采样并进行计算,采样次数多;


3、无法使用动画状态机控制动作;


4、动作信息在存储时会受保存格式的精度影响,因此读取出来的动画可能不够精确;


5、无法实现骨骼动画中的IK(反向动力学)等。

 

虽然有不少缺点,但是如果你的目的是大批量绘制环境装饰(树、草、石头)或细节要求不高的杂鱼小兵、路人,它都是你实现目的优秀手段,值得你去使用它。


最后


最后,分享一个没有写完的网格合并及实例化绘制工具,可以实现上述简单的功能。


通过工具生成动画资源文件


 简单的动画播放

 

大批携带动画角色的实例化绘制


工具及Demo下载地址:

https://github.com/elsong823/AnimationBaker



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存