由浅到浅入门批量渲染(三)
好久不见。
这是第34篇与游戏开发有关的文章。
上回简单总结了一下动态合批,这次我们继续说说实例化渲染。
| 实例化渲染
当我们想要呈现这样的场景:一片茂密的森林、广阔的草原或崎岖的山路时,会发现在这些场景中存在大量重复性元素:树木、草和岩石。
仙境怕是也不过如此吧
它们都使用了相同的模型,或者模型的种类很少,比如:树可能只有几种;但为了做出差异化,它们的颜色略有不同,高低参差不齐,当然位置也各不相同。
使用静态合批来处理它们(假设它们都没有动画),是不合适的。因为数量太多(林子大了,多少树都有),所以合并后的网格体积可能非常大,这会引起内存的增加;而且,这个合并后的网格还是由大量重复网格组成的,不划算。
使用动态合批来处理他们,虽然不会“合并”网格,但是仍然需要在渲染前遍历所有顶点,进行空间变换的操作;虽然单颗树、石头的顶点数量可能不多,但由于数量很多,所以也会在一定程度上增加CPU性能的开销,没必要。
那么,对于场景中这些模型重复、数量多的渲染需求,有没有适合的批处理策略呢?有吧,实例化渲染就是为了解决这样的问题。
| 简述工作原理
实例化渲染,是通过调用“特殊”的渲染接口,由GPU完成的“批处理”。
它与传统的渲染方式相比,最大的差别在于:调用渲染命令时需要告知GPU这次渲染的次数(绘制N个)。当GPU接到这个命令时,就会连续绘制N个物体到我们的屏幕上,其效率远高于连续调用N次传统渲染命令的和(一次绘制一个)。
举个例子,假设希望在屏幕上绘制出两个颜色、位置均不同的箱子。如果使用传统的渲染,则需要调用两次渲染命令(DrawCall = 2),分别为:画一个红箱子 和 画一个绿箱子。
两个颜色、位置各异的箱子
如果使用实例化渲染,则只需要调用一次渲染命令(DrawCall = 1),并且附带一个参数2(表示绘制两个)即可。
当然,如果只是这样,那GPU就会把两个箱子画在相同的位置上。所以我们还需要告诉GPU两个箱子各自的位置(其实是转换矩阵)以及颜色。
这个位置和颜色我们会按照数组的方式传递给GPU,大概这个样子吧:
分别传递保存位置和颜色的数组
那接下来GPU在进行渲染时,就会在渲染每一个箱子的时候,根据当前箱子的索引(第几个),拿到正确的属性(位置、颜色)来进行绘制了。
一个简单的实例化渲染流程
| Unity是如何处理实例化的
我们通过一个简单的场景,来看一下Unity为实例化渲染做了什么。
实例化渲染两个彩色箱子
颜色属性通过MaterialPropertyBlock传入
通过GPA观察Unity做了什么。
GPA中的VertexBuffer和IndexBuffer中的信息
注:Unity默认Cube网格,包含24个顶点和36个索引。
- 顶点缓冲区Size = (Position(float3) + Normal(float3) + Tangent(float4) + TexCoord(float2) + TexCoord1(float2)) x 24 = 1344Byte
- 索引缓冲区Size = Index(ushort) x 36 = 72Byte
可见,顶点、索引缓冲区内,确实只有一个网格的数据。
那么GPU如何判断每个Cube的绘制位置,及其颜色呢?
结合引擎为Dx平台生成的shader(我的测试环境使用的是Pc),可以很容易找到对应的数据。
转换矩阵及颜色被分别填入Constant Buffer中
Constant Buffer中的矩阵(Dx为行向量)
Constant Buffer中的属性(颜色)
可见,渲染时GPU可以通过当前实例化单位的索引,从Buffer中获取到对应的属性,完成正确的绘制。
| Unity中启用实例化渲染
当然,相比于上述无用的知识点,如何在Unity中使用实例化渲染可能更为重要。
在Unity中可以通过自动或手动的方式,启用实例化渲染。
自动启用实例化渲染
使用支持实例化渲染的Shader,并勾选材质球上的启用开关,Unity便会对满足条件的物体,自动开启实例化渲染。
有这个选项即表示该Shader支持实例化渲染
自定义Shader
如果你希望自己的Shader也支持实例化渲染,应重点注意以下内容:
#pragma multi_compile_instancing
启用实例化渲染(材质球上将出现启用实例化的勾选框);
UNITY_VERTEX_INPUT_INSTANCE_ID
在a2v及v2f的结构中定义实例化索引下标(SV_InstanceID ),也就是当前渲染单位的索引,用于从Constant Buffer中提取正确的属性(做显示差异化用);
UNITY_INSTANCING_BUFFER_START ~ END
在这个起止区域内定义属性,才能在着色器中正确的根据索引提取出当前渲染单位所对应的属性;
UNITY_SETUP_INSTANCE_ID
定义在着色器的起始位置,使顶点着色器(或片段着色器)可以正确的访问到实例化单位的索引;
UNITY_ACCESS_INSTANCED_PROP
根据索引访问到这个单位对应的属性,如上面例子中每个箱子的颜色属性。
这里只是简述一些相对重要的内容(凑些字数),官方文档中有更详细内容,建议优先了解。
手动实例化渲染
使用 Graphics.DrawMeshInstanced 和 Graphics.DrawMeshInstancedIndirect 来手动执行 GPU 实例化,详见官方文档中的解释,这里就不再赘述了。
| 实例化渲染的使用要求
并非所有设备都可以使用实例化渲染。
在Unity官方文档中,列举了各平台支持实例化渲染的最低要求。
官方文档中对实例化渲染的最低API支持要求
当然,我们也可以通过引擎中SystemInfo.supportsInstancing属性来判断环境是否支持实例化渲染。
那支持实例化渲染的机器占比大概是多少呢?由于国内大多数游戏公司都是以手游项目糊口。所以开发者可能会更多关注其在安卓平台上的情况。
根据Android开发者的官方数据显示,截至2020年8月30日,约88%的活跃安卓设备,都已经支持实例化渲染,所以基本上可以放心使用。
android开发者官网发布的活跃设备OpenGL ES版本占比信息
| 与静、动态合批的差异
静、动态合批实质上是将可以合批的对象真正的合并成一个大物体后,再通知GPU进行渲染,也就是其顶点索引缓冲区中必须包含全部参与合批对象的顶点信息;因此,可以认为是CPU完成的批处理。
实例化渲染是对网格信息的重复利用,无论最终要渲染出几个单位,其顶点和索引缓冲区内都只有一份数据,可以认为是GPU完成的批处理。
其实这么总结也有点问题,本质上讲:动、静态合批解决的是合批问题,也就是先有大量存在的单位,再通过一些手段合并成为批次;而实例化渲染其实是个复制的事儿,是从少量复制为大量,只是利用了它“可以通过传入属性实现差异化”的特点,在某些条件下达到了与合批相同的效果。
| 简单总结静、动态合批及实例化渲染
无论是静态合批、动态合批或实例化渲染,本质上并无孰优孰劣,它们都只是提高渲染效率的解决方案,也都有自己适合的场景或擅长解决的问题。
个人以为:
如果你的场景中存在多数静止的、使用了不同网格、相同材质的物体,特别是当你的相机通常只能照到一部分物体时(如第一视角),可以优先尝试下静态合批,通过牺牲一些内存来提升渲染效率;
针对那些运动的、网格顶点数很少、材质相同的物体,比如飞行的各种箭矢、炮弹等,使用动态合批,通过增加一些CPU处理顶点的性能开销,来提升渲染效率,也许是不错的选择;
如果有大量模型相同、材质相同、或尽管表现上有一些不同,但仍然可以通过属性来实现这些差异化的物体时,启用实例化渲染通常可以在很大程度上提升渲染效率。
| 写在最后
按计划下次更新的内容应该是“优化骨骼蒙皮动画,以及两种常用的批量渲染方式”,但觉得内容有点多,所以将其分为两个部分;因此,下次更新的内容变为“优化骨骼蒙皮动画”,而“两种常用的骨骼蒙皮动画单位的批量渲染方式”,将作为本系列的最后一次更新内容。
下回见喽。
我的公众号 偶尔学学Unity 会特别不定期更新与游戏开发可能有关的文章,欢迎关注,谢谢。