wgpu streaming: spatial chunk planning + coalesced multi-range reads

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>
This commit is contained in:
Dion Moult
2026-05-28 14:45:15 +10:00
parent c3a55d7f7b
commit 4d36174200
4 changed files with 435 additions and 217 deletions
+34 -16
View File
@@ -46,10 +46,18 @@
// 13 chunks), which is invisible compared to per-frame GPU work. // 13 chunks), which is invisible compared to per-frame GPU work.
// //
// At INSTANCED_VERTEX_STRIDE_BYTES = 12 B/vertex this caps a chunk at // At INSTANCED_VERTEX_STRIDE_BYTES = 12 B/vertex this caps a chunk at
// 11.18 M vertices. A mesh whose vertex range is bigger than this can't fit // ~11 M vertices. Tuned for the pre-v14 scatter-gather streaming model:
// in any chunk and would need splitting — typical IFC meshes are nowhere // spatial chunk planning means each chunk's bytes are NOT contiguous in
// near (hundreds of verts), and applyCachedModel asserts loudly if it ever // the sidecar, so per-load I/O cost scales with mesh count per chunk
// happens. // (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; static constexpr uint64_t WGPU_CHUNK_VERTEX_BYTES_LIMIT = 128ull * 1024 * 1024;
struct WgpuModelGpuData { struct WgpuModelGpuData {
@@ -116,22 +124,21 @@ struct WgpuModelGpuData {
// Render and pick skip chunks where !is_resident. // Render and pick skip chunks where !is_resident.
bool is_resident = true; bool is_resident = true;
// Where the chunk's vertex bytes live in the sidecar (offsets // Aggregate vertex / index sizes across all meshes in this chunk
// relative to vertex_section_offset on the model). Populated by // (sum of mesh.vertex_count * stride / mesh.index_count for each
// the streaming loader; zeroed for the non-streaming path. // mesh in mesh_ids). Used to size the pool allocation and to
uint64_t vertex_byte_offset = 0; // 0 == start of vertex section // compute the cull's per-chunk free-room check. Per-mesh layout
// is recovered by walking mesh_ids and the model's MeshInfo[].
uint64_t vertex_byte_size = 0; uint64_t vertex_byte_size = 0;
// Same for indices. index_first_u32 is in u32 units relative to
// the start of the index section (sidecar stores raw u32 indices,
// no byte-level offset is needed beyond multiplying by 4).
uint64_t index_first_u32 = 0;
uint64_t index_count = 0; uint64_t index_count = 0;
// World-space AABB covering every instance whose mesh lives in // World-space AABB covering every instance whose mesh lives in
// this chunk. Used by cull to reject whole chunks against the // this chunk. With spatial chunk planning this AABB is tight
// frustum before iterating instances — and by the streaming // (chunks group meshes by world centroid, not mesh-id), so the
// loader to prioritise which non-resident chunks to fetch first. // distance-based evictor can meaningfully tell chunks apart.
// Used by cull to reject whole chunks against the frustum before
// iterating instances — and by the streaming loader to
// prioritise which non-resident chunks to fetch first.
float aabb_min[3] = { std::numeric_limits<float>::infinity(), float aabb_min[3] = { std::numeric_limits<float>::infinity(),
std::numeric_limits<float>::infinity(), std::numeric_limits<float>::infinity(),
std::numeric_limits<float>::infinity() }; std::numeric_limits<float>::infinity() };
@@ -139,6 +146,17 @@ struct WgpuModelGpuData {
-std::numeric_limits<float>::infinity(), -std::numeric_limits<float>::infinity(),
-std::numeric_limits<float>::infinity() }; -std::numeric_limits<float>::infinity() };
// Mesh IDs assigned to this chunk, in chunk-local layout order.
// Spatial chunk planning sorts meshes by world centroid first,
// so this list is not in mesh-id order in general — each mesh's
// bytes live at scattered offsets in the sidecar file. The
// loader walks this list to scatter-gather the chunk's vertex
// + index bytes; mesh_chunk_local_base_vertex /
// mesh_chunk_local_ebo_first_u32 are computed in this same
// order at planning time so the cull's VisibleDrawGpu entries
// point at the correct chunk-local offsets.
std::vector<uint32_t> mesh_ids;
// LRU marker for streaming eviction. Updated to the window's // LRU marker for streaming eviction. Updated to the window's
// streaming_frame_idx_ every frame the chunk is rendered (i.e. // streaming_frame_idx_ every frame the chunk is rendered (i.e.
// total_visible_draws > 0). The evictor picks the smallest value // total_visible_draws > 0). The evictor picks the smallest value
+150
View File
@@ -35,7 +35,9 @@
#include "WgpuStreamingLoader.h" #include "WgpuStreamingLoader.h"
#include <algorithm>
#include <cstdio> #include <cstdio>
#include <cstring>
namespace { namespace {
@@ -168,3 +170,151 @@ bool readSidecarIndexChunk(const std::string& ifc_path,
std::fclose(f); std::fclose(f);
return got == size_t(chunk_index_count); return got == size_t(chunk_index_count);
} }
// Coalesce ranges that are close in file order into single reads. The
// input order is preserved in the destination buffer; we just merge
// reads on the file side. A `max_gap_bytes` tolerance lets us swallow
// small file gaps when reading would be cheaper than seeking.
//
// SIDE EFFECT: callers must give the dst buffer in INPUT order; the
// reader scatters bytes via per-input-range dst offsets after a single
// coalesced fread. Returns false on any I/O failure.
namespace {
struct ReadPlan {
uint64_t file_offset; // absolute file offset
uint64_t read_size; // total bytes to read
// Per input range: where its bytes land in this read, and where to
// copy them into the destination buffer.
struct Slice {
uint64_t src_offset; // offset within the read buffer
uint64_t dst_offset; // offset within the destination buffer
uint64_t bytes;
};
std::vector<Slice> slices;
};
// Build a plan that merges adjacent file ranges into single reads.
// `ranges` are (section-relative offset, size). `max_gap_bytes` is the
// largest "wasted bytes" we'll read to bridge two ranges into one read.
std::vector<ReadPlan> buildReadPlan(
uint64_t section_offset,
const std::vector<std::pair<uint64_t, uint64_t>>& ranges,
uint64_t max_gap_bytes) {
// Sort by file offset, remembering original order so we can scatter
// to the destination correctly.
struct Indexed { uint64_t off, size, dst; };
std::vector<Indexed> sorted;
sorted.reserve(ranges.size());
uint64_t dst_cursor = 0;
for (const auto& [off, sz] : ranges) {
sorted.push_back({off, sz, dst_cursor});
dst_cursor += sz;
}
std::sort(sorted.begin(), sorted.end(),
[](const Indexed& a, const Indexed& b) { return a.off < b.off; });
std::vector<ReadPlan> plans;
for (const auto& r : sorted) {
if (r.size == 0) continue;
if (!plans.empty()) {
ReadPlan& back = plans.back();
const uint64_t end_of_back = back.file_offset + back.read_size;
const uint64_t r_file = section_offset + r.off;
if (r_file >= end_of_back && r_file - end_of_back <= max_gap_bytes) {
// Merge: extend the read to include r (plus any gap).
const uint64_t new_size = (r_file + r.size) - back.file_offset;
back.slices.push_back({
r_file - back.file_offset, // src within read
r.dst,
r.size,
});
back.read_size = new_size;
continue;
}
}
ReadPlan np;
np.file_offset = section_offset + r.off;
np.read_size = r.size;
np.slices.push_back({0, r.dst, r.size});
plans.push_back(std::move(np));
}
return plans;
}
} // namespace
bool readSidecarVertexRanges(const std::string& ifc_path,
uint64_t vertex_section_offset,
const std::vector<std::pair<uint64_t, uint64_t>>& ranges,
std::vector<uint8_t>& out_bytes) {
uint64_t total = 0;
for (const auto& r : ranges) total += r.second;
out_bytes.resize(size_t(total));
if (total == 0) return true;
// 64 KB max gap: on SSDs a small contiguous read is much cheaper
// than a seek + fresh read, even if some bytes are discarded.
auto plans = buildReadPlan(vertex_section_offset, ranges, 64 * 1024);
const std::string path = sidecarPath(ifc_path);
FILE* f = std::fopen(path.c_str(), "rb");
if (!f) return false;
std::vector<uint8_t> scratch;
for (const auto& p : plans) {
scratch.resize(size_t(p.read_size));
if (std::fseek(f, long(p.file_offset), SEEK_SET) != 0) { std::fclose(f); return false; }
if (std::fread(scratch.data(), 1, scratch.size(), f) != scratch.size()) {
std::fclose(f); return false;
}
for (const auto& s : p.slices) {
std::memcpy(out_bytes.data() + s.dst_offset,
scratch.data() + s.src_offset, size_t(s.bytes));
}
}
std::fclose(f);
return true;
}
bool readSidecarIndexRanges(const std::string& ifc_path,
uint64_t index_section_offset,
const std::vector<std::pair<uint64_t, uint64_t>>& ranges,
std::vector<uint32_t>& out_indices) {
uint64_t total = 0;
for (const auto& r : ranges) total += r.second;
out_indices.resize(size_t(total));
if (total == 0) return true;
// Convert u32-range (first_u32, count_u32) to byte-range
// (file_offset, byte_size). Then coalesce + read.
std::vector<std::pair<uint64_t, uint64_t>> byte_ranges;
byte_ranges.reserve(ranges.size());
uint64_t out_byte_cursor = 0;
for (const auto& [first_u32, count] : ranges) {
// Store byte offsets relative to the index section.
byte_ranges.emplace_back(first_u32 * 4u, count * 4u);
out_byte_cursor += count * 4u;
}
auto plans = buildReadPlan(index_section_offset, byte_ranges, 64 * 1024);
const std::string path = sidecarPath(ifc_path);
FILE* f = std::fopen(path.c_str(), "rb");
if (!f) return false;
std::vector<uint8_t> scratch;
uint8_t* out_bytes = reinterpret_cast<uint8_t*>(out_indices.data());
for (const auto& p : plans) {
scratch.resize(size_t(p.read_size));
if (std::fseek(f, long(p.file_offset), SEEK_SET) != 0) { std::fclose(f); return false; }
if (std::fread(scratch.data(), 1, scratch.size(), f) != scratch.size()) {
std::fclose(f); return false;
}
for (const auto& s : p.slices) {
std::memcpy(out_bytes + s.dst_offset,
scratch.data() + s.src_offset, size_t(s.bytes));
}
}
std::fclose(f);
return true;
}
+18
View File
@@ -86,4 +86,22 @@ bool readSidecarIndexChunk(const std::string& ifc_path,
uint64_t chunk_index_count, uint64_t chunk_index_count,
std::vector<uint32_t>& out_indices); std::vector<uint32_t>& out_indices);
// Multi-range vertex read. `ranges` is a list of (section-relative
// byte_offset, byte_size) tuples; their contents are concatenated into
// out_bytes in input order. Single fopen across all ranges, so it's
// far cheaper than calling readSidecarVertexChunk N times when a
// spatially-grouped chunk needs to scatter-gather meshes that aren't
// adjacent in the sidecar. out_bytes is resized to the total size.
bool readSidecarVertexRanges(const std::string& ifc_path,
uint64_t vertex_section_offset,
const std::vector<std::pair<uint64_t, uint64_t>>& ranges,
std::vector<uint8_t>& out_bytes);
// Same for the index section. Ranges are (first_u32, count_u32);
// concatenated into out_indices in input order.
bool readSidecarIndexRanges(const std::string& ifc_path,
uint64_t index_section_offset,
const std::vector<std::pair<uint64_t, uint64_t>>& ranges,
std::vector<uint32_t>& out_indices);
#endif // WGPUSTREAMINGLOADER_H #endif // WGPUSTREAMINGLOADER_H
+233 -201
View File
@@ -564,91 +564,112 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id,
m.streaming_vertex_section_offset = metadata.vertex_section_offset; m.streaming_vertex_section_offset = metadata.vertex_section_offset;
m.streaming_index_section_offset = metadata.index_section_offset; m.streaming_index_section_offset = metadata.index_section_offset;
// ---- Compute chunk plan from MeshInfo (same as non-streaming path) - // ---- Spatial chunk plan ----------------------------------------------
// Walks meshes in order, opens a new chunk when adding the next would // Sort meshes by world-space centroid (mean of their instances' AABB
// exceed WGPU_CHUNK_VERTEX_BYTES_LIMIT. // centres), then greedy-pack into chunks ≤ WGPU_CHUNK_VERTEX_BYTES_LIMIT.
m.mesh_chunk_idx.assign(metadata.meta.meshes.size(), 0); // Each chunk's AABB ends up tight rather than spanning the whole model,
m.mesh_chunk_local_base_vertex.assign(metadata.meta.meshes.size(), 0); // so the distance-based streaming evictor can meaningfully distinguish
m.mesh_chunk_local_ebo_first_u32.assign(metadata.meta.meshes.size(), 0); // chunks. Per-mesh layout within a chunk is the spatial-sort order;
// the loader scatter-gathers from each mesh's sidecar offsets.
const size_t n_meshes = metadata.meta.meshes.size();
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);
struct ChunkPlan { // Per-mesh centroid = mean of its instances' world AABB centres.
size_t source_byte_offset = 0; // vertex bytes // Meshes with no instances stay at (0,0,0) — they're dead weight but
size_t byte_count = 0; // still need a chunk slot for layout consistency.
uint32_t vertex_count = 0; std::vector<float> mesh_cx(n_meshes, 0.0f),
uint32_t index_first_u32 = 0; // chunk's first LOD0 index in sd.indices mesh_cy(n_meshes, 0.0f),
uint32_t index_count = 0; mesh_cz(n_meshes, 0.0f);
}; std::vector<uint32_t> mesh_inst_count(n_meshes, 0);
std::vector<ChunkPlan> chunk_plans; for (const auto& inst : metadata.meta.instances) {
chunk_plans.push_back({}); if (inst.mesh_id >= n_meshes) continue;
size_t current_bytes = 0; mesh_cx[inst.mesh_id] += 0.5f * (inst.world_aabb_min[0] + inst.world_aabb_max[0]);
uint32_t current_idx = 0; mesh_cy[inst.mesh_id] += 0.5f * (inst.world_aabb_min[1] + inst.world_aabb_max[1]);
size_t current_start = 0; mesh_cz[inst.mesh_id] += 0.5f * (inst.world_aabb_min[2] + inst.world_aabb_max[2]);
uint32_t current_idx_start = 0; ++mesh_inst_count[inst.mesh_id];
uint32_t current_idx_count = 0; }
bool warned_lod1 = false; for (size_t i = 0; i < n_meshes; ++i) {
for (uint32_t mi = 0; mi < metadata.meta.meshes.size(); ++mi) { if (mesh_inst_count[i] > 0) {
const float inv = 1.0f / float(mesh_inst_count[i]);
mesh_cx[i] *= inv; mesh_cy[i] *= inv; mesh_cz[i] *= inv;
}
}
// Sort mesh indices by centroid. Lexicographic (z, y, x) is cheap and
// gives reasonable spatial locality — a Morton/Hilbert encode would
// be tighter but this is enough to make per-chunk AABBs much smaller
// than the model AABB. Stable sort to keep mesh-id order as the
// tiebreaker when many meshes coincide (instanced repeat geometry).
std::vector<uint32_t> sorted_mesh_ids(n_meshes);
std::iota(sorted_mesh_ids.begin(), sorted_mesh_ids.end(), 0u);
std::stable_sort(sorted_mesh_ids.begin(), sorted_mesh_ids.end(),
[&](uint32_t a, uint32_t b) {
if (mesh_cz[a] != mesh_cz[b]) return mesh_cz[a] < mesh_cz[b];
if (mesh_cy[a] != mesh_cy[b]) return mesh_cy[a] < mesh_cy[b];
return mesh_cx[a] < mesh_cx[b];
});
// Greedy pack sorted meshes into chunks.
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]; const MeshInfo& mesh = metadata.meta.meshes[mi];
if (!warned_lod1 && mesh.lod1_index_count > 0) { if (!warned_lod1 && mesh.lod1_index_count > 0) {
qWarning() << "[wgpu stream] LOD1 indices present but per-chunk index " qWarning() << "[wgpu stream] LOD1 indices present but per-chunk "
"buffers only carry LOD0; LOD1 will be ignored this load."; "buffers only carry LOD0; LOD1 will be ignored.";
warned_lod1 = true; warned_lod1 = true;
} }
const size_t mesh_bytes = size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES; const uint64_t mesh_bytes = uint64_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (current_bytes > 0 && current_bytes + mesh_bytes > WGPU_CHUNK_VERTEX_BYTES_LIMIT) { if (current_chunk_bytes > 0
ChunkPlan& done = chunk_plans[current_idx]; && current_chunk_bytes + mesh_bytes > WGPU_CHUNK_VERTEX_BYTES_LIMIT) {
done.source_byte_offset = current_start; chunk_mesh_ids.push_back({});
done.byte_count = current_bytes; current_chunk_bytes = 0;
done.vertex_count = uint32_t(current_bytes / INSTANCED_VERTEX_STRIDE_BYTES);
done.index_first_u32 = current_idx_start;
done.index_count = current_idx_count;
++current_idx;
chunk_plans.push_back({});
current_bytes = 0;
current_start = mesh.vbo_byte_offset;
current_idx_start = mesh.ebo_byte_offset / uint32_t(sizeof(uint32_t));
current_idx_count = 0;
} else if (current_bytes == 0) {
current_start = mesh.vbo_byte_offset;
current_idx_start = mesh.ebo_byte_offset / uint32_t(sizeof(uint32_t));
} }
m.mesh_chunk_idx[mi] = current_idx; chunk_mesh_ids.back().push_back(mi);
m.mesh_chunk_local_base_vertex[mi] current_chunk_bytes += mesh_bytes;
= uint32_t(current_bytes / INSTANCED_VERTEX_STRIDE_BYTES);
m.mesh_chunk_local_ebo_first_u32[mi] = current_idx_count;
current_bytes += mesh_bytes;
current_idx_count += mesh.index_count;
} }
{ if (chunk_mesh_ids.back().empty()) chunk_mesh_ids.pop_back();
ChunkPlan& done = chunk_plans[current_idx];
done.source_byte_offset = current_start;
done.byte_count = current_bytes;
done.vertex_count = uint32_t(current_bytes / INSTANCED_VERTEX_STRIDE_BYTES);
done.index_first_u32 = current_idx_start;
done.index_count = current_idx_count;
}
if (chunk_plans.back().byte_count == 0) chunk_plans.pop_back();
// Per-chunk instance count (used to right-size visible_draws / prefix // Per-chunk instance count (used to right-size visible_draws / prefix
// buffers per chunk). // buffers per chunk). Each instance belongs to one mesh's chunk.
std::vector<uint32_t> chunk_instance_count(chunk_plans.size(), 0); std::vector<uint32_t> mesh_to_chunk(n_meshes, 0);
for (size_t ci = 0; ci < chunk_mesh_ids.size(); ++ci) {
for (uint32_t mi : chunk_mesh_ids[ci]) mesh_to_chunk[mi] = uint32_t(ci);
}
std::vector<uint32_t> chunk_instance_count(chunk_mesh_ids.size(), 0);
for (const auto& inst : metadata.meta.instances) { for (const auto& inst : metadata.meta.instances) {
if (inst.mesh_id < m.mesh_chunk_idx.size()) { if (inst.mesh_id < n_meshes) ++chunk_instance_count[mesh_to_chunk[inst.mesh_id]];
++chunk_instance_count[m.mesh_chunk_idx[inst.mesh_id]];
}
} }
// ---- Allocate per-chunk state. NO vertex_storage yet (chunks are // ---- Allocate per-chunk state. NO pool slices yet (chunks are
// non-resident); record byte offsets for the per-frame loader. // non-resident); the per-frame loader brings them in as cull marks
m.chunks.resize(chunk_plans.size()); // them visible.
for (size_t ci = 0; ci < chunk_plans.size(); ++ci) { m.chunks.resize(chunk_mesh_ids.size());
const ChunkPlan& plan = chunk_plans[ci]; for (size_t ci = 0; ci < chunk_mesh_ids.size(); ++ci) {
WgpuModelGpuData::Chunk& c = m.chunks[ci]; WgpuModelGpuData::Chunk& c = m.chunks[ci];
c.vertex_count = plan.vertex_count; c.mesh_ids = std::move(chunk_mesh_ids[ci]);
c.is_resident = false; // streaming c.is_resident = false; // streaming
c.vertex_byte_offset = plan.source_byte_offset;
c.vertex_byte_size = plan.byte_count; // Walk this chunk's meshes in chunk-local layout order, computing
c.index_first_u32 = plan.index_first_u32; // each mesh's chunk-local base_vertex / ebo_first_u32 and the
c.index_count = plan.index_count; // chunk's aggregate vertex/index totals.
uint32_t chunk_local_v = 0;
uint32_t chunk_local_i = 0;
for (uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = metadata.meta.meshes[mi];
m.mesh_chunk_idx[mi] = uint32_t(ci);
m.mesh_chunk_local_base_vertex[mi] = chunk_local_v;
m.mesh_chunk_local_ebo_first_u32[mi] = chunk_local_i;
chunk_local_v += mesh.vertex_count;
chunk_local_i += mesh.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;
// Small per-chunk buffers, allocated upfront so cull can write into // Small per-chunk buffers, allocated upfront so cull can write into
// them. visible_draws_buffer cap = chunk's instance count (worst- // them. visible_draws_buffer cap = chunk's instance count (worst-
@@ -811,154 +832,155 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
m.mesh_count = uint32_t(data.meshes.size()); m.mesh_count = uint32_t(data.meshes.size());
m.instance_count = uint32_t(data.instances.size()); m.instance_count = uint32_t(data.instances.size());
// ---- Vertex chunking: split data.vertices into ≤128 MB chunks -------- // ---- Spatial chunk plan ----------------------------------------------
// Each mesh's vertex range stays in exactly one chunk. We walk meshes in // Identical algorithm to applyCachedModelStreaming: sort meshes by
// their existing order, accumulating into the current chunk until adding // world-space centroid, then greedy-pack into chunks of
// another mesh would overflow the limit; then start a new chunk. // ≤WGPU_CHUNK_VERTEX_BYTES_LIMIT. Each chunk's mesh_ids list defines
// // the chunk-local layout order. Non-streaming differs only in that
// After this, mesh_chunk_idx[mi] tells which chunk mesh mi lives in, // vertex+index bytes are already in memory (data.vertices,
// and mesh_chunk_local_base_vertex[mi] is the chunk-LOCAL vertex offset // data.indices), so we gather them with per-mesh queueWriteBuffer
// for that mesh (in vertex units, divide bytes by 12). The shader's // calls instead of scatter-gather disk reads.
// vertex_storage binding will be the chunk's vertex_storage so the const size_t n_meshes = data.meshes.size();
// chunk-local base_vertex indexes correctly. m.mesh_chunk_idx.assign(n_meshes, 0);
m.mesh_chunk_idx.assign(data.meshes.size(), 0); m.mesh_chunk_local_base_vertex.assign(n_meshes, 0);
m.mesh_chunk_local_base_vertex.assign(data.meshes.size(), 0); m.mesh_chunk_local_ebo_first_u32.assign(n_meshes, 0);
m.mesh_chunk_local_ebo_first_u32.assign(data.meshes.size(), 0);
struct ChunkPlan { std::vector<float> mesh_cx(n_meshes, 0.0f),
size_t source_byte_offset = 0; // where in data.vertices this chunk's slice starts mesh_cy(n_meshes, 0.0f),
size_t byte_count = 0; // bytes in this chunk mesh_cz(n_meshes, 0.0f);
uint32_t vertex_count = 0; // vertex_count = byte_count / 12 std::vector<uint32_t> mesh_inst_count(n_meshes, 0);
uint32_t index_first_u32 = 0; // chunk's first LOD0 index in data.indices for (const auto& inst : data.instances) {
uint32_t index_count = 0; // LOD0 indices belonging to this chunk if (inst.mesh_id >= n_meshes) continue;
}; mesh_cx[inst.mesh_id] += 0.5f * (inst.world_aabb_min[0] + inst.world_aabb_max[0]);
std::vector<ChunkPlan> chunk_plans; mesh_cy[inst.mesh_id] += 0.5f * (inst.world_aabb_min[1] + inst.world_aabb_max[1]);
size_t current_chunk_bytes = 0; mesh_cz[inst.mesh_id] += 0.5f * (inst.world_aabb_min[2] + inst.world_aabb_max[2]);
uint32_t current_chunk_idx = 0; ++mesh_inst_count[inst.mesh_id];
size_t current_chunk_start = 0; // byte offset in data.vertices for current chunk's start }
uint32_t current_idx_start = 0; for (size_t i = 0; i < n_meshes; ++i) {
uint32_t current_idx_count = 0; if (mesh_inst_count[i] > 0) {
bool warned_lod1 = false; const float inv = 1.0f / float(mesh_inst_count[i]);
mesh_cx[i] *= inv; mesh_cy[i] *= inv; mesh_cz[i] *= inv;
}
}
chunk_plans.push_back({}); // chunk 0 std::vector<uint32_t> sorted_mesh_ids(n_meshes);
for (uint32_t mi = 0; mi < data.meshes.size(); ++mi) { std::iota(sorted_mesh_ids.begin(), sorted_mesh_ids.end(), 0u);
std::stable_sort(sorted_mesh_ids.begin(), sorted_mesh_ids.end(),
[&](uint32_t a, uint32_t b) {
if (mesh_cz[a] != mesh_cz[b]) return mesh_cz[a] < mesh_cz[b];
if (mesh_cy[a] != mesh_cy[b]) return mesh_cy[a] < mesh_cy[b];
return mesh_cx[a] < mesh_cx[b];
});
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]; const MeshInfo& mesh = data.meshes[mi];
if (!warned_lod1 && mesh.lod1_index_count > 0) { if (!warned_lod1 && mesh.lod1_index_count > 0) {
qWarning() << "[wgpu] LOD1 indices present but per-chunk index buffers " qWarning() << "[wgpu] LOD1 indices present but per-chunk buffers "
"only carry LOD0; LOD1 will be ignored this load."; "only carry LOD0; LOD1 will be ignored this load.";
warned_lod1 = true; warned_lod1 = true;
} }
const size_t mesh_vertex_bytes = size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES; const uint64_t mesh_vertex_bytes = uint64_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (mesh_vertex_bytes > WGPU_CHUNK_VERTEX_BYTES_LIMIT) { if (mesh_vertex_bytes > WGPU_CHUNK_VERTEX_BYTES_LIMIT) {
qWarning().noquote().nospace() qWarning().noquote().nospace()
<< "Mesh #" << mi << " has " << mesh_vertex_bytes << "Mesh #" << mi << " has " << mesh_vertex_bytes
<< " B of vertex data — exceeds chunk limit " << " B — exceeds chunk limit " << WGPU_CHUNK_VERTEX_BYTES_LIMIT
<< WGPU_CHUNK_VERTEX_BYTES_LIMIT << ". Mesh-splitting is not implemented.";
<< " B. Mesh-splitting is not implemented; this mesh will be"
" in an oversize chunk that won't fit a web browser.";
} }
if (current_chunk_bytes > 0 if (current_chunk_bytes > 0
&& current_chunk_bytes + mesh_vertex_bytes > WGPU_CHUNK_VERTEX_BYTES_LIMIT) { && current_chunk_bytes + mesh_vertex_bytes > WGPU_CHUNK_VERTEX_BYTES_LIMIT) {
// Finalise current chunk, start a new one. chunk_mesh_ids.push_back({});
ChunkPlan& done = chunk_plans[current_chunk_idx];
done.source_byte_offset = current_chunk_start;
done.byte_count = current_chunk_bytes;
done.vertex_count = uint32_t(current_chunk_bytes / INSTANCED_VERTEX_STRIDE_BYTES);
done.index_first_u32 = current_idx_start;
done.index_count = current_idx_count;
++current_chunk_idx;
chunk_plans.push_back({});
current_chunk_bytes = 0; current_chunk_bytes = 0;
current_chunk_start = mesh.vbo_byte_offset;
current_idx_start = mesh.ebo_byte_offset / uint32_t(sizeof(uint32_t));
current_idx_count = 0;
} else if (current_chunk_bytes == 0) {
current_chunk_start = mesh.vbo_byte_offset;
current_idx_start = mesh.ebo_byte_offset / uint32_t(sizeof(uint32_t));
} }
chunk_mesh_ids.back().push_back(mi);
m.mesh_chunk_idx[mi] = current_chunk_idx;
m.mesh_chunk_local_base_vertex[mi]
= uint32_t(current_chunk_bytes / INSTANCED_VERTEX_STRIDE_BYTES);
m.mesh_chunk_local_ebo_first_u32[mi] = current_idx_count;
current_chunk_bytes += mesh_vertex_bytes; current_chunk_bytes += mesh_vertex_bytes;
current_idx_count += mesh.index_count;
} }
// Finalise the last (possibly first) chunk. if (chunk_mesh_ids.back().empty()) chunk_mesh_ids.pop_back();
{
ChunkPlan& done = chunk_plans[current_chunk_idx];
done.source_byte_offset = current_chunk_start;
done.byte_count = current_chunk_bytes;
done.vertex_count = uint32_t(current_chunk_bytes / INSTANCED_VERTEX_STRIDE_BYTES);
done.index_first_u32 = current_idx_start;
done.index_count = current_idx_count;
}
// Drop trailing empty chunk (e.g. on a no-mesh model).
if (chunk_plans.back().byte_count == 0) chunk_plans.pop_back();
// Count instances per chunk so each chunk's visible_draws + prefix_sums std::vector<uint32_t> mesh_to_chunk(n_meshes, 0);
// buffers can be sized to ITS worst case, not the model-wide instance for (size_t ci = 0; ci < chunk_mesh_ids.size(); ++ci) {
// count × 2 (which over-allocated by ~4× on multi-chunk models — each for (uint32_t mi : chunk_mesh_ids[ci]) mesh_to_chunk[mi] = uint32_t(ci);
// visible instance only ever contributes one VisibleDraw entry, LOD0 }
// OR LOD1 not both). std::vector<uint32_t> chunk_instance_count(chunk_mesh_ids.size(), 0);
std::vector<uint32_t> chunk_instance_count(chunk_plans.size(), 0);
for (const auto& inst : data.instances) { for (const auto& inst : data.instances) {
if (inst.mesh_id < m.mesh_chunk_idx.size()) { if (inst.mesh_id < n_meshes) ++chunk_instance_count[mesh_to_chunk[inst.mesh_id]];
++chunk_instance_count[m.mesh_chunk_idx[inst.mesh_id]];
}
} }
// ---- Allocate per-chunk buffers + upload vertex + index slices ----- // ---- Allocate per-chunk pool ranges and upload per-mesh slices ------
m.chunks.resize(chunk_plans.size()); m.chunks.resize(chunk_mesh_ids.size());
for (size_t ci = 0; ci < chunk_plans.size(); ++ci) { for (size_t ci = 0; ci < chunk_mesh_ids.size(); ++ci) {
const ChunkPlan& plan = chunk_plans[ci];
WgpuModelGpuData::Chunk& c = m.chunks[ci]; WgpuModelGpuData::Chunk& c = m.chunks[ci];
c.vertex_count = plan.vertex_count; c.mesh_ids = std::move(chunk_mesh_ids[ci]);
c.index_first_u32 = plan.index_first_u32;
c.index_count = plan.index_count;
// Vertex bytes: claim a pool range, upload via queueWriteBuffer. // Walk meshes in chunk-local layout order, computing each mesh's
// Storage binding offsets must align to 256 B (WebGPU spec floor // chunk-local offsets and the chunk's aggregate vertex/index totals.
// — minStorageBufferOffsetAlignment); WgpuBufferPool inserts the uint32_t chunk_local_v = 0;
// necessary pre-pad. Failure here is fatal for the model: a fresh uint32_t chunk_local_i = 0;
// applyCachedModel can't proceed without VRAM, so we bail with for (uint32_t mi : c.mesh_ids) {
// a clear log and let the caller see it as a load failure. const MeshInfo& mesh = data.meshes[mi];
c.vertex_slice = pool_.alloc(plan.byte_count, 256); m.mesh_chunk_idx[mi] = uint32_t(ci);
m.mesh_chunk_local_base_vertex[mi] = chunk_local_v;
m.mesh_chunk_local_ebo_first_u32[mi] = chunk_local_i;
chunk_local_v += mesh.vertex_count;
chunk_local_i += mesh.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.vertex_slice = pool_.alloc(c.vertex_byte_size, 256);
if (!c.vertex_slice.valid()) { if (!c.vertex_slice.valid()) {
qWarning().noquote().nospace() qWarning().noquote().nospace()
<< "[wgpu] pool OOM: chunk " << ci << " needed " << "[wgpu] pool OOM: chunk " << ci << " needed "
<< plan.byte_count << " B for vertices, pool free=" << c.vertex_byte_size << " B for vertices, pool free="
<< pool_.total_free_bytes() << " B across " << pool_.total_free_bytes() << " B across "
<< pool_.sub_buffer_count() << " sub-buffer(s); aborting model load"; << pool_.sub_buffer_count() << " sub-buffer(s); aborting model load";
releaseWgpuModelGpuData(m, pool_); releaseWgpuModelGpuData(m, pool_);
return; return;
} }
wgpuQueueWriteBuffer(queue_, c.vertex_slice.buffer, if (c.index_count > 0) {
c.vertex_slice.offset, c.index_slice = pool_.alloc(c.index_count * sizeof(uint32_t), 256);
data.vertices.data() + plan.source_byte_offset,
plan.byte_count);
m.vram_bytes_vbo += plan.byte_count;
// Per-chunk index slice — same dance, from the model's indices[].
const size_t chunk_index_bytes = size_t(plan.index_count) * sizeof(uint32_t);
if (chunk_index_bytes > 0) {
c.index_slice = pool_.alloc(chunk_index_bytes, 256);
if (!c.index_slice.valid()) { if (!c.index_slice.valid()) {
qWarning().noquote().nospace() qWarning().noquote().nospace()
<< "[wgpu] pool OOM: chunk " << ci << " needed " << "[wgpu] pool OOM: chunk " << ci << " needed "
<< chunk_index_bytes << " B for indices, pool free=" << (c.index_count * sizeof(uint32_t))
<< pool_.total_free_bytes() << " B across " << " B for indices, pool free=" << pool_.total_free_bytes()
<< pool_.sub_buffer_count() << " sub-buffer(s); aborting model load"; << " B across " << pool_.sub_buffer_count()
<< " sub-buffer(s); aborting model load";
releaseWgpuModelGpuData(m, pool_); releaseWgpuModelGpuData(m, pool_);
return; return;
} }
wgpuQueueWriteBuffer(queue_, c.index_slice.buffer,
c.index_slice.offset,
data.indices.data() + plan.index_first_u32,
chunk_index_bytes);
m.vram_bytes_ebo += chunk_index_bytes;
} }
// Gather each mesh's bytes from data.vertices / data.indices and
// write into the pool at chunk-local offsets. Multiple small
// queueWriteBuffer calls per chunk; wgpu batches them efficiently.
uint64_t v_off = 0;
uint64_t i_off = 0;
for (uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = data.meshes[mi];
const size_t v_bytes = size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (v_bytes > 0) {
wgpuQueueWriteBuffer(queue_, c.vertex_slice.buffer,
c.vertex_slice.offset + v_off,
data.vertices.data() + mesh.vbo_byte_offset,
v_bytes);
v_off += v_bytes;
}
const size_t i_bytes = size_t(mesh.index_count) * sizeof(uint32_t);
if (i_bytes > 0) {
wgpuQueueWriteBuffer(queue_, c.index_slice.buffer,
c.index_slice.offset + i_off,
data.indices.data() + (mesh.ebo_byte_offset / sizeof(uint32_t)),
i_bytes);
i_off += i_bytes;
}
}
m.vram_bytes_vbo += c.vertex_byte_size;
m.vram_bytes_ebo += c.index_count * sizeof(uint32_t);
} }
// Derive MeshGpu[] (vec4 aabb_min + vec4 aabb_max) from MeshInfo's // Derive MeshGpu[] (vec4 aabb_min + vec4 aabb_max) from MeshInfo's
@@ -3399,18 +3421,32 @@ bool WgpuViewportWindow::loadChunkBytesAndUploadGpu(WgpuModelGpuData& m, size_t
if (c.is_resident) return true; if (c.is_resident) return true;
if (m.streaming_file_path.empty()) return false; if (m.streaming_file_path.empty()) return false;
// Vertex slice. // Build scatter-gather ranges from this chunk's mesh_ids. Spatial
// chunk planning sorted meshes by world centroid, so the chunk's
// mesh ranges are NOT contiguous in the sidecar file — we need a
// multi-range read.
std::vector<std::pair<uint64_t, uint64_t>> v_ranges;
std::vector<std::pair<uint64_t, uint64_t>> i_ranges;
v_ranges.reserve(c.mesh_ids.size());
i_ranges.reserve(c.mesh_ids.size());
for (uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = m.meshes[mi];
const uint64_t v_bytes = uint64_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (v_bytes > 0) v_ranges.emplace_back(uint64_t(mesh.vbo_byte_offset), v_bytes);
if (mesh.index_count > 0) {
i_ranges.emplace_back(uint64_t(mesh.ebo_byte_offset / sizeof(uint32_t)),
uint64_t(mesh.index_count));
}
}
std::vector<uint8_t> vbytes; std::vector<uint8_t> vbytes;
if (!readSidecarVertexChunk(m.streaming_file_path, if (!readSidecarVertexRanges(m.streaming_file_path,
m.streaming_vertex_section_offset, m.streaming_vertex_section_offset,
c.vertex_byte_offset, v_ranges, vbytes)) {
c.vertex_byte_size,
vbytes)) {
qWarning().noquote().nospace() qWarning().noquote().nospace()
<< "[wgpu stream] failed to read vertex chunk " << chunk_idx << "[wgpu stream] failed to read vertex chunk " << chunk_idx
<< " from " << QString::fromStdString(m.streaming_file_path) << " (" << v_ranges.size() << " ranges, total "
<< " (offset=" << c.vertex_byte_offset << c.vertex_byte_size << " B)";
<< " size=" << c.vertex_byte_size << ")";
return false; return false;
} }
// Claim a pool range for the vertex bytes and upload. // Claim a pool range for the vertex bytes and upload.
@@ -3426,20 +3462,16 @@ bool WgpuViewportWindow::loadChunkBytesAndUploadGpu(WgpuModelGpuData& m, size_t
vbytes.data(), vbytes.size()); vbytes.data(), vbytes.size());
m.vram_bytes_vbo += vbytes.size(); m.vram_bytes_vbo += vbytes.size();
// Index slice. The byte-range read comes from // Index slice — scatter-gather from the same mesh_ids list.
// streaming_index_section_offset + index_first_u32 * 4 (set by
// applyCachedModelStreaming alongside the vertex offset).
if (c.index_count > 0) { if (c.index_count > 0) {
std::vector<uint32_t> idx; std::vector<uint32_t> idx;
if (!readSidecarIndexChunk(m.streaming_file_path, if (!readSidecarIndexRanges(m.streaming_file_path,
m.streaming_index_section_offset, m.streaming_index_section_offset,
c.index_first_u32, i_ranges, idx)) {
c.index_count,
idx)) {
qWarning().noquote().nospace() qWarning().noquote().nospace()
<< "[wgpu stream] failed to read index chunk " << chunk_idx << "[wgpu stream] failed to read index chunk " << chunk_idx
<< " (first=" << c.index_first_u32 << " (" << i_ranges.size() << " ranges, total "
<< " count=" << c.index_count << ")"; << c.index_count << " indices)";
// Return the vertex slice to the pool so we don't leak. // Return the vertex slice to the pool so we don't leak.
pool_.free(c.vertex_slice); pool_.free(c.vertex_slice);
m.vram_bytes_vbo -= c.vertex_slice.size; m.vram_bytes_vbo -= c.vertex_slice.size;