12 - GPU-driven: Hi-Z occlusion culling + indirect-count + bindless

Tutorials 1-11 all share one assumption: the CPU decides what to draw and how many. This one hands that decision to the GPU. A compute shader tests every instance against a hierarchical-Z depth pyramid, compacts the survivors into an indirect-draw buffer plus a GPU-written count, and the draw reads that count – the CPU never learns how many objects survive. Each survivor then selects its material from a bindless sampler2D[] array via nonuniformEXT, keyed by gl_DrawID. This is the production GPU-driven loop in miniature.

Occlusion culling is invisible by construction: from the camera, a culled object was hidden anyway, so its absence looks identical to “drew everything.” A single view can’t show that culling happened. So the frame renders the scene twice, side by side:

  • Left – camera view. A moving wall sits between the camera and a field of cubes. We draw only the survivors with cmd_draw_indexed_indirect_count. It looks correct, because the culled cubes were behind the wall. That is the whole value prop: a perf win that is visually transparent.

  • Right – god view. An overhead/offset camera that sees the wall from the side plus the cubes behind it. It draws all N cubes and tints each by what the GPU decided: full material if visible, a translucent red ghost if occlusion-culled, a translucent blue ghost if frustum-culled. This reveals what the GPU skipped.

The wall sweeps each frame, so the culled set sweeps in the god view while the camera view stays correct.

The headline rails:

  • Hierarchical-Z (HZB) depth pyramid (R32f storage image). An occluder depth pre-pass renders the wall into a depth image; a compute shader copies that into HZB mip 0, then max-downsamples the pyramid (each level is the farthest of the four texels below it). 8-bit Rgba8 is unusable for occlusion depth, so the storage image declares @format = "r32f" – a single-channel 32-bit float target.

  • Conservative occlusion test. For each instance the cull shader projects the eight world-AABB corners, derives a screen-space AABB + nearest depth, picks the HZB mip where the AABB spans roughly 2x2 texels, and samples the farthest occluder over that footprint. The instance is culled iff its nearest point is farther than the farthest occluder in its footprint – it is then fully hidden. The max-downsample makes this conservative: a cube whose footprint straddles the wall edge is kept (its boundary texel reads “background”).

  • Atomic stream compaction. A survivor does k = atomicAdd(drawCount, 1) and writes drawCmds[k] (a VkDrawIndexedIndirectCommand) + drawInstance[k] (its original instance id). The draw count is a GPU-written buffer.

  • Indirect-count draw. cmd_draw_indexed_indirect_count reads the command buffer and the count buffer; the GPU issues exactly drawCount draws with no CPU round-trip. The main vertex shader maps gl_DrawID back to its instance via drawInstance[gl_DrawID].

  • Bindless materials. Each survivor’s fragment shader indexes a sampler2D[N] descriptor array with samplers[nonuniformEXT(matIdx)] – one descriptor binding, N textures, no per-material descriptor set or pipeline switch.

  • A five-pass barrier chain. occluder depth pre-pass → HZB build (one dispatch per mip, compute→compute barriers) → cull (compute→indirect + compute→vertex barriers) → main indirect-count draw → god all-N instanced draw. The barrier chain is the real correctness risk; it is verified by the oracle reading back the GPU’s decision, not just by a green fence.

Every line of every shader is daslang, lowered to SPIR-V at compile time. Authoring the cull shader is what surfaced (and fixed, in dasSpirv) the two emitter prerequisites this tutorial needs: the @format storage-image qualifier and member access on a local struct value (let inst = instances[i]; inst.pos_scale).

The clip above is the headless recording: the wall sweeps across the field; in the left/camera panel the survivors are drawn via the indirect-count path and the scene always looks complete, while the right/god panel reveals the red occlusion ghosts (and blue frustum ghosts at the edges) sweeping with the wall. To see it live on your own GPU, run the windowed viewer (see See it live below).

The shaders

Ten shaders: the HZB build pair (hzb_mip0 copies the depth buffer, hzb_down max-downsamples), the cull compute shader (the heart of the tutorial), the main camera vertex/fragment pair (survivors via gl_DrawID + bindless), the god vertex/fragment pair (all N, tinted by cull reason), and the occluder wall’s vertex shader + its two fragment shaders (opaque for the camera view, translucent for the god view). The shared Scene UBO carries both cameras’ view-projection matrices; the CubeInstance SSBO is the static field.

module gpu_driven_tut_shaders public

require vulkan/vulkan_boost public
require vulkan/spirv_vulkan_shader public
require spirv/spirv_builtins public
require math

// ===== Hi-Z depth pyramid build =====
// Standard depth (0 = near, 1 = far). Each HZB texel holds the MAX depth of the region it covers (the
// FARTHEST occluder), so an object is occluded iff its nearest depth exceeds the HZB value over its
// screen footprint. mip 0 copies the occluder depth 1:1; each finer level is the max of the 2x2 below.

var @set = 0 @binding = 0 hzb_src : sampler2D                    // read: depth buffer (mip0) or HZB level L-1
var @set = 0 @binding = 1 @format = "r32f" hzb_dst : image2D     // write: HZB level L (R32f storage)

