Replace the old IFC viewer build switch with BUILD_BONSAIVIEWER in CMake, the Linux workflows, and the nix build script.
Generated with the assistance of an AI coding tool.
The reserved uint32_t was always written as 0 and never inspected on
read. Removing it shrinks the header from 16 to 12 bytes; the version
bump makes pre-existing sidecars fail the version check cleanly rather
than misreading by 4 bytes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously readSidecar/writeSidecar were keyed on (path, file_size) with
staleness rejected at read time. Switch to pure path-stem keying: foo.ifc
and foo.ifcdb/ both resolve to foo.ifcview, so the same cache serves either
source format. Staleness is user-managed (delete the sidecar to force a
rebuild), which also lets sidecars be copied or moved independently of the
source.
v8 header drops the source_file_size field.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Edge-collapse decimation (meshopt_simplify) returns BIM meshes unchanged
due to per-triangle vertex duplication and non-manifold topology. The
sloppy voxel-clustering decimator is faster, needs no shadow index
welding, and produces good results at the sub-30px LOD1 threshold.
Remove the non-sloppy branch, shadow buffer, IFC_LOD_SLOPPY and
IFC_LOD_LOCK_BORDER env vars.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace i16x2 octahedral normals with i8x2, filling the 2-byte padding
after position and saving 4 bytes per vertex. int8 gives ~1.4 deg
worst-case angular error — invisible for BIM geometry which is
overwhelmingly axis-aligned. 25% VBO reduction; sidecar files shrink
~15% overall (5.4 GB -> 4.6 GB on a 111-model test scene). Bumps
sidecar format to v7.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add --camera tx,ty,tz,dist,yaw,pitch and --benchmark N CLI args for
reproducible performance measurement. The benchmark orbits the camera
(0.5°/frame yaw) for N frames after a 5-frame warmup, prints
avg/median/p1/p99 frame times, then exits. Press C during interactive
use to print the current camera as a --camera argument.
Fix settle recull to fire after ANY camera motion (not just when
IFC_MIN_PX_MOTION is set), ensuring HiZ artifacts from motion frames
are always cleared when the camera stops.
Document Phase 3G (motion-adaptive culling + HiZ during motion) in
README with benchmark results from 1.06M-instance scene:
- Baseline: 16.3 fps
- IFC_MIN_PX_MOTION=10: 26.5 fps (1.6x)
- IFC_HIZ_MOTION=1: 46.6 fps (2.9x)
- Both combined: 51.0 fps (3.1x)
- + GPU_CULL: 52.0 fps (3.2x, negligible gain)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
During camera motion, use a larger pixel-radius threshold (IFC_MIN_PX_MOTION)
to aggressively cull small objects, dramatically reducing sub_draws and
improving orbit fps (e.g. 29→67 fps on 1M-instance scene). When the camera
stops, automatically re-cull at the base threshold to restore full detail.
Key behaviors:
- IFC_MIN_PX_MOTION=N sets the motion threshold (0 = disabled)
- Settle recull fires on the first still frame after motion
- HiZ pyramid invalidated on settle (stale from sparse motion frame)
- GPU cull results skipped on settle (dispatched at motion threshold)
- requestUpdate() ensures the settle frame actually runs
Also adds IFC_SUBDRAW_DIAG=1 diagnostic for sub-draw composition analysis
and documents Phase 3E/3F experiment results in README.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pack compute shader compacts non-empty indirect commands into
contiguous fwd/rev ranges, eliminating ~690k empty sub-draws that
dominated command-processor overhead. GL 4.6 entrypoint loaded via
getProcAddress with ARB fallback; graceful degradation to uncompacted
MDI when unavailable.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two-phase compute-cull dispatch when IFC_GPU_CULL=1:
Phase 1 frustum + contribution + LOD, no HiZ → survivors
Depth render survivors depth-only into half-viewport FBO
Build GPU compute max-reduce depth → R32F mip pyramid
Phase 2 same cull + HiZ test → final survivors
Color render final survivors
The compact shader's new hizOccluded() projects 8 AABB corners to
screen space, picks the mip level where the covered rect fits in ≤2×2
texels, and rejects when the AABB's near-depth exceeds the pyramid's
max depth.
New GPU resources (per-window):
hiz_gpu_fbo_ / hiz_gpu_depth_tex_ — depth-only FBO at half viewport
hiz_gpu_pyramid_tex_ — R32F mipmapped pyramid
hiz_gpu_copy_prog_ — compute: depth → pyramid L0
hiz_gpu_reduce_prog_ — compute: max-reduce L(n-1)→L(n)
hiz_gpu_depth_prog_ — vertex + trivial fragment
On a dense 18-model BIM dataset:
survivors: 140k → 65k (HiZ rejects ~50%)
triangles: 22M → 13M
gpu_cull: 0.06ms → 22.5ms (depth pre-pass CP overhead)
The depth pre-pass suffers the same empty-sub-draws CP overhead as the
color pass (690k commands, most with instanceCount=0). Once MDI
compaction lands, both passes will be fast. For now, net FPS is flat
(savings on color ≈ cost of depth pre-pass).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The compact shader now computes per-instance pixel radius and routes
survivors to LOD1 buckets when the projected sphere falls below the
LOD1 threshold (default 30 px, same as CPU path, tunable via
IFC_LOD1_PX).
Layout expanded from 2 to 4 buckets per mesh:
[0..M) fwd_lod0 [M..2M) fwd_lod1
[2M..3M) rev_lod0 [3M..4M) rev_lod1
Two MDIs per model: CCW for [0..2M), CW for [2M..4M). Per-mesh
has_lod1 flags live in a new gpu_mesh_flags_ssbo (binding 4).
Contribution cull refactored: the compact shader now computes
pixelRadius() once and uses it for both the min_pixel_radius rejection
and LOD routing, matching the CPU path's logic.
Visible-buffer worst case is 2 × total_instances (each LOD bucket
reserves the full fwd/rev capacity per mesh, since LOD selection is
dynamic).
Tri count drops ~60% on the test dataset (53M → 22M) thanks to LOD1
decimated meshes. FPS recovers from 16 to 36 despite 690k sub_draws
(4M layout). MDI compaction remains the final perf fix.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Promote the compute cull from a validation shader to the actual draw
driver. With the gate on, the CPU cull fan-out is skipped and MDI
consumes gpu_indirect_buffer / gpu_visible_ssbo directly.
- uploadGpuCullStaticBuffers() pre-fills per-mesh DrawElementsIndirect
commands and a mesh_base prefix sum so the compact shader can scatter
survivors into a fixed per-mesh range. Instance count for each
command is zeroed by a tiny reset dispatch, then the compact shader
atomically writes survivors and increments instanceCount.
- Draw loop branches on the gate: single CCW MDI with all mesh
commands. Fwd/rev winding split, LOD selection, and HiZ are still
CPU-path-only; reflected instances render with wrong winding under
this gate (step 3b).
- Once-per-second readback of each model's indirect buffer populates
the survivor / visible-object / visible-triangle stats so the
[frame] line reflects what the GPU actually drew.
Known regression: sub_draws is the full mesh count per model (~172k on
the test dataset) vs the handful of non-empty commands the CPU path
produces. Command-processor overhead from zero-instance sub-draws is
what drives the FPS drop, not the cull itself (0.05 ms). Compacting
non-empty commands requires glMultiDrawElementsIndirectCount, a GL 4.6
entrypoint not exposed by Qt's QOpenGLFunctions_4_5_Core; deferring to
3a-followup so we don't bolt a getProcAddress loader into the renderer
mid-restructure.
IFC_GPU_CULL is off by default, so this does not affect normal runs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The 'Known caveats' bullet still described the old 1-frame-stale
behavior. Since 6b496d802 the cull compares hiz_vp_ to the current VP
and drops HiZ rejection whenever they differ, so HiZ only helps on
still frames — orbiting gets no benefit. Call out the tradeoff and
the planned same-frame-depth-pre-pass fix slated for Phase 3E.
Two stability bugs:
1. Clicking an object left the scene with wrong shading until the camera
moved. The pick pass re-culls every model with its own parameters
(min_pixel_radius=0, no HiZ) and overwrites each model's visible_ssbo
and indirect buffer. The next render() saw an unchanged camera,
skipped the cull via the have_cached_cull_ shortcut, and drew the
stale pick-pass buffers. Fix: invalidate have_cached_cull_ at the
end of pickObjectAt().
2. Loading two sidecar-cached models made the second model's picked
properties resolve to the first model's elements. Sidecars store raw
object_id / model_id values from the session that wrote them, and
both files start at object_id=1, so element_map_ entries collided.
Fix: on load, rebase every PackedElementInfo and InstanceCpu by
(next_object_id_ - min_id_in_sidecar) and overwrite model_id with
the freshly-assigned handle before the elements hit element_map_.
Also document both in the README — the pick-pass note under 3A
contribution culling, the sidecar rebase under the sidecar format
section.
Add the parallel cull bullet to the feature list, a Phase 3D section
explaining the fan-out / scratch-ownership design + measured 4x
speedup, and renumber the planned GPU compute cull to Phase 3E so it
can cite 3D as the CPU algorithm being ported.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add the event-driven rendering bullet (zero idle cost, in-render frame
timing) and roadmap entries for VBO quantization and event-driven
rendering.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Position now u16x3 normalized against each mesh's local AABB; normal
oct-encoded to i16x2; RGBA8 colour unchanged. Per-mesh dequant basis
lives in a new MeshGpu SSBO at binding 2; both main and pick shaders
mix() against it before applying the instance transform.
Drops VBO and sidecar size by ~43 % (28 -> 16 B/vert), which matters
mostly for warm-load downloads of precomputed sidecars and steady-state
VRAM. LodBuilder dequantizes positions into a scratch buffer before
calling meshopt, since meshoptimizer needs float positions.
Also fixes a streaming-time crash in cullAndUploadVisible: bvh_items
was only populated at finalize, but the linear fallback indexes it
during streaming. Mirror BvhItem appends in uploadInstanceChunk so the
hot path stays valid before the BVH is built.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
After the main draw, blit the MSAA default-framebuffer depth to a
single-sample 256×128 depth texture, read it back, and build a CPU
max-reduced mip pyramid. Next frame's cullAndUploadVisible projects
each BVH node / instance AABB through the previous frame's VP and
compares the AABB's nearest depth against the pyramid's deepest value
at the matching mip level; strictly-beyond AABBs are rejected.
Conservative direction (aabb_near > hiz_max) — never wrongly rejects a
visible instance, so no flicker. BVH subtree-level test lets a single
8-corner projection reject up to a leaf's worth of instances.
Tuning knobs: IFC_NO_HIZ=1 disables; IFC_HIZ_SIZE overrides base width.
New stats counter hiz_rej shows rejects/frame.
Measured: big win on interior views (GPU-bound), roughly zero net
effect on exterior overviews (CPU-bound on cull traversal, so the
saved GPU work is masked). Tried a 3-deep PBO ring for async readback
and reverted — the extra frame of staleness produced visible flicker
on fast orbit, and the synchronous readback wasn't actually a measured
bottleneck at 256×128.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Decimate each unique mesh once at sidecar-build time and swap to the
reduced index slice per-instance per-frame when projected sphere radius
drops below IFC_LOD1_PX (default 30). Same VBO, same SSBO, just a
different firstIndex/count in the indirect command.
Extends MeshInfo (48→56 B) with lod1_ebo_byte_offset + lod1_index_count
and bumps the sidecar to v5. buildLods() runs inside
onStreamingFinished, appends decimated indices to sd.indices,
applyLodExtension pushes the EBO suffix to the live GPU state, and the
sidecar is written with LOD1 baked in.
simplifySloppy (voxel clustering) is used instead of the default
edge-collapse meshopt_simplify because BIM brep output is per-triangle-
unwelded and non-manifold after welding — simplify returned the input
unchanged for every mesh tested. Sloppy ignores topology. Knobs
(IFC_LOD_SLOPPY, IFC_LOD_ERROR, IFC_LOD_RATIO, IFC_LOD_MIN_SAVINGS,
IFC_LOD_LOCK_BORDER, IFC_LOD_DEBUG) are available for A/B tuning.
Result on the 128M-tri 10-model test scene (GTX 1650, 2px contribution
cull): 20.2 → 43.2 fps, 40M → 14M visible triangles, no change in
object count. LOD build adds 100–600 ms per model on first open,
cached thereafter.
README Phase 3B section is now a full writeup of pipeline, selection,
decimator-choice rationale, env vars, and measured numbers.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Earlier probes pointed at per-frame glNamedBufferSubData uploads as the
bottleneck (60 fps when those two calls were commented out). That was a
false reading — zeroing the uploads also emptied the indirect buffer, so
MDI drew nothing. "No upload" and "no draw" were indistinguishable.
Two new diagnostic env vars in render() isolate the real costs:
IFC_SKIP_MDI=1 keep cull + upload + binds, skip only the MDI
draws. Gives 62 fps with everything else running,
confirming the non-draw path fits in ~16 ms.
IFC_MAX_SUBDRAWS=N cap each MDI's drawcount. 67k -> 30k sub-draws
saves 0 ms, confirming sub-draw count itself is
not the bottleneck; the long tail of sub-draws
carries ~no triangles.
On a GTX 1650 with 128 M triangles in view, nvidia-smi sits at 95 %
GPU util and FPS scales with triangle work, not sub-draw count. The
card is simply rasterising at ~850 M tri/s. No CPU-side or upload
trick recovers it.
Revised Phase 3 is therefore shedding triangles, not bytes:
3A screen-space contribution culling (next)
3B LOD
3C HiZ occlusion
3D GPU-side compute culling
README Phase 3 section rewritten around the diagnosis, including the
false lead, so future work doesn't re-tread the upload path. The
aborted staging+resident ring-buffer implementation was reverted (the
uncommitted working tree is gone — pure glNamedBufferSubData retained
for the visible + indirect buffers, which we now know is fine).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous README described a pre-instancing world (32-byte world-
coord vertices with per-vertex object_id, ObjectDrawInfo structs, EBO
reordering after BVH build, and a Phase 3 plan built around moving
draw submission to the GPU). Most of that is either gone or already
solved:
- Vertices are now 28 B local-coord; per-instance transforms live
in an SSBO read through a visible-index SSBO and gl_BaseInstanceARB.
- ObjectDrawInfo is replaced by MeshInfo + InstanceCpu + InstanceGpu.
- No EBO reorder on BVH build — the BVH is over instance AABBs and
the mesh/EBO layout is orthogonal.
- Draw-call submission is already one glMultiDrawElementsIndirect
per model; the old Phase 3 goal is met.
New content worth keeping:
- GPU instancing section documents the mesh/instance/visible/indirect
buffer contract the whole renderer hangs off of.
- Reflection-aware two-pass draw is documented (det<0 placements,
forward/reverse slice split, glFrontFace toggle).
- reorient-shells and backface culling are called out as correctness
+ perf levers with their tradeoffs.
- Phase 3 is rewritten around the actual bottleneck surfaced by
profiling: per-frame glNamedBufferSubData stalls on the visible
and indirect buffers. Includes the diagnostic methodology (empty-
screen jump to 60 fps, window/MSAA invariance, upload-comment-out
experiment) so future-me remembers why this is the next step.
- 3A (persistent mapped ring buffers, near-term) and 3B (GPU-side
compute cull, longer-term) split out with scope estimates.
- Roadmap updated: instancing / MDI / reflections / reorient-shells
/ backface cull all ticked; 3A surfaced as the next open item.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Phase 2 performance: BVH acceleration with median-split build, per-model
trees, and EBO re-sorting for GPU cache coherence. Raw binary .ifcview
sidecar stores full geometry + BVH for instant subsequent loads (skip
tessellation entirely).
Per-model GPU buffers (VAO/VBO/EBO per model) eliminate cross-model buffer
copies on growth. Sidecar reads happen on a background thread. Bulk GPU
uploads are progressive (48 MB/frame chunks) so the viewport stays
interactive while multi-GB models stream in.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reflect current architecture: per-model streamers, glMultiDrawElements
with frustum culling, 32-byte vertex format with color, multiselect
file picker, settings/stats files.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>