Leaf-batched BVH draw commands

When a BVH leaf passes the frustum test, emit a single glMultiDrawElements
record covering the leaf's entire index range instead of one per object.
Leaves are contiguous in the EBO after reorderEbo, so the range is just
[first_object.index_offset, sum(index_count)]. Cuts draw calls by ~8x
(BVH_MAX_LEAF_SIZE) and shifts the bottleneck from CPU/driver per-draw
overhead toward GPU vertex throughput.

Per-object features (selection highlight, per-vertex color, object_id
picking) are unchanged — they operate on vertex attributes, not draw
state. Future per-object hide/override will use SSBO lookups sampled
by object_id in the fragment shader.

Slight overdraw from skipping per-object frustum tests within a leaf is
negligible given median-split BVH tightness and spare tri throughput.

Also adds visible_objects_ counter so stats still report true object
counts (not leaf counts), plus leaf_draws/model_draws breakdown in the
per-second frame log.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-04-12 19:17:44 +10:00
parent 36fa53122a
commit 8c8ef5c32b
2 changed files with 28 additions and 13 deletions
+27 -13
View File
@@ -838,16 +838,25 @@ void ViewportWindow::traverseBvh(const ModelBvh& mbvh, const ModelGpuData& mgpu,
continue;
if (node.count > 0) {
// Leaf-batched draw: after reorderEbo, a leaf's objects occupy a
// contiguous EBO range. Emit one draw command covering all of them
// instead of N per-object tests/draws. The leaf AABB test above is
// already a conservative cull; any overdraw (up to BVH_MAX_LEAF_SIZE
// objects that may be fully outside the frustum but inside the leaf
// AABB) costs far less than the per-draw CPU/driver overhead we save.
uint32_t first_oi = mbvh.object_indices[node.right_or_first];
const auto& first_obj = mgpu.draw_info[first_oi];
uint32_t leaf_offset = first_obj.index_offset;
uint32_t leaf_count = 0;
for (uint32_t i = 0; i < node.count; ++i) {
uint32_t oi = mbvh.object_indices[node.right_or_first + i];
const auto& obj = mgpu.draw_info[oi];
if (aabbInFrustum(obj.aabb_min, obj.aabb_max, planes)) {
cmd.counts.push_back(static_cast<GLsizei>(obj.index_count));
cmd.offsets.push_back(reinterpret_cast<const void*>(
static_cast<uintptr_t>(obj.index_offset)));
visible_triangles_ += obj.index_count / 3;
}
leaf_count += mgpu.draw_info[oi].index_count;
}
cmd.counts.push_back(static_cast<GLsizei>(leaf_count));
cmd.offsets.push_back(reinterpret_cast<const void*>(
static_cast<uintptr_t>(leaf_offset)));
visible_triangles_ += leaf_count / 3;
visible_objects_ += node.count;
} else {
if (sp < 63) {
stack[sp++] = node.right_or_first;
@@ -860,6 +869,7 @@ void ViewportWindow::traverseBvh(const ModelBvh& mbvh, const ModelGpuData& mgpu,
void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) {
frame_draw_cmds_.clear();
visible_triangles_ = 0;
visible_objects_ = 0;
// Extract 6 frustum planes from the view-projection matrix.
float planes[6][4];
@@ -912,6 +922,7 @@ void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) {
cmd.offsets.push_back(reinterpret_cast<const void*>(
static_cast<uintptr_t>(obj.index_offset)));
visible_triangles_ += obj.index_count / 3;
visible_objects_++;
}
}
}
@@ -964,9 +975,10 @@ void ViewportWindow::render() {
frame_count_ = 0;
accumulated_time_ = 0.0f;
uint32_t total_obj = 0, total_tri = 0, vis_obj = 0;
uint32_t total_obj = 0, total_tri = 0;
size_t total_vram = 0, total_vbo = 0, total_ebo = 0;
size_t num_models = 0, num_hidden = 0;
size_t total_leaf_draws = 0;
for (const auto& [mid, m] : models_gpu_) {
num_models++;
if (m.hidden) { num_hidden++; continue; }
@@ -977,29 +989,31 @@ void ViewportWindow::render() {
}
total_vram = total_vbo + total_ebo;
for (const auto& cmd : frame_draw_cmds_) {
vis_obj += static_cast<uint32_t>(cmd.counts.size());
total_leaf_draws += cmd.counts.size();
}
FrameStats stats;
stats.fps = last_fps_;
stats.frame_time_ms = 1000.0f / last_fps_;
stats.total_objects = total_obj;
stats.visible_objects = vis_obj;
stats.visible_objects = visible_objects_;
stats.total_triangles = total_tri;
stats.visible_triangles = visible_triangles_;
emit frameStatsUpdated(stats);
double vis_obj_pct = total_obj > 0 ? 100.0 * vis_obj / total_obj : 0.0;
double vis_obj_pct = total_obj > 0 ? 100.0 * visible_objects_ / total_obj : 0.0;
double vis_tri_pct = total_tri > 0 ? 100.0 * visible_triangles_ / total_tri : 0.0;
qDebug("[frame] %.1f fps %.2f ms obj %u/%u (%.1f%%) tri %u/%u (%.1f%%) "
"vram %.1f MB (vbo %.1f + ebo %.1f) models %zu (%zu hidden) draws %zu pending_uploads %zu",
"vram %.1f MB (vbo %.1f + ebo %.1f) models %zu (%zu hidden) "
"leaf_draws %zu model_draws %zu pending_uploads %zu",
last_fps_, 1000.0f / last_fps_,
vis_obj, total_obj, vis_obj_pct,
visible_objects_, total_obj, vis_obj_pct,
visible_triangles_, total_tri, vis_tri_pct,
total_vram / (1024.0 * 1024.0),
total_vbo / (1024.0 * 1024.0),
total_ebo / (1024.0 * 1024.0),
num_models, num_hidden,
total_leaf_draws,
frame_draw_cmds_.size(),
pending_uploads_.size());
}
+1
View File
@@ -201,6 +201,7 @@ private:
};
std::vector<ModelDrawCmd> frame_draw_cmds_;
uint32_t visible_triangles_ = 0;
uint32_t visible_objects_ = 0;
// Camera
QVector3D camera_target_{0, 0, 0};