Phase 3B: per-instance LOD via meshoptimizer simplifySloppy

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>
This commit is contained in:
Dion Moult
2026-04-13 18:31:43 +10:00
parent 68fea7bd45
commit c78e16eafb
10 changed files with 532 additions and 40 deletions
+14 -4
View File
@@ -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: