| name | bench-gpu-readback |
| description | Pattern for reading back GPU SSBO data in Titan bench tools — staging buffer, copy_buffer_to_buffer, map_async, poll, visibility classification from indirect draw + bitmask buffers |
| source | auto-skill |
| extracted_at | 2026-06-16T00:06:25.790Z |
GPU buffer readback for Titan bench tools
When a Titan bench tool needs to read back GPU SSBO data (indirect draw
counters, visibility bitmasks, occlusion statistics), use a staging
buffer readback. The pattern avoids blocking the render loop and handles
wgpu alignment constraints.
Step 1: Expose buffer accessors on Renderer3d
Add public methods on Renderer3d that return references to the GPU
buffers the bench needs to read:
impl Renderer3d {
pub fn scene_cull_indirect_draw_buffer(&self) -> Option<&wgpu::Buffer> {
self.scene_cull_data.indirect_draw_buffer.as_ref()
}
pub fn scene_cull_visibility_bitmask(&self) -> Option<&wgpu::Buffer> {
self.scene_cull_data.visibility_bitmask.as_ref()
}
}
Methods return Option<&wgpu::Buffer> so benches can early-exit
when the pass hasn't run yet (e.g. warmup frames).
Step 2: Read back in the bench harness
After each reporting frame (post-device.poll(wgpu::Maintain::Wait)):
fn read_back_indirect_buffer(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
source: &wgpu::Buffer,
count: usize,
) -> Vec<u32> {
let size = (count * std::mem::size_of::<u32>()) as u64;
let padded = wgpu::util::align_to(size, 256);
let staging = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("indirect readback staging"),
size: padded,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(source, 0, &staging, 0, size);
let index = device.queue().submit(std::iter::once(encoder.finish()));
let (tx, rx) = std::sync::mpsc::channel();
staging
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
tx.send(result).ok();
});
device.poll(wgpu::Maintain::Wait);
rx.recv()
.expect("staging buffer map channel closed")
.expect("staging buffer map failed");
let mapped = staging.slice(..).get_mapped_range();
let data: Vec<u32> = bytemuck::cast_slice(&mapped[..size as usize]).to_vec();
drop(mapped);
staging.unmap();
device
.queue()
.poll(wgpu::Maintain::WaitForSubmissionIndex(index));
data
}
Key details:
- 256-byte alignment: The staging buffer must be padded to 256
bytes for
MAP_READ usage. The copy uses unpadded size.
drop(mapped) before staging.unmap(): Required by wgpu —
the mapped range must be dropped before unmapping.
poll(WaitForSubmissionIndex) after submit: Ensures the copy
is done before reusing the command encoder on the next frame.
Step 3: Classify visibility from read-back data
Parse SceneIndirectCommand instances from the raw u32 readback:
#[repr(C)]
struct SceneIndirectCommand {
index_count: u32,
instance_count: u32,
first_index: u32,
vertex_offset: i32,
first_instance: u32,
}
fn parse_indirect_commands(raw: &[u32], count: usize) -> Vec<SceneIndirectCommand> {
let bytes = bytemuck::cast_slice(raw);
let cmds: &[SceneIndirectCommand] = bytemuck::cast_slice(&bytes[..count * 20]);
cmds.to_vec()
}
Visibility classification per group:
let instance_count = cmd.instance_count;
let bit = (bitmask[group_idx >> 5] >> (group_idx & 31)) & 1;
let (visible, reason) = if instance_count > 0 {
(1u8, "visible")
} else if bit != 0 {
(0u8, "hzb_occluded")
} else {
(0u8, "frustum_culled")
};
instance_count > 0 means the SceneCull shader did NOT zero the
slot — the group was visible. A zero instance_count with a set
visibility bit means the group passed frustum but was HZB-occluded.
Zero with an unset bit means frustum-culled.
Output format
Write per-frame CSV to --dump-dir:
frame,group_name,visible,instance_count,reason
0,occluder_a,1,1,visible
0,occludee_0,0,0,hzb_occluded
0,control_0,1,1,visible
Caveats
- Readback is blocking per frame. This is acceptable for a bench
tool (not for production rendering).
- Buffers must have
COPY_SRC usage. The source buffers must
be created with BufferUsages::COPY_SRC in addition to their
storage/indirect usages. If a buffer was created without COPY_SRC,
wrap it in a new buffer or add the flag at creation time.
- Bitmask word indexing: Visibility bitmask uses 32-bit words.
Group index
g maps to word = g >> 5, bit = g & 31.
- First frame after resize: Buffers may be recreated. The
accessor returns
None until the first post-resize dispatch.