Commit Graph

22 Commits

Author SHA1 Message Date
Dion Moult a1693259b8 wgpu backend: BVH cull (opt-in via --bvh, default off)
Stage 15 implementation lands but doesn't pay off as default-on. On a
562k-instance / 18-model scene with a centred camera, the BVH walk
adds ~10 ms of cull cost without rejecting enough subtrees to
compensate — every interior node's AABB straddles the frustum, so
descents go all the way to leaves anyway. Linear scan beats it by
that 10 ms.

GL's BVH works better mainly because they do full cull (frustum + HiZ
+ contribution) at every node — their per-test cost is lower (likely
SIMD-vectorised) and they get more subtree rejections. My current
impl does frustum-only at interior nodes (HiZ there cost more than
it saved on the smaller dataset).

For now, gate the whole BVH walk behind --bvh, default off. The
infrastructure (BvhAccel build at applyCachedModel, walk in cull,
release) stays in place so it's a one-flag toggle to measure either
side. Real default-on requires further tuning — see updated task #15.

Measured on 562k-instance scene:
  --bvh on  → 25.9ms total (cull 25.4ms)
  --bvh off → 15.4ms total (cull 14.5ms)   ← default

For comparison, GL on the same scene + camera:
  GL → 18.2ms total (cull 8.5ms wall, multi-threaded BVH)

Net: wgpu beats GL by ~3ms total despite slower cull, because the
GPU side (no edge-pass cost, async HiZ readback, lean main pipeline)
gives back more than the cull deficit.

Also added task #17 (GPU compute-shader cull) as the asymptotic
answer — both backends hit CPU cull as the ceiling on ≥500k scenes;
moving it to a compute shader drops it to sub-ms regardless.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:04:44 +10:00
Dion Moult 7dc13eb104 wgpu backend: chunk vertex storage to fit browser limits + settle frame
Two pieces:

1. Per-chunk vertex storage (stage 13)
   WebGPU mandates maxStorageBufferBindingSize ≥ 128 MB. Real BIM models
   routinely exceed that (one of yours is 139 MB vertex). Without
   chunking, every browser load would fail with
   "exceeds max_storage_buffer_binding_size".

   Strategy: each model's vertex data is split into ≤ 128 MB chunks at
   applyCachedModel time. Each chunk gets its own vertex_storage buffer,
   visible_draws / prefix_sums buffers, per_chunk_uniform, and bind group.
   Index buffer, instance storage, and mesh storage stay single-per-model
   (they fit well under the cap on every scene we've seen). Mesh-to-chunk
   assignment is bake-time-deterministic (walks meshes in order, opens a
   new chunk when adding the next would overflow).

   Cull buckets visible instances by their mesh's chunk; render issues
   one drawcall per non-empty chunk per model. WGSL is unchanged — the
   binary-search vertex pulling works identically per chunk because
   base_vertex is now CHUNK-LOCAL (the chunk's bind group binds its own
   vertex_storage).

   Single code path: chunking is ALWAYS on at 128 MB regardless of
   target. Cost on desktop is a handful of extra drawcalls per frame
   (1 per non-empty chunk; typical models = 1-3 chunks). Negligible.

   A mesh whose vertex range is itself > 128 MB can't fit in any chunk
   and would need splitting — typical IFC meshes are nowhere near that
   (hundreds of verts), and applyCachedModel warns loudly if one ever
   appears.

   --web-limits CLI flag requests the WebGPU mandatory floor limits
   (128 MB max storage binding, 256 MB max buffer) instead of the
   adapter's actual max. Used to verify chunking actually fits through
   browser constraints — turns "trust me, web will work" into a hard
   test. The 139 MB scene loads cleanly with --web-limits.

2. Settle frame after motion (bug fix)
   Reported regression: after orbiting, sub-pixel instances dropped by
   motion-mode contribution culling stayed missing after the camera
   stopped. Event-driven rendering means no frame is scheduled after
   mouse-up, so the cull never re-ran at the still threshold.

   Fix: track last_cull_was_motion_. If this frame used the motion
   threshold, requestUpdate() after present to schedule one settle
   frame. Next frame: camera_moved = false → still threshold → small
   instances reappear. Matches GL's last_cull_was_motion_ behaviour.

Verified pixel-identical on basic.ifc; loads the user's dense scene
successfully under --web-limits (chunks=2 on the 139 MB model,
chunks=1 on the others).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 21:05:38 +10:00
Dion Moult 4dfe27e251 wgpu backend: per-element visibility + H/Shift+H/I hotkeys
Closes the interactive selection loop. After this commit you can:
  - LMB-click an object  → highlight (selection)
  - Press H              → hide all selected
  - Press Shift+H        → show all (clear hidden set)
  - Press I              → isolate selected (hide everything else)

WgpuVisibilityState (new header) is a plain unordered_set<uint32_t> of
hidden object_ids — mirrors src/ifcviewer/Visibility.h's shape but
stays Qt-free for the ifcviewer-core extract later.

cullModelCpuCompute consults visibility_.isHidden(inst.object_id)
before the frustum test — hidden instances cost nothing on every axis
(no draw, no depth contribution, no pick hit). The CPU vector is
read concurrently by the parallel cull workers, which is safe because
mutations only happen between renders (handlers requestUpdate after
mutating; render reads).

Hiding deselects (matches GL behaviour: H clears the now-invisible
selection rather than leaving phantom selected-but-invisible ids).

Stage 5's last piece — clip planes — is deferred. Adding the uniform
array + WGSL discard is mechanical, but the section-tool UI that
drives them isn't ported yet (minimal viewer has no way to place a
clip plane), so it'd ship as empty plumbing. Will land alongside the
section-tool port.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:28:07 +10:00
Dion Moult 54fa7d8379 wgpu backend: selection visualisation + global object_id rebase
Closes the loop on stage 4 (pick): clicking an object now highlights it
on screen. Plus the prerequisite plumbing for selection to behave
correctly across multi-sidecar loads.

