wgpu: input parity, fly mode, diagnostic instrumentation, chunk-priority fix

Brings the wgpu viewport's keyboard + mouse into line with GL ViewportWindow
+ Bonsai's MainWindow shortcut table, lands fly-mode, swaps in three
diagnostic env vars, and fixes a chunk-priority bug exposed by the
diagnostics.

Keyboard parity with GL + Bonsai:
  P             — toggle perspective / ortho projection
  X / Shift+X   — front / back view (eye on ±X, pitch 0)
  Y / Shift+Y   — right / left view (eye on ±Y, pitch 0)
  Z / Shift+Z   — top / bottom view (pitch ±90°)
  F             — focus camera on currently selected object
  Home          — frame entire scene
  C             — print --camera CLI args for current view
  H             — hide selected
  Shift+H       — isolate selected (hide everything not in selection)
  Alt+H         — show all (clear hidden set)
  Shift+F       — enter fly mode (matches BonsaiViewer)
  Escape (fly)  — exit fly mode
  WASD/QE/Shift — fly movement (when in fly mode)

The previous H/Shift+H/I assignments were wrong vs Bonsai (Shift+H went
to show-all, I to isolate); both are fixed.

Fly mode:
  - GL-style absolute m/s base speed (default 5.0), Shift = 5×, scrollwheel
    adjusts ×1.25/×0.8 per notch (Blender convention). Scrollwheel does
    NOT zoom in fly mode; that interfered with speed when speed was
    distance-scaled (it was, briefly; replaced with absolute m/s).
  - Mouse-look pins eye: yaw/pitch update first, then target is re-derived
    so orbitEye(target, dist, new_yaw, new_pitch) == old eye. Result:
    camera rotates in place (FPS) rather than orbiting the pivot.
  - dt ceiling clamp at 100ms (matches GL fps_move_speed_) so a stall
    doesn't warp the camera.
  - Pitch sign matches non-inverted FPS convention (mouse-up = look up).

Mouse-nav presets (WGPU_NAV_PRESET=blender|rhino|revit, default blender):
  Blender — Orbit MMB,        Pan Shift+MMB
  Rhino   — Orbit RMB,        Pan Shift+RMB
  Revit   — Orbit Shift+MMB,  Pan MMB
LMB stays free for selection in every preset. Nav-drag kind is captured
at press time so a mid-drag Shift release doesn't flip orbit↔pan. The
pan up-vector switches to world-Y at near-vertical pitch so panning
still works in top/bottom view (would otherwise NaN at pitch=±90°).

Camera-math refactor:
  buildViewProj(view, proj) centralises perspective↔ortho selection and
  the near-vertical up-vector switch. Four open-coded copies of the
  view/proj build (cull, debug, streaming priority, render uniforms) now
  call it instead, ensuring projection mode + up-vector switch land
  identically everywhere. basic.ifc pixel-diff is 0 (refactor confirmed
  output-equivalent on the path with no ortho / no near-vertical pitch).

WGPU_PRESENT_MODE=fifo|fifo_relaxed|mailbox|immediate (default fifo):
  Diagnostic toggle for stutter analysis. fifo_relaxed gave the tightest
  per-frame dt distribution on a federated bench scene; immediate gave
  uncapped throughput at the cost of tearing. Mailbox not supported on
  Vulkan + NVIDIA Linux but kept as an option for other backends.

WGPU_FLY_DEBUG=1: per-frame [fly] log printing dt, render gap, key
count, speed, position delta. Confirmed render_gap == dt to four
decimal places — fpsIntegrate runs exactly once per render, no
double-tick. Cull cost (~14-20 ms) is the dominant frame variance and
the eventual fix is task #49 (sub-model parallel cull) — fly-mode
stutter on slow scenes is a downstream symptom of cull cost, not a
fly-mode bug.

WGPU_STREAM_DEBUG=1: per-frame [stream-debug] log with cands / enq /
drained / ev_lru / ev_pri / blocked / resident / cycled / max_load.
Confirms thrash / pool-bound / load-budget cases on big scenes.

Pick-and-track diagnostic: clicking an object enumerates every chunk
holding instances of that object (an IFC object can split across
representations / chunks), printing each chunk's AABB + instance AABB +
residency. If any tracked chunk's is_resident flips true → false in
driveStreamingLoads, an EVICTED dump prints with the chunk's AABB,
priority, pool state, this-frame eviction counts, and the top-5
candidates that displaced it. Surfaces exactly why an object disappeared.

chunkScreenAreaPx fix (uses diagnostic to confirm the bug):
  A chunk's AABB is the union of every instance's world AABB in the
  chunk. On a federated IFC the camera commonly sits INSIDE that AABB
  (e.g. inside a 263×30×15 m floor-area bounding box). Previously the
  8-corner projection silently dropped corners with clip.w <= 1e-3
  (behind near plane), so the projected bbox of the surviving in-front
  corners was a tiny fraction of the chunk's true on-screen footprint.
  Result: big-AABB chunks lost every eviction fight, visible objects
  popped out as the camera tilted. Fix: short-circuit to full-viewport
  area when (a) eye is inside the chunk AABB (mirrors GL's
  contributionPasses camera-inside short-circuit), or (b) any AABB
  corner sits behind the near plane (AABB straddles → 8 corners cannot
  honestly measure footprint; conservatively over-prioritise).

The fix is a workaround for the deeper chunking issue — chunks are
mesh-keyed (group of meshes), and a mesh's AABB used in chunking is
the mean of its instances' positions, which is meaningless for
heavily-deduplicated meshes scattered across the scene. The root fix
is task #55 (spatial instance bucketing, runtime prototype) + #56
(sidecar v15 instance-keyed format). chunkScreenAreaPx fix unblocks
the user-visible "missing objects" issue while those land.

