From 930678e3d26a65aa0c8bb29307f0772c3d6e9276 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 18 Apr 2026 20:46:30 +1000 Subject: [PATCH] ifcviewer: motion-adaptive contribution culling + sub-draw diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During camera motion, use a larger pixel-radius threshold (IFC_MIN_PX_MOTION) to aggressively cull small objects, dramatically reducing sub_draws and improving orbit fps (e.g. 29→67 fps on 1M-instance scene). When the camera stops, automatically re-cull at the base threshold to restore full detail. Key behaviors: - IFC_MIN_PX_MOTION=N sets the motion threshold (0 = disabled) - Settle recull fires on the first still frame after motion - HiZ pyramid invalidated on settle (stale from sparse motion frame) - GPU cull results skipped on settle (dispatched at motion threshold) - requestUpdate() ensures the settle frame actually runs Also adds IFC_SUBDRAW_DIAG=1 diagnostic for sub-draw composition analysis and documents Phase 3E/3F experiment results in README. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/README.md | 235 +++++++++++++++++++++++++++++-- src/ifcviewer/ViewportWindow.cpp | 214 ++++++++++++++++++++++++++-- src/ifcviewer/ViewportWindow.h | 5 + 3 files changed, 434 insertions(+), 20 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 77ffefc40e..81b128bdf8 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -740,17 +740,226 @@ The stats line now reports `cull[wall X | work: clr Y trv Z emt W upl U]`: where CPU cycles went. `IFC_CULL_THREADS=0` forces single-threaded mode for comparison. -#### 3E. GPU-side culling via compute (longer-term) +#### 3E. GPU compute culling — experiments, results, and current state -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. Three compute dispatches per model: (1) -count survivors per `(mesh, winding, LOD)` bucket, (2) prefix-sum the -counts into `baseInstance` offsets and write the indirect command buffer, -(3) re-test and compact survivors into the dense visible list. HiZ moves -to a GPU depth texture sampled directly in the shader, eliminating the -Phase 3C readback. Lets culling scale to millions of instances and -single-model scenes where Phase 3D can't parallelise. +##### What we tried + +**Attempt 1: Full GPU-driven rendering (reverted).** Five commits +(`4fe32b54`..`d5b7b87b`) moved the entire cull-to-draw pipeline onto +the GPU: a compute shader performed frustum + contribution + HiZ +culling, selected LOD0/LOD1, handled fwd/rev winding bucketing, wrote +indirect draw commands via `glMultiDrawElementsIndirectCount`, and +drove rendering without CPU readback. This was architecturally clean +but complex — the GPU built per-model indirect command buffers with +atomic counters, prefix sums, and per-bucket compaction. It worked +correctly but introduced code smells (extension loaders for +`glMultiDrawElementsIndirectCount` not exposed by Qt6's +`QOpenGLFunctions_4_5_Core`, ad-hoc GPU readbacks for validation). +All five commits were reverted as a single block to keep the codebase +clean while preserving the AABB SSBO upload (`b2044737`) and the +frustum-only validation shader (`b17860fc`). + +**Attempt 2: GPU frustum-only validation shader.** A minimal compute +shader (64 threads/workgroup) testing each instance's AABB against 6 +frustum planes. Used as a measurement baseline — no contribution, +HiZ, LOD, or winding. Results on a 1.06 M-instance / 111-model scene +(GTX 1650): + +| Metric | GPU frustum-only | CPU BVH (parallel) | +|--------|------------------|--------------------| +| Cull time | **0.82 ms** (GPU timestamp) | 9.6–15.2 ms wall | +| Survivors | 279 k (frustum only) | 130 k (frustum + contribution + HiZ) | + +The GPU brute-force scan of 1.06 M instances in 0.82 ms was 12–18× +faster than the CPU BVH walk despite testing every instance. + +**Attempt 3: Hybrid GPU cull with synchronous readback.** Added +contribution culling to the GPU shader (bounding-sphere screen-space +radius test), then read back the compact survivor list to the CPU with +`glGetNamedBufferSubData`. CPU retains HiZ, LOD selection, winding +bucketing, indirect command building, and all GL draw calls. + +| Phase | Time | +|-------|------| +| GPU dispatch (frustum + contribution) | 0.92 ms | +| Synchronous readback (`glGetNamedBufferSubData`) | **4.2–7.4 ms** | +| CPU consume (HiZ + LOD + winding + emit) | 6.4–9.8 ms | +| **Total wall** | **~15 ms** | + +The synchronous readback pipeline-stalled the GPU, adding 4–7 ms of +idle wait. Total wall time was roughly equal to the CPU-only path, +negating the GPU cull's speed advantage. + +**Attempt 4: Async one-frame-late readback (committed, `30e43ffe`).** +Replaced synchronous readback with a persistent-mapped buffer +(`GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT`) and a `glFenceSync` / +`glClientWaitSync` fence. The GPU writes survivors this frame; the +CPU reads them next frame. One frame of latency, but zero stalls. + +| Phase | Time | +|-------|------| +| GPU dispatch | 0.69–0.78 ms | +| Async readback (fence poll) | **0.00 ms** | +| CPU consume | 5.0–6.2 ms | +| **Total wall** | **~5.5 ms** | + +vs the CPU-only path at 5.2–6.4 ms wall on the same scene. The GPU +cull + async readback matches or slightly beats the parallel CPU BVH +path, with headroom for scenes where the CPU path can't parallelise +(single large model). + +**Attempt 5: Dirty-mesh tracking (committed, `01dd8d57`).** Profiling +the CPU consume phase revealed that `clr` (clearing per-mesh visibility +buckets) and `emit` (building indirect commands) were O(total_meshes) += O(462 k), not O(survivors). Added a dirty-mesh list so only mesh +buckets that received survivors are cleared and iterated. + +Consume sub-phase breakdown (summed across parallel threads, +~128 k survivors): + +| Sub-phase | Before | After | Scales with | +|-----------|--------|-------|-------------| +| bin (model binning) | 0.11 ms | 0.18 ms | O(survivors) | +| clr (bucket clear) | 2.0 ms | **1.6 ms** | O(dirty meshes) | +| class (HiZ + LOD + winding) | 5.1 ms | 5.3 ms | O(survivors) | +| emit (indirect cmd build) | 4.2 ms | **2.2 ms** | O(dirty meshes) | + +Emit improved ~48%, clr ~20%. The dominant cost shifted to `class` +(per-survivor HiZ + LOD + winding classification). + +##### What we learned + +1. **GPU brute-force beats CPU BVH for frustum + contribution.** + 0.82 ms for 1.06 M instances vs 10–15 ms for the CPU BVH walk. + The BVH's hierarchical skip advantage is overwhelmed by the GPU's + raw parallelism — 1 M independent AABB-vs-frustum tests is a + perfect compute workload. + +2. **Synchronous readback kills the advantage.** The 4–7 ms stall from + `glGetNamedBufferSubData` on ~1 MB of data negated all GPU savings. + A pipeline stall is worse than just doing the work on the CPU. + +3. **Async one-frame-late readback works well.** Persistent mapping + + fence polling adds zero measurable overhead. The one-frame latency + is imperceptible for culling — worst case, a few objects at the + frustum edge pop in one frame late during fast camera motion. + +4. **CPU consume is now the bottleneck.** With GPU dispatch at <1 ms + and readback at 0 ms, the 5–6 ms consume phase (HiZ test, LOD + selection, winding classification, indirect command building) + dominates. The `class` sub-phase alone is 5+ ms, scaling linearly + with survivor count. + +5. **Dirty-mesh tracking helps but doesn't transform performance.** + The 462 k total meshes → ~104 k active meshes reduction cut emit + in half, but the per-survivor classification work is the true + bottleneck. + +##### What remains + +The hybrid path (`IFC_GPU_CULL=1`) is functional and committed. It +matches the CPU path's performance today and provides the foundation +for further GPU offload. Remaining opportunities: + +- Move HiZ + LOD + winding classification to the GPU (eliminates the + 5 ms `class` sub-phase entirely — the GPU already has the AABBs and + can sample the HiZ pyramid directly). +- GPU BVH traversal to reduce dispatch from O(total) to O(visible + + tree overhead) — matters when survivor ratio is low. +- GPU-driven indirect command building (eliminates CPU emit entirely). + +Each of these would chip away at the consume phase, but the sub_draw +analysis below reveals a more fundamental bottleneck. + +#### 3F. Sub-draw fragmentation analysis + +##### The problem + +With GPU cull solving the *culling* bottleneck, the dominant cost +shifts to the *drawing* side. On the 1.06 M-instance / 111-model +scene, frame times are 48–63 ms despite only 24–47 M visible +triangles — well within the GTX 1650's throughput. The culprit is +the number of indirect sub-draws (individual `DrawElementsIndirectCommand` +entries inside each `glMultiDrawElementsIndirect` call). + +##### Measurement + +Diagnostic instrumentation (`IFC_SUBDRAW_DIAG=1`) revealed: + +**Mixed scene (111 models, 1.06 M instances):** + +| instanceCount | sub_draws | % of total | instances | triangles | +|---------------|-----------|------------|-----------|-----------| +| 1 | 114,624 | **95.7%** | 114,624 | 16.9 M | +| 2 | 2,269 | 1.9% | 4,538 | 1.3 M | +| 3–4 | 1,127 | 0.9% | 3,873 | 1.6 M | +| 5–8 | 1,106 | 0.9% | 6,407 | 1.9 M | +| 9–16 | 376 | 0.3% | 4,315 | 0.8 M | +| 17–64 | 264 | 0.2% | 7,766 | 8.0 M | +| 65–256 | 29 | <0.1% | 3,331 | 2.0 M | +| 257+ | 8 | <0.1% | 9,732 | 0.4 M | + +**Steel-only scene (18 models, 570 k instances):** + +| instanceCount | sub_draws | % of total | instances | triangles | +|---------------|-----------|------------|-----------|-----------| +| 1 | 68,616 | **85.9%** | 68,616 | 12.5 M | +| 2 | 5,385 | 6.7% | 10,770 | 2.7 M | +| 3–4 | 2,581 | 3.2% | 9,100 | 1.3 M | +| 5+ | 3,324 | 4.2% | 66,407 | 7.0 M | + +##### Consolidation potential + +The mesh-level consolidation analysis found: + +- **119,803 unique visible mesh IDs = 119,803 sub_draws** (perfect 1:1) +- **0 meshes split by winding or LOD buckets** — no mesh_id appears in + more than one (fwd/rev × lod0/lod1) bucket +- **0% reduction** available from merging across winding/LOD +- **114,624 meshes (95.7%)** are genuinely unique geometry placed + exactly once — instancing provides zero benefit for these + +This is a fundamental property of the IFC data, not a pipeline +inefficiency. BIM models contain thousands of unique parametric +shapes (custom brackets, unique beam profiles, one-off fittings) each +placed at a single location. Only a minority of elements (standard +doors, windows, pipe fittings) share geometry across placements. + +##### Conclusions + +1. **Instancing is maxed out.** The pipeline already groups all + instances of each mesh into a single sub_draw. With 96% of meshes + having exactly one visible instance, there is nothing more to + group. + +2. **Per-draw overhead dominates frame time.** 95–120 k sub_draws at + ~20 fps = 48–50 ms/frame, but only 24–33 M triangles. A GTX 1650 + can shade 1+ billion triangles/sec; the GPU is starving on + per-command overhead (command fetch, baseInstance lookup, draw + setup), not vertex/fragment throughput. + +3. **The path forward is static batching.** Merge the vertex and + index data of multiple distinct single-instance meshes into + combined VBO/EBO ranges, each issued as one sub_draw. Batches of + 256–1024 spatially-coherent meshes would collapse 91–115 k + sub_draws into 100–450, a 200–1000× reduction. + +4. **Trade-offs of static batching:** + - Culling granularity degrades from per-mesh to per-batch. Batches + must be spatially coherent (e.g., BVH subtree leaves) or invisible + geometry gets drawn. + - Per-instance attributes (object_id, colour_override) must move + into the vertex stream or a per-vertex SSBO lookup, since + instancing no longer applies to merged meshes. + - The VBO/EBO layout changes at finalize time; existing instancing + stays for multi-instance meshes (the 4% that benefit from it). + - The sidecar format needs a version bump to cache batch membership. + +5. **The steel scene validates the hypothesis.** It has better + instancing reuse (86% single-instance vs 96%) and correspondingly + better fps (49 vs 20). The ~2.5× fps ratio tracks the sub_draw + ratio (~80 k vs ~120 k), confirming per-draw overhead as the + dominant cost. ### Planned follow-ups (post-Phase-3) @@ -769,7 +978,8 @@ Scene size Bottleneck Fix + Phase 3B LOD (done) multi-million + occluders redundant rasterisation Phase 3C HiZ (done, CPU readback) many models, serial cull single-thread BVH trv Phase 3D parallel cull (done) -single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (planned) +single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (hybrid, done) +90k+ unique visible meshes per-draw GPU overhead Phase 3F static batching (next) ``` ## Roadmap @@ -793,6 +1003,7 @@ single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (plann - [x] Phase 3D — Parallel per-model CPU cull (`std::async` fan-out) - [x] Quantized VBO (16 B/vert, sidecar v6) - [x] Event-driven rendering (zero idle CPU/GPU, cull skipped on still frames) -- [ ] **Phase 3E — GPU-side compute-shader culling** (next; replaces the HiZ readback) +- [x] Phase 3E — GPU compute-shader culling (hybrid: GPU frustum+contribution, async readback, CPU HiZ+LOD+emit) +- [ ] **Phase 3F — Static batching of single-instance meshes** (next; reduces 90k+ sub_draws to hundreds) - [ ] Vulkan/MoltenVK backend for macOS - [ ] Embedded Python scripting console diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index a8beb1704f..e67a378fd3 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1262,6 +1262,11 @@ void ViewportWindow::buildHizPyramid() { hiz_depth_tex_, 0); gl_->glNamedFramebufferDrawBuffer(hiz_fbo_, GL_NONE); gl_->glNamedFramebufferReadBuffer(hiz_fbo_, GL_NONE); + { + GLenum s = gl_->glCheckNamedFramebufferStatus(hiz_fbo_, GL_FRAMEBUFFER); + if (s != GL_FRAMEBUFFER_COMPLETE) + qWarning("HiZ FBO incomplete: 0x%04x", s); + } hiz_base_w_ = base_w; hiz_base_h_ = base_h; @@ -1285,8 +1290,10 @@ void ViewportWindow::buildHizPyramid() { hiz_pyramid_.assign(off, 1.0f); } - // Step 1: MSAA default-fb → full-size single-sample resolve (same-size). + // Drain stale GL errors before HiZ pipeline. while (gl_->glGetError() != GL_NO_ERROR) {} + + // Step 1: MSAA default-fb → full-size SS resolve (same-size blit). gl_->glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); gl_->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, hiz_resolve_fbo_); gl_->glBlitFramebuffer(0, 0, win_w, win_h, @@ -1327,6 +1334,28 @@ void ViewportWindow::buildHizPyramid() { static_cast(hiz_depth_readback_.size() * sizeof(float)), hiz_depth_readback_.data()); + { + static int diag = 5; + static int skip = 60; + if (skip > 0) { --skip; } + else if (diag > 0) { + --diag; + float mn = 1.0f, mx = 0.0f; + int zeros = 0, ones = 0; + for (size_t i = 0; i < hiz_depth_readback_.size(); ++i) { + float v = hiz_depth_readback_[i]; + if (v < mn) mn = v; + if (v > mx) mx = v; + if (v == 0.0f) ++zeros; + if (v == 1.0f) ++ones; + } + int geom = (int)hiz_depth_readback_.size() - zeros - ones; + qWarning("HiZ readback %dx%d: min=%.6f max=%.6f zeros=%d ones=%d geom=%d total=%d", + hiz_base_w_, hiz_base_h_, mn, mx, zeros, ones, geom, + (int)hiz_depth_readback_.size()); + } + } + // Copy level 0 into the pyramid, then max-reduce subsequent levels. std::memcpy(hiz_pyramid_.data() + hiz_mip_offset_[0], hiz_depth_readback_.data(), @@ -1920,14 +1949,15 @@ void ViewportWindow::render() { // culling below. const float focal_px = 0.5f * static_cast(h) / std::tan(qDegreesToRadians(0.5f * camera_fov_y_deg_)); - // Drop frustum-visible objects smaller than this many pixels. Override - // with IFC_MIN_PX (0 = disabled). 2 px radius = ~4x4 pixels, well below - // what's meaningful at normal viewing distances and eliminates the long - // tail of distant MEP/fixings that dominate BIM triangle counts. - static const float min_pixel_radius = []{ + static const float base_min_pixel_radius = []{ const char* e = std::getenv("IFC_MIN_PX"); return (e && *e) ? static_cast(std::atof(e)) : 2.0f; }(); + static const float motion_min_pixel_radius = []{ + const char* e = std::getenv("IFC_MIN_PX_MOTION"); + return (e && *e) ? static_cast(std::atof(e)) + : 0.0f; // 0 = disabled (no motion boost) + }(); gl_->glUseProgram(main_program_); GLint u_vp = gl_->glGetUniformLocation(main_program_, "u_view_projection"); @@ -1952,9 +1982,25 @@ void ViewportWindow::render() { const bool camera_unchanged = have_cached_cull_ && last_cull_view_ == view_matrix_ && last_cull_proj_ == proj_matrix_; - const bool cull_this_frame = !camera_unchanged; + const bool camera_moving = !camera_unchanged; + // Force a re-cull on the first still frame after motion so we + // restore the base (tighter) contribution threshold. + const bool needs_settle_recull = !camera_moving + && last_cull_was_motion_ + && motion_min_pixel_radius > base_min_pixel_radius; + const bool cull_this_frame = camera_moving || needs_settle_recull; + // Invalidate HiZ on the settle frame: the pyramid was built from the + // motion frame's sparse depth (aggressive threshold hid objects whose + // depth would normally populate the pyramid), causing false occlusion. + if (needs_settle_recull) + hiz_vp_valid_ = false; + const bool use_motion_threshold = camera_moving + && motion_min_pixel_radius > base_min_pixel_radius; + const float min_pixel_radius = use_motion_threshold + ? motion_min_pixel_radius : base_min_pixel_radius; if (cull_this_frame) { hiz_reject_count_.store(0, std::memory_order_relaxed); + last_cull_was_motion_ = use_motion_threshold; } else { ++cull_skipped_frames_; } @@ -1983,8 +2029,11 @@ void ViewportWindow::render() { } // --- Try to consume last frame's GPU cull results (one-frame-late) --- + // Skip GPU consume on the settle re-cull: the pending results were + // dispatched at the motion threshold and would be too aggressively + // culled. Fall through to CPU which culls at the base threshold. bool gpu_consumed = false; - if (gpu_cull_enabled && gpu_cull_fence_) { + if (gpu_cull_enabled && gpu_cull_fence_ && !needs_settle_recull) { GLenum sync_status = gl_->glClientWaitSync( gpu_cull_fence_, 0, 0); if (sync_status == GL_ALREADY_SIGNALED || @@ -2273,6 +2322,10 @@ void ViewportWindow::render() { last_cull_proj_ = proj_matrix_; have_cached_cull_ = true; } + if (needs_settle_recull) + qDebug("[motion-cull] settle result: obj=%u sub_draws=%u hiz_rej=%u", + visible_objects_, indirect_sub_draws_, + hiz_reject_count_.load()); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0); renderAxisGizmo(); @@ -2288,6 +2341,12 @@ void ViewportWindow::render() { context_->swapBuffers(this); + // Ensure one more frame runs after the last motion frame so the + // settle recull can detect the camera has stopped and restore the + // base contribution threshold. + if (last_cull_was_motion_) + requestUpdate(); + // Measure frame *cost* (time spent inside render()) rather than the // wall-clock gap between frames. With event-driven rendering, idle gaps // between requestUpdate() calls would otherwise pollute the FPS window. @@ -2378,6 +2437,145 @@ void ViewportWindow::render() { total_ebo / (1024.0*1024.0), total_ssbo / (1024.0*1024.0), num_models, num_hidden); + + // One-shot sub_draw composition diagnostic. + static const bool subdraw_diag = std::getenv("IFC_SUBDRAW_DIAG") != nullptr; + if (subdraw_diag) { + uint32_t total_subdraws = 0; + uint32_t hist[8] = {}; + uint32_t instances_in_bucket[8] = {}; + uint32_t tris_in_bucket[8] = {}; + + struct ModelStats { + uint32_t model_id; + uint32_t subdraws; + uint32_t single_instance; + uint32_t total_meshes; + uint32_t total_instances; + }; + std::vector per_model; + + auto bucket_idx = [](uint32_t ic) -> int { + if (ic <= 1) return 0; + if (ic <= 2) return 1; + if (ic <= 4) return 2; + if (ic <= 8) return 3; + if (ic <= 16) return 4; + if (ic <= 64) return 5; + if (ic <= 256) return 6; + return 7; + }; + + // --- Mesh-level consolidation analysis --- + // Per mesh_id, count visible instances across all 4 buckets. + // Also count how many buckets each mesh_id appears in. + uint32_t unique_visible_meshes = 0; + uint32_t meshes_truly_single = 0; // 1 instance total, 1 bucket + uint32_t meshes_split_by_state = 0; // >1 bucket but each has 1 instance + uint32_t subdraws_if_merged_buckets = 0; // sub_draws if winding+LOD ignored + uint32_t mesh_vis_hist[8] = {}; // histogram of per-mesh visible instance counts + + for (const auto& [mid, mm] : models_gpu_) { + if (mm.hidden) continue; + ModelStats ms{mid, mm.indirect_command_count, 0, + static_cast(mm.meshes.size()), + static_cast(mm.instances.size())}; + for (const auto& cmd : mm.indirect_scratch) { + int b = bucket_idx(cmd.instanceCount); + hist[b]++; + instances_in_bucket[b] += cmd.instanceCount; + tris_in_bucket[b] += (cmd.count / 3) * cmd.instanceCount; + if (cmd.instanceCount == 1) ms.single_instance++; + total_subdraws++; + } + per_model.push_back(ms); + + const size_t nm = mm.meshes.size(); + for (size_t mi = 0; mi < nm; ++mi) { + uint32_t total_vis = 0; + uint32_t buckets_present = 0; + auto count_bucket = [&](const std::vector>& v) { + if (mi < v.size() && !v[mi].empty()) { + total_vis += static_cast(v[mi].size()); + buckets_present++; + } + }; + count_bucket(mm.vis_fwd_lod0); + count_bucket(mm.vis_fwd_lod1); + count_bucket(mm.vis_rev_lod0); + count_bucket(mm.vis_rev_lod1); + if (total_vis == 0) continue; + + unique_visible_meshes++; + mesh_vis_hist[bucket_idx(total_vis)]++; + if (total_vis > 0) subdraws_if_merged_buckets++; + + if (total_vis == 1 && buckets_present == 1) + meshes_truly_single++; + else if (buckets_present > 1) { + bool all_single = true; + auto check = [&](const std::vector>& v) { + if (mi < v.size() && v[mi].size() > 1) all_single = false; + }; + check(mm.vis_fwd_lod0); check(mm.vis_fwd_lod1); + check(mm.vis_rev_lod0); check(mm.vis_rev_lod1); + if (all_single) meshes_split_by_state++; + } + } + } + + qDebug("\n=== SUB_DRAW COMPOSITION (this frame) ==="); + qDebug("Total sub_draws: %u", total_subdraws); + const char* labels[] = {" 1", " 2", " 3-4", " 5-8", + " 9-16", "17-64", "65-256", " 257+"}; + qDebug("instanceCount histogram:"); + qDebug(" range | sub_draws | instances | triangles"); + for (int i = 0; i < 8; ++i) { + if (hist[i] == 0) continue; + qDebug(" %s | %7u | %9u | %10u", + labels[i], hist[i], instances_in_bucket[i], tris_in_bucket[i]); + } + + uint32_t single = hist[0], small = hist[0] + hist[1] + hist[2]; + qDebug("Single-instance sub_draws: %u (%.1f%%)", + single, total_subdraws ? 100.0 * single / total_subdraws : 0.0); + qDebug("Small (<=4) sub_draws: %u (%.1f%%)", + small, total_subdraws ? 100.0 * small / total_subdraws : 0.0); + + qDebug("\n--- MESH-LEVEL CONSOLIDATION ---"); + qDebug("Unique visible mesh IDs: %u", unique_visible_meshes); + qDebug("Visible instance count per mesh_id:"); + qDebug(" range | mesh_ids"); + for (int i = 0; i < 8; ++i) { + if (mesh_vis_hist[i] == 0) continue; + qDebug(" %s | %7u", labels[i], mesh_vis_hist[i]); + } + + qDebug("\nAmong single-instance sub_draws (%u):", single); + qDebug(" Truly unique (1 inst, 1 bucket): %u", meshes_truly_single); + qDebug(" Split by state (>1 bucket, each =1): %u (saves %u sub_draws if merged)", + meshes_split_by_state, meshes_split_by_state); + + qDebug("\nEstimated sub_draws by grouping strategy:"); + qDebug(" Current (mesh_id x winding x LOD): %u", total_subdraws); + qDebug(" Merged buckets (mesh_id only): %u (%.0f%% reduction)", + subdraws_if_merged_buckets, + total_subdraws ? 100.0 * (1.0 - (double)subdraws_if_merged_buckets / total_subdraws) : 0.0); + + std::sort(per_model.begin(), per_model.end(), + [](const ModelStats& a, const ModelStats& b) { + return a.subdraws > b.subdraws; + }); + qDebug("\nTop 15 models by sub_draw count:"); + qDebug(" model_id | sub_draws | single_inst | meshes | instances"); + for (size_t i = 0; i < std::min(15, per_model.size()); ++i) { + const auto& ms = per_model[i]; + qDebug(" %7u | %7u | %7u | %7u | %7u", + ms.model_id, ms.subdraws, ms.single_instance, + ms.total_meshes, ms.total_instances); + } + qDebug("=== END SUB_DRAW COMPOSITION ===\n"); + } } } diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 3a0b7e4049..a442802176 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -376,6 +376,11 @@ private: QMatrix4x4 last_cull_proj_; bool have_cached_cull_ = false; + // Motion-adaptive contribution culling. During camera motion, use a + // larger pixel-radius threshold to aggressively cull small objects. + // When the camera stops, re-cull once at the base threshold. + bool last_cull_was_motion_ = false; + // Per-frame stats uint32_t visible_triangles_ = 0; uint32_t visible_objects_ = 0;