Chunks are now grouped by world-space centroid instead of mesh-id
range, so each chunk's AABB tightly bounds its geometry instead of
spanning the whole model. Distance-based eviction can finally
distinguish the near corner of a skyscraper from the far corner.
Algorithm:
1. Compute each mesh's centroid = mean of its instances' world AABB
centres.
2. Sort mesh indices lexicographically by (z, y, x) centroid. Stable
sort keeps mesh-id order as tiebreaker for instanced repeats.
3. Greedy-pack sorted meshes into chunks ≤ WGPU_CHUNK_VERTEX_BYTES_LIMIT.
4. Each Chunk stores its mesh_ids list; the per-mesh layout (chunk_local
base_vertex / ebo_first_u32) is computed by walking the list at plan
time.
Loader: chunk vertex/index bytes are no longer file-contiguous, so
streaming uses new multi-range read paths
(readSidecarVertexRanges / readSidecarIndexRanges). Each range list
is sorted by file offset and adjacent ranges coalesced with a 64 KB
gap tolerance — on the close-camera benchmark this brings the
per-chunk seek count back down to ~mesh-id-grouping levels, so the
spatial sort costs ~nothing on I/O while delivering tighter AABBs.
Non-streaming applyCachedModel mirrors the spatial plan but gathers
from in-memory data.vertices / data.indices via per-mesh
queueWriteBuffer calls at chunk-local offsets.
Chunk struct drops vertex_byte_offset and index_first_u32 (no longer
meaningful — each chunk is N scattered ranges). vertex_byte_size and
index_count stay as aggregates for pool sizing + eviction math.
Tuning: kept WGPU_CHUNK_VERTEX_BYTES_LIMIT at 128 MB. Tried 8 MB and
32 MB; both gave tighter AABBs but the scatter-gather I/O cost blew
up because the per-frame load count grows linearly as chunks shrink
(orbit shifts the working set faster across finer chunks). 128 MB +
coalescing is the empirical sweet spot pre-v14. Once sidecar v14
re-orders bytes on disk to match spatial chunks, we can drop the
limit to ~8 MB for sharp eviction without re-paying the seek cost.
Benchmarks (big federation, --streaming):
close camera: avg 36 fps median 53 (was 35/49) — parity
default camera: avg 33 fps median 47 (was 40/49) — small regression
likely from increased coalesce overhead on more-
scattered orbit traversals; will resolve with v14.
Pixel-identical to non-streaming on basic.ifc on both paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First foundational piece for task #16. WgpuStreamingLoader exposes:
- readSidecarMetadataOnly(path): reads v13 header + mesh dict + instance
dict + georef + elements + string table from disk. Skips the bulky
vertex and index byte sections, recording their on-disk offsets so
they can be range-read later (per-chunk, on demand). The file handle
is closed before return.
- readSidecarVertexChunk / readSidecarIndexChunk: open + fseek + fread
for a byte range. Synchronous; intended to be called from a worker
thread for true async streaming or the main thread for stage-1
on-demand load.
No format change yet — operates on existing v13 sidecars. v14 with an
explicit per-chunk TOC arrives in a follow-up; this layer abstracts
the chunk boundaries so the upgrade stays internal.
No integration with existing applyCachedModel — that's commit 3/4.
Build verifies the API compiles and links into IfcViewerWgpu.
Commits in this series:
1/4: metadata-only reader (THIS)
2/4: per-chunk residency state on WgpuModelGpuData
3/4: --streaming opt-in path through applyCachedModel
4/4: per-frame chunk-on-visible loader (the OOM fix)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>