本文重点:
1、渲染非常多的球体 2、添加GPU Instancing支持 3、使用material property blocks 4、让instancing和LODgroups共存
这是渲染系列的第19篇教程。上一章节涵盖了 realtime GI, probe volumes, 和LOD groups,这一节我们来试一下另外一种缩减DrawCall的方法,合批。
该教程使用Unity 2017.1.0f3制作。
(数千个球体,只用了极少的批次)
1、合并实例
指示GPU绘制图像需要花费时间。为其提供数据(包括网格和材质属性)也需要时间。我们已经知道有两种方法可以减少绘制调用的数量,即静态和动态批处理。
Unity可以将静态对象的网格合并为更大的静态网格,从而减少draw calls。但只有使用相同材质的对象才能以这种方式组合,它是以存储更多网格数据为代价的。启用动态批处理后,Unity在运行时会对视图中的动态对象执行相同的操作。但仅适用于小型网格,否则会适得其反,开销反而变得非常大。
还有另一种组合绘图调用的方法。被称为GPUinstancing 或几何instancing 。与动态批处理一样,此操作在运行时针对可见对象完成。这个想法是让GPU一次性渲染同一网格多次。因此,它不能组合不同的网格或材质,但不局限于小网格。这里我们将试试这个方法。
1.1 很多的球体
要测试GPU instancing,我们需要渲染同一个网格很多次。首先我们来创建一个简单的球体prefab,这里先设置为白色的材质。
(白色的球体预置)
要实例化此球体,先创建一个测试组件,该组件会多次生成预制件并将其随机放置在球形区域内。让实例化产生的球体放置在它的子层级下,这样编辑器的层次结构窗口就不用显示数千个Instance实例而耗费性能了。
创建一个新场景,并使用此组件将测试对象放入其中。将球预制件分配给它。我将使用它在半径为50的球形范围内创建5000个球实例。
(测试对象)
将测试对象放置在原点处,将相机放置在(0,0,-100)处,可以确保看到整个球体。现在,我们可以使用游戏窗口的统计面板来确定如何绘制所有对象。关闭主光源的阴影,以便仅绘制球体以及背景。再将相机设置为使用forward rendering路径。
(球形范围的大量球体实例)
在刚才的示例中,它需要5002次DC来渲染视图,在统计面板中称为“Batches”。那是5000个球体,外加两个额外的背景和相机效果。请注意,即使启用了动态批处理,也不会批处理这些球。那是因为球体网格太大。如果我们改用立方体的话,它们将会被批处理。
(球形范围的大量立方体实例)
对于立方体,我们只用了8个批次,因此所有的立方体渲染实际上只占用了6个批次。一共减少了4994个批次调用,这个值可以在Saved by batching 下看到。就本示例而言,它还记录了更高的帧率。比如83而不是使用球体的35。fps是对渲染帧时间的度量,而不是实际帧率,但这仍然是性能差异的良好指标。立方体的绘制速度更快,因为它们是批处理的,而且还因为立方体比球体所需的网格数据少得多。因此,这不是一个公平的比较。
由于编辑器自身会产生大量开销,因此构建中的性能差异可能会更大。尤其是场景窗口会使渲染放慢很多,因为这是必须渲染的额外视图。在播放模式下,我将其隐藏以提高性能。
1.2 支持实例化(Instancing)
默认情况下,还无法进行GPU实例化。必须设计着色器来支持它。我们需要给每种材质显式的启用实例化。Unity的标准着色器对此有一个开关。我们也向MyLightingShaderGUI添加实例化的开关。像标准着色器的GUI一样,我们将为其创建“Advanced Options”部分。可以通过调用MaterialEditor.EnableInstancingField方法来添加开关。在一个新的DoAdvanced方法里添加逻辑吧。
把这个部分添加到我们GUI的底部。
选择白色材质。现在,一个Advanced Options标题在其检查器的底部可见。但是,还没有控制实例化的开关。
(现在尚不支持 实例化)
仅当着色器实际支持实例化时,才会显示该开关。我们可以通过将#pragma multi_compile_instancing指令添加到着色器来启用此支持。这将为一些关键字启用着色器变体,在我们的示例中为INSTANCING_ON,但其他关键字也是可以的。为“My First Lighting”的base pass执行此操作。
(支持和开启 实例化)
我们的材质现在具有“Enable Instancing”开关。打开将改变球体的渲染方式。
(每一个批次只有一个位置)
在现在的示例下,批处理数量已减少到42,这意味着现在仅用40个批处理即可渲染所有5000个球体。帧率也高达80 fps,但是只有几个球体可见。
实际上所有5000个球体都在渲染,只是同一批中的所有球体都位于同一位置。它们都使用批次中第一个球的转换矩阵。发生这种情况是因为现在一批中所有球体的矩阵都作为数组发送到GPU。在不告知着色器要使用哪个数组索引的情况下,它始终使用第一个索引。
1.3 实例 Ids
与实例相对应的数组索引称为其实例ID。GPU通过顶点数据将其传递到着色器的顶点程序。在大多数平台上,它是一个无符号整数,名为instanceID,具有SV_InstanceID语义。我们可以简单地使用UNITY_VERTEX_INPUT_INSTANCE_ID宏将其包含在我们的VertexData结构中。它在UnityCG中包含的UnityInstancing中定义。它为我们提供了实例ID的正确定义,或者在未启用实例化时不提供任何内容。将其添加到“My Lighting”中的VertexData结构。
启用实例化后,我们现在可以在顶点程序中访问实例ID。有了它,就可以在变换顶点位置时使用正确的矩阵。但是,UnityObjectToClipPos没有矩阵参数。它始终使用unity_ObjectToWorld。要解决此问题,UnityInstancing包含文件会使用使用矩阵数组的宏覆盖unity_ObjectToWorld。这可以被认为是一种宏的
Dirty Hack,但它无需更改现有着色器代码即可工作,从而确保了向后兼容性。
(Dirty Hack:以不符合设计原理 不易维护 不易调整 不够健壮 不够美观的方式解决问题,https://www.zhihu.com/question/20372589)
要使Hack工作,实例的数组索引必须对所有着色器代码全局可用。我们通过UNITY_SETUP_INSTANCE_ID宏进行手动设置,该宏必须在顶点程序中完成,然后再执行任何可能需要它的代码。
(实例化的球体)
着色器现在可以访问所有实例的变换矩阵,因此球体将在其实际位置进行渲染。
矩阵数组替换是怎么起作用的?
在最简单的情况下,启用实例化可以总结为这一点。
UnityInstinging中的实际代码要复杂得多。它处理平台的差异,其他使用实例的方式,以及立体渲染的特殊代码,这导致了间接定义的多个步骤。它还必须重新定义UnityObjectToClipPos,因为UnityCG首先包含UnityShaderUtilities 。
稍后将解释缓冲区宏。
1.4 合批大小
你最终得到的批次数量可能与我得到的数量不同。在我的情况下,以40批渲染5000个球体实例,这意味着每批125个球体。
每个批次都需要自己的矩阵数组,此数据发送到GPU并存储在内存缓冲区中,在Direct3D中称为常量缓冲区,在OpenGL中称为统一(uniform)缓冲区。这些缓冲区具有最大容量限制,它限制了一个批次中可以容纳多少个实例。假设台式机GPU每个缓冲区的限制为64KB。
一个矩阵由16个浮点数组成,每个浮点数均为4个字节。因此,每个矩阵64个字节。每个实例都需要一个对象到世界的转换矩阵。但是,我们还需要一个世界到对象的矩阵来转换法线向量。因此,最终每个实例有128个字节。这导致最大批处理大小为 64000/128 = 500,能在10个批处理中渲染5000个球体。
最大值不是512吗?
内存的计量是2进制,不是10进制所以1KB代表1024个bytes。所以64*1024/128=512。
默认情况下,UNITY_INSTANCED_ARRAY_SIZE定义为500,但是你可以使用编译器指令覆盖它。例如,#pragma instancing_options maxcount:512将最大值设置为512。但是,这会将导致断言失败的错误,因此实际限制为511。其实500和512之间没有太大差异。
尽管台式机的最大容量为64KB,但假定大多数移动设备的最大容量仅为16KB。Unity通过在针对OpenGL ES 3,OpenGL Core或Metal时将最大值除以四来解决此问题。因为我在编辑器中使用的是OpenGL Core,所以最终的最大批处理大小为 500/4 = 125。
你可以通过添加编译器指令#pragma instancing_options force_same_maxcount_for_gl来禁用该自动减少功能。多个实例化选项组合在同一指令中。但是,这可能会导致在部署到移动设备上时发生问题,因此需要小心使用。
那assumeuniformscaling选项呢?
你可以使用#pragma instancing_options假定统一缩放来指示所有实例对象具有统一的缩放比例。这消除了将世界到对象矩阵用于法线转换的需要。设置此选项后,虽然UnityObjectToWorldNormal函数确实会更改其行为,但它不会消除第二个矩阵数组。因此,在Unity 2017.1.0以前,此选项实际上没有任何作用。
1.5 实例化阴影
到目前为止,我们还没有阴影。重新打开主阴影的柔和阴影,并确保阴影距离足以包含所有球体。当相机位于-100且球体的半径为50时,阴影距离150对我来说足够了。
(很多的阴影)
为5000个球体渲染阴影会给GPU造成巨大损失。但是我们也可以在渲染球体阴影时使用GPU实例化。将所需指令添加到阴影caster pass中。
再将UNITY_VERTEX_INPUT_INSTANCE_ID和UNITY_SETUP_INSTANCE_ID添加到“My Shadows”中。
(实例化阴影)
现在批次有了大幅度的降低。
1.6 多灯光
我们仅在base pass和shadow caster pass中添加了实例化支持。因此,批处理不适用于其他光源。要验证这一点,请停用主光源并添加一些会影响多个球体的聚光灯或点光源。但不要为它们打开阴影,因为那样会降低帧率。
(多灯光会导致渲染性能急速下降)
事实证明,不受额外光照影响的球体仍与阴影一起进行批处理。但是其他区域甚至没有在其base pass中分批处理。对于这些情况,Unity完全不支持批处理。要将实例化与多个光源结合使用,现在别无选择,只能切换到deferred rendering 路径。为此,请将所需的编译器指令添加到着色器的deferred pass中。
(延迟光照下的多灯光表现)
在确认它可以用于延迟渲染后,切换回正向渲染模式。
2 混合材质属性
所有批处理形式的限制之一是它们仅限于具有相同材质的对象。当我们希望渲染的对象具有多样性时,此限制就会成为阻碍。
2.1 随机颜色
例如,当我们改变球体的颜色。创建每个实例的材质后,为其分配随机颜色。这将隐式创建共享的材质副本,因此最终在内存中有5000个材质实例。
(随机颜色的球体,没有阴影和合批)
即使我们为材质启用了批处理,它也不再起作用。关闭阴影可以更清楚地看到这一点。我们回到每个球体一次抽DC。而且由于每个球体现在都有自己的材质,因此每个球体的着色器状态也必须更改。这在统计面板中显示为SetPass Calls。它曾经是所有的球体共用一个,但是现在是5000。结果,我的帧率下降到了10fps。
2.2 材质属性块
除了使用每个球体创建新的材质实例外,我们还可以使用材质属性块。这些是小的对象,其中包含着色器属性的重写。设置属性块的颜色并将其传递给球体的渲染器,而不是直接分配材质的颜色。
MeshRenderer.SetPropertyBlock方法复制该块的数据,因此不依赖于我们在本地创建的块。这使我们可以重用一个块来配置所有实例。
进行此更改后,我们将返回所有球体的SetPassCall。但它们又是白色的。这是因为GPU尚不知道该属性的重写。
2.3 Property Buffers
渲染实例对象时,Unity通过将数组上传到其内存来使转换矩阵可用于GPU。Unity对存储在材料属性块中的属性执行相同的操作。但这要起作用的话,必须在“My Lighting”中定义一个适当的缓冲区。
声明实例化缓冲区的工作类似于创建诸如插值器之类的结构,但是确切的语法因平台而异。我们可以使用UNITY_INSTANCING_CBUFFER_START和UNITY_INSTANCING_CBUFFER_END宏来解决差异。启用实例化后,它们还不会做任何操作。
将_Color变量的定义放在实例缓冲区中。UNITY_INSTANCING_CBUFFER_START宏需要一个名称参数。实际名称无关紧要。宏以UnityInstancing_为其前缀,以防止名称冲突。
像变换矩阵一样,启用实例化后,颜色数据将作为数组上传到GPU。UNITY_DEFINE_INSTANCED_PROP宏会为我们处理正确的声明语法。
要访问片段程序中的数组,我们还需要在其中知道实例ID。因此,将其添加到interpolator 结构中。
在顶点程序中,将ID从顶点数据复制到interpolators。启用实例化时,UNITY_TRANSFER_INSTANCE_ID宏定义此简单操作,否则不执行任何操作。
在片段程序的开头,使ID全局可用,就像在顶点程序中一样。
现在,我们必须在不使用实例化时以_Color的形式访问颜色,而在启用实例化时以_Color [unity_InstanceID]的形式访问颜色。我们可以为此使用UNITY_ACCESS_INSTANCED_PROP宏。
它为什么不编译,或者为什么Unity更改我的代码?
自Unity 2017.3起,UNITY_ACCESS_INSTANCED_PROP宏已更改。现在,它要求您提供缓冲区名称作为第一个参数。代替使用UNITY_ACCESS_INSTANCED_PROP(_Color),请使用UNITY_ACCESS_INSTANCED_PROP(InstanceProperties,_Color)
(合批的带颜色的球体)
现在,我们的颜色随机的球再次被批处理。我们可以用相同的方式使其他属性可变。对于颜色,浮点数,矩阵和四分量浮点向量,这是可以的。如果要改变纹理,可以使用单独的纹理数组,并将索引添加到实例化缓冲区。
可以在同一个缓冲区中组合多个属性,但要牢记大小限制。还应注意,缓冲区被划分为32位块,因此单个浮点数需要与向量相同的空间。您也可以使用多个缓冲区,但是也有一个限制,它们不是免费提供的。启用实例化后,每个要缓冲的属性都将成为一个数组,因此仅对需要根据实例变化的属性执行此操作。
2.4 阴影
我们的阴影也取决于颜色。调整“My Shadows”,以便每个实例也可以支持唯一的颜色。
2.5 LOD Instancing
上次,我们增加了对LOD组的支持。让我们看看它们是否与GPU实例兼容。使用LOD组创建一个新的预制件,该LOD组仅包含一个包含白色材质的球体。将其设置为Cross Fade并进行配置,以使LOD 0在过渡宽度0.25时被剔除为3%。这为我们明显的小球体提供了一个不错的过渡范围。
(LOD 球体预置)
将此预制件关联到我们的测试对象,而不是常规球体。由于此对象本身没有网格渲染器,因此此时进入播放模式时会出现错误。我们必须调整GPUInstancingTest.Start,以便在根对象本身没有渲染器的情况下访问子对象的渲染器。在进行此操作时,请确保它适用于具有任意级别的简单对象和LOD组。
(没有实例化的LOD渐隐,带有阴影)
不幸的是,如果没有有效的批处理,我们现在将获得Fade范围。Unity能够对以相同的LOD褪色因子结束的球进行批处理,但是如果可以像往常一样对它们进行批处理会更好。我们可以通过用缓冲数组替换unity_LODFade来实现。为支持实例化的每个Pass添加lod fade实例化选项来指示Unity的着色器代码执行此操作。
(实例LOD融合)
现在,我们的着色器同时支持最佳实例化和LOD渐变。
下一个教程是 视差([Parallax])。