Two-Pass Occlusion Culling with DirectX Indirect Draws
Two-pass occlusion culling is a GPU-driven rendering technique
designed to eliminate non-visible geometry before rasterization without
relying on expensive CPU-GPU synchronization. By utilizing temporal
coherence, modern graphics pipelines test scene visibility across two
phases using compute shaders and a hierarchical depth buffer (Hi-Z).
When combined with DirectX 12 compute pipelines and indirect draw calls
(ExecuteIndirect), the GPU evaluates object visibility and
issues its own draw commands autonomously, virtually eliminating CPU
draw-call overhead and rendering stalls.
The Concept of Two-Pass Occlusion Culling
Traditional CPU-side occlusion culling requires reading back depth data from the GPU, which introduces multiple frames of latency and pipeline stalls. Single-pass GPU culling often suffers from a "chicken-and-egg" problem: you cannot test an object's visibility until you have drawn the occluders, but you do not know which occluders to draw without testing them.
Two-pass occlusion culling resolves this by exploiting temporal coherence—the principle that an object visible in frame \(N-1\) is overwhelmingly likely to remain visible in frame \(N\). The technique breaks frame rendering into two distinct execution steps:
- First Pass: Render the geometry that was visible in the previous frame to build an initial depth buffer.
- Second Pass: Test all previously invisible geometry against this updated depth buffer. If any previously occluded objects have become visible (such as an object moving out from behind a wall), render them to complete the scene.
The Hi-Z Buffer Requirement
Both passes rely on a Hierarchical Depth Buffer (Hi-Z), which is a full mipmap chain of the scene's depth buffer. In the Hi-Z pyramid, each pixel in a higher mip level stores the maximum (furthest) depth of the corresponding \(2 \times 2\) texels from the finer mip level (assuming a standard reversed-Z configuration, where depth ranges from 1.0 at the near plane to 0.0 at the far plane).
A compute shader can test an object's screen-space bounding box against the Hi-Z buffer in a single texture fetch by sampling a mip level where the bounding box spans approximately one to two texels.
Orchestration with DirectX Compute and Indirect Draws
DirectX 12 enables this entire architecture to run on the GPU via
compute shaders, unordered access views (UAVs), and indirect argument
buffers executed via
ID3D12GraphicsCommandList::ExecuteIndirect.
Phase 1: Re-Projecting and Rendering Visible Objects
- Hi-Z Downsample: At the start of the frame, the final depth buffer from the previous frame is downsampled into a Hi-Z mip chain using a compute shader.
- First Culling Compute Pass: A compute shader evaluates all objects that were marked visible in the previous frame. It transforms each object's axis-aligned bounding box (AABB) into the current frame's projection space and tests it against the previous frame's Hi-Z.
- Indirect Argument Generation: For every instance
that passes the test, the compute shader appends drawing parameters
(index count, instance count, start index, base vertex, start instance)
into a
D3D12_DRAW_INDEXED_ARGUMENTSstructured buffer using atomic operations. - First Indirect Draw: The command list transitions
the argument buffer to an indirect argument state and issues
ExecuteIndirect. The GPU immediately draws only the passing instances, generating an initial depth buffer for the current frame.
Phase 2: Updating Depth and Catching False Occlusions
- Hi-Z Generation from Pass 1: A compute pass creates a new Hi-Z mip chain based on the depth buffer just generated by the first draw pass.
- Second Culling Compute Pass: A compute shader now tests all objects that were marked invisible or occluded in the previous frame. It tests their current-frame AABBs against the newly updated Hi-Z buffer.
- Second Indirect Argument Generation: Any object that passes this test represents newly revealed geometry (false negatives from the first pass). Their drawing arguments are appended to a second indirect argument buffer.
- Second Indirect Draw: The command list executes a
second
ExecuteIndirectcall, rendering only the newly visible geometry into the current render targets.
Visibility State Persistence
To maintain temporal coherence across frames, the GPU stores a visibility bitmask or state buffer in a default heap. When the compute shaders determine an object's visibility status in either pass, they update this buffer using UAV writes. In frame \(N+1\), the compute shader reads this state to categorize instances into the first or second pass pools, creating a continuous, self-correcting feedback loop.
Architectural Advantages
- Zero CPU Readback: Occlusion queries do not touch the CPU, avoiding multi-frame stalls and PCI-e transfer bottlenecks.
- Minimal Overdraw: Heavily occluded regions, complex interiors, and densely populated environments bypass the pixel pipeline almost entirely.
- No "Popping" Artifacts: Unlike single-frame predictive culling, the second pass guarantees that any object that suddenly becomes visible is rendered in the same frame before the back buffer is presented.