struct HzbPush {
    src_lod : int       // mip level to read via texelFetch (0 when reading the depth buffer for mip 0)
}
var @push_constant hzb_pc : HzbPush

// mip 0: copy the occluder depth buffer into HZB level 0 (1:1, lod 0 of the depth sampler).
[vulkan_compute_shader(local_size_x=8, local_size_y=8, name="hzb_mip0_spv")]
def hzb_mip0 {
    let gid = gl_GlobalInvocationID
    let c = int2(int(gid.x), int(gid.y))
    let d = texelFetch(hzb_src, c, 0)
    imageStore(hzb_dst, c, float4(d.x, 0.0f, 0.0f, 1.0f))
}

// downsample: HZB[L][c] = max over the 2x2 footprint in level L-1 (conservative farthest occluder).
// Every level is a power-of-two, exact 2x reduction and the host dispatches exactly (dim/8) groups, so
// the 2x2 reads (s .. s+(1,1)) are always in bounds -- no edge-clamp and no out-of-bounds fetch.
[vulkan_compute_shader(local_size_x=8, local_size_y=8, name="hzb_down_spv")]
def hzb_down {
    let gid = gl_GlobalInvocationID
    let c = int2(int(gid.x), int(gid.y))
    let s = c * 2
    let lod = hzb_pc.src_lod
    let a = texelFetch(hzb_src, s, lod).x
    let b = texelFetch(hzb_src, s + int2(1, 0), lod).x
    let e = texelFetch(hzb_src, s + int2(0, 1), lod).x
    let f = texelFetch(hzb_src, s + int2(1, 1), lod).x
    let m = max(max(a, b), max(e, f))
    imageStore(hzb_dst, c, float4(m, 0.0f, 0.0f, 1.0f))
}

// ===== instance scene + cull compute =====

// One axis-aligned cube per instance (no rotation): the world AABB is pos +/- 0.5*scale. Counts are
// carried as floats in float4s (cast in-shader) to stay on the codegen paths the UBO emitter handles
// cleanly -- trailing scalar UBO fields are a known rough edge.

struct CubeInstance {
    pos_scale : float4    // xyz = world center, w = uniform scale
    mat_idx   : int       // bindless material index
    pad0      : int
    pad1      : int
    pad2      : int       // std430 pad to 32 bytes
}

// VkDrawIndexedIndirectCommand layout (5 x 32-bit), appended by the cull shader, consumed by
// vkCmdDrawIndexedIndirectCount.
struct DrawCmd {
    index_count    : uint
    instance_count : uint
    first_index    : uint
    vertex_offset  : int
    first_instance : uint
}

struct Scene {
    view_proj     : float4x4    // main camera (the culling + main-draw viewpoint)
    god_view_proj : float4x4    // god camera (the reveal viewpoint, sees the occluder from the side)
    hzb_dims      : float4      // x,y = HZB base size (px); z = mip count; w = instance count
    params        : float4      // x = index count per cube (36); yzw unused
}

var @uniform @set = 0 @binding = 0 scene : Scene
var @ssbo @set = 0 @binding = 1 instances : array<CubeInstance>
var @ssbo @set = 0 @binding = 2 draw_cmds : array<DrawCmd>
var @ssbo @set = 0 @binding = 3 draw_count : array<uint>
var @ssbo @set = 0 @binding = 4 draw_instance : array<uint>
var @ssbo @set = 0 @binding = 5 cull_reason : array<uint>
var @set = 0 @binding = 6 hzb_tex : sampler2D

