Generating Hi-Z Depth Pyramids in GLSL Compute
This article provides a technical guide on constructing a Hierarchical Depth (Hi-Z) buffer using compute shaders and min-reduction in GLSL. It covers the texture configuration required for mipmapped depth data, the GLSL compute shader implementation for sampling and reducing texels, edge-case management for odd texture dimensions, and the host-side dispatch loop with proper execution barriers.
Hi-Z Concept Overview
A Hierarchical Depth (Hi-Z) buffer is a mipmapped texture where each texel in mip level \(N\) contains the conservative depth (typically the minimum or maximum depth) of a \(2 \times 2\) texel footprint from mip level \(N-1\). This pyramid allows GPU-driven occlusion culling algorithms to test an object's bounding box against a low-resolution depth representation before rasterization.
In standard depth conventions (where near is 0.0 and far is 1.0), the
reduction operation uses min to capture the closest
occluding surface. In reversed-Z setups (where near is 1.0 and far is
0.0), a max reduction must be used instead.
Texture Setup
The Hi-Z buffer requires a single-channel floating-point format that
supports mipmapping and simultaneous bindless or image load/store
access. GL_R32F is commonly chosen for compatibility and
precision.
GLuint hiZTex;
glCreateTextures(GL_TEXTURE_2D, 1, &hiZTex);
int levels = 1 + std::floor(std::log2(std::max(width, height)));
glTextureStorage2D(hiZTex, levels, GL_R32F, width, height);
glTextureParameteri(hiZTex, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST);
glTextureParameteri(hiZTex, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTextureParameteri(hiZTex, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTextureParameteri(hiZTex, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);Mip 0 is initialized by copying the primary depth attachment from
your geometry pass into level 0 using glCopyImageSubData or
a full-screen blit.
Compute Shader Implementation
The downsampling pass is executed per mip level. A standard workgroup size of \(8 \times 8\) or \(16 \times 16\) threads processes texels in the destination mip level.
When source dimensions are odd numbers, a \(2 \times 2\) texel window can omit border texels. To preserve conservative depth testing, compute shaders must expand the search footprint up to a \(3 \times 3\) or conditionally fetch extra samples along the edges when reducing odd-sized parent levels.
#version 450 core
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
// Sampler bound to input mip level (N-1)
layout(binding = 0) uniform sampler2D uSourceMip;
// Image bound to output mip level (N)
layout(r32f, binding = 1) writeonly uniform image2D uDestinationMip;
layout(location = 0) uniform ivec2 uSourceSize;
layout(location = 1) uniform ivec2 uDestinationSize;
void main() {
ivec2 destCoord = ivec2(gl_GlobalInvocationID.xy);
if (destCoord.x >= uDestinationSize.x || destCoord.y >= uDestinationSize.y) {
return;
}
ivec2 sourceCoord = destCoord * 2;
// Check if source dimensions are odd
bool isOddX = (uSourceSize.x & 1) != 0;
bool isOddY = (uSourceSize.y & 1) != 0;
// Base 2x2 footprint
float d0 = texelFetch(uSourceMip, sourceCoord + ivec2(0, 0), 0).r;
float d1 = texelFetch(uSourceMip, sourceCoord + ivec2(1, 0), 0).r;
float d2 = texelFetch(uSourceMip, sourceCoord + ivec2(0, 1), 0).r;
float d3 = texelFetch(uSourceMip, sourceCoord + ivec2(1, 1), 0).r;
float minDepth = min(min(d0, d1), min(d2, d3));
// Handle extra row/column for non-power-of-two (NPOT) dimensions
if (isOddX && (destCoord.x == uDestinationSize.x - 1)) {
float extraX0 = texelFetch(uSourceMip, sourceCoord + ivec2(2, 0), 0).r;
float extraX1 = texelFetch(uSourceMip, sourceCoord + ivec2(2, 1), 0).r;
minDepth = min(minDepth, min(extraX0, extraX1));
}
if (isOddY && (destCoord.y == uDestinationSize.y - 1)) {
float extraY0 = texelFetch(uSourceMip, sourceCoord + ivec2(0, 2), 0).r;
float extraY1 = texelFetch(uSourceMip, sourceCoord + ivec2(1, 2), 0).r;
minDepth = min(minDepth, min(extraY0, extraY1));
}
if (isOddX && isOddY && (destCoord == uDestinationSize - 1)) {
float corner = texelFetch(uSourceMip, sourceCoord + ivec2(2, 2), 0).r;
minDepth = min(minDepth, corner);
}
imageStore(uDestinationMip, destCoord, vec4(minDepth));
}Host-Side Dispatch Loop and Synchronization
Because each level \(N\) depends directly on the results of level \(N-1\), generating the full pyramid requires sequential dispatches with memory barriers to avoid data races.
Using glTextureView avoids rebinding the entire
mipmapped resource and isolates specific mip levels for sampling.
glUseProgram(hiZPipeline);
int curWidth = width;
int curHeight = height;
for (int level = 1; level < levels; ++level) {
int nextWidth = std::max(1, curWidth / 2);
int nextHeight = std::max(1, curHeight / 2);
// Pass dimensions to compute shader
glUniform2i(0, curWidth, curHeight);
glUniform2i(1, nextWidth, nextHeight);
// Bind source mip (level - 1) as a texture sampler via texture view
glBindTextureUnit(0, mipViews[level - 1]);
// Bind destination mip (level) as writable image
glBindImageTexture(1, hiZTex, level, GL_FALSE, 0, GL_WRITE_ONLY, GL_R32F);
// Calculate workgroup dispatches (8x8 threads per group)
GLuint groupsX = (nextWidth + 7) / 8;
GLuint groupsY = (nextHeight + 7) / 8;
glDispatchCompute(groupsX, groupsY, 1);
// Synchronize image writes with subsequent shader reads
glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT | GL_TEXTURE_FETCH_BARRIER_BIT);
curWidth = nextWidth;
curHeight = nextHeight;
}Performance Considerations
- Subgroup Min Reductions: Modern hardware allows
workgroups to perform intra-group reductions using
subgroupMin(GL_KHR_shader_subgroup_arithmetic) if processing multiple destination texels per workgroup, which reduces overall memory bandwidth. - Texture Views vs. Base Level Sampling: Creating
individual
glTextureViewhandles for each mip level enables the use ofsampler2Dat level 0 instead of re-specifyingGL_TEXTURE_BASE_LEVELandGL_TEXTURE_MAX_LEVELon the primary texture prior to every dispatch. - Texture Gather Alternative: If power-of-two
dimensions are guaranteed,
textureGathercan collect the \(2 \times 2\) footprint in a single instruction by sampling at the boundary between the four source texels.