ifcviewer: move driveStreamingLoads into ViewportCore (#84-o)

The per-frame streaming residency driver — LRU/priority eviction,
worker-result drain, candidate selection, click-and-track diagnostic,
sync-fallback for screenshot capture — now lives in ViewportCore.
ViewportWindow::driveStreamingLoads is a one-line forwarder.

Streaming-related state moves to core with reference aliases on VW:
streaming_{loads,more_pending,candidates,evictions_{lru,pri},drained,
blocked_oom}_this_frame_, streaming_debug_, tracked_{object_id,
chunk_mid,chunk_idx,was_resident}_, and pending_screenshot_path_. The
pick handler and bench-warm gate (still VW) read/write through the
aliases unchanged.

Qt-isms in the body were replaced en route:
- QFileInfo(...).completeBaseName() → std::filesystem::path::stem()
- requestUpdate() → host_->requestFrame()
- QString::number(x, 'f', N) in numeric logs → raw double / int (we lose
  fixed-precision in a couple of diag lines; acceptable tradeoff).

host_->requestFrame() means the streaming loop is now host-agnostic:
the WebViewportHost will provide its own requestAnimationFrame
equivalent when it lands.
This commit is contained in:
Dion Moult
2026-06-06 17:12:15 +10:00
parent 077080b318
commit d86a5af662
4 changed files with 498 additions and 524 deletions
+418
View File
@@ -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 <filesystem>
#include <set>
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<std::uint64_t>::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<Candidate> 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<Stat> 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<std::size_t>(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;
}
}
+40 -2
View File
@@ -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
+15 -497
View File
@@ -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<uint64_t>::max();
for (auto& [mid, m] : models_gpu_) {
for (size_t ci = 0; ci < m.chunks.size(); ++ci) {
auto& c = m.chunks[ci];
if (!c.is_resident) continue;
if (c.last_visible_frame_idx == streaming_frame_idx_) continue;
if (c.last_visible_frame_idx < victim_lru) {
victim_lru = c.last_visible_frame_idx;
victim_m = &m;
victim_ci = ci;
}
}
}
if (!victim_m) return 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<Candidate> 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<Stat> 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<size_t>(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
+25 -25
View File
@@ -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.