diff --git a/src/ifcviewer-wgpu/WgpuModelGpuData.h b/src/ifcviewer-wgpu/WgpuModelGpuData.h index ef8a270d0f..03dd33e224 100644 --- a/src/ifcviewer-wgpu/WgpuModelGpuData.h +++ b/src/ifcviewer-wgpu/WgpuModelGpuData.h @@ -122,6 +122,13 @@ struct WgpuModelGpuData { // and flips true once the chunk's vertex bytes are uploaded. // Render and pick skip chunks where !is_resident. bool is_resident = true; + // Set true while a worker-thread read is in flight for this + // chunk. Prevents driveStreamingLoads from re-enqueueing it + // every frame until its result is drained. Cleared when the + // result is applied (or dropped on failure / stale model). + // Eviction is not gated on this (eviction only acts on resident + // chunks; a loading chunk has no slice to free yet). + bool is_loading = false; // Aggregate vertex / index sizes across all meshes in this chunk // (sum of mesh.vertex_count * stride / mesh.index_count for each diff --git a/src/ifcviewer-wgpu/WgpuStreamingThread.cpp b/src/ifcviewer-wgpu/WgpuStreamingThread.cpp new file mode 100644 index 0000000000..765a07f31f --- /dev/null +++ b/src/ifcviewer-wgpu/WgpuStreamingThread.cpp @@ -0,0 +1,122 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#include "WgpuStreamingThread.h" + +#include "WgpuStreamingLoader.h" + +WgpuStreamingThread::~WgpuStreamingThread() { + stop(); +} + +void WgpuStreamingThread::start() { + std::unique_lock lk(mu_); + if (running_) return; + shutdown_ = false; + running_ = true; + lk.unlock(); + worker_ = std::thread(&WgpuStreamingThread::workerLoop, this); +} + +void WgpuStreamingThread::stop() { + { + std::unique_lock lk(mu_); + if (!running_) return; + shutdown_ = true; + } + cv_.notify_all(); + if (worker_.joinable()) worker_.join(); + std::unique_lock lk(mu_); + running_ = false; + requests_.clear(); + results_.clear(); +} + +bool WgpuStreamingThread::enqueue(Request req) { + { + std::unique_lock lk(mu_); + if (!running_ || shutdown_) return false; + requests_.push_back(std::move(req)); + } + cv_.notify_one(); + return true; +} + +std::vector WgpuStreamingThread::drainResults() { + std::vector out; + { + std::unique_lock lk(mu_); + out.reserve(results_.size()); + while (!results_.empty()) { + out.push_back(std::move(results_.front())); + results_.pop_front(); + } + } + return out; +} + +std::size_t WgpuStreamingThread::inFlightApprox() const { + std::unique_lock lk(mu_); + return requests_.size() + (in_progress_ ? 1u : 0u); +} + +void WgpuStreamingThread::workerLoop() { + for (;;) { + Request req; + { + std::unique_lock lk(mu_); + cv_.wait(lk, [this]() { return shutdown_ || !requests_.empty(); }); + if (shutdown_ && requests_.empty()) return; + req = std::move(requests_.front()); + requests_.pop_front(); + in_progress_ = true; + } + + // Disk reads happen off-thread. Each Request carries everything + // the reader needs; the viewport keeps the corresponding chunk + // marked is_loading so eviction won't yank the slot underneath + // us. The vbytes / idx buffers are allocated here on the worker + // thread — they cross back to the main thread when the result + // is drained and applied (pool.alloc + queueWriteBuffer). + Result res; + res.model_id = req.model_id; + res.chunk_idx = req.chunk_idx; + res.success = true; + if (!req.v_ranges.empty()) { + if (!readSidecarVertexRanges(req.file_path, + req.vertex_section_offset, + req.v_ranges, res.vbytes)) { + res.success = false; + } + } + if (res.success && !req.i_ranges.empty()) { + if (!readSidecarIndexRanges(req.file_path, + req.index_section_offset, + req.i_ranges, res.idx)) { + res.success = false; + } + } + + { + std::unique_lock lk(mu_); + results_.push_back(std::move(res)); + in_progress_ = false; + } + } +} diff --git a/src/ifcviewer-wgpu/WgpuStreamingThread.h b/src/ifcviewer-wgpu/WgpuStreamingThread.h new file mode 100644 index 0000000000..98fb6a91c8 --- /dev/null +++ b/src/ifcviewer-wgpu/WgpuStreamingThread.h @@ -0,0 +1,100 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#ifndef WGPUSTREAMINGTHREAD_H +#define WGPUSTREAMINGTHREAD_H + +#include +#include +#include +#include +#include +#include +#include +#include + +// Worker thread for scatter-gather chunk reads. Decouples disk I/O +// (~tens of ms per chunk on SSD, hundreds on slower media) from the +// render thread. The viewport's per-frame driveStreamingLoads enqueues +// requests for non-resident-frustum-visible chunks, drains any +// completed Results on subsequent frames, and only performs the +// GPU-side (pool.alloc + queueWriteBuffer + bind-group build) work +// on the main thread — wgpu queue ops aren't thread-safe. +// +// Lifetime: start() spawns the worker; stop() signals shutdown and +// joins. The Result destructor releases its byte vectors back to the +// heap, so dropping unclaimed Results (e.g. when their model was +// unloaded mid-flight) is a free operation. +class WgpuStreamingThread { +public: + struct Request { + uint32_t model_id; + std::size_t chunk_idx; + std::string file_path; + uint64_t vertex_section_offset; + uint64_t index_section_offset; + // (section-relative byte_offset, byte_size) + std::vector> v_ranges; + // (first_u32, count_u32) + std::vector> i_ranges; + }; + + struct Result { + uint32_t model_id; + std::size_t chunk_idx; + bool success; + std::vector vbytes; + std::vector idx; + }; + + ~WgpuStreamingThread(); + + // Spawn the worker thread. Safe to call once; subsequent calls are + // no-ops while the worker is alive. + void start(); + // Signal shutdown, wake the worker, join. Idempotent. Must be + // called before the WgpuBufferPool the results would upload into + // is destroyed. + void stop(); + + // Enqueue a request. Returns false if the worker has stopped. + bool enqueue(Request req); + // Move all completed results out of the result queue. Always + // non-blocking; if nothing is ready, returns an empty vector. + std::vector drainResults(); + + // Approximate count of requests still in flight (in queue or + // currently being processed). Useful for the bench warm gate to + // know when streaming has truly settled. + std::size_t inFlightApprox() const; + +private: + void workerLoop(); + + std::thread worker_; + mutable std::mutex mu_; + std::condition_variable cv_; + std::deque requests_; + std::deque results_; + bool in_progress_ = false; + bool shutdown_ = false; + bool running_ = false; +}; + +#endif // WGPUSTREAMINGTHREAD_H diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp index 946dc2ba22..38e23b25e6 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp @@ -1302,6 +1302,11 @@ bool WgpuViewportWindow::initWgpu() { return false; } + // Background loader for streaming reads — must outlive any + // applyCachedModelStreaming call so we can drain results into the + // pool. Stopped in shutdown() before pool_.destroy(). + streaming_thread_.start(); + // ---- Pick a surface format ------------------------------------------- WGPUSurfaceCapabilities caps = {}; if (wgpuSurfaceGetCapabilities(surface_, adapter_, &caps) != WGPUStatus_Success @@ -3018,7 +3023,13 @@ void WgpuViewportWindow::render() { if (!bench_warm_done_) { constexpr int CONVERGE_FRAMES_REQUIRED = 5; constexpr int MAX_WARM_FRAMES = 600; - if (streaming_loads_this_frame_ > 0) { + // With async I/O, "no main-thread work this frame" isn't + // enough — a worker thread might still be reading. The + // streaming is truly settled only when the worker queue is + // empty AND no chunks are awaiting drain. + const bool worker_idle = + streaming_thread_.inFlightApprox() == 0; + if (streaming_loads_this_frame_ > 0 || !worker_idle) { bench_warm_streak_ = 0; } else { ++bench_warm_streak_; @@ -3383,69 +3394,56 @@ void WgpuViewportWindow::buildChunkBindGroup(WgpuModelGpuData& m, size_t chunk_i c.bind_group = wgpuDeviceCreateBindGroup(device_, &desc); } -bool WgpuViewportWindow::loadChunkBytesAndUploadGpu(WgpuModelGpuData& 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; - - // 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> v_ranges; - std::vector> i_ranges; - v_ranges.reserve(c.mesh_ids.size()); - i_ranges.reserve(c.mesh_ids.size()); +// 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 WgpuStreamingThread::Request makeChunkRequest( + const WgpuModelGpuData& m, size_t chunk_idx, uint32_t model_id) { + const auto& c = m.chunks[chunk_idx]; + WgpuStreamingThread::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) v_ranges.emplace_back(uint64_t(mesh.vbo_byte_offset), v_bytes); + if (v_bytes > 0) { + req.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)); + req.i_ranges.emplace_back( + uint64_t(mesh.ebo_byte_offset / sizeof(uint32_t)), + uint64_t(mesh.index_count)); } } + return req; +} + +// 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 WgpuViewportWindow::applyStreamedChunk( + WgpuModelGpuData& m, size_t chunk_idx, + const std::vector& vbytes, + const std::vector& idx) { + auto& c = m.chunks[chunk_idx]; - std::vector vbytes; - if (!readSidecarVertexRanges(m.streaming_file_path, - m.streaming_vertex_section_offset, - v_ranges, vbytes)) { - qWarning().noquote().nospace() - << "[wgpu stream] failed to read vertex chunk " << chunk_idx - << " (" << v_ranges.size() << " ranges, total " - << c.vertex_byte_size << " B)"; - return false; - } - // Claim a pool range for the vertex bytes and upload. c.vertex_slice = pool_.alloc(vbytes.size(), 256); - if (!c.vertex_slice.valid()) { - // No room — caller (driveStreamingLoads) should have evicted - // first. This branch is a safety net for the very-first-frame - // case where pool eviction may not have caught up. - return false; - } + 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(); - // Index slice — scatter-gather from the same mesh_ids list. - if (c.index_count > 0) { - std::vector idx; - if (!readSidecarIndexRanges(m.streaming_file_path, - m.streaming_index_section_offset, - i_ranges, idx)) { - qWarning().noquote().nospace() - << "[wgpu stream] failed to read index chunk " << chunk_idx - << " (" << i_ranges.size() << " ranges, total " - << c.index_count << " indices)"; - // Return the vertex slice to the pool so we don't leak. - pool_.free(c.vertex_slice); - m.vram_bytes_vbo -= c.vertex_slice.size; - c.vertex_slice = {}; - return false; - } + if (!idx.empty()) { const size_t ibytes = idx.size() * sizeof(uint32_t); c.index_slice = pool_.alloc(ibytes, 256); if (!c.index_slice.valid()) { @@ -3462,9 +3460,48 @@ bool WgpuViewportWindow::loadChunkBytesAndUploadGpu(WgpuModelGpuData& m, size_t buildChunkBindGroup(m, chunk_idx); c.is_resident = true; + c.is_loading = false; return true; } +bool WgpuViewportWindow::loadChunkBytesAndUploadGpu(WgpuModelGpuData& 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_. + WgpuStreamingThread::Request req = makeChunkRequest(m, chunk_idx, /*mid*/ 0); + std::vector vbytes; + std::vector idx; + if (!req.v_ranges.empty()) { + if (!readSidecarVertexRanges(req.file_path, + req.vertex_section_offset, + req.v_ranges, vbytes)) { + qWarning().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)) { + qWarning().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 WgpuViewportWindow::unloadChunk(WgpuModelGpuData& m, size_t chunk_idx) { if (chunk_idx >= m.chunks.size()) return; auto& c = m.chunks[chunk_idx]; @@ -3619,16 +3656,48 @@ void WgpuViewportWindow::driveStreamingLoads() { return true; }; - // Gather candidates: every non-resident frustum-visible chunk. Sort - // by distance (closest first) so processing converges monotonically — - // each successful swap replaces a far resident with a closer - // candidate, and when the next candidate is farther than every - // remaining resident, we stop. Without sorting, the load loop - // visits candidates in arbitrary (model/chunk-id) order, which - // creates an infinite swap cycle on scenes where the frustum-visible - // set exceeds pool capacity: each frame loads 4 random candidates - // and evicts 4 random residents, getting nowhere. - struct Candidate { WgpuModelGpuData* m; size_t ci; float dist2; }; + // ---- Drain worker results ------------------------------------------- + // Apply any chunk reads that the streaming thread finished since + // last frame. Each apply does pool.alloc + queueWriteBuffer + bind + // group build — strictly main-thread work because wgpu queue ops + // are not thread-safe. Counts toward loads_this_frame for the + // bench warm gate's "settled" check. + { + auto results = streaming_thread_.drainResults(); + for (auto& res : results) { + auto it = models_gpu_.find(res.model_id); + if (it == models_gpu_.end()) continue; // model unloaded + auto& m = it->second; + if (res.chunk_idx >= m.chunks.size()) continue; + auto& c = m.chunks[res.chunk_idx]; + // The chunk may have been "unloaded" mid-flight (it wasn't + // resident yet — eviction only acts on residents — but the + // loader could have re-enqueued or the model could have + // been hidden). Clear the loading flag regardless. + c.is_loading = false; + if (!res.success) { + qWarning().noquote().nospace() + << "[wgpu stream] worker read failed for model " + << res.model_id << " chunk " << res.chunk_idx; + continue; + } + if (!applyStreamedChunk(m, res.chunk_idx, res.vbytes, res.idx)) { + // Pool OOM at apply time — eviction had freed less than + // we needed by the time the result returned. Next frame's + // loader will re-enqueue if still wanted. + continue; + } + ++loads; + c.last_visible_frame_idx = streaming_frame_idx_; + } + } + + // ---- Enqueue new requests ------------------------------------------- + // Gather non-resident, !is_loading, frustum-visible chunks; sort by + // distance (closest first) so processing converges monotonically. + // Each enqueue makes room in the pool by eviction so the result will + // be likely to fit when it returns — apply's alloc is best-effort. + struct Candidate { WgpuModelGpuData* m; size_t ci; uint32_t mid; float dist2; }; std::vector candidates; candidates.reserve(64); for (auto& [mid, m] : models_gpu_) { @@ -3636,8 +3705,9 @@ void WgpuViewportWindow::driveStreamingLoads() { for (size_t ci = 0; ci < m.chunks.size(); ++ci) { auto& c = m.chunks[ci]; if (c.is_resident) continue; + if (c.is_loading) continue; if (c.frustum_visible_count == 0) continue; - candidates.push_back({&m, ci, chunk_center_dist2(c)}); + candidates.push_back({&m, ci, mid, chunk_center_dist2(c)}); } } std::sort(candidates.begin(), candidates.end(), @@ -3645,20 +3715,14 @@ void WgpuViewportWindow::driveStreamingLoads() { return a.dist2 < b.dist2; }); + int enqueued = 0; for (const Candidate& cand : candidates) { - if (loads >= MAX_STREAMING_LOADS_PER_FRAME) { + if (enqueued >= MAX_STREAMING_LOADS_PER_FRAME) { more_pending = true; break; } auto& c = cand.m->chunks[cand.ci]; - // Make room. Phase 1: drop LRU non-visible (chunks resident from - // a previous viewpoint that aren't frustum-visible now). Phase 2: - // drop the farthest-from-eye resident that's strictly farther - // than this candidate. With distance-sorted candidates, phase 2 - // monotonically converges — once the next candidate is farther - // than every resident, evict_farthest_than fails for it and all - // subsequent (even farther) candidates, and we stop. const uint64_t need = c.vertex_byte_size + c.index_count * sizeof(uint32_t); while (!pool_can_fit(c.vertex_byte_size) @@ -3672,27 +3736,37 @@ void WgpuViewportWindow::driveStreamingLoads() { if (!pool_can_fit(c.vertex_byte_size) || (c.index_count > 0 && !pool_can_fit(c.index_count * sizeof(uint32_t)))) { - // This candidate doesn't fit. Sorted-by-distance means every - // remaining candidate is farther, so none of them will fit - // either — bail out of the whole loop rather than waste - // iterations probing each one. + // Sorted-by-distance: every remaining candidate is farther + // and won't fit either. more_pending = true; break; } - if (loadChunkBytesAndUploadGpu(*cand.m, cand.ci)) { - ++loads; - c.last_visible_frame_idx = streaming_frame_idx_; + // Sync fallback when a screenshot is pending: the deferred-capture + // wait would let the window manager re-layout the window while we + // 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_.isEmpty()) { + if (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))) { + c.is_loading = true; + ++enqueued; } } - // Keep the frame loop running only while we're making progress. - // When loads == 0 (whether because everything fits or because the - // pool is at its hardware cap and the rest of the visible set - // can't fit), the loader has converged — let the renderer go idle - // until something actually changes (camera move, model add/remove - // triggers their own requestUpdate). Spinning here would burn the - // CPU forever on scenes whose visible set exceeds the pool. - if (loads > 0) requestUpdate(); + loads += enqueued; + // Keep the frame loop running while we're making progress or there + // are worker reads still in flight. When everything's quiet + // (no main-thread work this frame AND worker queue empty) we let + // the renderer idle until the camera moves or a model loads. + // Spinning otherwise would burn CPU forever on visible-set > + // pool-capacity scenes. + if (loads > 0 || streaming_thread_.inFlightApprox() > 0) requestUpdate(); // Surface per-frame activity for the bench harness to gate the // orbit sweep against cold-load. We only export loads — more_pending @@ -4093,6 +4167,11 @@ void WgpuViewportWindow::wheelEvent(QWheelEvent* event) { } void WgpuViewportWindow::shutdown() { + // Stop the streaming worker first so no late results land in the + // pool after we've torn down the model state. Pending in-flight + // reads are completed (worker drains its queue) then thread joins. + streaming_thread_.stop(); + // Release per-model buffers before the device they were created from. for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m, pool_); models_gpu_.clear(); diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.h b/src/ifcviewer-wgpu/WgpuViewportWindow.h index 36f6556663..a66c67112b 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.h +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h @@ -36,6 +36,7 @@ #include "WgpuBufferPool.h" #include "WgpuModelGpuData.h" #include "WgpuSelectionState.h" +#include "WgpuStreamingThread.h" #include "WgpuVisibilityState.h" // Stage-2 wgpu viewport: opens a native QWindow, brings up a wgpu instance/ @@ -137,6 +138,13 @@ private: // expected to have already evicted enough). No-op (returns true) // when already resident. bool loadChunkBytesAndUploadGpu(WgpuModelGpuData& 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(WgpuModelGpuData& m, size_t chunk_idx, + const std::vector& vbytes, + const std::vector& 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 @@ -414,6 +422,13 @@ public: // the old hand-picked streaming_vram_budget_bytes_ knob entirely. WgpuBufferPool pool_; + // Background worker that does scatter-gather chunk reads off the + // render thread. driveStreamingLoads enqueues requests for visible + // non-resident chunks and drains completed results into the pool + // on subsequent frames. Kills the 100-300 ms per-frame stutters + // that synchronous disk reads caused during orbit. + WgpuStreamingThread streaming_thread_; + // Per-frame streaming activity, written by driveStreamingLoads, // consumed by the benchmark harness to delay the orbit sweep until // the initial cold-load settles. `loads` = chunks brought resident