A native plugin that brings true Multi-Draw Indirect (MDI) to Unity.
A draw call is one instruction to the GPU: "render this object with this material". Each one has real CPU cost: the engine has to validate state, build a command, and submit it to the graphics driver.
A "small" scene easily hits 2,000-3,000 draw calls per frame, even with very few objects on screen. Each visible object is drawn several times per frame: once for the main image, once or twice more for shadows, often again for a depth pre-pass, plus extra passes for transparency and post-processing. Add LODs (different mesh detail at different distances) and the count grows fast.
Even on a fast desktop CPU with Unity's built-in batching, 1,000 draw calls can cost over 1 ms of CPU time per frame. At 60 fps you have a 16.6 ms budget for everything (physics, AI, scripts, rendering), so 1 ms spent just submitting draws to the GPU is a real problem.
Multi-Draw Indirect (MDI) replaces N draw calls with one. You write the parameters for all N draws into a single GPU buffer, then make one call that tells the GPU to run every draw it finds in that buffer. The GPU does the dispatch itself, with no per-draw CPU overhead. This is how engines like Unreal (Nanite) and Frostbite render millions of objects without drowning the CPU in draw-call cost.
Modern GPU-driven rendering pipelines rely on Multi-Draw Indirect to batch thousands of draw calls into a single GPU command. Unity does not expose MDI in any form:
Graphics.RenderPrimitivesIndexedIndirect / Graphics.RenderMeshIndirectis the closest built-in alternative, but it is not MDI: it issues individual draw calls on the CPU side. It also cannot be used inside CommandBuffers, which makes it unusable in scriptable render pipelines and render graph workflows.CommandBuffer.DrawProceduralIndirect / CommandBuffer.DrawMeshInstancedIndirectsupports only a single indirect draw per call. Issuing it in a loop ("ProceduralIndirect loop") works but scales poorly: each call pays the full CPU cost of state validation, command recording, and managed-to-native transitions.
This plugin injects a single native MDI command directly into Unity's graphics command stream via IssuePluginEventAndData. The result is true hardware-level batching with minimal CPU cost.
| Graphics API | Status | Backend |
|---|---|---|
| D3D11 | ✅ Supported | NvAPI (NVIDIA) DrawIndexedInstancedIndirect / loop fallback |
| D3D12 | ✅ Supported | ExecuteIndirect via CommandRecordingState |
| Vulkan | ✅ Supported | vkCmdDrawIndexedIndirect (multi-draw or loop fallback) |
| OpenGL Core | ✅ Supported | glMultiDrawElementsIndirect |
| OpenGL ES 3.1+ | ✅ Supported | glMultiDrawElementsIndirect |
| Metal | ✅ Supported | drawIndexedPrimitives:indirectBuffer: via Objective-C method swizzling |
| WebGPU | ✅ Supported | Cached GPURenderBundle replay (or multiDrawIndexedIndirect where available (see chrome://flags/#enable-unsafe-webgpu)) via JS prototype interception, pure .jslib |
The package ships prebuilt binaries for every supported OS, no manual compilation needed:
| OS | Binary | Graphics APIs |
|---|---|---|
| Windows x86_64 | GfxPluginMDI.dll |
D3D11, D3D12, Vulkan, OpenGL |
| Linux x86_64 | libGfxPluginMDI.so |
Vulkan, OpenGL |
| macOS (Intel + Apple Silicon) | GfxPluginMDI.bundle |
Metal |
| Android (arm64-v8a, armeabi-v7a) | libGfxPluginMDI.so |
Vulkan, OpenGL ES |
| Web | MDIBackend_WebGPU.jslib |
WebGPU |
The Linux binary targets glibc 2.34+ (Ubuntu 22.04 and newer, Unity 6's Linux baseline) with the C++ runtime linked statically, so it has no dependencies beyond libc. To rebuild it from source, run NativePlugin~/build_linux.sh on any Linux machine or container.
CPU time comparison for 25,000 draw calls. D3D11/D3D12/Vulkan/OpenGL ES/WebGPU tested on RTX 3080, AMD Ryzen 9 5950X; Metal tested on Apple M2 Pro (Mac mini).
Measured as total PlayerLoop time (not just command submission) in the build, so the numbers include all engine overhead per frame:
| Method | CPU Time |
|---|---|
| MultiDrawIndirect | 0.41 ms |
| ProceduralIndirect Loop | 23.77 ms |
| RenderPrimitivesIndexedIndirect | 15.11 ms |
| Method | CPU Time |
|---|---|
| MultiDrawIndirect | 0.35 ms |
| ProceduralIndirect Loop | 28.61 ms |
| RenderPrimitivesIndexedIndirect | 36.24 ms |
| Method | CPU Time |
|---|---|
| MultiDrawIndirect | 0.35 ms |
| ProceduralIndirect Loop | 25.06 ms |
| RenderPrimitivesIndexedIndirect | 23.08 ms |
| Method | CPU Time |
|---|---|
| MultiDrawIndirect | 1.18 ms |
| ProceduralIndirect Loop | 25.7 ms |
| RenderPrimitivesIndexedIndirect | 23.7 ms |
| Method | CPU Time |
|---|---|
| MultiDrawIndirect | 1.52 ms |
| ProceduralIndirect Loop | 23.06 ms |
| RenderPrimitivesIndexedIndirect | 18.43 ms |
| Method | CPU Time |
|---|---|
| MultiDrawIndirect | 5.25 ms |
| ProceduralIndirect Loop | 53.00 ms |
| RenderPrimitivesIndexedIndirect | 31.12 ms |
- D3D11 + RenderDoc: The plugin uses NvAPI, which can cause Unity to crash when RenderDoc attempts to inject at runtime. To avoid this, attach RenderDoc at Unity startup (launch Unity from RenderDoc) rather than connecting mid-session.
- D3D11 + AMD GPUs: D3D11 does not have a native MDI API. On NVIDIA, this is solved via NvAPI, which can attach to an already-created D3D11 device. AMD has an equivalent extension in AGS (
agsDriverExtensionsDX11_MultiDrawIndexedInstancedIndirect), but AGS requires the D3D11 device to be created throughagsDriverExtensionsDX11_CreateDevice. Unity creates the device itself, so AGS extensions cannot be enabled retroactively. Because of this (and lack of AMD hardware for testing), MDI on D3D11 + AMD is not currently supported. AMD GPUs are fully supported under D3D12, Vulkan, and OpenGL. - Consoles: The plugin has been tested on desktop Windows, Linux, macOS, Web (WebGPU in Chromium-based browsers), and mobile devices (iOS; Android with Adreno and Mali GPUs). It has not been verified on consoles (PlayStation, Xbox, Switch), so support there is not guaranteed.
- Mobile hardware multi-draw coverage: On iOS, Metal indirect draws (the mechanism behind the swizzling backend) require A9 hardware or newer (iPhone 6s, 2015+). On Android, true hardware MDI depends on the GPU's Vulkan
multiDrawIndirectfeature: Qualcomm Adreno exposes it, while ARM Mali generally does not (only the newest Immortalis-class drivers report it). The same split applies under OpenGL ES:GL_EXT_multi_draw_indirectis available on Adreno but not on Mali. On GPUs without the feature nothing breaks: the plugin detects it at init and falls back to a native loop (vkCmdDrawIndexedIndirectwithdrawCount = 1per command, recorded directly into Unity's command buffer). That is still one plugin event per batch and far cheaper than the managed C# loop, just not a single hardware command. - Identity buffer instance limit (D3D11/D3D12/OpenGL/GLES): The per-instance identity buffer defaults to 65,536 entries. For any draw command in an MDI batch,
startInstance + instanceCountmust not exceed this value. UseMultiDrawIndirect.MaxInstanceCountto increase or decrease the limit at runtime. This limitation does not apply to Vulkan.
Add the package via Unity Package Manager using a git URL:
- Open Window > Package Manager
- Click + > Add package from git URL...
- Enter:
https://github.com/saivs/com.saivs.plugin.mdi.git
The plugin exposes two extension methods on CommandBuffer (and RasterCommandBuffer / UnsafeCommandBuffer in Unity 6+): an indexed/procedural form (MultiDrawIndexedIndirect) and a Mesh form (MultiDrawMeshIndirect). Both run as true single-call MDI on every supported backend.
using Saivs.Graphics.Core.MDI;// Increase the limit to 1,000,000 instances (D3D11/D3D12/OpenGL)
// See Documentation~/DeepDive-D3D.md, section "Per-Instance Identity Buffer"
MultiDrawIndirect.MaxInstanceCount = 1_000_000;
// Query the current limit
uint current = MultiDrawIndirect.MaxInstanceCount;After filling the argument buffer in the compute shader, it needs to be prepared. On some APIs, Unity doesn't see the args barrier without this, and so MultiDrawIndirect may render with incorrect data.
cmd.DispatchCompute(...);
cmd.PrepareIndirectArgs(argsBuffer);Pass a Unity Mesh directly. Vertices come through the standard input assembler so the shader can use the regular POSITION / NORMAL / TEXCOORD0 / etc.
cmd.MultiDrawMeshIndirect(
mesh: mesh,
material: material,
properties: propertyBlock,
shaderPass: 0,
bufferWithArgs: argsBuffer,
argsStartIndex: 0,
argsCount: drawCount
);One call replaces the entire draw loop. When the native plugin is available, all draws are batched into a single MDI command.
The argsBuffer layout is identical to MultiDrawIndexedIndirect (GraphicsBuffer.IndirectDrawIndexedArgs). Each entry's startIndex / baseVertexIndex directly drives which slice of the mesh's index/vertex buffer is read for that draw, so you can scatter different shapes across the batch by combining several meshes into one and indexing them through args. There is no submeshIndex parameter for multi-submesh meshes; encode the slice you need directly through startIndex / baseVertexIndex / indexCountPerInstance.
Matching vertex shader (vertex data arrives through the standard semantics):
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.saivs.multi-draw-indirect/Runtime/ShaderLibrary/MDI.hlsl"
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
};
VertexOutput vert(Attributes input, MDI_INSTANCE_ID_PARAMETER)
{
uint globalInstanceID = MDI_INSTANCE_ID;
// ... transform input.positionOS, fetch per-instance data by globalInstanceID, etc.
}
ENDHLSLThe shader pulls vertex data from a StructuredBuffer indexed by SV_VertexID.
cmd.MultiDrawIndexedIndirect(
indexBuffer: indexBuffer,
material: material,
properties: propertyBlock,
shaderPass: 0,
topology: MeshTopology.Triangles,
bufferWithArgs: argsBuffer,
argsStartIndex: 0,
argsCount: drawCount
);Matching vertex shader (vertex data is fetched manually from a StructuredBuffer via SV_VertexID):
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.saivs.multi-draw-indirect/Runtime/ShaderLibrary/MDI.hlsl"
VertexOutput vert(uint vertexID : SV_VertexID, MDI_INSTANCE_ID_PARAMETER)
{
uint globalInstanceID = MDI_INSTANCE_ID;
// Use globalInstanceID and vertexID to fetch per-instance data (positions, transforms, etc.)
}
ENDHLSLAn overload of MultiDrawIndexedIndirect where the draw count is read by the GPU from a GraphicsBuffer (e.g. written by a culling compute shader via an atomic counter) instead of being fixed on the CPU. No readback or CPU sync required:
cmd.MultiDrawIndexedIndirect(
indexBuffer: indexBuffer,
material: material,
properties: propertyBlock,
shaderPass: 0,
topology: MeshTopology.Triangles,
bufferWithArgs: argsBuffer,
argsStartIndex: 0,
drawCountBuffer: countBuffer, // uint32 draw count, written by GPU compute
drawCountByteOffset: 0, // byte offset of the count inside countBuffer (multiple of 4)
maxDrawCount: maxDrawCount // upper bound (count is clamped to this)
);Backend behaviour:
| Backend | GPU count mechanism |
|---|---|
| D3D12 | ExecuteIndirect count buffer argument |
| Vulkan | vkCmdDrawIndexedIndirectCount (core 1.2 / KHR / AMD), loop fallback otherwise |
| OpenGL / GLES | glMultiDrawElementsIndirectCount (GL 4.6 / ARB_indirect_parameters), loop fallback otherwise |
| WebGPU | multiDrawIndexedIndirect count-buffer argument (experimental multi-draw feature) |
| D3D11 / Metal | No native count API, executes maxDrawCount draws |
On backends without a native count mechanism (and in every loop fallback), maxDrawCount draws are executed, so the compute shader must zero the instanceCount of unused args entries; the extra draws then produce no work. Following that rule makes behaviour identical on all backends.
Backend support. True single-call MDI through this API runs on every supported backend (D3D11, D3D12, Vulkan, Metal, WebGPU, OpenGL Core, OpenGL ES). On Metal, Vulkan and WebGPU the user mesh doesn't need a TEXCOORD7 element, because MDI.hlsl resolves MDI_INSTANCE_ID through SV_InstanceID. On D3D11 and D3D12 the native plugin reflects the user shader's vertex bytecode and patches the input layout / PSO at creation time to add a per-instance TEXCOORD7 → identity buffer element on slot 15, leaving the user mesh's vertex buffers untouched. On OpenGL / OpenGL ES the plugin clones Unity's mesh VAO into its own VAO and adds the same per-instance TEXCOORD7 binding, with a small fingerprint-keyed cache so repeated draws of the same mesh skip the cloning. The mesh's indexBufferTarget is augmented with Raw automatically the first time it's seen, so mesh.GetIndexBuffer() returns a buffer the native plugin can address.
Both APIs above share two macros that handle cross-platform instance ID resolution automatically:
| Macro | Purpose |
|---|---|
MDI_INSTANCE_ID_PARAMETER |
Place in the vertex shader signature; expands to the correct platform-specific parameter |
MDI_INSTANCE_ID |
Use in the vertex shader body; resolves to the global instance index across all draw commands |
The macros expand differently depending on the platform and compile-time defines:
| Platform | MDI_INSTANCE_ID_PARAMETER |
MDI_INSTANCE_ID |
|---|---|---|
| D3D11 / D3D12 / OpenGL / OpenGL ES | uint : TEXCOORD7 |
Identity buffer value (global instance index) |
| Vulkan / WebGPU | uint : SV_InstanceID |
SV_InstanceID (already includes startInstance) |
| Metal | uint : SV_InstanceID |
_ArgsBuffer[_MDI_DrawIndex_Buffer[0]].startInstance + SV_InstanceID |
Fallback loop (MDI_NATIVE_LOOP) |
uint : SV_InstanceID |
_ArgsBuffer[_MDI_DrawIndex].startInstance + SV_InstanceID |
See the included sample shader for a complete working example with multiple pass configurations.
Each backend hit its own wall. On several platforms MDI support looked impossible at first: Unity exposes no way to modify pipeline state on D3D, on Metal the command encoder is already dead by the time a plugin event fires, and on the Web there is no native plugin interface for WebGPU at all. Every wall had a way around it: inline hooking on D3D, method swizzling on Metal, prototype patching on the Web.
A recurring (but not universal) sub-problem is obtaining a correct global instance index, one that uniquely identifies each instance across all draw commands in a single MDI batch. Vulkan hands it out for free (gl_InstanceIndex includes firstInstance) and WebGPU follows the same semantics, but D3D11/D3D12/OpenGL zero out SV_InstanceID on every draw command, so each of them needs its own workaround.
The full story for each backend:
| Backend | The wall | The way around |
|---|---|---|
| Direct3D 12 & 11 | SV_InstanceID ignores startInstance; Unity gives no access to PSOs / input layouts; D3D11 has no MDI API at all |
Per-instance identity buffer + inline-hooking PSO creation; NvAPI on D3D11 |
| OpenGL Core & ES | gl_InstanceID doesn't include baseInstance; touching Unity's VAO breaks its state cache |
Identity buffer on a dedicated / cloned VAO with a fingerprint-keyed cache |
| Metal | Encoder already ended when plugin events fire; no [[base_instance]] through HLSL; encoding a draw without a PSO aborts the process |
Objective-C method swizzling of Unity's own prime draw |
| WebGPU | No native plugin interface; no dynamic libraries; multi-draw not in the WebGPU standard | JS prototype patching + cached GPURenderBundle replay, pure .jslib |
