diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index b9732e4a77..045a0f99be 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -1730,3 +1730,421 @@ void ViewportCore::unloadChunk(ModelGpuData& m, std::size_t chunk_idx) { c.total_visible_vertices = 0; c.is_resident = false; } + +// =========================================================================== +// Streaming driver (#84-o): driveStreamingLoads +// =========================================================================== + +#include +#include + +namespace { +// Extract the file's base name (no extension, no parent dirs) for log +// readability — replaces the previous QFileInfo(...).completeBaseName(). +std::string pathStem(const std::string& path) { + if (path.empty()) return {}; + return std::filesystem::path(path).stem().string(); +} +} // namespace + +void ViewportCore::driveStreamingLoads() { + // 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 per-chunk frame state. (a) LRU stamp on frustum-visible + // residents (HiZ flicker can't un-stamp them; cull-with-HiZ would + // thrash the LRU). (b) EMA-smoothed visibility_history: how often + // the chunk has *actually* contributed pixels (post-HiZ) over the + // last ~30 frames. + constexpr float HISTORY_ALPHA = 1.0f / 30.0f; + for (auto& [mid, m] : models_gpu_) { + if (m.hidden) continue; + for (auto& c : m.chunks) { + if (c.is_resident && c.frustum_visible_count > 0) { + c.last_visible_frame_idx = streaming_frame_idx_; + } + const float current = (c.total_visible_draws > 0) ? 1.0f : 0.0f; + c.visibility_history = + c.visibility_history * (1.0f - HISTORY_ALPHA) + + current * HISTORY_ALPHA; + } + } + + // Build the camera's view-projection for the diagnostic dump below. + Eigen::Matrix4f v_mat, p_mat; + buildViewProj(v_mat, p_mat); + const Eigen::Matrix4f vp_mat = p_mat * v_mat; + + // chunk.current_priority was accumulated during cullModelCpuCompute + // (one add per frustum-passing instance). No standalone walk needed + // here; the candidate/resident priority lambdas just read it. + auto chunk_screen_area_px = [&](const ModelGpuData::Chunk& c) -> float { + return c.current_priority; + }; + + // Resident chunks: contribution × visibility_history (floored), so + // chunks that don't actually render lose priority over time and + // become evictable. Candidates: pure contribution — best-case + // estimate. Newly-loaded chunks get a GRACE_FRAMES grace period at + // full max-history factor to stop equal-priority swap loops. + constexpr float HISTORY_FLOOR = 0.05f; + constexpr std::uint64_t GRACE_FRAMES = 30; + auto resident_priority = [&](const ModelGpuData::Chunk& c) -> float { + const std::uint64_t age = streaming_frame_idx_ - c.loaded_frame_idx; + const float vis = (age < GRACE_FRAMES) + ? 1.0f + : std::max(c.visibility_history, HISTORY_FLOOR); + return chunk_screen_area_px(c) * vis; + }; + auto candidate_priority = [&](const ModelGpuData::Chunk& c) -> float { + return chunk_screen_area_px(c); + }; + + // Per-frame load budget. 4 chunks/frame × 60fps ingests 240/sec — + // a 100-model scene fully resides in ~1s. + constexpr int MAX_STREAMING_LOADS_PER_FRAME = 4; + int loads = 0; + bool more_pending = false; + + // Reset per-frame counters used by WGPU_STREAM_DEBUG output. + streaming_candidates_this_frame_ = 0; + streaming_evictions_lru_this_frame_ = 0; + streaming_evictions_pri_this_frame_ = 0; + streaming_drained_this_frame_ = 0; + streaming_blocked_oom_this_frame_ = 0; + + auto pool_can_fit = [&](std::uint64_t bytes) -> bool { + if (pool_.largest_free_run_bytes() >= bytes) return true; + if (pool_.can_grow() && pool_.next_growth_size_bytes() >= bytes) return true; + return 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. + auto evict_one_lru = [&]() -> bool { + ModelGpuData* victim_m = nullptr; + std::size_t victim_ci = 0; + std::uint64_t victim_lru = std::numeric_limits::max(); + for (auto& [mid, m] : models_gpu_) { + for (std::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 false; + unloadChunk(*victim_m, victim_ci); + ++streaming_evictions_lru_this_frame_; + return true; + }; + + // Phase-2 evictor: when every resident is visible-this-frame but a + // higher-priority candidate needs room, drop the lowest-priority + // resident provided the candidate's contribution is meaningfully + // bigger (2× area hysteresis stops oscillation). + constexpr float EVICT_PRIORITY_RATIO = 2.0f; + // WGPU_STREAM_EVICT_LOG=1 — log every priority-eviction with the + // (candidate, victim) pair and detect direct A→B→A 2-cycles. + static const bool evict_log = + std::getenv("WGPU_STREAM_EVICT_LOG") != nullptr; + auto evict_lowest_priority_than = [&](std::uint32_t cand_mid, + std::uint32_t cand_ci, + float cand_priority) -> bool { + const float threshold = cand_priority / EVICT_PRIORITY_RATIO; + ModelGpuData* victim_m = nullptr; + std::size_t victim_ci = 0; + float victim_priority = threshold; + for (auto& [mid, m] : models_gpu_) { + for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) { + auto& c = m.chunks[ci]; + if (!c.is_resident) continue; + const float p = resident_priority(c); + if (p < victim_priority) { + victim_priority = p; + victim_m = &m; + victim_ci = ci; + } + } + } + if (!victim_m) return false; + + auto& victim = victim_m->chunks[victim_ci]; + + if (evict_log) { + const std::string cand_stem = pathStem( + models_gpu_.at(cand_mid).streaming_file_path); + const std::string vic_stem = pathStem(victim_m->streaming_file_path); + // 2-cycle detection: this victim was previously evicted by + // THIS exact candidate — the smoking gun for a swap loop. + const bool is_2_cycle = + victim.last_evicted_by_model_id == cand_mid + && victim.last_evicted_by_chunk_idx == cand_ci + && victim.load_count > 1; + Log::info() + << (is_2_cycle ? "[evict 2-cycle] " : "[evict] ") + << "kicked chunk " << victim_ci + << " of " << vic_stem + << " (eff=" << int(victim_priority) + << ", load_count=" << victim.load_count + << ") for chunk " << cand_ci + << " of " << cand_stem + << " (pri=" << int(cand_priority) + << ", threshold=" << int(threshold) << ")"; + } + + victim.last_evicted_by_model_id = cand_mid; + victim.last_evicted_by_chunk_idx = cand_ci; + victim.last_evicted_by_priority = cand_priority; + victim.last_evicted_frame_idx = streaming_frame_idx_; + + unloadChunk(*victim_m, victim_ci); + ++streaming_evictions_pri_this_frame_; + return true; + }; + + constexpr std::uint64_t BLOCKED_COOLDOWN_FRAMES = 180; + + // ---- Drain worker results ------------------------------------------- + { + 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]; + c.is_loading = false; + if (!res.success) { + Log::warn() << "[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)) { + c.blocked_cooldown_until_frame_idx = + streaming_frame_idx_ + BLOCKED_COOLDOWN_FRAMES; + if (evict_log) { + Log::info() + << "[blocked-apply] chunk " << res.chunk_idx + << " of " << pathStem(m.streaming_file_path) + << " — pool OOM at apply, fetched bytes discarded" + << " — cooldown " << BLOCKED_COOLDOWN_FRAMES << "f"; + } + continue; + } + ++loads; + ++streaming_drained_this_frame_; + ++c.load_count; + c.last_visible_frame_idx = streaming_frame_idx_; + // Thrash watch — fire once per power-of-≈3 threshold. + const std::uint32_t lc = c.load_count; + if (lc == 3 || lc == 10 || lc == 30 || lc == 100 + || (lc > 100 && (lc % 100) == 0)) { + Log::info() + << "[stream thrash] chunk " << res.chunk_idx + << " of " << pathStem(m.streaming_file_path) + << " loaded " << lc << "x -- pool saturated?"; + } + } + } + + // ---- Enqueue new requests ------------------------------------------- + struct Candidate { + ModelGpuData* m; + std::size_t ci; + std::uint32_t mid; + float priority; + }; + std::vector candidates; + candidates.reserve(64); + for (auto& [mid, m] : models_gpu_) { + if (m.streaming_file_path.empty() || m.hidden) continue; + for (std::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; + if (c.blocked_cooldown_until_frame_idx > streaming_frame_idx_) continue; + candidates.push_back({&m, ci, mid, candidate_priority(c)}); + } + } + streaming_candidates_this_frame_ = int(candidates.size()); + std::sort(candidates.begin(), candidates.end(), + [](const Candidate& a, const Candidate& b) { + return a.priority > b.priority; + }); + + int enqueued = 0; + for (const Candidate& cand : candidates) { + if (enqueued >= MAX_STREAMING_LOADS_PER_FRAME) { + more_pending = true; + break; + } + auto& c = cand.m->chunks[cand.ci]; + + const std::uint64_t need = c.vertex_byte_size + + c.index_count * sizeof(std::uint32_t); + while (!pool_can_fit(c.vertex_byte_size) + || (c.index_count > 0 + && !pool_can_fit(c.index_count * sizeof(std::uint32_t))) + || pool_.total_free_bytes() < need) { + if (evict_one_lru()) continue; + if (evict_lowest_priority_than(cand.mid, std::uint32_t(cand.ci), + cand.priority)) continue; + break; + } + if (!pool_can_fit(c.vertex_byte_size) + || (c.index_count > 0 + && !pool_can_fit(c.index_count * sizeof(std::uint32_t)))) { + ++streaming_blocked_oom_this_frame_; + c.blocked_cooldown_until_frame_idx = + streaming_frame_idx_ + BLOCKED_COOLDOWN_FRAMES; + if (evict_log) { + const std::uint64_t v_bytes = c.vertex_byte_size; + const std::uint64_t i_bytes = c.index_count * sizeof(std::uint32_t); + const double mb = 1.0 / (1024.0 * 1024.0); + Log::info() + << "[blocked] chunk " << cand.ci + << " of " << pathStem(cand.m->streaming_file_path) + << " (pri=" << int(cand.priority) + << ") -- needs v=" << double(v_bytes) * mb + << " MB + i=" << double(i_bytes) * mb + << " MB; pool largest_free=" + << double(pool_.largest_free_run_bytes()) * mb + << " MB total_free=" + << double(pool_.total_free_bytes()) * mb + << " MB can_grow=" << (pool_.can_grow() ? "Y" : "N") + << " -- cooldown " << BLOCKED_COOLDOWN_FRAMES << "f"; + } + more_pending = true; + continue; + } + + // 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. + if (!pending_screenshot_path_.empty()) { + 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; + } + } + loads += enqueued; + if (loads > 0 || streaming_thread_.inFlightApprox() > 0) host_->requestFrame(); + + streaming_loads_this_frame_ = loads; + streaming_more_pending_ = more_pending; + + // Click-and-track diagnostic. When the user picked an object, we + // noted which chunk holds it. If that chunk has just transitioned + // resident→evicted, dump the priority + pool state at the moment + // of loss. + if (tracked_chunk_idx_ != SIZE_MAX) { + auto it = models_gpu_.find(tracked_chunk_mid_); + if (it != models_gpu_.end() + && tracked_chunk_idx_ < it->second.chunks.size()) { + const auto& m = it->second; + const auto& c = m.chunks[tracked_chunk_idx_]; + if (tracked_was_resident_ && !c.is_resident) { + const double mb = 1.0 / (1024.0 * 1024.0); + const float my_area = chunkScreenAreaPx(c, vp_mat); + const std::uint64_t my_bytes = c.vertex_byte_size + + c.index_count * sizeof(std::uint32_t); + Log::info() + << "[track] chunk " << tracked_chunk_idx_ + << " (object " << tracked_object_id_ + << ", model " << tracked_chunk_mid_ + << ") EVICTED this frame"; + Log::info() + << " area=" << int(my_area) << "px2" + << " frustum_vis=" << c.frustum_visible_count + << " hist=" << c.visibility_history + << " load_count=" << c.load_count + << " size=" << double(my_bytes) * mb << "MB"; + Log::info() + << " chunk aabb " + << (c.aabb_max[0] - c.aabb_min[0]) << "x" + << (c.aabb_max[1] - c.aabb_min[1]) << "x" + << (c.aabb_max[2] - c.aabb_min[2]) << "m" + << " centre=(" + << 0.5f * (c.aabb_min[0] + c.aabb_max[0]) << "," + << 0.5f * (c.aabb_min[1] + c.aabb_max[1]) << "," + << 0.5f * (c.aabb_min[2] + c.aabb_max[2]) << ")"; + Log::info() + << " pool used=" + << int(double(pool_.total_used_bytes()) * mb) + << "/" + << int(double(pool_.total_capacity_bytes()) * mb) + << "MB largest_free=" + << double(pool_.largest_free_run_bytes()) * mb << "MB"; + Log::info() + << " this-frame: cands=" << streaming_candidates_this_frame_ + << " enq=" << enqueued + << " ev_lru=" << streaming_evictions_lru_this_frame_ + << " ev_pri=" << streaming_evictions_pri_this_frame_ + << " blocked=" << streaming_blocked_oom_this_frame_; + + struct Stat { std::uint32_t mid; std::size_t ci; float area; }; + std::vector all; + all.reserve(64); + for (const auto& [mid2, m2] : models_gpu_) { + for (std::size_t ci2 = 0; ci2 < m2.chunks.size(); ++ci2) { + const auto& cc = m2.chunks[ci2]; + if (cc.is_resident) continue; + if (cc.frustum_visible_count == 0) continue; + all.push_back({mid2, ci2, chunkScreenAreaPx(cc, vp_mat)}); + } + } + std::sort(all.begin(), all.end(), + [](const Stat& a, const Stat& b){ return a.area > b.area; }); + const std::size_t n = std::min(5, all.size()); + for (std::size_t i = 0; i < n; ++i) { + Log::info() + << " top cand #" << i << ": model " << all[i].mid + << " chunk " << all[i].ci + << " area=" << int(all[i].area) << "px2"; + } + } + tracked_was_resident_ = c.is_resident; + } + } + + if (streaming_debug_) { + std::size_t resident = 0; + std::uint32_t max_load_count = 0; + std::size_t cycled = 0; + for (const auto& [mid, m] : models_gpu_) { + for (const auto& c : m.chunks) { + if (c.is_resident) ++resident; + if (c.load_count > max_load_count) max_load_count = c.load_count; + if (c.load_count > 1) ++cycled; + } + } + Log::info() + << "[stream-debug] f" << streaming_frame_idx_ + << " cands=" << streaming_candidates_this_frame_ + << " enq=" << enqueued + << " drained=" << streaming_drained_this_frame_ + << " ev_lru=" << streaming_evictions_lru_this_frame_ + << " ev_pri=" << streaming_evictions_pri_this_frame_ + << " blocked=" << streaming_blocked_oom_this_frame_ + << " resident=" << resident + << " cycled=" << cycled + << " max_load=" << max_load_count; + } +} diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 89054ac951..8f75f9a3be 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -288,11 +288,18 @@ public: // 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_. + // call from the main thread. static StreamingThread::Request makeChunkRequest( const ModelGpuData& m, std::size_t chunk_idx, std::uint32_t model_id); + // Per-frame streaming driver. Called from render() after cull. Walks + // every model's chunks once for residency bookkeeping, drains the + // worker's completed results into the pool, then enqueues new + // requests for visible non-resident chunks (LRU + priority eviction + // when the pool can't fit). Triggers host_->requestFrame() while + // residency is still settling so the render loop keeps ticking. + void driveStreamingLoads(); + private: bool probeAndCreatePool(); @@ -418,6 +425,37 @@ private: // (resets only at shutdown). std::uint64_t streaming_frame_idx_ = 0; + // Per-frame streaming activity. Written by driveStreamingLoads, + // consumed by the benchmark warm-gate (`loads_this_frame == 0 AND + // worker idle == settled`). `more_pending` is a soft hint — true + // means residency hasn't converged and the loop should keep ticking. + int streaming_loads_this_frame_ = 0; + bool streaming_more_pending_ = false; + + // Per-frame breakdown counters consumed by the WGPU_STREAM_DEBUG + // log. All reset at the top of driveStreamingLoads. + int streaming_candidates_this_frame_ = 0; + int streaming_evictions_lru_this_frame_ = 0; + int streaming_evictions_pri_this_frame_ = 0; + int streaming_drained_this_frame_ = 0; + int streaming_blocked_oom_this_frame_ = 0; + bool streaming_debug_ = false; // WGPU_STREAM_DEBUG=1 + + // Click-and-track diagnostic. Set by the pick handler when an + // object is selected; driveStreamingLoads dumps priority + pool + // state every time that chunk transitions resident→evicted so + // we can pinpoint WHY a piece of geometry disappeared. + std::uint32_t tracked_object_id_ = 0; + std::uint32_t tracked_chunk_mid_ = 0; + std::size_t tracked_chunk_idx_ = SIZE_MAX; + bool tracked_was_resident_ = false; + + // When non-empty, a screenshot capture is pending and the streaming + // loader switches to the synchronous-fetch fallback so the + // first-frame capture isn't an empty buffer. Cleared after capture + // completes. + std::string pending_screenshot_path_; + // 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 diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 33553087c0..0e2c3b8daf 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -267,7 +267,20 @@ ViewportWindow::ViewportWindow(QWindow* parent) section_planes_ (core_.section_planes_), xray_alpha_cap_ (core_.xray_alpha_cap_), selection_ (core_.selection_), - visibility_ (core_.visibility_) { + visibility_ (core_.visibility_), + tracked_object_id_ (core_.tracked_object_id_), + tracked_chunk_mid_ (core_.tracked_chunk_mid_), + tracked_chunk_idx_ (core_.tracked_chunk_idx_), + tracked_was_resident_ (core_.tracked_was_resident_), + streaming_loads_this_frame_ (core_.streaming_loads_this_frame_), + streaming_more_pending_ (core_.streaming_more_pending_), + streaming_candidates_this_frame_ (core_.streaming_candidates_this_frame_), + streaming_evictions_lru_this_frame_(core_.streaming_evictions_lru_this_frame_), + streaming_evictions_pri_this_frame_(core_.streaming_evictions_pri_this_frame_), + streaming_drained_this_frame_ (core_.streaming_drained_this_frame_), + streaming_blocked_oom_this_frame_(core_.streaming_blocked_oom_this_frame_), + streaming_debug_ (core_.streaming_debug_), + pending_screenshot_path_(core_.pending_screenshot_path_) { // wgpu doesn't need a GL context; we just need a real native window // whose backing layer matches the GPU API wgpu will drive. // @@ -4902,502 +4915,7 @@ void ViewportWindow::buildModelBindGroup(ModelGpuData& m) { // unloadChunk moved to ViewportCore (#84-n). -void ViewportWindow::driveStreamingLoads() { - // 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 per-chunk frame state. (a) LRU stamp on frustum-visible - // residents (HiZ flicker can't un-stamp them; cull-with-HiZ would - // thrash the LRU). (b) EMA-smoothed visibility_history: how often - // the chunk has *actually* contributed pixels (post-HiZ) over the - // last ~30 frames. The two metrics serve different jobs — LRU - // distinguishes "out of view" from "in view", history distinguishes - // "in view AND not occluded" from "in view BUT mostly occluded". - constexpr float HISTORY_ALPHA = 1.0f / 30.0f; - for (auto& [mid, m] : models_gpu_) { - if (m.hidden) continue; - for (auto& c : m.chunks) { - if (c.is_resident && c.frustum_visible_count > 0) { - c.last_visible_frame_idx = streaming_frame_idx_; - } - const float current = (c.total_visible_draws > 0) ? 1.0f : 0.0f; - c.visibility_history = - c.visibility_history * (1.0f - HISTORY_ALPHA) - + current * HISTORY_ALPHA; - } - } - - // Build the camera's view-projection (still needed for the AABB-based - // diagnostic dump in the tracking output below). Cull/render use the - // same helper. - Eigen::Matrix4f v_mat, p_mat; - core_.buildViewProj(v_mat, p_mat); - const Eigen::Matrix4f vp_mat = p_mat * v_mat; - - // chunk.current_priority was accumulated during cullModelCpuCompute - // (one add per frustum-passing instance). No standalone walk needed - // here; the candidate/resident priority lambdas just read it. - auto chunk_screen_area_px = [&](const ModelGpuData::Chunk& c) -> float { - return c.current_priority; - }; - - // Resident chunks: contribution × visibility_history (floored), so - // chunks that don't actually render lose priority over time and - // become evictable. Candidates: pure contribution — best-case - // estimate. Asymmetry lets new high-contribution chunks displace - // long-resident-but-occluded ones. - // - // CRITICAL: newly-loaded chunks get a "grace period" of GRACE_FRAMES - // at the full max-history factor. Without it, a freshly-loaded - // chunk's effective priority crashes to contribution × 0.05 next - // frame (history hasn't had time to develop), and the chunk it - // displaced — back as a candidate at full priority — re-displaces - // it. Infinite reverse-swap between equal-priority chunks. The - // cycle starves the per-frame load budget (MAX_STREAMING_LOADS = 4) - // so candidates ranked below the cyclers (e.g. brace chunks at - // priority position 20) never get attempted. Grace period gives - // visibility_history time to settle and breaks the cycle. - constexpr float HISTORY_FLOOR = 0.05f; - constexpr uint64_t GRACE_FRAMES = 30; - auto resident_priority = [&](const ModelGpuData::Chunk& c) -> float { - const uint64_t age = streaming_frame_idx_ - c.loaded_frame_idx; - const float vis = (age < GRACE_FRAMES) - ? 1.0f - : std::max(c.visibility_history, HISTORY_FLOOR); - return chunk_screen_area_px(c) * vis; - }; - auto candidate_priority = [&](const ModelGpuData::Chunk& c) -> float { - return chunk_screen_area_px(c); - }; - - // 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. The hard ceiling on total - // residency is the pool capacity (probed at startup); when the pool - // can't fit a candidate, the evictors below free closer-fitting - // ranges until it does. - constexpr int MAX_STREAMING_LOADS_PER_FRAME = 4; - int loads = 0; - bool more_pending = false; - - // Reset per-frame counters used by WGPU_STREAM_DEBUG output. - streaming_candidates_this_frame_ = 0; - streaming_evictions_lru_this_frame_ = 0; - streaming_evictions_pri_this_frame_ = 0; - streaming_drained_this_frame_ = 0; - streaming_blocked_oom_this_frame_ = 0; - - // The pool needs `need` contiguous bytes free for both the vertex and - // index allocations a load requires. Fragmentation matters: a chunk - // may fit total-free-bytes but not largest_free_run_bytes(). With - // multi-sub-buffer pools, an alloc can also succeed by growing the - // pool (adding a new sub-buffer at per_sub_buffer_capacity_bytes()), - // so a chunk also "fits" if it's smaller than one fresh sub-buffer. - // The actual alloc handles the growth attempt; this predicate only - // avoids wasted evict-then-fail loops. - auto pool_can_fit = [&](uint64_t bytes) -> bool { - if (pool_.largest_free_run_bytes() >= bytes) return true; - // Growth might still rescue us. Use next_growth_size_bytes() - // rather than per_sub_buffer_capacity_bytes() — after a refusal - // at e.g. 2 GB, halve-on-failure pushes the next achievable - // sub-buffer down to 1 GB; saying "fits if ≤2 GB" would lie. - if (pool_.can_grow() && pool_.next_growth_size_bytes() >= bytes) return true; - return 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 true iff a chunk was evicted. - auto evict_one_lru = [&]() -> bool { - ModelGpuData* victim_m = nullptr; - size_t victim_ci = 0; - uint64_t victim_lru = std::numeric_limits::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 false; - core_.unloadChunk(*victim_m, victim_ci); - ++streaming_evictions_lru_this_frame_; - return true; - }; - - // Phase-2 evictor: when every resident chunk is visible-this-frame - // but we still need room for a higher-priority candidate, drop the - // resident with the lowest priority (contribution × history) — - // provided the candidate's contribution is meaningfully bigger. - // 2.0× hysteresis: candidate must have 2× more pixel area than the - // victim's effective priority. In linear-radius terms that's a - // ~41% gap, which is what stops 5 m vs 7 m chunks from oscillating. - // Area metric is much more discriminating than radius, so we can - // afford a bigger gap and still leave room for genuine swaps. - constexpr float EVICT_PRIORITY_RATIO = 2.0f; - // WGPU_STREAM_EVICT_LOG=1 — log every priority-eviction with the - // (candidate, victim) pair and detect direct A→B→A 2-cycles. Noisy - // when working-set > pool; gated separately from WGPU_STREAM_DEEP_DEBUG - // so you can run one without the other. - static const bool evict_log = - std::getenv("WGPU_STREAM_EVICT_LOG") != nullptr; - auto evict_lowest_priority_than = [&](uint32_t cand_mid, - uint32_t cand_ci, - float cand_priority) -> bool { - const float threshold = cand_priority / EVICT_PRIORITY_RATIO; - ModelGpuData* victim_m = nullptr; - size_t victim_ci = 0; - float victim_priority = threshold; - 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 p = resident_priority(c); - if (p < victim_priority) { - victim_priority = p; - victim_m = &m; - victim_ci = ci; - } - } - } - if (!victim_m) return false; - - auto& victim = victim_m->chunks[victim_ci]; - - if (evict_log) { - QFileInfo cand_fi(QString::fromStdString( - models_gpu_.at(cand_mid).streaming_file_path)); - QFileInfo vic_fi(QString::fromStdString(victim_m->streaming_file_path)); - // 2-cycle detection: this victim was previously evicted by - // THIS exact candidate. That's the smoking gun for a swap- - // loop — A pushes B out, B comes back as candidate, B - // pushes A out, A comes back as candidate, … - const bool is_2_cycle = - victim.last_evicted_by_model_id == cand_mid - && victim.last_evicted_by_chunk_idx == cand_ci - && victim.load_count > 1; - Log::info().noquote().nospace() - << (is_2_cycle ? "[evict 2-cycle] " : "[evict] ") - << "kicked chunk " << victim_ci - << " of " << vic_fi.completeBaseName() - << " (eff=" << QString::number(victim_priority, 'f', 0) - << ", load_count=" << victim.load_count - << ") for chunk " << cand_ci - << " of " << cand_fi.completeBaseName() - << " (pri=" << QString::number(cand_priority, 'f', 0) - << ", threshold=" << QString::number(threshold, 'f', 0) << ")"; - } - - victim.last_evicted_by_model_id = cand_mid; - victim.last_evicted_by_chunk_idx = cand_ci; - victim.last_evicted_by_priority = cand_priority; - victim.last_evicted_frame_idx = streaming_frame_idx_; - - core_.unloadChunk(*victim_m, victim_ci); - ++streaming_evictions_pri_this_frame_; - return true; - }; - - // Cooldown duration for chunks that hit OOM (at can-fit time or at - // apply time). 180 frames ≈ 3s at 60 fps. Web-friendly: caps re- - // fetches of a chronically-unfittable chunk's byte range at one - // every ~3 seconds, instead of every frame. If the pool layout - // changes within the cooldown (other chunks evicted, fragmentation - // resolved) the chunk re-attempts once the cooldown expires. - constexpr uint64_t BLOCKED_COOLDOWN_FRAMES = 180; - - // ---- 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) { - Log::warn().noquote().nospace() - << "[wgpu stream] worker read failed for model " - << res.model_id << " chunk " << res.chunk_idx; - continue; - } - 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 - // a disk read / web fetch and discarded it; without - // the cooldown the same byte range would be re-fetched - // every frame until pool layout changes. - c.blocked_cooldown_until_frame_idx = - streaming_frame_idx_ + BLOCKED_COOLDOWN_FRAMES; - if (evict_log) { - QFileInfo fi(QString::fromStdString(m.streaming_file_path)); - Log::info().noquote().nospace() - << "[blocked-apply] chunk " << res.chunk_idx - << " of " << fi.completeBaseName() - << " — pool OOM at apply, fetched bytes discarded" - << " — cooldown " << BLOCKED_COOLDOWN_FRAMES << "f"; - } - continue; - } - ++loads; - ++streaming_drained_this_frame_; - ++c.load_count; - c.last_visible_frame_idx = streaming_frame_idx_; - // Thrash watch — fire once per power-of-≈3 threshold (3, 10, - // 30, 100). A chunk that crosses 10 has been re-loaded 10× - // this session; that points at either pool saturation or a - // hysteresis boundary keeping it on the evict/load edge. - // One line per crossing per chunk — bounded in noise. - const uint32_t lc = c.load_count; - if (lc == 3 || lc == 10 || lc == 30 || lc == 100 - || (lc > 100 && (lc % 100) == 0)) { - QFileInfo fi(QString::fromStdString(m.streaming_file_path)); - Log::info().noquote().nospace() - << "[stream thrash] chunk " << res.chunk_idx - << " of " << fi.completeBaseName() - << " loaded " << lc << "× — pool saturated?"; - } - } - } - - // ---- Enqueue new requests ------------------------------------------- - // Gather non-resident, !is_loading, frustum-visible chunks; sort by - // candidate priority (contribution_px) DESCENDING so the biggest - // screen-coverage chunks load first. Each enqueue makes room in - // the pool by evicting low-priority residents (contribution × - // visibility_history); apply's alloc is best-effort. - struct Candidate { ModelGpuData* m; size_t ci; uint32_t mid; float priority; }; - std::vector candidates; - candidates.reserve(64); - 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) { - auto& c = m.chunks[ci]; - if (c.is_resident) continue; - if (c.is_loading) continue; - if (c.frustum_visible_count == 0) continue; - // Cooldown after a previous OOM. Skip — don't waste a fetch - // or an enqueue slot on a chunk we just learned doesn't fit. - if (c.blocked_cooldown_until_frame_idx > streaming_frame_idx_) continue; - candidates.push_back({&m, ci, mid, candidate_priority(c)}); - } - } - streaming_candidates_this_frame_ = int(candidates.size()); - std::sort(candidates.begin(), candidates.end(), - [](const Candidate& a, const Candidate& b) { - return a.priority > b.priority; // biggest first - }); - - int enqueued = 0; - for (const Candidate& cand : candidates) { - if (enqueued >= MAX_STREAMING_LOADS_PER_FRAME) { - more_pending = true; - break; - } - auto& c = cand.m->chunks[cand.ci]; - - const uint64_t need = c.vertex_byte_size - + c.index_count * sizeof(uint32_t); - while (!pool_can_fit(c.vertex_byte_size) - || (c.index_count > 0 - && !pool_can_fit(c.index_count * sizeof(uint32_t))) - || pool_.total_free_bytes() < need) { - if (evict_one_lru()) continue; - if (evict_lowest_priority_than(cand.mid, uint32_t(cand.ci), - cand.priority)) continue; - break; - } - if (!pool_can_fit(c.vertex_byte_size) - || (c.index_count > 0 - && !pool_can_fit(c.index_count * sizeof(uint32_t)))) { - // Block + cooldown. The break-here-on-block logic was - // wrong: it assumed lower-priority candidates can't beat - // this one, which is true for *priority* eviction but not - // for *size-based fitting*. A smaller candidate may slot - // happily into a 24 MB hole even when the 31 MB candidate - // can't. Continue to the next candidate; the cooldown stops - // the chronically-blocked chunk from re-entering candidacy - // every frame (would otherwise burn web bandwidth on the - // same wasted fetches). - ++streaming_blocked_oom_this_frame_; - c.blocked_cooldown_until_frame_idx = - streaming_frame_idx_ + BLOCKED_COOLDOWN_FRAMES; - if (evict_log) { - const uint64_t v_bytes = c.vertex_byte_size; - const uint64_t i_bytes = c.index_count * sizeof(uint32_t); - const double mb = 1.0 / (1024.0 * 1024.0); - QFileInfo fi(QString::fromStdString(cand.m->streaming_file_path)); - Log::info().noquote().nospace() - << "[blocked] chunk " << cand.ci - << " of " << fi.completeBaseName() - << " (pri=" << QString::number(cand.priority, 'f', 0) - << ") — needs v=" << QString::number(double(v_bytes) * mb, 'f', 1) - << " MB + i=" << QString::number(double(i_bytes) * mb, 'f', 1) - << " MB; pool largest_free=" - << QString::number(double(pool_.largest_free_run_bytes()) * mb, 'f', 1) - << " MB total_free=" - << QString::number(double(pool_.total_free_bytes()) * mb, 'f', 1) - << " MB can_grow=" << (pool_.can_grow() ? "Y" : "N") - << " — cooldown " << BLOCKED_COOLDOWN_FRAMES << "f"; - } - more_pending = true; - continue; - } - - // 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_.empty()) { - if (core_.loadChunkBytesAndUploadGpu(*cand.m, cand.ci)) { - ++enqueued; - c.last_visible_frame_idx = streaming_frame_idx_; - } - continue; - } - - if (streaming_thread_.enqueue(ViewportCore::makeChunkRequest(*cand.m, cand.ci, cand.mid))) { - c.is_loading = true; - ++enqueued; - } - } - 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 - // can stay true forever in the can't-fit case and is not a "done" - // signal. - streaming_loads_this_frame_ = loads; - streaming_more_pending_ = more_pending; - - // Click-and-track diagnostic. When the user picked an object, we noted - // which chunk holds it. If that chunk has just transitioned resident - // → evicted, dump the priority + pool state at the moment of loss so - // we can see WHY it lost (was the new candidate higher priority? did - // the pool fail to fit anyone? did frustum visibility just go to 0?). - if (tracked_chunk_idx_ != SIZE_MAX) { - auto it = models_gpu_.find(tracked_chunk_mid_); - if (it != models_gpu_.end() - && tracked_chunk_idx_ < it->second.chunks.size()) { - const auto& m = it->second; - const auto& c = m.chunks[tracked_chunk_idx_]; - if (tracked_was_resident_ && !c.is_resident) { - const double mb = 1.0 / (1024.0 * 1024.0); - const float my_area = core_.chunkScreenAreaPx(c, vp_mat); - const uint64_t my_bytes = c.vertex_byte_size - + c.index_count * sizeof(uint32_t); - Log::info().noquote().nospace() - << "[track] chunk " << tracked_chunk_idx_ - << " (object " << tracked_object_id_ - << ", model " << tracked_chunk_mid_ - << ") EVICTED this frame"; - Log::info().noquote().nospace() - << " area=" << QString::number(my_area, 'f', 0) << "px²" - << " frustum_vis=" << c.frustum_visible_count - << " hist=" << QString::number(c.visibility_history, 'f', 2) - << " load_count=" << c.load_count - << " size=" << QString::number(double(my_bytes) * mb, 'f', 1) << "MB"; - Log::info().noquote().nospace() - << " chunk aabb " - << QString::number(c.aabb_max[0] - c.aabb_min[0], 'f', 1) << "×" - << QString::number(c.aabb_max[1] - c.aabb_min[1], 'f', 1) << "×" - << QString::number(c.aabb_max[2] - c.aabb_min[2], 'f', 1) << "m" - << " centre=(" - << QString::number(0.5f * (c.aabb_min[0] + c.aabb_max[0]), 'f', 1) << "," - << QString::number(0.5f * (c.aabb_min[1] + c.aabb_max[1]), 'f', 1) << "," - << QString::number(0.5f * (c.aabb_min[2] + c.aabb_max[2]), 'f', 1) << ")"; - Log::info().noquote().nospace() - << " pool used=" - << QString::number(double(pool_.total_used_bytes()) * mb, 'f', 0) - << "/" - << QString::number(double(pool_.total_capacity_bytes()) * mb, 'f', 0) - << "MB largest_free=" - << QString::number(double(pool_.largest_free_run_bytes()) * mb, 'f', 1) << "MB"; - Log::info().noquote().nospace() - << " this-frame: cands=" << streaming_candidates_this_frame_ - << " enq=" << enqueued - << " ev_lru=" << streaming_evictions_lru_this_frame_ - << " ev_pri=" << streaming_evictions_pri_this_frame_ - << " blocked=" << streaming_blocked_oom_this_frame_; - - // Top 5 candidates by priority — see which chunk(s) outscored ours. - struct Stat { uint32_t mid; size_t ci; float area; }; - std::vector all; - all.reserve(64); - for (const auto& [mid2, m2] : models_gpu_) { - for (size_t ci2 = 0; ci2 < m2.chunks.size(); ++ci2) { - const auto& cc = m2.chunks[ci2]; - if (cc.is_resident) continue; - if (cc.frustum_visible_count == 0) continue; - all.push_back({mid2, ci2, core_.chunkScreenAreaPx(cc, vp_mat)}); - } - } - std::sort(all.begin(), all.end(), - [](const Stat& a, const Stat& b){ return a.area > b.area; }); - const size_t n = std::min(5, all.size()); - for (size_t i = 0; i < n; ++i) { - Log::info().noquote().nospace() - << " top cand #" << i << ": model " << all[i].mid - << " chunk " << all[i].ci - << " area=" << QString::number(all[i].area, 'f', 0) << "px²"; - } - } - tracked_was_resident_ = c.is_resident; - } - } - - if (streaming_debug_) { - // Cheap per-frame breakdown so a thrash cycle's shape becomes - // visible — high candidates + high evictions + low net loads is - // the smoking gun for "working set > pool". - size_t resident = 0; - uint32_t max_load_count = 0; - size_t cycled = 0; // chunks loaded > 1 time this session - for (const auto& [mid, m] : models_gpu_) { - for (const auto& c : m.chunks) { - if (c.is_resident) ++resident; - if (c.load_count > max_load_count) max_load_count = c.load_count; - if (c.load_count > 1) ++cycled; - } - } - Log::info().noquote().nospace() - << "[stream-debug] f" << streaming_frame_idx_ - << " cands=" << streaming_candidates_this_frame_ - << " enq=" << enqueued - << " drained=" << streaming_drained_this_frame_ - << " ev_lru=" << streaming_evictions_lru_this_frame_ - << " ev_pri=" << streaming_evictions_pri_this_frame_ - << " blocked=" << streaming_blocked_oom_this_frame_ - << " resident=" << resident - << " cycled=" << cycled - << " max_load=" << max_load_count; - } -} +void ViewportWindow::driveStreamingLoads() { core_.driveStreamingLoads(); } // ----------------------------------------------------------------------------- // Depth attachment diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 03832b2b6b..7116696806 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -860,14 +860,13 @@ private: bool fly_debug_ = false; Stopwatch fly_render_clock_; - // Click-and-track diagnostic: when a pick lands, stash the chunk - // that holds the picked object. driveStreamingLoads watches for that - // chunk's `is_resident` flipping true→false and dumps the priority - // / pool stats at the moment of eviction so we can see why it lost. - uint32_t tracked_object_id_ = 0; - uint32_t tracked_chunk_mid_ = 0; - size_t tracked_chunk_idx_ = SIZE_MAX; - bool tracked_was_resident_ = false; + // Click-and-track aliases (storage in core_). The pick handler still + // lives in VW so it touches these by name; driveStreamingLoads in + // core dumps the priority / pool stats at eviction. + uint32_t& tracked_object_id_; + uint32_t& tracked_chunk_mid_; + size_t& tracked_chunk_idx_; + bool& tracked_was_resident_; // Mouse-navigation bindings — mirrors GL's NavBindings + currentNavBindings(). // Selection stays on LMB for every preset (none of the presets steal it), @@ -944,22 +943,20 @@ public: BufferPool& pool_; StreamingThread& 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 - // this frame; `more_pending` = the loader wants to keep going. - int streaming_loads_this_frame_ = 0; - bool streaming_more_pending_ = false; + // Per-frame streaming activity aliases (storage in core_). Written + // by driveStreamingLoads; consumed by the still-in-VW benchmark + // harness. + int& streaming_loads_this_frame_; + bool& streaming_more_pending_; - // Per-frame streaming counters for WGPU_STREAM_DEBUG. Mutated inside - // driveStreamingLoads, consumed by the per-frame debug print and the - // bench-warm timeout dump. - int streaming_candidates_this_frame_ = 0; - int streaming_evictions_lru_this_frame_ = 0; - int streaming_evictions_pri_this_frame_ = 0; - int streaming_drained_this_frame_ = 0; - int streaming_blocked_oom_this_frame_ = 0; - bool streaming_debug_ = false; // WGPU_STREAM_DEBUG=1 + // Per-frame streaming counters (storage in core_). Consumed by the + // bench-warm timeout dump in render() (still VW-side). + int& streaming_candidates_this_frame_; + int& streaming_evictions_lru_this_frame_; + int& streaming_evictions_pri_this_frame_; + int& streaming_drained_this_frame_; + int& streaming_blocked_oom_this_frame_; + bool& streaming_debug_; // Bench warm-phase counters. We wait until N consecutive frames with // 0 loads (convergence) before starting the orbit sweep, capped by @@ -1015,8 +1012,11 @@ private: // mouse. Matches GL's last_cull_was_motion_ behaviour. bool last_cull_was_motion_ = false; - // Pending one-shot screenshot, captured at the end of the next render(). - std::string pending_screenshot_path_; + // Pending one-shot screenshot path alias (storage in core_). + // Captured at the end of the next render(); driveStreamingLoads + // observes the non-empty value to switch into the sync chunk-load + // fallback so the first-frame capture isn't an empty buffer. + std::string& pending_screenshot_path_; bool pending_screenshot_quit_ = false; // Mouse navigation state. LMB drag orbits, MMB drag pans, wheel zooms.