Commit Graph

4 Commits

Author SHA1 Message Date
Dion Moult 8074541057 Surface VRAM shortfall to the user and let them unload models
When the geometry in view needs more GPU memory than the cache can
hold, the viewer keeps the largest on-screen chunks resident and streams
the rest as the camera moves. That is the right degradation, but it was
invisible: nothing told the user the scene did not fit, and the only
lever was removing or hiding models, neither of which is "keep it in the
federation but stop spending GPU memory on it".

Viewer core:
- ModelGpuData::unloaded, with drawable() = !hidden && !unloaded now the
  test every cull / draw / pick / streaming pass uses. unloadModel evicts
  every chunk and releases the model's own buffers; loadModel recreates
  them from the CPU mirrors (no disk read) and lets chunks stream back.
  Recompose keeps the CPU instances current while a model is unloaded so
  a reload sees up-to-date transforms. The MeshGpu/InstanceGpu record
  builders are factored out so load and reload share them.
- FrameStats reports the camera's working set: chunks wanted, how many
  of those are not resident, and their bytes.
- modelVramBytes / isModelUnloaded accessors, forwarded by ViewportWindow.

BonsaiViewer:
- Models tree gains a memory column (name | MB | eye) refreshed once a
  second and on load-state changes; unloaded models read "unloaded" in
  italics. The viewport stays the single authority for the state;
  SessionState only carries the modelLoadStateChanged notification.
- Context menu: "Unload Model" / "Load Model", distinct from hide and
  remove, reporting the MB freed in the status bar.
- Status bar notice, independent of the perf-stats toggle, once the
  shortfall has persisted for 3 s (a moment of missing chunks after any
  camera move is normal): "GPU memory full: N of M visible chunks (X MB)
  not loaded", with a tooltip pointing at Unload. The perf label also
  shows "N/M chunks waiting".

Verified on the GPU: unloading a 497 MB model frees it immediately with
the others still rendering; reloading streams all 180 chunks back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:42:13 +10:00
Dion Moult ab99024307 ifcviewer: budget the geometry cache and make required allocations fallible
Loading enough models drove the chunk pool to the driver's refusal point,
after which the first click aborted: the pick attachments are allocated
lazily, wgpu-native reported their OOM as a validation error nobody
observed, and the invalid views reached wgpuQueueSubmit, which panics
across the FFI boundary. Two policy defects compounding: the cache was
allowed to take the last byte, and nothing but the pool's own growth was
treated as fallible.

GPU memory is now two tiers. Required allocations (per-pixel attachments,
a model's metadata buffers, readback staging) are eager, deterministic
and fallible; the chunk pool is an elastic cache that grows only to a
budget and yields whenever a required allocation fails.

- GpuBudget (pure, unit-tested): desktop derives the budget from the
  driver's free-memory report minus a reserve for the attachments at 4K;
  web keeps the wasm-heap cap; either lowers it on pressure. The budget's
  source differs per platform, the mechanism does not.
- GpuAllocScope: the OOM/Validation error-scope dance in one place,
  synchronous on wgpu-native, provisional on Dawn-web. BufferPool's
  inline copy now uses it.
- BufferPool::shrinkToCapacity releases whole sub-buffers newest-first
  after the owner empties them; growth clamps to the budget instead of
  overshooting.
- ViewportCore::allocateRequired runs any required creation under a
  scope and, on failure, lowers the budget, evicts and releases cache
  sub-buffers, waits for the device to reclaim them, and retries until
  it fits or the cache is at its floor. Pick attachments are created with
  the other attachments in configureSurface; render() skips a frame
  rather than submit invalid views; a model whose buffers cannot fit is
  not loaded instead of aborting.

Verified on a 4 GB GeForce: the pool clamps itself at the derived budget
(256+256+67 MB for a 579 MB budget) and, in a standalone check against
the real device, a pool grown to the driver's refusal point observes a
failed required allocation, releases 320 MB and succeeds on retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:42:12 +10:00
Dion Moult b7d2b2fa3a bonsaiviewer: show pool and device VRAM in the performance stats
FrameStats gains the geometry pool's used/capacity bytes and, on desktop,
the device-wide used/total reported by the driver (NVML via dlopen, or
amdgpu/i915 sysfs, matched to the wgpu adapter's vendor/device id so a
switchable-graphics laptop reports the card wgpu actually picked). The
device query is polled once a second, not per frame. Web has no VRAM
query, so the device figure is omitted there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:42:12 +10:00
Dion Moult ebb9c91bdf ifcviewer: move render() body into ViewportCore (#84-x)
The frame loop — surface acquisition, parallel cull dispatch, streaming
drive, two-pass main render, HiZ resolve, edge pass, screenshot
capture, FrameStats emission, interactive / bench heartbeat, bench
summary + auto-quit — all live in ViewportCore now.
ViewportWindow::render() shrinks to the Qt-only prelude:
isExposed() guard, fpsIntegrate() (fly-mode WASD step), then
core_.render().

The overlay renderer stays Qt-bound (OverlayRenderer.h carries
QString labels). Core reaches it via two new ViewportHost virtuals:
encodeOverlaysInMainPass (section gizmos, highlights, pivot, lines,
points — in-MSAA-pass) and encodeOverlaysPostMain (corner axis,
marquee, labels — on the resolved surface). The QtViewportHost
implementation in ViewportWindow forwards each to overlays_.X().

FrameStats moves to its own Qt-free header (FrameStats.h) with
ViewportWindow::FrameStats re-exported as a using-alias so the
bonsai-side signal binding keeps working. ViewportHost::onFrameStats
replaces the placeholder 4-double signature with the typed POD.
OverlayFrame moves alongside (OverlayFrame.h) so the host overlay
callbacks can carry it without dragging Qt into core.

Bench + frame-stats + cull-tuning state (min_pixel_radius_,
motion_min_pixel_radius_, lod1_pixel_threshold_, cull_threads_enabled_,
prev_camera_*, has_prev_camera_, last_cull_was_motion_, last_visible_*,
last_cull_*ms_, last_stream_ms_, bench_*, interactive_frame_count_,
frame_time_ms_window_/_sum_/_count_/_head_) move to ViewportCore; VW
keeps reference aliases so the env-var prelude, setBenchmarkFrames,
and the various tool keybind setters keep compiling unchanged.

Smoke checks: --screenshot path renders + saves a clean PNG;
--benchmark 10 runs the warm gate, prints the per-frame log + summary,
and exits cleanly via host_->quit().
2026-06-06 20:27:33 +10:00