// One invocation per instance: project the world AABB, frustum-test, then Hi-Z occlusion-test against
// the depth pyramid. Survivors append a draw command + their instance id (atomic compaction) and get
// cull_reason 0; the god view colours the rest by reason (1 = frustum, 2 = occluded).
[vulkan_compute_shader(local_size_x=64, name="cull_spv")]
def cull_cs {
    let i = int(gl_GlobalInvocationID.x)
    if (i >= int(scene.hzb_dims.w)) return
    let inst = instances[i]
    let cen = inst.pos_scale.xyz
    let hf = inst.pos_scale.w * 0.5f
    // project the 8 world-AABB corners (unrolled -- the emitter keeps screen-AABB accumulation simplest
    // as straight-line lets rather than loop-carried vector state).
    let lo3 = cen - float3(hf, hf, hf)
    let hi3 = cen + float3(hf, hf, hf)
    let c0 = scene.view_proj * float4(lo3.x, lo3.y, lo3.z, 1.0f)
    let c1 = scene.view_proj * float4(hi3.x, lo3.y, lo3.z, 1.0f)
    let c2 = scene.view_proj * float4(lo3.x, hi3.y, lo3.z, 1.0f)
    let c3 = scene.view_proj * float4(hi3.x, hi3.y, lo3.z, 1.0f)
    let c4 = scene.view_proj * float4(lo3.x, lo3.y, hi3.z, 1.0f)
    let c5 = scene.view_proj * float4(hi3.x, lo3.y, hi3.z, 1.0f)
    let c6 = scene.view_proj * float4(lo3.x, hi3.y, hi3.z, 1.0f)
    let c7 = scene.view_proj * float4(hi3.x, hi3.y, hi3.z, 1.0f)
    let min_w = min(min(min(c0.w, c1.w), min(c2.w, c3.w)), min(min(c4.w, c5.w), min(c6.w, c7.w)))
    // Divide each corner by its own w to get NDC, unconditionally. If the AABB crosses the near plane
    // (min_w ~ 0) the projection is garbage, but `near_cross` wins the decision below and the instance
    // is kept, so the garbage is never acted on -- this keeps the shader flat (no deep nesting).
    let n0 = c0.xyz * (1.0f / c0.w)
    let n1 = c1.xyz * (1.0f / c1.w)
    let n2 = c2.xyz * (1.0f / c2.w)
    let n3 = c3.xyz * (1.0f / c3.w)
    let n4 = c4.xyz * (1.0f / c4.w)
    let n5 = c5.xyz * (1.0f / c5.w)
    let n6 = c6.xyz * (1.0f / c6.w)
    let n7 = c7.xyz * (1.0f / c7.w)
    let lo = min(min(min(n0, n1), min(n2, n3)), min(min(n4, n5), min(n6, n7)))
    let hi = max(max(max(n0, n1), max(n2, n3)), max(max(n4, n5), max(n6, n7)))
    let near_z = lo.z
    // Hi-Z occlusion sample: pick the mip where the AABB spans ~2x2 texels, sample its footprint, take
    // the farthest occluder. Computed unconditionally; the result only matters when the AABB is on-screen.
    let mip_count = int(scene.hzb_dims.z)
    let base = scene.hzb_dims.xy
    let uv_min = clamp(lo.xy * 0.5f + float2(0.5f, 0.5f), float2(0.0f, 0.0f), float2(1.0f, 1.0f))
    let uv_max = clamp(hi.xy * 0.5f + float2(0.5f, 0.5f), float2(0.0f, 0.0f), float2(1.0f, 1.0f))
    let span = (uv_max - uv_min) * base
    let lod = float(clamp(int(ceil(log2(max(max(span.x, span.y), 1.0f)))), 0, mip_count - 1))
    let o0 = textureLod(hzb_tex, uv_min, lod).x
    let o1 = textureLod(hzb_tex, float2(uv_max.x, uv_min.y), lod).x
    let o2 = textureLod(hzb_tex, float2(uv_min.x, uv_max.y), lod).x
    let o3 = textureLod(hzb_tex, uv_max, lod).x
    let occ = max(max(o0, o1), max(o2, o3))
    // flat decision (nested ternary -> OpSelect; 0 = visible, 1 = frustum-culled, 2 = occlusion-culled).
    // near-cross overrides everything -> keep (0).
    let near_cross = min_w <= 0.0001f
    let frustum = hi.x < -1.0f || lo.x > 1.0f || hi.y < -1.0f || lo.y > 1.0f || near_z > 1.0f
    let occluded = near_z > occ
    let reason = near_cross ? 0 : (frustum ? 1 : (occluded ? 2 : 0))
    cull_reason[i] = uint(reason)
    if (reason == 0) {
        let kk = int(atomicAdd(draw_count[0], 1u))
        draw_cmds[kk].index_count = uint(scene.params.x)
        draw_cmds[kk].instance_count = 1u
        draw_cmds[kk].first_index = 0u
        draw_cmds[kk].vertex_offset = 0
        draw_cmds[kk].first_instance = 0u
        draw_instance[kk] = uint(i)
    }
}

// ===== shared cube geometry + bindless materials =====
// The 36-index unit cube (tutorial 04/05 geometry) plus a UV for texturing. N_MAT distinct textures live
// in a bindless sampler2D[] array; each instance picks one with nonuniformEXT (the index varies per draw).

var @in @location = 0 a_pos    : float3
var @in @location = 1 a_normal : float3
var @in @location = 2 a_uv     : float2

let N_MAT = 6
var @set = 0 @binding = 7 samplers : sampler2D[6]

// ===== main camera view: survivors only, via vkCmdDrawIndexedIndirectCount =====

// gl_DrawID is the compacted draw ordinal [0, drawCount); draw_instance[gl_DrawID] maps it back to the
// original instance the cull kept. The wall in front means the culled instances were hidden anyway, so
// this view looks correct -- that is the whole point of occlusion culling.

var @out @location = 0 v_normal : float3
var @out @location = 1 v_uv     : float2
var @out @flat @location = 2 v_mat : int

[vulkan_vertex_shader(name="main_vert_spv")]
def main_vs {
    let idx = int(draw_instance[gl_DrawID])
    let inst = instances[idx]
    let world = a_pos * inst.pos_scale.w + inst.pos_scale.xyz
    gl_Position = scene.view_proj * float4(world, 1.0f)
    v_normal = a_normal
    v_uv = a_uv
    v_mat = inst.mat_idx
}

