ifcviewer: motion-adaptive contribution culling + sub-draw diagnostics

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 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-04-18 20:46:30 +10:00
parent 4e3cc63de1
commit 930678e3d2
3 changed files with 434 additions and 20 deletions
+223 -12
View File
@@ -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.615.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 1218×
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.27.4 ms** |
| CPU consume (HiZ + LOD + winding + emit) | 6.49.8 ms |
| **Total wall** | **~15 ms** |
The synchronous readback pipeline-stalled the GPU, adding 47 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.690.78 ms |
| Async readback (fence poll) | **0.00 ms** |
| CPU consume | 5.06.2 ms |
| **Total wall** | **~5.5 ms** |
vs the CPU-only path at 5.26.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 1015 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 47 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 56 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 4863 ms despite only 2447 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 |
| 34 | 1,127 | 0.9% | 3,873 | 1.6 M |
| 58 | 1,106 | 0.9% | 6,407 | 1.9 M |
| 916 | 376 | 0.3% | 4,315 | 0.8 M |
| 1764 | 264 | 0.2% | 7,766 | 8.0 M |
| 65256 | 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 |
| 34 | 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.** 95120 k sub_draws at
~20 fps = 4850 ms/frame, but only 2433 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
2561024 spatially-coherent meshes would collapse 91115 k
sub_draws into 100450, a 2001000× 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
+206 -8
View File
@@ -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<GLsizei>(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<float>(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<float>(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<float>(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<ModelStats> 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<uint32_t>(mm.meshes.size()),
static_cast<uint32_t>(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<std::vector<uint32_t>>& v) {
if (mi < v.size() && !v[mi].empty()) {
total_vis += static_cast<uint32_t>(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<std::vector<uint32_t>>& 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<size_t>(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");
}
}
}
+5
View File
@@ -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;