Instancing
首先承接上一篇的内容,我们来算一算余下的草海信息大概有多少:
假设视距是100m,乘以2得200米的边长,也就是4万平米的大小地块围绕在摄像机周围,估算视锥内的面积在1万~2万平米之间,取个平均1.5万吧,以一个拥有5个三角形面的模型草为例,每平米铺满大概需要20株这样的模型草,那么我么就有
15000 * 20 * 5 = 1.5m
一共一百多万个三角形面,移动端GPU渲染这1.5m个三角形... 感觉不算轻松,后续可能要做一些LOD减面处理了,但也不是不能渲,只要对应的shader足够简单高效,就不会影响游戏体验。
那么如何来创建并存储这些草体模型呢?
对于传统流程,可以简单归纳下渲染草海的步骤:
1)首先是CPU对视锥内的每一块地砖提取密度信息,创建出对应若干株草的Mesh网格,
2)然后这些三角形网格会被缓存到在本地内存中,以供下一帧复用
3)在每帧提交给GPU前可能Unity还会帮助我们做一次静态合批,将若干块地砖上的草的网格进行合并,抛弃掉一些无用的数据和矩阵
4)最后上传给GPU进行渲染,所有草的网格信息自然在GPU的显存中也会存一份
我想最主要矛盾还是在于这么多面的Mesh创建和存储起来很麻烦。美术大大只给了我们一株草的模型和一张密度图,所以如果设计不当,我们每移动一次摄像机,就会产生一片新的待渲染地砖,就需要根据其上的密度信息新生成一批可能不同的Mesh;假如我们在游戏里控制角色做了一次传送,或者长距离跳跃,弄不好可能就要再生成一边1.5M个三角形面片了,这是很不划算的事情。那么全部都提前生成好如何?这就更不可能了,这些三角形网格比密度信息还占空间得多,我们除了要存下所有顶点的位置信息外,还要准备诸如顶点UV,法线,甚至变化矩阵等各种辅助数据。
有一点很明确,我们不想在内存中创建存储和维护这150万片三角形,存储它们既消耗内存,处理起来也很消耗CPU,在某些时刻还得花费大量IO把数据从CPU搬运到GPU。可以的话最好能在GPU中动态的创建,以一种高效的方式:由CPU指定模板,指定实例化的个数,每个实例的位置等一些专有属性,其余所有的活都在GPU内完成(这其中包括创建对象,存储对象和渲染对象等操作)这些操作大家是否感觉有些熟悉?这其实就是之前提到过的基于 GPU Instance 技术的解决方案。
回顾一下之前提过的 DrawMeshInstancedIndirect 接口,它是Unity对GPUInstance的一种典型实现,接收的参数如图(也就是CPU向GPU提交的部分数据),第一个参数:vegeMesh 是实例模型本体,第二个入参是个数字,规定取用那个SubMesh(大家可以先不理会),下面的instanceMat则是草的材质,接下来的renderBound规定了这次操作所包围的空间边界,只有在这块空间与摄像机视锥有交集的前提下,当前接口才干活。最后面的argsBuffer是个结构化入参(格式是ComputeBuffer),里面得设置上需要实例化的个数等额外信息。顺便多提一嘴,这个接口所允许的实例个数上限没有限制,所以理论上对每一株草进行实例化都是可以的。
实际上我们不会为每株草各进行实例化,理由前面已经说明过,没有足够的空间来存储每一株草的位置和朝向等属性。
那么我们有什么呢?
密度!
如果vegeMesh这个变量代表的是一个平方米内所有草经过合并后的集合,然后通过GPUInstance接口,让vegeMesh在GPU内重复15000次(15000是视锥内草地面积),同时给每一个实例指派对应的坐标和密度信息,我们就能在地块的基础上,还原整个草海了。
不过如果真的这么照做的话,很快就会发现新问题,GPUInstance只接收一个模板Mesh,如何用一份Mesh来表现出草皮密度的多样性呢?如果什么都不做,只按照顶格密度去合并出一个Mesh传入接口,我们得到的是一个二阶分布的草海,每一平米的地皮要么寸草不生,要么郁郁葱葱,没有过度,这很可能不符合美术大大需求的,是要解决的问题。
这个问题的本质其实是:如何解决 GPUInstance 一次只接收一份Mesh作为模板,但是我们有非常多种密度的Mesh需要表现的矛盾?(X2次)
解决方案并不唯一,打破这对矛盾可以从2个方面入手,要么让GPUInstance"能够"接收多组Mesh,要么让合并后的一组Mesh能表现出某种多样性。
比如说:
方案1:在CPU端就提前制备好各种密度的不同Mesh,多次调用GPUInstance,每次都针对不同密度的Mesh。
方案2:是在GPU端玩花样,GPUInstance只执行一次,接收一份Mesh,然后想办法在着色器的顶点阶段修改这份Mesh,使之表现出不同的密度来
方案3:仍然是在GPU端玩花样,这次使用Geometry Shader,直接在GPU内从0开始绘制草的模型,要什么样密度的就绘制什么样的
先说说我的想法,首先是方案3用到了Geometry Shader,是OpenGL ES 3.2支持的特性(和曲面细分一起),Unity2019也加了进去,(顺便说下,GPU Instance是ES 3.1特性,大概2014年面世的),回到这个几何着色器,我个人是愿意去尝试一下效果的,毕竟在GPU内手操绘图就不再需要CPU端传任何草的模型网格过来了,而且理论上提供了非常高的绘制自由度。不过也有些问题,其一就是绘图过程不宜复杂,复杂了工作流会很大,包括使用和维护等方面;其二是感性的直觉,就是复制草模型的速度应该是快于绘制草的速度,在需求绘制海量草Mesh的时候,综合效率可能还是GPUInstance来得好(当然这点需要验证)
再来是方案1,执行起来相对简单,也支持预置任意的Mesh,不过随着密度层级的增加,完全依靠方案1来处理草海在性能上就不够scale了,毕竟有多少层密度就要调用多少次GPUInstance接口,虽然我相信GPUInstance接口的效率,但是怎么说呢,如果能够一次搞定的话,尽量一次搞定比较好,把性能留给未来其他类型的植被需求吧。
所以综合考量,我个人觉得方案2是比较好的,也就是如果我们能够找到一种,只用一份Mesh来变化出不同密度层级的手段的话,问题就能在GPU内解决了。
当然答案是有的,前不久我阅读过Unity AssetStore上一个比较成功的刷草插件“uNature”,它实现了一种解决方案:
如图所示,假设满密度是10,代表我们把10株草的Mesh合并到一起,在将Mesh传入GPU之前我们得做一步额外的操作,也就是在合并的过程中,对每一株合并进来的草按顺序编入一个id,1,2,3,4,5,...,10,这些id被记录到了每个顶点上(可以参考图中地块上的数字角标,可以看到,属于一组Mesh的每一株草都被独立编号了)。这样当传入GPU后,满密度的Mesh大概会是最右侧这样。
我们知道,着色器在填充像素前,会有个处理顶点的阶段,每个顶点都会被单独处理,而处理逻辑我们是可以定制的。假设我通过某种方式知道了当前处理的这株草是来自于某个地砖的,而这个地砖的密度我也知道,比方说密度值是7吧(可以参考最中间的那个地块),那么着色器顶点逻辑中我们可以这么干:输入一个顶点,我们从顶点信息中找到编号id(这个id表示当前顶点属于第几号草,比如说6号),当我发现这个id <= 7时,就正常提交和渲染这个顶点,这时6号草会被完整渲染出来了;但是当id > 7时(比如8号),我们选择跳过这个顶点。这样做的后果就是,这块地砖上原本有10株草,经过过滤,只剩下编号为1到7的7株草了。7不就是这个地砖对应的密度么?匹配上了。
如果听起来迷糊,不要紧,罗里吧嗦这么多,就是为了让GPU剔除掉合并后Mesh里多余的草,让留下的草的个数符合当前地砖上对应的密度值即可。
方法能达成目标,但是细究起来感觉还是比较粗暴的,按照预置的id消减网格,每次都会是一个顺序,所以不加修饰的话在艺术表现上难免会有生硬的感觉。一般会安排一些高耸粗壮的草使用靠后的id,而矮小的草使用靠前的id,连续id的草之间的位置关系也最好是随机的,最后可能还得附上一些随机偏移来优化效果,这都是后话了。