var @in @location = 0 f_normal : float3
var @in @location = 1 f_uv     : float2
var @in @flat @location = 2 f_mat : int
var @out @location = 0 main_color : float4

[vulkan_fragment_shader(name="main_frag_spv")]
def main_fs {
    let n = normalize(f_normal)
    let l = normalize(float3(0.4f, 1.0f, 0.5f))
    let lambert = max(dot(n, l), 0.0f) * 0.7f + 0.3f
    let tex = texture(samplers[nonuniformEXT(f_mat)], f_uv)
    main_color = float4(tex.xyz * lambert, 1.0f)
}

// ===== god view: ALL N instances, the reveal =====

// A regular instanced draw (cmd_draw_indexed, instanceCount = N) from an offset camera that sees the
// occluder side-on. gl_InstanceIndex IS the instance id, so cull_reason[gl_InstanceIndex] colours each
// cube by the GPU's verdict: full material if visible, red ghost if occlusion-, blue if frustum-culled.

var @out @location = 0 g_normal : float3
var @out @location = 1 g_uv     : float2
var @out @flat @location = 2 g_mat    : int
var @out @flat @location = 3 g_reason : int

[vulkan_vertex_shader(name="god_vert_spv")]
def god_vs {
    let idx = gl_InstanceIndex
    let inst = instances[idx]
    let world = a_pos * inst.pos_scale.w + inst.pos_scale.xyz
    gl_Position = scene.god_view_proj * float4(world, 1.0f)
    g_normal = a_normal
    g_uv = a_uv
    g_mat = inst.mat_idx
    g_reason = int(cull_reason[idx])
}

var @in @location = 0 gf_normal : float3
var @in @location = 1 gf_uv     : float2
var @in @flat @location = 2 gf_mat    : int
var @in @flat @location = 3 gf_reason : int
var @out @location = 0 god_color : float4

[vulkan_fragment_shader(name="god_frag_spv")]
def god_fs {
    let n = normalize(gf_normal)
    let l = normalize(float3(0.4f, 1.0f, 0.5f))
    let lambert = max(dot(n, l), 0.0f) * 0.7f + 0.3f
    let tex = texture(samplers[nonuniformEXT(gf_mat)], gf_uv)
    let lit = tex.xyz * lambert
    let red = float3(0.9f, 0.15f, 0.1f)         // occlusion-culled (reason 2)
    let blue = float3(0.15f, 0.3f, 0.9f)        // frustum-culled (reason 1)
    let ghost = gf_reason == 2 ? red : blue
    let culled = gf_reason != 0
    let rgb = culled ? ghost : lit
    let a = culled ? 0.7f : 1.0f                 // translucent ghosts read over the wall + survivors
    god_color = float4(rgb, a)
}

// ===== occluder wall =====

// One opaque slab, rendered three times: into the depth pre-pass (so it populates the HZB), opaque into
// the main view, and translucent into the god view. The host bakes the wall's animated position into the
// mvp push constant (a lone matrix -> no trailing-scalar push-constant codegen pitfall).

struct OccPush {
    mvp : float4x4
}
var @push_constant occ_pc : OccPush

[vulkan_vertex_shader(name="occluder_vert_spv")]
def occluder_vs {
    gl_Position = occ_pc.mvp * float4(a_pos, 1.0f)
}

var @out @location = 0 occ_solid : float4
[vulkan_fragment_shader(name="occluder_solid_frag_spv")]
def occluder_solid_fs {
    occ_solid = float4(0.45f, 0.45f, 0.5f, 1.0f)        // opaque gray (main view)
}

var @out @location = 0 occ_ghost : float4
[vulkan_fragment_shader(name="occluder_ghost_frag_spv")]
def occluder_ghost_fs {
    occ_ghost = float4(0.5f, 0.5f, 0.55f, 0.16f)        // faint (god view: see the ghosts behind it)
}

The render (headless)

The host builds the R32f HZB image with per-mip storage views + one full-chain sampled view, the occluder depth image, the wide side-by-side target, the cube geometry + six bindless material textures, the SSBOs (instances, draw commands, draw count, draw-instance map, cull reasons), and the eight pipelines (three compute, five graphics). record_frame records the five-pass chain into one command buffer, with the explicit barriers between every stage.

