diff --git a/src/ifcviewer/CMakeLists.txt b/src/ifcviewer/CMakeLists.txt
index 9f1c4dac50..70642acabf 100644
--- a/src/ifcviewer/CMakeLists.txt
+++ b/src/ifcviewer/CMakeLists.txt
@@ -26,6 +26,7 @@ set(QT_VERSION 6 CACHE STRING "Qt version")
find_package(Qt${QT_VERSION} COMPONENTS Core Gui Widgets OpenGL REQUIRED PATHS ${QT_DIR})
find_package(OpenGL REQUIRED)
+find_package(meshoptimizer REQUIRED)
file(GLOB IFCVIEWER_CPP_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp)
file(GLOB IFCVIEWER_H_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.h)
@@ -51,6 +52,7 @@ target_link_libraries(IfcViewer PRIVATE
Qt${QT_VERSION}::Widgets
Qt${QT_VERSION}::OpenGL
OpenGL::GL
+ meshoptimizer::meshoptimizer
)
if(UNIX AND NOT APPLE)
diff --git a/src/ifcviewer/InstancedGeometry.h b/src/ifcviewer/InstancedGeometry.h
index 1c027976ef..ef79751806 100644
--- a/src/ifcviewer/InstancedGeometry.h
+++ b/src/ifcviewer/InstancedGeometry.h
@@ -33,18 +33,28 @@ static constexpr int INSTANCED_VERTEX_STRIDE_BYTES = 28;
static constexpr int INSTANCED_VERTEX_STRIDE_FLOATS = 7;
// Per-mesh metadata on the CPU side. Meshes own a slice of the model's
-// VBO and EBO (both local-coords/mesh-local indices).
+// VBO (shared across LODs) and one or more slices of the EBO, one per LOD.
+//
+// LOD0 is the original, full-resolution tessellation — the fields
+// `ebo_byte_offset` / `index_count` describe it.
+//
+// LOD1 is an optional decimated copy of the same triangles referencing the
+// same vertex buffer. Built at sidecar time via meshoptimizer for meshes
+// whose triangle count crosses a threshold. `lod1_index_count == 0`
+// means no LOD1 was built; the renderer must use LOD0 at every distance.
struct MeshInfo {
uint32_t vbo_byte_offset = 0; // where this mesh's vertices start
uint32_t vertex_count = 0;
- uint32_t ebo_byte_offset = 0; // where this mesh's indices start
- uint32_t index_count = 0;
+ uint32_t ebo_byte_offset = 0; // LOD0 indices
+ uint32_t index_count = 0; // LOD0 index count
float local_aabb_min[3]{};
float local_aabb_max[3]{};
uint32_t first_instance = 0; // index into per-model instances array
uint32_t instance_count = 0;
+ uint32_t lod1_ebo_byte_offset = 0;
+ uint32_t lod1_index_count = 0; // 0 = no LOD1 available
};
-static_assert(sizeof(MeshInfo) == 48, "MeshInfo must be 48 bytes");
+static_assert(sizeof(MeshInfo) == 56, "MeshInfo must be 56 bytes");
// Per-instance record uploaded to an SSBO and read by the vertex shader.
// Layout deliberately matches std430 expectations:
diff --git a/src/ifcviewer/LodBuilder.cpp b/src/ifcviewer/LodBuilder.cpp
new file mode 100644
index 0000000000..88b8c9f046
--- /dev/null
+++ b/src/ifcviewer/LodBuilder.cpp
@@ -0,0 +1,203 @@
+/********************************************************************************
+ * *
+ * This file is part of IfcOpenShell. *
+ * *
+ * IfcOpenShell is free software: you can redistribute it and/or modify *
+ * it under the terms of the Lesser GNU General Public License as published by *
+ * the Free Software Foundation, either version 3.0 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * IfcOpenShell is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * Lesser GNU General Public License for more details. *
+ * *
+ * You should have received a copy of the Lesser GNU General Public License *
+ * along with this program. If not, see . *
+ * *
+ ********************************************************************************/
+
+#include "LodBuilder.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+void buildLods(SidecarData& sd,
+ int min_triangles,
+ float target_ratio,
+ float target_error) {
+ if (sd.meshes.empty() || sd.vertices.empty() || sd.indices.empty()) return;
+
+ const size_t vtx_stride_bytes = INSTANCED_VERTEX_STRIDE_BYTES;
+ const size_t vtx_stride_floats = INSTANCED_VERTEX_STRIDE_FLOATS;
+ const size_t total_vertex_count = sd.vertices.size() / vtx_stride_floats;
+
+ // Env var knobs so we can tune without rebuilding.
+ // IFC_LOD_LOCK_BORDER=1 re-enable LockBorder (off by default: BIM
+ // geometry is often non-manifold so locking
+ // borders prevents any collapse).
+ // IFC_LOD_ERROR= override target_error (default 0.05 → 0.2).
+ // IFC_LOD_RATIO= override target_ratio.
+ // IFC_LOD_MIN_SAVINGS=<0..1> minimum fraction of tris saved to accept
+ // (default 0.25).
+ // IFC_LOD_DEBUG=1 print per-mesh diagnostics for the first
+ // few meshes of each call.
+ // IFC_LOD_SLOPPY=0 disable sloppy (clustering) decimator.
+ // Default ON: BIM brep output is usually
+ // non-manifold, so edge-collapse simplify
+ // returns the input unchanged.
+ const char* env_lock = std::getenv("IFC_LOD_LOCK_BORDER");
+ const char* env_err = std::getenv("IFC_LOD_ERROR");
+ const char* env_ratio = std::getenv("IFC_LOD_RATIO");
+ const char* env_savings = std::getenv("IFC_LOD_MIN_SAVINGS");
+ const char* env_debug = std::getenv("IFC_LOD_DEBUG");
+ const char* env_sloppy = std::getenv("IFC_LOD_SLOPPY");
+
+ const bool lock_border = env_lock && env_lock[0] == '1';
+ const bool use_sloppy = !(env_sloppy && env_sloppy[0] == '0');
+ if (env_err) target_error = static_cast(std::atof(env_err));
+ if (env_ratio) target_ratio = static_cast(std::atof(env_ratio));
+ float min_savings = 0.25f;
+ if (env_savings) min_savings = static_cast(std::atof(env_savings));
+ const bool debug = env_debug && env_debug[0] == '1';
+
+ // Loosened defaults: BIM meshes are non-manifold; LockBorder ≈ zero
+ // collapses. A 0.2 error budget still looks fine at sub-4px.
+ if (target_error < 0.2f) target_error = 0.2f;
+
+ // Scratch buffers reused across meshes so we only allocate once.
+ std::vector simplified;
+ std::vector shadow;
+ simplified.reserve(1024);
+ shadow.reserve(1024);
+
+ int dbg_printed = 0;
+ int dbg_rejected_savings = 0;
+ int dbg_rejected_noreduce = 0;
+ int dbg_accepted = 0;
+
+ for (auto& mesh : sd.meshes) {
+ mesh.lod1_ebo_byte_offset = 0;
+ mesh.lod1_index_count = 0;
+
+ const uint32_t tri_count = mesh.index_count / 3;
+ if (static_cast(tri_count) < min_triangles) continue;
+ if (mesh.vertex_count == 0) continue;
+
+ // meshopt wants a pointer to the *first position* and a vertex_count
+ // equal to the number of referenced vertices (i.e. the absolute upper
+ // bound on indices we might see). Indices in `sd.indices` for this
+ // mesh are mesh-local (0..mesh.vertex_count). Pass the base-vertex
+ // as an offset into sd.vertices so meshopt reads positions at the
+ // right place.
+ const uint32_t base_vertex = mesh.vbo_byte_offset / vtx_stride_bytes;
+ if (base_vertex + mesh.vertex_count > total_vertex_count) continue;
+
+ const uint32_t first_index = mesh.ebo_byte_offset / sizeof(uint32_t);
+ if (first_index + mesh.index_count > sd.indices.size()) continue;
+
+ const float* positions =
+ sd.vertices.data() + base_vertex * vtx_stride_floats;
+ const uint32_t* indices = sd.indices.data() + first_index;
+
+ const size_t target_index_count = std::max(
+ 3, static_cast(mesh.index_count * target_ratio) / 3 * 3);
+
+ // The instanced VBO stores each triangle's vertices separately, so the
+ // mesh's index buffer is topologically disconnected — every edge is
+ // boundary, every vertex is unique, and meshopt_simplify can't collapse
+ // anything. Build a shadow index buffer that welds by position, so
+ // shared-position vertices share an ID; then simplify on that. Output
+ // indices are still valid mesh-local IDs (canonical representatives),
+ // usable directly as LOD1 indices against the same VBO.
+ shadow.resize(mesh.index_count);
+ meshopt_generateShadowIndexBuffer(
+ shadow.data(),
+ indices, mesh.index_count,
+ positions, mesh.vertex_count,
+ sizeof(float) * 3, // compare only xyz
+ vtx_stride_bytes);
+
+ simplified.resize(mesh.index_count);
+ float result_error = 0.0f;
+ size_t new_index_count = 0;
+
+ if (use_sloppy) {
+ // Cluster-based decimator. Ignores topology entirely; great for
+ // BIM brep output which is usually non-manifold / has T-junctions.
+ // Operates directly on the original indices — welding isn't
+ // needed since it quantises positions into voxel cells.
+ new_index_count = meshopt_simplifySloppy(
+ simplified.data(),
+ indices, mesh.index_count,
+ positions, mesh.vertex_count, vtx_stride_bytes,
+ target_index_count, target_error,
+ &result_error);
+ } else {
+ const unsigned int options =
+ lock_border ? static_cast(meshopt_SimplifyLockBorder) : 0u;
+ new_index_count = meshopt_simplify(
+ simplified.data(),
+ shadow.data(), mesh.index_count,
+ positions, mesh.vertex_count, vtx_stride_bytes,
+ target_index_count, target_error,
+ options, &result_error);
+ }
+
+ if (debug && dbg_printed < 8) {
+ std::fprintf(stderr,
+ " [lod] mesh tris=%u target=%zu got=%zu err=%.4f\n",
+ tri_count, target_index_count / 3,
+ new_index_count / 3, result_error);
+ ++dbg_printed;
+ }
+
+ // Accept only if we actually saved a meaningful chunk of tris.
+ if (new_index_count == 0 || new_index_count >= mesh.index_count) {
+ ++dbg_rejected_noreduce;
+ continue;
+ }
+
+ const uint32_t saved = mesh.index_count - static_cast(new_index_count);
+ if (static_cast(saved) < min_savings * static_cast(mesh.index_count)) {
+ ++dbg_rejected_savings;
+ continue;
+ }
+ ++dbg_accepted;
+
+ // Append the surviving indices to sd.indices; record the offset.
+ const size_t append_offset_bytes = sd.indices.size() * sizeof(uint32_t);
+ sd.indices.insert(sd.indices.end(),
+ simplified.begin(),
+ simplified.begin() + new_index_count);
+ mesh.lod1_ebo_byte_offset = static_cast(append_offset_bytes);
+ mesh.lod1_index_count = static_cast(new_index_count);
+ }
+
+ if (debug) {
+ std::fprintf(stderr,
+ " [lod] summary: accepted=%d rejected_noreduce=%d rejected_savings=%d "
+ "(lock_border=%d target_error=%.3f target_ratio=%.3f min_savings=%.3f)\n",
+ dbg_accepted, dbg_rejected_noreduce, dbg_rejected_savings,
+ lock_border ? 1 : 0, target_error, target_ratio, min_savings);
+ }
+}
+
+LodStats summariseLods(const SidecarData& sd) {
+ LodStats s;
+ s.meshes_total = static_cast(sd.meshes.size());
+ for (const auto& m : sd.meshes) {
+ s.tris_lod0 += m.index_count / 3;
+ if (m.lod1_index_count > 0) {
+ ++s.meshes_with_lod1;
+ s.tris_lod1 += m.lod1_index_count / 3;
+ s.tris_lod0_for_lod1 += m.index_count / 3;
+ }
+ }
+ return s;
+}
diff --git a/src/ifcviewer/LodBuilder.h b/src/ifcviewer/LodBuilder.h
new file mode 100644
index 0000000000..a937ae4987
--- /dev/null
+++ b/src/ifcviewer/LodBuilder.h
@@ -0,0 +1,56 @@
+/********************************************************************************
+ * *
+ * This file is part of IfcOpenShell. *
+ * *
+ * IfcOpenShell is free software: you can redistribute it and/or modify *
+ * it under the terms of the Lesser GNU General Public License as published by *
+ * the Free Software Foundation, either version 3.0 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * IfcOpenShell is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * Lesser GNU General Public License for more details. *
+ * *
+ * You should have received a copy of the Lesser GNU General Public License *
+ * along with this program. If not, see . *
+ * *
+ ********************************************************************************/
+
+#ifndef LODBUILDER_H
+#define LODBUILDER_H
+
+#include "SidecarCache.h"
+
+// Build a LOD1 index slice for every mesh in `sd` whose triangle count is
+// above `min_triangles`, using meshoptimizer's edge-collapse decimator. The
+// LOD1 indices are appended to `sd.indices`; each MeshInfo's
+// lod1_ebo_byte_offset + lod1_index_count are populated to point at the
+// appended range. Meshes that don't qualify (too small) or where the
+// decimator couldn't meet the target within the error budget have
+// lod1_index_count left at 0 (renderer falls back to LOD0).
+//
+// Defaults match the Phase 3B first-iteration design:
+// min_triangles = 500 — below this the overhead dominates
+// target_ratio = 0.25 — aim for 25% of original tris
+// target_error = 0.05 — stop if relative error exceeds 5%
+//
+// `sd.vertices` is read (position is the first 3 floats of each
+// INSTANCED_VERTEX_STRIDE_FLOATS-wide vertex) but not modified — LOD1
+// reuses the same vertex buffer, just with a different index list.
+void buildLods(SidecarData& sd,
+ int min_triangles = 500,
+ float target_ratio = 0.25f,
+ float target_error = 0.05f);
+
+// Cheap summary for logging. Safe to call before or after buildLods.
+struct LodStats {
+ uint32_t meshes_total = 0;
+ uint32_t meshes_with_lod1 = 0;
+ uint32_t tris_lod0 = 0; // sum across all meshes
+ uint32_t tris_lod1 = 0; // only for meshes that got LOD1
+ uint32_t tris_lod0_for_lod1 = 0; // LOD0 tris of the meshes that got LOD1
+};
+LodStats summariseLods(const SidecarData& sd);
+
+#endif // LODBUILDER_H
diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer/MainWindow.cpp
index 8b63f3bdf6..7dc5454700 100644
--- a/src/ifcviewer/MainWindow.cpp
+++ b/src/ifcviewer/MainWindow.cpp
@@ -20,6 +20,7 @@
#include "MainWindow.h"
#include "AppSettings.h"
#include "SettingsWindow.h"
+#include "LodBuilder.h"
#include "SidecarCache.h"
#include
@@ -395,6 +396,19 @@ void MainWindow::onStreamingFinished() {
sd.elements.push_back(pe);
}
+ // Build LOD1 for eligible meshes (extends sd.indices and
+ // populates MeshInfo::lod1_*), push the extension onto the
+ // live GPU state so this session benefits too, then cache.
+ QElapsedTimer t_lod; t_lod.start();
+ buildLods(sd);
+ LodStats ls = summariseLods(sd);
+ qDebug(" LOD build: %lld ms — %u/%u meshes got LOD1 "
+ "(%u tris → %u tris for those meshes)",
+ t_lod.elapsed(),
+ ls.meshes_with_lod1, ls.meshes_total,
+ ls.tris_lod0_for_lod1, ls.tris_lod1);
+ viewport_->applyLodExtension(loading_model_id_, sd);
+
std::string ifc_path = it->second.file_path.toStdString();
uint64_t file_size = static_cast(
QFileInfo(it->second.file_path).size());
diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md
index 7bb6972b83..82bd89555c 100644
--- a/src/ifcviewer/README.md
+++ b/src/ifcviewer/README.md
@@ -92,7 +92,8 @@ engine with a Qt6 interface and OpenGL 4.5 rendering.
| `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 |
+| `LodBuilder.h/cpp` | Post-stream decimation of unique meshes via meshoptimizer (`simplifySloppy`) |
+| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v5) sidecar read/write |
| `AppSettings.h/cpp` | Persisted preferences (geometry library, stats overlay, backface culling) |
| `SettingsWindow.h/cpp` | Settings dialog |
| `CMakeLists.txt` | Build configuration |
@@ -106,6 +107,9 @@ engine with a Qt6 interface and OpenGL 4.5 rendering.
at GL 4.1).
- **IfcOpenShell C++ libraries** (IfcParse, IfcGeom, and their
dependencies: Open CASCADE, Boost, Eigen3, optionally CGAL).
+- **[meshoptimizer](https://github.com/zeux/meshoptimizer)** — linked via
+ `find_package(meshoptimizer REQUIRED)`. Used at sidecar-build time for LOD
+ decimation; not needed at runtime once a sidecar exists.
## Building
@@ -266,7 +270,7 @@ while stack not empty:
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)
+#### Sidecar format (`.ifcview`, v5)
Raw memory dump, Blender-`.blend`-style — no serialisation, no parsing.
Stores everything needed to skip the `IfcGeom::Iterator` pass:
@@ -276,7 +280,7 @@ 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 + MeshInfo[] per-unique-mesh metadata (56 B each, incl. LOD1 slice)
uint32_t + InstanceCpu[] per-placement records (transform + AABB + ids)
uint32_t + PackedElementInfo[] element tree records
uint32_t + char[] string table
@@ -449,14 +453,117 @@ At 4 px, frame time breakdown matches: ~16 ms non-draw baseline (from
throughput on the post-cull geometry — next steps (LOD, HiZ) attack
that directly.
-#### 3B. Distance / contribution LOD (medium-term)
+#### 3B. Distance / contribution LOD — ✅ done
-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.
+Decimate each unique representation once (at sidecar-build time), store
+the reduced index slice in the same EBO, and switch to it per-instance
+per-frame whenever the projected sphere radius is small enough that the
+reduced silhouette is indistinguishable from the original.
+
+##### Pipeline
+
+1. **After streaming finishes**, `MainWindow` calls `buildLods(sd)` on
+ the snapshotted `SidecarData`. Each eligible mesh's decimated index
+ list is appended to `sd.indices`; the per-mesh `MeshInfo` gains two
+ new fields:
+
+ ```cpp
+ uint32_t lod1_ebo_byte_offset; // appended slice, same VBO
+ uint32_t lod1_index_count; // 0 = no LOD1 was built
+ ```
+
+ `MeshInfo` grew from 48 to 56 bytes, which also bumps the sidecar
+ format to v5.
+
+2. `viewport_->applyLodExtension(model_id, sd)` pushes the new index
+ suffix onto the live EBO via `glNamedBufferSubData` and replaces the
+ CPU-side `m.meshes` vector. The VBO and instance SSBO are untouched
+ — LOD1 reuses the same vertices, only the indices differ.
+
+3. The sidecar is then written with both LOD0 and LOD1 indices baked in,
+ so subsequent loads of the same file pick up LOD1 for free.
+
+##### Selection
+
+The contribution-cull pass already computes each instance's projected
+pixel radius. LOD1 is selected when that radius falls below
+`IFC_LOD1_PX` (default 30 px) and the mesh has a non-empty LOD1 slice.
+Camera-inside-AABB short-circuits select LOD0 (treated as "infinite
+radius") so you never accidentally see the reduced mesh up close.
+
+The visible-instance pipeline gains two more buckets (`fwd_lod1_`,
+`rev_lod1_`), so the four-way split is now `{fwd, rev} × {LOD0, LOD1}`.
+LOD0/LOD1 within a winding slice are contiguous — only winding requires
+`glFrontFace` to flip between MDI calls, LOD does not. `firstIndex` /
+`count` in the `DrawElementsIndirectCommand` pick which slice of the EBO
+to walk; everything else (base vertex, base instance, SSBO bindings,
+shader) is unchanged.
+
+##### Decimator choice: `meshopt_simplifySloppy`
+
+The first attempt used `meshopt_simplify`, which is an edge-collapse
+decimator. It returned every input mesh unchanged (`err = 0.0`) for two
+reasons, both inherent to BIM brep output:
+
+1. **Per-triangle vertex duplication.** The instanced VBO stores each
+ triangle's vertices separately so that hard-edge normals can differ
+ across triangles. Topologically there are no shared vertices, so no
+ edges exist for `meshopt_simplify` to collapse. A
+ `meshopt_generateShadowIndexBuffer` welding pass (hash xyz only,
+ ignore the interleaved normal/colour) fixes this half cheaply — the
+ VBO isn't touched, only a per-call shadow index buffer is built.
+2. **Non-manifold topology even after welding.** BIM brep output has
+ T-junctions, coplanar slivers, separate solids meeting at a plane,
+ and multi-material cuts. `meshopt_simplify` needs valid 2-manifold
+ edge pairs to score collapses; it refuses the non-manifold ones, the
+ priority queue never fires, and it returns the input untouched.
+
+`meshopt_simplifySloppy` is a **voxel-clustering decimator** — it
+quantises positions into cells and merges everything in a cell to a
+single point. Topology is irrelevant, so it works directly on the
+original indices (welding isn't even needed). The trade-off is that it
+rounds off sharp corners and can produce slightly degenerate triangles,
+so it doesn't look great at mid-screen size. For a LOD1 that only
+activates below 30 px projected radius that's invisible in practice. If
+you ever want LOD1 to remain active at larger sizes, the only robust
+fix is to pre-process BIM meshes into manifold form (fuse coplanar
+faces, split at T-junctions) — a significant project unto itself.
+
+##### Tuning knobs (env vars)
+
+| Var | Default | Effect |
+|-----|---------|--------|
+| `IFC_LOD1_PX` | `30` | Projected sphere radius (px) below which LOD1 kicks in. `0` disables LOD1 entirely. |
+| `IFC_LOD_SLOPPY` | `1` | `0` falls back to edge-collapse (`meshopt_simplify`) on shadow-welded indices. Typically produces zero LOD1 output for BIM — useful only for A/B comparison. |
+| `IFC_LOD_ERROR` | `0.2` | Target relative error passed to meshopt. |
+| `IFC_LOD_RATIO` | `0.25` | Target triangle-count ratio (LOD1 aims for 25 % of LOD0 tris). |
+| `IFC_LOD_MIN_SAVINGS` | `0.25` | Reject the LOD1 result if it doesn't shave at least this fraction of triangles. |
+| `IFC_LOD_LOCK_BORDER` | `0` | `1` re-enables `meshopt_SimplifyLockBorder` (only meaningful with `IFC_LOD_SLOPPY=0`). |
+| `IFC_LOD_DEBUG` | `0` | `1` prints per-mesh `tris / target / got / err` for the first 8 candidate meshes plus an accept/reject summary per model. |
+
+##### Measured results
+
+Same 10-model / 128 M-tri scene as Phase 3A (GTX 1650), 2 px contribution
+threshold, overview camera, all models finalised with LOD1 built:
+
+| Build | FPS | Frame time | Visible tris | Visible objs |
+|-------|-----|-----------|--------------|--------------|
+| Phase 3A alone (2 px) | 20.2 | 49 ms | 40 M | 89 k |
+| Phase 3A + 3B (LOD1 ≤ 30 px) | **43.2** | **23 ms** | 14 M | 81 k |
+
+Roughly half the remaining frame time, same object count (LOD is
+lossless w.r.t. visibility — swapping index slice doesn't hide
+anything). The triangle reduction on meshes that qualified for LOD1 is
+~80 %: e.g. 4.17 M → 0.82 M tris for the 3618 eligible meshes of Model
+1, 3.25 M → 0.65 M for Model 2, etc. Only about 20 % of unique meshes
+qualify (the threshold is 500 tris — below that the indirect-command
+overhead dominates), but those are the fat tail carrying most of the
+rasterisation cost.
+
+LOD build itself runs on the main thread inside `onStreamingFinished`;
+typical cost is 100–600 ms per model, folded into the already-visible
+"finalizing" step. Cached into the sidecar afterwards, so subsequent
+opens skip it entirely.
#### 3C. Hierarchical-Z occlusion culling (longer-term)
@@ -490,7 +597,7 @@ Scene size Bottleneck Fix
< 100k instances CPU cull scan Phase 1 only
100k–500k CPU cull scan BVH (Phase 2) — done
500k+ tris / overview shot GPU vertex + raster Phase 3A contribution cull
- (+ 3B LOD for close-ups)
+ + Phase 3B LOD (done)
multi-million + occluders redundant rasterisation Phase 3C HiZ occlusion
```
@@ -508,10 +615,10 @@ multi-million + occluders redundant rasterisation Phase 3C HiZ occlusion
- [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`, `IFC_MIN_PX`)
+- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`)
- [x] Phase 3A — screen-space contribution culling
-- [ ] **Phase 3B — distance / contribution LOD** (next)
-- [ ] Phase 3C — Hierarchical-Z occlusion culling
+- [x] Phase 3B — distance / contribution LOD (meshoptimizer `simplifySloppy`)
+- [ ] **Phase 3C — Hierarchical-Z occlusion culling** (next)
- [ ] Phase 3D — GPU-side compute-shader culling
- [ ] Vulkan/MoltenVK backend for macOS
- [ ] Embedded Python scripting console
diff --git a/src/ifcviewer/SidecarCache.cpp b/src/ifcviewer/SidecarCache.cpp
index 3c5ca9cd8d..da3943988d 100644
--- a/src/ifcviewer/SidecarCache.cpp
+++ b/src/ifcviewer/SidecarCache.cpp
@@ -17,7 +17,11 @@
* *
********************************************************************************/
-// v4 layout (all multi-byte fields native-endian; endianness marker in header):
+// v5 layout (all multi-byte fields native-endian; endianness marker in header).
+// Same sequence as v4; the only change is that MeshInfo grew two uint32_ts
+// (lod1_ebo_byte_offset + lod1_index_count) and `indices` may contain extra
+// appended LOD1 slices pointed at by those offsets.
+//
//
// SidecarHeader (16 bytes)
// uint64_t source_file_size
diff --git a/src/ifcviewer/SidecarCache.h b/src/ifcviewer/SidecarCache.h
index e14eb9d256..332abdc802 100644
--- a/src/ifcviewer/SidecarCache.h
+++ b/src/ifcviewer/SidecarCache.h
@@ -34,7 +34,10 @@
#include
static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW"
-static constexpr uint32_t SIDECAR_VERSION = 4;
+// v5 = MeshInfo extended with lod1_ebo_byte_offset + lod1_index_count (56 B).
+// sd.indices may contain an appended LOD1 index slice for each mesh
+// where meshoptimizer decimation produced useful output.
+static constexpr uint32_t SIDECAR_VERSION = 5;
static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304;
// Fixed-size element record. Strings are stored as (offset, length) pairs
diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp
index db002c1870..2606ffd3f3 100644
--- a/src/ifcviewer/ViewportWindow.cpp
+++ b/src/ifcviewer/ViewportWindow.cpp
@@ -751,6 +751,34 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
ssbo_bytes / (1024.0*1024.0));
}
+void ViewportWindow::applyLodExtension(uint32_t model_id, const SidecarData& sd) {
+ if (!gl_initialized_) return;
+ auto it = models_gpu_.find(model_id);
+ if (it == models_gpu_.end() || !it->second.finalized) return;
+ ModelGpuData& m = it->second;
+
+ const size_t total_ib_bytes = sd.indices.size() * sizeof(uint32_t);
+ if (total_ib_bytes <= m.ebo_used) {
+ // buildLods didn't add anything; just refresh the meshes vector in
+ // case lod1_* fields were touched.
+ m.meshes = sd.meshes;
+ return;
+ }
+
+ context_->makeCurrent(this);
+ if (total_ib_bytes > m.ebo_capacity) {
+ if (!growModelEbo(m, total_ib_bytes)) return;
+ }
+ const size_t append_bytes = total_ib_bytes - m.ebo_used;
+ const uint32_t* appended_src =
+ sd.indices.data() + (m.ebo_used / sizeof(uint32_t));
+ gl_->glNamedBufferSubData(m.ebo, m.ebo_used, append_bytes, appended_src);
+ m.ebo_used = total_ib_bytes;
+
+ // Replace mesh metadata so cullAndUploadVisible sees the new lod1_ fields.
+ m.meshes = sd.meshes;
+}
+
void ViewportWindow::resetScene() {
if (!gl_initialized_) return;
context_->makeCurrent(this);
@@ -826,17 +854,33 @@ uint32_t ViewportWindow::pickObjectAt(int x, int y) {
void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6][4],
float focal_px, float min_pixel_radius) {
- // Per-mesh scratch, split by winding: fwd = non-reflected (CCW in screen
- // space), rev = reflected (CW in screen space). Splitting lets the draw
+ // Per-mesh scratch, split by winding × LOD. Winding split lets the draw
// pass toggle glFrontFace once between two MDI calls so GL_CULL_FACE does
- // the right thing for both.
- if (visible_by_mesh_fwd_.size() < m.meshes.size()) visible_by_mesh_fwd_.resize(m.meshes.size());
- if (visible_by_mesh_rev_.size() < m.meshes.size()) visible_by_mesh_rev_.resize(m.meshes.size());
+ // the right thing for both. LOD split means instances that want the
+ // decimated mesh go into a different bucket that emits against
+ // mesh.lod1_ebo_byte_offset / lod1_index_count.
+ auto resize_if = [&](std::vector>& v) {
+ if (v.size() < m.meshes.size()) v.resize(m.meshes.size());
+ };
+ resize_if(visible_by_mesh_fwd_lod0_);
+ resize_if(visible_by_mesh_fwd_lod1_);
+ resize_if(visible_by_mesh_rev_lod0_);
+ resize_if(visible_by_mesh_rev_lod1_);
for (size_t i = 0; i < m.meshes.size(); ++i) {
- visible_by_mesh_fwd_[i].clear();
- visible_by_mesh_rev_[i].clear();
+ visible_by_mesh_fwd_lod0_[i].clear();
+ visible_by_mesh_fwd_lod1_[i].clear();
+ visible_by_mesh_rev_lod0_[i].clear();
+ visible_by_mesh_rev_lod1_[i].clear();
}
+ // LOD1 switches in when projected sphere radius (in pixels) drops below
+ // this threshold. Overridable for tuning. Set to 0 to disable LOD1
+ // entirely (always draw LOD0).
+ static const float lod1_px_threshold = []{
+ const char* e = std::getenv("IFC_LOD1_PX");
+ return (e && *e) ? static_cast(std::atof(e)) : 30.0f;
+ }();
+
// Bounding-sphere contribution test: approximate an AABB by its enclosing
// sphere (centre = midpoint, radius = half-diagonal). Project radius to
// pixels as r_px = focal_px * r / distance (perspective). Reject if
@@ -871,15 +915,44 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6]
return focal_px * radius >= min_pixel_radius * dist;
};
+ // Returns projected sphere radius in pixels (or +inf when camera is
+ // inside the AABB). Shares the geometry with contributionPasses; this
+ // version returns the value so we can also use it for LOD selection.
+ auto pixelRadius = [&](const float mn[3], const float mx[3]) -> float {
+ if (cx >= mn[0] && cx <= mx[0] &&
+ cy >= mn[1] && cy <= mx[1] &&
+ cz >= mn[2] && cz <= mx[2]) {
+ return std::numeric_limits::infinity();
+ }
+ float ex = 0.5f * (mx[0] - mn[0]);
+ float ey = 0.5f * (mx[1] - mn[1]);
+ float ez = 0.5f * (mx[2] - mn[2]);
+ float radius = std::sqrt(ex*ex + ey*ey + ez*ez);
+ float dx = 0.5f * (mx[0] + mn[0]) - cx;
+ float dy = 0.5f * (mx[1] + mn[1]) - cy;
+ float dz = 0.5f * (mx[2] + mn[2]) - cz;
+ float dist = std::sqrt(dx*dx + dy*dy + dz*dz);
+ return dist > 0.0f ? focal_px * radius / dist
+ : std::numeric_limits::infinity();
+ };
+
auto test_and_push = [&](uint32_t inst_idx) {
const InstanceCpu& inst = m.instances[inst_idx];
if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) return;
if (!contributionPasses(inst.world_aabb_min, inst.world_aabb_max)) return;
if (inst.mesh_id >= m.meshes.size()) return;
+ const MeshInfo& mesh = m.meshes[inst.mesh_id];
+ const bool want_lod1 = mesh.lod1_index_count > 0 &&
+ lod1_px_threshold > 0.0f &&
+ pixelRadius(inst.world_aabb_min, inst.world_aabb_max) < lod1_px_threshold;
const bool reflected = inst_idx < m.instance_reflected.size()
&& m.instance_reflected[inst_idx] != 0;
- if (reflected) visible_by_mesh_rev_[inst.mesh_id].push_back(inst_idx);
- else visible_by_mesh_fwd_[inst.mesh_id].push_back(inst_idx);
+ auto& bucket =
+ reflected ? (want_lod1 ? visible_by_mesh_rev_lod1_
+ : visible_by_mesh_rev_lod0_)
+ : (want_lod1 ? visible_by_mesh_fwd_lod1_
+ : visible_by_mesh_fwd_lod0_);
+ bucket[inst.mesh_id].push_back(inst_idx);
};
if (!m.bvh.nodes.empty()) {
@@ -911,22 +984,28 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6]
for (uint32_t i = 0; i < m.instances.size(); ++i) test_and_push(i);
}
- // Flatten fwd-slice first, then rev-slice, into visible_flat_. Build
- // matching DrawElementsIndirectCommands; commands for the fwd slice fill
- // [0, indirect_forward_count), rev fills [indirect_forward_count, end).
+ // Flatten fwd-slice first (LOD0 then LOD1), then rev-slice (ditto), into
+ // visible_flat_. Commands for the fwd slice fill [0, indirect_forward_count),
+ // rev fills [indirect_forward_count, end). LOD0/LOD1 within a winding
+ // slice are contiguous — winding is what requires glFrontFace to flip
+ // between MDI calls, LOD is not.
visible_flat_.clear();
indirect_scratch_.clear();
- auto emit_slice = [&](std::vector>& by_mesh) {
+ auto emit_slice = [&](std::vector>& by_mesh, int lod) {
for (size_t mi = 0; mi < m.meshes.size(); ++mi) {
const auto& mesh = m.meshes[mi];
const uint32_t vis_count = static_cast(by_mesh[mi].size());
- if (vis_count == 0 || mesh.index_count == 0) continue;
+ const uint32_t idx_count =
+ (lod == 1) ? mesh.lod1_index_count : mesh.index_count;
+ const uint32_t ebo_off =
+ (lod == 1) ? mesh.lod1_ebo_byte_offset : mesh.ebo_byte_offset;
+ if (vis_count == 0 || idx_count == 0) continue;
DrawElementsIndirectCommand cmd;
- cmd.count = mesh.index_count;
+ cmd.count = idx_count;
cmd.instanceCount = vis_count;
- cmd.firstIndex = mesh.ebo_byte_offset / sizeof(uint32_t);
+ cmd.firstIndex = ebo_off / sizeof(uint32_t);
cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES;
cmd.baseInstance = static_cast(visible_flat_.size());
indirect_scratch_.push_back(cmd);
@@ -936,9 +1015,11 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6]
}
};
- emit_slice(visible_by_mesh_fwd_);
+ emit_slice(visible_by_mesh_fwd_lod0_, 0);
+ emit_slice(visible_by_mesh_fwd_lod1_, 1);
m.indirect_forward_count = static_cast(indirect_scratch_.size());
- emit_slice(visible_by_mesh_rev_);
+ emit_slice(visible_by_mesh_rev_lod0_, 0);
+ emit_slice(visible_by_mesh_rev_lod1_, 1);
m.indirect_command_count = static_cast(indirect_scratch_.size());
// Upload visible list (keep binding alive even when empty).
diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h
index a8d696121a..fe54cce921 100644
--- a/src/ifcviewer/ViewportWindow.h
+++ b/src/ifcviewer/ViewportWindow.h
@@ -127,6 +127,13 @@ public:
// any existing state for model_id and marks it drawable.
void applyCachedModel(uint32_t model_id, SidecarData data);
+ // After buildLods() has extended sd.indices + populated lod1_* fields,
+ // push just the appended index slice + the refreshed mesh metadata onto
+ // the live GPU state for model_id. VBO / SSBO / instance array are left
+ // alone; only the EBO grows and m.meshes is replaced. No-op if the
+ // model isn't finalised on the viewport.
+ void applyLodExtension(uint32_t model_id, const SidecarData& sd);
+
void hideModel(uint32_t model_id);
void showModel(uint32_t model_id);
void removeModel(uint32_t model_id);
@@ -223,8 +230,13 @@ private:
// per-frame allocation. indirect_scratch_ is the matching array of
// DrawElementsIndirectCommand records — forward-declared as bytes so
// the header doesn't need the struct definition.
- std::vector> visible_by_mesh_fwd_;
- std::vector> visible_by_mesh_rev_;
+ // Four buckets = {fwd, rev} × {LOD0, LOD1}. LOD1 buckets are only
+ // populated when the mesh has lod1_index_count > 0 and the projected
+ // pixel radius is below the LOD switch threshold.
+ std::vector> visible_by_mesh_fwd_lod0_;
+ std::vector> visible_by_mesh_fwd_lod1_;
+ std::vector> visible_by_mesh_rev_lod0_;
+ std::vector> visible_by_mesh_rev_lod1_;
std::vector visible_flat_;
std::vector indirect_scratch_;