Deep Read

GPU Instancing - DrawMeshInstancedIndirect

DrawMeshInstancedIndirect 合批流程

DrawMeshInstancedIndirect 把"这帧画多少个实例"的决策权从 CPU 移到 GPU:CPU 不再遍历、剔除、统计数量,而是把全部候选实例、一段做剔除的 Compute、一个绘制参数缓冲交给 GPU,由 GPU 自己算出可见数量并据此绘制。实例化的基础原理见 GPU InstancingGPU InstancingGPU Instancing 做的事:一次 Draw Call + 一份 Mesh/Shader 状态,通过 InstanceID 在 GPU 端索引每个实例的差异数据(transform、color 等),复用顶点数据,省掉 CPU → GPU 的重复提交。 问题:CPU 循环发 Draw Call 的开销   传统流程每画一个物体,CPU 都要调用图形API 发 Draw Call ,给到图形驱动,由驱动翻译成 GPU 原生指令写进 Command Buffer,GPU 再异步取出执行(这条 CPU→驱动→GPU 的提交链路见 [[性能优化大纲]])。驱动每翻译一条 Draw Call 都有一笔固定的 CPU 耗时,场景里几千个物体,这笔翻译成本就付几千遍。 image.png 问题在于: 如果这几千个物体用的是同一份网格和同一个材质,CPU 其实在为同一件事反复发命令,每次唯一的差别只是变换矩阵。GPU Instancing 从这里切入:它不让每次提交更便宜(那是 [[性能优化-SRP合批|SRP Batcher]] 的活),而是直接把提交次数从 N 压到 1。 底,这里只讲 Indirect 多出来的部分。整条链路分三段。

一、CPU 准备数据

  • 实例数据缓冲 instanceDataBuffer:一个 ComputeBuffer,按实例数量上限存下所有候选实例的属性(变换矩阵、颜色等),一次性上传显存。
  • 绘制参数缓冲 argsBuffer:类型为 ComputeBufferType.IndirectArgumentsComputeBuffer,含 5 个 uint,布局由图形 API(DirectX/Vulkan)固定:
下标含义取值
args[0]indexCountPerInstance,单个实例的索引数mesh.GetIndexCount(0)
args[1]instanceCount,要画多少个初始填 0,运行时由 GPU 写入
args[2]startIndexLocation,起始索引通常 0
args[3]baseVertexLocation,起始顶点通常 0
args[4]startInstanceLocation,起始实例通常 0

其中 args[1] 是整套机制的关键——它不由 CPU 填,而是每帧由 GPU 剔除完再写进去。

二、GPU 剔除(Compute Shader)

派发一个 Compute Shader(线程组织见 Compute ShaderCompute ShaderCompute Shader 让我们能直接把通用计算任务交给 GPU 并行处理,而不局限于传统的顶点/片元着色。要写好它,先得理解 GPU 是如何把成千上万个线程分配到硬件上执行的。 GPU 的并行执行层级 GPU 的并行能力来自一套分级的线程调度体系。从最底层的执行单元到开发者组织的线程集合,NVIDIA 和 AMD 有各自的术语,但结构基本对应。 执行单元:SP / CUDA Core SP(Streaming Processor)是 GPU 最基本的执行单元,在 NVIDIA 中也叫 CUDA Core,相当于 CPU 里的算术/逻辑单元。所有算术、逻辑、数据搬运指令最终都在 SP 上执行,每个 SP 通常一个时钟周期处理一个线程的一条指令。 GPU 之所以能高并发,正是因为它拥有成百上千个 SP 同时工作——市场上宣传的"几千核心"指的就是 SP(CUDA Core)的数量。AMD 架构中对应的执行单元通常称为 SIMD 单元,实现细节略有不同。 调度单元:SM / Compute Unit SM(Streaming Multiprocessor)是比 SP 更高)并行遍历所有实例,逐个做视锥 / 距离 / 遮挡判断。通过测试的实例 Append 进一个可见实例缓冲(AppendStructuredBuffer),这个缓冲自带内部计数器,自动记录追加了多少个。

三、CPU 发起绘制

Dispatch 剔除 Compute 后,用 ComputeBuffer.CopyCount 把可见缓冲的计数器值拷进 argsBufferargs[1],再调用 Graphics.DrawMeshInstancedIndirect。CPU 到此为止,全程不知道这帧画了几个;GPU 读 argsBuffer 拿到实例数,从可见缓冲里取数据绘制。

要点是:可见数量从产生(Append 计数器)到消费(argsBuffer)全程在显存里流转,没有一次 GPU→CPU 回读(read-back)。这正是它相比 DrawMeshInstanced 的价值——后者的实例数量和矩阵数组仍由 CPU 准备,几十万实例时光是 CPU 遍历剔除、组织数组本身就成了瓶颈;Indirect 把这步搬进 GPU 并行做,CPU 只剩几次缓冲绑定和一次绘制调用。本质上是拿现代 GPU 的通用并行计算(GPGPU)换掉了 CPU 的串行准备。

代码示例

C# 逻辑

