大家好,我是阿赵。这次来讨论一下Unity渲染大量物体的一些方法。

一、小例子

  这里先来看一个例子:

阿赵星系


在这里插入图片描述
在这里插入图片描述

  这个例子里面会出现很多个立方体,这些立方体会有不同的颜色,有随机的旋转和缩放。最后这些立方体还会组成阿赵的头像logo。
  在例子里面,我使用的立方体数量是50万个,当然增加到100万个也是完全可以的,只是帧率会降低一半,变成只有100fps左右。
在这里插入图片描述

  如果有熟悉Unity官方例子的朋友,应该看出来了,这个其实就是Unity的API文档里面自带的例子:
在这里插入图片描述

  各位朋友有兴趣可以在Unity的API文档里面搜索一下:

Graphics.DrawMeshInstancedIndirect
在这里插入图片描述

  在下面就有C#和Shader的源码了,所以我就不再把源码贴出来了。我这个例子是在这个基础上,加上了读取图片的像素作为立方体位置和颜色,然后加入了随机的旋转和缩放而已。
重点说一下实现的原理。

1、渲染方法

上面已经说了,这个渲染的主要方法是Graphics.DrawMeshInstancedIndirect,具体的用法可以查看API,简单的说,这个方法可以指定需要渲染的一个Mesh,子网格的index,一个材质球,一个范围,然后通过参数控制需要渲染多少个物体。一次提交,Unity就会把同一个网格模型用同一个材质球的数据渲染指定的次数。

2、数据传递和Shader的实现

1.ComputeBuffer组装数据

  由于Graphics.DrawMeshInstancedIndirect方法是需要指定一个材质球作为渲染的实例,所以我们可以通过给材质球设置buffer,这个buffer是一个数组,里面的数据会根据实例id排序,然后在Shader里面通过实例id拿回自己所属的物体对应的数据。
  比如官方例子里面,声明了一个叫做positionBuffer的ComputeBuffer,在初始化的时候,positionBuffer的长度和需要生成的cube的数量是一样的,比如生成一万个cube,那么positionBuffer的长度就是一万。
  然后在通过循环,把一万个立方体的位置数据通过Vector4设置给positionBuffer。最后,这个positionBuffer是通过

instanceMaterial.SetBuffer("positionBuffer", positionBuffer);

  设置给了指定的需要渲染的材质球。

2.Shader读取

  由于之前已经把所有的cube的位置数据存到材质球上了,所以在Shader上面就可以获取了。
  为了能让外部存储positionBuffer,所以在Shader里面要先声明结构体:

StructuredBuffer<float4> positionBuffer;

  然后在vert顶点程序里面,获取到对应实例id的数据:

v2f vert (appdata_full v, uint instanceID : SV_InstanceID)
{
   float4 data = positionBuffer[instanceID];
}

  整个过程就是这样简单,shader获得了位置信息之后,就可以在shader里面给顶点位置做偏移,算出它的实际世界坐标了。
  这时候整个渲染过程就完成了,假设cube的位置都不需要发生变化,那么这些数据只需要设置一次给材质球,那么剩下的事情都是GPU自己去渲染了。

3、关于数据变更

  这个过程里面有个问题,就是假如立方体的位置由于某种原因需要发生变化,那怎么办呢?
  很明显,如果需要改变位置,就只能改变positionBuffer里面的数据,然后再传递给材质球了。这个过程需要重新从CPU提交数据给GPU,如果位置频繁的变化,那么就要频繁的提交数据给GPU,这个将会是一个比较大的消耗。
  为了解决这个问题,可以使用ComputerShader。ComputerShader的特点是把CPU的逻辑转换到GPU去进行,并且可以并行计算。 在位置变化的时候,给位置数据设置一个脏标记,然后调用ComputerShader,在里面通过实例id直接改变positionBuffer里面的数据,然后直接设置给材质球。
  到这一步,这个渲染的方案似乎就已经完成了, 而且看着比较的完美。但事实上真的是这样吗?

二、渲染多个物体的真正讨论

  上面介绍了这个Demo例子的好处,接下来才是这一篇文章的真正内容。

1、例子的一些缺陷

  这个例子使用的其实就是GPUInstancing的思想,把同样网格、同样材质球的物体,进行手动同一批次的渲染,原理就是这么简单而已,并没有很深奥的技术。只是由于Unity官方的推广做得好,让大家看到这么一个数十万甚至百万级别数量的立方体组成星云,看着很唬人而已。
  这个例子作为一个Demo的存在,看起来很强大,实际上,它也是存在一些问题的。

1.受限于模型面数。

这个例子可以渲染这么多立方体,完全是因为立方体的面数低。一个立方体只有12个三角面,就算渲染100万个,也就是1200万面而已。如果换成自带的有几百个三角面的球形Mesh,会发现只要几万个球形,帧率就掉得很厉害。
在这里插入图片描述
在这里插入图片描述

  所以渲染大批量的东西的前提,还是面数不能太高,不然还是会掉帧严重。

2.管理资源的逻辑

  由于必须同一个网格和同一个材质球,所以在实际应用的时候,场景里面出现的所有物体,都需要先通过C#的逻辑写一个管理器,把它们分类,存放在不同的组里面。然后这些物体的变更和销毁,也需要在管理器里面进行相应的变更操作。如果游戏里面出现的美术元素比较复杂,实际上这个过程的变更也会跟着变得频繁,会一定程度的消耗CPU的性能。