def public record_frame(res : GpuDrivenResources; cmd : CommandBuffer; wall_x : float) {   // nolint:STYLE038 - flat command-record run
    let raw = boost_value_to_vk(cmd)

    // ===== pass 1: occluder depth pre-pass (camera view) =====
    occ_pc.mvp = camera_view_proj() * wall_model(wall_x)
    var depth_clear <- [clear_depth(1.0f)]
    record_render_pass(cmd, res.rp_depth, res.fb_depth, full_area(HZB_BASE, HZB_BASE), depth_clear) {
        cmd_bind_pipeline(cmd, res.depth_pipe)
        occluder_vs_push_constants(cmd, res.occ_pl)
        cmd_bind_vertex_buffer(cmd, 0u, weak_copy(res.cube_vb.buffer))
        cmd_bind_index_buffer(cmd, weak_copy(res.cube_ib.buffer), 0ul, VkIndexType.UINT16)
        cmd_draw_indexed(cmd, uint(N_INDICES), 1u, 0u, 0, 0u)
    }
    delete depth_clear

    // barrier: depth write -> compute sampled read (occ_depth is in DEPTH_STENCIL_READ_ONLY already)
    {
        var ib : ImageMemoryBarrier
        ib.srcAccessMask.depth_stencil_attachment_write = true
        ib.dstAccessMask.shader_read = true
        ib.oldLayout = VkImageLayout.DEPTH_STENCIL_READ_ONLY_OPTIMAL
        ib.newLayout = VkImageLayout.DEPTH_STENCIL_READ_ONLY_OPTIMAL
        ib.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
        ib.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
        ib.image = weak_copy(res.occ_depth.image)
        ib.subresourceRange.aspectMask.depth = true
        ib.subresourceRange.levelCount = 1u
        ib.subresourceRange.layerCount = 1u
        var imgs : array<ImageMemoryBarrier>
        imgs |> emplace(ib)
        let no_mem : array<MemoryBarrier>
        let no_buf : array<BufferMemoryBarrier>
        var src : VkPipelineStageFlags
        src.early_fragment_tests = true       // the depth-only occluder pipeline has no fragment
        src.late_fragment_tests = true        // shader, so its depth write lands in EARLY tests; cover both
        var dst : VkPipelineStageFlags
        dst.compute_shader = true
        let no_dep : VkDependencyFlags
        cmd_pipeline_barrier(cmd, src, dst, no_dep, no_mem, no_buf, imgs)
    }

    // ===== pass 2: HZB build =====
    // transition the whole HZB chain UNDEFINED -> GENERAL for storage writes
    {
        var ib : ImageMemoryBarrier
        ib.dstAccessMask.shader_write = true
        ib.oldLayout = VkImageLayout.UNDEFINED
        ib.newLayout = VkImageLayout.GENERAL
        ib.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
        ib.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
        ib.image = weak_copy(res.hzb.image)
        ib.subresourceRange.aspectMask.color = true
        ib.subresourceRange.levelCount = uint(MIP_COUNT)
        ib.subresourceRange.layerCount = 1u
        var imgs : array<ImageMemoryBarrier>
        imgs |> emplace(ib)
        let no_mem : array<MemoryBarrier>
        let no_buf : array<BufferMemoryBarrier>
        var src : VkPipelineStageFlags
        src.top_of_pipe = true
        var dst : VkPipelineStageFlags
        dst.compute_shader = true
        let no_dep : VkDependencyFlags
        cmd_pipeline_barrier(cmd, src, dst, no_dep, no_mem, no_buf, imgs)
    }
    // mip 0: copy occluder depth -> HZB[0]
    cmd_bind_pipeline(cmd, res.mip0_pipe, VkPipelineBindPoint.COMPUTE)
    bind_compute_set(cmd, res.hzb_pl, res.hzb_sets[0])
    cmd_dispatch(cmd, uint(HZB_BASE / 8), uint(HZB_BASE / 8), 1u)
    compute_barrier(cmd)
    // downsample: mip L = max 2x2 of mip L-1
    cmd_bind_pipeline(cmd, res.down_pipe, VkPipelineBindPoint.COMPUTE)
    for (l in range(MIP_COUNT - 1)) {
        let dst_mip = l + 1
        let dim = HZB_BASE >> dst_mip
        hzb_pc.src_lod = l
        bind_compute_set(cmd, res.hzb_pl, res.hzb_sets[dst_mip])
        hzb_down_push_constants(cmd, res.hzb_pl)
        cmd_dispatch(cmd, uint(max(dim / 8, 1)), uint(max(dim / 8, 1)), 1u)
        compute_barrier(cmd)
    }

    // ===== pass 3: cull =====
    // zero the draw count, then make it visible to the cull shader
    unsafe {
        vkCmdFillBuffer(raw, boost_value_to_vk(res.draw_count.buffer), 0ul, 4ul, 0u)
    }
    {
        var bb : BufferMemoryBarrier
        bb.srcAccessMask.transfer_write = true
        bb.dstAccessMask.shader_read = true
        bb.dstAccessMask.shader_write = true
        bb.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
        bb.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
        bb.buffer = weak_copy(res.draw_count.buffer)
        bb.offset = 0ul
        bb.size = 4ul
        var bufs : array<BufferMemoryBarrier>
        bufs |> emplace(bb)
        let no_mem : array<MemoryBarrier>
        let no_img : array<ImageMemoryBarrier>
        var src : VkPipelineStageFlags
        src.transfer = true
        var dst : VkPipelineStageFlags
        dst.compute_shader = true
        let no_dep : VkDependencyFlags
        cmd_pipeline_barrier(cmd, src, dst, no_dep, no_mem, bufs, no_img)
    }
    cmd_bind_pipeline(cmd, res.cull_pipe, VkPipelineBindPoint.COMPUTE)
    bind_compute_set(cmd, res.cull_pl, res.cull_set)
    cmd_dispatch(cmd, uint((N_INSTANCES + 63) / 64), 1u, 1u)

    // barrier: cull writes -> indirect draw read (draw_cmds/draw_count) + vertex read (draw_inst/cull_reason)
    {
        var bb_ind : BufferMemoryBarrier
        bb_ind.srcAccessMask.shader_write = true
        bb_ind.dstAccessMask.indirect_command_read = true
        bb_ind.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
        bb_ind.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
        bb_ind.buffer = weak_copy(res.draw_cmds.buffer)
        bb_ind.size = VK_WHOLE_SIZE
        var bb_cnt : BufferMemoryBarrier
        bb_cnt.srcAccessMask.shader_write = true
        bb_cnt.dstAccessMask.indirect_command_read = true
        bb_cnt.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
        bb_cnt.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
        bb_cnt.buffer = weak_copy(res.draw_count.buffer)
        bb_cnt.size = VK_WHOLE_SIZE
        var bb_vtx : BufferMemoryBarrier
        bb_vtx.srcAccessMask.shader_write = true
        bb_vtx.dstAccessMask.shader_read = true
        bb_vtx.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
        bb_vtx.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
        bb_vtx.buffer = weak_copy(res.draw_inst.buffer)
        bb_vtx.size = VK_WHOLE_SIZE
        var bb_rsn : BufferMemoryBarrier
        bb_rsn.srcAccessMask.shader_write = true
        bb_rsn.dstAccessMask.shader_read = true
        bb_rsn.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
        bb_rsn.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED
        bb_rsn.buffer = weak_copy(res.cull_reason.buffer)
        bb_rsn.size = VK_WHOLE_SIZE
        let bufs <- [<-bb_ind, <-bb_cnt, <-bb_vtx, <-bb_rsn]
        let no_mem : array<MemoryBarrier>
        let no_img : array<ImageMemoryBarrier>
        var src : VkPipelineStageFlags
        src.compute_shader = true
        var dst : VkPipelineStageFlags
        dst.draw_indirect = true
        dst.vertex_shader = true
        let no_dep : VkDependencyFlags
        cmd_pipeline_barrier(cmd, src, dst, no_dep, no_mem, bufs, no_img)
    }

    // ===== passes 4 + 5: main view (left) + god view (right) into one wide target =====
    var clears <- [clear_color(0.04f, 0.05f, 0.08f, 1.0f), clear_depth(1.0f)]
    record_render_pass(cmd, res.rp_color, res.fb_color, full_area(OUT_W, OUT_H), clears) {
        // ---- left panel: camera view, occluder + survivors via indirect count ----
        set_panel(cmd, 0)
        occ_pc.mvp = camera_view_proj() * wall_model(wall_x)
        cmd_bind_pipeline(cmd, res.occ_solid_pipe)
        occluder_vs_push_constants(cmd, res.occ_pl)
        cmd_bind_vertex_buffer(cmd, 0u, weak_copy(res.cube_vb.buffer))
        cmd_bind_index_buffer(cmd, weak_copy(res.cube_ib.buffer), 0ul, VkIndexType.UINT16)
        cmd_draw_indexed(cmd, uint(N_INDICES), 1u, 0u, 0, 0u)

        cmd_bind_pipeline(cmd, res.main_pipe)
        bind_graphics_set(cmd, res.main_pl, res.main_set)
        cmd_bind_vertex_buffer(cmd, 0u, weak_copy(res.cube_vb.buffer))
        cmd_bind_index_buffer(cmd, weak_copy(res.cube_ib.buffer), 0ul, VkIndexType.UINT16)
        cmd_draw_indexed_indirect_count(cmd, res.draw_cmds.buffer, 0ul, res.draw_count.buffer, 0ul,
            uint(N_INSTANCES), DRAWCMD_STRIDE)

        // ---- right panel: god view, ALL N instances + translucent wall ----
        set_panel(cmd, 1)
        cmd_bind_pipeline(cmd, res.god_pipe)
        bind_graphics_set(cmd, res.god_pl, res.god_set)
        cmd_bind_vertex_buffer(cmd, 0u, weak_copy(res.cube_vb.buffer))
        cmd_bind_index_buffer(cmd, weak_copy(res.cube_ib.buffer), 0ul, VkIndexType.UINT16)
        cmd_draw_indexed(cmd, uint(N_INDICES), uint(N_INSTANCES), 0u, 0, 0u)

        occ_pc.mvp = god_view_proj() * wall_model(wall_x)
        cmd_bind_pipeline(cmd, res.occ_ghost_pipe)
        occluder_vs_push_constants(cmd, res.occ_pl)
        cmd_bind_vertex_buffer(cmd, 0u, weak_copy(res.cube_vb.buffer))
        cmd_bind_index_buffer(cmd, weak_copy(res.cube_ib.buffer), 0ul, VkIndexType.UINT16)
        cmd_draw_indexed(cmd, uint(N_INDICES), 1u, 0u, 0, 0u)
    }
    delete clears
}

