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
Rgba8is 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 writesdrawCmds[k](aVkDrawIndexedIndirectCommand) +drawInstance[k](its original instance id). The draw count is a GPU-written buffer.Indirect-count draw.
cmd_draw_indexed_indirect_countreads the command buffer and the count buffer; the GPU issues exactlydrawCountdraws with no CPU round-trip. The main vertex shader mapsgl_DrawIDback to its instance viadrawInstance[gl_DrawID].Bindless materials. Each survivor’s fragment shader indexes a
sampler2D[N]descriptor array withsamplers[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.