mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
wgpu streaming (5/4): per-chunk indices + LRU/distance eviction (stopgap)
Defers index buffers per-chunk (alongside vertex bytes) so streaming fully delivers on its "don't load until visible" contract — the previous per-model index buffer was upfront-loaded and tipped scenes >~1.5 GB into allocator OOM at frame 1. Adds residency tracking + a two-phase evictor: (1) drop LRU non-visible chunks first, (2) if everything resident is visible-this-frame, drop the farthest-from-eye chunk only when the candidate to load is closer. This gives monotonic convergence to "closest visible chunks fit the budget" instead of "first 4 win, rest never load." Default budget set to 1 GB — explicitly a stopgap, documented inline. The per-machine OOM ceiling on wgpu-native (caused by allocator fragmentation from one VkDeviceMemory per createBuffer call) cannot be solved by tuning this knob. The proper fix is a probed single-pool buffer with sub-allocation, tracked under task #16. Caveat: LOD1 indices are now force-disabled when chunking — per-chunk buffers only carry LOD0. Re-enabling needs LOD1 to participate in the chunk plan. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,10 @@ int main(int argc, char* argv[]) {
|
||||
"Enable streaming sidecar load. Reads metadata-only at load time; "
|
||||
"vertex chunks are deferred and loaded on demand as they become "
|
||||
"frustum-visible. Required for scenes that exceed GPU memory."});
|
||||
parser.addOption({"streaming-vram-mb",
|
||||
"Set the streaming residency budget in MB (default 1900). Tune "
|
||||
"down to test browser-class memory ceilings; tune up if your GPU "
|
||||
"+ driver are happy with larger allocations.", "mb"});
|
||||
parser.process(app);
|
||||
|
||||
auto* viewport = new WgpuViewportWindow;
|
||||
@@ -70,6 +74,16 @@ int main(int argc, char* argv[]) {
|
||||
if (parser.isSet("web-limits")) viewport->web_limits_ = true;
|
||||
if (parser.isSet("bvh")) viewport->bvh_enabled_ = true;
|
||||
if (parser.isSet("streaming")) viewport->streaming_enabled_ = true;
|
||||
if (parser.isSet("streaming-vram-mb")) {
|
||||
bool ok = false;
|
||||
const uint64_t mb = parser.value("streaming-vram-mb").toULongLong(&ok);
|
||||
if (ok && mb > 0) {
|
||||
viewport->streaming_vram_budget_bytes_ = mb * 1024ull * 1024ull;
|
||||
} else {
|
||||
qWarning() << "--streaming-vram-mb: failed to parse"
|
||||
<< parser.value("streaming-vram-mb");
|
||||
}
|
||||
}
|
||||
|
||||
QWidget* container = QWidget::createWindowContainer(viewport);
|
||||
container->setMinimumSize(320, 240);
|
||||
|
||||
@@ -76,6 +76,11 @@ struct WgpuModelGpuData {
|
||||
// path always sets is_resident=true so existing code is unchanged.
|
||||
struct Chunk {
|
||||
WGPUBuffer vertex_storage = nullptr;
|
||||
// Per-chunk index buffer (was previously per-model). Each chunk
|
||||
// covers a contiguous range of mesh ids, so its indices form a
|
||||
// contiguous slice of the model's overall index data. Storing
|
||||
// per-chunk lets streaming defer index loading alongside vertices.
|
||||
WGPUBuffer index_buffer = nullptr;
|
||||
WGPUBuffer visible_draws_buffer = nullptr;
|
||||
WGPUBuffer prefix_sums_buffer = nullptr;
|
||||
WGPUBuffer per_chunk_uniform = nullptr;
|
||||
@@ -103,6 +108,12 @@ struct WgpuModelGpuData {
|
||||
uint64_t vertex_byte_offset = 0; // 0 == start of vertex section
|
||||
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;
|
||||
|
||||
// World-space AABB covering every instance whose mesh lives in
|
||||
// this chunk. Used by cull to reject whole chunks against the
|
||||
// frustum before iterating instances — and by the streaming
|
||||
@@ -113,6 +124,12 @@ struct WgpuModelGpuData {
|
||||
float aabb_max[3] = { -std::numeric_limits<float>::infinity(),
|
||||
-std::numeric_limits<float>::infinity(),
|
||||
-std::numeric_limits<float>::infinity() };
|
||||
|
||||
// LRU marker for streaming eviction. Updated to the window's
|
||||
// streaming_frame_idx_ every frame the chunk is rendered (i.e.
|
||||
// total_visible_draws > 0). The evictor picks the smallest value
|
||||
// among non-visible resident chunks when it needs to free VRAM.
|
||||
uint64_t last_visible_frame_idx = 0;
|
||||
};
|
||||
std::vector<Chunk> chunks;
|
||||
|
||||
@@ -121,17 +138,19 @@ struct WgpuModelGpuData {
|
||||
// from this file. Empty path = legacy non-streaming load.
|
||||
std::string streaming_file_path;
|
||||
uint64_t streaming_vertex_section_offset = 0;
|
||||
uint64_t streaming_index_section_offset = 0;
|
||||
|
||||
// For each mesh in meshes[], the chunk it lives in plus the chunk-local
|
||||
// vertex offset (where its vertex range starts in chunk's vertex_storage).
|
||||
// Populated at applyCachedModel time.
|
||||
// offsets into that chunk's vertex_storage and index_buffer. Populated
|
||||
// at applyCachedModel time; consumed by cullModelCpuCompute when it
|
||||
// populates VisibleDrawGpu entries.
|
||||
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;
|
||||
|
||||
// Model-shared buffers (NOT chunked — sizes safely under 128 MB on real
|
||||
// scenes: index buffer up to ~76 MB on a 19 M-index model; instance and
|
||||
// mesh storage are far smaller).
|
||||
WGPUBuffer index_buffer = nullptr; // u32 mesh-local indices (read as storage)
|
||||
// Model-shared buffers. Mesh + instance storage are small (<10 MB on
|
||||
// any real scene we've seen); the chunked index buffer lives in Chunk
|
||||
// alongside vertex_storage so streaming can defer both together.
|
||||
WGPUBuffer mesh_storage = nullptr; // MeshGpu[]: aabb_min/max
|
||||
WGPUBuffer instance_storage = nullptr; // InstanceGpu[]: transform + ids
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ void releaseWgpuModelGpuData(WgpuModelGpuData& m) {
|
||||
for (auto& c : m.chunks) {
|
||||
if (c.bind_group) { wgpuBindGroupRelease(c.bind_group); c.bind_group = nullptr; }
|
||||
if (c.vertex_storage) { wgpuBufferRelease(c.vertex_storage); c.vertex_storage = nullptr; }
|
||||
if (c.index_buffer) { wgpuBufferRelease(c.index_buffer); c.index_buffer = nullptr; }
|
||||
if (c.visible_draws_buffer) { wgpuBufferRelease(c.visible_draws_buffer); c.visible_draws_buffer = nullptr; }
|
||||
if (c.prefix_sums_buffer) { wgpuBufferRelease(c.prefix_sums_buffer); c.prefix_sums_buffer = nullptr; }
|
||||
if (c.per_chunk_uniform) { wgpuBufferRelease(c.per_chunk_uniform); c.per_chunk_uniform = nullptr; }
|
||||
@@ -143,7 +144,7 @@ void releaseWgpuModelGpuData(WgpuModelGpuData& m) {
|
||||
m.chunks.clear();
|
||||
m.mesh_chunk_idx.clear();
|
||||
m.mesh_chunk_local_base_vertex.clear();
|
||||
if (m.index_buffer) { wgpuBufferRelease(m.index_buffer); m.index_buffer = nullptr; }
|
||||
m.mesh_chunk_local_ebo_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;
|
||||
@@ -555,48 +556,69 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id,
|
||||
m.instance_count = uint32_t(metadata.meta.instances.size());
|
||||
m.streaming_file_path = metadata.file_path;
|
||||
m.streaming_vertex_section_offset = metadata.vertex_section_offset;
|
||||
m.streaming_index_section_offset = metadata.index_section_offset;
|
||||
|
||||
// ---- Compute chunk plan from MeshInfo (same as non-streaming path) -
|
||||
// Walks meshes in order, opens a new chunk when adding the next would
|
||||
// exceed WGPU_CHUNK_VERTEX_BYTES_LIMIT.
|
||||
m.mesh_chunk_idx.assign(metadata.meta.meshes.size(), 0);
|
||||
m.mesh_chunk_local_base_vertex.assign(metadata.meta.meshes.size(), 0);
|
||||
m.mesh_chunk_local_ebo_first_u32.assign(metadata.meta.meshes.size(), 0);
|
||||
|
||||
struct ChunkPlan {
|
||||
size_t source_byte_offset = 0;
|
||||
size_t source_byte_offset = 0; // vertex bytes
|
||||
size_t byte_count = 0;
|
||||
uint32_t vertex_count = 0;
|
||||
uint32_t index_first_u32 = 0; // chunk's first LOD0 index in sd.indices
|
||||
uint32_t index_count = 0;
|
||||
};
|
||||
std::vector<ChunkPlan> chunk_plans;
|
||||
chunk_plans.push_back({});
|
||||
size_t current_bytes = 0;
|
||||
uint32_t current_idx = 0;
|
||||
size_t current_start = 0;
|
||||
uint32_t current_idx_start = 0;
|
||||
uint32_t current_idx_count = 0;
|
||||
bool warned_lod1 = false;
|
||||
for (uint32_t mi = 0; mi < metadata.meta.meshes.size(); ++mi) {
|
||||
const MeshInfo& mesh = metadata.meta.meshes[mi];
|
||||
if (!warned_lod1 && mesh.lod1_index_count > 0) {
|
||||
qWarning() << "[wgpu stream] LOD1 indices present but per-chunk index "
|
||||
"buffers only carry LOD0; LOD1 will be ignored this load.";
|
||||
warned_lod1 = true;
|
||||
}
|
||||
const size_t mesh_bytes = size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
|
||||
if (current_bytes > 0 && current_bytes + mesh_bytes > WGPU_CHUNK_VERTEX_BYTES_LIMIT) {
|
||||
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;
|
||||
++current_idx;
|
||||
chunk_plans.push_back({});
|
||||
current_bytes = 0;
|
||||
current_start = mesh.vbo_byte_offset;
|
||||
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_start = mesh.vbo_byte_offset;
|
||||
current_idx_start = mesh.ebo_byte_offset / uint32_t(sizeof(uint32_t));
|
||||
}
|
||||
m.mesh_chunk_idx[mi] = current_idx;
|
||||
m.mesh_chunk_local_base_vertex[mi]
|
||||
= uint32_t(current_bytes / INSTANCED_VERTEX_STRIDE_BYTES);
|
||||
current_bytes += mesh_bytes;
|
||||
m.mesh_chunk_local_ebo_first_u32[mi] = current_idx_count;
|
||||
current_bytes += mesh_bytes;
|
||||
current_idx_count += mesh.index_count;
|
||||
}
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -619,6 +641,8 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id,
|
||||
c.is_resident = false; // streaming
|
||||
c.vertex_byte_offset = plan.source_byte_offset;
|
||||
c.vertex_byte_size = plan.byte_count;
|
||||
c.index_first_u32 = plan.index_first_u32;
|
||||
c.index_count = plan.index_count;
|
||||
|
||||
// Small per-chunk buffers, allocated upfront so cull can write into
|
||||
// them. visible_draws_buffer cap = chunk's instance count (worst-
|
||||
@@ -654,31 +678,10 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id,
|
||||
c.prefix_sums_scratch.reserve(chunk_inst + 1);
|
||||
}
|
||||
|
||||
// ---- Model-shared buffers: indices, mesh quant, instances ----------
|
||||
// Stage-1 streaming still loads these upfront — they're small relative
|
||||
// to vertex data on real scenes (index ≈ 1/2 of vertex, mesh + instance
|
||||
// <1%). Stage 2 can defer indices per chunk if needed.
|
||||
//
|
||||
// Index buffer requires reading the index section from disk now since
|
||||
// metadata-only skipped it. Use readSidecarIndexChunk for the full range.
|
||||
std::vector<uint32_t> indices_full;
|
||||
if (metadata.index_total_count > 0
|
||||
&& !readSidecarIndexChunk(metadata.file_path,
|
||||
metadata.index_section_offset,
|
||||
/*chunk_first_index=*/0,
|
||||
metadata.index_total_count,
|
||||
indices_full)) {
|
||||
qWarning().noquote() << "Failed to read index section for streaming load:"
|
||||
<< QString::fromStdString(metadata.file_path);
|
||||
return;
|
||||
}
|
||||
const size_t index_bytes = indices_full.size() * sizeof(uint32_t);
|
||||
m.index_buffer = createBufferWithData(
|
||||
device_, queue_,
|
||||
indices_full.data(), index_bytes,
|
||||
WGPUBufferUsage_Storage | WGPUBufferUsage_Index,
|
||||
"model.index_buffer");
|
||||
m.vram_bytes_ebo += index_bytes;
|
||||
// Index section is NOT loaded upfront. Each chunk's index slice will
|
||||
// be range-read alongside its vertex bytes in loadChunkBytesAndUploadGpu.
|
||||
// Eliminates the 1.5+ GB upfront index VRAM cost that was the binding
|
||||
// OOM constraint on real scenes.
|
||||
|
||||
// MeshGpu storage (per-mesh quant basis).
|
||||
std::vector<MeshGpu> mesh_gpu;
|
||||
@@ -814,20 +817,31 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
|
||||
// chunk-local base_vertex indexes correctly.
|
||||
m.mesh_chunk_idx.assign(data.meshes.size(), 0);
|
||||
m.mesh_chunk_local_base_vertex.assign(data.meshes.size(), 0);
|
||||
m.mesh_chunk_local_ebo_first_u32.assign(data.meshes.size(), 0);
|
||||
|
||||
struct ChunkPlan {
|
||||
size_t source_byte_offset = 0; // where in data.vertices this chunk's slice starts
|
||||
size_t byte_count = 0; // bytes in this chunk
|
||||
uint32_t vertex_count = 0; // vertex_count = byte_count / 12
|
||||
uint32_t index_first_u32 = 0; // chunk's first LOD0 index in data.indices
|
||||
uint32_t index_count = 0; // LOD0 indices belonging to this chunk
|
||||
};
|
||||
std::vector<ChunkPlan> chunk_plans;
|
||||
size_t current_chunk_bytes = 0;
|
||||
uint32_t current_chunk_idx = 0;
|
||||
size_t current_chunk_start = 0; // byte offset in data.vertices for current chunk's start
|
||||
uint32_t current_idx_start = 0;
|
||||
uint32_t current_idx_count = 0;
|
||||
bool warned_lod1 = false;
|
||||
|
||||
chunk_plans.push_back({}); // chunk 0
|
||||
for (uint32_t mi = 0; mi < data.meshes.size(); ++mi) {
|
||||
const MeshInfo& mesh = data.meshes[mi];
|
||||
if (!warned_lod1 && mesh.lod1_index_count > 0) {
|
||||
qWarning() << "[wgpu] LOD1 indices present but per-chunk index buffers "
|
||||
"only carry LOD0; LOD1 will be ignored this load.";
|
||||
warned_lod1 = true;
|
||||
}
|
||||
const size_t mesh_vertex_bytes = size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
|
||||
if (mesh_vertex_bytes > WGPU_CHUNK_VERTEX_BYTES_LIMIT) {
|
||||
qWarning().noquote().nospace()
|
||||
@@ -838,10 +852,6 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
|
||||
" in an oversize chunk that won't fit a web browser.";
|
||||
}
|
||||
|
||||
// Sanity: mesh.vbo_byte_offset is the GLOBAL byte offset in
|
||||
// data.vertices. We use it for chunk bookkeeping; if a sidecar's
|
||||
// meshes aren't sorted by vbo_byte_offset things go subtly wrong,
|
||||
// but every baker produces them ascending.
|
||||
if (current_chunk_bytes > 0
|
||||
&& current_chunk_bytes + mesh_vertex_bytes > WGPU_CHUNK_VERTEX_BYTES_LIMIT) {
|
||||
// Finalise current chunk, start a new one.
|
||||
@@ -849,18 +859,25 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
|
||||
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_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));
|
||||
}
|
||||
|
||||
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_idx_count += mesh.index_count;
|
||||
}
|
||||
// Finalise the last (possibly first) chunk.
|
||||
{
|
||||
@@ -868,6 +885,8 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
|
||||
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();
|
||||
@@ -884,12 +903,14 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Allocate per-chunk buffers + upload vertex slices --------------
|
||||
// ---- Allocate per-chunk buffers + upload vertex + index slices -----
|
||||
m.chunks.resize(chunk_plans.size());
|
||||
for (size_t ci = 0; ci < chunk_plans.size(); ++ci) {
|
||||
const ChunkPlan& plan = chunk_plans[ci];
|
||||
WgpuModelGpuData::Chunk& c = m.chunks[ci];
|
||||
c.vertex_count = plan.vertex_count;
|
||||
c.vertex_count = plan.vertex_count;
|
||||
c.index_first_u32 = plan.index_first_u32;
|
||||
c.index_count = plan.index_count;
|
||||
|
||||
const char* vs_label = (ci == 0) ? "model.chunk0.vertex_storage"
|
||||
: "model.chunkN.vertex_storage";
|
||||
@@ -900,17 +921,20 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
|
||||
WGPUBufferUsage_Storage,
|
||||
vs_label);
|
||||
m.vram_bytes_vbo += plan.byte_count;
|
||||
}
|
||||
|
||||
// Index buffer — single, shared across chunks. Mesh-local u32 indices.
|
||||
// Bound as storage in the vertex shader for manual fetch.
|
||||
const size_t index_bytes = data.indices.size() * sizeof(uint32_t);
|
||||
m.index_buffer = createBufferWithData(
|
||||
device_, queue_,
|
||||
data.indices.data(), index_bytes,
|
||||
WGPUBufferUsage_Storage | WGPUBufferUsage_Index,
|
||||
"model.index_buffer");
|
||||
m.vram_bytes_ebo += index_bytes;
|
||||
// Per-chunk index slice. base mesh's ebo_byte_offset gives us the
|
||||
// chunk's start; chunk's index_count tells us how far the slice runs.
|
||||
const size_t chunk_index_bytes = size_t(plan.index_count) * sizeof(uint32_t);
|
||||
if (chunk_index_bytes > 0) {
|
||||
c.index_buffer = createBufferWithData(
|
||||
device_, queue_,
|
||||
data.indices.data() + plan.index_first_u32,
|
||||
chunk_index_bytes,
|
||||
WGPUBufferUsage_Storage | WGPUBufferUsage_Index,
|
||||
"model.chunk.index_buffer");
|
||||
m.vram_bytes_ebo += chunk_index_bytes;
|
||||
}
|
||||
}
|
||||
|
||||
// Derive MeshGpu[] (vec4 aabb_min + vec4 aabb_max) from MeshInfo's
|
||||
// local_aabb_*. Mirrors the GL backend's mesh_info_ssbo population.
|
||||
@@ -2414,22 +2438,30 @@ 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 is CHUNK-LOCAL; the chunk's bind group
|
||||
// points at its own vertex_storage so the shader indexes correctly.
|
||||
// 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.
|
||||
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 = (use_lod1 ? mesh.lod1_ebo_byte_offset : mesh.ebo_byte_offset)
|
||||
/ uint32_t(sizeof(uint32_t));
|
||||
d.ebo_first_u32 = 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 = use_lod1 ? mesh.lod1_index_count
|
||||
: mesh.index_count;
|
||||
const uint32_t entry_vert_count = effective_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]);
|
||||
};
|
||||
@@ -3121,7 +3153,7 @@ void WgpuViewportWindow::uploadSelectionFlagsIfDirty() {
|
||||
}
|
||||
|
||||
void WgpuViewportWindow::buildModelBindGroup(WgpuModelGpuData& m) {
|
||||
if (!m.mesh_storage || !m.instance_storage || !m.index_buffer) {
|
||||
if (!m.mesh_storage || !m.instance_storage) {
|
||||
// Empty model — no chunks, no bind groups; the draw loop will skip.
|
||||
return;
|
||||
}
|
||||
@@ -3137,9 +3169,9 @@ void WgpuViewportWindow::buildChunkBindGroup(WgpuModelGpuData& m, size_t chunk_i
|
||||
wgpuBindGroupRelease(c.bind_group);
|
||||
c.bind_group = nullptr;
|
||||
}
|
||||
if (!c.vertex_storage || !c.visible_draws_buffer
|
||||
if (!c.vertex_storage || !c.index_buffer || !c.visible_draws_buffer
|
||||
|| !c.prefix_sums_buffer || !c.per_chunk_uniform
|
||||
|| !m.mesh_storage || !m.instance_storage || !m.index_buffer) {
|
||||
|| !m.mesh_storage || !m.instance_storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3154,7 +3186,7 @@ void WgpuViewportWindow::buildChunkBindGroup(WgpuModelGpuData& m, size_t chunk_i
|
||||
entries[2].buffer = m.instance_storage;
|
||||
entries[2].size = WGPU_WHOLE_SIZE;
|
||||
entries[3].binding = 3;
|
||||
entries[3].buffer = m.index_buffer;
|
||||
entries[3].buffer = c.index_buffer;
|
||||
entries[3].size = WGPU_WHOLE_SIZE;
|
||||
entries[4].binding = 4;
|
||||
entries[4].buffer = c.visible_draws_buffer;
|
||||
@@ -3180,12 +3212,13 @@ bool WgpuViewportWindow::loadChunkBytesAndUploadGpu(WgpuModelGpuData& m, size_t
|
||||
if (c.is_resident) return true;
|
||||
if (m.streaming_file_path.empty()) return false;
|
||||
|
||||
std::vector<uint8_t> bytes;
|
||||
// Vertex slice.
|
||||
std::vector<uint8_t> vbytes;
|
||||
if (!readSidecarVertexChunk(m.streaming_file_path,
|
||||
m.streaming_vertex_section_offset,
|
||||
c.vertex_byte_offset,
|
||||
c.vertex_byte_size,
|
||||
bytes)) {
|
||||
vbytes)) {
|
||||
qWarning().noquote().nospace()
|
||||
<< "[wgpu stream] failed to read vertex chunk " << chunk_idx
|
||||
<< " from " << QString::fromStdString(m.streaming_file_path)
|
||||
@@ -3193,28 +3226,181 @@ bool WgpuViewportWindow::loadChunkBytesAndUploadGpu(WgpuModelGpuData& m, size_t
|
||||
<< " size=" << c.vertex_byte_size << ")";
|
||||
return false;
|
||||
}
|
||||
|
||||
c.vertex_storage = createBufferWithData(
|
||||
device_, queue_,
|
||||
bytes.data(), bytes.size(),
|
||||
vbytes.data(), vbytes.size(),
|
||||
WGPUBufferUsage_Storage,
|
||||
"model.chunk.vertex_storage_streamed");
|
||||
if (!c.vertex_storage) return false;
|
||||
m.vram_bytes_vbo += vbytes.size();
|
||||
streaming_vram_resident_bytes_ += vbytes.size();
|
||||
|
||||
// Index slice. The byte-range read comes from
|
||||
// streaming_index_section_offset + index_first_u32 * 4 (set by
|
||||
// applyCachedModelStreaming alongside the vertex offset).
|
||||
if (c.index_count > 0) {
|
||||
std::vector<uint32_t> idx;
|
||||
if (!readSidecarIndexChunk(m.streaming_file_path,
|
||||
m.streaming_index_section_offset,
|
||||
c.index_first_u32,
|
||||
c.index_count,
|
||||
idx)) {
|
||||
qWarning().noquote().nospace()
|
||||
<< "[wgpu stream] failed to read index chunk " << chunk_idx
|
||||
<< " (first=" << c.index_first_u32
|
||||
<< " count=" << c.index_count << ")";
|
||||
// Release the vertex buffer we just allocated so we don't leak.
|
||||
wgpuBufferRelease(c.vertex_storage);
|
||||
c.vertex_storage = nullptr;
|
||||
m.vram_bytes_vbo -= vbytes.size();
|
||||
streaming_vram_resident_bytes_ -= vbytes.size();
|
||||
return false;
|
||||
}
|
||||
const size_t ibytes = idx.size() * sizeof(uint32_t);
|
||||
c.index_buffer = createBufferWithData(
|
||||
device_, queue_,
|
||||
idx.data(), ibytes,
|
||||
WGPUBufferUsage_Storage | WGPUBufferUsage_Index,
|
||||
"model.chunk.index_buffer_streamed");
|
||||
m.vram_bytes_ebo += ibytes;
|
||||
streaming_vram_resident_bytes_ += ibytes;
|
||||
}
|
||||
|
||||
m.vram_bytes_vbo += bytes.size();
|
||||
buildChunkBindGroup(m, chunk_idx);
|
||||
c.is_resident = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void WgpuViewportWindow::unloadChunk(WgpuModelGpuData& m, size_t chunk_idx) {
|
||||
if (chunk_idx >= m.chunks.size()) return;
|
||||
auto& c = m.chunks[chunk_idx];
|
||||
if (!c.is_resident) return;
|
||||
|
||||
if (c.bind_group) {
|
||||
wgpuBindGroupRelease(c.bind_group);
|
||||
c.bind_group = nullptr;
|
||||
}
|
||||
if (c.vertex_storage) {
|
||||
const uint64_t vsz = c.vertex_byte_size;
|
||||
wgpuBufferRelease(c.vertex_storage);
|
||||
c.vertex_storage = nullptr;
|
||||
m.vram_bytes_vbo -= vsz;
|
||||
streaming_vram_resident_bytes_ -= vsz;
|
||||
}
|
||||
if (c.index_buffer) {
|
||||
const uint64_t isz = c.index_count * sizeof(uint32_t);
|
||||
wgpuBufferRelease(c.index_buffer);
|
||||
c.index_buffer = nullptr;
|
||||
m.vram_bytes_ebo -= isz;
|
||||
streaming_vram_resident_bytes_ -= isz;
|
||||
}
|
||||
// Clear per-frame visibility so the chunk doesn't get re-rendered or
|
||||
// re-evicted on the same frame; cull will set it again next time
|
||||
// the chunk falls in the frustum.
|
||||
c.total_visible_draws = 0;
|
||||
c.total_visible_vertices = 0;
|
||||
c.is_resident = false;
|
||||
}
|
||||
|
||||
void WgpuViewportWindow::driveStreamingLoads() {
|
||||
// Per-frame budget. Caps first-frame stall on a fresh load — at 4
|
||||
// chunks/frame × 60fps we ingest 240 chunks/sec, fast enough that
|
||||
// a 100-model scene fully resides in ~1s. Eviction-aware policies
|
||||
// can tune this later.
|
||||
// Bump LRU clock once per call. Resident-and-visible chunks get
|
||||
// stamped with this value below; the evictor uses it to find the
|
||||
// least-recently-visible non-visible resident chunk.
|
||||
++streaming_frame_idx_;
|
||||
|
||||
// Refresh LRU stamps for every resident chunk the cull just touched.
|
||||
// Doing this before the load loop means newly-loaded chunks (which
|
||||
// get stamped inside the load path) and already-resident-visible
|
||||
// chunks share a single coherent timeline.
|
||||
for (auto& [mid, m] : models_gpu_) {
|
||||
if (m.hidden) continue;
|
||||
for (auto& c : m.chunks) {
|
||||
if (c.is_resident && c.total_visible_draws > 0) {
|
||||
c.last_visible_frame_idx = streaming_frame_idx_;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// World-space camera eye. Used to rank chunks by distance for the
|
||||
// distance-fallback evictor — when LRU has no non-visible victims
|
||||
// left, drop the visible chunk farthest from the camera so the
|
||||
// closest visible chunks always stay resident under a tight budget.
|
||||
const QVector3D eye = orbitEye(camera_target_, camera_distance_,
|
||||
camera_yaw_deg_, camera_pitch_deg_);
|
||||
|
||||
auto chunk_center_dist2 = [&](const WgpuModelGpuData::Chunk& c) -> float {
|
||||
const float cx = 0.5f * (c.aabb_min[0] + c.aabb_max[0]);
|
||||
const float cy = 0.5f * (c.aabb_min[1] + c.aabb_max[1]);
|
||||
const float cz = 0.5f * (c.aabb_min[2] + c.aabb_max[2]);
|
||||
const float dx = cx - eye.x();
|
||||
const float dy = cy - eye.y();
|
||||
const float dz = cz - eye.z();
|
||||
return dx*dx + dy*dy + dz*dz;
|
||||
};
|
||||
|
||||
// Per-frame load budget. Caps first-frame stall on a fresh load — at
|
||||
// 4 chunks/frame × 60fps we ingest 240 chunks/sec, fast enough that
|
||||
// a 100-model scene fully resides in ~1s. Bigger scenes are bounded
|
||||
// by the VRAM budget below: the residency set never grows past
|
||||
// streaming_vram_budget_bytes_, so we stay clear of the wgpu-native
|
||||
// allocator's OOM ceiling (~2.4 GB on this box, default budget 1.9).
|
||||
constexpr int MAX_STREAMING_LOADS_PER_FRAME = 4;
|
||||
int loads = 0;
|
||||
bool more_pending = false;
|
||||
|
||||
// Phase-1 evictor: drop the LRU non-visible resident chunk. Skips
|
||||
// chunks stamped on streaming_frame_idx_ to avoid yanking what cull
|
||||
// just marked visible. Returns bytes freed (0 ⇒ no candidate).
|
||||
auto evict_one_lru = [&]() -> uint64_t {
|
||||
WgpuModelGpuData* victim_m = nullptr;
|
||||
size_t victim_ci = 0;
|
||||
uint64_t victim_lru = std::numeric_limits<uint64_t>::max();
|
||||
for (auto& [mid, m] : models_gpu_) {
|
||||
for (size_t ci = 0; ci < m.chunks.size(); ++ci) {
|
||||
auto& c = m.chunks[ci];
|
||||
if (!c.is_resident) continue;
|
||||
if (c.last_visible_frame_idx == streaming_frame_idx_) continue;
|
||||
if (c.last_visible_frame_idx < victim_lru) {
|
||||
victim_lru = c.last_visible_frame_idx;
|
||||
victim_m = &m;
|
||||
victim_ci = ci;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!victim_m) return 0;
|
||||
const uint64_t before = streaming_vram_resident_bytes_;
|
||||
unloadChunk(*victim_m, victim_ci);
|
||||
return before - streaming_vram_resident_bytes_;
|
||||
};
|
||||
|
||||
// Phase-2 evictor: when every resident chunk is visible-this-frame
|
||||
// but we still need room for a closer one, drop the farthest-from-
|
||||
// eye visible chunk — provided it is farther than the candidate we
|
||||
// want to load. Without the distance check this would loop forever
|
||||
// swapping pairs; with it, residency monotonically converges to the
|
||||
// closest visible chunks that fit the budget.
|
||||
auto evict_farthest_than = [&](float candidate_dist2) -> uint64_t {
|
||||
WgpuModelGpuData* victim_m = nullptr;
|
||||
size_t victim_ci = 0;
|
||||
float victim_dist2 = candidate_dist2;
|
||||
for (auto& [mid, m] : models_gpu_) {
|
||||
for (size_t ci = 0; ci < m.chunks.size(); ++ci) {
|
||||
auto& c = m.chunks[ci];
|
||||
if (!c.is_resident) continue;
|
||||
const float d2 = chunk_center_dist2(c);
|
||||
if (d2 > victim_dist2) {
|
||||
victim_dist2 = d2;
|
||||
victim_m = &m;
|
||||
victim_ci = ci;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!victim_m) return 0;
|
||||
const uint64_t before = streaming_vram_resident_bytes_;
|
||||
unloadChunk(*victim_m, victim_ci);
|
||||
return before - streaming_vram_resident_bytes_;
|
||||
};
|
||||
|
||||
for (auto& [mid, m] : models_gpu_) {
|
||||
if (m.streaming_file_path.empty() || m.hidden) continue;
|
||||
for (size_t ci = 0; ci < m.chunks.size(); ++ci) {
|
||||
@@ -3227,13 +3413,38 @@ void WgpuViewportWindow::driveStreamingLoads() {
|
||||
more_pending = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Make room. Phase 1: drop LRU non-visible. Phase 2: if still
|
||||
// over budget, drop the farthest-from-eye visible chunk that
|
||||
// is strictly farther than the candidate we want to load.
|
||||
const uint64_t need = c.vertex_byte_size
|
||||
+ c.index_count * sizeof(uint32_t);
|
||||
while (streaming_vram_resident_bytes_ + need
|
||||
> streaming_vram_budget_bytes_) {
|
||||
if (evict_one_lru() > 0) continue;
|
||||
if (evict_farthest_than(chunk_center_dist2(c)) > 0) continue;
|
||||
break; // nothing left we're willing to evict
|
||||
}
|
||||
if (streaming_vram_resident_bytes_ + need
|
||||
> streaming_vram_budget_bytes_) {
|
||||
// Candidate is farther than every resident — skip it.
|
||||
// We'll come back to it if it gets closer.
|
||||
more_pending = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (loadChunkBytesAndUploadGpu(m, ci)) {
|
||||
++loads;
|
||||
// Stamp the just-loaded chunk so eviction this frame
|
||||
// can't immediately yank it back out.
|
||||
c.last_visible_frame_idx = streaming_frame_idx_;
|
||||
}
|
||||
}
|
||||
if (more_pending) break;
|
||||
if (more_pending && loads >= MAX_STREAMING_LOADS_PER_FRAME) break;
|
||||
}
|
||||
// Keep the frame loop running until all visible chunks are resident.
|
||||
// Keep the frame loop running until all visible chunks are resident
|
||||
// (or the budget definitively prevents that, in which case residency
|
||||
// converges to the closest visible chunks that fit).
|
||||
if (more_pending) requestUpdate();
|
||||
}
|
||||
|
||||
|
||||
@@ -133,9 +133,15 @@ private:
|
||||
// vertex_storage, build the chunk's bind group, flip is_resident=true.
|
||||
// Returns true on success. No-op (returns true) when already resident.
|
||||
bool loadChunkBytesAndUploadGpu(WgpuModelGpuData& m, size_t chunk_idx);
|
||||
// Release a resident chunk's vertex+index buffers and bind group;
|
||||
// flip is_resident=false. The chunk's CPU metadata (offsets, AABB,
|
||||
// visible-draw scratch) is retained so a subsequent
|
||||
// loadChunkBytesAndUploadGpu can bring it back without re-planning.
|
||||
void unloadChunk(WgpuModelGpuData& m, size_t chunk_idx);
|
||||
// Called from render() after cull: find non-resident chunks with
|
||||
// current visible draw counts > 0 and bring them resident, up to a
|
||||
// per-frame budget. Triggers requestUpdate() if more remain.
|
||||
// per-frame budget. Triggers requestUpdate() if more remain. Evicts
|
||||
// LRU non-visible chunks first when over streaming_vram_budget_bytes_.
|
||||
void driveStreamingLoads();
|
||||
void ensureDepthTexture(int w, int h);
|
||||
void releaseDepthTexture();
|
||||
@@ -393,6 +399,31 @@ public:
|
||||
// preserved; --streaming opts in.
|
||||
bool streaming_enabled_ = false;
|
||||
|
||||
// Streaming residency budget (bytes). The per-frame loader will not
|
||||
// bring a chunk resident if doing so would exceed this; instead it
|
||||
// evicts the LRU non-visible chunks until headroom exists, then
|
||||
// falls back to evicting the farthest-from-camera visible chunks
|
||||
// when even visible-set residency would overshoot.
|
||||
//
|
||||
// STOPGAP: this is a hand-picked number — the wrong shape of fix.
|
||||
// wgpu-native's per-allocation overhead (gpu-alloc-rs fragmentation +
|
||||
// one VkDeviceMemory block per createBuffer) makes the true usable
|
||||
// ceiling far below physical VRAM, by a margin that varies per
|
||||
// GPU/driver/runtime. The proper fix is a single-pool buffer with
|
||||
// sub-allocation (see WgpuBufferPool task), whose size is *probed*
|
||||
// at startup via wgpuDevicePushErrorScope rather than guessed at
|
||||
// compile time. Once the pool lands, this field disappears.
|
||||
//
|
||||
// For now: 1 GB is a portable-ish floor that won't OOM on any
|
||||
// desktop GPU we care about, and is at least close to the web's
|
||||
// common ceiling. Tune via --streaming-vram-mb on machines with
|
||||
// more headroom.
|
||||
uint64_t streaming_vram_budget_bytes_ = 1024ull * 1024 * 1024;
|
||||
uint64_t streaming_vram_resident_bytes_ = 0;
|
||||
// Monotonic frame counter, bumped at the top of driveStreamingLoads.
|
||||
// Used as the LRU key for chunk eviction.
|
||||
uint64_t streaming_frame_idx_ = 0;
|
||||
|
||||
private:
|
||||
|
||||
// Switch to LOD1 when an instance's projected bounding-sphere radius
|
||||
|
||||
Reference in New Issue
Block a user