If a tree falls in a forest and no one is around to hear it, does it make a sound? This is a popular introductory question in philosophy, on which many have opined over the centuries. When it comes to virtual worlds, the answer is a resounding no. Out of sight, out of mind, out of processing. It will be logged, its state updated but it won't be made visible or audible for it is outside the view frustum (and audio range).
What of objects that are in the view frustum, but are completely occluded by some other, larger object? The problem of occlusion culling is a fundamental one in computer graphics and has been extensively researched, especially as it is continuously affected by advances in hardware and software.
A recap of recent history
In the early 2000s, central processors were still single-threaded and GPUs were considerably less programmable than they are today. The solution was introduced in the form of special hardware functionality to tackle occlusion culling on the GPU called "hardware occlusion queries". However, they require a ridiculously slow, error-prone, constant ping-pong match between the CPU and the GPU which considerably reduces the possible performance gain margins. The CPU has to configure the GPU for occlusion queries, submit the query data (render coarse geometry) and read back the results in order to update its indirection-heavy hierarchical structures dedicated to accelerating visibility determination and avoiding false negatives. And only then is it finally able to submit all the visible instances for rendering using the immediate mode APIs of the time (Direct3D 9.x / OpenGL 1.x+). The most successful approach of this generation of occlusion culling technology was called coherent hierarchical occlusion culling (commonly abbreviated as CHC++).
Coherent hierarchical occlusion culling in actionSliding into the 2010s, more and more users had already acquired multithreaded microprocessors which boasted vectorization extensions as well. Tired of the cache-incoherent nature of hierarchical occlusion culling, hard-to-manage false negatives and overall latency of communicating back and forth with the GPU before even beginning to construct a frame, we turned to more CPU-centric, data-oriented methods for occlusion culling. Especially as it was already becoming more and more economical to just brute force "general" culling just by doing view frustum culling against large blocks directly on the CPUs and still run circles around methods like octree-based culling. The solution was a bit out there, we're essentially talking about software occlusion queries. They involved running a small rasterizer on the CPUs which generated a low resolution depth buffer which would then be used solely with bounding geometry to estimate occlusion by large, predefined static occluders. First commercially successful game (I am aware of) that opted for this method was DICE's Battlefield 3 running on the Frostbite engine. I have no doubt that this was directly motivated by the considerable number of Cell SPEs alongside the PPU on the Playstation 3.
Software-rasterized low resolution depth of large, static occluders for occlusion testing.With the arrival of true indirect dispatch allowing the GPU to compute the drawing set on its own without CPU intervention, a shift back towards hardware-accelerated occlusion culling was made. This was further bolstered by advances in temporal reprojection approaches, leading to methods where the occlusion culled set was determined using a downsampled, hierarchical depth buffer from previous frames during the advent of "next-gen" graphics APIs around 2014 and 2015.
Around that time, I had a strong itch regarding whether or not I could mix early-z testing and conservative rasterization to utilize fast hardware paths of modern GPUs to do my occlusion culling for free because there are penalties to the hierarchical approach which I deemed undesirable. Unfortunately, conservative rasterization tier support on modern GPUs was and is so bad that even Maxwell 2.0 GPUs like the GTX 980 support only tier 1 which is inadequate for the task at hand. On the other hand, my hunch about early-z subversion was right on the money. And fortunately for me, I wasn't the only one to consider it which is always helpful to avoid a considerable amount of head-scratching and writing suboptimal implementations.
Are you still there?
We start with the depth buffer of the previous frame. Naturally, this means the first frame will have no occlusion culling. However, unlike temporal supersampling which takes a few frames to converge, we're ready to go on the second frame. Given that efficient occlusion culling methods use oriented bounding boxes and, at best, coarse meshes to represent entities in the scene, we don't need the full resolution provided by the depth buffer which would bog down the system and eliminate the advantage.
CPU-side occlusion culling implementations utilizing software rasterization like the one behind DICE's Battlefield 3 have shown that $1/16$ resolution is more than sufficient. It is also the second most reasonable guess given that the buffer needs to be downsampled isotropically and done in one step. Downsampling is done by taking the closest (maximum when inverted) value of each 4x4 block considered, which is par for the course when it comes to analyzing depth buffers. Especially if you have done depth-sensitive ray marchers for screenspace reflections or performed something like tile-based light culling.
// Optimized for readability
#define THREAD_GROUP_DIMENSION 8
// Organize your data as you like.
struct thread_execution_information {
uint3 thread_id : SV_DispatchThreadID;
uint3 thread_group_id : SV_GroupThreadID;
uint3 group_id : SV_GroupID;
uint group_index : SV_GroupIndex;
};
[numthreads(THREAD_GROUP_DIMENSION, THREAD_GROUP_DIMENSION, 1)]
void main(thread_execution_information executing_thread_information) {
float2 const center_offset = float2(0.5f, 0.5f);
float2 const thread_fid = float2(executing_thread_information.thread_id.xy);
float2 const downsampled_buffer_dimensions = 0.25f * depth_buffer_dimensions;
// This will move us to the center of each 4x4 block within the full resolution buffer.
float2 const downsampled_center = (thread_fid + center_offset) / downsampled_buffer_dimensions;
if(downsampled_center.x >= 1.0f || downsampled_center.y >= 1.0f) return; // Out of bounds
// Full resolution buffer texel size, so we can get to the centers of the surrounding
// 2x2 blocks on which we will apply D3D12_FILTER_MAXIMUM_MIN_MAG_POINT_MIP_LINEAR,
// to ensure there are no zero weights which would discard them from the max comparison.
float2 const full_resolution_texel_size = 1.0f / depth_buffer_dimensions;
float block_maximums[4];
float2 block_center_offsets[4] = {
float2(-1.0f, -1.0f), float2( 1.0f, -1.0f),
float2( 1.0f, 1.0f), float2(-1.0f, 1.0f)
};
[unroll]
for(int i = 0; i < 4; ++i) {
float2 const center = downsampled_center + full_resolution_texel_size * block_center_offsets[i];
block_maximums[i] = depth_buffer.SampleLevel(depth_max_sampler, center, 0).r;
}
// Final manual maximum determination performed, ready for reprojection.
float downsampled_depth = max(max(max(block_maximums[0], block_maximums[1]), block_maximums[2]), block_maximums[3]);
// ...Downsampling the depth buffer using D3D11.2+ "MinMaxFiltering" (Tied to FL12_0 / Tiled Resources Tier 2).Regarding Min-Max Filtering
We're relying on functionality which was originally optional with Direct3D 11.2. Specifically, the ability to do min-max sampling which is now widely supported on Direct3D 12 capable hardware. Supporting transitional hardware with primarily FL11_1 capabilities will require you to implement the downsampling the old-fashioned way.
Onto the reprojection. Just like with temporal supersampling, we only have the information which is available in screenspace. Fast camera movements will generate erroneous areas due to a myriad of reasons, stuff leaving the screen, going behind it, coming onto it, objects moving in a way that information from previous frames is invalidated etc. Good news is that occlusion culling is far less sensitive to these types of errors but that doesn't mean a free lunch, unfortunately. Since there isn't a good way to select an alternative value in those areas, the only one that can be used semi-reliably is the downsampled depth we computed in the listing above.
Credit where credit is due
The nugget in the last paragraph is due to the folks behind Deus Ex's Dawn engine. Previously, I actually used a heuristic to "content-aware out" those gaps which turned out to be unnecessarily complex. Like I said, always pays to have multiple people on the same trail, willing to share their findings.
Furthermore, as depths are non-linear and inverted (avoiding z-fighting), another concern is stuff going behind the screen because that's where depth values may "explode" and corrupt our efficiency.
// We're in texture space right now. In Direct3D, the origin is the top-left corner.
// In order to use the inverse projection transform, we need to go back to normalized device coordinates.
// Depth is in the 0 to 1 range, Y axis is flipped and XY go from [-1, 1].
float2 xy_ndc = downsampled_center * float2(2.0f, -2.0f) - 1.0f;
float4 position_ws = mul(previous_inverted_projection_view_transform, float4(xy_ndc, downsampled_depth, 1.0f));
position_ws /= position_ws.w; // Complete the non-linear part of the transform.
// Using the new world position and the current projection-view transfrom, we compute
// the new placement position in texture space to put the reprojected depth in.
// First to NDC:
float4 reprojected_position = mul(current_projection_view_transform, position_ws);
reprojected_position /= reprojected_position.w;
float depth = reprojected_position.w < 0.0f ? downsampled_depth : reprojected_position.z;
// Then to texture space:
reprojected_position.xy *= float2(0.5f, -0.5f);
reprojected_position.xy += 0.5f;
// The downsampled depth buffer has all its values initialized to 0 by default, which precludes the possibility
// of utilizing InterlockedMin, requiring us to do some inversion gymnastics here for this to work.
uint intermediate_depth = asuint(1.0f - saturate(depth));
// Safest thing is to overwrite the possible hole immediately, minimizing the error (but this is a coarse approximation)
InterlockedMax(downsampled_depth_buffer[executing_thread_information.thread_id.xy], intermediate_depth);
// Finally store in reprojected position as well.
int2 reprojected_position_ps = int2(saturate(reprojected_position.xy) * downsampled_buffer_dimensions);
InterlockedMax(downsampled_depth_buffer[reprojected_position_ps], intermediate_depth);
}Temporal reprojection followed by cleanup on aisle 3.With the reprojected, downsampled depth buffer in tow, we're ready to subvert age old depth prepass of forward-biased approaches to occlusion culling by placing the [earlydepthstencil] attribute on the pixel shader. As you (should) know, this will force early depth testing which predicates the execution of the pixel shader. Thus, as we draw our OBBs and coarse bounding geometries, only the visible ones will actually trigger the execution of the pixel shader.
In it, we we use a simple UAV to a linear structured buffer where the "structure" are integers, fundamentally behaving like an array of booleans. It's trivial to clear this structure to $0$ by using the appropriate ID3D12GraphicsCommandList :: ClearUnorderedAccessView* Direct3D 12 API, so the final shader looks satisfyingly simple. You just use the instance IDs to index into the preallocated visibility buffer and set it to $1$. Unordered access mechanism takes care of the boatload of incoming writes.
RWStructuredBuffer<uint> visibility_buffer : register(u0);
[earlydepthstencil]
void main(uint instance_id : SV_InstanceID) {
visibility_buffer[instance_id] = 1; // Free lunch.
}Simplest shader you'll ever write. All that remains is to write a custom-tailored compute shader (for your tech) which operates on every instance that passed general culling, whatever your implementation of it might be. You go through each and check them against the visibility buffer. If you determine one is visible, generate the indirect drawing information and store it in the appropriate buffer which you'll trigger from the command list later on.
However, keep in mind that errors due to temporal reprojection and even failure to rasterize small OBBs (not touching an unobstructed pixel's center) are guaranteed to introduce false negatives. Thus, you should also maintain a list of occluded instances and redraw their OBBs against the freshly built full resolution depth buffer composed of all the possible occluders after indirectly dispatching the visible instances. This entails running through a near carbon-copy of the first pass, albeit considerably simplified.
The approach works for both forward and defer biased rendering systems and integrates seamlessly. I am personally still rolling a hybrid forward-biased renderer because of my preference for delivering the option of superb spatio-temporal supersampling and elegant handling of transparency. But if your platform has considerable constraints, nobody will judge you for going deferred.