为方便编辑模式下实时调参,在 OnValidate() 里参数变化时重新初始化:

void OnValidate()
{
    if (!Application.isPlaying)
    {
        Initialize();
    }
}

Initialize 先释放旧 Buffer 防泄漏,再做可用性检查(缺资源或平台不支持 Compute 就直接退出):

void Initialize()
{
    // 确保在重新初始化前释放旧资源
    ReleaseBuffers();

    if (mainCamera == null)
        mainCamera = Camera.main;

    if (instanceMesh == null || instanceMaterial == null || cullingComputeShader == null || mainCamera == null ||
        !SystemInfo.supportsComputeShaders)
    {
        isInitialized = false;
        return;
    }
    // ...
}
private void ReleaseBuffers()
{
    allInstancesDataBuffer?.Release();
    allInstancesDataBuffer = null;

    argsBuffer?.Release();
    argsBuffer = null;

    visibleInstancesDataBuffer?.Release();
    visibleInstancesDataBuffer = null;

    // isInitialized 在这里设为 false,确保下次 Update 时能重新初始化
    isInitialized = false;
}

再生成实例数据:在世界空间的圆形区域内随机撒点、随机上色,instanceRadius 是单个实例的包围球半径(供 Compute 剔除用),最后填进 InstanceData 列表:

void Initialize()
{
    // ...

    this.instanceRadius = instanceMesh.bounds.extents.magnitude * Radius;

    // 初始化所有实例数据
    List<InstanceData> allInstances = new List<InstanceData>(instanceCount);
    for (int i = 0; i < instanceCount; i++)
    {
        float angle = Random.Range(0f, Mathf.PI * 2f);
        float distance = Random.Range(0f, areaSize / 2f);
        float x = Mathf.Cos(angle) * distance;
        float z = Mathf.Sin(angle) * distance;
        Vector4 pos = new Vector4(x, 0, z, 1);
        Color randomColor = new Color(Random.value, Random.value, Random.value, 1);

        allInstances.Add(new InstanceData { position = pos, color = randomColor });
    }
    // ...
}

接着创建三个 ComputeBuffer——allInstancesDataBuffer(全部实例)、visibleInstancesDataBuffer(剔除后可见)、argsBuffer(绘制参数)。三个细节:

  • stride(步幅)是每个元素的字节数,GPU 按它切分缓冲。InstanceData 是两个 Vector4(各 16 字节),所以 stride = 32
  • visibleInstancesDataBufferComputeBufferType.Append:Compute 里能 Append 追加元素,缓冲自带计数器记录数量,后续靠它拿到可见总数。
  • argsBufferComputeBufferType.IndirectArguments,只含 1 个元素(一组绘制指令),大小 5 * sizeof(uint) = 20 字节。args[1] 故意初始化为 0,交给 GPU 填。
void Initialize()
{
    // ...

    int stride = Marshal.SizeOf(typeof(InstanceData));
    allInstancesDataBuffer = new ComputeBuffer(instanceCount, stride);
    allInstancesDataBuffer.SetData(allInstances);

    visibleInstancesDataBuffer = new ComputeBuffer(instanceCount, stride, ComputeBufferType.Append);

    argsBuffer = new ComputeBuffer(1, args.Length * sizeof(uint), ComputeBufferType.IndirectArguments);
    args[0] = (uint)instanceMesh.GetIndexCount(0);
    args[1] = 0;
    args[2] = (uint)instanceMesh.GetIndexStart(0);
    args[3] = (uint)instanceMesh.GetBaseVertex(0);
    args[4] = 0;
    argsBuffer.SetData(args);

    // ...
}
private struct InstanceData
{
    public Vector4 position;
    public Vector4 color;
}

然后把缓冲绑定到 Compute 和材质。注意最后一行——要把可见实例缓冲绑给渲染材质,渲染时才能按 SV_InstanceID 取到剔除后的数据:

void Initialize()
{
    // ...

    cullingKernelID = cullingComputeShader.FindKernel("CullInstances");
    // 绑定缓冲区
    cullingComputeShader.SetBuffer(cullingKernelID, "_AllInstancesData", allInstancesDataBuffer);
    cullingComputeShader.SetBuffer(cullingKernelID, "_VisibleInstancesData", visibleInstancesDataBuffer);
    // 关键:将*可见*实例的缓冲区绑定到渲染材质
    instanceMaterial.SetBuffer("_VisibleInstancesData", visibleInstancesDataBuffer);

    isInitialized = true;

    // ...
}

Update 每帧驱动剔除和绘制。先把剔除所需的相机信息发给 Compute:CalculateFrustumPlanes 取相机视锥的六个平面,连同远裁切距离、相机变换、包围球半径一起 Set 进显存:

void Update()
{
    if (!isInitialized)
    {
        Initialize();
        // 如果初始化失败,则不执行后续逻辑
        if (!isInitialized)
            return;
    }

    // --- GPU Culling ---
    Plane[] frustumPlanes = GeometryUtility.CalculateFrustumPlanes(mainCamera);
    cullingComputeShader.SetVectorArray("_FrustumPlanes", PlanesToVector4(frustumPlanes));
    cullingComputeShader.SetFloat("_MaxDistance", mainCamera.farClipPlane);
    cullingComputeShader.SetMatrix("_CameraLocalToWorld", mainCamera.transform.localToWorldMatrix);
    cullingComputeShader.SetFloat("_InstanceRadius", instanceRadius);

    // ...
}

