wgpu backend: CPU frustum cull + per-mesh draw compaction

Stage 6 of the wgpu port. Replaces the one-draw-per-(mesh, instance) loop
with a CPU cull pass that survives one drawIndexed per non-empty mesh
with packed instanceCount.

Adds to WgpuModelGpuData:
  - visible_buffer: u32[] storage SSBO, pre-sized to instance_count at
    applyCachedModel so the bind group reference never invalidates.
    Re-uploaded each frame via wgpuQueueWriteBuffer.
  - mesh_draws: per-mesh schedule (first_instance, instance_count,
    first_index, base_vertex, index_count). instance_count==0 means the
    mesh contributed nothing this frame and the draw is elided entirely.

cullModelCpu per-frame:
  - Extract 6 frustum planes from the same VP we write into the uniform.
    WebGPU clip-space z is [0, 1], so near plane = matrix row 2 (not
    row 3 + row 2 as in GL); rest of the derivation is standard.
  - Per-instance AABB-vs-frustum test using the p-vertex shortcut
    (cheapest correct early-out for AABBs).
  - Bucket survivors by mesh_id; flatten into a contiguous u32 list;
    upload via wgpuQueueWriteBuffer. Per-mesh slice is [first_instance,
    first_instance + instance_count).

WGSL adds @group(1) @binding(3) var<storage, read> visible: array<u32>
and an extra indirection: instance_idx = visible[iid]; the rest of the
shader is unchanged. firstInstance on each drawIndexed offsets into
visible[], so each mesh reads its own slice.

Verified two ways:
  1. basic.ifc (3 instances, all on-screen) renders pixel-identically
     to pre-stage-6 — proves cull keeps everything it should.
  2. basic.ifc + a synthetic instance placed at (100, 100, 100) is
     culled cleanly: only the cube renders, the far quad is rejected
     by the frustum test. Proves cull actually rejects out-of-frustum
     geometry rather than passing everything through.

Contribution culling, HiZ, and LOD selection arrive in stages 7 and 8;
they all hook into the same cullModelCpu seam.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-27 14:05:58 +10:00
parent 75b9963136
commit 61726e00a4
3 changed files with 222 additions and 33 deletions
+23 -1
View File
@@ -51,10 +51,32 @@ struct WgpuModelGpuData {
// recompose from placement_transformation when stage matrices change.
WGPUBuffer instance_storage = nullptr;
// Bind group binding the three storage buffers above (group=1 in the
// u32[] of visible instance indices, repacked per frame by the CPU cull
// into per-mesh contiguous slices. Sized for the worst case
// (instance_count entries) at applyCachedModel time so we never have to
// recreate it (and the bind group that references it) mid-frame.
WGPUBuffer visible_buffer = nullptr;
size_t visible_buffer_capacity = 0; // entries (u32 count), not bytes
// Bind group binding the four storage buffers above (group=1 in the
// main pipeline). Built in applyCachedModel after the buffers exist.
WGPUBindGroup bind_group = nullptr;
// One per mesh, populated per frame by cullAndCompact. instance_count==0
// means the mesh contributes nothing this frame and the draw is skipped.
struct MeshDraw {
uint32_t first_instance = 0; // offset into visible_buffer
uint32_t instance_count = 0;
uint32_t first_index = 0; // index buffer offset (in u32 indices)
int32_t base_vertex = 0; // added to every fetched vertex_index
uint32_t index_count = 0;
};
std::vector<MeshDraw> mesh_draws; // size = meshes.size() after first frame
// Scratch reused each frame so per-frame cull doesn't allocate. Sized
// to instance_count entries on first use; never shrunk.
std::vector<uint32_t> visible_flat_scratch;
// Size mirrors for stats / range checks.
size_t vertex_bytes = 0;
uint32_t index_count = 0;