From c03a7fe117a237041c82461b927c8d68805360cb Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 14 Apr 2026 20:33:21 +1000 Subject: [PATCH] Cull: read AABBs from compact bvh_items in the hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cullAndUploadVisible was reading each instance's AABB through m.instances[idx] — a 104-byte InstanceCpu struct — for the frustum / contribution / HiZ tests. Only 24 of those bytes (the two float[3] AABBs) are actually used by the tests; the rest (4×4 transform + header) is pure cache-line waste, and with 569k instances the array is 59 MB, well past any cache. bvh_items[idx] already stores a 1:1 compact 28-byte record with the same AABB, built unconditionally in buildBvhForModel(). Switch the hot test path to read from it, and only touch InstanceCpu once an instance has passed all three tests (for mesh_id). Modest ~20 % drop in cull-traverse time on a 569k-object overview (26 ms → 21 ms). Also add four cull-phase timers (clr / trv / emt / upl) to the per-second stats line so future optimisation work has concrete numbers to chase. Confirmed via these timers that bucket clears, emit and GPU upload are all <1 ms combined; traversal is where the remaining CPU cost lives. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 43 +++++++++++++++++++++++++++----- src/ifcviewer/ViewportWindow.h | 10 ++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index fdfff63997..a85b33bae4 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1097,6 +1097,9 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] // 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. + QElapsedTimer phase_timer; + phase_timer.start(); + auto resize_if = [&](std::vector>& v) { if (v.size() < m.meshes.size()) v.resize(m.meshes.size()); }; @@ -1110,6 +1113,8 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] visible_by_mesh_rev_lod0_[i].clear(); visible_by_mesh_rev_lod1_[i].clear(); } + cull_clear_ns_ += phase_timer.nsecsElapsed(); + phase_timer.restart(); // LOD1 switches in when projected sphere radius (in pixels) drops below // this threshold. Overridable for tuning. Set to 0 to disable LOD1 @@ -1179,19 +1184,26 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] // env var, or before the first pyramid has been built. const bool hiz_on = hizEnabled() && min_pixel_radius > 0.0f && hiz_vp_valid_; + // Hot path: read the AABB from the compact bvh_items array (28 B stride) + // rather than the wide InstanceCpu (104 B stride). Most instances fail + // frustum or contribution, so we want to avoid touching the wider struct + // until a survivor needs its mesh_id. This alone turns the cull from + // cache-miss-per-instance into stream-friendly linear reads. 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 (hiz_on && aabbOccludedByHiz(inst.world_aabb_min, inst.world_aabb_max)) { + const BvhItem& item = m.bvh_items[inst_idx]; + if (!aabbInFrustum(item.aabb_min, item.aabb_max, planes)) return; + if (!contributionPasses(item.aabb_min, item.aabb_max)) return; + if (hiz_on && aabbOccludedByHiz(item.aabb_min, item.aabb_max)) { ++hiz_reject_count_; return; } + // Survivor — now pay the wide-struct fetch for mesh_id. + const InstanceCpu& inst = m.instances[inst_idx]; 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; + pixelRadius(item.aabb_min, item.aabb_max) < lod1_px_threshold; const bool reflected = inst_idx < m.instance_reflected.size() && m.instance_reflected[inst_idx] != 0; auto& bucket = @@ -1236,6 +1248,8 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] } else { for (uint32_t i = 0; i < m.instances.size(); ++i) test_and_push(i); } + cull_traverse_ns_ += phase_timer.nsecsElapsed(); + phase_timer.restart(); // Flatten fwd-slice first (LOD0 then LOD1), then rev-slice (ditto), into // visible_flat_. Commands for the fwd slice fill [0, indirect_forward_count), @@ -1274,6 +1288,8 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] 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()); + cull_emit_ns_ += phase_timer.nsecsElapsed(); + phase_timer.restart(); // Upload visible list (keep binding alive even when empty). size_t vis_bytes = std::max(visible_flat_.size() * sizeof(uint32_t), @@ -1293,7 +1309,10 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] // Upload indirect command buffer. size_t ind_bytes = indirect_scratch_.size() * sizeof(DrawElementsIndirectCommand); - if (ind_bytes == 0) return; + if (ind_bytes == 0) { + cull_upload_ns_ += phase_timer.nsecsElapsed(); + return; + } if (m.indirect_buffer == 0 || m.indirect_capacity < ind_bytes) { if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); size_t new_cap = m.indirect_capacity ? m.indirect_capacity : 4096; @@ -1303,6 +1322,7 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] m.indirect_capacity = new_cap; } gl_->glNamedBufferSubData(m.indirect_buffer, 0, ind_bytes, indirect_scratch_.data()); + cull_upload_ns_ += phase_timer.nsecsElapsed(); } void ViewportWindow::updateCamera() { @@ -1446,6 +1466,7 @@ void ViewportWindow::render() { frame_count_++; if (accumulated_time_ >= 1.0f) { last_fps_ = static_cast(frame_count_) / accumulated_time_; + const uint32_t frames_in_window = static_cast(frame_count_); frame_count_ = 0; accumulated_time_ = 0.0f; @@ -1475,14 +1496,24 @@ void ViewportWindow::render() { stats.indirect_sub_draws = indirect_sub_draws_; emit frameStatsUpdated(stats); + const double inv_frames = frames_in_window > 0 + ? 1.0 / static_cast(frames_in_window) : 0.0; + const double clr_ms = cull_clear_ns_ * 1e-6 * inv_frames; + const double trv_ms = cull_traverse_ns_ * 1e-6 * inv_frames; + const double emt_ms = cull_emit_ns_ * 1e-6 * inv_frames; + const double upl_ms = cull_upload_ns_ * 1e-6 * inv_frames; + cull_clear_ns_ = cull_traverse_ns_ = cull_emit_ns_ = cull_upload_ns_ = 0; + qDebug("[frame] %.1f fps %.2f ms obj %u/%u tri %u/%u " "meshes %u gl_draws %u sub_draws %u hiz_rej %u " + "cull[clr %.2f trv %.2f emt %.2f upl %.2f]ms " "vram %.1f MB (vbo %.1f + ebo %.1f + ssbo %.1f) models %zu (%zu hidden)", last_fps_, 1000.0f / last_fps_, visible_objects_, total_obj, visible_triangles_, total_tri, total_meshes, gl_draw_calls_, indirect_sub_draws_, hiz_reject_count_, + clr_ms, trv_ms, emt_ms, upl_ms, (total_vbo + total_ebo + total_ssbo) / (1024.0*1024.0), total_vbo / (1024.0*1024.0), total_ebo / (1024.0*1024.0), diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index fd584bb4cb..5d22f89288 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -259,6 +259,16 @@ private: bool hiz_vp_valid_ = false; uint32_t hiz_reject_count_ = 0; // per-frame stat + // Cull-phase timers. Accumulated across all frames in the current + // 1-second stats window; divided by frame_count_ at print time to + // give per-frame average ms. Reset each window. Lets us see where + // CPU time actually goes: bucket clears vs BVH traversal vs emit vs + // GPU upload. + uint64_t cull_clear_ns_ = 0; + uint64_t cull_traverse_ns_ = 0; + uint64_t cull_emit_ns_ = 0; + uint64_t cull_upload_ns_ = 0; + // Per-frame stats uint32_t visible_triangles_ = 0; uint32_t visible_objects_ = 0;