wgpu chunks: pack LOD1 indices alongside LOD0; cull picks per-instance

Sidecar LOD1 is per-mesh, index-only — meshoptimizer-baked decimated
index slices that share the LOD0 VBO. Before this change the wgpu
chunk path force-disabled LOD1 (effective_lod1 = false; tagged in a
comment as "until per-chunk LOD1 storage lands"), so it had to walk
every visible instance at full LOD0 even when the per-instance LOD
selection said the projected radius was below the LOD1 threshold.

Per-chunk layout: append LOD1 indices for the chunk's meshes after
all LOD0 indices in the same pool slice. A new
mesh_chunk_local_lod1_first_u32 array records each mesh's LOD1
starting offset (in u32s) within the chunk's index slice; LOD0
offsets stay where they were. The cull's emit then sets
VisibleDrawGpu.ebo_first_u32 to whichever side matches the per-
instance use_lod1 decision the prior #8 commit already computed.
Vertex pulling is oblivious to the LOD split — it just reads the
indices the cull pointed it at.

c.index_count is repurposed as the LOD0+LOD1 total so the pool
allocation, eviction's pool-fit check, and the VRAM accounting all
scale automatically. c.lod1_index_count exposes the LOD1 portion
for stats.

makeChunkRequest appends LOD1 byte ranges to req.i_ranges in the
same per-mesh order; the streaming worker concatenates ranges in
order, so the assembled idx blob lands LOD0-first / LOD1-second
which matches the chunk-local packing.

On a 10-model regen with meshoptimizer-baked sidecars, the bench
[frame] heartbeat shows lod1 firing on ~80-90% of LOD1-eligible
instances and saving 10-30M tris per frame versus the prior
LOD0-only ceiling. Same camera/scene on basic.ifc is still
pixel-identical (no mesh in basic.ifc is large enough to bake a
LOD1, so the cull just follows the LOD0 path it always did).

Temporary debug counters (lod1_dbg_count_ et al.) print
"lod1 X/Y (saved Z tris, N no-lod1)" in both interactive and
benchmark [frame] heartbeats — kept on while LOD1 correctness gets
confirmed across more scenes, will come out once trust is built.

The non-streaming applyCachedModel path runs the same LOD1 plumbing
but no longer fits the full federation scene in pool (extra index
bytes push past the 2 GB single-buffer cap); that mode was
already streaming-only on that scene before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-29 09:02:27 +10:00
parent c7fba1abb8
commit 10f8fc88e8
3 changed files with 106 additions and 29 deletions
+9
View File
@@ -139,6 +139,11 @@ struct WgpuModelGpuData {
// is recovered by walking mesh_ids and the model's MeshInfo[].
uint64_t vertex_byte_size = 0;
uint64_t index_count = 0;
// Of `index_count`, how many are LOD1 indices. LOD0 indices occupy
// chunk-local u32 offsets [0, index_count - lod1_index_count); LOD1
// indices occupy [index_count - lod1_index_count, index_count). 0
// when no mesh in this chunk had a baked LOD1 slice.
uint32_t lod1_index_count = 0;
// World-space AABB covering every instance whose mesh lives in
// this chunk. With spatial chunk planning this AABB is tight
@@ -217,6 +222,10 @@ struct WgpuModelGpuData {
std::vector<uint32_t> mesh_chunk_idx;
std::vector<uint32_t> mesh_chunk_local_base_vertex;
std::vector<uint32_t> mesh_chunk_local_ebo_first_u32;
// Where in the chunk's index slice this mesh's LOD1 indices start
// (in u32 units). Only meaningful when m.meshes[mi].lod1_index_count > 0;
// entries for meshes without LOD1 are 0 and unused.
std::vector<uint32_t> mesh_chunk_local_lod1_first_u32;
// Model-shared buffers. Mesh + instance storage are small (<10 MB on
// any real scene we've seen); the chunked index buffer lives in Chunk