Commit Graph

4 Commits

Author SHA1 Message Date
Dion Moult 51dc31a50b wgpu backend: 10× perf — megadraw, async HiZ, parallel cull, motion mode
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>
2026-05-27 19:46:13 +10:00
Dion Moult 61726e00a4 wgpu backend: CPU frustum cull + per-mesh draw compaction
Stage 6 of the wgpu port. Replaces the one-draw-per-(mesh, instance) loop
with a CPU cull pass that survives one drawIndexed per non-empty mesh
with packed instanceCount.

Adds to WgpuModelGpuData:
  - visible_buffer: u32[] storage SSBO, pre-sized to instance_count at
    applyCachedModel so the bind group reference never invalidates.
    Re-uploaded each frame via wgpuQueueWriteBuffer.
  - mesh_draws: per-mesh schedule (first_instance, instance_count,
    first_index, base_vertex, index_count). instance_count==0 means the
    mesh contributed nothing this frame and the draw is elided entirely.

cullModelCpu per-frame:
  - Extract 6 frustum planes from the same VP we write into the uniform.
    WebGPU clip-space z is [0, 1], so near plane = matrix row 2 (not
    row 3 + row 2 as in GL); rest of the derivation is standard.
  - Per-instance AABB-vs-frustum test using the p-vertex shortcut
    (cheapest correct early-out for AABBs).
  - Bucket survivors by mesh_id; flatten into a contiguous u32 list;
    upload via wgpuQueueWriteBuffer. Per-mesh slice is [first_instance,
    first_instance + instance_count).

WGSL adds @group(1) @binding(3) var<storage, read> visible: array<u32>
and an extra indirection: instance_idx = visible[iid]; the rest of the
shader is unchanged. firstInstance on each drawIndexed offsets into
visible[], so each mesh reads its own slice.

Verified two ways:
  1. basic.ifc (3 instances, all on-screen) renders pixel-identically
     to pre-stage-6 — proves cull keeps everything it should.
  2. basic.ifc + a synthetic instance placed at (100, 100, 100) is
     culled cleanly: only the cube renders, the far quad is rejected
     by the frustum test. Proves cull actually rejects out-of-frustum
     geometry rather than passing everything through.

Contribution culling, HiZ, and LOD selection arrive in stages 7 and 8;
they all hook into the same cullModelCpu seam.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:05:58 +10:00
Dion Moult bbf2bfde92 wgpu backend: vertex-pulling main render pass
Stage 3 of the wgpu port. Replaces the clear-only render loop with the
full main shading pass:

  - WGSL port of the GL main shader. Vertex-pulling: the vertex storage
    buffer is read as array<u32> in the shader, with pos/normal/color
    decoded manually per vertex. baseVertex (set per draw to mesh's
    vertex offset) folds into @builtin(vertex_index) automatically;
    firstInstance carries the instance slot for @builtin(instance_index).
    No vertex-input layout — vertex pulling means no IA bindings.

  - Render pipeline bound to depth-32-float (write-on, less compare),
    back-face cull, CCW front face. Pre-multiplies a [-1,1]→[0,1] z-remap
    matrix onto Qt's projection so WebGPU's clip-z convention is met.

  - Two bind groups: group=0 per-frame (uniform with view-proj + key/fill
    light + hemisphere ambient), group=1 per-model (three read-only
    storage buffers: vertices, mesh quant, instances).

  - Depth texture is created lazily and recreated on surface resize.

  - Orbit camera state on WgpuViewportWindow with viewAll() that frames
    the union of all loaded models' world AABBs after the first load.
    Mouse navigation lands later.

  - Draw loop: one drawIndexed per (mesh, instance) pair per model. This
    is correct but CPU-heavy on dense scenes; stage 6 introduces the cull
    + compacted visible list that lets multiple instances of one mesh
    collapse to a single call, and the eventual GPU-driven cull (post
    sunset of the GL backend) goes further.

Verified on /tmp/quad_v13.ifcview (1 mesh, 1 instance) and on a real v13
sidecar baked from basic.ifc via the GL minimal viewer (3 meshes,
3 instances, 864 B verts). No wgpu validation errors fire across pipeline
creation, depth attachment, bind groups, or the draw loop on either.
Visual confirmation deferred until --screenshot lands (task #10) which
is being pulled forward next so we don't keep flying blind.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:19:09 +10:00
Dion Moult 9daa5fe195 wgpu backend: load .ifcview sidecars onto GPU buffers
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>
2026-05-27 12:48:03 +10:00