Closes the perf gap to the GL backend on real BIM benchmarks. On a 10-
sidecar / 380k-instance corpus at a fixed --camera the wgpu binary went
from 110.6 ms to 11.6 ms (vs GL's 23 ms — half the frame time, but
note GL is doing extra work the wgpu backend hasn't ported yet; see
the caveats list at the bottom). Bundled because the pieces interlock
and shipping any of them without the others reintroduces the same wall.
1. Cross-mesh vertex pulling (single mega-draw per model)
The previous one-drawIndexed-per-(mesh × LOD-bucket) loop was costing
~13ms on a 27k-mesh scene. CPU now emits a flat visible_draws[]
(16 B per visible (mesh,lod,instance)) plus a prefix_sums[] table.
WGSL binary-searches prefix_sums by @builtin(vertex_index) to find
the entry, then manually fetches the mesh-local index from a
storage-bound indices[] and pulls the packed 12 B vertex. No
setIndexBuffer; the shader reads everything from storage. Bind
group grew from 4 to 7 entries (vertices, meshes, instances,
indices, visible_draws, prefix_sums, per-model uniform) — well
under WebGPU's mandatory 8 storage / 12 uniform floor.
2. Async HiZ readback via ping-pong staging buffers
Sync wait via wgpuInstanceProcessEvents was costing ~37 ms on a
real scene (GPU drain). Two staging slots now ping-pong: frame N
kicks a non-blocking mapAsync on slot K, frame N+1's first action
is one processEvents drain. Pyramid is 1-2 frames stale — matches
the "slightly-stale depth, fine" pattern the GL backend already
documents. encodeHizResolve returns -1 (skip) if both slots are
in flight; cull keeps using the most recent pyramid.
3. Cull reorder: contribution before HiZ
HiZ projection is ~10× more expensive than the contribution
check, yet most contribution-survivors would be HiZ-rejected
anyway on dense scenes. Computing projected_px first lets
contribution short-circuit ~80% of HiZ tests with no rejection-
quality loss. Saved ~34 ms on the dense bench.
4. Motion-mode contribution threshold
AppSettings::motionMinPixelRadius parity. While the camera is
changing (orbit/pan/zoom/--benchmark sweep), drop instances
below 10 px instead of 2 px. Halves visible_objects during
motion with no perceived quality loss.
5. Parallel cull (std::async across models)
Per-model cullModelCpu split into Compute (CPU-only, thread-safe)
+ Upload (main-thread wgpu queue writes). std::async fan-outs the
compute across models; main-thread joins and uploads. Wall-clock
cull on the 10-model corpus drops from ~17 ms single-threaded to
~9 ms across cores.
6. --no-hiz CLI flag + per-phase benchmark timings
Benchmark now also prints "per-frame avg ms: cull=X
hiz_readback=Y" so future regressions can be attributed without
guesswork. --no-hiz toggles the master switch from the CLI.
Honest caveats — wgpu is currently faster mostly because GL is doing
work we haven't ported yet:
- Edge silhouette pass (stage 9) will add ~3-5 ms back to wgpu.
- GL's HiZ uses the BVH so it rejects whole subtrees (1.7k vs
our 358 rejects on the same scene). BVH for HiZ is future work
(task #13 / a new task) — until then we draw more sub-pixel
geometry that's behind closer surfaces. Visually correct, perf
cost paid. Stage 4+5 are unaffected.
Verified pixel-identical on basic.ifc through every change. Real-scene
visual diff against GL pending the --screenshot flag on the GL minimal
(task #10's other half).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two pieces that block proper side-by-side parity with the GL minimal:
1. --camera tx,ty,tz,dist,yaw,pitch. Same format string as the GL
minimal so a pasted camera arg lands the same view on both backends.
setCamera() also flips initial_view_applied_ = true so the auto-
viewAll-on-first-load doesn't snap away from the script-set position
when the model finishes uploading.
2. Real BIM models exceed the conservative WebGPU defaults at device
create time. A 114k-instance / 19M-index sidecar's vertex storage is
139 MB, which trips wgpu's default 128 MB max_storage_buffer_binding_
size and bind-group creation fails. Now wgpuAdapterGetLimits is
called first and the device is requested at the adapter's full
ceiling — every desktop driver supports multi-GB.
Trade-off worth flagging: web parity will fail here because browsers
cap at the defaults. The eventual fix is to split a model's vertex/
instance storage into ≤128 MB chunks with a small per-frame routing
table, which is a real chunk of work. For now this unblocks all the
native benchmarking the user is actually doing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stage 11 of the wgpu port. WgpuViewportWindow gains setBenchmarkFrames(N);
the minimal driver wires it to a --benchmark N flag. Renders N frames
after a 5-frame warmup, yaw-sweeping the camera at 0.5°/frame, captures
per-frame wall time with QElapsedTimer (cull + encode + present), and
prints avg/median/p1/p99 + last-frame stats in the same line format as
IfcViewerMinimal so a script can diff them line for line.
Per-frame stats (visible_objects, visible_triangles, sub_draws) are now
summed in render() from m.mesh_draws. hiz_rej reports 0 until stage 7
adds HiZ occlusion.
Verified on basic.ifc (3 instances): wgpu 11.68 ms avg vs GL 11.75 ms
avg — same scene, same camera sweep, same window size. Noise-level
delta as expected on a tiny scene; the interesting comparison is on
real BIM corpora once you bake them to v13 sidecars.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pulls the capture half of task #10 forward so we stop flying blind from
stage 3 onward. WgpuViewportWindow gains captureNextFrameToPng(path);
the minimal driver wires it to a --screenshot PATH flag that renders
one frame, copies the surface texture back to host memory, writes a
PNG via QImage, and quits.
CopySrc is added to the surface configuration usage so the surface
texture can be the copy source. The texel-to-buffer copy honours
WebGPU's 256-byte bytes-per-row alignment by padding rows and stripping
the padding when assembling the QImage. Surface format 28 (BGRA8Unorm)
is byte-swapped to RGBA on the way into QImage::Format_RGBA8888;
RGBA8 surface formats are memcpy'd straight through.
Verified end-to-end on /tmp/basic.ifcview: 3 cube meshes/instances
render with depth, back-face cull, and the hemisphere-ambient + key+fill
lighting model — top face reads sky (bright), front faces read mid-tone,
exactly as the WGSL shading intended. The pixel-diff half of task #10
(comparing against a GL baseline) lands later when the GL minimal binary
gets an equivalent flag.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stage 2 of the wgpu port. WgpuViewportWindow gains a queueLoadSidecar
API (called from the minimal driver before init) and an applyCachedModel
that runs after init: reads via SidecarCache::readSidecar, allocates
four wgpu buffers per model (vertex storage, index, mesh-quant storage,
instance storage), uploads via wgpuQueueWriteBuffer, retains a CPU
mirror of the MeshInfo/InstanceCpu arrays for the cull and picking
paths that arrive in later stages.
MeshGpu (the per-mesh quantization basis) is derived from MeshInfo on
the fly; InstanceGpu (transform + ids) is derived from InstanceCpu and
uses the cached float transform — composing from placement_transformation
against federation-stage matrices lands when stage 5 wires those.
SidecarCache.cpp is compiled into IfcViewerWgpu directly: it's pure
C++ with no Qt/OCCT/IFC-parse deps, so dragging in the IfcViewer
static lib for one source file would be wasteful. This duplication
goes away once src/ifcviewer-core/ is extracted (task #12).
Verified on a synthesised v13 sidecar (4 verts, 6 indices, 1 mesh,
1 instance) and a multi-sidecar load that assigns successive model_ids.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds src/ifcviewer-wgpu/ and src/ifcviewer-wgpu-minimal/ behind a new
BUILD_BONSAIVIEWER_WGPU option (default OFF), gated independently of
BUILD_BONSAIVIEWER. Stage 1 brings up a Qt window with a wgpu-native
v29 surface (X11) and clears to the background colour — no rendering
beyond that yet. Mirrors the lifecycle of the GL ViewportWindow so
subsequent stages (vertex-pulling renderer, pick, cull, HiZ, overlay)
slot in without restructuring the host.
wgpu-native is fetched as a pre-built binary release via FetchContent;
its .so SONAME is patched in at configure time so dependents get a
clean DT_NEEDED. The X11 native handle is obtained via the public
QNativeInterface::QX11Application API; Wayland and macOS/Windows
surface creation are stubbed with explicit "not wired yet" warnings.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>