Unity性能优化-渲染-DrawCall优化——GPUInstancing 一.GPUInstancing是什么GPU Instancing本质上是为了解决“重复渲染相同网格的多次 DrawCall”这一性能瓶颈的问题其核心思想是一次绘制多个变体只传一次 Mesh 数据但多次使用仅改变每个实例的少量属性。极大减少了 CPU 到 GPU 的通信开销DrawCall 数释放 CPU性能飙升。GPU Instancing 是“让 GPU 重复绘制同一个物体多个版本”的一种超高效绘制方式是一种可以降低DrawCall的性能优化方式。二.关于GPUInstancing的冷知识1.在URP下,没有显式公开UNITY_GET_INSTANCE_ID宏。记得当时问AI的时候经常提及可以在Shader内获取实例ID像UNITY_GET_INSTANCE_ID和unity_instanceid 还以为能靠宏或全局变量方便直接获取到ID,但是Shader里却找不到,现在看来是一场乌龙但我们可以使用底层的SV_InstanceID语义来直接访问实例 ID。直接在顶点着色器的输入函数中写 uint id:SV_InstanceID;可以绕过 Unity 的宏封装。2.GPUInstancing与Batching合批的关系虽然GPUInstancing可以起到合批的作用但是一般都叫Batching和GPUInstancing区分开来。这里我习惯将Batching包括静态和动态批处理理解为Unity自动帮我们做合批优化特指CPU端的合批。而GPUInsatncing是我们手动提交DrawCall可以理解为GPU端的合批实例化不是Unity自动合批的结果。*CPU合批静态/动态批处理CPU把多个网格合成一个DrawCall这叫合批减少CPU Draw Call数量Unity会在Profiler里把它归为“Saved by Batching”。*GPU Instancing一次DrawCall绘制多个实例是GPU端的实例化技术CPU只发一次DrawCallGPU按实例渲染。Unity有时会把它也算进“Saved by Batching”但统计机制和CPU合批不一样特别是使用DrawMeshInstancedIndirect时这个统计可能不会显示。三.如何应用GPUInsatncing前提Shader内定义预编译宏,在材质面板开启EnableGPUInstancing。#pragma multi_compile_instancing一.Shader内定义实例对象属性在Shader内定义实例对象的自定义属性主要有两种方式使用UnityInsatcneBuffer和StructureBuffer下面分别介绍。1.方案一(不推荐):使用InstancingBuffer如果选择使用UNITY_INSTANCING_BUFFER来定义实例属性,必须依靠Unity的实例宏封装机制(1)UNITY_VERTEX_INPUT_INSTANCE_ID作用用于在Vertex Shader输入 / 输出结构中定义一个语义为SV_InstanceID的元素。使用场景顶点着色器输入/输出结构体中1必须在顶点着色器的输入结构体中声明struct appdata { float4 vertex : POSITION; UNITY_VERTEX_INPUT_INSTANCE_ID };2仅当需要在片元着色器中的实例属性时需要在顶点着色器输出结构体中声明struct v2f { float4 pos : SV_POSITION; //仅当您想访问片段着色器中的实例属性时才有必要 //UNITY_VERTEX_INPUT_INSTANCE_ID };(2)UNITY_INSTANCING_CBUFFER_START(name) / UNITY_INSTANCING_CBUFFER_END每个Instance独有的属性必须定义在一个遵循特殊命名规则的Constant Buffer中。使用这对宏来定义这些Constant Buffer。“name”参数可以是任意字符串。(3)UNITY_DEFINE_INSTANCED_PROP(float4, _name)定义一个具有特定类型和名字的每个Instance独有的Shader属性。这个宏实际会定义一个Uniform数组。参数1为类型支持float及floatx向量类型参数2为实例属性的名称。在Pass内定义INSTANCE_CBUFFERUNITY_INSTANCING_BUFFER_START(Props) UNITY_DEFINE_INSTANCED_PROP(float, _ShellIndex) UNITY_INSTANCING_BUFFER_END(Props)(4)UNITY_SETUP_INSTANCE_ID(v)这个宏必须在Vertex Shader的最开始调用如果你需要在Fragment Shader里访问Instanced属性则需要在Fragment Shader的开始也用一下。这个宏的目的在于让Instance ID在Shader函数里也能够被访问到。5UNITY_TRANSFER_INSTANCE_ID(v, o)在Vertex Shader中把Instance ID从输入结构拷贝至输出结构中。只有当你需要在Fragment Shader中访问每个Instance独有的属性时才需要写这个宏。6UNITY_ACCESS_INSTANCED_PROP(_name)可以说最重要最核心的一个前面的所有宏最终目的都是使用UNITY_ACCESS_INSTANCED_PROP(_name)来获取实例的自定义数据。可以访问声明在InstanceBuffer中的每个Instance独有的属性。这个宏会使用Instance ID作为索引到Uniform数组中去取当前Instance对应的数据。使用前必须先使用UNITY_SETUP_INSTANCE_ID(v);访问到实例IDVert/Frag{ UNITY_SETUP_INSTANCE_ID(v); float shellIndex UNITY_ACCESS_INSTANCED_PROP(Props, _ShellIndex); }*注意误区以一个“常见错误用法”举例说明控制脚本中这样写for (int i 0; i shellCount; i) { var mpb new MaterialPropertyBlock(); mpb.SetFloat(_ShellIndex, i); Graphics.DrawMesh( mesh, matrix, material, 0, null, 0, mpb, ShadowCastingMode.Off, false ); }Shader中这样写UNITY_INSTANCING_BUFFER_START(Props) UNITY_DEFINE_INSTANCED_PROP(float, _ShellIndex) UNITY_INSTANCING_BUFFER_END(Props) float shellIndex UNITY_ACCESS_INSTANCED_PROP(Props, _ShellIndex);你会以为“我用了UNITY_ACCESS_INSTANCED_PROP那就是 GPU Instancing 了吧”错这种写法其实就是N 个 DrawCall一次DrawMesh就是一次DrawCall循环n次就是n次DrawCall因为每个 MPB 不一样Unity 根本无法合批它就当你是单独画的 N 个物体。即使 Shader 看起来用了 GPU Instancing也完全没有实际效果Batch 不减少性能没提升反而更糟。2.方案二(推荐):使用StructuredBuffer如果你选择StructureBuffer来传递实例自定义数据恭喜你你可以忘记上面的有关Unity内置的实例封装机制相关的所有宏StructuredBuffer是一种只读的 GPU 缓冲区可在 Shader 中访问由 CPU或 ComputeShader传入的结构化数组。它不像CBUFFER那样局限于固定大小或不能索引。Shader内的StructuredBuffer需要和C#中的ComputerBuffer搭配使用完成实例属性数据的传递Shader内声明和使用语法struct appdata { float4 vertex : POSITION; uint id: SV_InstanceID; } StructuredBufferfloat _MyBuffer; v2f vert(appdata v) { //在Shader内StructuredBuffer常搭配实例的id属性使用。 float shellIndex _ShellIndexBuffer[v.id]; }3.优劣对比这里直接说,本人推荐使用StructuredBuffer,不推荐使用InstnceBuffer,观点如下:1.InsatnceBuffer适用场景狭窄且使用繁琐基本只适合以下情况1.只想通过DrawMeshInstanced()绘制;优中选优,我选性能天花板DrawMeshInstancedIndirect()2.每个实例只需要少量简单属性(仅支持flaotx类型);3.只传一个MPB统一绘制。仅适合极其简单但不灵活的 Instancing 场景。一旦你想实现复杂动画,实例数据动态扩容/更新,使用 ComputeShader 写入属性,用 Indirect 绘制,每帧动态控制实例显示状态,传结构体数组或 float3[]、int[]那InstanceBuffer立刻崩溃必须用StructuredBuffer。2.StructuredBuffer适用场景灵活且使用方便(1)完全不依赖 Unity 的 Instancing 宏;(2)DrawMeshInstanced()或DrawMeshInstancedIndirect()都能用;(3)不必考虑破坏合批;(4)属性数量、类型自由扩展。真正做到了想传什么就传什么。总结InstanceBuffer这种 Unity 宏方案越来越显得尴尬不够灵活、难扩展、还容易破坏合批而StructuredBuffer是真正现代化、可控、灵活、SRP 兼容的解决方案。二.控制脚本中传递实例数据一般选择使用GPUInstancing都是使用Graphics类中的实例绘制API的这里就不考虑Graphics.DrawMesh()了,而是以Graphics.DrawMeshInstanced()为例。1.方案一:MPB如果不考虑GPUInstancing一般有多MPB配合多次DrawMesh()的用法但由于GPUInsatncing的思想是一次绘制多个实例也就是只调用一次Graphics.DrawMeshInstanced()这要求使用同一个MPB所以在使用MPB传递实例数据时我们可以靠MPB传递一个数组传递自定义实例数据。Shader内定义数组变量float _ShellIndexArray[128]; // 注意最多128个Unity 限制 struct appdata { float4 vertex : POSITION; uint id : SV_InstanceID; // 必须获取实例ID }; struct v2f { float4 pos : SV_POSITION; float shellIndex : TEXCOORD0; }; v2f vert(appdata v) { v2f o; float shellIndex _ShellIndexArray[v.id]; }C#控制脚本内传递对应数组属性float[] shellIndexArray new float[instanceCount]; for (int i 0; i instanceCount; i) { xxx shellIndexArray[i] i; // 自定义 per-instance 数据 } mpb new MaterialPropertyBlock(); mpb.SetFloatArray(_ShellIndexArray, shellIndexArray);2.方案二(推荐):ComputeBufferComputeBuffer是 Unity 提供的一种 GPU 侧的内存容器用于在CPU 和 GPU 间传递结构化数据。它可以被(1)Shader内读取如 vertex/fragment shader(2)Compute Shader 中读写(3)也可用于 GPU Instancing 自定义每实例数据。Shader内定义StructedBuffer变量StructuredBufferT _MyBuffer;C#控制脚本内定义并绑定数据到ComputerBuffer中private ComputeBuffer shellIndexBuffer; float[] shellIndices; shellIndexBuffer new ComputeBuffer(shellCount, sizeof(float)); shellIndexBuffer.SetData(shellIndices); material.SetBuffer(_ShellIndexBuffer, shellIndexBuffer);3.对比优劣特性MaterialPropertyBlockComputeBuffer数据量少量参数几十以内大量数据上万条都行数据结构简单标量、数组任意结构体float3、matrix、struct等访问方式通过SetFloat/SetVector/SetMatrix通过 GPU buffer 或 ComputeShader 直接访问性能小批量非常轻量、CPU快创建稍慢占用显存访问灵活实例化支持支持每个实例传入不同值可替代 Unity Instancing支持间接绘制动态更新需要频繁调用 SetXXX()可高效一次性传入大批数据支持结构不支持复杂结构无 struct支持StructuredBufferTGPU 计算不支持支持支持 ComputeShader使用MPB更好只需要传一两个float给每个实例。实例数少几十个以内。使用ComputeBuffer更好每个实例要传多个复杂数据如float3 velocity,float4 color,float4x4 matrix。实现DrawMeshInstancedIndirect()也就是“完全由 GPU 控制绘制”。要通过ComputeShader动态修改实例数据。实例数非常多几千~几万级别。要共享数据给多个 Pass 或 Shader。三.ShaderC#控制脚本使用搭配在使用GPUInsatncing情况下主要有两种搭配方式,下面以Graphics.DrawMeshInstanced()为例。1.方案一:若干float[ ] MPBShader内直接定义float[ ]属性依靠实例id取值float _ShellIndexArray[128]; // 注意最多128个Unity 限制 struct appdata { float4 vertex : POSITION; uint id : SV_InstanceID; // 必须获取实例ID }; struct v2f { float4 pos : SV_POSITION; float shellIndex : TEXCOORD0; }; v2f vert(appdata v) { v2f o; float shellIndex _ShellIndexArray[v.id]; }C#控制脚本中使用MPB绑定若干个数组传递不同实例数据Matrix4x4[] matrices new Matrix4x4[shellCount]; MaterialPropertyBlock mpb new MaterialPropertyBlock(); float[] shellIndices new float[shellCount]; for (int i 0; i shellCount; i) { matrices[i] transform.localToWorldMatrix; shellIndices[i] i; } mpb.SetFloatArray(_ShellIndex, shellIndices); // 一次 DrawCall 才是真正 Instanced Graphics.DrawMeshInstanced( mesh, 0, material, matrices, shellCount, mpb );2.方案二: StructureBufferComputeBufferShader内定义StructedBuffer变量注意Shader中的StructedBuffer必须搭配C#脚本中的ComputerBuffer使用!struct MyData { float3 position; float4 color; }; StructuredBufferMyData _MyBuffer; //MyData也可以是float取决于用途可以额外不自定义 struct appdata { float4 vertex : POSITION; uint id : SV_InstanceID; // 必须获取实例ID }; struct v2f { float4 pos : SV_POSITION; float shellIndex : TEXCOORD0; }; v2f vert(appdata v) { v2f o; float shellIndex _MyBuffer[v.id]; }C#控制脚本中定义并绑定数据到ComputeBuffer内private ComputeBuffer shellIndexBuffer; float[] shellIndices; void Start(){ shellIndices new float[shellCount]; for (int i 0; i shellCount; i) { shellIndices[i] i; } shellIndexBuffer new ComputeBuffer(shellCount, sizeof(float)); shellIndexBuffer.SetData(shellIndices); material.SetBuffer(_ShellIndexBuffer, shellIndexBuffer); }本篇完