How to Optimize Draw Calls in Modern Graphics APIs
Modern low-level graphics APIs like Vulkan, DirectX 12, and Metal give developers granular control over the GPU, significantly reducing driver overhead compared to legacy APIs. However, inefficient draw call submission can still create severe CPU bottlenecks that stall the rendering pipeline. This article explores the primary architectural methods used in modern game development to minimize draw call submission overhead, including multi-threaded command generation, GPU-driven indirect drawing, hardware instancing, and bindless resource management.
Multi-Threaded Command Recording
Unlike older APIs that required all rendering commands to be issued from a single main thread, modern APIs allow command lists or command buffers to be recorded concurrently across multiple CPU worker threads.
- Independent Allocators: Each worker thread records commands into its own command list using dedicated command allocators or pools to prevent thread contention.
- Secondary Command Buffers: Systems can record reusable sub-passes or modular scene chunks independently and execute them within a primary command buffer on the main render thread.
- Batch Submission: Once recorded, all command lists are assembled and submitted to the GPU queue in a minimal number of batch operations, drastically reducing driver submission overhead.
GPU-Driven Rendering and Indirect Drawing
Indirect draw calls (vkCmdDrawIndirect in Vulkan or
ExecuteIndirect in DirectX 12) move draw parameters—such as
vertex counts, instance counts, and offsets—from CPU memory to GPU
buffers.
- Compute Shader Culling: A compute shader running on the GPU performs frustum and occlusion culling, writes the visibility results directly into an indirect draw buffer, and dispatches the exact number of visible instances.
- Multi-Draw Indirect: Developers can bundle thousands of distinct draw calls into a single indirect execution call. This eliminates the CPU’s need to read back visibility data or issue individual draw commands per visible object.
Hardware Instancing
When rendering multiple objects that share the same mesh geometry and materials (such as foliage, debris, or modular environment assets), hardware instancing combines them into a single draw command.
- Per-Instance Data Streams: Instance-specific parameters—such as transformation matrices, colors, or animation frames—are passed via secondary vertex buffers or structured buffers.
- Draw Count Reduction: A single draw call can render thousands of unique instances, cutting down both CPU submission time and state changes across the pipeline.
Bindless Rendering and Descriptor Indexing
Traditional rendering architectures bind individual textures and buffers before each draw call, causing frequent CPU-side state transitions. Bindless architectures bypass this limitation.
- Global Descriptor Tables: Rather than rebinding resources per object, all textures and buffers are loaded into large, globally accessible descriptor arrays.
- Index-Based Access: Shaders access specific materials by reading an integer index passed via push constants, root constants, or instance buffers.
- Zero State Changes: Because resources do not need to be rebound between draws, batches of disparate objects can be rendered consecutively without pipeline stalls.
Pipeline State Object (PSO) Caching
Switching graphics states (blend modes, rasterizer states, shaders) individually creates substantial CPU overhead. Modern APIs encapsulate the entire GPU pipeline state into immutable Pipeline State Objects (PSOs).
- Ahead-of-Time (AOT) Creation: PSOs should be compiled asynchronously during load times or generated via pipeline caches to prevent runtime compilation hitches.
- State-Sorted Draw Queues: Draw calls should be sorted by PSO hash values. Grouping draws that share the same PSO minimizes expensive context switches on both the CPU and GPU.
Push Constants and Root Constants
For minor, per-draw data updates (such as dynamic offsets or object IDs), allocating uniform buffers introduces memory management and synchronization overhead.
- Direct Register Updates: Push constants (Vulkan) and root constants (DirectX 12) write small payloads (typically 128 to 256 bytes) directly into GPU registers.
- Low Latency: Using these fast paths avoids descriptor set updates and buffer mapping, making rapid per-draw parameter updates virtually free in terms of CPU submission time.