From 72af067c9a81a484b601648b4b5f1916224611e9 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 30 May 2026 08:06:56 +1000 Subject: [PATCH] wgpu: streaming + HiZ correctness fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three correctness bugs found and fixed, plus an unrelated fly-mode deadlock surfaced along the way. HiZ false-rejection at the bottom of the screen ------------------------------------------------ The mip-pyramid sizing floored when halving — for a 256×70 mip 0 the level-3 mip is 32×8, but mip-0 row 69 maps to ly = 69>>3 = 8, which is out of bounds for an 8-row mip. ly1 then clamps down to 7 while ly0 stays at 8, the sampling loop runs zero times, max_d retains its initial 0.0, and `min_z > 0` rejects every AABB whose projected y range touches the bottom row. Same class for the right edge on very wide viewports. Fix: ceil rather than floor when halving mip dimensions so every parent row has a covering child texel, plus std::clamp on both lookup endpoints as belt-and-suspenders for any future mip-sizing change. Surfaced after the user added more sidecars and saw "anything near the bottom of the screen, no matter close or far" disappear ~0.5 s after camera stops — that delay was the strict-VP gate + readback latency opening the HiZ window. Found via WGPU_HIZ_TRACE rejection logs that showed every rejection had `max_d=0` and `ly0 > ly1`. Streaming priority lets the ocean starve out the bracing --------------------------------------------------------- Per-instance projected screen footprint was estimated as bounding- sphere radius squared. BIM geometry is overwhelmingly thin-in-one-axis (slabs, pipes, columns, windows) and a flat ocean plane viewed nearly edge-on gets a sphere projection ~250× larger than its actual screen rect. Its chunk dominated the priority ranking and evicted the brace chunks despite the braces being one of the closest visible things. Fix: per-instance priority is now the screen-space AABB rectangle area (world AABB extents projected onto the camera right/up basis vectors, divided by view-z). Sphere radius is retained for the contribution cull and LOD pick because conservative-over is the right failure mode there. Stale-VP HiZ gate ----------------- HiZ resolves into an async ping-pong of staging buffers, so the pyramid resident at cull time was typically captured one or two frames ago. During camera motion the captured VP differs from vp_this_frame and AABBs end up sampling depth taken for what was at slightly-different screen positions in the old view. Strict by default now: HiZ engages only when hiz_vp_ == vp_this_frame. WGPU_HIZ_MOTION=1 trusts the stale pyramid (matches GL's default). Fly mode Shift+Q deadlock -------------------------- keyPressEvent requested a redraw only on the first key of a new held set (was_empty). Pressing Shift first then Q never satisfied that condition because Shift had already populated the set, so the render loop never ticked. Now every relevant keypress calls requestUpdate unconditionally. HiZ stays opt-in behind WGPU_HIZ=1 for one release while the fix bakes; WGPU_HIZ_TRACE=1 keeps the per-rejection diagnostic available for future bugs. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-wgpu/WgpuModelGpuData.h | 8 + src/ifcviewer-wgpu/WgpuViewportWindow.cpp | 253 ++++++++++++++++++---- src/ifcviewer-wgpu/WgpuViewportWindow.h | 34 ++- 3 files changed, 244 insertions(+), 51 deletions(-) diff --git a/src/ifcviewer-wgpu/WgpuModelGpuData.h b/src/ifcviewer-wgpu/WgpuModelGpuData.h index 6021357b60..f79e293e28 100644 --- a/src/ifcviewer-wgpu/WgpuModelGpuData.h +++ b/src/ifcviewer-wgpu/WgpuModelGpuData.h @@ -210,6 +210,14 @@ struct WgpuModelGpuData { // with load_count >> 1 has been cycling — used by the stream // debug log (WGPU_STREAM_DEBUG=1) to surface thrash. uint32_t load_count = 0; + // Per-frame instance-aware priority. Sum of px² projected + // contributions of every instance owned by this chunk — + // captures the chunk's actual on-screen footprint, not the + // (often loose) AABB union projection. Computed once per + // frame at the top of driveStreamingLoads from the camera + // state; the candidate/resident priority lambdas just read + // this. See task #57 for the rationale. + float current_priority = 0.0f; }; std::vector chunks; diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp index 6f2f97a9ab..9574ec0b18 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp @@ -74,6 +74,7 @@ static inline float srgbToLinear(float s) { // stride in the capture path. static constexpr uint64_t WGPU_BYTES_PER_ROW_ALIGN = 256; + // Forward declaration — defined below alongside updateFrameUniforms. Used // by render() to extract camera/frustum state without duplicating the math. static QVector3D orbitEye(const float target[3], float dist, @@ -1410,6 +1411,13 @@ bool WgpuViewportWindow::initWgpu() { "[stream-debug] log enabled"; } } + if (const char* s = std::getenv("WGPU_HIZ")) { + if (s[0] == '1') { + hiz_enabled_ = true; + qInfo() << "[wgpu] WGPU_HIZ=1 — HiZ occlusion culling enabled " + "(disabled by default; see task #58)"; + } + } if (const char* s = std::getenv("WGPU_SPATIAL_BUCKETS")) { spatial_buckets_enabled_ = (s[0] == '1'); if (spatial_buckets_enabled_) { @@ -2649,8 +2657,15 @@ void WgpuViewportWindow::drainHizReadbacks() { hiz_mip_h_.push_back(h); total += w * h; if (w == 1 && h == 1) break; - w = std::max(1u, w / 2u); - h = std::max(1u, h / 2u); + // Ceil rather than floor when halving. With floor a mip-0 + // row of H0-1 maps to ly = (H0-1)>>level which can land + // outside floor(H0/2^level) entirely — the bottom (and + // right) rows of mip 0 then never propagate into coarse + // mips, so lookups for AABBs near those edges land in an + // empty sample range with the initial max_d=0 and reject + // everything. Ceil gives every parent row a child texel. + w = std::max(1u, (w + 1u) / 2u); + h = std::max(1u, (h + 1u) / 2u); } hiz_pyramid_.assign(total, 0.0f); } @@ -2734,8 +2749,9 @@ bool WgpuViewportWindow::aabbOccludedByHiz(const float mn[3], const float mx[3]) if (min_z < 0.0f) return false; // crosses near plane // Convert NDC AABB to pyramid-pixel AABB at mip 0. - // NDC y is +up; texture y is +down (matches our resolve shader's - // y-flip via clip_pos.y = -y). + // NDC y is +up; HiZ-texture y is +down (the resolve shader's + // builtin-position fragment coords are framebuffer-space which + // is +Y-down). v = 0.5 * (1 - ny) gives the mapping. const uint32_t W0 = hiz_mip_w_[0]; const uint32_t H0 = hiz_mip_h_[0]; const float u_lo = 0.5f * (nx_lo + 1.0f); @@ -2756,10 +2772,19 @@ bool WgpuViewportWindow::aabbOccludedByHiz(const float mn[3], const float mx[3]) const uint32_t lw = hiz_mip_w_[level]; const uint32_t lh = hiz_mip_h_[level]; - const int lx0 = std::max(0, int(x0) >> level); - const int ly0 = std::max(0, int(y0) >> level); - const int lx1 = std::min(int(lw) - 1, int(x1) >> level); - const int ly1 = std::min(int(lh) - 1, int(y1) >> level); + // Clamp BOTH endpoints to the mip's valid range. ly0 / lx0 also need + // to be clamped on the upper end — without that, an AABB whose + // bottom touches NDC y = -1 (or right touches +1) shifts to a child + // texel index that exceeds the mip's dimensions, the loop never + // iterates, and max_d stays at its 0.0 initial value → false reject. + // The ceil-mip construction above prevents this in the common case, + // but this guard makes the lookup robust to any future mip-sizing + // change too. + const int lx0 = std::clamp(int(x0) >> level, 0, int(lw) - 1); + const int ly0 = std::clamp(int(y0) >> level, 0, int(lh) - 1); + const int lx1 = std::clamp(int(x1) >> level, 0, int(lw) - 1); + const int ly1 = std::clamp(int(y1) >> level, 0, int(lh) - 1); + if (lx0 > lx1 || ly0 > ly1) return false; // empty sample range const float* level_data = &hiz_pyramid_[hiz_mip_offset_[level]]; float max_d = 0.0f; @@ -2771,7 +2796,32 @@ bool WgpuViewportWindow::aabbOccludedByHiz(const float mn[3], const float mx[3]) // AABB occluded iff its nearest projected z is BEHIND the depth pyramid's // coverage (greater in WebGPU's [0,1] z, where 0 is near, 1 is far). - return min_z > max_d; + // No epsilon: min_z is a strict lower bound on the AABB's actual mesh + // depth (it's the closest corner of the conservative bounding box), so + // min_z > max_d implies actual_mesh_depth > max_d. + const bool rejected = (min_z > max_d); + + // WGPU_HIZ_TRACE diagnostic. Decrement the shared budget atomically + // and log when this rejection got a slot. Logs target the post-stop + // false-rejection class of bug — fields are everything needed to + // reconstruct the decision: AABB world bounds, screen NDC bounds, + // mip level and sample rect, max_d sampled, min_z computed, gap. + if (rejected && hiz_trace_budget_.load(std::memory_order_relaxed) > 0) { + int prev = hiz_trace_budget_.fetch_sub(1, std::memory_order_relaxed); + if (prev > 0) { + qInfo().noquote().nospace() + << "[hiz reject] aabb_min=(" << mn[0] << "," << mn[1] << "," << mn[2] << ")" + << " aabb_max=(" << mx[0] << "," << mx[1] << "," << mx[2] << ")" + << " ndc_x=[" << nx_lo << "," << nx_hi << "]" + << " ndc_y=[" << ny_lo << "," << ny_hi << "]" + << " min_z=" << min_z << " max_d=" << max_d + << " gap=" << (min_z - max_d) + << " level=" << level + << " sample=(" << lx0 << "," << ly0 << ")-(" << lx1 << "," << ly1 << ")" + << " mip=" << lw << "x" << lh; + } + } + return rejected; } void WgpuViewportWindow::setBenchmarkFrames(int frames) { @@ -2787,7 +2837,10 @@ void WgpuViewportWindow::setBenchmarkFrames(int frames) { uint32_t WgpuViewportWindow::cullModelCpuCompute(WgpuModelGpuData& m, const float planes[6][4], - const float eye[3], const float forward[3], + const float eye[3], + const float forward[3], + const float right[3], + const float up[3], float focal_px, float min_radius_px, float lod1_threshold_px, @@ -2809,6 +2862,7 @@ uint32_t WgpuViewportWindow::cullModelCpuCompute(WgpuModelGpuData& m, c.total_visible_vertices = 0; c.total_visible_draws = 0; c.frustum_visible_count = 0; + c.current_priority = 0.0f; } // Per-chunk running vertex count (used to populate that chunk's prefix @@ -2828,20 +2882,38 @@ uint32_t WgpuViewportWindow::cullModelCpuCompute(WgpuModelGpuData& m, // necessarily this one. if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) return; + const uint32_t chunk_idx = m.instance_chunk_idx[i]; + WgpuModelGpuData::Chunk& c = m.chunks[chunk_idx]; + // Bump the chunk's frustum-only counter before contribution / HiZ. - // This is the signal driveStreamingLoads keys residency on — stable - // across frames when the camera doesn't move, so the loader doesn't - // thrash on HiZ visibility flicker. - ++m.chunks[m.instance_chunk_idx[i]].frustum_visible_count; + // Stable across frames when the camera doesn't move, so the + // streaming loader doesn't thrash on HiZ visibility flicker. + ++c.frustum_visible_count; const MeshInfo& mesh = m.meshes[inst.mesh_id]; - // Projected bounding-sphere radius in pixels — shared between the - // contribution-cull and LOD-pick decisions. Computed before HiZ so - // contribution can short-circuit the per-instance HiZ projection - // (which is the bulk of cull cost on dense scenes). + // Two screen-space metrics computed per instance: + // + // projected_px — sphere-radius projection. Cheap, conservative + // (over-estimates). Used by the contribution + // gate (`projected_px < min_radius_px`) and + // LOD pick. Conservative-over is the right + // failure mode there: we'd rather draw a tiny + // sub-pixel sliver than wrongly skip it. + // box_area_px2 — AABB-rectangle projection. Tight. Used only + // by the streaming priority accumulator. BIM + // geometry is thin-in-one-axis (slabs, pipes, + // columns, windows); a sphere bounding a flat + // ocean plane over-states screen footprint by + // 100×+ when viewed edge-on, which made occluded + // far geometry steal residency from close, + // visible structural elements (e.g. bracing). + // + // We accumulate BEFORE contribution / HiZ rejection because + // streaming asks "do we want this chunk's bytes resident", not + // "do we draw it this frame". float projected_px = std::numeric_limits::infinity(); - if (contrib_enabled || (lod_enabled && mesh.lod1_index_count > 0)) { + { const float cx = 0.5f * (inst.world_aabb_min[0] + inst.world_aabb_max[0]); const float cy = 0.5f * (inst.world_aabb_min[1] + inst.world_aabb_max[1]); const float cz = 0.5f * (inst.world_aabb_min[2] + inst.world_aabb_max[2]); @@ -2854,6 +2926,26 @@ uint32_t WgpuViewportWindow::cullModelCpuCompute(WgpuModelGpuData& m, + forward[2] * (cz - eye[2]); if (view_z > 1e-3f) { projected_px = radius_world * focal_px / view_z; + + // World-AABB half-extents projected onto camera right/up. + // Each |basis · world_axis| term is the contribution of + // that world axis to that screen axis (e.g. a horizontal + // ocean plane's Z extent collapses to ~0 in screen-x when + // viewed edge-on). + const float hex = 0.5f * ex; + const float hey = 0.5f * ey; + const float hez = 0.5f * ez; + const float view_he_x = std::fabs(right[0]) * hex + + std::fabs(right[1]) * hey + + std::fabs(right[2]) * hez; + const float view_he_y = std::fabs(up[0]) * hex + + std::fabs(up[1]) * hey + + std::fabs(up[2]) * hez; + const float inv_z = focal_px / view_z; + const float box_area_px2 = 4.0f + * view_he_x * inv_z + * view_he_y * inv_z; + c.current_priority += box_area_px2; } } @@ -2880,12 +2972,9 @@ uint32_t WgpuViewportWindow::cullModelCpuCompute(WgpuModelGpuData& m, // them directly. When use_lod1, ebo_first_u32 routes into the LOD1 // section of the chunk's index slice (which is packed after the // LOD0 section at chunk-build time); the shader is oblivious to - // the LOD split. Lookups are per-INSTANCE (not per-mesh) so the - // spatial-bucket planner (#55) can duplicate a mesh into multiple - // chunks; each instance still resolves to exactly one chunk. - const uint32_t chunk_idx = m.instance_chunk_idx[i]; - WgpuModelGpuData::Chunk& c = m.chunks[chunk_idx]; - + // the LOD split. (chunk_idx and c were resolved at the top of + // process_instance so the priority accumulator could reach the + // chunk before contribution / HiZ rejected this instance.) WgpuModelGpuData::VisibleDrawGpu d; d.mesh_id = inst.mesh_id; d.instance_idx = i; @@ -3034,8 +3123,18 @@ void WgpuViewportWindow::render() { // pixels. focal_px maps view-space depth to projected radius: // projected_px = world_radius * focal_px / view_z. const QVector3D fwd_q = (target - eye).normalized(); - const float eye_a[3] = { eye.x(), eye.y(), eye.z() }; - const float fwd_a[3] = { fwd_q.x(), fwd_q.y(), fwd_q.z() }; + // World-up convention: Z-up. Near the poles lookAt degenerates, + // so swap to Y-up — mirrors buildViewProj's pitch gate at line + // 4701 so cull's camera basis matches the actual view matrix. + const QVector3D world_up = (std::abs(camera_pitch_deg_) >= 89.0f) + ? QVector3D(0.0f, 1.0f, 0.0f) + : QVector3D(0.0f, 0.0f, 1.0f); + const QVector3D right_q = QVector3D::crossProduct(fwd_q, world_up).normalized(); + const QVector3D up_q = QVector3D::crossProduct(right_q, fwd_q).normalized(); + const float eye_a[3] = { eye.x(), eye.y(), eye.z() }; + const float fwd_a[3] = { fwd_q.x(), fwd_q.y(), fwd_q.z() }; + const float right_a[3] = { right_q.x(), right_q.y(), right_q.z() }; + const float up_a[3] = { up_q.x(), up_q.y(), up_q.z() }; const float focal_px = (configured_h_ > 0) ? (0.5f * float(configured_h_) / std::tan(qDegreesToRadians(camera_fov_y_deg_) * 0.5f)) @@ -3058,6 +3157,63 @@ void WgpuViewportWindow::render() { use_motion_threshold ? motion_min_pixel_radius_ : min_pixel_radius_; last_cull_was_motion_ = use_motion_threshold; + // HiZ stale-VP gate. The depth pyramid is async — the pyramid + // resident in hiz_pyramid_ was captured one or more frames ago + // at hiz_vp_. If the current VP differs, AABBs project through + // a stale matrix to wrong screen-space positions and sample + // depth captured for what was at THOSE positions in the old + // view — incorrect rejections. Strict by default: HiZ on only + // when current VP exactly matches the pyramid's. WGPU_HIZ_MOTION=1 + // trusts the stale pyramid across motion (matches GL's default + // behaviour; the env var name mirrors GL's IFC_HIZ_MOTION knob + // but the wgpu default is inverted toward strictness). + static const bool hiz_trust_stale = []{ + const char* e = std::getenv("WGPU_HIZ_MOTION"); + return e && e[0] == '1'; + }(); + const bool hiz_vp_matches = hiz_valid_ + && (hiz_trust_stale || hiz_vp_ == vp_this_frame); + const bool hiz_for_this_frame = hiz_enabled_ && hiz_vp_matches; + + // WGPU_HIZ_TRACE: arm rejection logging when HiZ is about to + // fire post-settle. Reports per-frame budget, dumps a snapshot + // of the pyramid's bottom rows (the band the post-stop bug + // manifests in), and the per-rejection details land via the + // hiz_trace_budget_ atomic checked inside aabbOccludedByHiz. + static const bool hiz_trace_on = []{ + const char* e = std::getenv("WGPU_HIZ_TRACE"); + return e && e[0] == '1'; + }(); + if (hiz_trace_on && hiz_for_this_frame) { + constexpr int kHizTracePerFrame = 12; + hiz_trace_budget_.store(kHizTracePerFrame, std::memory_order_relaxed); + // One-shot per-frame log so the user can correlate rejections + // with what they were looking at. + qInfo().noquote().nospace() + << "[hiz trace] frame: vp_match=" + << (hiz_vp_ == vp_this_frame ? "exact" : "loose") + << " pyramid_mip0=" << hiz_mip_w_[0] << "x" << hiz_mip_h_[0] + << " budget=" << kHizTracePerFrame; + // Dump the bottom 3 rows of mip 0, evenly sampled across width. + // If the bug is "pyramid bottom rows hold near-zero depth" + // these values will be visibly small. + const uint32_t W0 = hiz_mip_w_[0]; + const uint32_t H0 = hiz_mip_h_[0]; + const float* L0 = &hiz_pyramid_[hiz_mip_offset_[0]]; + for (int dy = 2; dy >= 0; --dy) { + const uint32_t y = H0 - 1 - uint32_t(dy); + QString row; + for (int s = 0; s < 8; ++s) { + const uint32_t x = (s * (W0 - 1)) / 7; + row += QString::asprintf("%.4f ", L0[y * W0 + x]); + } + qInfo().noquote().nospace() + << "[hiz trace] pyramid row " << y << " (8 samples): " << row; + } + } else if (hiz_trace_on) { + hiz_trace_budget_.store(0, std::memory_order_relaxed); + } + // Cull each model on its own worker thread. wgpu queue writes are // serialised on the main thread after the parallel compute joins — // wgpu-native doesn't guarantee thread-safety on queue ops. @@ -3069,12 +3225,13 @@ void WgpuViewportWindow::render() { if (m.hidden) continue; auto& m_ref = m; futures.emplace_back(mid, std::async(std::launch::async, - [this, &m_ref, &planes, &eye_a, &fwd_a, - focal_px, effective_min_px]() { + [this, &m_ref, &planes, &eye_a, &fwd_a, &right_a, &up_a, + focal_px, effective_min_px, hiz_for_this_frame]() { return cullModelCpuCompute( - m_ref, planes, eye_a, fwd_a, focal_px, + m_ref, planes, eye_a, fwd_a, right_a, up_a, + focal_px, effective_min_px, lod1_pixel_threshold_, - hiz_enabled_); + hiz_for_this_frame); })); } for (auto& [mid, fut] : futures) { @@ -3084,9 +3241,9 @@ void WgpuViewportWindow::render() { for (auto& [mid, m] : models_gpu_) { if (m.hidden) continue; hiz_reject_count_ += cullModelCpuCompute( - m, planes, eye_a, fwd_a, focal_px, + m, planes, eye_a, fwd_a, right_a, up_a, focal_px, effective_min_px, lod1_pixel_threshold_, - hiz_enabled_); + hiz_for_this_frame); } } @@ -4210,21 +4367,18 @@ void WgpuViewportWindow::driveStreamingLoads() { } } - // Build the camera's view-projection so we can project AABB corners - // to actual screen-space pixels. Same helper as cull/render so an - // ortho or near-vertical view scores chunks the same way. + // 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. QMatrix4x4 v_mat, p_mat; buildViewProj(v_mat, p_mat); const QMatrix4x4 vp_mat = p_mat * v_mat; - // Chunk priority = projected pixel area of the AABB on screen. - // "What would this chunk's AABB cover if rendered solid given the - // current camera and viewport." Replaces the prior bounding-sphere- - // radius² metric, which was a 3D approximation — it treated a - // 322 × 55 × 5 m slab as a 163 m sphere, same huge priority face-on - // or edge-on, even though edge-on covers far fewer pixels. + // 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 WgpuModelGpuData::Chunk& c) -> float { - return chunkScreenAreaPx(c, vp_mat); + return c.current_priority; }; // Resident chunks: contribution × visibility_history (floored), so @@ -5479,10 +5633,15 @@ void WgpuViewportWindow::keyPressEvent(QKeyEvent* event) { if (!event->isAutoRepeat()) { const bool was_empty = fps_keys_held_.isEmpty(); fps_keys_held_.insert(key); - if (was_empty) { - fps_last_tick_.restart(); - requestUpdate(); - } + if (was_empty) fps_last_tick_.restart(); + // ALWAYS kick the render loop, not just on first key. + // If Shift was pressed first (Shift-alone doesn't move → + // fpsIntegrate exits early without requesting another + // frame, so the loop dies), and Q is pressed next, the + // old "only on was_empty" trigger missed it and Q never + // integrated. Re-arming requestUpdate per keypress is + // free (Qt coalesces) and resolves the deadlock. + requestUpdate(); } return; default: break; diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.h b/src/ifcviewer-wgpu/WgpuViewportWindow.h index c0cc84d84c..d0ad963e3f 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.h +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h @@ -30,6 +30,7 @@ #include +#include #include #include #include @@ -294,9 +295,19 @@ private: // vertices. Touches no wgpu state, so this can run on a worker thread // (multiple models culled in parallel). Returns the number of HiZ // rejections accumulated (caller adds to the per-frame stat). + // right/up are world-space camera basis vectors (orthonormal with + // forward). Used by the streaming priority accumulator to project + // each instance's world AABB to a screen-space rectangle — far + // tighter than a bounding-sphere projection for BIM geometry, which + // is overwhelmingly thin-in-one-axis (pipes, columns, slabs, + // windows). Sphere projection is kept for contribution / LOD picks + // because conservative-over is the right failure mode there. uint32_t cullModelCpuCompute(WgpuModelGpuData& m, const float planes[6][4], - const float eye[3], const float forward[3], + const float eye[3], + const float forward[3], + const float right[3], + const float up[3], float focal_px, float min_radius_px, float lod1_threshold_px, @@ -432,6 +443,13 @@ private: bool hiz_valid_ = false; uint32_t hiz_reject_count_ = 0; // per-frame stat + // WGPU_HIZ_TRACE=1 — diagnostic logging budget shared across the + // parallel cull threads. Set to a non-zero count at start of cull + // when tracing is on; each rejection in aabbOccludedByHiz atomically + // decrements and logs while >0. Atomic because cull dispatches one + // thread per model. + mutable std::atomic hiz_trace_budget_{0}; + QColor background_color_ = QColor("#202329"); // Camera (orbit, right-handed Y-up world → wait, BIM is +Z up). @@ -533,9 +551,17 @@ private: uint32_t spatial_max_instances_ = 5000; public: - // Master switch for HiZ occlusion. Set false to skip the depth resolve - // + readback + cull test entirely (matches IFC_NO_HIZ in the GL backend). - bool hiz_enabled_ = true; + // Master switch for HiZ occlusion. OFF by default — has two issues vs + // the GL backend on this codepath (see task #58): + // (1) Correctness: bottom-edge AABBs get falsely rejected as the + // camera rotates. Math review didn't pin it down; root cause + // likely needs RenderDoc capture of the pyramid. + // (2) Perf: HiZ ON costs ~2.5 ms more cull time than HiZ OFF on + // the federation bench but only saves ~1 ms of raster work + // because our indirect-draw iterates the visible_draws buffer + // regardless. Net 9% slower (49.7 vs 54.2 fps). + // Opt-in via WGPU_HIZ=1. + bool hiz_enabled_ = false; // When true, initWgpu requests the WebGPU mandatory floor limits // (maxStorageBufferBindingSize=128MB, maxBufferSize=256MB) instead of