Files
IfcOpenShell/src/ifcviewer/README.md
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

501 lines
21 KiB
Markdown
Raw Normal View History

2026-04-11 16:30:10 +10:00
# IfcViewer
A high-performance native IFC viewer built on IfcOpenShell's C++ geometry
engine with a Qt6 interface and OpenGL 4.5 rendering.
2026-04-11 16:30:10 +10:00
## Architecture
```
+---------------------------------------------------+
| Qt6 Application (MainWindow) |
| +----------+ +----------------------------------+|
| | Element | | 3D Viewport ||
| | Tree | | (QWindow + OpenGL 4.5 Core) ||
| | (per- | | ||
| | model) | | Per-model: VAO/VBO/EBO ||
| +----------+ | instance SSBO ||
| | Property | | visible SSBO ||
| | Table | | indirect buffer ||
| +----------+ | glMultiDrawElementsIndirect ||
| | Status / Progress / Stats |
+---------------------------------------------------+
^ ^
| |
element metadata MeshChunk / InstanceChunk / Sidecar
| |
+---------------------------------------------------+
| GeometryStreamer (one per loaded model) |
| IfcGeom::Iterator with N threads |
| Dedups representations -> MeshChunk |
| Emits one InstanceChunk per placement |
+---------------------------------------------------+
2026-04-11 16:30:10 +10:00
```
### Key design decisions
- **QWindow viewport** embedded via `QWidget::createWindowContainer()`. Gives
us a raw native surface for OpenGL, bypassing `QOpenGLWidget`'s compositor
overhead.
- **GPU instancing as the central pillar.** IFC models are dominated by
repeated geometry — identical doors, windows, studs, pipes placed at
different transforms. IfcOpenShell's iterator surfaces representation
identity, so we upload each unique mesh exactly once and keep per-placement
data (transform, object id, optional colour override) in a separate SSBO.
For real projects this collapses tens of millions of triangles of duplicate
vertex data into a few hundred MB of unique meshes.
- **Per-model GPU buffers**: each loaded model gets its own
VAO/VBO/EBO/instance-SSBO/visible-SSBO/indirect-buffer. No cross-model
growth copies. Removing a model frees its GPU memory immediately.
- **Local-coordinate vertex format (28 B):** position (3 floats) + normal
(3 floats) + packed RGBA8 colour (1 uint). The per-instance transform is
applied in the vertex shader via an SSBO lookup. No world-baked vertex data.
- **Multi-draw indirect:** every frame the CPU builds a flat list of visible
instance indices and one `DrawElementsIndirectCommand` per non-empty mesh,
then issues a single `glMultiDrawElementsIndirect` per model. 50k visible
instances across 8k unique meshes collapse to one driver-side command
submission per model.
- **BVH frustum culling over instances**: per-model BVH trees cull whole
subtrees of placements with one frustum test. Falls back to a linear scan
during progressive upload and for very small models (< 32 instances).
- **Reflection-aware two-pass draw:** IFC placements can have negative-
determinant transforms (mirrored families). These flip the screen-space
winding of their triangles, which would make them vanish under
`GL_CULL_FACE`. The cull pass buckets visible instances into forward
(det ≥ 0) and reverse (det < 0) slices and the renderer issues two MDI
calls per model with `glFrontFace` toggled between them.
- **`reorient-shells` enabled in the iterator:** makes face winding
consistent within a shell at geometry-gen time — the only place this can
actually be fixed. Without it, files with inside-out faces produce dark
patches and swiss-cheese under backface culling. Costs iterator time but
is cached in the sidecar.
- **Progressive rendering during streaming:** the viewport is drawable
before `finalizeModel()`. Instances are pushed to the SSBO one at a time
via `glNamedBufferSubData` as they arrive, and the linear-scan cull path
handles them until the BVH is built. Orbit and pan remain interactive
through load.
- **Non-blocking sidecar loading**: sidecars are read on a background
thread; only the final GPU upload touches the main thread.
- **GPU object picking**: a second render pass writes object IDs into an
R32UI framebuffer. Click reads back one pixel. No CPU-side raycasting.
- **Multi-model support**: multiple IFCs can be loaded simultaneously.
Each gets its own `GeometryStreamer` (which owns the `ifcopenshell::file`
for property lookup). Models load sequentially. Per-model
hide/show/remove.
2026-04-11 16:30:10 +10:00
### Files
| File | Purpose |
|------|---------|
| `main.cpp` | Application entry, GL 4.5 surface format, CLI argument parsing |
| `MainWindow.h/cpp` | Qt main window: multi-model project, element tree, properties, status |
| `ViewportWindow.h/cpp` | OpenGL 4.5 Core renderer: shaders, buffers, camera, culling, MDI draw, picking |
| `GeometryStreamer.h/cpp` | Background iterator runner; emits `MeshChunk` + `InstanceChunk` |
| `InstancedGeometry.h` | Shared structs: `MeshInfo`, `InstanceCpu`, `InstanceGpu`, chunk records |
| `BvhAccel.h/cpp` | Median-split BVH builder; operates on instance world-AABBs |
| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v4) sidecar read/write |
| `AppSettings.h/cpp` | Persisted preferences (geometry library, stats overlay, backface culling) |
| `SettingsWindow.h/cpp` | Settings dialog |
2026-04-11 16:30:10 +10:00
| `CMakeLists.txt` | Build configuration |
## Dependencies
- **Qt6** (Core, Gui, Widgets, OpenGL)
- **OpenGL 4.5** with `GL_ARB_direct_state_access` and
`GL_ARB_shader_draw_parameters` — available on Windows and Linux. macOS
will need a Vulkan/MoltenVK backend (not yet implemented; macOS caps out
at GL 4.1).
- **IfcOpenShell C++ libraries** (IfcParse, IfcGeom, and their
dependencies: Open CASCADE, Boost, Eigen3, optionally CGAL).
2026-04-11 16:30:10 +10:00
## Building
IfcViewer is part of the IfcOpenShell CMake project. From the repo root:
2026-04-11 16:30:10 +10:00
```sh
mkdir build && cd build
cmake ../cmake \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_IFCVIEWER=ON \
-DBUILD_CONVERT=OFF \
-DBUILD_IFCPYTHON=OFF \
-DBUILD_GEOMSERVER=OFF \
-DBUILD_DOCUMENTATION=OFF \
-DBUILD_EXAMPLES=OFF \
-DCOLLADA_SUPPORT=OFF \
-DGLTF_SUPPORT=OFF \
-DHDF5_SUPPORT=OFF
make -j$(nproc) IfcViewer
```
If Qt6 is not in a standard location, pass `-DQT_DIR=/path/to/qt6`.
## Usage
```sh
./IfcViewer arch.ifc struct.ifc mep.ifc
./IfcViewer # then File -> Add Files
2026-04-11 16:30:10 +10:00
```
### Controls
| Input | Action |
|-------|--------|
| Middle mouse drag | Orbit camera |
| Shift + middle mouse drag | Pan camera |
| Scroll wheel | Zoom |
| Left click | Select object |
2026-04-11 16:30:10 +10:00
### Keyboard
2026-04-11 16:30:10 +10:00
| Key | Action |
|-----|--------|
| Ctrl+O | Add files |
2026-04-11 16:30:10 +10:00
| Ctrl+Q | Quit |
### Settings
2026-04-11 19:50:44 +10:00
- **Geometry Library** — kernel string passed to IfcOpenShell (default
`hybrid-cgal-simple-opencascade`).
- **Show Performance Stats** — overlay FPS / object / triangle / draw
counts in the status bar.
- **Backface Culling** — `GL_CULL_FACE` on closed solids. Default on.
Disable if a model uses open shells and you see missing faces.
2026-04-11 19:50:44 +10:00
## Performance Strategy
2026-04-11 19:50:44 +10:00
The viewer targets smooth orbiting at 60 fps on real-world multi-discipline
BIM projects (a "real job" being ~50 models, several million placements,
hundreds of millions of rasterised triangles when everything is in view).
2026-04-11 19:50:44 +10:00
Rendering performance has evolved in phases. Each builds on the previous,
and smaller models never pay for optimisations they don't need.
2026-04-11 19:50:44 +10:00
### Phase 1 — Per-object Frustum Culling
2026-04-11 19:50:44 +10:00
**Status:** implemented (and still the fallback for small models / during
streaming).
2026-04-11 19:50:44 +10:00
Six view-frustum planes are extracted from the view-projection matrix each
frame. Each instance's world AABB is tested with the p-vertex / n-vertex
method (one dot product + one compare per plane, 6 planes).
2026-04-11 19:50:44 +10:00
Surviving instance indices are written into a per-mesh bucket, then
flattened into a single `uint[]` (the "visible SSBO", binding = 1) and
accompanied by one `DrawElementsIndirectCommand` per non-empty mesh.
One `glMultiDrawElementsIndirect` call per model draws everything.
2026-04-11 19:50:44 +10:00
Cost: ~6 dot products per instance per frame. Fine up to ~100 k instances
per frame; above that the linear scan shows up in profiles, motivating
Phase 2.
2026-04-11 19:50:44 +10:00
### Phase 2 — BVH Acceleration + Sidecar Cache
2026-04-11 19:50:44 +10:00
**Status:** implemented.
2026-04-11 19:50:44 +10:00
For models exceeding ~32 instances, a bounding volume hierarchy groups
nearby placements into a binary tree and culls entire subtrees with a
single frustum test. This reduces per-frame work from O(N) to O(log N) in
the best case (camera zoomed to a corner) and remains well under 1 ms for
100 k instances in the worst case (everything on screen).
2026-04-11 19:50:44 +10:00
A BVH was chosen over an octree because BIM data is spatially non-uniform
— dense MEP risers in one zone, sparse open atria in another. An octree
subdivides space uniformly, wasting nodes on empty regions and creating
deep chains in dense ones. A BVH adapts its splits to the actual
placement distribution.
2026-04-11 19:50:44 +10:00
#### Activation
2026-04-11 19:50:44 +10:00
The BVH is optional and non-disruptive. Until it is built, the Phase 1
linear scan handles culling. The renderer checks for a BVH per model and
falls back to the scan for any model that doesn't have one.
2026-04-11 19:50:44 +10:00
It activates in one of two ways:
2026-04-11 19:50:44 +10:00
1. **Sidecar hit** — the `.ifcview` file next to the `.ifc` is found and
valid; its instance data is uploaded and the BVH rebuilt on the fly
from the restored AABBs (cheap — `< 100 ms` for 100 k placements).
2. **After streaming**`finalizeModel()` builds the BVH synchronously
once all chunks are in (instances already live on the GPU, so there's
no EBO re-sort to do). The sidecar is written afterwards.
2026-04-11 19:50:44 +10:00
Models under 32 instances skip the BVH.
2026-04-11 19:50:44 +10:00
#### BVH node layout (32 B, two per cache line)
2026-04-11 19:50:44 +10:00
```cpp
struct BvhNode {
float aabb_min[3]; // 12 B
float aabb_max[3]; // 12 B
uint32_t right_or_first; // interior: right child index; leaf: first item index
uint16_t count; // 0 = interior, >0 = leaf
uint16_t axis; // 0/1/2 for interior; unused for leaf
};
```
2026-04-11 19:50:44 +10:00
Left child is always the next node (pre-order DFS). Leaf items are
indices into the per-model `instances` array; the parallel `bvh_items[]`
array carries the world AABBs.
#### Build: object-median split
1. Compute centroid of each item's AABB.
2. Pick the longest axis of the node's AABB.
3. `std::nth_element` partitions at the median on that axis — O(n).
4. Recurse until a leaf holds ≤ 8 items.
O(n log n) total. No SAH — for frustum culling (6-plane tests, early
subtree reject) the quality difference vs median is negligible.
#### Traversal: stack-based, no recursion
```
stack[64] = { 0 } // root
while stack not empty:
node = nodes[stack.pop()]
if node.aabb outside frustum: continue
if leaf:
for each item in node:
if item.aabb in frustum: emit to visible list
else:
push right child, push left child // left processed first (DFS)
```
Depth 64 is enough for billions of items on any balanced tree. The stack
is on the C++ stack, zero per-frame allocation.
#### Sidecar format (`.ifcview`, v4)
Raw memory dump, Blender-`.blend`-style — no serialisation, no parsing.
Stores everything needed to skip the `IfcGeom::Iterator` pass:
2026-04-11 19:50:44 +10:00
```
SidecarHeader (magic "IFVW", version, endian, ...)
uint64_t source_file_size
uint32_t + float[] vertex data (7 floats × N_verts, local coords)
uint32_t + uint32_t[] index data (mesh-local)
uint32_t + MeshInfo[] per-unique-mesh metadata (48 B each)
uint32_t + InstanceCpu[] per-placement records (transform + AABB + ids)
uint32_t + PackedElementInfo[] element tree records
uint32_t + char[] string table
2026-04-11 19:50:44 +10:00
```
Staleness check: `source_file_size` vs actual file size. Mismatched →
reject and rebuild. Endianness marker rejects cross-arch caches.
2026-04-11 19:50:44 +10:00
### GPU Instancing pipeline (the central pillar)
Everything above plugs into a single data-flow, worth documenting on its
own because it's what makes the whole thing fast.
2026-04-11 19:50:44 +10:00
Per-model state on the GPU:
2026-04-11 19:50:44 +10:00
| Buffer | Contents | Lifetime |
|--------|----------|----------|
| `VBO` | Interleaved local-coord vertex data (28 B/vert). One range per unique representation. | Grow-on-demand during streaming; static after finalize. |
| `EBO` | Mesh-local uint32 indices. One range per unique representation. | Same. |
| `SSBO` (binding 0) | `InstanceGpu[]` (80 B each: mat4 transform, object_id, color_override, pad). | Appended during streaming, static after finalize. |
| `visible SSBO` (binding 1) | `uint32[]` — flat list of visible instance indices, ordered by mesh, uploaded each frame. | Rewritten every frame. |
| Draw-indirect buffer | `DrawElementsIndirectCommand[]` — one per non-empty mesh, uploaded each frame. | Rewritten every frame. |
2026-04-11 19:50:44 +10:00
Draw command:
2026-04-11 19:50:44 +10:00
```c
struct DrawElementsIndirectCommand {
uint32_t count; // mesh.index_count
uint32_t instanceCount; // visible-list length for this mesh
uint32_t firstIndex; // mesh.ebo_byte_offset / 4
uint32_t baseVertex; // mesh.vbo_byte_offset / 28
uint32_t baseInstance; // offset into the flat visible-index array
};
```
2026-04-11 19:50:44 +10:00
The vertex shader reads `visible[gl_BaseInstanceARB + gl_InstanceID]` to
get the real instance id, then indexes into the instance SSBO:
2026-04-11 19:50:44 +10:00
```glsl
uint slot = uint(gl_BaseInstanceARB) + uint(gl_InstanceID);
uint iid = visible[slot];
InstanceRecord inst = instances[iid];
gl_Position = u_view_projection * inst.transform * vec4(a_position, 1.0);
```
2026-04-11 19:50:44 +10:00
`gl_BaseInstanceARB` requires `GL_ARB_shader_draw_parameters`, which is
available on all GL-4.6-capable drivers.
2026-04-11 19:50:44 +10:00
Reflection handling: at upload time we store a parallel
`instance_reflected[]` byte array (1 if the transform's upper-3×3 has
det < 0). The cull pass produces two flat visible-list slices — fwd
(non-reflected) first, rev (reflected) after — concatenated into one
buffer. The renderer issues MDI twice: fwd with `glFrontFace(GL_CCW)`,
rev with `glFrontFace(GL_CW)`. `GL_CULL_FACE` stays on and does the
right thing in both passes.
2026-04-11 19:50:44 +10:00
### Current bottleneck — draw-bound, not upload-bound
2026-04-11 19:50:44 +10:00
The original README's Phase 3 ("GPU-driven indirect draw") described
moving draw submission to the GPU via compute. In the meantime, GPU
instancing and MDI made the CPU-side draw cost essentially free (10
`glMultiDrawElementsIndirect` calls per frame for 10 models). **That
goal is met.** The real ceiling lies elsewhere, and it took a couple of
bad hypotheses to pin down.
2026-04-11 19:50:44 +10:00
#### Profiled scene
2026-04-11 19:50:44 +10:00
10 models / 379 k instances / 128 M triangles, everything in view, no
camera motion, GTX 1650 (PCIe dGPU, 4 GB VRAM):
| Metric | Value |
|--------|-------|
| FPS | 6.7 |
| Frame time | 149 ms |
| gl_draws | 10 |
| Sub-draws packed in indirect buffers | 67 037 |
`nvidia-smi` reports 95 % GPU utilisation during render — the GPU is
the thing that's pinned.
#### False lead: "the per-frame uploads are the bottleneck"
The first round of probes pointed at the two `glNamedBufferSubData`
calls per model per frame (visible list ~1.5 MB + indirect buffer
~1.3 MB):
| Probe | Result | Initial interpretation |
|-------|--------|------------------------|
| Camera off-screen (nothing visible) | 60 fps | GPU idle → CPU path cheap |
| Comment out the two `glNamedBufferSubData` | 60 fps, blank screen | Uploads are the bottleneck |
This led to an aborted Phase 3A implementation of persistent-mapped
triple-buffered rings (and then staging + VRAM-resident with
`glCopyNamedBufferSubData`). Neither moved the FPS needle — both still
sat at 6.7 fps.
The probe was wrong: **commenting out the uploads emptied the indirect
buffer, so MDI drew zero triangles. "No upload" and "no draw" were
indistinguishable in the test.**
#### What actually isolates the draw cost
Two diagnostic env vars now live in `render()`:
- `IFC_SKIP_MDI=1` — keep everything (cull, upload, binds) but skip the
actual `glMultiDrawElementsIndirect` calls.
- `IFC_MAX_SUBDRAWS=N` — truncate each MDI's drawcount to N while still
running the rest of the frame.
Results on the profiled scene:
| Probe | FPS | Frame time |
|-------|-----|-----------|
| baseline | 6.7 | 149 ms |
| `IFC_SKIP_MDI=1` | 62.5 | 16 ms |
| `IFC_MAX_SUBDRAWS=30000` | 6.7 | 149 ms |
| `IFC_MAX_SUBDRAWS=10000` | 7.5 | 133 ms |
| `IFC_MAX_SUBDRAWS=1000` | 20.2 | 49 ms |
Readings:
1. `SKIP_MDI` gives 62 fps with all upload/bind machinery still running
— the non-draw path fits in ~16 ms easily. **Not upload-bound.**
2. Halving the sub-draw count (67 k → 30 k) saves 0 ms. If per-sub-draw
command-processor overhead were material, dropping 37 k sub-draws
would save measurable time no matter which sub-draws were dropped.
It doesn't. **67 k sub-draws is not the bottleneck** — the long tail
carries almost no triangles, and the heavyweights dominate.
3. Time only starts coming down once the cap is low enough to shed bulk
triangle work (1000 sub-draws → 49 ms). The curve is consistent with
a long-tailed distribution: a handful of very big meshes × instance
counts do most of the rasterisation.
**Conclusion: the GTX 1650 is rasterising 128 M triangles at ~850 M
tri/s, and that eats ~133 ms of the 149 ms frame.** No CPU-side or
upload-side work will recover it. The only way forward is to draw
fewer triangles.
### Phase 3 (revised) — Shed triangles, not bytes
In order of effort/payoff for BIM workloads:
#### 3A. Screen-space contribution culling (near-term)
Project each visible-instance AABB to screen space during BVH
traversal. Reject instances whose projected size is below a threshold
(~4 px). In BIM this is the single biggest win: at viewer zoom levels
that encompass a whole building, most MEP fittings, fixings, furniture
legs, door hardware etc. occupy < 1 px and contribute nothing.
Scope: a projection + pixel-area test inside
`ViewportWindow::cullAndUploadVisible`. Zero new GPU state. Expect
1030× reduction in drawn triangles on plant/MEP-dense scenes; full
buildings viewed in overview should approach 60 fps.
#### 3B. Distance / contribution LOD (medium-term)
Pre-simplify unique representations at ingress time (store LOD 0 / 1 /
2 meshes in the VBO/EBO with offsets), select LOD per instance per
frame by the same projected-size metric as 3A. The visible-SSBO
plumbing and MDI structure don't change — only `firstIndex`/`count` in
the indirect command does. Ingress side needs a decimation pass
(`meshoptimizer` or similar); GPU side is nearly free.
#### 3C. Hierarchical-Z occlusion culling (longer-term)
Render large occluders first, build a depth pyramid, test instance
AABBs against it. In dense BIM most geometry is behind other geometry
from any given interior viewpoint; historically a 310× reduction in
drawn instances. Most valuable *after* 3A+3B, which together handle
the far-away and small-detail cases. Pairs naturally with GPU-side
culling (a compute shader doing the HiZ test and writing the visible
list + indirect buffer in place).
#### 3D. GPU-side culling via compute (longer-term)
Push the cull loop to a compute shader reading the per-instance SSBO +
frustum planes + HiZ pyramid, emitting the visible list and indirect
commands with atomic counters. Eliminates all CPU→GPU per-frame bytes
and lets 3C scale to millions of instances. Worth doing once 3A3C
have stabilised the CPU-side algorithm we'd be porting.
### Planned follow-ups (post-Phase-3)
- **Mesh shaders / meshlets.** Ceiling-raising, but overkill until the
above are exhausted and we've hit silicon limits on vertex/raster
throughput.
## Summary table
2026-04-11 19:50:44 +10:00
```
Scene size Bottleneck Fix
----------- ---------- ---
< 100k instances CPU cull scan Phase 1 only
100k500k CPU cull scan BVH (Phase 2) — done
500k+ tris / overview shot GPU vertex + raster Phase 3A contribution cull
(+ 3B LOD for close-ups)
multi-million + occluders redundant rasterisation Phase 3C HiZ occlusion
2026-04-11 19:50:44 +10:00
```
2026-04-11 16:30:10 +10:00
## Roadmap
- [x] Material colour support (per-vertex RGBA8)
- [x] Per-model GPU buffers (VAO/VBO/EBO per model, no cross-model copies)
- [x] Per-object frustum culling (Phase 1)
- [x] BVH acceleration with per-model trees (Phase 2)
- [x] Raw binary `.ifcview` sidecar cache
- [x] Non-blocking sidecar loading (background thread I/O)
- [x] Progressive GPU upload (VBO/EBO growth + streaming-time instance appends)
- [x] GPU instancing (unique meshes + per-placement SSBO)
- [x] `glMultiDrawElementsIndirect` draw path
- [x] Reflection-aware two-pass draw for mirrored placements
- [x] Backface culling (user-toggleable, default on)
- [x] `reorient-shells` enabled in iterator
- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`)
- [ ] **Phase 3A — screen-space contribution culling** (next)
- [ ] Phase 3B — distance / contribution LOD
- [ ] Phase 3C — Hierarchical-Z occlusion culling
- [ ] Phase 3D — GPU-side compute-shader culling
2026-04-11 16:30:10 +10:00
- [ ] Vulkan/MoltenVK backend for macOS
- [ ] Embedded Python scripting console