Unity性能优化-渲染模块(1)-CPU侧(2)-DrawCall优化(2)GPUInstancing

发布于:2025-07-01 ⋅ 阅读:(27) ⋅ 点赞:(0)

一.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 IDShader函数里也能够被访问到。

(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);
}

本篇完