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

  1. 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.
  2. Texture Views vs. Base Level Sampling: Creating individual glTextureView handles for each mip level enables the use of sampler2D at level 0 instead of re-specifying GL_TEXTURE_BASE_LEVEL and GL_TEXTURE_MAX_LEVEL on the primary texture prior to every dispatch.
  3. Texture Gather Alternative: If power-of-two dimensions are guaranteed, textureGather can collect the \(2 \times 2\) footprint in a single instruction by sampling at the boundary between the four source texels.