ifcviewer: move chunk residency helpers (buildChunkBindGroup + applyStreamedChunk + loadChunkBytesAndUploadGpu + unloadChunk + makeChunkRequest) into ViewportCore (#84-n)

The chunk-state machine that mediates between the streaming pool and the
per-chunk WGPU bind groups now lives in ViewportCore. ViewportWindow's
remaining streaming code (driveStreamingLoads, finalizeModel) calls
through to core_.applyStreamedChunk / core_.unloadChunk /
core_.loadChunkBytesAndUploadGpu, and the chunk request builder is a
static helper on ViewportCore so VW's still-here driveStreamingLoads can
enqueue requests against streaming_thread_ without reimplementing it.

streaming_frame_idx_ moved to core (alongside the residency clock),
aliased on VW so the inline streaming logic stays compiling. The
mesh-volume side effect inside applyStreamedChunk now fires a
std::function<void()> callback (core_.on_volume_dirty_) instead of
reaching into ViewportWindow::updateVolumeReadout — VW wires the
callback in its ctor, non-Qt hosts leave it null and pay nothing.

computeMeshLocalVolumeQuantised moved to ViewportCore.cpp's anonymous
namespace; it was only called by applyStreamedChunk.
This commit is contained in:
Dion Moult
2026-06-06 16:33:31 +10:00
parent ea24851a51
commit 077080b318
4 changed files with 408 additions and 368 deletions
+325
View File
@@ -1405,3 +1405,328 @@ void ViewportCore::shutdown() {
wgpu_initialized_ = false;
surface_configured_ = false;
}
// ===========================================================================
// Chunk residency (#84-n): buildChunkBindGroup + applyStreamedChunk +
// loadChunkBytesAndUploadGpu + unloadChunk + makeChunkRequest +
// computeMeshLocalVolumeQuantised
// ===========================================================================
#include "StreamingLoader.h"
namespace {
// Pure function of the mesh's local AABB + index list. Computes the
// signed-tetrahedra-volume sum (divergence theorem), takes its absolute
// value, divides by 6 — gives the mesh-local volume in m³. Side effect:
// if `out_tris` is non-null, populates it with dequantised positions +
// index copy so the Area / Volume tool can later refine to a single
// triangle. Stays as a free helper because applyStreamedChunk is the
// only call site.
double computeMeshLocalVolumeQuantised(
const MeshInfo& mesh,
const std::uint8_t* vbase, const std::uint32_t* ibase,
std::uint32_t n_indices,
ModelGpuData::MeshTriangles* out_tris) {
if (n_indices < 3 || vbase == nullptr || ibase == nullptr) return 0.0;
const float ax = mesh.local_aabb_min[0];
const float ay = mesh.local_aabb_min[1];
const float az = mesh.local_aabb_min[2];
const float ex = mesh.local_aabb_max[0] - ax;
const float ey = mesh.local_aabb_max[1] - ay;
const float ez = mesh.local_aabb_max[2] - az;
const float inv_q = 1.0f / 65535.0f;
std::vector<float> positions;
positions.resize(std::size_t(mesh.vertex_count) * 3);
for (std::uint32_t v = 0; v < mesh.vertex_count; ++v) {
const std::uint8_t* p = vbase
+ std::size_t(v) * INSTANCED_VERTEX_STRIDE_BYTES;
std::uint16_t qx, qy, qz;
std::memcpy(&qx, p + 0, 2);
std::memcpy(&qy, p + 2, 2);
std::memcpy(&qz, p + 4, 2);
positions[3 * v + 0] = ax + float(qx) * inv_q * ex;
positions[3 * v + 1] = ay + float(qy) * inv_q * ey;
positions[3 * v + 2] = az + float(qz) * inv_q * ez;
}
double sum = 0.0;
for (std::uint32_t i = 0; i + 2 < n_indices; i += 3) {
const std::uint32_t i0 = ibase[i + 0];
const std::uint32_t i1 = ibase[i + 1];
const std::uint32_t i2 = ibase[i + 2];
if (i0 >= mesh.vertex_count || i1 >= mesh.vertex_count
|| i2 >= mesh.vertex_count) continue;
const float* p0 = &positions[3 * i0];
const float* p1 = &positions[3 * i1];
const float* p2 = &positions[3 * i2];
const double cx = double(p1[1]) * p2[2] - double(p1[2]) * p2[1];
const double cy = double(p1[2]) * p2[0] - double(p1[0]) * p2[2];
const double cz = double(p1[0]) * p2[1] - double(p1[1]) * p2[0];
sum += double(p0[0]) * cx + double(p0[1]) * cy + double(p0[2]) * cz;
}
if (out_tris) {
out_tris->positions = std::move(positions);
out_tris->indices.assign(ibase, ibase + n_indices);
}
return std::abs(sum) / 6.0;
}
} // namespace
void ViewportCore::buildChunkBindGroup(ModelGpuData& m, std::size_t chunk_idx) {
if (chunk_idx >= m.chunks.size()) return;
auto& c = m.chunks[chunk_idx];
if (c.bind_group) {
wgpuBindGroupRelease(c.bind_group);
c.bind_group = nullptr;
}
if (!c.vertex_slice.valid() || !c.index_slice.valid()
|| !c.visible_draws_buffer || !c.prefix_sums_buffer || !c.per_chunk_uniform
|| !m.mesh_storage || !m.instance_storage) {
return;
}
WGPUBindGroupEntry entries[7] = {};
// vertices and indices live in the shared pool. Each slice carries
// the specific sub-buffer it landed in (the pool may span several
// when scenes exceed wgpu's single-buffer cap). The other entries
// are still per-chunk small buffers (visible_draws/prefix_sums/uniform)
// or per-model (mesh/instance).
entries[0].binding = 0;
entries[0].buffer = c.vertex_slice.buffer;
entries[0].offset = c.vertex_slice.offset;
entries[0].size = c.vertex_slice.size;
entries[1].binding = 1;
entries[1].buffer = m.mesh_storage;
entries[1].size = WGPU_WHOLE_SIZE;
entries[2].binding = 2;
entries[2].buffer = m.instance_storage;
entries[2].size = WGPU_WHOLE_SIZE;
entries[3].binding = 3;
entries[3].buffer = c.index_slice.buffer;
entries[3].offset = c.index_slice.offset;
entries[3].size = c.index_slice.size;
entries[4].binding = 4;
entries[4].buffer = c.visible_draws_buffer;
entries[4].size = WGPU_WHOLE_SIZE;
entries[5].binding = 5;
entries[5].buffer = c.prefix_sums_buffer;
entries[5].size = WGPU_WHOLE_SIZE;
entries[6].binding = 6;
entries[6].buffer = c.per_chunk_uniform;
entries[6].size = 16;
WGPUBindGroupDescriptor desc = {};
desc.layout = model_bgl_;
desc.entryCount = 7;
desc.entries = entries;
desc.label = svFromCStr("ifcviewer-wgpu.chunk_bind_group");
c.bind_group = wgpuDeviceCreateBindGroup(device_, &desc);
}
bool ViewportCore::applyStreamedChunk(
ModelGpuData& m, std::size_t chunk_idx,
const std::vector<std::uint8_t>& vbytes,
const std::vector<std::uint32_t>& idx) {
auto& c = m.chunks[chunk_idx];
c.vertex_slice = pool_.alloc(vbytes.size(), 256);
if (!c.vertex_slice.valid()) return false;
wgpuQueueWriteBuffer(queue_, c.vertex_slice.buffer,
c.vertex_slice.offset,
vbytes.data(), vbytes.size());
m.vram_bytes_vbo += vbytes.size();
if (!idx.empty()) {
const std::size_t ibytes = idx.size() * sizeof(std::uint32_t);
c.index_slice = pool_.alloc(ibytes, 256);
if (!c.index_slice.valid()) {
pool_.free(c.vertex_slice);
m.vram_bytes_vbo -= c.vertex_slice.size;
c.vertex_slice = {};
return false;
}
wgpuQueueWriteBuffer(queue_, c.index_slice.buffer,
c.index_slice.offset,
idx.data(), ibytes);
m.vram_bytes_ebo += ibytes;
}
buildChunkBindGroup(m, chunk_idx);
c.is_resident = true;
c.is_loading = false;
c.loaded_frame_idx = streaming_frame_idx_;
// Per-mesh alpha probe. Scan every vertex of every mesh in this chunk
// for any alpha byte < 255 — fires the mesh_has_alpha flag the cull
// classifier reads to route instances of this mesh to the transparent
// pass. Done here (vs. once at sidecar bake time) because for the
// streaming path the bytes only arrive now; the same code services
// both the worker-result drain and the sync first-frame fallback.
if (m.mesh_has_alpha.size() == m.meshes.size()) {
for (std::uint32_t mi : c.mesh_ids) {
if (mi >= m.meshes.size()) continue;
const MeshInfo& mesh = m.meshes[mi];
if (mesh.vertex_count == 0) continue;
const std::size_t v_off =
std::size_t(m.mesh_chunk_local_base_vertex[mi])
* INSTANCED_VERTEX_STRIDE_BYTES;
const std::size_t v_end = v_off
+ std::size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (v_end > vbytes.size()) continue;
bool any_alpha = false;
// Alpha byte sits in the high byte of the vertex's 3rd u32
// (shader: `w2 >> 24`), i.e. offset 11 within the 12-byte
// vertex record. See InstancedGeometry.h's vertex layout.
for (std::uint32_t v = 0; v < mesh.vertex_count && !any_alpha; ++v) {
const std::size_t a_off = v_off
+ std::size_t(v) * INSTANCED_VERTEX_STRIDE_BYTES + 11;
if (vbytes[a_off] < 255u) any_alpha = true;
}
m.mesh_has_alpha[mi] = any_alpha
? std::uint8_t(1) : std::uint8_t(0);
}
}
// Mesh-local volumes for the meshes in this chunk. applyCachedModel
// left them zero because the bytes weren't in memory yet; the first
// chunk to deliver each mesh fills it in.
bool filled_volume = false;
if (!m.mesh_local_volumes.empty() && !idx.empty()) {
for (std::uint32_t mi : c.mesh_ids) {
if (mi >= m.meshes.size() || mi >= m.mesh_local_volumes.size()) continue;
if (m.mesh_local_volumes[mi] != 0.0) continue;
const MeshInfo& mesh = m.meshes[mi];
if (mesh.vertex_count == 0 || mesh.index_count < 3) continue;
const std::size_t v_off =
std::size_t(m.mesh_chunk_local_base_vertex[mi])
* INSTANCED_VERTEX_STRIDE_BYTES;
const std::size_t i_off = m.mesh_chunk_local_ebo_first_u32[mi];
const std::size_t v_end = v_off
+ std::size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (v_end > vbytes.size()) continue;
if (i_off + mesh.index_count > idx.size()) continue;
ModelGpuData::MeshTriangles* tris =
(mi < m.mesh_triangles_cache.size())
? &m.mesh_triangles_cache[mi]
: nullptr;
m.mesh_local_volumes[mi] = computeMeshLocalVolumeQuantised(
mesh, vbytes.data() + v_off, idx.data() + i_off, mesh.index_count,
tris);
filled_volume = true;
}
}
// Fire the tool-refresh callback once per apply if anything new filled
// in. ViewportWindow wires this to its Volume-tool HUD; non-Qt hosts
// leave the callback null (no-op).
if (filled_volume && on_volume_dirty_) on_volume_dirty_();
return true;
}
StreamingThread::Request ViewportCore::makeChunkRequest(
const ModelGpuData& m, std::size_t chunk_idx,
std::uint32_t model_id) {
const auto& c = m.chunks[chunk_idx];
StreamingThread::Request req;
req.model_id = model_id;
req.chunk_idx = chunk_idx;
req.file_path = m.streaming_file_path;
req.vertex_section_offset = m.streaming_vertex_section_offset;
req.index_section_offset = m.streaming_index_section_offset;
req.v_ranges.reserve(c.mesh_ids.size());
req.i_ranges.reserve(c.mesh_ids.size());
for (std::uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = m.meshes[mi];
const std::uint64_t v_bytes =
std::uint64_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (v_bytes > 0) {
req.v_ranges.emplace_back(std::uint64_t(mesh.vbo_byte_offset), v_bytes);
}
if (mesh.index_count > 0) {
req.i_ranges.emplace_back(
std::uint64_t(mesh.ebo_byte_offset / sizeof(std::uint32_t)),
std::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 (std::uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = m.meshes[mi];
if (mesh.lod1_index_count == 0) continue;
req.i_ranges.emplace_back(
std::uint64_t(mesh.lod1_ebo_byte_offset / sizeof(std::uint32_t)),
std::uint64_t(mesh.lod1_index_count));
}
return req;
}
bool ViewportCore::loadChunkBytesAndUploadGpu(ModelGpuData& m,
std::size_t chunk_idx) {
if (chunk_idx >= m.chunks.size()) return false;
auto& c = m.chunks[chunk_idx];
if (c.is_resident) return true;
if (m.streaming_file_path.empty()) return false;
// Synchronous fallback: build the request, do the disk read inline,
// apply. Used only when the async path can't be — i.e. by the
// screenshot test on first frame.
StreamingThread::Request req = makeChunkRequest(m, chunk_idx, /*model_id*/ 0);
std::vector<std::uint8_t> vbytes;
std::vector<std::uint32_t> idx;
if (!req.v_ranges.empty()) {
if (!readSidecarVertexRanges(req.file_path,
req.vertex_section_offset,
req.v_ranges, vbytes)) {
Log::warn() << "[wgpu stream] failed to read vertex chunk "
<< chunk_idx
<< " (" << req.v_ranges.size() << " ranges, total "
<< c.vertex_byte_size << " B)";
return false;
}
}
if (!req.i_ranges.empty()) {
if (!readSidecarIndexRanges(req.file_path,
req.index_section_offset,
req.i_ranges, idx)) {
Log::warn() << "[wgpu stream] failed to read index chunk "
<< chunk_idx
<< " (" << req.i_ranges.size() << " ranges, total "
<< c.index_count << " indices)";
return false;
}
}
return applyStreamedChunk(m, chunk_idx, vbytes, idx);
}
void ViewportCore::unloadChunk(ModelGpuData& m, std::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_slice.valid()) {
m.vram_bytes_vbo -= c.vertex_slice.size;
pool_.free(c.vertex_slice);
c.vertex_slice = {};
}
if (c.index_slice.valid()) {
m.vram_bytes_ebo -= c.index_slice.size;
pool_.free(c.index_slice);
c.index_slice = {};
}
// 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;
}
+54
View File
@@ -38,6 +38,7 @@
#include <Eigen/Dense>
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_map>
#include <utility>
@@ -252,6 +253,46 @@ public:
bool initWgpu(bool web_limits);
void shutdown();
// ---- Chunk residency (#84-n) ------------------------------------------
//
// Build the per-chunk WGPUBindGroup over its current pool slices +
// shared model storage. Idempotent — releases any previous bind group
// first. Called after a slice's bytes have been written via
// applyStreamedChunk, and from buildModelBindGroup for every chunk
// at model-load time.
void buildChunkBindGroup(ModelGpuData& m, std::size_t chunk_idx);
// Pool-allocate vertex + index slices for the chunk, queueWriteBuffer
// the bytes, build the bind group, flip is_resident=true. Returns
// false on pool OOM (caller should have made room first); on
// failure, no slices are claimed and is_resident stays false.
// Called from the worker-result drain (async) and from
// loadChunkBytesAndUploadGpu (sync first-frame fallback). Fires
// on_volume_dirty_ when this chunk filled in any mesh-local volume
// so consumers (the Volume tool's HUD) can refresh.
bool applyStreamedChunk(ModelGpuData& m, std::size_t chunk_idx,
const std::vector<std::uint8_t>& vbytes,
const std::vector<std::uint32_t>& idx);
// Synchronous-fallback path: read this chunk's byte ranges from
// the sidecar file directly (no worker thread) and apply. Used by
// the screenshot test on first frame, and any caller that needs a
// chunk resident inside the same call (no deferred-state to
// manage). Returns true on success.
bool loadChunkBytesAndUploadGpu(ModelGpuData& m, std::size_t chunk_idx);
// Release the chunk's pool slices + bind group, clear residency.
// No-op if !c.is_resident.
void unloadChunk(ModelGpuData& m, std::size_t chunk_idx);
// Build the worker request for a chunk. Walks the chunk's mesh_ids
// and derives scatter-gather byte/index ranges from each mesh's
// sidecar offsets. Pure function of model + chunk metadata; safe to
// call from the main thread. Static — also used by the still-in-VW
// driveStreamingLoads to enqueue against streaming_thread_.
static StreamingThread::Request makeChunkRequest(
const ModelGpuData& m, std::size_t chunk_idx, std::uint32_t model_id);
private:
bool probeAndCreatePool();
@@ -370,6 +411,19 @@ private:
// are unambiguous across models.
uint32_t next_object_id_ = 1;
// Monotonic streaming-residency clock. Bumped at the top of
// driveStreamingLoads; applyStreamedChunk stamps loaded_frame_idx
// with it for the new-chunk grace period; the LRU evictor reads
// last_visible_frame_idx against it. Lifetime matches models_gpu_
// (resets only at shutdown).
std::uint64_t streaming_frame_idx_ = 0;
// Tool-refresh callback: fired by applyStreamedChunk when a newly-
// arrived chunk filled in a mesh-local volume. ViewportWindow wires
// this to its Volume-tool HUD refresh in the ctor. Null by default
// (no-op) so headless and non-Qt hosts pay nothing.
std::function<void()> on_volume_dirty_;
// Federation false origin (metres, double precision). Applied to
// every instance composition so geometry rebased through a large
// model offset doesn't lose float32 precision near the GPU origin.
+22 -345
View File
@@ -82,16 +82,8 @@ static constexpr uint64_t WGPU_BYTES_PER_ROW_ALIGN = 256;
static Eigen::Vector3f orbitEye(const float target[3], float dist,
float yaw_deg, float pitch_deg);
// Forward declaration — defined alongside the Volume tool. Called from
// both applyCachedModel (full load) and applyStreamedChunk (per-chunk
// fill in streaming mode) so the same quantised-bytes path runs in both.
// Also writes the dequantised positions + index copy into `out_tris`
// so the Area tool's CPU shadow is built in the same pass — the loop
// already touches every vertex, so the marginal cost is one memcpy.
static double computeMeshLocalVolumeQuantised(
const MeshInfo& mesh,
const uint8_t* vbase, const uint32_t* ibase, uint32_t n_indices,
ModelGpuData::MeshTriangles* out_tris);
// computeMeshLocalVolumeQuantised moved to ViewportCore.cpp anon
// namespace (#84-n).
// Ray-AABB (slab) + ray-triangle (Möller-Trumbore). Used by raycast()
// AND by pickMeshLocalAt to refine the AABB-coarse surface hit into a
@@ -250,6 +242,7 @@ ViewportWindow::ViewportWindow(QWindow* parent)
pick_pipeline_(core_.pick_pipeline_),
pool_ (core_.pool_),
streaming_thread_(core_.streaming_thread_),
streaming_frame_idx_(core_.streaming_frame_idx_),
models_gpu_ (core_.models_gpu_),
next_model_id_ (core_.next_model_id_),
next_object_id_ (core_.next_object_id_),
@@ -298,6 +291,12 @@ ViewportWindow::ViewportWindow(QWindow* parent)
// we fully own, and wgpu-native can do its lifetime gymnastics
// without stepping on Qt's bookkeeping.
setSurfaceType(QSurface::OpenGLSurface);
// Tool-refresh callback. Fires from core_'s applyStreamedChunk when a
// freshly-arrived chunk filled in a mesh-local volume. The Volume
// tool's HUD is the only consumer today; updateVolumeReadout is a
// cheap no-op outside Volume mode.
core_.on_volume_dirty_ = [this]() { updateVolumeReadout(); };
}
ViewportWindow::~ViewportWindow() {
@@ -994,7 +993,7 @@ void ViewportWindow::finalizeModel(uint32_t model_id) {
// no-op walk; left here so the layout stays parallel to the
// sidecar gather.
if (!applyStreamedChunk(m, ci, vbytes, idx)) {
if (!core_.applyStreamedChunk(m, ci, vbytes, idx)) {
Log::warn().nospace()
<< "[wgpu direct] finalizeModel(" << model_id
<< "): applyStreamedChunk failed on chunk " << ci
@@ -2791,69 +2790,7 @@ static double det3OfPlacement(const double M[16]) {
+ m02 * (m10 * m21 - m11 * m20);
}
// Local-frame volume of a mesh from its raw quantised vertex+index bytes.
// `vbase` points at the first vertex (12 B/vertex, 3×uint16 pos quantised
// against mesh.local_aabb), `ibase` at the first u32 index in mesh-local
// numbering, `n_indices` is the LOD0 index count. Signed-tetrahedra-
// from-origin → |sum|/6 so winding doesn't matter. Same algorithm as
// Bonsai's meshLocalVolume; takes the dequant step from
// INSTANCED_VERTEX_STRIDE_BYTES layout.
//
// When `out_tris` is non-null, dequantised positions + the LOD0 index
// copy are written into it for the Area tool's CPU shadow. Avoids a
// second pass over every vertex.
static double computeMeshLocalVolumeQuantised(
const MeshInfo& mesh,
const uint8_t* vbase, const uint32_t* ibase, uint32_t n_indices,
ModelGpuData::MeshTriangles* out_tris) {
if (n_indices < 3 || vbase == nullptr || ibase == nullptr) return 0.0;
const float ax = mesh.local_aabb_min[0];
const float ay = mesh.local_aabb_min[1];
const float az = mesh.local_aabb_min[2];
const float ex = mesh.local_aabb_max[0] - ax;
const float ey = mesh.local_aabb_max[1] - ay;
const float ez = mesh.local_aabb_max[2] - az;
const float inv_q = 1.0f / 65535.0f;
// Eager-dequant every vertex once into a stack-allocated scratch
// (small per-mesh — bounded by mesh.vertex_count, typically tens
// to thousands). The Area shadow needs the same floats, so writing
// to scratch + memcpying out is cheaper than dequantising twice.
std::vector<float> positions;
positions.resize(size_t(mesh.vertex_count) * 3);
for (uint32_t v = 0; v < mesh.vertex_count; ++v) {
const uint8_t* p = vbase + size_t(v) * INSTANCED_VERTEX_STRIDE_BYTES;
uint16_t qx, qy, qz;
std::memcpy(&qx, p + 0, 2);
std::memcpy(&qy, p + 2, 2);
std::memcpy(&qz, p + 4, 2);
positions[3 * v + 0] = ax + float(qx) * inv_q * ex;
positions[3 * v + 1] = ay + float(qy) * inv_q * ey;
positions[3 * v + 2] = az + float(qz) * inv_q * ez;
}
double sum = 0.0;
for (uint32_t i = 0; i + 2 < n_indices; i += 3) {
const uint32_t i0 = ibase[i + 0];
const uint32_t i1 = ibase[i + 1];
const uint32_t i2 = ibase[i + 2];
if (i0 >= mesh.vertex_count || i1 >= mesh.vertex_count
|| i2 >= mesh.vertex_count) continue;
const float* p0 = &positions[3 * i0];
const float* p1 = &positions[3 * i1];
const float* p2 = &positions[3 * i2];
const double cx = double(p1[1]) * p2[2] - double(p1[2]) * p2[1];
const double cy = double(p1[2]) * p2[0] - double(p1[0]) * p2[2];
const double cz = double(p1[0]) * p2[1] - double(p1[1]) * p2[0];
sum += double(p0[0]) * cx + double(p0[1]) * cy + double(p0[2]) * cz;
}
if (out_tris) {
out_tris->positions = std::move(positions);
out_tris->indices.assign(ibase, ibase + n_indices);
}
return std::abs(sum) / 6.0;
}
// computeMeshLocalVolumeQuantised moved to ViewportCore.cpp anon namespace (#84-n).
void ViewportWindow::toggleAreaTool() {
setToolMode(tool_mode_ == ToolMode::Area ? ToolMode::NoTool : ToolMode::Area);
@@ -4951,279 +4888,19 @@ void ViewportWindow::buildModelBindGroup(ModelGpuData& m) {
return;
}
for (size_t ci = 0; ci < m.chunks.size(); ++ci) {
buildChunkBindGroup(m, ci);
core_.buildChunkBindGroup(m, ci);
}
}
void ViewportWindow::buildChunkBindGroup(ModelGpuData& m, size_t chunk_idx) {
if (chunk_idx >= m.chunks.size()) return;
auto& c = m.chunks[chunk_idx];
if (c.bind_group) {
wgpuBindGroupRelease(c.bind_group);
c.bind_group = nullptr;
}
if (!c.vertex_slice.valid() || !c.index_slice.valid()
|| !c.visible_draws_buffer || !c.prefix_sums_buffer || !c.per_chunk_uniform
|| !m.mesh_storage || !m.instance_storage) {
return;
}
// buildChunkBindGroup moved to ViewportCore (#84-n).
WGPUBindGroupEntry entries[7] = {};
// vertices and indices live in the shared pool. Each slice carries
// the specific sub-buffer it landed in (the pool may span several
// when scenes exceed wgpu's single-buffer cap). The other entries
// are still per-chunk small buffers (visible_draws/prefix_sums/uniform)
// or per-model (mesh/instance).
entries[0].binding = 0;
entries[0].buffer = c.vertex_slice.buffer;
entries[0].offset = c.vertex_slice.offset;
entries[0].size = c.vertex_slice.size;
entries[1].binding = 1;
entries[1].buffer = m.mesh_storage;
entries[1].size = WGPU_WHOLE_SIZE;
entries[2].binding = 2;
entries[2].buffer = m.instance_storage;
entries[2].size = WGPU_WHOLE_SIZE;
entries[3].binding = 3;
entries[3].buffer = c.index_slice.buffer;
entries[3].offset = c.index_slice.offset;
entries[3].size = c.index_slice.size;
entries[4].binding = 4;
entries[4].buffer = c.visible_draws_buffer;
entries[4].size = WGPU_WHOLE_SIZE;
entries[5].binding = 5;
entries[5].buffer = c.prefix_sums_buffer;
entries[5].size = WGPU_WHOLE_SIZE;
entries[6].binding = 6;
entries[6].buffer = c.per_chunk_uniform;
entries[6].size = 16;
// makeChunkRequest moved to ViewportCore (folded into loadChunkBytesAndUploadGpu) (#84-n).
WGPUBindGroupDescriptor desc = {};
desc.layout = model_bgl_;
desc.entryCount = 7;
desc.entries = entries;
desc.label = svFromCStr("ifcviewer-wgpu.chunk_bind_group");
c.bind_group = wgpuDeviceCreateBindGroup(device_, &desc);
}
// applyStreamedChunk moved to ViewportCore (#84-n).
// Build the worker request for a chunk. Walks the chunk's mesh_ids and
// derives scatter-gather byte/index ranges from each mesh's sidecar
// offsets. Pure function of model + chunk metadata; safe to call from
// the main thread.
static StreamingThread::Request makeChunkRequest(
const ModelGpuData& m, size_t chunk_idx, uint32_t model_id) {
const auto& c = m.chunks[chunk_idx];
StreamingThread::Request req;
req.model_id = model_id;
req.chunk_idx = chunk_idx;
req.file_path = m.streaming_file_path;
req.vertex_section_offset = m.streaming_vertex_section_offset;
req.index_section_offset = m.streaming_index_section_offset;
req.v_ranges.reserve(c.mesh_ids.size());
req.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) {
req.v_ranges.emplace_back(uint64_t(mesh.vbo_byte_offset), v_bytes);
}
if (mesh.index_count > 0) {
req.i_ranges.emplace_back(
uint64_t(mesh.ebo_byte_offset / sizeof(uint32_t)),
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;
}
// loadChunkBytesAndUploadGpu moved to ViewportCore (#84-n).
// Apply a streamed chunk's bytes to the GPU: pool-allocate vertex +
// index slices, queueWriteBuffer the bytes, build the bind group, flip
// is_resident=true. Returns false on pool OOM (caller should have made
// room first); on failure, no slices are claimed and is_resident
// stays false. Called both from the worker-result drain (async) and
// from loadChunkBytesAndUploadGpu (sync first-frame fallback).
bool ViewportWindow::applyStreamedChunk(
ModelGpuData& m, size_t chunk_idx,
const std::vector<uint8_t>& vbytes,
const std::vector<uint32_t>& idx) {
auto& c = m.chunks[chunk_idx];
c.vertex_slice = pool_.alloc(vbytes.size(), 256);
if (!c.vertex_slice.valid()) return false;
wgpuQueueWriteBuffer(queue_, c.vertex_slice.buffer,
c.vertex_slice.offset,
vbytes.data(), vbytes.size());
m.vram_bytes_vbo += vbytes.size();
if (!idx.empty()) {
const size_t ibytes = idx.size() * sizeof(uint32_t);
c.index_slice = pool_.alloc(ibytes, 256);
if (!c.index_slice.valid()) {
pool_.free(c.vertex_slice);
m.vram_bytes_vbo -= c.vertex_slice.size;
c.vertex_slice = {};
return false;
}
wgpuQueueWriteBuffer(queue_, c.index_slice.buffer,
c.index_slice.offset,
idx.data(), ibytes);
m.vram_bytes_ebo += ibytes;
}
buildChunkBindGroup(m, chunk_idx);
c.is_resident = true;
c.is_loading = false;
c.loaded_frame_idx = streaming_frame_idx_;
// Per-mesh alpha probe. Scan every vertex of every mesh in this chunk
// for any alpha byte < 255 — fires the mesh_has_alpha flag the cull
// classifier reads to route instances of this mesh to the transparent
// pass. Done here (vs. once at sidecar bake time) because for the
// streaming path the bytes only arrive now; the same code services
// both the worker-result drain and the sync first-frame fallback.
// O(verts-in-chunk) — typically a few k per chunk, dominated by the
// queueWriteBuffer above. Spatial-bucket re-entry for the same mesh
// from a different chunk overwrites — alpha is a per-mesh property
// so a redundant assign is correct; cheap.
if (m.mesh_has_alpha.size() == m.meshes.size()) {
for (uint32_t mi : c.mesh_ids) {
if (mi >= m.meshes.size()) continue;
const MeshInfo& mesh = m.meshes[mi];
if (mesh.vertex_count == 0) continue;
const size_t v_off = size_t(m.mesh_chunk_local_base_vertex[mi])
* INSTANCED_VERTEX_STRIDE_BYTES;
const size_t v_end = v_off
+ size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (v_end > vbytes.size()) continue;
bool any_alpha = false;
// Alpha byte sits in the high byte of the vertex's 3rd u32
// (shader: `w2 >> 24`), i.e. offset 11 within the 12-byte
// vertex record. See InstancedGeometry.h's vertex layout
// comment.
for (uint32_t v = 0; v < mesh.vertex_count && !any_alpha; ++v) {
const size_t a_off = v_off
+ size_t(v) * INSTANCED_VERTEX_STRIDE_BYTES + 11;
if (vbytes[a_off] < 255u) any_alpha = true;
}
m.mesh_has_alpha[mi] = any_alpha ? uint8_t(1) : uint8_t(0);
}
}
// Mesh-local volumes for the meshes in this chunk. applyCachedModel
// left them zero because the bytes weren't in memory yet; the first
// chunk to deliver each mesh fills it in. Spatial-bucket mode may
// re-enter for the same mesh from a different chunk — the != 0 guard
// skips the redundant work. Indices are mesh-local (numbered against
// the mesh's own vertex range), so vbase + ibase are per-mesh slices
// into the chunk's freshly-arrived bytes.
bool filled_volume = false;
if (!m.mesh_local_volumes.empty() && !idx.empty()) {
for (uint32_t mi : c.mesh_ids) {
if (mi >= m.meshes.size() || mi >= m.mesh_local_volumes.size()) continue;
if (m.mesh_local_volumes[mi] != 0.0) continue;
const MeshInfo& mesh = m.meshes[mi];
if (mesh.vertex_count == 0 || mesh.index_count < 3) continue;
const size_t v_off = size_t(m.mesh_chunk_local_base_vertex[mi])
* INSTANCED_VERTEX_STRIDE_BYTES;
const size_t i_off = m.mesh_chunk_local_ebo_first_u32[mi];
const size_t v_end = v_off
+ size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (v_end > vbytes.size()) continue;
if (i_off + mesh.index_count > idx.size()) continue;
ModelGpuData::MeshTriangles* tris =
(mi < m.mesh_triangles_cache.size())
? &m.mesh_triangles_cache[mi]
: nullptr;
m.mesh_local_volumes[mi] = computeMeshLocalVolumeQuantised(
mesh, vbytes.data() + v_off, idx.data() + i_off, mesh.index_count,
tris);
filled_volume = true;
}
}
// If the user is staring at a Volume readout while chunks page in,
// refresh as soon as a chunk delivers a mesh we just filled — they'd
// otherwise see 0 m³ for the whole selection until they click again.
if (filled_volume && tool_mode_ == ToolMode::Volume) {
updateVolumeReadout();
}
return true;
}
bool ViewportWindow::loadChunkBytesAndUploadGpu(ModelGpuData& m, size_t chunk_idx) {
if (chunk_idx >= m.chunks.size()) return false;
auto& c = m.chunks[chunk_idx];
if (c.is_resident) return true;
if (m.streaming_file_path.empty()) return false;
// Synchronous fallback: build the request, do the disk read inline,
// apply. Used only when the async path can't be — i.e. by the
// screenshot test on first frame. Normal streaming goes through
// driveStreamingLoads → streaming_thread_.
StreamingThread::Request req = makeChunkRequest(m, chunk_idx, /*mid*/ 0);
std::vector<uint8_t> vbytes;
std::vector<uint32_t> idx;
if (!req.v_ranges.empty()) {
if (!readSidecarVertexRanges(req.file_path,
req.vertex_section_offset,
req.v_ranges, vbytes)) {
Log::warn().noquote().nospace()
<< "[wgpu stream] failed to read vertex chunk " << chunk_idx
<< " (" << req.v_ranges.size() << " ranges, total "
<< c.vertex_byte_size << " B)";
return false;
}
}
if (!req.i_ranges.empty()) {
if (!readSidecarIndexRanges(req.file_path,
req.index_section_offset,
req.i_ranges, idx)) {
Log::warn().noquote().nospace()
<< "[wgpu stream] failed to read index chunk " << chunk_idx
<< " (" << req.i_ranges.size() << " ranges, total "
<< c.index_count << " indices)";
return false;
}
}
return applyStreamedChunk(m, chunk_idx, vbytes, idx);
}
void ViewportWindow::unloadChunk(ModelGpuData& 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_slice.valid()) {
m.vram_bytes_vbo -= c.vertex_slice.size;
pool_.free(c.vertex_slice);
c.vertex_slice = {};
}
if (c.index_slice.valid()) {
m.vram_bytes_ebo -= c.index_slice.size;
pool_.free(c.index_slice);
c.index_slice = {};
}
// 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;
}
// unloadChunk moved to ViewportCore (#84-n).
void ViewportWindow::driveStreamingLoads() {
// Bump LRU clock once per call. Resident-and-visible chunks get
@@ -5350,7 +5027,7 @@ void ViewportWindow::driveStreamingLoads() {
}
}
if (!victim_m) return false;
unloadChunk(*victim_m, victim_ci);
core_.unloadChunk(*victim_m, victim_ci);
++streaming_evictions_lru_this_frame_;
return true;
};
@@ -5423,7 +5100,7 @@ void ViewportWindow::driveStreamingLoads() {
victim.last_evicted_by_priority = cand_priority;
victim.last_evicted_frame_idx = streaming_frame_idx_;
unloadChunk(*victim_m, victim_ci);
core_.unloadChunk(*victim_m, victim_ci);
++streaming_evictions_pri_this_frame_;
return true;
};
@@ -5461,7 +5138,7 @@ void ViewportWindow::driveStreamingLoads() {
<< res.model_id << " chunk " << res.chunk_idx;
continue;
}
if (!applyStreamedChunk(m, res.chunk_idx, res.vbytes, res.idx)) {
if (!core_.applyStreamedChunk(m, res.chunk_idx, res.vbytes, res.idx)) {
// Pool OOM at apply time — pool fragmented further
// between enqueue and worker-result. Set the same
// cooldown as the enqueue-time block: we just paid for
@@ -5590,14 +5267,14 @@ void ViewportWindow::driveStreamingLoads() {
// wait, capturing at the wrong size. With sync loads the chunk
// appears in the same frame we enqueue, no deferred-state to manage.
if (!pending_screenshot_path_.empty()) {
if (loadChunkBytesAndUploadGpu(*cand.m, cand.ci)) {
if (core_.loadChunkBytesAndUploadGpu(*cand.m, cand.ci)) {
++enqueued;
c.last_visible_frame_idx = streaming_frame_idx_;
}
continue;
}
if (streaming_thread_.enqueue(makeChunkRequest(*cand.m, cand.ci, cand.mid))) {
if (streaming_thread_.enqueue(ViewportCore::makeChunkRequest(*cand.m, cand.ci, cand.mid))) {
c.is_loading = true;
++enqueued;
}
+7 -23
View File
@@ -270,26 +270,9 @@ private:
bool buildPipelines();
void buildModelBindGroup(ModelGpuData& m);
void buildChunkBindGroup(ModelGpuData& m, size_t chunk_idx);
// Streaming: read the chunk's vertex + index bytes from disk,
// sub-allocate ranges in pool_, queueWriteBuffer them in, build the
// chunk's bind group, flip is_resident=true. Returns true on success;
// false if either the disk read or a pool alloc fails (caller is
// expected to have already evicted enough). No-op (returns true)
// when already resident.
bool loadChunkBytesAndUploadGpu(ModelGpuData& m, size_t chunk_idx);
// Pool-allocate + queueWriteBuffer + build bind group for a chunk
// whose vbytes/idx have already been read (by either the worker
// thread's drained result or the sync fallback). Returns false on
// pool OOM. Toggles is_resident=true / is_loading=false on success.
bool applyStreamedChunk(ModelGpuData& m, size_t chunk_idx,
const std::vector<uint8_t>& vbytes,
const std::vector<uint32_t>& idx);
// Release a resident chunk's pool ranges + 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(ModelGpuData& m, size_t chunk_idx);
// buildChunkBindGroup / applyStreamedChunk / loadChunkBytesAndUploadGpu
// / unloadChunk moved to ViewportCore (#84-n). Call them via core_.
//
// Called from render() after cull: find non-resident chunks with
// current visible draw counts > 0 and bring them resident. When the
// pool is full, evicts LRU non-visible chunks first, then falls back
@@ -946,9 +929,10 @@ public:
// scene fits through the constraints a browser will impose.
bool web_limits_ = false;
// Monotonic frame counter, bumped at the top of driveStreamingLoads.
// Used as the LRU key for chunk eviction.
uint64_t streaming_frame_idx_ = 0;
// Monotonic frame counter alias (storage in core_). Bumped at the
// top of driveStreamingLoads; used as the LRU key for chunk
// eviction.
uint64_t& streaming_frame_idx_;
// Sub-allocator for all chunk vertex + index bytes. Sized at startup
// by probeAndCreatePool() — the runtime tells us how big a single