wgpu chunks: 3D Morton-code spatial sort (tight voxel chunks)

The previous chunk-plan sorted meshes by lexicographic (z, y, x)
centroid — effectively a 1D Z-slab traversal. On a typical IFC
building (50 × 50 × 100 m), a 16-MB chunk's 80-ish meshes spanned
roughly 50 × 50 × 0.5 m. On a city federation it was much worse:
the first chunk grouped ground-floor stuff from every building,
spanning the entire scene horizontally. Per-chunk AABBs that wide
make frustum / contribution / HiZ rejection useless (every chunk
"overlaps the frustum" by virtue of spanning the whole scene).

3D Morton (Z-order) interleaves bits of quantised (x, y, z)
centroids, so consecutive items in the sorted order cluster in all
3 axes — chunks become tight 3D voxels of the model. Prerequisite
for the contribution-aware eviction priority (task #25) to actually
discriminate near and far chunks.

21 bits per axis = ~2 M bins per axis, sub-millimetre precision on
a kilometre-scale scene. Both apply paths (streaming and non-
streaming) share the same sortMeshIdsByMorton helper.

Benchmark unchanged (~47 fps avg, 20 ms cull, 0.3 ms stream) — the
distance-based evictor still keys on chunk centres, which moved
slightly under Morton but not enough to materially shift residency.
The user-visible win comes from the next commit, which switches
priority to screen-space contribution × HiZ history — both of which
need today's tight AABBs to mean anything.

Pixel-identical to non-streaming on basic.ifc on both paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-28 18:04:05 +10:00
parent 0ec72482c2
commit 3d368e0079
2 changed files with 107 additions and 34 deletions
+15 -13
View File
@@ -45,19 +45,21 @@
// 13 chunks), which is invisible compared to per-frame GPU work.
//
// At INSTANCED_VERTEX_STRIDE_BYTES = 12 B/vertex this caps a chunk at
// ~11 M vertices. Tuned for the pre-v14 scatter-gather streaming model:
// spatial chunk planning means each chunk's bytes are NOT contiguous in
// the sidecar, so per-load I/O cost scales with mesh count per chunk
// (one fseek+fread per file gap). Bigger chunks = more meshes per
// chunk = more seeks per load, BUT also fewer chunks total = fewer
// loads per frame as orbit shifts the visible set. The latter
// dominates: 128 MB chunks → ~16 chunks per pool → 1-2 loads per
// frame → ~20-30 ms stream cost. Smaller chunks (32 / 8 MB) bring
// finer eviction granularity but explode the loads-per-frame count.
// Sidecar v14 (on-disk spatial reorder) is the proper fix — once
// chunks ARE file-contiguous, the per-mesh seek cost vanishes and we
// can drop the chunk size back to ~8 MB for sharp eviction.
static constexpr uint64_t WGPU_CHUNK_VERTEX_BYTES_LIMIT = 128ull * 1024 * 1024;
// ~1.4 M vertices. 16 MB is the sweet spot once background-thread I/O
// (WgpuStreamingThread) is in place: scatter-gather per-mesh seeks
// happen on the worker, not the render thread, so smaller chunks
// (and thus more per-frame loads as orbit shifts) no longer stall
// rendering. The win is much finer pool-allocation granularity —
// a 3 GB pool fits ~190 chunks vs ~21 at 128 MB — so visible
// geometry is far less likely to get "trapped" behind invisible
// chunkmates. Pre-async this size gave 7 fps (the sync loads blocked
// the render thread); now it's bounded by cull cost not stream cost.
//
// Sidecar v14 (on-disk spatial reorder) would let us go smaller still
// (~4 MB) with single-fread chunk loads, but the difference between
// 16 MB and 4 MB is much smaller than the difference between 128 MB
// and 16 MB.
static constexpr uint64_t WGPU_CHUNK_VERTEX_BYTES_LIMIT = 16ull * 1024 * 1024;
struct WgpuModelGpuData {
// std430 layout: 16 bytes per entry, naturally aligned. base_vertex is