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
+88 -29
View File
@@ -232,6 +232,7 @@ void releaseWgpuModelGpuData(WgpuModelGpuData& m, WgpuBufferPool& pool) {
m.mesh_chunk_idx.clear();
m.mesh_chunk_local_base_vertex.clear();
m.mesh_chunk_local_ebo_first_u32.clear();
m.mesh_chunk_local_lod1_first_u32.clear();
if (m.mesh_storage) { wgpuBufferRelease(m.mesh_storage); m.mesh_storage = nullptr; }
if (m.instance_storage) { wgpuBufferRelease(m.instance_storage); m.instance_storage = nullptr; }
m.vertex_bytes = 0;
@@ -654,6 +655,7 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id,
m.mesh_chunk_idx.assign(n_meshes, 0);
m.mesh_chunk_local_base_vertex.assign(n_meshes, 0);
m.mesh_chunk_local_ebo_first_u32.assign(n_meshes, 0);
m.mesh_chunk_local_lod1_first_u32.assign(n_meshes, 0);
// Per-mesh centroid = mean of its instances' world AABB centres.
// Meshes with no instances stay at (0,0,0) — they're dead weight but
@@ -688,14 +690,8 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id,
std::vector<std::vector<uint32_t>> chunk_mesh_ids;
chunk_mesh_ids.push_back({});
uint64_t current_chunk_bytes = 0;
bool warned_lod1 = false;
for (uint32_t mi : sorted_mesh_ids) {
const MeshInfo& mesh = metadata.meta.meshes[mi];
if (!warned_lod1 && mesh.lod1_index_count > 0) {
qWarning() << "[wgpu stream] LOD1 indices present but per-chunk "
"buffers only carry LOD0; LOD1 will be ignored.";
warned_lod1 = true;
}
const uint64_t mesh_bytes = uint64_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (current_chunk_bytes > 0
&& current_chunk_bytes + mesh_bytes > WGPU_CHUNK_VERTEX_BYTES_LIMIT) {
@@ -729,7 +725,11 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id,
// Walk this chunk's meshes in chunk-local layout order, computing
// each mesh's chunk-local base_vertex / ebo_first_u32 and the
// chunk's aggregate vertex/index totals.
// chunk's aggregate vertex/index totals. LOD1 indices (if any
// mesh has them baked) get a second pass and pack AFTER all the
// LOD0 indices in the chunk's index slice — so a single slice
// carries both LODs and cull picks per-instance by chunk-local
// u32 offset.
uint32_t chunk_local_v = 0;
uint32_t chunk_local_i = 0;
for (uint32_t mi : c.mesh_ids) {
@@ -740,9 +740,17 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id,
chunk_local_v += mesh.vertex_count;
chunk_local_i += mesh.index_count;
}
uint32_t chunk_local_lod1 = 0;
for (uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = metadata.meta.meshes[mi];
if (mesh.lod1_index_count == 0) continue;
m.mesh_chunk_local_lod1_first_u32[mi] = chunk_local_i + chunk_local_lod1;
chunk_local_lod1 += mesh.lod1_index_count;
}
c.vertex_count = chunk_local_v;
c.vertex_byte_size = uint64_t(chunk_local_v) * INSTANCED_VERTEX_STRIDE_BYTES;
c.index_count = chunk_local_i;
c.index_count = chunk_local_i + chunk_local_lod1;
c.lod1_index_count = chunk_local_lod1;
// Small per-chunk buffers, allocated upfront so cull can write into
// them. visible_draws_buffer cap = chunk's instance count (worst-
@@ -907,6 +915,7 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
m.mesh_chunk_idx.assign(n_meshes, 0);
m.mesh_chunk_local_base_vertex.assign(n_meshes, 0);
m.mesh_chunk_local_ebo_first_u32.assign(n_meshes, 0);
m.mesh_chunk_local_lod1_first_u32.assign(n_meshes, 0);
std::vector<float> mesh_cx(n_meshes, 0.0f),
mesh_cy(n_meshes, 0.0f),
@@ -934,14 +943,8 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
std::vector<std::vector<uint32_t>> chunk_mesh_ids;
chunk_mesh_ids.push_back({});
uint64_t current_chunk_bytes = 0;
bool warned_lod1 = false;
for (uint32_t mi : sorted_mesh_ids) {
const MeshInfo& mesh = data.meshes[mi];
if (!warned_lod1 && mesh.lod1_index_count > 0) {
qWarning() << "[wgpu] LOD1 indices present but per-chunk buffers "
"only carry LOD0; LOD1 will be ignored this load.";
warned_lod1 = true;
}
const uint64_t mesh_vertex_bytes = uint64_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (mesh_vertex_bytes > WGPU_CHUNK_VERTEX_BYTES_LIMIT) {
qWarning().noquote().nospace()
@@ -976,6 +979,9 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
// Walk meshes in chunk-local layout order, computing each mesh's
// chunk-local offsets and the chunk's aggregate vertex/index totals.
// LOD1 indices (if any mesh has them baked) pack AFTER all the
// LOD0 indices in the chunk's index slice — single slice carries
// both LODs, cull picks per-instance by chunk-local u32 offset.
uint32_t chunk_local_v = 0;
uint32_t chunk_local_i = 0;
for (uint32_t mi : c.mesh_ids) {
@@ -986,9 +992,17 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
chunk_local_v += mesh.vertex_count;
chunk_local_i += mesh.index_count;
}
uint32_t chunk_local_lod1 = 0;
for (uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = data.meshes[mi];
if (mesh.lod1_index_count == 0) continue;
m.mesh_chunk_local_lod1_first_u32[mi] = chunk_local_i + chunk_local_lod1;
chunk_local_lod1 += mesh.lod1_index_count;
}
c.vertex_count = chunk_local_v;
c.vertex_byte_size = uint64_t(chunk_local_v) * INSTANCED_VERTEX_STRIDE_BYTES;
c.index_count = chunk_local_i;
c.index_count = chunk_local_i + chunk_local_lod1;
c.lod1_index_count = chunk_local_lod1;
c.vertex_slice = pool_.alloc(c.vertex_byte_size, 256);
if (!c.vertex_slice.valid()) {
@@ -1038,6 +1052,19 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
i_off += i_bytes;
}
}
// LOD1 indices second, packed after all LOD0 indices in the slice.
// mesh_chunk_local_lod1_first_u32[mi] already encodes this layout —
// we just have to copy in the same order it was assigned.
for (uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = data.meshes[mi];
if (mesh.lod1_index_count == 0) continue;
const size_t l1_bytes = size_t(mesh.lod1_index_count) * sizeof(uint32_t);
wgpuQueueWriteBuffer(queue_, c.index_slice.buffer,
c.index_slice.offset + i_off,
data.indices.data() + (mesh.lod1_ebo_byte_offset / sizeof(uint32_t)),
l1_bytes);
i_off += l1_bytes;
}
m.vram_bytes_vbo += c.vertex_byte_size;
m.vram_bytes_ebo += c.index_count * sizeof(uint32_t);
}
@@ -2658,32 +2685,39 @@ uint32_t WgpuViewportWindow::cullModelCpuCompute(WgpuModelGpuData& m,
&& mesh.lod1_index_count > 0
&& projected_px < lod1_threshold_px;
// LOD1 is incompatible with the current per-chunk index layout
// (LOD1 indices are appended at the end of sd.indices, not
// contiguous with their chunk's range). Force LOD0 until per-
// chunk LOD1 storage lands.
const bool effective_lod1 = false;
(void)use_lod1;
// Emit one VisibleDraw entry into the chunk that owns this mesh's
// vertex range. base_vertex AND ebo_first_u32 are both CHUNK-LOCAL
// — the chunk's bind group points at its own vertex_storage and
// index_buffer slices so the shader indexes them directly.
// index_buffer slices so the shader indexes them directly. When
// use_lod1, ebo_first_u32 routes into the LOD1 section of the
// chunk's index slice (which is packed after the LOD0 section at
// chunk-build time); the shader is oblivious to the LOD split.
const uint32_t chunk_idx = m.mesh_chunk_idx[inst.mesh_id];
WgpuModelGpuData::Chunk& c = m.chunks[chunk_idx];
WgpuModelGpuData::VisibleDrawGpu d;
d.mesh_id = inst.mesh_id;
d.instance_idx = i;
d.ebo_first_u32 = m.mesh_chunk_local_ebo_first_u32[inst.mesh_id];
d.ebo_first_u32 = use_lod1
? m.mesh_chunk_local_lod1_first_u32[inst.mesh_id]
: m.mesh_chunk_local_ebo_first_u32[inst.mesh_id];
d.base_vertex = m.mesh_chunk_local_base_vertex[inst.mesh_id];
c.visible_draws_scratch.push_back(d);
const uint32_t entry_vert_count = effective_lod1
? mesh.lod1_index_count
: mesh.index_count;
const uint32_t entry_vert_count = use_lod1 ? mesh.lod1_index_count
: mesh.index_count;
running_vertex_count[chunk_idx] += entry_vert_count;
c.prefix_sums_scratch.push_back(running_vertex_count[chunk_idx]);
if (use_lod1) {
++lod1_dbg_count_;
lod1_dbg_tris_saved_ += (mesh.index_count > mesh.lod1_index_count
? (mesh.index_count - mesh.lod1_index_count) / 3
: 0);
} else if (mesh.lod1_index_count > 0) {
++lod0_dbg_eligible_count_;
} else {
++lod0_dbg_no_lod1_count_;
}
};
// Chunk-driven walk: frustum-test each chunk's AABB once, and skip
@@ -3135,7 +3169,14 @@ void WgpuViewportWindow::render() {
<< " chunks " << chunks_resident << "/" << chunks_frustum_vis
<< "/" << chunks_total << " (missing " << chunks_missing << ")"
<< " vram " << QString::number(double(total_vbo + total_ebo + total_ssbo) * mb, 'f', 1) << "MB"
<< " models " << models_gpu_.size();
<< " models " << models_gpu_.size()
<< " lod1 " << lod1_dbg_count_ << "/" << (lod1_dbg_count_ + lod0_dbg_eligible_count_)
<< " (saved " << lod1_dbg_tris_saved_ << " tris, "
<< lod0_dbg_no_lod1_count_ << " no-lod1)";
lod1_dbg_count_ = 0;
lod0_dbg_eligible_count_ = 0;
lod0_dbg_no_lod1_count_ = 0;
lod1_dbg_tris_saved_ = 0;
// Every ~120 frames, when something's missing, dump the
// top-8 models by missing-chunk count + top/bottom chunks
@@ -3405,7 +3446,14 @@ void WgpuViewportWindow::render() {
<< " (vbo " << QString::number(double(total_vbo) * mb, 'f', 1)
<< " + ebo " << QString::number(double(total_ebo) * mb, 'f', 1)
<< " + ssbo " << QString::number(double(total_ssbo) * mb, 'f', 1) << ")"
<< " models " << models_gpu_.size();
<< " models " << models_gpu_.size()
<< " lod1 " << lod1_dbg_count_ << "/" << (lod1_dbg_count_ + lod0_dbg_eligible_count_)
<< " (saved " << lod1_dbg_tris_saved_ << " tris, "
<< lod0_dbg_no_lod1_count_ << " no-lod1)";
lod1_dbg_count_ = 0;
lod0_dbg_eligible_count_ = 0;
lod0_dbg_no_lod1_count_ = 0;
lod1_dbg_tris_saved_ = 0;
}
camera_yaw_deg_ = bench_yaw_start_
+ bench_yaw_speed_ * float(bench_count_ + 1);
@@ -3732,6 +3780,17 @@ static WgpuStreamingThread::Request makeChunkRequest(
uint64_t(mesh.index_count));
}
}
// LOD1 indices second pass — matches the chunk-local packing order
// (all LOD0 first, then LOD1) so the worker's concatenated index
// result lands at the offsets recorded in
// m.mesh_chunk_local_lod1_first_u32.
for (uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = m.meshes[mi];
if (mesh.lod1_index_count == 0) continue;
req.i_ranges.emplace_back(
uint64_t(mesh.lod1_ebo_byte_offset / sizeof(uint32_t)),
uint64_t(mesh.lod1_index_count));
}
return req;
}