Self-verifying

The pixel oracle is the CI regression gate (lavapipe in CI, real GPU locally). With the wall pinned at the centre the scene is deterministic: the central columns of the field sit behind the wall, the outer ring is off-screen, the rest is clear. The test reads the GPU’s per-instance cull_reason straight from the SSBO and asserts the decision three ways – one occlusion-culled instance, one visible, one frustum-culled – then asserts the GPU-written survivor count equals the visible count, and that the rendering reflects the decision: the occluded instance shows the occluder (not its cube) in the camera view and a red ghost in the god view, while the visible instance shows its material.

[test]
def test_gpu_driven_occlusion(t : T?) {
    if (!draw_indirect_count_available()) {
        feint("drawIndirectCount not advertised by this device; skipping (CI lavapipe may predate it)\n")
        return
    }
    var inscope ctx <- build_gpu_driven_context()
    var pixels <- render_frame(ctx, WALL_PIN_X)
    var reasons <- read_cull_reasons(ctx)
    let dc = read_draw_count(ctx)

    // gy = 5 row of the 12x12 grid: f . . . o o o o . . . f
    let A = 5 * GRID_N + 5      // occluded: central column, behind the wall
    let B = 5 * GRID_N + 2      // visible:  left of the wall, on-screen
    let C = 5 * GRID_N + 0      // frustum:  outer ring, off-screen

    // (1) the GPU cull decision, read straight from the cull_reason SSBO
    t |> success(reasons[A] == 2u, "instance A (behind wall) is occlusion-culled, reason 2 (got {reasons[A]})")
    t |> success(reasons[B] == 0u, "instance B (clear of wall) is visible, reason 0 (got {reasons[B]})")
    t |> success(reasons[C] == 1u, "instance C (off-screen) is frustum-culled, reason 1 (got {reasons[C]})")

    // (2) the GPU-written survivor count == the number of visible instances (consumed by the
    //     indirect-count draw -- the CPU never set it)
    var n_visible = 0
    for (i in range(N_INSTANCES)) {
        if (reasons[i] == 0u) {
            n_visible++
        }
    }
    t |> success(int(dc) == n_visible, "draw_count {dc} == visible instances {n_visible}")
    t |> success(n_visible > 0 && n_visible < N_INSTANCES, "some but not all instances survived ({n_visible}/{N_INSTANCES})")

    // (3) camera view: A is hidden behind the opaque wall -> the pixel is the occluder, NOT A's cube.
    //     This is the whole value prop: the culled cube is invisible from the camera.
    let a_cam = px_at(pixels, camera_view_proj(), A, 0)
    t |> success(is_occluder_gray(a_cam), "camera view at A shows the occluder, not the cube (got {a_cam})")

    // (4) god view: A is revealed as a red occlusion ghost.
    let a_god = px_at(pixels, god_view_proj(), A, 1)
    t |> success(a_god.x > 120 && a_god.y < 80 && a_god.z < 80, "god view at A is a red ghost (got {a_god})")

    // (5) camera view: B really was drawn -> a coloured cube, not the gray wall and not the background.
    let b_cam = px_at(pixels, camera_view_proj(), B, 0)
    t |> success(!is_occluder_gray(b_cam) && luma(b_cam) > 25, "camera view at B shows the cube material (got {b_cam})")

    delete reasons
    delete pixels
}

