wgpu: streaming + HiZ correctness fixes

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 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-30 08:06:56 +10:00
parent 126d2d4c06
commit 72af067c9a
3 changed files with 244 additions and 51 deletions
+30 -4
View File
@@ -30,6 +30,7 @@
#include <webgpu/webgpu.h>
#include <atomic>
#include <cstdint>
#include <deque>
#include <unordered_map>
@@ -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<int> 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