Pieces:

  1. WgpuSelectionState (new header)
     CPU-side multi-set + active-id, mirroring the GL Selection.h shape
     but pure stdlib (no Qt deps) so it can move into ifcviewer-core
     later without dragging Qt across. clear/replace/add/remove/toggle
     APIs + a fillFlagsArray helper that packs (selected, active) into
     a u32 bitmap indexed by object_id.

  2. selection_flags storage buffer + frame_bgl bump to 2 entries
     Indexed by object_id, bit 0 = selected, bit 1 = active. Lives in
     the frame bind group (group=0 binding=1) because object_ids are
     globally unique — making it model-scoped would be the wrong cut.
     ensureSelectionFlagsBuffer grows geometrically (64 → 128 → … u32)
     as new models push next_object_id_ up, rebuilds the frame bind
     group when it does.

  3. Global object_id rebase in applyCachedModel
     Each sidecar's local ids start from 1 and collide across files;
     pick was previously ambiguous on multi-model loads. We now add
     next_object_id_ as a base offset, rewrite InstanceCpu.object_id
     (CPU mirror stays consistent) + InstanceGpu.object_id (what pick
     reads back), and bump next_object_id_ by the model's max + 1.

  4. WGSL main fragment reads sel_flags
     Vertex shader passes inst.object_id through to fragment as
     @interpolate(flat). Fragment reads sel_flags[object_id], mixes
     (0.2, 0.6, 1.0) at 0.45 for in-selection and (0.4, 0.8, 1.0) at
     0.40 on top for active. Same constants as the GL main shader.

  5. Mouse → selection
     LMB-click-without-drag pick result feeds the selection:
       no modifier → replace
       Shift      → add
       Ctrl       → remove (active migrates to another id in the set)
       miss + no modifier → clear
     uploadSelectionFlagsIfDirty repacks + writes the GPU bitmap at
     the top of the next render(); no upload on still frames.

Pick pipeline is unchanged — it already outputs the per-instance
object_id, and that's what the selection storage indexes.

Visibility + clip planes are pending follow-ups in stage 5 (mostly
small, share the same buffer-lifecycle pattern). Edge silhouette
(stage 9 partial), --screenshot diff harness (stage 10 partial),
ifcviewer-core extract (stage 12), and web chunking (stage 13) all
still pending.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:25:05 +10:00
Dion Moult 1bbcd10bc0 wgpu backend: pick pass with sync R32UInt readback
Stage 4 of the wgpu port. LMB-click-without-drag now resolves the
object_id under the cursor by running a dedicated pick render and
copying back the single texel at the click position.

  - Pick pipeline reuses the existing pipeline_layout_ (same bindings
    as main: frame uniform at group=0, per-model storages at group=1).
    Different vs / fs entry points (vs_pick / fs_pick) in the main
    WGSL module — the vertex pulling logic is duplicated for now but
    the bind group layout match means no pipeline_layout rebuild and
    pickObjectAt can reuse the current frame's already-uploaded
    visible_draws + per-model bind groups.

  - Pick FBO: surface-sized R32UInt color attachment + Depth32Float
    depth, both single-sample (no MSAA — pick needs exact texel
    access). CopySrc on the color so we can copyTextureToBuffer the
    1×1 click region. Recreated on surface resize.

  - pickObjectAt: encodes a one-shot pick pass + a single texel copy
    into a 256-byte staging buffer, submits, mapAsync, sync-spins
    processEvents until ready. Synchronous wait is fine here — pick
    runs on click, not per-frame, so a sub-ms stall is invisible.

  - Mouse integration: existing LMB drag-orbit preserved. A 3-pixel
    threshold promotes drag (set nav_dragged_); release without
    dragging triggers pickObjectAt at the release coords (logical
    Qt → physical pixels via devicePixelRatio). object_id is logged;
    selection state to consume the id arrives with stage 5.

Object_id 0 means miss (clear value); the pick attachment is cleared
to 0 before each pass and the fragment writes the instance's
object_id, so any non-zero result is a real hit on a drawn instance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:12:38 +10:00
Dion Moult f4243038bd wgpu backend: edge silhouette post-process (ported GL renderEdgePass)
First piece of stage 9 — the dark outlines BonsaiViewer / the GL backend
draw at depth discontinuities. Ported the GL renderEdgePass algorithm
verbatim, including the three things my earlier attempt missed:

  1. Linearise depth to view-space metres before the Laplacian. Raw
     [0,1] clip-z is heavily non-linear so a fixed-threshold edge
     detector only caught near-camera silhouettes. Now reverses the
     wgpu z-remap (z * 2 - 1 back to GL NDC) then standard reverse-
     perspective to view-z.

  2. Threshold scales with depth: t = EDGE_THRESHOLD * c. A 4 mm gap
     between two surfaces reads the same whether it's 0.5 m or 50 m
     away from the camera.

  3. Multiplicative blend (Dst, Zero) with fragment output of
     vec3(1 - edge). Strictly darkens, never brightens. Matches GL's
     (GL_DST_COLOR, GL_ZERO) blend.

Constants EDGE_SCALE=6.0 / EDGE_THRESHOLD=0.004 are GL's tuned values.
Camera near/far hard-coded to 0.1 / 10000 (the viewport defaults);
they'll move to a small uniform when AppSettings ports across.

Pipeline state: depth-attachment-less, sample count 1, blend on, no
cull. Reuses depth_texture_'s TextureBinding usage that HiZ added.
Render pass loads the resolved main-pass colour (LoadOp_Load) and
writes back through the multiplicative blend; encoded between the main
pass and the HiZ resolve so HiZ uses the same MSAA depth that produced
the edges. edge_bind_group_ rebuilds lazily when depth_view_ is
replaced (mirrors the HiZ bind group lifecycle).

Perf cost on the 10-sidecar / 380k-instance benchmark: 0.1 ms (11.5 →
11.6 ms). Fullscreen depth-laplacian is essentially free on this GPU.

Remaining stage 9 work: HUD/labels/lines/points overlay primitives,
which need the QPainter-→-texture path. Lower visual priority than
edges; handled in a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:54:29 +10:00
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 406124ca3d wgpu backend: HiZ occlusion culling
Stage 7 of the wgpu port. Per-frame after the main render pass:

  1. encodeHizResolve runs a depth-only render pass that samples the
     MSAA depth texture (sample 0) and max-reduces it into a small
     single-sample Depth32Float target (256 × ~h-aspect). Implemented
     as a fullscreen-triangle WGSL pipeline; one nested loop per
     output texel over its source rect. WebGPU has no built-in depth
     resolve, so this combined resolve+downsample fragment shader is
     the way.

  2. copyTextureToBuffer writes the small resolved depth into a
     CPU-mappable staging buffer (≈ 160 KB at 256×160).

  3. readbackAndBuildHizPyramid maps the staging buffer (sync via
     wgpuInstanceProcessEvents — small enough that the stall is
     well under a millisecond), strips per-row padding, and CPU
     max-reduces a full mip pyramid (level 0 → 1×1). Stores the VP
     used so the next frame can project AABBs into the same space.

Next frame, cullModelCpu calls aabbOccludedByHiz after the frustum
test: projects all 8 AABB corners through hiz_vp_, computes the
screen-space AABB and the nearest projected z, picks the mip level
where the AABB covers ≤ 2 texels per axis, samples that level's 2×2
window, and culls iff min_z > max_pyramid_depth in [0,1] z.

Plumbing changes:
  - depth_texture_ gains TextureBinding usage so the resolve shader
    can read it.
  - hiz_enabled_ master switch defaults true; mirrors IFC_NO_HIZ in
    the GL backend. Disabling skips encode + readback entirely.
  - Bench output's "hiz_rej N" field now reflects actual rejections.

Verified: basic.ifc (3 instances, no occluders) renders pixel-
identical to pre-HiZ — proves the test rejects nothing it shouldn't.
Real rejection counts need a dense scene; this should drop visible-
objects count noticeably on real BIM benchmarks where back-of-room
walls hide each other.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:16:13 +10:00
Dion Moult 6ce2b564c1 wgpu backend: per-instance contribution culling in cullModelCpu
Quick win before the proper HiZ stage. Adds a min_pixel_radius
threshold (defaults 2.0 to match AppSettings::minPixelRadius() in GL):
instances whose projected bounding-sphere radius falls below it are
dropped from the per-mesh buckets entirely.

The projected_px math (radius_world * focal_px / view_z) is now
computed once per instance and shared with the LOD pick that uses the
same number. Saves one square root per instance per frame on dense
scenes vs the previous code path that only computed it inside the LOD
branch.

Expected impact on real BIM benchmarks: visible-objects count drops by
roughly 10×, matching the GL backend's number. Without this fix, wgpu
was drawing every frustum-surviving sub-pixel instance — most of the
work and most of the geometry the GL backend wasn't even submitting.

Motion-mode threshold bump (10.0 in GL during camera drag) lands
later when mouse-driven motion tracking is wired up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:04:42 +10:00
Dion Moult 7893135790 wgpu backend: --camera flag + request adapter's max buffer limits
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>
2026-05-27 14:58:18 +10:00
Dion Moult f9a9273ecc wgpu backend: match GL orbit + viewAll math so pivots align
Reported regression: --benchmark on the same sidecar visibly rotated
around a different point in the wgpu binary than in IfcViewerMinimal.
Root cause was two camera-convention drifts:

  1. orbitEye placed the camera at (sin yaw, -cos yaw) from target;
     the GL backend uses (cos yaw, sin yaw). Same target, but the
     camera faces a different side of the model at yaw=0, which made
     the orbit feel like it pivoted around a different point even
     though the actual world-space target was the same. Now exactly
     matches GL ViewportWindow::updateCamera:
        eye.x = target.x + dist * cos(pitch) * cos(yaw)
        eye.y = target.y + dist * cos(pitch) * sin(yaw)
        eye.z = target.z + dist * sin(pitch)

  2. viewAll's distance was an ad-hoc 0.6 * diag / tan(half_fov);
     GL uses frameAabb(mn, mx, 1.10): tan_half = tan(fov/2),
     min_aspect = min(aspect, 1), distance = (radius / (tan_half *
     min_aspect)) * 1.10. Aspect-aware so portrait windows pull back
     enough that the bounding sphere still fits on the tighter axis.
     Now ported verbatim.

Also logs the computed target + distance on viewAll so a follow-up
side-by-side run prints both backends' framings and any remaining
discrepancy is easy to spot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:52:12 +10:00
Dion Moult 244a145255 wgpu backend: per-instance LOD0/LOD1 pick in the cull
Stage 8 of the wgpu port. cullModelCpu now buckets each visible instance
by (mesh_id, lod) instead of (mesh_id), and emits one MeshDraw record
per non-empty bucket. LOD pick projects the instance's world-space
bounding sphere to pixels via

    projected_px = world_radius * focal_px / view_z

where focal_px = viewport_h / (2 * tan(fov_y/2)) and view_z is the
forward·(center-eye) depth. When projected_px < lod1_pixel_threshold_
AND the mesh has a baked LOD1 slice (MeshInfo.lod1_index_count > 0),
the instance draws the LOD1 index range instead of LOD0; baseVertex
and the vertex storage are shared between LODs.

mesh_draws can now grow to up to 2 × meshes.size() per frame (LOD0 + LOD1
slice per mesh). The visible_buffer layout per mesh becomes
[LOD0 instances | LOD1 instances] contiguous, with each MeshDraw
referencing its own firstInstance offset.

lod1_pixel_threshold_ defaults to 30 (mirrors AppSettings::
lod1PixelThreshold() in the GL backend); set to 0 to disable LOD1
entirely (always LOD0). AppSettings port lands in a later commit.

