一.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只发一次DrawCall,GPU按实例渲染。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_CBUFFER
UNITY_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函数里也能够被访问到。
(5)UNITY_TRANSFER_INSTANCE_ID(v, o)
在Vertex Shader中把Instance ID从输入结构拷贝至输出结构中。只有当你需要在Fragment Shader中访问每个Instance独有的属性时才需要写这个宏。
(6)UNITY_ACCESS_INSTANCED_PROP(_name)
可以说最重要最核心的一个,前面的所有宏最终目的都是使用UNITY_ACCESS_INSTANCED_PROP(_name)来获取实例的自定义数据。
可以访问声明在InstanceBuffer中的每个Instance独有的属性。这个宏会使用Instance ID作为索引到Uniform数组中去取当前Instance对应的数据。
使用前必须先使用UNITY_SETUP_INSTANCE_ID(v);访问到实例ID!
Vert/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;
}
StructuredBuffer<float> _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.方案二(推荐):ComputerBuffer
ComputeBuffer
是 Unity 提供的一种 GPU 侧的内存容器,用于在 CPU 和 GPU 间传递结构化数据。它可以被:
(1)Shader内读取;(如 vertex/fragment shader)
(2)Compute Shader 中读写;
(3)也可用于 GPU Instancing 自定义每实例数据。
Shader内定义StructedBuffer变量:
StructuredBuffer<T> _MyBuffer;
C#控制脚本内定义并绑定数据到ComputerBuffer中:
private ComputeBuffer shellIndexBuffer;
float[] shellIndices;
shellIndexBuffer = new ComputeBuffer(shellCount, sizeof(float));
shellIndexBuffer.SetData(shellIndices);
material.SetBuffer("_ShellIndexBuffer", shellIndexBuffer);
3.对比优劣
特性 | MaterialPropertyBlock |
ComputeBuffer |
---|---|---|
数据量 | 少量参数(几十以内) | 大量数据(上万条都行) |
数据结构 | 简单标量、数组 | 任意结构体(float3、matrix、struct等) |
访问方式 | 通过SetFloat/SetVector/SetMatrix |
通过 GPU buffer 或 ComputeShader 直接访问 |
性能(小批量) | 非常轻量、CPU快 | 创建稍慢,占用显存,访问灵活 |
实例化支持 | 支持每个实例传入不同值 | 可替代 Unity Instancing,支持间接绘制 |
动态更新 | 需要频繁调用 SetXXX() | 可高效一次性传入大批数据 |
支持结构 | 不支持复杂结构(无 struct) | 支持 StructuredBuffer<T> |
GPU 计算 | 不支持 | 支持(支持 ComputeShader) |
使用 MPB
更好:
只需要传一两个float给每个实例。
实例数少(几十个以内)。
使用 ComputeBuffer
更好:
每个实例要传多个复杂数据:如 float3 velocity
, float4 color
, float4x4 matrix
。
实现 DrawMeshInstancedIndirect()
,也就是“完全由 GPU 控制绘制”。
要通过 ComputeShader
动态修改实例数据。
实例数非常多(几千~几万级别)。
要共享数据给多个 Pass 或 Shader。
三.Shader+C#控制脚本使用搭配
在使用GPUInsatncing情况下主要有两种搭配方式,下面以Graphics.DrawMeshInstanced()为例。
1.方案一:若干float[ ] +MPB
Shader内直接定义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.方案二: StructureBuffer+ComputerBuffer
Shader内定义StructedBuffer变量:
注意:Shader中的StructedBuffer必须搭配C#脚本中的ComputerBuffer使用!
struct MyData
{
float3 position;
float4 color;
};
StructuredBuffer<MyData> _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#控制脚本中定义并绑定数据到ComputerBuffer内:
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);
}
本篇完