Extracted chunkScreenAreaPx from a driveStreamingLoads-local lambda
to a private member so the disappear-diagnostic and any future call
sites can use it consistently.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-29 15:23:39 +10:00
parent a63bc63439
commit 3eaa35d748
2 changed files with 793 additions and 106 deletions
+99
View File
@@ -22,8 +22,10 @@
#include <QWindow>
#include <QColor>
#include <QElapsedTimer>
#include <QMatrix4x4>
#include <QPoint>
#include <QSet>
#include <QString>
#include <webgpu/webgpu.h>
@@ -98,6 +100,50 @@ public:
void setCamera(float tx, float ty, float tz,
float dist, float yaw_deg, float pitch_deg);
// GL-parity camera helpers. setStandardView snaps to an axis-aligned
// angle without re-framing (used by X/Y/Z keys). focusOnSelectedObject
// frames the union AABB of the current selection. toggleProjection
// flips perspective <-> orthographic. cameraString formats the current
// state for a --camera CLI arg.
void setStandardView(float yaw_deg, float pitch_deg);
void focusOnSelectedObject();
void toggleProjection();
QString cameraString() const;
// FPS / fly mode. enterFpsMode swaps the orbit camera for a WASD/QE
// free-fly camera (hotkey: Shift+F). exitFpsMode restores the orbit
// pivot and reveals the cursor. Mouse-look uses raw deltas (cursor is
// hidden and recentered each frame).
void enterFpsMode();
void exitFpsMode();
bool fpsMode() const { return fps_mode_; }
private:
// Common camera math used by render, cull, streaming, and pick. Produces
// the view matrix and a WebGPU-correct projection (z mapped to [0, 1]).
// Single helper so projection_ortho_ and the up-vector switch at near-
// vertical pitch land identically everywhere.
void buildViewProj(QMatrix4x4& view_out, QMatrix4x4& proj_out) const;
// Per-frame WASD integration when fps_mode_ is true. Called near the
// top of render() so the displayed frame already reflects movement.
void fpsIntegrate();
// Build the camera AABB for a single object across all loaded models.
bool computeObjectAabb(uint32_t object_id,
float mn[3], float mx[3]) const;
// Re-aim the orbit camera so the bounding sphere of [mn, mx] fits.
void frameAabb(const float mn[3], const float mx[3], float padding);
// Resolve nav_preset_ env var to orbit/pan bindings.
void applyNavPreset(const char* name);
// Project the chunk's world-space AABB through `vp_mat` and return the
// 2D pixel area covered on screen. This is the streaming loader's
// chunk-priority metric — extracted from driveStreamingLoads as a
// member so the click-and-track diagnostic can compare scores.
float chunkScreenAreaPx(const WgpuModelGpuData::Chunk& c,
const QMatrix4x4& vp_mat) const;
public:
// Queue a one-shot framebuffer capture: the next rendered frame is
// copied back to host memory and saved to `path` as PNG. If
// `quit_after` is true, QCoreApplication::quit() is called once the
@@ -120,6 +166,7 @@ protected:
void mouseMoveEvent(QMouseEvent* event) override;
void wheelEvent(QWheelEvent* event) override;
void keyPressEvent(QKeyEvent* event) override;
void keyReleaseEvent(QKeyEvent* event) override;
private:
bool initWgpu();
@@ -378,6 +425,58 @@ private:
// Mirrors the GL viewport's defaults; mouse navigation lands later.
float camera_target_[3] = { 0.0f, 0.0f, 0.0f };
float camera_distance_ = 50.0f;
// Perspective by default; toggleProjection() (P key) flips this. When
// true, the per-frame projection builder uses an orthographic matrix
// sized by camera_distance_ × tan(fov/2) so toggling looks like a
// smooth swap rather than a jump in apparent size.
bool projection_ortho_ = false;
// Fly / FPS-mode state. Mirrors GL ViewportWindow::CameraMode::Fps.
// While fps_mode_ is true: cursor is hidden, mouse-look uses raw
// deltas, fps_keys_held_ accumulates pressed W/A/S/D/Q/E/Shift, and
// render() integrates a movement step each frame from those keys.
// exit via Esc (also any unrelated key click) — recenter the cursor
// back at fps_press_center_ so the orbit camera resumes cleanly.
bool fps_mode_ = false;
QSet<int> fps_keys_held_;
QElapsedTimer fps_last_tick_;
QPoint fps_press_center_;
bool fps_ignore_next_mouse_move_ = false;
// Fly base speed in m/s at no-modifier (Shift gives a 5× boost). Default
// 5.0 matches GL fps_move_speed_. Scrollwheel in fly mode adjusts this
// by ×1.25 / ×0.8 per notch, Blender-style — wheel does NOT zoom while
// in fly mode (which would change camera_distance_ underneath us and
// make speed jitter if speed were distance-scaled).
float fps_move_speed_ = 5.0f;
// Per-frame [fly] dt log when WGPU_FLY_DEBUG=1. Diagnoses stutter:
// print dt of each fpsIntegrate call and the prior render's elapsed
// ms. Off by default (env-gated) so the normal log stays clean.
bool fly_debug_ = false;
QElapsedTimer 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;
// Mouse-navigation bindings — mirrors GL's NavBindings + currentNavBindings().
// Selection stays on LMB for every preset (none of the presets steal it),
// so the click-vs-drag distinction at mouseReleaseEvent's pick path keeps
// working. Set at init from WGPU_NAV_PRESET=blender|rhino|revit (default
// blender, matching GL's AppSettings::NavPreset::Blender default).
Qt::MouseButton orbit_button_ = Qt::MiddleButton;
Qt::KeyboardModifiers orbit_mods_ = Qt::NoModifier;
Qt::MouseButton pan_button_ = Qt::MiddleButton;
Qt::KeyboardModifiers pan_mods_ = Qt::ShiftModifier;
// Set by mousePressEvent based on which binding matched; consumed by
// mouseMoveEvent so mid-drag modifier changes don't switch axes.
enum class NavDrag : uint8_t { Inactive, Orbit, Pan };
NavDrag nav_drag_kind_ = NavDrag::Inactive;
float camera_yaw_deg_ = 45.0f;
float camera_pitch_deg_ = 30.0f;
float camera_fov_y_deg_ = 45.0f;