Verified: basic.ifc (3 tiny instances, no LOD1 baked by meshoptimizer
since each mesh is well under the 500-tri threshold) renders pixel-
identical to pre-stage-8 — proves the all-LOD0 path is preserved.
Real LOD switching needs a sidecar where buildLods produced LOD1 slices.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:47:23 +10:00
Dion Moult 4596f2e584 wgpu backend: lighting parity, MSAA, cavity shading, fix sRGB output
Closes the visible gap to BonsaiViewer down to just the post-process
edge silhouette pass (still pending in task #9). Four changes bundled
because together they bring up the parity story:

  - WGSL fragment now applies cavity = clamp(length(fwidth(n))*1.5,
    0, 0.35) and multiplies by (1 - cavity). Matches GL shader.

  - Lighting constants switched to GL's exact values: key (0.3, 0.5,
    0.8), fill (-0.3, -0.5, 0.8), sky tint (0.55, 0.60, 0.70), ground
    tint (0.35, 0.32, 0.28). My initial guesses were close but not
    identical; matching them means side-by-side diffs only flag actual
    pipeline differences, not lighting tweaks.

  - 4× MSAA: render pass writes into a MULTISAMPLE color attachment
    (surface_format_-matched), resolves into the surface texture for
    present. Depth is also 4 samples. Pipeline.multisample.count = 4.
    ensureMsaaColorTexture / releaseMsaaColorTexture mirror the depth-
    texture lifecycle. Matches GL minimal's QSurfaceFormat::setSamples(4).

  - sRGB output fix. wgpu-native's Vulkan swap chain on X11 treats
    BGRA8Unorm as sRGB-output (applies linear→sRGB encoding on shader
    writes), even though caps.formats[0] reports plain Unorm. The GL
    backend writes to a non-sRGB framebuffer with no such conversion,
    so a clearValue of (0.125, 0.137, 0.161) lands as bytes (32, 35,
    41) on GL but (99, 104, 112) on wgpu — ~3× brighter. Pre-decoding
    via srgbToLinear on (a) the clearValue in C++ and (b) the final
    fragment colour in WGSL makes wgpu's implicit encode round-trip,
    so the final bytes match GL. Verified via screenshot pixel sample:
    #202329 background reads as exactly (32, 35, 41).

Remaining visible gap to BonsaiViewer is the dark-line edge silhouettes
(renderEdgePass in GL, depth laplacian → outline). That belongs with
the overlay / post-process work in task #9.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:44:13 +10:00
Dion Moult a95437dd64 wgpu backend: match GL pitch sign so drag-down tilts the camera up
Drag-down was decreasing pitch (camera diving), opposite to the GL
viewport's convention where drag-down increases pitch so the top of
the object rotates toward the viewer. Yaw direction was already
correct. Matches the existing user muscle memory from IfcViewerMinimal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:29:04 +10:00
Dion Moult de44de26f8 wgpu backend: clearer sidecar-load diagnostics + tilde expansion
The single "(file missing, wrong magic, or schema mismatch)" message
was making triage harder than necessary. loadSidecar now expands a
leading ~/ (shells skip it inside double quotes, which trips up paste-
from-launcher), and on failure peeks the file's header itself to
report exactly which check failed:

  - "Sidecar not found"          — file doesn't exist
  - "Sidecar unreadable"         — exists but open failed
  - "Sidecar truncated"          — <12 bytes
  - "Sidecar magic mismatch"     — wrong magic, reports got vs expected
  - "Sidecar schema mismatch"    — wrong version, reports both numbers
                                    and suggests re-baking
  - "Sidecar endianness mismatch" — cross-platform load attempt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:26:20 +10:00
Dion Moult 819196b3ce wgpu backend: --benchmark N parity with the GL minimal
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>
2026-05-27 14:14:40 +10:00
Dion Moult ddef8c65b5 wgpu backend: orbit/pan/zoom mouse navigation
LMB drag → orbit (yaw/pitch, pitch clamped to ±89.9° to avoid gimbal
flip at the poles). MMB drag → pan in the camera's screen-space plane,
world-units-per-pixel sized against the view frustum at the pivot depth
so panning feels constant regardless of zoom. Wheel → zoom (12% per
notch, sign matches "wheel up = closer"). LMB is bound to orbit because
selection isn't wired yet; will rebind to selection + nav preset once
AppSettings ports over.

Pure addition to WgpuViewportWindow — overrides four QWindow event
handlers, no changes to render or cull paths. Lets you actually fly
around a loaded sidecar without a screenshot loop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:10:51 +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 75b9963136 wgpu backend: --screenshot capability for visual verification
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>
2026-05-27 13:33:19 +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
Dion Moult 19a39a0413 Scaffold experimental wgpu viewer backend
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>
2026-05-27 12:23:23 +10:00