接着是每帧的三步核心:

  • SetCounterValue(0):清零可见缓冲的计数器,否则会跨帧累加。
  • Dispatch:按 64 一组派发线程,每个实例一个线程并行剔除。
  • CopyCount:纯 GPU 内部操作,把可见缓冲的计数器拷进 argsBufferargs[1](偏移 sizeof(uint) = 4 字节),避免回读。

最后 DrawMeshInstancedIndirect 是一次很轻的 CPU 调用,绘制数量和参数都在 argsBuffer 里。传入的 renderBounds 是能罩住所有实例的大包围盒,供引擎做整批的粗剔除——连这个盒子都不可见时可整批跳过。

void Update()
{
    // ...

    visibleInstancesDataBuffer.SetCounterValue(0);
    cullingComputeShader.Dispatch(cullingKernelID, Mathf.CeilToInt(instanceCount / 64f), 1, 1);
    ComputeBuffer.CopyCount(visibleInstancesDataBuffer, argsBuffer, sizeof(uint));

    // --- Rendering ---
    Bounds renderBounds = new Bounds(Vector3.zero, new Vector3(areaSize * 2, 20, areaSize * 2));
    Graphics.DrawMeshInstancedIndirect(
        instanceMesh,
        0,
        instanceMaterial,
        renderBounds,
        argsBuffer
    );
}

Compute Shader

剔除逻辑跑在 Compute 上,只做一件事:读入全部实例,输出一份只含可见实例的紧凑列表。

数据结构和缓冲声明——InstanceData 的字段布局必须和 C# 端完全一致,否则 GPU 解析错位。输入是只读的 StructuredBuffer,输出是可追加的 AppendStructuredBuffer

#pragma kernel CullInstances

// 与 C# 脚本匹配的数据结构
struct InstanceData
{
    float4 position;
    float4 color;
};

// 输入:所有实例的数据
StructuredBuffer<InstanceData> _AllInstancesData;
// 输出:一个可追加的缓冲区,用于存储可见实例
AppendStructuredBuffer<InstanceData> _VisibleInstancesData;

// 从 C# 传递过来的摄像机视锥平面
float4 _FrustumPlanes[6];
float _MaxDistance;
float4x4 _CameraLocalToWorld;
float _InstanceRadius;

// ...

主函数 CullInstances 每线程处理一个实例,IsInFrustum 分两步判可见:

  • 距离初筛:distance(worldPos, cameraPos) > _MaxDistance 直接剔掉远处的,省下后面的平面测试。
  • 视锥测试:遍历六个平面,dot(plane.xyz, pos) + plane.w + radius < 0 表示整个包围球落在某平面外侧;只要在任一平面外就判不可见,过了全部六个才可见。
// ...

// 判断一个点是否在所有视锥平面内部
bool IsInFrustum(float3 worldPos, float radius)
{
    float3 cameraPos = _CameraLocalToWorld[3].xyz;
    if (distance(worldPos, cameraPos) > _MaxDistance)
    {
        return false;
    }

    for (int i = 0; i < 6; i++)
    {
        // dot(plane.xyz, pos) + plane.w 计算点到平面的垂直距离
        if (dot(_FrustumPlanes[i].xyz, worldPos) + _FrustumPlanes[i].w + radius < 0)
        {
            return false; // 在视锥外
        }
    }
    return true; // 在视锥内
}

[numthreads(64, 1, 1)]
void CullInstances(uint3 id : SV_DispatchThreadID)
{
    // 获取当前线程处理的实例数据
    InstanceData data = _AllInstancesData[id.x];

    // 执行剔除检查
    if (IsInFrustum(data.position.xyz, _InstanceRadius))
    {
        // 如果可见,则将其添加到可见实例的缓冲区中
        _VisibleInstancesData.Append(data);
    }
}

渲染 Shader

渲染 Shader 很简单:用 SV_InstanceID 从可见缓冲取当前实例的数据,把实例位置加到模型顶点上,转到裁剪空间。

// ...
// 与 C# 脚本中匹配的数据结构
struct InstanceData
{
    float4 position;
    float4 color;
};

StructuredBuffer<InstanceData> _VisibleInstancesData;

Varyings vert(Attributes input, uint instanceID : SV_InstanceID)
{
    Varyings o = (Varyings)0;
    UNITY_SETUP_INSTANCE_ID(input);

    // 使用实例 ID 作为索引,从 Buffer 中读取当前实例的数据
    InstanceData data = _VisibleInstancesData[instanceID];
    // 将实例的位置应用到模型的顶点上
    float3 worldPos = input.positionOS.xyz + data.position.xyz;

    // 转换到裁剪空间
    o.positionHCS = TransformWorldToHClip(worldPos);
    // ...
}