See it live

window/show_gpu_driven.das opens a GLFW window with a Vulkan swapchain and runs the full five-pass GPU-driven frame per frame, blitting the wide side-by-side target onto the swapchain image. The wall sweeps with wall-clock time, so the red ghosts in the god panel sweep while the camera panel stays correct.

require glfw/glfw_boost
require vulkan
require vulkan/vulkan_boost
require vulkan/vulkan_window
require ../gpu_driven_tut.das
require daslib/defer
require math

[export]
def main {   // nolint:STYLE038 - flat viewer lifecycle scaffold
    if (volkInitialize() != 0) {
        panic("no vulkan loader")
    }
    glfwInitVulkanLoader(vk_get_instance_proc_addr())
    if (glfwInit() == 0) {
        panic("can't init glfw")
    }
    defer() { glfwTerminate() }
    if (glfwVulkanSupported() == 0) {
        panic("glfw reports no vulkan support")
    }
    glfwWindowHint(int(GLFW_CLIENT_API), GLFW_NO_API)
    glfwWindowHint(int(GLFW_RESIZABLE), GLFW_TRUE)
    var window = glfwCreateWindow(OUT_W, OUT_H, "dasVulkan tutorial 12 - GPU-driven Hi-Z occlusion culling", null, null)
    if (window == null) {
        panic("can't create window")
    }
    defer() { glfwDestroyWindow(window) }

    var ext_count = 0u
    let glfw_exts = glfwGetRequiredInstanceExtensions(unsafe(addr(ext_count)))
    var inst_exts : array<string>
    inst_exts |> reserve(int(ext_count))
    for (i in range(int(ext_count))) {
        unsafe {
            inst_exts |> push(glfw_exts[i])
        }
    }
    var inscope instance <- create_instance("dasVulkan tutorial 12 (window)", make_api_version(1u, 2u, 0u), inst_exts)
    volkLoadInstance(boost_value_to_vk(instance))

    var inscope surface <- create_surface(instance, glfwGetNativeWindow(window), glfwGetNativeDisplay())

    let phys = select_physical_device(instance)
    let gfx = select_graphics_queue_family(phys)
    if (!queue_family_supports_present(phys, gfx, surface)) {
        panic("graphics queue family does not support presentation")
    }
    var inscope device <- create_device_gpu_driven(phys, gfx, ["VK_KHR_swapchain"])
    volkLoadDevice(boost_value_to_vk(device))
    let queue = get_device_queue(device, gfx, 0u)

    var inscope pool <- create_command_pool(device, CommandPoolCreateInfo(queueFamilyIndex = gfx))
    var inscope res <- build_gpu_driven_resources(device, phys, queue, pool)
    update_uniforms(res, device)                          // the scene UBO is static; only the wall (push) moves

    var inscope swap <- create_swapchain(device, phys, surface, OUT_W, OUT_H)
    var inscope sync <- create_frame_sync(device)

    while (glfwWindowShouldClose(window) == 0) {
        glfwPollEvents()
        var fbw = 0
        var fbh = 0
        glfwGetFramebufferSize(window, fbw, fbh)
        if (fbw == 0 || fbh == 0) {
            glfwWaitEvents()
            continue
        }
        if (fbw != swap.width || fbh != swap.height) {
            vkDeviceWaitIdle(boost_value_to_vk(device))
            delete swap
            swap <- create_swapchain(device, phys, surface, fbw, fbh)   // nolint:PERF030 - deleted just above
        }
        let t = float(glfwGetTime())
        let wall_x = 5.0f * sin(t * 1.05f)                // sweep the wall across the field (~6 s round trip)

        let ok = present_frame(device, queue, swap, pool, sync) $(cmd; target; _idx) {
            record_frame(res, cmd, wall_x)

            let none : VkAccessFlags
            var transfer_write : VkAccessFlags
            transfer_write.transfer_write = true
            var top : VkPipelineStageFlags
            top.top_of_pipe = true
            var xfer : VkPipelineStageFlags
            xfer.transfer = true
            var bottom : VkPipelineStageFlags
            bottom.bottom_of_pipe = true
            let dst = vk_value_to_boost(target)
            transition_image(cmd, dst, VkImageLayout.UNDEFINED, VkImageLayout.TRANSFER_DST_OPTIMAL,
                none, transfer_write, top, xfer)
            var region : VkImageBlit
            region.srcSubresource.aspectMask.color = true
            region.srcSubresource.layerCount = 1u
            region.srcOffsets[1].x = OUT_W
            region.srcOffsets[1].y = OUT_H
            region.srcOffsets[1].z = 1
            region.dstSubresource.aspectMask.color = true
            region.dstSubresource.layerCount = 1u
            region.dstOffsets[1].x = swap.width
            region.dstOffsets[1].y = swap.height
            region.dstOffsets[1].z = 1
            unsafe {
                vkCmdBlitImage(boost_value_to_vk(cmd), boost_value_to_vk(res.out_color.image), VkImageLayout.TRANSFER_SRC_OPTIMAL,
                    target, VkImageLayout.TRANSFER_DST_OPTIMAL, 1u, addr(region), VkFilter.LINEAR)
            }
            transition_image(cmd, dst, VkImageLayout.TRANSFER_DST_OPTIMAL, VkImageLayout.PRESENT_SRC_KHR,
                transfer_write, none, xfer, bottom)
        }
        if (!ok) {
            vkDeviceWaitIdle(boost_value_to_vk(device))
            delete swap
            swap <- create_swapchain(device, phys, surface, fbw, fbh)   // nolint:PERF030 - deleted just above
        }
    }
    vkDeviceWaitIdle(boost_value_to_vk(device))
}

