| name | gpu-cull-draw-indirect-debug |
| description | When depth prepass draw_indirect produces zero depth despite correct pipeline setup, force instance_count=1 on CPU to isolate buffer vs draw path issues, and add COPY_SRC to untappable textures for frame-level inspection |
| source | auto-skill |
| extracted_at | 2026-06-16T04:31:08.725Z |
GPU-Driven Culling: Debugging draw_indirect Depth Prep
When the depth prepass uses draw_indirect from a CPU-populated indirect buffer
and produces all-zero depth, follow this diagnostic sequence to isolate the
failure point.
Isolate draw_indirect vs frustum culling
The indirect buffer has instance_count set per-group. Frustum-culled groups
get instance_count = 0 on CPU. To determine whether the issue is incorrect
frustum culling (wrong groups zeroed) or the draw_indirect mechanism itself:
Test: Force instance_count = 1 for the first N groups regardless of
frustum visibility, THEN write the buffer:
for cmd in indirect_commands.iter_mut().take(4) {
cmd.instance_count = 1;
}
self.pool.queue().write_buffer(buf, 0, bytemuck::cast_slice(&indirect_commands));
Then tap the depth prepass:
cargo run -p titan-render-bench-culling --features debug-dumps -- \
--tap depth_prepass --dump-window 5..7 --dump-once
Check the output:
import numpy as np
d = np.frombuffer(open('titan-dumps/depth_prepass-frame6.bin','rb').read(),
dtype=np.float32).reshape(360,640)
print('non-zero fraction:', (d > 0).sum() / d.size)
- If non-zero: The
draw_indirect mechanism works. The issue is with
frustum culling producing wrong instance counts. Check the visible set,
the frustum_visible_set construction, and the BVH original_indices mapping.
- If still all zeros: The
draw_indirect path itself is failing. The GPU
is not executing indirect draws from the buffer. Likely causes: queue.write_buffer
not flushed before encoder submit, or the indirect buffer state transition
(COPY_DST → INDIRECT) is not happening.
Tap untappable textures by adding COPY_SRC
The HZB occlusion texture (hzb_closest) is created with
STORAGE_BINDING | TEXTURE_BINDING — no COPY_SRC, so tap_texture crashes
with a validation error. For diagnostic inspection in a throwaway worktree,
temporarily add COPY_SRC to the texture usage in hzb.rs:
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
Then add a tap dispatch after the HZB build in the DepthPrepass arm:
#[cfg(feature = "debug-dumps")]
self.tap_registry.tap_texture(
self.pool.device(),
&mut encoder,
self.frame_index,
crate::manifest::resources::HZB_OCCLUSION,
&hzb_occ._texture,
titan_rendering_core::TextureKind::R32Float,
hzb_occ.mip0_size,
"hzb_occlusion",
);
This captures mip 0 (quarter-res of render target) so the HZB contents can be
compared against the source depth prepass.
Print indirect buffer contents per frame
To verify the CPU writes the expected data to the indirect buffer before
dispatch, print the first few commands each frame (only early frames to
limit noise):
if self.frame_index <= 2 {
for (i, cmd) in indirect_commands.iter().enumerate().take(4) {
eprintln!("indirect[{}] inst={} firstInst={}",
i, cmd.instance_count, cmd.first_instance);
}
}
Watch for:
instance_count changing between frames when the camera is stationary —
indicates non-deterministic group ordering (HashMaps in extract_draw_groups)
- All groups zeroed — indicates the frustum visible set is empty or wrong
Verify each pass arm is reached
Add eprintln diagnostics at each component boundary in the pass dispatch:
Pass::DepthPrepass => {
eprintln!("DepthPrepass entered, frame={}", self.frame_index);
let (Some(pipeline), Some(view)) = (...) else {
eprintln!("DepthPrepass skipped: pipeline={} view={}", ...);
continue;
};
}
Pass::SceneCull => {
eprintln!("SceneCull entered, frame={}", self.frame_index);
}
And for the HZB build inside the DepthPrepass arm:
if let (Some(builder), Some(hzb_occ)) = (...) {
eprintln!("HzbBuild executing, frame={}", self.frame_index);
hzb_occ.record_build(&mut encoder, builder);
} else {
eprintln!("HzbBuild SKIPPED: builder={} hzb_occ={}", ...);
}
Verify depth prepass tap actually receives data
The depth prepass tap fires AFTER the render pass (drop(rp)). If the tap
produces a file but it's all zeros, the render pass completed but no fragments
passed the depth test. Check:
depth_compare: must be Greater for reverse-Z with clear value 0.0
depth_write_enabled: must be Some(true)
- Vertex shader output: must produce positions within clip space
- Bind groups: both group 0 (camera) and group 1 (instance data) must be set
draw_indirect offset: must be group_idx * 20 (SceneIndirectCommand size)
Root causes of draw_indirect producing zero depth
Two distinct root causes were isolated (2026-06-15):
1. Wrong wgpu draw call — draw_indirect vs draw_indexed_indirect
RenderPass::draw_indirect is a non-indexed indirect draw. It reads a
DrawIndirectArgs struct (4 × u32 = 16 bytes): vertex_count, instance_count, first_vertex, first_instance. The index buffer set via set_index_buffer is
ignored.
The Titan code had set_index_buffer + draw_indirect with a buffer
containing SceneIndirectCommand (5 × u32 = 20 bytes, indexed layout:
index_count, instance_count, first_index, vertex_offset, first_instance).
draw_indirect read only the first 16 bytes, interpreting first_index as
first_vertex and vertex_offset as first_instance. The mesh has 24 vertices
but index_count=36 was read as vertex_count=36 — the GPU tried to read 36
non-indexed vertices from a 24-vertex buffer, producing undefined results.
Fix: Use draw_indexed_indirect (or multi_draw_indexed_indirect with
count=1), which reads the full 20-byte DrawIndexedIndirectArgs structure and
uses the currently-bound index buffer.
rp.draw_indirect(indirect_buf, group_idx * 20);
rp.draw_indexed_indirect(indirect_buf, group_idx * 20);
2. queue.write_buffer not visible to draw_indexed_indirect
queue.write_buffer submits a copy command to the queue internally.
draw_indexed_indirect in a later encoder submission reads the buffer
with INDIRECT usage. On some driver/backend combinations, the internal
queue submission from write_buffer does not create a sufficient barrier
for the subsequent indirect draw — the GPU reads stale (zeroed) data.
Fix: Use an encoder-based staging copy instead of queue.write_buffer.
This ensures the copy is recorded in the same command encoder as the draw,
and wgpu inserts the proper COPY_DST → INDIRECT barrier:
let staging = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("indirect staging"),
size: data.len() as u64,
usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::MAP_WRITE,
mapped_at_creation: true,
});
staging.slice(..).get_mapped_range_mut().copy_from_slice(&data);
staging.unmap();
encoder.copy_buffer_to_buffer(&staging, 0, buf, 0, data.len() as u64);