From 3cc759d72b51f9816180449d2aab391390a16170 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 30 Jun 2026 10:11:58 +1000 Subject: [PATCH] ifcviewer: fix blank-until-interaction stall on web (streaming settle burst) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On first web load the sample stayed blank until a click/drag, then popped in. Root cause: the main draw + cull run before driveStreamingLoads in render(), so a chunk that becomes resident there is only painted a frame later. On desktop the streaming thread keeps inFlightApprox() > 0 during a load, so the render loop keeps ticking and the next frame paints it. On web the sync MEMFS / Blob load finishes instantly (inFlightApprox stays 0), so the single post-load requestFrame fired once and the on-demand loop went idle before the geometry was ever drawn — until some input re-armed it. Fix: arm a bounded settle burst (kStreamingSettleFrames) whenever there's streaming activity — a load this frame, work still queued, or a visible chunk not yet resident — and bleed it down over the next few frames, each requesting one more. Covers the cull→display latency under an on-demand loop and still quiesces at idle (no busy-rendering). General, not web-only. Regression test: the sample must render with NO pointer input — a centred patch (the framed cube) differs from a corner patch (background); a blank stall leaves both as background. Verified empirically with a no-interaction probe (canvas went from a static blank hash to a stable rendered one). 107/107 unit tests pass; 4/4 web smoke tests pass. Co-Authored-By: Claude Opus 4.8 --- src/ifcviewer-web/tests/smoke.spec.mjs | 33 ++++++++++++++++++++++++++ src/ifcviewer/ViewportCore.cpp | 30 ++++++++++++++++++++++- src/ifcviewer/ViewportCore.h | 8 +++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/ifcviewer-web/tests/smoke.spec.mjs b/src/ifcviewer-web/tests/smoke.spec.mjs index 51743dc374..4c02386d7e 100644 --- a/src/ifcviewer-web/tests/smoke.spec.mjs +++ b/src/ifcviewer-web/tests/smoke.spec.mjs @@ -65,6 +65,39 @@ test('renders the sample and orbits without WebGPU errors', async ({ page }) => expect(gpuErrors, gpuErrors.join('\n')).toEqual([]); }); +test('sample renders without any interaction (streaming settle loop)', async ({ page }) => { + // Regression for the blank-until-click stall: the main draw + cull precede + // driveStreamingLoads, so a freshly-resident chunk paints a frame later. On + // web's on-demand loop a single post-load requestFrame wasn't enough, so the + // sample stayed blank until some input re-armed the loop. The settle burst + // keeps frames coming until streaming converges. Here: NO mouse input at all + // — a centred patch (the framed cube) must differ from a corner patch + // (background). If the loop stalls blank, both patches are background. + const gpuErrors = []; + page.on('console', (msg) => { + if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(msg.text())) + gpuErrors.push(msg.text()); + }); + await page.goto('/IfcViewerWeb.html'); + await page.waitForFunction( + () => !!(window.Module && window.Module._app_ptr), null, { timeout: 30_000 }); + + // Settle window — strictly no pointer events. + await page.waitForTimeout(1500); + + const box = await page.locator('#viewer-canvas').boundingBox(); + const patch = (cx, cy) => page.screenshot({ + clip: { x: Math.round(cx - 12), y: Math.round(cy - 12), width: 24, height: 24 }, + }); + const center = await patch(box.x + box.width / 2, box.y + box.height / 2); + const corner = await patch(box.x + 16, box.y + 16); + expect( + Buffer.compare(center, corner), + 'centre patch matches corner — sample never rendered without interaction', + ).not.toBe(0); + expect(gpuErrors, gpuErrors.join('\n')).toEqual([]); +}); + test('loads a user-picked sidecar through the Blob.slice byte-range path', async ({ page }) => { // Exercises #88: the picked File is read via Blob.slice (metadata head/tail // + per-chunk byte ranges) WITHOUT copying the whole file into the wasm diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index c85db64a10..9a62d65294 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -2305,7 +2305,35 @@ void ViewportCore::driveStreamingLoads() { } } loads += enqueued; - if (loads > 0 || streaming_thread_.inFlightApprox() > 0) host_->requestFrame(); + + // Keep the render loop alive while streaming settles. The main draw + cull + // run *before* this point in render(), so a chunk that becomes resident + // here is only drawn on a later frame — and on web a sync load finishes + // instantly (inFlightApprox stays 0), so a single requestFrame after a + // load isn't enough to flush that cull→display latency. Arm a short settle + // burst whenever there's streaming activity (a load this frame, work still + // queued, or a visible chunk not yet resident) and bleed it down over the + // next few frames so an on-demand render loop doesn't stall before the + // geometry actually appears. Bounded, so the loop still quiesces at idle. + bool visible_pending = false; + for (const auto& [mid, m] : models_gpu_) { + if (m.streaming_file_path.empty() || m.hidden) continue; + for (const auto& c : m.chunks) { + if (!c.is_resident && (c.frustum_visible_count > 0 || c.is_loading)) { + visible_pending = true; + break; + } + } + if (visible_pending) break; + } + if (loads > 0 || more_pending || visible_pending + || streaming_thread_.inFlightApprox() > 0) { + streaming_settle_frames_ = kStreamingSettleFrames; + } + if (streaming_settle_frames_ > 0) { + --streaming_settle_frames_; + host_->requestFrame(); + } streaming_loads_this_frame_ = loads; streaming_more_pending_ = more_pending; diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 1eee525c6f..1789f04b8c 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -873,6 +873,14 @@ private: int streaming_loads_this_frame_ = 0; bool streaming_more_pending_ = false; + // Settle burst: keep the render loop alive for a few frames after any + // streaming activity so the cull→load→display latency (the draw + cull + // precede driveStreamingLoads, so a freshly-resident chunk paints a frame + // later) flushes even under an on-demand render loop (web). Bounded, so + // the loop still quiesces when streaming is done. See driveStreamingLoads. + static constexpr int kStreamingSettleFrames = 4; + int streaming_settle_frames_ = 0; + // Per-frame breakdown counters consumed by the WGPU_STREAM_DEBUG // log. All reset at the top of driveStreamingLoads. int streaming_candidates_this_frame_ = 0;