渲染大量球体-优化DrawCall
支持GPU-Instance
使用材质属性块
LOD-Groups支持GPU-Instance
Unity 2017.1.0f3
1 Batching Instance-批处理
指示GPU绘制需要花时间;向其传递mesh和material属性也要花时间。现在已知两种节省Draw Call的方式:static和dynamic batching
Unity可以将多个静态物体的网格合并为一个更大的静态网格,从而减少draw call。 注意:只有使用相同材质的对象才能以这种方式组合。 这是以必须存储更多网格数据为代价的。 启用动态批处理后,Unity在运行时会对视图中的动态对象执行相同的操作。 这仅适用于小型网格物体,否则开销将变得太大。
还有另一种组合draw call的方法:GPU instance或Geometry instance。与动态批处理一样,此操作在运行时针对可见对象。 它的目标是让GPU一次性渲染同一网格的多个副本。 因此,它不能组合不同的网格或材质,但不仅限于小网格。
1.1 创建大量球体
using UnityEngine; public class GPUInstancingTest : MonoBehaviour { public Transform prefab; public int instances = 5000; public float radius = 50f; //单位圆内随机一点并放大坐标50倍,生成5000个球体 //然后查看statistics统计的draw Call信息 void Start () { for (int i = 0; i < instances; i++) { Transform t = Instantiate(prefab); t.localPosition = Random.insideUnitSphere * radius; t.SetParent(transform); } } }
使用forward render path统计到的draw call,去掉背景和camera Effect两个draw call:
5000 draw call
但是当使用cube代替球体
6 draw call
1.2 支持Instance
默认情况下,GPU Instance不会开启,必须设计shader以支持它。 即使这样,也必须为每种材料显式启用实例化。 Unity的standard着色器有一个开关。像标准着色器的GUI一样,我们将为shader扩展面板创建“高级选项”部分。 可以通过调用MaterialEditor.EnableInstancingField方法来添加切换。
void DoAdvanced () { GUILayout.Label("Advanced Options", EditorStyles.boldLabel); editor.EnableInstancingField(); }
仅当shader实际支持instance时,才会显示该切换。 我们可以通过将#pragma multi_compile_instancing指令添加到着色器base-pass启用此支持。 这将为一些关键字启用着色器变体,自定义关键字INSTANCING_ON,其他关键字也可以。
#pragma multi_compile_fwdbase #pragma multi_compile_fog #pragma multi_compile_instancing
instance开关
合并了,但是显示有错误
批处理数量已减少到42,这意味着现在仅用40个批处理即可渲染所有5000个球体。帧速率也高达80 fps,但是只有几个球体可见。错误原因:虽然5000个球体仍在渲染,但是在合批中同一批次的所有球体的顶点转换时都使用了同一个位置:它们都使用同一批次中第一个球的转换矩阵。 发生这种情况是因为现在同一批中所有球体的矩阵都作为数组发送到GPU。 在不告知着色器要使用哪个数组索引的情况下,它始终使用第一个索引。
1.3 Instance IDs
上述错误解决办法:每个Instance相对应的数组索引称为其Instance ID,GPU通过顶点数据将其传递到着色器的vertex程序。在大多数平台上,它是一个无符号整数,名为instanceID,具有SV_InstanceID语义。 我们可以简单地使用UNITY_VERTEX_INPUT_INSTANCE_ID宏将其包含在我们的VertexData结构中。 它在UnityCG中包含的UnityInstancing.cginc文件中定义。 它为我们提供了实例ID的正确定义,或者在未启用实例化时不提供任何内容。将其添加到VertexData结构。
struct VertexData { UNITY_VERTEX_INPUT_INSTANCE_ID float4 vertex : POSITION; … };
启用instance后,我们现在可以在顶点程序中访问instanceID。 有了它,我们可以在变换顶点位置时使用正确的矩阵。 但是,UnityObjectToClipPos函数没有矩阵参数,它函数内部始终使用unity_ObjectToWorld矩阵。要解决此问题,UnityInstancing包含文件会使用矩阵数组的宏覆盖unity_ObjectToWorld。 这可以被认为是肮脏的宏技巧,但无需更改现有着色器代码即可工作,从而确保了向后兼容性。
要使它工作,instance的数组索引必须对所有着色器代码全局可用。必须通过UNITY_SETUP_INSTANCE_ID宏进行手动设置,该宏必须在vertex程序最先计算,然后再执行其他的代码。
InterpolatorsVertex MyVertexProgram (VertexData v) { InterpolatorsVertex i; UNITY_INITIALIZE_OUTPUT(Interpolators, i); UNITY_SETUP_INSTANCE_ID(v); i.pos = UnityObjectToClipPos(v.vertex); … }
正确显示
矩阵替换内部实现?
//UnityInstancing中的实际代码要复杂得多。 它要处理平台差异,其他使用实例化的方法以及用于立 //体声渲染的特殊代码,从而导致间接定义的多个步骤。 它还必须重新定义UnityObjectToClipPos,因 //为UnityCG首先包含UnityShaderUtilities。 //缓冲区宏将在后面说明。 static uint unity_InstanceID; CBUFFER_START(UnityDrawCallInfo) // Where the current batch starts within the instanced arrays. int unity_BaseInstanceID; CBUFFER_END #define UNITY_VERTEX_INPUT_INSTANCE_ID uint instanceID : SV_InstanceID; #define UNITY_SETUP_INSTANCE_ID(input) \ unity_InstanceID = input.instanceID + unity_BaseInstanceID; // Redefine some of the built-in variables / // macros to make them work with instancing. UNITY_INSTANCING_CBUFFER_START(PerDraw0) float4x4 unity_ObjectToWorldArray[UNITY_INSTANCED_ARRAY_SIZE]; float4x4 unity_WorldToObjectArray[UNITY_INSTANCED_ARRAY_SIZE]; UNITY_INSTANCING_CBUFFER_END #define unity_ObjectToWorld unity_ObjectToWorldArray[unity_InstanceID] #define unity_WorldToObject unity_WorldToObjectArray[unity_InstanceID]
1.4 批处理大小
每台设备不一样,最终得到的批次数量可能与当前实验得到的数量不同。现在这情况下,以40批渲染5000个球体实例,这意味着每批125个球体。
每个批次都需要自己的矩阵数组。 此数据发送到GPU并存储在内存缓冲区中,在Direct3D中称为常量缓冲区,在OpenGL中称为统一缓冲区。 这些缓冲区具有最大大小,这限制了一批中可以容纳多少个实例。 假设台式机GPU每个缓冲区的限制为64KB。
一个矩阵由16个浮点数组成,每个浮点数均为4个字节。 因此,每个矩阵64个字节。 每个实例都需要一个对象到世界的转换矩阵。 但是,我们还需要一个世界到对象的矩阵来转换法线向量。 因此,最终每个实例有128个字节。 这导致最大批处理大小为“ 64000/128 = 500”,这只能在10个批处理中渲染5000个球体。
内存单位是2进制,所以1KB表示1024字节,而不是1000。因此,'(64 * 1024)/ 128 = 512 '。UNITY_INSTANCED_ARRAY_SIZE默认定义为500,但您可以使用编译器指令覆盖它。例如,#pragma instancing_options maxcount:512将最大值设置为512。但是,这将导致断言失败错误,因此实际限制为511。到目前为止,500和512之间没有太大的差别。
即使假设台式机的最大容量为64KB成立,但是大多数移动设备的最大容量远远达不到64,可能仅为16KB。 Unity通过在针对OpenGL ES 3,OpenGL Core或Metal时将最大值除以四来解决此问题。 因为我在编辑器中使用的是OpenGL Core,所以最终的最大批处理大小为“ 500/4 = 125”。
可以通过添加编译器指令#pragma instancing_options force_same_maxcount_for_gl来禁用此自动减少功能。 多个instance选项组合在同一指令中。 但是,这可能会导致在部署到移动设备上时发生故障,因此请小心使用。
那假设均等缩放选项呢? 可以使用#pragma instancing_options指示所有instance对象具有统一的缩放比例。 这消除了将世界到对象矩阵用于法线转换的需要(少存储一个矩阵)。 设置此选项后,虽然UnityObjectToWorldNormal函数确实会更改其行为,但它不会消除第二个矩阵数组。 因此,至少在Unity 2017.1.0中,此选项实际上没有任何作用。
1.5 Instance Shadows
到目前为止,一直没有阴影。 重新打开主阴影的Soft shadow,并确保阴影距离足以包含所有球体
批处理爆炸
为大量物体渲染阴影会增加GPU耗能。但是我们也可以在渲染球体阴影时使用GPU instance。在shadow caster-pass中添加instance指令;同时也增加UNITY_VERTEX_INPUT_INSTANCE_ID
and UNITY_SETUP_INSTANCE_ID
#pragma multi_compile_shadowcaster #pragma multi_compile_instancing
struct VertexData { UNITY_VERTEX_INPUT_INSTANCE_ID … }; … InterpolatorsVertex MyShadowVertexProgram (VertexData v) { InterpolatorsVertex i; UNITY_SETUP_INSTANCE_ID(v); … }
instanced 阴影
1.6 多光源
我们仅在base-pass和shadow caster-pass中添加了instance支持。 因此,批处理不适用于其他光源。 要验证这一点,停用主光源并添加一些会影响多个球体的聚光灯或点光源。 不要为它们打开阴影,因为那样会降低帧速率。
批处理爆炸
上图,完全不支持多光源批处理。 要将instance与多个光源结合使用,只能切换到延迟渲染路径。 为此,请将所需的编译器指令添加到着色器的延迟传递中。
#pragma multi_compile_prepassfinal #pragma multi_compile_instancing
多光源instance
2 Mixing Material Properties
所有批处理都有一个限制:它们仅限于具有相同材料的对象。 当我们希望渲染的对象具有多样性时,此限制就会成为问题。
2.1 随机着色
随机改变球体的颜色
void Start () { for (int i = 0; i < instances; i++) { Transform t = Instantiate(prefab); t.localPosition = Random.insideUnitSphere * radius; t.SetParent(transform); t.GetComponent().material.color = new Color(Random.value, Random.value, Random.value); } }
球体与随机的颜色,没有批量和阴影
即使我们为物料启用了批处理,它也不再起作用。由于每个球体现在都有自己的材质,因此每个球体的着色器状态也必被更改。 这显示在统计面板中为SetPass call 数量。它曾经是所有领域的一体机,但是现在是5000。
2.2 材质属性块-Material Property Blocks
除了为每个球体创建新的材质实例外,我们还可以使用材质属性块。 这些是小的修改,设置属性块的颜色并将其传递给球体的渲染器,而不是直接分配材质的颜色。
void Start () { MaterialPropertyBlock properties = new MaterialPropertyBlock(); for (int i = 0; i < instances; i++)
{ Transform t = Instantiate(prefab); t.localPosition = Random.insideUnitSphere * radius; t.SetParent(transform);// MaterialPropertyBlock properties = new MaterialPropertyBlock();properties.SetColor
( "_Color", new Color(Random.value, Random.value, Random.value) ); t.GetComponent().SetPropertyBlock(properties); } }
2.3 Property Buffers-属性缓冲区
渲染instance对象时,Unity通过将数组传递到GPU内存来使转换矩阵可用于GPU。 Unity对存储在材料属性块中的属性执行相同的操作。 但这要起作用,我们必须在shader中定义一个适当的缓冲区。
声明instance缓冲区的工作类似于创建诸如插值器之类的结构,但是确切的语法因平台而异。 我们可以使用UNITY_INSTANCING_CBUFFER_START和UNITY_INSTANCING_CBUFFER_END宏来解决差异。 启用实例化后,它们将不执行任何操作。
将_Color变量的定义放在instance缓冲区中。 UNITY_INSTANCING_CBUFFER_START宏需要一个名称参数。 实际名称无关紧要。 宏以UnityInstancing_为其前缀,以防止名称冲突。
UNITY_INSTANCING_CBUFFER_START(InstanceProperties)
float4 _Color;
UNITY_INSTANCING_CBUFFER_END
像变换矩阵一样,启用instance后,颜色数据作为数组上传到GPU。UNITY_DEFINE_INSTANCED_PROP宏会为我们处理正确的声明语法。
UNITY_INSTANCING_CBUFFER_START(InstanceProperties) //float4 _Color; UNITY_DEFINE_INSTANCED_PROP(float4, _Color) UNITY_INSTANCING_CBUFFER_END
最后要访问fragment程序中的数组,我们还需要在其中知道instanceID。 因此,将其添加到插值器结构中。
struct InterpolatorsVertex { UNITY_VERTEX_INPUT_INSTANCE_ID … }; struct Interpolators { UNITY_VERTEX_INPUT_INSTANCE_ID … };
在vertex顶点程序中,将ID从顶点数据复制到插值器。 启用实例化时,UNITY_TRANSFER_INSTANCE_ID宏定义此简单操作,否则不执行任何操作。
InterpolatorsVertex MyVertexProgram (VertexData v) { InterpolatorsVertex i; UNITY_INITIALIZE_OUTPUT(Interpolators, i); UNITY_SETUP_INSTANCE_ID(v); UNITY_TRANSFER_INSTANCE_ID(v, i); … }
在片段程序的开头,使ID全局可用,就像在顶点程序中一样。
FragmentOutput MyFragmentProgram (Interpolators i) { UNITY_SETUP_INSTANCE_ID(i); … }
现在,我们必须在不使用instance时以_Color的形式访问颜色,而在启用实例化时以_Color [unity_InstanceID]的形式访问颜色。 使用UNITY_ACCESS_INSTANCED_PROP宏可同时支持上述两种访问。
float3 GetAlbedo (Interpolators i) { float3 albedo = tex2D(_MainTex, i.uv.xy).rgb * UNITY_ACCESS_INSTANCED_PROP(_Color).rgb; … } float GetAlpha (Interpolators i) { float alpha = UNITY_ACCESS_INSTANCED_PROP(_Color).a; … }
新版本如果编译有错误: 从2017.3及以上版本, UNITY_ACCESS_INSTANCED_PROP macro改了.它需要两个参数:buffer名,颜色名使用UNITY_ACCESS_INSTANCED_PROP(InstanceProperties, _Color).
现在,我们的颜色随机的球再次被批处理。 我们可以用相同的方式使其他属性可变。 对于颜色,浮点数,矩阵和四分量浮点向量,这是可能的。 如果要改变纹理,可以使用单独的纹理数组,并将索引添加到实例化缓冲区。其他属性修改类似。
可以在同一个缓冲区中组合多个属性,但要牢记大小限制。 还应注意,缓冲区被划分为32位块,因此单个浮点数需要与向量相同的空间。 您也可以使用多个缓冲区,但是也有一个限制,它们不是免费提供的。 启用instance后,每个要缓冲的属性都将成为一个数组,因此仅对需要根据instance变化的属性执行此操作。
2.4 阴影
我们的阴影也取决于颜色。 调整shader阴影以便每个实例也可以支持唯一的颜色。
//float4 _Color; UNITY_INSTANCING_CBUFFER_START(InstanceProperties) UNITY_DEFINE_INSTANCED_PROP(float4, _Color) UNITY_INSTANCING_CBUFFER_END … struct InterpolatorsVertex { UNITY_VERTEX_INPUT_INSTANCE_ID … }; struct Interpolators { UNITY_VERTEX_INPUT_INSTANCE_ID … }; float GetAlpha (Interpolators i) { float alpha = UNITY_ACCESS_INSTANCED_PROP(_Color).a; … } InterpolatorsVertex MyShadowVertexProgram (VertexData v) { InterpolatorsVertex i; UNITY_SETUP_INSTANCE_ID(v); UNITY_TRANSFER_INSTANCE_ID(v, i); … } float4 MyShadowFragmentProgram (Interpolators i) : SV_TARGET { UNITY_SETUP_INSTANCE_ID(i); … }
2.5 LOD Instance
void Start () { MaterialPropertyBlock properties = new MaterialPropertyBlock(); for (int i = 0; i < instances; i++) { Transform t = Instantiate(prefab); t.localPosition = Random.insideUnitSphere * radius; t.SetParent(transform); //MaterialPropertyBlock properties = new MaterialPropertyBlock(); properties.SetColor ( "_Color", new Color(Random.value, Random.value, Random.value) ); //t.GetComponent().SetPropertyBlock(properties); MeshRenderer r = t.GetComponent(); if (r) { r.SetPropertyBlock(properties); } else {
//对LOD子对象设置颜色 for (int ci = 0; ci < t.childCount; ci++) { r = t.GetChild(ci).GetComponent(); if (r) { r.SetPropertyBlock(properties); } } } } }
不幸的是没有有效的批处理。Unity能够对以相同的LOD颜色球体进行批处理,但是如果可以像往常一样进行批处理会更好。 我们可以通过用缓冲数组替换unity_LODFade来实现。可以通过为支持实例化的每个过程添加lodfade实例化选项来指示Unity的着色器代码执行此操作。
#pragma multi_compile_instancing #pragma instancing_options lodfade
instance LOD fading