跳转至

Instancing 与 Indirect Draw

实例多起来以后

场景里的草和树多起来以后,CPU 每帧都要遍历对象、做视锥裁剪,再把 Draw Call 提交给 GPU。实例数量到几万时,即使材质都一样,主线程也会先顶不住。

这种情况可以先试 GPU Instancing,再考虑 Indirect Draw。两者不是替代关系,Indirect 只是把“画多少个”交给 Buffer 决定。

先分清两种提交方式

方式 谁决定实例数量 适合场景
GPU Instancing CPU 调用时传入 数量中等、对象变化不频繁
Indirect Draw GraphicsBuffer 或 Compute Shader 大规模实例、GPU 裁剪

普通的 Graphics.RenderMeshInstanced 已经能处理很多情况。只有当实例数量大到 CPU 裁剪和提交成为瓶颈时,Indirect Draw 才有意义。

用 Buffer 提交绘制参数

Unity 的 Graphics.RenderMeshIndirect 从 GraphicsBuffer 读取绘制参数。不要自己按 int 拼布局,使用 GraphicsBuffer.IndirectDrawIndexedArgs 更稳,因为不同图形 API 的字段布局可能不同。1

private GraphicsBuffer _argsBuffer;

private void Draw(Mesh mesh, Material material, int instanceCount)
{
    var args = new GraphicsBuffer.IndirectDrawIndexedArgs[1];
    args[0].indexCountPerInstance = mesh.GetIndexCount(0);
    args[0].instanceCount = (uint)instanceCount;
    args[0].startIndex = mesh.GetIndexStart(0);
    args[0].baseVertexIndex = (uint)mesh.GetBaseVertex(0);
    args[0].startInstance = 0;

    if (_argsBuffer == null)
    {
        _argsBuffer = new GraphicsBuffer(
            GraphicsBuffer.Target.IndirectArguments,
            1,
            GraphicsBuffer.IndirectDrawIndexedArgs.size);
    }

    _argsBuffer.SetData(args);

    var rp = new RenderParams(material)
    {
        worldBounds = new Bounds(Vector3.zero, Vector3.one * 500f)
    };

    Graphics.RenderMeshIndirect(rp, mesh, _argsBuffer, 1);
}

private void OnDestroy()
{
    _argsBuffer?.Release();
    _argsBuffer = null;
}

worldBounds 不要随手写一个特别大的值。Unity 会把它当成整批实例的包围盒做剔除和排序,设得过大,整批内容会被一直保留在可见列表里。

把裁剪移到 GPU

Indirect Draw 常见流程如下:

  1. 把实例位置、缩放和包围盒写入 GraphicsBuffer。
  2. Compute Shader 做视锥裁剪,把可见索引写入另一个 Buffer。
  3. 更新 Indirect Args 里的实例数量。
  4. 提交一次 Indirect Draw。
  5. 调试时回读数量,检查裁剪结果是否符合预期。

裁剪的核心代码不复杂:

[numthreads(64, 1, 1)]
void Cull(uint3 id : SV_DispatchThreadID)
{
    if (id.x >= _InstanceCount)
        return;

    Instance inst = _Instances[id.x];
    if (!VisibleAgainstPlanes(inst.Bounds, _Planes))
        return;

    uint slot;
    InterlockedAdd(_Args[1], 1, slot);
    _VisibleIndices[slot] = id.x;
}

这里 _Args[1] 表示索引绘制参数中的实例数量,但不同平台的参数布局可能不同。实际项目最好用 Unity 提供的结构体或平台宏来定位,不要直接把下标当成跨平台常量。2

什么时候不值得

如果场景只有几百个对象,CPU 提交成本通常还不高。加 Compute Shader、同步和额外 Buffer 之后,反而可能更慢。

可以先看三个指标:

  • 主线程的 Camera.Render 和批处理耗时是否明显。
  • GPU 是否已经吃满,单纯减少提交不会带来收益。
  • 实例数量是否足够大,且大部分时间真的在相机外或被遮挡。

另外,透明物体通常还需要 CPU 排序。Indirect Draw 解决的是提交和裁剪,不会自动解决透明排序问题。

一个调试技巧

先不启用 GPU 裁剪,只用固定的 instanceCount 跑一遍 Indirect Draw,确认矩阵、索引和材质属性都正确。再把 Compute 裁剪接上,对比可见数量。

如果画面突然少了一大片,先检查 worldBounds、视锥平面和矩阵的空间是否一致。很多时候不是裁剪算法写错了,而是实例数据还在本地空间,平面却传了世界空间。