Running it

# the CI pixel-oracle gate (lavapipe in CI, real GPU locally)
daslang -load_module <dasVulkan> <daslang>/dastest/dastest.das -- \
    --test <dasVulkan>/tutorials/12_gpu_driven

# watch it live in a window (needs the glfw module + a display)
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/12_gpu_driven/window/show_gpu_driven.das

# regenerate the recording (needs stbimage + audio + ffmpeg locally)
daslang -load_module <dasVulkan> \
    <dasVulkan>/tutorials/12_gpu_driven/recording/record_gpu_driven.das

Going further

This is single-pass Hi-Z: a dedicated occluder pass builds the HZB this frame, then the cull tests against it. Production engines use two-pass temporal reprojection instead – draw last frame’s visible set, build the HZB from that, re-test all instances, then draw the newly-disoccluded ones – which removes the dedicated occluder pass and handles disocclusion as the camera moves. The depth convention here is standard (0 = near, 1 = far) with the HZB storing the max; a reverse-Z buffer storing the min gives markedly better precision and is the production default.

13 - Mesh Shaders: GPU Cluster Culling keeps the GPU in charge of the draw but goes further: it drops the vertex + index buffers entirely for VK_EXT_mesh_shader, where a task shader culls clusters and a mesh shader amplifies the surviving geometry on the GPU.