mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
b0ef47819fc7db66984b59a0bf8582bd0c4f25c7
20984 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b0ef47819f |
ci: IFCOS_BUILD_PYTHON_WRAPPER env-gate (off for bonsai macOS CI)
# Why this exists
The bonsai macOS CI (`build_osx.yml`, arm64) currently fails the
`IfcOpenShell-Python` smoke test with:
ImportError: dlopen(.../_ifcopenshell_wrapper.cpython-311-darwin.so,
0x0002):
Library not loaded: @rpath/ifcopenshell.document.rdb.dylib
Reason: tried: '$ORIGIN/ifcopenshell.document.rdb.dylib'
(no such file)
`_ifcopenshell_wrapper.cpython-311-darwin.so` has a hard `LC_LOAD_DYLIB`
of `@rpath/ifcopenshell.document.rdb.dylib` and its only `LC_RPATH` is
`$ORIGIN` (= `site-packages/ifcopenshell/`). The plug-in dylib is not
present at that path on macOS, so the wrapper fails to load and the
build smoke test (`build-all.py: compile_python_wrapper`) errors out.
BonsaiViewer.app builds, installs, and macdeployqt-deploys cleanly
before this point — the failure is downstream and unrelated to wgpu,
BonsaiViewer, or anything else on this branch.
# Where the regression came from
Two commits on the branch line that became `ifcviewer-wgpu`:
|
||
|
|
938dda80d0 |
ci: GCC 11 portability + BUNDLE DESTINATION "." on macOS
Linux (Rocky manylinux, GCC 11): - WgpuAreaMeasurement.h: include <cstddef> directly. GCC 11 does not transitively pull `size_t` through <vector>, so triangleCount()'s return type fails to parse. - WgpuViewportWindow.cpp:meshLocalToGlobal: use static_cast<double>(...) instead of double(mesh_local[N]) when constructing the Eigen::Vector4d. The latter triggers GCC 11's most-vexing-parse: it reads `Vector4d local(double(mesh_local[0]), double(mesh_local[1]), ...)` as a function declaration of `local` taking parameters `double mesh_local[0]` etc., colliding with the outer `mesh_local` parameter and failing with "redefinition of double* mesh_local". macOS arm64: - IfcViewerWgpuMinimal + BonsaiViewer install rules: change `BUNDLE DESTINATION bin` → `BUNDLE DESTINATION .`. Qt's deploy generator emits `macdeployqt <Target>.app` with no path prefix, which only resolves when the bundle sits at the install-prefix root. `BUNDLE DESTINATION bin` put it at `<prefix>/bin/Target.app` and the install/strip step failed with "Could not find app bundle". Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
45e9f763c8 |
ci: fix bonsai cross-platform build on Linux, macOS arm64, Windows x64
Bundles four portability fixes uncovered by manually firing the platform workflows against this branch: - WgpuOverlayRenderer.cpp: GCC 11 (Rocky manylinux runner) does not parse a multi-line raw string inside `#define`. Converted THICK_LINE_HELPERS_WGSL from a `#define` to a `static const char*` and switched AXIS_WGSL / SECTION_WGSL / MARQUEE_WGSL to `std::string` so they can concatenate at static-init time. Three call sites now pass `.c_str()` to svFromCStr. - bonsaiviewer/CMakeLists.txt: added BUNDLE DESTINATION to the install rule (same fix already applied to IfcViewerWgpuMinimal). MACOSX_BUNDLE targets fail at configure on macOS without it even when nobody runs `make install`. - build_osx.yml: dropped the x64 (Intel cross-compile) matrix row. The runner is arm64 so `brew --prefix qt` returns the arm64 prefix; we'd need a separate x86_64 Qt install under /usr/local to cross-build BonsaiViewer. Revisit if Intel-Mac demand resurfaces. - build_win.yml: dropped the ARM64 matrix row. wgpu-native does not ship a Windows-ARM64 binary, so IfcViewerWgpu's link step fails with ~60 unresolved wgpu* externs. Re-enable when upstream publishes that target. Cherry-pick this commit to v0.8.0 so the workflow_dispatch buttons see the dropped rows. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
d390911d75 |
build_osx: build BonsaiViewer on macOS via build-all.py
Linux (build_rocky.yml) and Windows (win/build-all-win.py) already pass BUILD_BONSAIVIEWER=ON when building from source; macOS was the odd one out. Three small changes to bring it up to parity: 1. .github/workflows/build_osx.yml — \`brew install qt\` (Qt6 with Svg) in the Install Dependencies step, then set QT_DIR=\$(brew --prefix qt) and BUILD_BONSAIVIEWER=ON in the Run Build Script env. 2. nix/build-all.py — install_qt6() now honours a pre-set QT_DIR. Before this change get_qt6_aqt_config() raised on non-Linux, blocking bonsai builds on macOS/Windows from ever using a system-provided Qt6. We now validate that QT_DIR points at a real Qt6 install (probes lib/cmake/Qt6/Qt6Config.cmake) and skip the aqt download path if so. Linux flow is unchanged: when QT_DIR is unset the function falls through to the existing aqtinstall path. build_osx.yml stays workflow_dispatch-only — slow run (~1h with ccache, longer cold), so manual fire when wanted. Cherry-pick this file + nix/build-all.py to v0.8.0 to make the workflow dispatchable against the ifcviewer-wgpu branch before the squash lands. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
57a94095e8 |
ci: wgpu mac — drop the bare \-j\ flag
Ninja rejects \`-j\` without a numeric argument (unlike make, which treats bare \`-j\` as unlimited parallelism). Build step exited with \`ninja: fatal: invalid -j parameter\`. Drop the \`-- -j\` tail entirely; Ninja already parallelises across available cores by default. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
025e60e635 |
ci: wgpu mac — cover ifcviewer-wgpu-minimal + wgpu-mem-probe in paths filter
The first run after BUNDLE DESTINATION fix didn't trigger because that commit only touched src/ifcviewer-wgpu-minimal/CMakeLists.txt, which the workflow's paths: list didn't cover. Add the two sibling subprojects (-minimal and wgpu-mem-probe) since they participate in the same configure pass. (Manual workflow_dispatch is still unavailable until this workflow lands on the default branch — GitHub gates the "Run workflow" button on the default branch's copy of the file.) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
07825b7442 |
ifcviewer-wgpu-minimal: add BUNDLE DESTINATION for macOS install rule
The target has MACOSX_BUNDLE ON, which on Darwin requires
install(TARGETS) to specify a BUNDLE DESTINATION — CMake validates
that at configure time, even when nobody runs `make install`. The
macOS CI configure step failed with:
install TARGETS given no BUNDLE DESTINATION for MACOSX_BUNDLE
executable target "IfcViewerWgpuMinimal".
Set BUNDLE DESTINATION bin alongside RUNTIME DESTINATION bin so both
platforms install into the same spot. No behavioural change on Linux
(no MACOSX_BUNDLE) — verified locally.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
317dbaa440 |
ci: wgpu mac — install Boost on the runner
Top-level cmake/CMakeLists.txt:328 does an unconditional find_package(Boost REQUIRED COMPONENTS program_options regex thread date_time iostreams) before the BUILD_BONSAIVIEWER gate, so we have to install it on the runner even though IfcViewerWgpu itself doesn't touch Boost. Removed via task #12 (extract ifcviewer-core) later. Other unconditional finds in the top-level CMake (manifold, nlohmann_json, USD, RocksDB, zstd) are already gated on flags that default to OFF — no action needed for those. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
b103563c8d |
ci: wgpu mac — disable IfcGeom/CGAL/OCCT; drop ctest -R filter
Two iterations on the first CI run: 1. The top-level cmake/CMakeLists.txt unconditionally find_package's CGAL (line 217) and OpenCASCADE (222) before our BUILD_BONSAIVIEWER gate kicks in, so configure failed with "Could NOT find CGAL". Pass BUILD_IFCGEOM=OFF + BUILD_IFCPYTHON=OFF + BUILD_CONVERT=OFF + BUILD_EXAMPLES=OFF + BUILD_GEOMSERVER=OFF + WITH_OPENCASCADE=OFF + WITH_CGAL=OFF + COLLADA_SUPPORT=OFF so all the heavy deps stay out of the configure step. Verified locally on Linux. 2. The ctest -R "wgpu" filter was case-sensitive and the Catch2 test names begin with capital "Wgpu" (e.g. "WgpuSelectionState starts empty..."), so it matched zero tests and ctest exited with "No tests were found". The standalone wgpu config only builds the wgpu tests anyway, so the filter is unnecessary — drop it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
53e9240702 |
ci: wgpu sanity check on macOS
Lightweight workflow that compiles IfcViewerWgpu (the static lib) on macOS arm64 + runs the wgpu state tests. Skips IfcViewerWgpuMinimal because createSurface has no Metal path yet (task #32); the platform surface blocks in WgpuViewportWindow.cpp are wrapped in #if defined(Q_OS_LINUX) so the lib itself compiles cleanly on macOS. Goal: catch portability regressions in the wgpu source on Apple Silicon without paying for build_osx.yml's full IfcGeom + OCCT + Python wheel pipeline. ~5 min vs hours. Triggers on push/PR that touches src/ifcviewer-wgpu, the shared headers it depends on, the top-level CMake, or this workflow itself. Also workflow_dispatch for manual runs. Hoists the Catch2 fetch in cmake/CMakeLists.txt out of the BUILD_BONSAIVIEWER gate so the standalone wgpu config (BUILD_BONSAIVIEWER=OFF + BUILD_BONSAIVIEWER_WGPU=ON + BUILD_BONSAIVIEWER_TESTS=ON) can build tests without dragging the whole bonsai/IfcGeom tree in. Default remains OFF, so default builds stay offline-capable. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
42ab97b134 |
tests: pass WITH_MESH_OPTIMIZER into test_lod_builder
LodBuilder.cpp guards its real body behind #ifdef WITH_MESH_OPTIMIZER
(the stub is `return;`). The IfcViewer static lib propagates the
define via target_compile_definitions, but test_lod_builder compiles
LodBuilder.cpp standalone (it doesn't link IfcViewer), so the test
silently exercised the no-op path. summariseLods and buildLods cases
asserted on the post-build state and saw zero LOD1 output.
Pre-existing regression since
|
||
|
|
379f913f65 |
wgpu tests: port selection + visibility state coverage
Two Tier-1 unit binaries under src/ifcviewer-wgpu/tests/ — same Catch2 + CTest harness as the surviving GL-side tests, gated by BUILD_BONSAIVIEWER_TESTS. - test_wgpu_selection: 17 cases / 71 assertions covering replace, add, remove, toggle, clear, contains, count, selectionIds, fillFlagsArray, active-id semantics, dirty-bit, and id == 0 sentinel handling. - test_wgpu_visibility: 8 cases / 27 assertions covering hide, show, clear, isHidden, hiddenIds, idempotence, and the 0 sentinel. The wgpu state classes have a deliberately simpler shape than the GL ones (no Q_OBJECT, no signals — replaced by a dirty bit; no bulk set/add/remove methods — bulk behaviour lives in the viewport verbs). One *intentional* behavioural difference is documented in the test: add(id) steals active in the wgpu API, where GL's addToSelection kept the prior active. Each pick should drive the properties panel to the most recently touched object. Bulk hide/isolate/show-all semantics live in WgpuViewportWindow, which composes WgpuVisibilityState + the model instance lists; those are integration-level, not Tier-1, so they're not covered here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
2981500b3b |
Route bonsai through wgpu; delete the GL backend
Bonsai now drives the wgpu viewport for both sidecar and direct-IFC
loads. The GL viewer and its supporting state classes are gone.
SceneLoader rewire:
- Takes WgpuViewportWindow* instead of ViewportWindow*.
- Sidecar path reads metadata only (readSidecarMetadataOnly) and hands
the StreamingSidecar off to the new applyCachedModel. Field accesses
inside applySidecarData go through .meta.
- Direct-IFC path uses the wgpu A-path (upload{Mesh,Instance}Chunk +
finalizeModel). The applyLodExtension call is dropped — wgpu has no
live LOD1 splice; LOD1 still lands in the on-disk sidecar for the
next open.
Bonsai migration:
- ViewportWindow → WgpuViewportWindow across MainWindow, Measurement,
SessionState, and every modules/*/{Commands,Panel,View}.{h,cpp} —
116 sites total. Same s/OverlayRenderer::/WgpuOverlayRenderer::/
rename, 12 sites.
- Includes flipped from ../ifcviewer/ViewportWindow.h to
../ifcviewer-wgpu/WgpuViewportWindow.h. OverlayRenderer.h include
dropped (transitively reached via the viewport header).
- BonsaiViewer links IfcViewerWgpu in addition to IfcViewer for the
duration of the migration; the GL-side IfcViewer also publicly links
IfcViewerWgpu so SceneLoader can resolve WgpuViewportWindow.
GL backend deletion:
- src/ifcviewer/ViewportWindow.{cpp,h}, BvhAccel.*, OverlayRenderer.*,
Selection.*, Visibility.* all gone.
- src/ifcviewer-minimal/ removed entirely (MinimalWindow drove the GL
viewport).
- src/ifcviewer/tests: test_bvh_accel, test_selection, test_visibility
removed. The first has no replacement (wgpu doesn't use a per-instance
BVH); the latter two are ported separately. test_lod_builder,
test_sidecar_cache, test_instanced_geometry, test_federation remain
(backend-agnostic).
- IfcViewer's CMakeLists drops OpenGL, Qt::OpenGL, Qt::Widgets — none
of the surviving translation units reach for them.
Build flag plumbing:
- BUILD_BONSAIVIEWER now auto-enables BUILD_BONSAIVIEWER_WGPU since
SceneLoader requires the wgpu lib for its WgpuViewportWindow* arg.
- The wgpu subprojects add_subdirectory ahead of the GL one so
IfcViewerWgpu exists when IfcViewer's link evaluates.
- src/ifcviewer-minimal subdir reference removed from cmake/CMakeLists.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
9c067d1d0e |
wgpu: bonsai-ready API surface + direct-IFC ingestion + streaming-always
Make the wgpu viewport ready for bonsai's verb actions, federation
refresh, and tool routing — i.e. callable from an outside host, not
just from the minimal viewer's own hotkeys.
Surface additions on WgpuViewportWindow:
- Qt signals: objectPicked, frameStatsUpdated, surfacePickedInTool,
toolModeChanged, toolBackspacePressed.
- FrameStats struct + rolling 60-sample frame-time window for the
fps field; emit at end of render() so external listeners see fresh
numbers in the same tick.
- InstanceLookup struct + findInstance(object_id, ...) const for the
measurement tools' O(1) object → (model, mesh, placement) resolve.
- Federation hooks (setFederatedFalseOrigin / setModelCoordinateOperation
/ setModelTransformation) + per-model coordinate_operation_meters /
model_transformation_meters fields on WgpuModelGpuData. Implement
composeInstanceFromPlacement + recomposeAndUploadModel so each setter
actually applies — model recompose runs in double, casts to float for
the GPU upload, and refreshes per-chunk world AABBs. meshLocalToGlobal
now composes coordinate_operation · placement properly.
- showModel / hideModel for per-model visibility, plus element-level
verbs (hideSelectedElements / isolateSelectedElements / showAllElements
/ invertElementVisibility) and setSelectedObjectId / cameraState() /
projectionOrtho() / toggle{Area,Length,Volume}Tool wrappers.
- Section-cutting methods (toggleSectionTool / clearSectionPlanes /
sectionToolActive) moved to public so bonsai's Commands.cpp can call.
- QVector3D overload of computeObjectAabb to match the GL signature.
- ToolMode::None → ToolMode::NoTool (X11 macro collision avoidance).
Direct-IFC ingestion (A-path), mirrors the GL streaming push API:
- uploadMeshChunk / uploadInstanceChunk stage into pending_direct_loads_
using the same vertex quantisation as SidecarBuilder so direct-load
and sidecar-load produce byte-identical buffers.
- finalizeModel wraps the staged data in a file-less StreamingSidecar,
routes through the existing applyCachedModel chunk planner, then
gathers per-chunk vertex+index bytes from memory and feeds
applyStreamedChunk synchronously. Every chunk lands is_resident=true
immediately (no disk I/O to defer).
Streaming collapse:
- Delete the applyCachedModel(SidecarData) full-load path entirely.
- Rename applyCachedModelStreaming → applyCachedModel; loadSidecar
always uses the metadata-only reader. Drop the --streaming CLI flag
from IfcViewerWgpuMinimal and the streaming_enabled_ field.
WgpuSelectionState::ids() → selectionIds() so bonsai's
`viewport_->selection().selectionIds()` compiles unchanged.
Eigen3 added as a public dep of IfcViewerWgpu for the federation
matrices.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
e80897886d |
wgpu: per-chunk OOM cooldown + continue past blocked candidates
Fixes two coupled streaming pathologies on working-set > pool scenes: 1) The candidate loop used `break` when a candidate couldn't fit even after eviction. Comment justified it with "sorted by priority, lower candidates can't beat it either" — true for *priority* eviction, but the failure is *size-based fitting*. A 31 MB candidate that doesn't fit in 24 MB largest-free was starving the entire per-frame budget, including smaller candidates that would have fit happily. Replaced with `continue`. 2) Same blocked candidate re-entered the candidate list every frame forever, spamming `[blocked]` and (worse, on web) paying for the same byte-range fetch over and over when apply-time OOM happened. Added `blocked_cooldown_until_frame_idx` on the chunk: when OOM strikes at enqueue *or* apply, the chunk is skipped from candidate gathering for ~3s. Web-friendly cap of one wasted fetch per 3s per chronic chunk instead of per-frame. Cooldown expires naturally; if the pool layout changes within the window (other chunks evicted, fragmentation coalesces) the chunk re-enters automatically. Also added eviction-attribution + chunk thrash detection (gated behind WGPU_STREAM_EVICT_LOG=1) to confirm A→B→A 2-cycles vs simple sacrificial-victim cycles. Quietened the steady-state stream debug dump — moved the verbose multi-line "missing/resident/bottom" snapshot behind WGPU_STREAM_DEEP_DEBUG=1 with a wider 300-frame interval, and added a single-line `[stream]` health summary every ~5s in interactive mode. Removed the hardcoded one-off "brace.ifc bracing all chunks" dump that was investigation scaffolding for a now-closed bug. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
699f22b502 |
wgpu: length measurement tool (L hotkey, adaptive 1/2/3/4+ point readout)
Ports Bonsai's LengthMeasurement onto WgpuViewportWindow as a new
WgpuLengthMeasurement class. Each LMB appends a world-space pick point
and the readout adapts to the running count:
1 pt → laser-measure: coplanar-patch BFS on the click's surface
projects every patch vertex into the surface's own tangent
basis to get face extents (X/Y/Z bars dashed in world space),
plus ENH coords for the picked point, plus a vertical
raycast for floor/ceiling distance on horizontal surfaces.
2 pts → distance A→B + axis-coloured ΔX/ΔY/ΔZ stair-step + dashed
perpendicular projection when both picks landed on
near-parallel surfaces.
3 pts → angle at middle vertex + triangle area + perimeter.
4+ pts → polygon area via best-fit-plane shoelace (Jacobi-3x3
eigendecomp inline; no Eigen dep) or fan-triangulated
fallback for non-planar loops, plus closed-loop perimeter.
Backspace / Del removes the last point; Esc / L again exits.
Dependencies layered in:
- pickMeshLocalAt now refines the AABB-coarse pickSurfaceAt hit into a
real triangle hit via Möller-Trumbore against the picked instance's
CPU mesh shadow. Without this the BFS seeds with whatever triangle
is closest to the bounding-box corner — producing patches and
extents shaped like the AABB instead of the surface.
- meshLocalToGlobal: applies the instance's placement_transformation
only (no per-model CoordinateOperation in wgpu yet). ENH equals
IFC-world for non-federated loads, which is what the minimal viewer
handles.
- raycast: brute-force world-AABB cull + Möller-Trumbore over the CPU
mesh shadow. Used by the laser-measure ceiling/floor distance.
- ToolMode gains Length; click handler routes plain/Alt LMB through
onLengthPick, Backspace through onLengthBackspace. Marquee-arm is
gated off in Length mode.
Volume HUD now shows "Volume: 0.0000 m³ (0 objects)" the moment V is
pressed, matching how A primes "Area: 0.0000 m²" — gives the user a
visible cue the tool is active before any selection.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
8761f9ac46 |
wgpu: area measurement tool (A hotkey, BFS coplanar patch + cyan highlight)
Ports Bonsai's AreaMeasurement onto WgpuViewportWindow as a new WgpuAreaMeasurement class. Each LMB pick resolves to (instance, triangle), BFS-expands the coplanar patch (dot(normal, seed_normal) > 0.9999, ~0.81° tolerance), and toggles it in/out of the running set. Alt+LMB skips BFS for single-triangle accumulate. Connected-components sweep over the selected set produces one "X.XXXX m²" label per patch at its area-weighted centroid in world space; HUD shows the running total + triangle count. Dependencies layered in: - WgpuOverlayRenderer.setHighlightTriangles / encodeHighlightTriangles: translucent world-space triangle list (cyan @ 0.45 alpha), depth- tested but depth-write off so the corner gizmo + labels still sit on top. - WgpuViewportWindow.pickMeshLocalAt: reuses pickSurfaceAt for the world hit, then inverts the instance's composed transform to express it in mesh-local space — what the BFS needs. Uses the live map key (`mid`) rather than InstanceCpu.model_id, which is whatever the GL streamer wrote at sidecar-write time and goes stale across sessions. - WgpuViewportWindow.readbackMeshTriangles: CPU mesh shadow lookup. The shadow itself is populated during the same dequant pass that computes mesh-local volume — applyCachedModel for full loads and applyStreamedChunk for streaming, so the BFS has data the moment the user can pick it. WgpuModelGpuData gains a MeshTriangles vector indexed by mesh_id; doubles per-vertex CPU memory (12 B/vert) but skips wgpu mapAsync plumbing for now. Bounds-check at pick time gracefully no-ops when a stale sidecar field is out of range. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
0774398d4e |
wgpu: volume measurement tool (V hotkey, selection-driven HUD + per-object labels)
Ports Bonsai's volumeOfObjects + volumesPerObject onto WgpuViewportWindow. Mesh-local volumes are precomputed at applyCachedModel via signed- tetrahedra-from-origin (dequantising positions from the 12 B/vertex GPU layout); per-instance volume is just the cached local × |det(placement)|. No GPU readback — measurement is O(K) in the selection size. Streaming path computes volumes per-chunk as they arrive — fills any mesh whose chunk just delivered, then re-runs updateVolumeReadout if the user is staring at a Volume readout while the geometry pages in. UX matches GL: V toggles, Esc exits, selection-driven (LMB pick / marquee / Shift/Ctrl set ops all funnel into updateVolumeReadout). HUD shows total + count; one overlay label per object at its AABB centre, capped at 200 to keep the label-texture cache bounded on large marquees. Side fixes layered on the label overlay: - O(1) AABB lookup via object_id_to_instance instead of linear-scanning every model's instance list per selected object. - Label texture cache evicts entries not touched this frame, so churning through "X.XXXX m³" strings doesn't pin GPU memory. - DrawRec stores the WGPUBindGroup handle by value rather than a LabelTexture* pointer into the QHash — getOrCreateLabelTexture can rehash the table and invalidate every captured pointer, which crashed large marquee selections with BindGroup-no-longer-alive. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
3ac7a79b7a |
wgpu: overlay labels + HUD text (QPainter rasterise, content-cached)
Ports GL OverlayRenderer's setOverlayLabels + setHudText to wgpu. Each unique string is rasterised via QPainter into a QImage (dark-grey rounded background + white antialiased text) and uploaded as an RGBA8 texture; the cache is keyed by content + font size so identical strings across frames are texture-free. Per-frame work is projection, vertex assembly, and one draw per visible label. Drawn last in the frame on the resolved surface so labels sit on top of every other overlay (no depth-test). HUD uses pt 11 at top-left matching GL; world-anchored labels use pt 9 centred at the projected screen position. WebGPU has no QOpenGLPaintDevice equivalent — the GL backend's two- stage GL-rect + QPainter pass becomes one textured quad per item here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
716dba2244 |
wgpu: overlay points (sprite-style, quad-expanded with stroke halo)
Ports GL OverlayRenderer's setOverlayPoints API to WgpuOverlayRenderer. Each point becomes a 6-vertex screen-space quad sized to inner_diameter + 2*stroke_extra; the fragment reads its per-vertex corner varying instead of gl_PointCoord (WebGPU has no sized-point primitive). Sharp inner/stroke transition + AA on the outer edge only, matching GL. Single uniform slot per set — colors are global to the call, not per point. Vertex buffer regrows 1.5× on demand so steady-state sets don't re-allocate. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
1d26e5fb92 |
wgpu: overlay-line groups (stroke + dash, per-group dynamic uniform offset)
Ports GL OverlayRenderer's LineGroup API to WgpuOverlayRenderer. Each group's segments are CPU-expanded into screen-space quads; the WGSL fragment reproduces the GL pixel-distance stroke pick + arc-length dash logic. One uniform slot per group, bound via dynamic offset so a single bind-group services up to N groups. No caller yet — sets up the API the wgpu measure tools (task #29) will use. WgpuViewportWindow.setOverlayLines mirrors the GL viewport's signature so the bonsai Measurement code can target either backend through one interface. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
814ae8304f |
wgpu: extract overlays (axis, pivot, section, marquee) into WgpuOverlayRenderer
WgpuViewportWindow.cpp had ~1100 lines of pipeline/shader/buffer plumbing for the axis indicator, pivot gizmo, section visualizer, and marquee drag rect. Mirroring the GL backend's split, that lives in its own class now; the viewport keeps the camera/cull/draw loop and hands the renderer a per-frame WgpuOverlayFrame snapshot for each encode call. No behavioural change — pixel-identical screenshot on basic.ifcview. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
07913e2832 |
wgpu: marquee box-select (drag rect + Shift/Ctrl set ops)
Drag LMB in empty space to select every visible object whose pick-pixel
falls inside the rect. Mirrors GL ViewportWindow's marquee.
UI flow
- LMB press in non-tool-consuming context arms the marquee. The
cursor must move past kBoxSelectThresholdPx (5 logical) for it to
become active — until then a release falls through to single-pick,
so an unintentional micro-drag still picks under the cursor.
- Press-time modifiers decide the set op so a mid-drag Shift release
doesn't flip behaviour:
plain → selection.clear() then add every picked id
Shift → add to current selection
Ctrl → remove from current selection
- Section tool intercepts plain LMB first (already wired); the
marquee is mutually exclusive with it.
Rectangle pick
picksInRect(x, y, w, h):
- Render the existing pick pass (R32UInt object_id + RGBA16F normal
MRT) into the persistent pick attachments.
- copyTextureToBuffer the rect region of pick_color_texture_ into
box_pick_staging_buffer_ (regrown 2× on demand to fit the
largest rect we've seen). R32UInt is a color format so partial
sub-rect copies are allowed (unlike Depth32Float).
- Iterate the mapped staging buffer, accumulate unique non-zero ids
into an unordered_set, return.
Visual rect
A new marquee overlay pipeline draws the drag rect on the resolved
surface after the corner gizmo. Two passes per active frame share one
uniform buffer / bind group:
fill — 6-vert unit quad, vs_fill maps (0,1)² to NDC via
rect_min/rect_max, fs_fill outputs color × fill_alpha
outline — 24-vert thick-line quad (4 segs × 6 verts), uses the
shared thick_line_clip + fs_main from THICK_LINE_HELPERS_WGSL
so the rect outline has analytical AA without MSAA
Colour: Bonsai decorator_color_special (0.157, 0.565, 1.000) for the
axis-blue parity the user requested; outline alpha 0.95, fill alpha
0.20 of that so geometry behind the rect still reads.
Closes #41 and #61.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
0ed355154a |
wgpu: shared thick-line shader, Bonsai decorator palette, fatter section gizmo
Consolidation
THICK_LINE_HELPERS_WGSL — a macro that both AXIS_WGSL and SECTION_WGSL
prefix via adjacent string-literal pasting — holds:
- VsOut: clip_pos + rgba colour + side_t for AA
- thick_line_clip(p_start, p_end, t, side, viewport, line_width):
the screen-space quad expansion with consistent perpendicular so
the quad never collapses into a bowtie
- fs_main: |side_t| + fwidth() coverage smoothstep — analytical
1-pixel AA regardless of MSAA
Each gizmo shader now only declares its uniform struct + a 10-line
vertex shader. C++ side gains thickLineVertexLayout(attribs[5]) so
both call sites set up the 5-attribute layout in one call instead of
20+ lines each. Net diff is -28 lines on this commit and roughly -60
relative to the unconsolidated section commit; the next thick-line
gizmo (measure tool, selection outline, …) starts from ~30 lines of
WGSL + a vertex buffer.
Bonsai decorator palette
All overlay colours now come from src/bonsai/bonsai/bim/ui.py's
decorator_color_* defaults so they match Bonsai's Blender add-on:
decorator_color_error = (1.000, 0.200, 0.322) red → +X axis, section gizmo
decorator_color_selected = (0.545, 0.863, 0.000) green → +Y axis
decorator_color_special = (0.157, 0.565, 1.000) blue → +Z axis
Section gizmo polish
- Entire gizmo (quad outline + arrow shaft + arrow head) goes red.
GL's white quad + yellow arrow disappeared against light surfaces;
one saturated red reads against any background and identifies the
geometry as a tool overlay.
- Line width bumped to 5 logical px and the per-vertex tint dropped
to (1, 1, 1, 1) so the tint multiplier stays available for a future
"selected" state without changing the base colour.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
8173074050 |
wgpu: section cutting tool — K hotkey, click-to-add, drag arrow, Esc/Del
Mirrors GL ViewportWindow's section tool end-to-end. Hotkeys K toggle the tool active Shift+K clearSectionPlanes Esc deactivate the tool Del/Bksp remove the most recently added plane (tool-active only) LMB click pickSurfaceAt → addSectionPlaneAtSurface (no modifier) LMB drag on the arrow gizmo: slide the plane along its normal State FrameUniforms grows by clip_count (i32) + clip_planes[6] (vec4). The six-plane cap matches GL's MaxSectionPlanes. WGSL pads via three scalar i32s instead of a vec3<i32> so the array starts at offset 144 to match the tightly-packed C++ struct (240 B) — vec3 would have forced clip_planes to 160 and broken the binding-size match. is_section_clipped(world) in WGSL evaluates all active planes and returns true if any signals "on the positive side". Both main and pick fragments discard with it so cuts are visible AND selection is consistent — you can't pick something the user can't see. Surface pick Pick pass now emits 2 color targets: R32UInt object_id at @location(0) and RGBA16F packed world-space normal at @location(1). Normal is packed × 0.5 + 0.5 so unsigned-ish halfs keep the sign. pickSurfaceAt reads both via 1×1 texel copies (RGBA16F is a color format with no full-mip restriction, unlike Depth32Float). World position comes from ray-AABB intersection against the picked instance's AABB — equally accurate for "drop a plane where I clicked" and dodges the Depth32Float copy-extent rule entirely. The pick normal is decoded into the per-fragment surface normal so the plane lands perpendicular to the actual triangle (not the AABB face). Plane gizmo Identical geometry to GL's renderSectionPlanes: 2×2 m quad outline (white) + 1 m arrow shaft along +n (yellow-orange) + 4 arrow-head diagonals. Drawn inside the main MSAA pass with depth LessEqual + no depth write. Lines are rendered as screen-space-expanded thick quads with fwidth-based AA, same technique the axis indicator uses, so the gizmo reads against busy BIM geometry rather than disappearing as 1-px hairlines. Drag mousePressEvent claims a plain-LMB press if it hits an arrow gizmo (12 logical-px grab radius, distance to the (origin, origin+n) screen-space segment). The drag handler projects the cursor delta onto the screen-space axis and converts to metres via delta·axis / |axis|² — same formula GL uses. Mid-drag camera moves keep working because the projection re-runs every frame against the press-time origin. Closes the click-to-add + drag halves of #30 / #60. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
e6c6df905a |
wgpu: corner axis gizmo + orbit pivot indicator (shared geometry)
Both overlays draw the same three positive-axis rays (origin → +X / +Y
/ +Z) — one screen-space-thick-line shader, one vertex buffer, one
bind group layout. The vertex stage transforms each vertex as
`mvp * (origin + position * arm)` so the same primitive serves both
modes:
corner: viewport set to a 110×110 px box in the bottom-left,
camera-orientation ortho MVP, origin=0, arm=1.
pivot : full viewport, main view-proj, origin=camera_target,
arm = 30 logical px in world units.
Pipelines
axis_pivot_pipeline_ — MSAA + depth LessEqual (α=1)
axis_pivot_xray_pipeline_ — MSAA + depth GreaterEqual (α=0.30)
axis_corner_pipeline_ — resolved surface, no depth, sampleCount=1
Pivot renders inside the main MSAA pass after geometry (depth
interaction); corner renders on the resolved surface after the edge
silhouette pass so the laplacian can't darken its lines. The pivot's
two passes — x-ray first then visible — give an occluded-side hint
matching GL's renderPivotIndicator.
Screen-space thick lines
WebGPU has no lineWidth, so each axis is a 2-triangle quad expanded
by `line_width / 2` pixels along the screen-space perpendicular in
the vertex shader. Every vertex carries BOTH endpoints (start, end)
plus `t ∈ {0,1}` and `side ∈ {-1,+1}` so the direction is computed
consistently as `s_end - s_start` regardless of which end the vertex
sits at — an earlier "this vertex vs the other end" formulation
flipped sign at the end vertex and produced a bowtie.
Analytical AA
|side_t| ∈ [0,1] is the perpendicular distance from the line centre.
`smoothstep(1-fwidth, 1, |side_t|)` gives a 1-pixel coverage falloff
at the long edges — gizmos read cleanly even on the resolved-surface
corner pass which has no MSAA.
Pivot visibility
- orbit / pan drag press → on, release → off
- wheel zoom → on with 600 ms afterglow via QTimer
Pole fallback for the corner gizmo's lookAt mirrors buildViewProj's
identical fix (swap Y-up when |pitch| ≥ 89°), so top/bottom standard
views don't degenerate.
Tracked under task #59.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
d4169693c9 |
wgpu: drop dormant spatial-bucket prototype
Removed the WGPU_SPATIAL_BUCKETS=1 octree-style instance planner (planSpatialChunks, SpatialPlan, the env-var pair, the field, the load-time branch). Was an opt-in prototype kept in tree as a possible acceleration for "find the right instance chunk", but: - Benchmarked slower than mesh-keyed (~24-26 ms vs ~19.7 ms) — the higher chunk count's per-chunk bind-group + draw overhead more than ate the tight-AABB win on this dataset. - Not load-bearing for the brace correctness fix — that turned out to be the AABB-projected screen-rect priority metric (commit 6200ab9fa), which works on either chunk topology. - The "find the right chunk" hypothesis is moot: instance_chunk_idx[] is precomputed at load time, so cull has nothing to look up. Git log preserves the implementation if it's ever revisited. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
72af067c9a |
wgpu: streaming + HiZ correctness fixes
Three correctness bugs found and fixed, plus an unrelated fly-mode deadlock surfaced along the way. HiZ false-rejection at the bottom of the screen ------------------------------------------------ The mip-pyramid sizing floored when halving — for a 256×70 mip 0 the level-3 mip is 32×8, but mip-0 row 69 maps to ly = 69>>3 = 8, which is out of bounds for an 8-row mip. ly1 then clamps down to 7 while ly0 stays at 8, the sampling loop runs zero times, max_d retains its initial 0.0, and `min_z > 0` rejects every AABB whose projected y range touches the bottom row. Same class for the right edge on very wide viewports. Fix: ceil rather than floor when halving mip dimensions so every parent row has a covering child texel, plus std::clamp on both lookup endpoints as belt-and-suspenders for any future mip-sizing change. Surfaced after the user added more sidecars and saw "anything near the bottom of the screen, no matter close or far" disappear ~0.5 s after camera stops — that delay was the strict-VP gate + readback latency opening the HiZ window. Found via WGPU_HIZ_TRACE rejection logs that showed every rejection had `max_d=0` and `ly0 > ly1`. Streaming priority lets the ocean starve out the bracing --------------------------------------------------------- Per-instance projected screen footprint was estimated as bounding- sphere radius squared. BIM geometry is overwhelmingly thin-in-one-axis (slabs, pipes, columns, windows) and a flat ocean plane viewed nearly edge-on gets a sphere projection ~250× larger than its actual screen rect. Its chunk dominated the priority ranking and evicted the brace chunks despite the braces being one of the closest visible things. Fix: per-instance priority is now the screen-space AABB rectangle area (world AABB extents projected onto the camera right/up basis vectors, divided by view-z). Sphere radius is retained for the contribution cull and LOD pick because conservative-over is the right failure mode there. Stale-VP HiZ gate ----------------- HiZ resolves into an async ping-pong of staging buffers, so the pyramid resident at cull time was typically captured one or two frames ago. During camera motion the captured VP differs from vp_this_frame and AABBs end up sampling depth taken for what was at slightly-different screen positions in the old view. Strict by default now: HiZ engages only when hiz_vp_ == vp_this_frame. WGPU_HIZ_MOTION=1 trusts the stale pyramid (matches GL's default). Fly mode Shift+Q deadlock -------------------------- keyPressEvent requested a redraw only on the first key of a new held set (was_empty). Pressing Shift first then Q never satisfied that condition because Shift had already populated the set, so the render loop never ticked. Now every relevant keypress calls requestUpdate unconditionally. HiZ stays opt-in behind WGPU_HIZ=1 for one release while the fix bakes; WGPU_HIZ_TRACE=1 keeps the per-rejection diagnostic available for future bugs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
126d2d4c06 |
wgpu: spatial instance bucketing for streaming (env-gated prototype)
WGPU_SPATIAL_BUCKETS=1 swaps the applyCachedModelStreaming planner from mesh-keyed Morton+greedy to octree-style instance bucketing. Default behaviour unchanged (env var unset → mesh-keyed planner runs). Phase 1 of #55 / #56. The mesh-keyed planner produces chunks whose AABBs are the union of all instances of the chunk's meshes — for heavily-deduplicated IFC meshes (a "standard floor tile" used 800 times across a federation) the mesh's "centroid" is a mean of scattered instance positions and the chunk's AABB ends up spanning the entire model. Symptom: chunk-level frustum cull rarely fires (AABB always intersects view), and the screen-area priority metric under-rates big-AABB chunks because their corners straddle the near plane. Visible objects pop in/out as the camera tilts, even though they're fully on screen. The spatial planner bucketises INSTANCES directly. Each leaf bucket contains its instance list + the unique mesh data those instances reference. A mesh whose instances scatter into multiple buckets gets its vertex/index data uploaded into multiple pool slices — duplication is the cost for tight bucket AABBs. For IFC this is acceptable: heavily-shared meshes tend to be small (fittings, fasteners), so per-bucket duplication adds tens-of-MB not GB. Octree implementation (planSpatialChunks): - work-stack subdivision: for each (instance subset, AABB), split into 8 octants around centre and recurse - stop conditions: bucket fits WGPU_CHUNK_VERTEX_BYTES_LIMIT for union vertex bytes AND ≤ spatial_max_instances_ instances; OR single instance left; OR every instance falls into the same octant (pathological — emit as leaf rather than infinite recurse) - spatial_max_instances_ default 5000, overridable via WGPU_SPATIAL_BUCKET_MAX_INSTS env var so the prototype can be swept without rebuilding Data-model adjustment beyond what dc2927997 prepared: - Per-chunk per-mesh chunk-local offset table (chunk_mesh_offsets) built during the chunk-construction loop. The mesh-keyed per-mesh global arrays (mesh_chunk_idx etc.) still get populated for legacy reads, but under spatial bucketing they're overwritten when the same mesh appears in multiple chunks — harmless because cull reads the per-instance arrays exclusively (per dc2927997). - Post-construction, per-instance arrays are populated from chunk_mesh_offsets via (instance_to_chunk[i], inst.mesh_id) lookup. Mesh-keyed planner derives identical values to before (pixel-identical); spatial planner now writes the correct per-bucket offsets even when the mesh appears in multiple chunks. basic.ifc parity on all three paths confirmed (non-streaming mesh-keyed, streaming mesh-keyed, streaming spatial all produce 0 pixel diff vs the reference). Spatial planner produced 1 bucket on basic.ifc (3 instances, well under thresholds) as expected. Non-streaming applyCachedModel left unchanged — the prototype targets the streaming path which is where the federation-scale missing-objects issue lives. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
1f8f6bffc4 |
wgpu cull: refactor per-mesh chunk lookups to per-instance
Preparatory refactor for spatial instance bucketing (#55). Cull previously routed through per-mesh tables (mesh_chunk_idx, mesh_chunk_local_base_vertex, _ebo_first_u32, _lod1_first_u32) to find the chunk and chunk-local offsets for each instance. That assumes a mesh lives in EXACTLY ONE chunk — the assumption holds under the current mesh-keyed planner but breaks under spatial bucketing, where the same mesh can be duplicated across multiple buckets if its instances are scattered. Adds four per-instance arrays (instance_chunk_idx, instance_base_vertex, instance_ebo_first_u32, instance_lod1_first_u32) populated at planning time. The current mesh-keyed planner derives them by translation: instance_chunk_idx[i] = mesh_chunk_idx[instances[i].mesh_id] The spatial-bucket planner (next commit) will populate them directly, allowing the same mesh_id to map to different chunks for different instances. cullModelCpuCompute now reads the per-instance arrays: - frustum_visible_count uses chunks[instance_chunk_idx[i]] - VisibleDrawGpu uses instance_base_vertex / instance_ebo_first_u32 / instance_lod1_first_u32 - LOD-select branch and use_lod1 logic unchanged. Per-mesh tables stay (used by makeChunkRequest, debug logs, the planner itself). Memory cost: 16 bytes × N instances ≈ 16 MB on a 1M-instance scene. Pixel-identical on basic.ifc in both non-streaming and streaming modes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
3eaa35d748 |
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>
|
||
|
|
a63bc63439 |
wgpu: diagnostic instrumentation for cull/streaming perf
Adds three knobs and three new heartbeat numbers to support the ongoing perf-parity work. None affect behaviour in default runs. cull[wall|compute|upload] split timer The existing cull_timer wrapped both the parallel std::async dispatch and the sequential cullModelCpuUpload loop (queueWriteBuffer × 3 per resident chunk × ~120 chunks ≈ 360 wgpu calls per frame). Splitting them ruled upload out as the bottleneck on a 51-model federation scene: compute ≈ 16-17 ms, upload ≈ 1 ms. WGPU_CULL_THREADS=0 — force sequential cull std::async-per-model was already in place; this env var disables it so we can compare wall time vs sequential and confirm parallelism is working. On the federation scene with 52 models: sequential 74 ms vs parallel 17 ms = 4.4× speedup. Confirmed; the 17 ms floor is not a parallelism failure, it's the cost of culling the largest single model (model 43, 114k instances) ÷ no parallelism within that model. WGPU_STREAM_DEBUG=1 — per-frame [stream-debug] log Surfaces cands/enq/drained/ev_lru/ev_pri/blocked/resident/cycled/ max_load each frame from driveStreamingLoads. The "cycled" / "max_load" pair makes thrash vs eviction-churn vs just-loading distinguishable. Off by default; opt-in via the env var. Bench-warm timeout dump When [bench warm] times out (600 frames without 0-loads streak), prints a structured summary: resident/missing/total chunks, cycled count, pool usage, largest free run, avg missing chunk size, and an auto-classifier diagnosis (POOL FRAGMENTED vs WORKING SET > POOL vs FEW-CHUNK CYCLE vs still-loading). Caught a real fragmentation pattern (18 MB largest free run vs ~100 MB typical chunk) on a 51-model run where the dumb classifier would have called it a load-budget problem. LOD1 firing counter "lod1 X/Y (saved Z tris, N no-lod1)" suffix on the [frame] log. X = LOD1-selected this frame, Y = LOD1-eligible, Z = tris not drawn vs always-LOD0, N = visible instances with no baked LOD1 (mesh below IFC_LOD_MIN_TRIS). Confirmed LOD1 path is genuinely firing post the per-chunk LOD1-storage commit, and exposed that ~90% of instances in real scenes are no-lod1 meshes — relevant to the future LOD-tier-residency design. Chunk.load_count + Chunk.lod0/1 layout bookkeeping Per-chunk reload counter for the thrash detector. lod0/1 layout_count fields prep the data model for distance-tiered residency (Phase B of #31) but aren't acted on yet. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
6a3dd4a0eb |
wgpu cull: contribution-cull defaults 2/10 → 3/15, env-var overrides
wgpu computes projected_px from view-Z distance (forward·(centre-eye)), which is the perspective-divide-correct denominator: an instance's on-screen radius really is world_radius * focal / z_view. GL computes the same value but with euclidean distance (sqrt(dx²+dy²+dz²)) — for off-axis instances euclidean > z_view, so GL underestimates screen size and culls more aggressively at the same numeric threshold. Concretely on the federation scene at the test camera, wgpu was drawing ~3× the instances GL drew despite identical 2/10 thresholds: wgpu obj 11067, hiz_rej 16532 vs GL obj 2310, hiz_rej 6823. Same fps (vsync-pinned), but ~30% more cull work for raster output that the user already wasn't seeing because GL had been quietly dropping it. Keeping wgpu's view-Z formula (more physically correct) and bumping the thresholds to 3.0 / 15.0 to match GL's effective drop rate. On the federation scene this lands obj/tri counts within ~10% of GL across the orbit, and shaves cull from 3.44 ms to 2.61 ms avg. basic.ifc parity is unchanged (its 3 instances clear 3 px easily). WGPU_MIN_PX / WGPU_MIN_PX_MOTION env vars added so the thresholds can be swept without rebuilding — needed while we visually confirm the new defaults across more scenes / cameras. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
10f8fc88e8 |
wgpu chunks: pack LOD1 indices alongside LOD0; cull picks per-instance
Sidecar LOD1 is per-mesh, index-only — meshoptimizer-baked decimated index slices that share the LOD0 VBO. Before this change the wgpu chunk path force-disabled LOD1 (effective_lod1 = false; tagged in a comment as "until per-chunk LOD1 storage lands"), so it had to walk every visible instance at full LOD0 even when the per-instance LOD selection said the projected radius was below the LOD1 threshold. Per-chunk layout: append LOD1 indices for the chunk's meshes after all LOD0 indices in the same pool slice. A new mesh_chunk_local_lod1_first_u32 array records each mesh's LOD1 starting offset (in u32s) within the chunk's index slice; LOD0 offsets stay where they were. The cull's emit then sets VisibleDrawGpu.ebo_first_u32 to whichever side matches the per- instance use_lod1 decision the prior #8 commit already computed. Vertex pulling is oblivious to the LOD split — it just reads the indices the cull pointed it at. c.index_count is repurposed as the LOD0+LOD1 total so the pool allocation, eviction's pool-fit check, and the VRAM accounting all scale automatically. c.lod1_index_count exposes the LOD1 portion for stats. makeChunkRequest appends LOD1 byte ranges to req.i_ranges in the same per-mesh order; the streaming worker concatenates ranges in order, so the assembled idx blob lands LOD0-first / LOD1-second which matches the chunk-local packing. On a 10-model regen with meshoptimizer-baked sidecars, the bench [frame] heartbeat shows lod1 firing on ~80-90% of LOD1-eligible instances and saving 10-30M tris per frame versus the prior LOD0-only ceiling. Same camera/scene on basic.ifc is still pixel-identical (no mesh in basic.ifc is large enough to bake a LOD1, so the cull just follows the LOD0 path it always did). Temporary debug counters (lod1_dbg_count_ et al.) print "lod1 X/Y (saved Z tris, N no-lod1)" in both interactive and benchmark [frame] heartbeats — kept on while LOD1 correctness gets confirmed across more scenes, will come out once trust is built. The non-streaming applyCachedModel path runs the same LOD1 plumbing but no longer fits the full federation scene in pool (extra index bytes push past the 2 GB single-buffer cap); that mode was already streaming-only on that scene before. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
c7fba1abb8 |
wgpu streaming: screen-space AABB priority + grace period + interactive heartbeat
The chunk priority metric is now the 2D projected pixel area of the chunk's AABB on screen — 8 corners projected through view-projection, 2D axis-aligned bbox of the projected points, clamped to viewport. This replaces the prior bounding-sphere-radius² metric, which was a 3D approximation: it treated a 322 × 55 × 5 m slab as a 163 m sphere, giving it the same huge priority face-on or edge-on. The new metric genuinely answers "what would this chunk's AABB cover if rendered solid given the current camera and viewport." Newly-loaded chunks get a 30-frame grace period at full priority (visibility_history floor temporarily forced to 1.0). Without it, just-loaded chunks crashed to history=0 → effective priority = pri × 0.05 → immediately reverse-swapped by the chunk they displaced. Cycle starved the per-frame load budget so candidates ranked below the cyclers never got attempted. 30 frames = HISTORY_ALPHA's time constant — enough for visibility_history to develop meaningfully. EVICT_PRIORITY_RATIO bumped 1.21 → 2.0 to suppress more swap noise between similar-priority chunks. Interactive heartbeat log added: every render in non-bench mode prints [frame] with fps, ms, obj, sub_draws, hiz_rej, cull, stream, chunks breakdown (resident/frustum/total + missing count), VRAM, model count. Every 30 frames when something's missing, also dumps: - top 8 models by missing chunk count - top 20 missing chunks by priority (with AABBs) - bottom 5 residents by effective priority - all chunks of brace.ifc (one-off diagnostic, hardcoded for the brace-visibility investigation) The heartbeat made the streaming bug visible: a brace model that isolation-loads correctly is missing in the full set because slabs covering more pixels win the priority contest. Per-model fairness or manual pinning are the remaining options if pixel-area + grace + hysteresis isn't enough — left for follow-up so the user can decide based on real testing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
3d368e0079 |
wgpu chunks: 3D Morton-code spatial sort (tight voxel chunks)
The previous chunk-plan sorted meshes by lexicographic (z, y, x) centroid — effectively a 1D Z-slab traversal. On a typical IFC building (50 × 50 × 100 m), a 16-MB chunk's 80-ish meshes spanned roughly 50 × 50 × 0.5 m. On a city federation it was much worse: the first chunk grouped ground-floor stuff from every building, spanning the entire scene horizontally. Per-chunk AABBs that wide make frustum / contribution / HiZ rejection useless (every chunk "overlaps the frustum" by virtue of spanning the whole scene). 3D Morton (Z-order) interleaves bits of quantised (x, y, z) centroids, so consecutive items in the sorted order cluster in all 3 axes — chunks become tight 3D voxels of the model. Prerequisite for the contribution-aware eviction priority (task #25) to actually discriminate near and far chunks. 21 bits per axis = ~2 M bins per axis, sub-millimetre precision on a kilometre-scale scene. Both apply paths (streaming and non- streaming) share the same sortMeshIdsByMorton helper. Benchmark unchanged (~47 fps avg, 20 ms cull, 0.3 ms stream) — the distance-based evictor still keys on chunk centres, which moved slightly under Morton but not enough to materially shift residency. The user-visible win comes from the next commit, which switches priority to screen-space contribution × HiZ history — both of which need today's tight AABBs to mean anything. Pixel-identical to non-streaming on basic.ifc on both paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
0ec72482c2 |
wgpu pool: halve-on-failure in addSubBuffer extracts +35% VRAM
Many Vulkan drivers cap a single VkDeviceMemory allocation at exactly
maxStorageBufferBindingSize (NVIDIA: 2 GB on consumer GeForce) or
refuse big contiguous allocations once heap is fragmented. The old
addSubBuffer gave up at the first refusal, latching growth_disabled_
— so on a 4 GB GeForce we extracted 2 GB and called it done.
The wgpu-mem-probe tool (
|
||
|
|
feab05650d |
wgpu-mem-probe: standalone tool to investigate driver VRAM ceilings
New headless wgpu probe app — no Qt, no surface, just initializes a device and stress-tests buffer allocations. Reports: 1. Adapter + device limits (maxBufferSize, maxStorageBufferBindingSize). 2. Single-allocation probe: descending sizes, each released, finds the largest single buffer the driver will grant. 3. Cumulative probe: halve-on-failure, finds total VRAM the runtime will let us park behind one device across multiple sub-buffers. 4. Fixed-size cumulative probe: 1 GB / 512 MB / 256 MB uniform sizes, to detect whether the "big-first" strategy leaves VRAM on the table. Findings on a GTX 1650 (4 GB physical) + wgpu-native + Vulkan: - maxStorageBufferBindingSize = 2 GB (driver cap, not wgpu-native). - Any single storage buffer > 2 GB is REFUSED. - Total available across N sub-buffers = ~3 GB, INVARIANT under allocation pattern (2+1+0.06, 3×1 GB, 6×512 MB, 12×256 MB all reach 3.00 GB). Driver hands out a fixed VRAM slice; pattern doesn't matter. - Remaining ~1 GB is held by the desktop compositor + OS. - GL's higher "4 GB+ resident" claim is overcommit into host RAM, which wgpu/Vulkan don't do. The +50% (2 → 3 GB) improvement is real and worth chasing — a follow-up halve-on-failure addSubBuffer in WgpuBufferPool will extract that on this card. On 8/16/24 GB GPUs the same code gets us proportionally more. Build: ninja -C build-viewer-wgpu WgpuMemProbe Run: ./build-viewer-wgpu/wgpu-mem-probe/WgpuMemProbe Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
dcc2bf1c01 |
wgpu streaming: background-thread chunk I/O kills render-thread stutters
The sync chunk-read on the render thread was causing 100-300 ms spikes during orbit whenever a new chunk needed to scatter-gather its mesh bytes from disk. p99 was 326 ms on the close-camera benchmark. New WgpuStreamingThread: one worker thread with a condvar-protected request/result queue. driveStreamingLoads becomes drain-then-enqueue: 1. Drain any results the worker pushed since last frame. For each, pool-allocate slices + queueWriteBuffer + build the chunk bind group (still main-thread because wgpu queue ops aren't thread-safe). 2. Walk visible non-resident chunks (sorted by distance), evict to make pool room, and enqueue the request. Chunk gains is_loading flag to prevent re-enqueueing while in flight. loadChunkBytesAndUploadGpu becomes the sync fallback path, used only when a screenshot is pending — the deferred-capture wait would otherwise let the window manager re-layout the window between frames and the test framework would capture at the wrong size. Normal streaming always goes through the worker. Bench warm-gate / requestUpdate gating updated to consider streaming_thread_.inFlightApprox() so we don't declare "converged" while a worker read is still in flight, and the render loop stays alive until the worker queue is empty. Refactored loadChunkBytesAndUploadGpu into two helpers: - makeChunkRequest: builds the worker request from chunk metadata - applyStreamedChunk: pool.alloc + queueWriteBuffer + bind group Both the sync and async paths share applyStreamedChunk. Benchmark (big federation, --streaming): close camera: avg 24 fps p99 47 ms (was 27/326) default camera: avg 24 fps p99 46 ms (was 31/186) stream time: ~2 ms (was 8-12) cull is now the bottleneck (20 ms median) — task #17 (GPU compute cull) is the next frontier. Pixel-identical to non-streaming on basic.ifc on both paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
6f66d08bee |
wgpu cull: chunk-level frustum cull replaces BVH walk
cullModelCpuCompute previously had two paths: a flat linear scan over
all instances (default), or a BVH-stack walk (--bvh, gated off because
it regressed on dense scenes — the BVH built per instance but its
interior-node AABBs spanned huge chunks of model so most subtrees
straddled the frustum and the walk overhead beat the rejection win).
With spatial chunk planning (commit
|
||
|
|
4d36174200 |
wgpu streaming: spatial chunk planning + coalesced multi-range reads
Chunks are now grouped by world-space centroid instead of mesh-id
range, so each chunk's AABB tightly bounds its geometry instead of
spanning the whole model. Distance-based eviction can finally
distinguish the near corner of a skyscraper from the far corner.
Algorithm:
1. Compute each mesh's centroid = mean of its instances' world AABB
centres.
2. Sort mesh indices lexicographically by (z, y, x) centroid. Stable
sort keeps mesh-id order as tiebreaker for instanced repeats.
3. Greedy-pack sorted meshes into chunks ≤ WGPU_CHUNK_VERTEX_BYTES_LIMIT.
4. Each Chunk stores its mesh_ids list; the per-mesh layout (chunk_local
base_vertex / ebo_first_u32) is computed by walking the list at plan
time.
Loader: chunk vertex/index bytes are no longer file-contiguous, so
streaming uses new multi-range read paths
(readSidecarVertexRanges / readSidecarIndexRanges). Each range list
is sorted by file offset and adjacent ranges coalesced with a 64 KB
gap tolerance — on the close-camera benchmark this brings the
per-chunk seek count back down to ~mesh-id-grouping levels, so the
spatial sort costs ~nothing on I/O while delivering tighter AABBs.
Non-streaming applyCachedModel mirrors the spatial plan but gathers
from in-memory data.vertices / data.indices via per-mesh
queueWriteBuffer calls at chunk-local offsets.
Chunk struct drops vertex_byte_offset and index_first_u32 (no longer
meaningful — each chunk is N scattered ranges). vertex_byte_size and
index_count stay as aggregates for pool sizing + eviction math.
Tuning: kept WGPU_CHUNK_VERTEX_BYTES_LIMIT at 128 MB. Tried 8 MB and
32 MB; both gave tighter AABBs but the scatter-gather I/O cost blew
up because the per-frame load count grows linearly as chunks shrink
(orbit shifts the working set faster across finer chunks). 128 MB +
coalescing is the empirical sweet spot pre-v14. Once sidecar v14
re-orders bytes on disk to match spatial chunks, we can drop the
limit to ~8 MB for sharp eviction without re-paying the seek cost.
Benchmarks (big federation, --streaming):
close camera: avg 36 fps median 53 (was 35/49) — parity
default camera: avg 33 fps median 47 (was 40/49) — small regression
likely from increased coalesce overhead on more-
scattered orbit traversals; will resolve with v14.
Pixel-identical to non-streaming on basic.ifc on both paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
c3a55d7f7b |
wgpu streaming: multi-pool growth, frustum-only residency, sorted convergence
Five interlocking fixes that take --streaming on the big federation scene from "5 fps + endless flicker + infinite cold-load" to a stable 35-49 fps with a converged working set. 1. Multi-sub-buffer WgpuBufferPool. Pool now grows lazily by adding sub-buffers of per_sub_buffer_capacity_ when alloc demand exceeds existing free runs. Each Slice carries (buffer, offset, size, sub_idx). On driver refusal of addSubBuffer, growth_disabled_ latches so subsequent allocs don't keep retrying and log-spamming. pool_can_fit consults can_grow() to know when growth could rescue a candidate vs when eviction is the only path. 2. Split cull / stream benchmark timers. The previous "cull[wall]" metric was actually cull + driveStreamingLoads, blaming the wrong subsystem (~170 ms of "cull" was synchronous disk I/O). 3. frustum_visible_count on Chunk, populated in cullModelCpuCompute right after the per-instance aabbInFrustum check. driveStreamingLoads now keys residency on this instead of total_visible_draws (which includes contribution + HiZ). HiZ visibility flips frame-to-frame as occluders shift; using it for residency caused chunks to be evicted then immediately re-loaded, every frame, even with a stationary camera — both the perf cliff and the visible flicker. 4. Distance-sorted candidates in driveStreamingLoads. Walk the non-resident frustum-visible chunks in distance order (closest first). With sorted processing, evict_farthest_than converges monotonically: each swap replaces a far resident with a closer candidate; once the next candidate is farther than every remaining resident, the loop exits. Without sorting the loader visited candidates in model/chunk-id order, swapping random chunks every frame without ever converging. 5. 10% eviction hysteresis (EVICT_DIST2_RATIO = 1.21). On scenes where many chunks are clustered at similar distance from the camera (e.g. several chunks all ~370 m away), naive "evict any resident strictly farther than candidate" triggers sub-meter swaps every frame, never resting. Requiring the victim to be 10% farther in linear distance kills these cycles while still allowing genuine "much closer" candidates to evict. Plus: latched bench_warm_done_ on the cold-load gate, with a 5-frames-of-zero-loads convergence test (default-camera big scene converges in 20 frames) and a 600-frame timeout fallback that prints exactly once. Measured on the test federation (111 sidecars, ~3 GB raw, 1 M instances) with the user's close-in camera: - avg 35 fps (was 5), median 49 fps (was 7) - cull 19 ms (now the bottleneck), stream 5-8 ms (was 172) - p99 184 ms — occasional big-chunk load on the render thread; background-thread I/O would smooth that out as a follow-up. With the default wide camera: - avg 40 fps, converges in 20 frames, residency grows naturally from 59 → 76 chunks as orbit shifts the frustum. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
502c29fbc2 |
wgpu: probed-size pool replaces per-chunk createBuffer
Drops the per-machine "guess the OOM ceiling" budget knob in favour of a single buffer pool whose capacity is *probed* at device-init time. The runtime answers the question: descend from min(maxBufferSize, 4 GB) through OOM error scopes, accept the largest size that allocates cleanly. On a desktop wgpu-native v29 box this lands at 2 GB; on browser-class platforms it'll land at 256 MB – 1 GB depending on the implementation. Same code path either way. Architecture: - WgpuBufferPool (new): single WGPUBuffer + free-list sub-allocator with adjacent-range coalescing and first-fit. 256 B alignment for storage-binding offsets. - Chunks now hold (pool_vertex_offset, pool_vertex_size) and (pool_index_offset, pool_index_size) instead of per-chunk WGPUBuffer handles. Load = pool.alloc + queueWriteBuffer. Unload = pool.free. - Bind groups bind pool_.buffer() at the chunk's specific (offset, size) for both the vertex and index storage bindings. - Eviction queries pool.largest_free_run_bytes() instead of a tracked budget; the two-phase LRU/distance evictor's policy is unchanged. What this fixes: - No more gpu-alloc-rs fragmentation OOM: one VkDeviceMemory block instead of N per-chunk blocks with rounding overhead. On the test dataset (~3 GB on disk, 562 k visible instances) the wgpu backend now runs through to render without OOM at any point. - No --streaming-vram-mb knob, no hardcoded budget constant, no per-machine calibration. The pool size adapts to whatever the runtime grants. Notes: - Error scope probing: wgpu-native v29 classifies "Not enough memory left" as WGPUErrorType_Validation, not OutOfMemory. We push both filters (nested) and treat either firing as probe failure. - The 4 GB probe cap is principled, not magic: above that, wgpu-native's advertised maxBufferSize is sometimes a sentinel (1 TB) that just forces wasteful halving steps. 4 GB is the largest buffer any realistic WebGPU implementation will grant a single allocation today. - Pool destroy()/release happens after model release in shutdown() so the underlying buffer outlives every bind group that references it. Follow-ups: spatial chunking (task #22) for finer eviction granularity; cull perf needs work at 100+ models / 1M+ instances (separate from streaming concerns). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
71e61dd8a5 |
wgpu streaming (5/4): per-chunk indices + LRU/distance eviction (stopgap)
Defers index buffers per-chunk (alongside vertex bytes) so streaming fully delivers on its "don't load until visible" contract — the previous per-model index buffer was upfront-loaded and tipped scenes >~1.5 GB into allocator OOM at frame 1. Adds residency tracking + a two-phase evictor: (1) drop LRU non-visible chunks first, (2) if everything resident is visible-this-frame, drop the farthest-from-eye chunk only when the candidate to load is closer. This gives monotonic convergence to "closest visible chunks fit the budget" instead of "first 4 win, rest never load." Default budget set to 1 GB — explicitly a stopgap, documented inline. The per-machine OOM ceiling on wgpu-native (caused by allocator fragmentation from one VkDeviceMemory per createBuffer call) cannot be solved by tuning this knob. The proper fix is a probed single-pool buffer with sub-allocation, tracked under task #16. Caveat: LOD1 indices are now force-disabled when chunking — per-chunk buffers only carry LOD0. Re-enabling needs LOD1 to participate in the chunk plan. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
5a3e5167df |
wgpu streaming (4/4): per-frame chunk-on-visible loader
The OOM fix for vertex storage. With --streaming, chunks now load on
demand:
- After cull determines which chunks have visible draws, driveStreamingLoads
walks non-resident chunks with total_visible_draws > 0 and brings up
to MAX_STREAMING_LOADS_PER_FRAME (currently 4) into residency.
- Each load: readSidecarVertexChunk → createBufferWithData →
buildChunkBindGroup → is_resident = true. Same frame's draw loop
picks up the newly-built bind_group and renders the chunk.
- If more non-resident-but-visible chunks remain, requestUpdate is
called so the load loop keeps running until the visible set is fully
resident.
Per-chunk bind group construction refactored out of buildModelBindGroup
into a buildChunkBindGroup(m, chunk_idx) helper so the streaming loader
can build one chunk at a time as it arrives.
4 chunks/frame × 60 fps = 240 chunks/sec ingestion. A 200-chunk scene
fully resides in ~1 second of motion. Off-screen chunks never become
resident, never pay vertex-storage VRAM — that's where most of the OOM
fix lands.
Verified on basic.ifc: pixel-identical to non-streaming. On the user's
real 111-model / 1M-instance scene: all metadata loads succeed (was
OOM before), then loader runs but **indices are still loaded upfront
(1.5 GB!) so OOM still hits when vertex chunks start adding on top.**
Per-chunk index deferral is the next commit.
Eager-no-evict policy (per the design conversation): chunks stay
resident once loaded. LRU eviction lands in a follow-up if a workload
proves it necessary.
This completes the 4-commit stage-1 series for task #16. Stage-2:
defer indices, deferred mesh/instance storage if needed, async worker
thread.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
f6d888d42b |
wgpu streaming (3/4): --streaming scaffold + applyCachedModelStreaming
Wires the metadata-only reader (commit 1) through a parallel streaming
load path. With --streaming on:
- loadSidecar routes through readSidecarMetadataOnly: reads header +
mesh dict + instance dict + georef + elements upfront. Skips
vertex bytes entirely.
- applyCachedModelStreaming computes the same chunk plan as the
non-streaming path, allocates the small per-chunk buffers
(visible_draws + prefix_sums + per_chunk_uniform), allocates the
model-shared mesh + instance + index buffers, but leaves each
chunk's vertex_storage NULL and is_resident=false.
- Stores streaming_file_path + vertex_section_offset on the model so
the per-frame loader can range-read chunks later.
- Computes per-chunk world AABB by walking instances → mesh → chunk;
used by both cull (chunk-level frustum reject, future) and the
streaming loader (proximity-prioritised fetch, future).
Index buffer is still loaded upfront in stage 1 (small relative to
vertex data: ~1/2 of vertex bytes on real scenes). Stage 2 may defer
it too if measurements suggest it's worth the extra plumbing.
Render + pick already gate on c.bind_group (null when non-resident),
so the existing guards correctly skip non-resident chunks without
further changes.
With this commit alone, --streaming mode shows an EMPTY scene (just
background colour) because no chunk ever becomes resident. Commit 4
adds the per-frame loader that triggers chunk load when cull marks
them visible — that's the commit where rendering kicks in and the
OOM fix actually lands.
Default behaviour (no --streaming): legacy synchronous full-load.
Pixel-identical to the prior commit on basic.ifc.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
d368ee449d |
wgpu streaming (2/4): per-chunk residency fields on WgpuModelGpuData
Foundation for streaming. Adds to each Chunk:
- is_resident (default true; streaming flips false initially)
- vertex_byte_offset / vertex_byte_size in the sidecar file
- aabb_min / aabb_max world-space chunk bounds (used by future cull
and streaming priority)
Plus on the model:
- streaming_file_path (non-empty = streaming path was used)
- streaming_vertex_section_offset (where the chunks live in the file)
All fields default to backward-compatible values: is_resident=true,
streaming_file_path empty. The existing non-streaming applyCachedModel
sets up a Chunk with is_resident=true (implicit) and ignores the
streaming fields, so no behaviour changes yet.
Commit 3/4 wires the metadata-only reader from (1/4) through a new
applyCachedModelStreaming path that flips is_resident=false initially;
commit 4/4 adds the per-frame loader that brings chunks resident on
demand. This commit is verified pixel-identical to the previous render
on basic.ifc.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
a06d920fc6 |
wgpu streaming (1/4): metadata-only sidecar reader
First foundational piece for task #16. WgpuStreamingLoader exposes: - readSidecarMetadataOnly(path): reads v13 header + mesh dict + instance dict + georef + elements + string table from disk. Skips the bulky vertex and index byte sections, recording their on-disk offsets so they can be range-read later (per-chunk, on demand). The file handle is closed before return. - readSidecarVertexChunk / readSidecarIndexChunk: open + fseek + fread for a byte range. Synchronous; intended to be called from a worker thread for true async streaming or the main thread for stage-1 on-demand load. No format change yet — operates on existing v13 sidecars. v14 with an explicit per-chunk TOC arrives in a follow-up; this layer abstracts the chunk boundaries so the upgrade stays internal. No integration with existing applyCachedModel — that's commit 3/4. Build verifies the API compiles and links into IfcViewerWgpu. Commits in this series: 1/4: metadata-only reader (THIS) 2/4: per-chunk residency state on WgpuModelGpuData 3/4: --streaming opt-in path through applyCachedModel 4/4: per-frame chunk-on-visible loader (the OOM fix) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |