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>