3.模型的剔除问题也需要自己去处理。

  只要调用了渲染命令,Unity会全部把我们需要的物体渲染出来,不论这个物体的位置是否在摄像机的视锥里面。也就是说,摄像机的视锥剔除功能已经失效了。这时候我们就只有2个选择,要么花费CPU的性能去做剔除,减少GPU的渲染;要么就不管了,直接有多少渲染多少,看看GPU是否能顶得住。

4.数据的变更问题。

  很多时候,我们会认为渲染的瓶颈是从CPU传递数据到GPU。上面说到,比如位置变更,我们可以通过ComputerShader去计算,并且直接在GPU层面就给材质球赋值。这绝对是一个可以解决大部分问题的方案。但在现实应用之中,首先我们需要处理的可能不止位移旋转缩放功能,比如如果用这个功能去渲染场景角色,角色的行为和表现形式会很复杂。一个角色处于不同的状态中,它可能会有不同的颜色表现,动作表现,等等。如果通过VAT来播放顶点动画,还涉及到需要替换不同的贴图和设置不同的参数。这种复杂程度如果单纯通过ComputeBuffer,每次有其中一个物体的数据变更,就整套数据重新设置,重新计算。每次有物体的增加减少,就得所有ComputeBuffer重新创建一遍,其实也不见得很合理。

2、实际应用的可行性

  我必须肯定的是,通过Graphics.DrawMeshInstancedIndirect来调用渲染,在性能这个层面上来说,肯定是非常的可控的,如果应用得合理,是可以很优秀。
  不过由于上面提出来的一些问题,会导致Graphics.DrawMeshInstancedIndirect这个美好的东西,变得不那么容易使用。如果要使用它,我们起码应该写一个底层的框架,进行渲染对象的管理、数据变更的管理、然后所有物体需要用到的Shader,都应该特殊的定制,以保证为了大批渲染需要从ComputerShader或者ComputeBuffer设置的属性在Shader里面被支持。
  如果渲染的大批量物体都是固定的东西,比如,大面积的草地,比如一大片星空。这样功能相对比较简单的东西,问题就不大,因为需要接触这个功能的美术和程序都不会很多。但如果是需要渲染大批量像角色之类行为复杂功能比较多的单位,需要解决的问题会比较多。可能随着需求的增加,底层渲染需要不停的增加Buffer参数,频繁的修改组装数据的方法和Shader来满足需求。
  如果是我自己的个人项目,我应该可以接受这样的做法,毕竟所有东西都是出于我自己的手,可控性很强。如果是团队合作的项目,想使用这个方案,我觉得最基础的前提条件有几个:
1.TA团队很靠谱,可以把整个底层渲染持续的维护下去,有新增的渲染内容要一直增加支持。
2.美术团队很听话,可以所有资源都按照特殊的需要去做,不能自己去网上乱找资源或者Shader直接就用在项目里面。
3.前端程序在写业务逻辑的时候必须遵循底层渲染提供的方法来调用。

3、替代方案

  GPU Instancing这个技术Unity使用了已经很久了,反而是URP的SRPBatcher是后来出现的技术。这个技术的实质也是把可以存在GPU不需要频繁变动的参数,包含在CBUFFER_START和CBUFFER_END里面。这样在没有实质变化的时候,GPU可以直接拿上次的数据直接渲染。所以并不是想象中的每次渲染都需要从CPU提交数据给GPU的。
  SRPBatcher对比GPU Instancing的好处是,它不限于同一个Mesh网格和同一个材质球的实例才能合并,而是使用同一个Shader的所有物体都可以通过SRPBatcher来合并。
  可能正是由于这些特点,让SRPBatcher更容易使用。在Unity引擎里面,如果一个物体同时支持GUI Instancing和SRPBatcher,那么Unity引擎会优先使用SRPBatcher。
  说句老实话,使用Graphics.DrawMeshInstancedIndirect自己控制GPU Instancing渲染, 肯定会比直接用SRPBatcher会更省,毕竟没有了场景里面的具体GameObject对象,省下了不少的引擎底层的基础计算和内存。至于合并、提交、剔除等部分的逻辑,其实SPRBatcher同样也是帮我们实现了,但对于能力强的开发者来说,有针对性的对自己项目进行优化,实现的逻辑肯定会比Unity引擎提供的通用功能优化得更好,更适合自己。
  不过,很多时候规模稍微大点的项目,我们考虑问题的时候除了极致的性能实现以外,还要考虑易用性的问题。如果可以在不改变正常开发习惯的基础上,让性能得到一定的提升,那样整个团队在开发中的学习成本会降低,出错的可能也会降低,可以更容易的设计和开展业务逻辑,美术人员也可以在限制更少的情况下进行资源的制作。
在这里插入图片描述

  之前我做了一个使用VAT+SRPBatcher制作的万人同屏的例子,我承认在极致的渲染效率上,肯定是比不上Graphics.DrawMeshInstancedIndirect的无GameObject手动合批渲染的。不过实际上,这个方案不会因为模型的种类增加而降低合批性能,用多少种模型都一样,当时每个模型的实际面数是一千多,有些复杂的是接近两千,那么实现了一万五千个模型同屏,实际上面数已经达到了两千多万三角面了,大概是Unity官方例子里面的Cube数量达到200万时候差不多的面数。如果我把官方例子设置到200万个Cube,实际上它的帧率也会降低的:
在这里插入图片描述

  同样的面数级别时,比SRPBatcher高了十几帧。
  所以我才会说,如果要追求极致,Graphics.DrawMeshInstancedIndirect肯定会更好,但从易用性来说,SRPBatcher也许会更好。

Logo

分享前沿Unity技术干货和开发经验,精彩的Unity活动和社区相关信息

更多推荐