ifcviewer: remove meshopt_simplify path, keep only simplifySloppy

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>
This commit is contained in:
Dion Moult
2026-04-20 15:41:06 +10:00
parent 3015f758ba
commit 5161b0a3f8
3 changed files with 25 additions and 82 deletions
+12 -53
View File
@@ -37,28 +37,17 @@ void buildLods(SidecarData& sd,
const size_t total_vertex_count = sd.vertices.size() / vtx_stride_bytes; const size_t total_vertex_count = sd.vertices.size() / vtx_stride_bytes;
// Env var knobs so we can tune without rebuilding. // 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=<float> override target_error (default 0.05 → 0.2). // IFC_LOD_ERROR=<float> override target_error (default 0.05 → 0.2).
// IFC_LOD_RATIO=<float> override target_ratio. // IFC_LOD_RATIO=<float> override target_ratio.
// IFC_LOD_MIN_SAVINGS=<0..1> minimum fraction of tris saved to accept // IFC_LOD_MIN_SAVINGS=<0..1> minimum fraction of tris saved to accept
// (default 0.25). // (default 0.25).
// IFC_LOD_DEBUG=1 print per-mesh diagnostics for the first // IFC_LOD_DEBUG=1 print per-mesh diagnostics for the first
// few meshes of each call. // 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_err = std::getenv("IFC_LOD_ERROR");
const char* env_ratio = std::getenv("IFC_LOD_RATIO"); const char* env_ratio = std::getenv("IFC_LOD_RATIO");
const char* env_savings = std::getenv("IFC_LOD_MIN_SAVINGS"); const char* env_savings = std::getenv("IFC_LOD_MIN_SAVINGS");
const char* env_debug = std::getenv("IFC_LOD_DEBUG"); 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<float>(std::atof(env_err)); if (env_err) target_error = static_cast<float>(std::atof(env_err));
if (env_ratio) target_ratio = static_cast<float>(std::atof(env_ratio)); if (env_ratio) target_ratio = static_cast<float>(std::atof(env_ratio));
float min_savings = 0.25f; float min_savings = 0.25f;
@@ -71,10 +60,8 @@ void buildLods(SidecarData& sd,
// Scratch buffers reused across meshes so we only allocate once. // Scratch buffers reused across meshes so we only allocate once.
std::vector<uint32_t> simplified; std::vector<uint32_t> simplified;
std::vector<uint32_t> shadow;
std::vector<float> dequant_pos; // 3 floats/vertex, dequantized std::vector<float> dequant_pos; // 3 floats/vertex, dequantized
simplified.reserve(1024); simplified.reserve(1024);
shadow.reserve(1024);
dequant_pos.reserve(1024 * 3); dequant_pos.reserve(1024 * 3);
int dbg_printed = 0; int dbg_printed = 0;
@@ -128,46 +115,18 @@ void buildLods(SidecarData& sd,
const size_t target_index_count = std::max<size_t>( const size_t target_index_count = std::max<size_t>(
3, static_cast<size_t>(mesh.index_count * target_ratio) / 3 * 3); 3, static_cast<size_t>(mesh.index_count * target_ratio) / 3 * 3);
// The instanced VBO stores each triangle's vertices separately, so the // Cluster-based (sloppy) decimator. Ignores topology entirely;
// mesh's index buffer is topologically disconnected — every edge is // ideal for BIM brep output which is usually non-manifold / has
// boundary, every vertex is unique, and meshopt_simplify can't collapse // T-junctions / per-triangle vertex duplication. Quantises
// anything. Build a shadow index buffer that welds by position, so // positions into voxel cells — no welding needed.
// 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
local_pos_stride);
simplified.resize(mesh.index_count); simplified.resize(mesh.index_count);
float result_error = 0.0f; float result_error = 0.0f;
size_t new_index_count = 0; size_t new_index_count = meshopt_simplifySloppy(
simplified.data(),
if (use_sloppy) { indices, mesh.index_count,
// Cluster-based decimator. Ignores topology entirely; great for positions, mesh.vertex_count, local_pos_stride,
// BIM brep output which is usually non-manifold / has T-junctions. target_index_count, target_error,
// Operates directly on the original indices — welding isn't &result_error);
// needed since it quantises positions into voxel cells.
new_index_count = meshopt_simplifySloppy(
simplified.data(),
indices, mesh.index_count,
positions, mesh.vertex_count, local_pos_stride,
target_index_count, target_error,
&result_error);
} else {
const unsigned int options =
lock_border ? static_cast<unsigned int>(meshopt_SimplifyLockBorder) : 0u;
new_index_count = meshopt_simplify(
simplified.data(),
shadow.data(), mesh.index_count,
positions, mesh.vertex_count, local_pos_stride,
target_index_count, target_error,
options, &result_error);
}
if (debug && dbg_printed < 8) { if (debug && dbg_printed < 8) {
std::fprintf(stderr, std::fprintf(stderr,
@@ -202,9 +161,9 @@ void buildLods(SidecarData& sd,
if (debug) { if (debug) {
std::fprintf(stderr, std::fprintf(stderr,
" [lod] summary: accepted=%d rejected_noreduce=%d rejected_savings=%d " " [lod] summary: accepted=%d rejected_noreduce=%d rejected_savings=%d "
"(lock_border=%d target_error=%.3f target_ratio=%.3f min_savings=%.3f)\n", "(target_error=%.3f target_ratio=%.3f min_savings=%.3f)\n",
dbg_accepted, dbg_rejected_noreduce, dbg_rejected_savings, dbg_accepted, dbg_rejected_noreduce, dbg_rejected_savings,
lock_border ? 1 : 0, target_error, target_ratio, min_savings); target_error, target_ratio, min_savings);
} }
} }
+2 -2
View File
@@ -23,8 +23,8 @@
#include "SidecarCache.h" #include "SidecarCache.h"
// Build a LOD1 index slice for every mesh in `sd` whose triangle count is // Build a LOD1 index slice for every mesh in `sd` whose triangle count is
// above `min_triangles`, using meshoptimizer's edge-collapse decimator. The // above `min_triangles`, using meshoptimizer's sloppy (voxel-clustering)
// LOD1 indices are appended to `sd.indices`; each MeshInfo's // 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 // 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 // appended range. Meshes that don't qualify (too small) or where the
// decimator couldn't meet the target within the error budget have // decimator couldn't meet the target within the error budget have
+11 -27
View File
@@ -540,44 +540,28 @@ shader) is unchanged.
##### Decimator choice: `meshopt_simplifySloppy` ##### 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 `meshopt_simplifySloppy` is a **voxel-clustering decimator** — it
quantises positions into cells and merges everything in a cell to a quantises positions into cells and merges everything in a cell to a
single point. Topology is irrelevant, so it works directly on the single point. This is the only meshoptimizer decimator that works on
original indices (welding isn't even needed). The trade-off is that it BIM brep output, which has per-triangle vertex duplication (hard-edge
rounds off sharp corners and can produce slightly degenerate triangles, normals) and non-manifold topology (T-junctions, coplanar slivers,
so it doesn't look great at mid-screen size. For a LOD1 that only separate solids meeting at a plane). The edge-collapse decimator
activates below 30 px projected radius that's invisible in practice. If (`meshopt_simplify`) needs 2-manifold edge pairs to score collapses;
you ever want LOD1 to remain active at larger sizes, the only robust on BIM geometry it returns the input unchanged.
fix is to pre-process BIM meshes into manifold form (fuse coplanar
faces, split at T-junctions) — a significant project unto itself. `simplifySloppy` 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.
##### Tuning knobs (env vars) ##### Tuning knobs (env vars)
| Var | Default | Effect | | Var | Default | Effect |
|-----|---------|--------| |-----|---------|--------|
| `IFC_LOD1_PX` | `30` | Projected sphere radius (px) below which LOD1 kicks in. `0` disables LOD1 entirely. | | `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_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_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_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. | | `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 ##### Measured results