Cull: skip cullAndUploadVisible + HiZ on still frames

render() was re-running the full cull every 16 ms timer tick even when
nothing had changed — the camera matrices, scene state, and therefore
visible set were all identical to the previous frame's.  The GPU was
still happy to redraw from the cached indirect buffer, but the CPU was
burning 21 ms/frame rebuilding the same visible list.

Detect the no-op case by comparing view/proj against last_cull_view_ /
last_cull_proj_ and checking a scene-dirty flag (have_cached_cull_)
that every mutator on models_gpu_ invalidates — finalizeModel,
applyCachedModel, applyLodExtension, hide/show/remove/reset, and
uploadInstanceChunk.  When the check passes we skip both
cullAndUploadVisible and buildHizPyramid (the depth buffer is
bit-identical, so re-reading it produces the same pyramid).

Per-model visible_objects / visible_triangles stats now live on
ModelGpuData so the stats line reports correct numbers on skipped
frames instead of reading from a stale indirect_scratch_.

Measured on a 569k-object overview: still frames go 22 fps → 62 fps;
orbiting goes 23 fps → ~30-50 fps depending on how hard you move the
mouse (the cull only pays its full cost on the ~25 % of frames where
the camera actually moved).  The stats line gains a "skipped N/M"
field so you can see the ratio live.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-04-14 20:50:58 +10:00
parent c03a7fe117
commit d0c5bd5e85
2 changed files with 77 additions and 11 deletions
+61 -11
View File
@@ -612,6 +612,7 @@ void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) {
if (chunk.local_mesh_id < m.meshes.size()) {
m.total_triangles += m.meshes[chunk.local_mesh_id].index_count / 3;
}
have_cached_cull_ = false;
}
void ViewportWindow::finalizeModel(uint32_t model_id) {
@@ -635,6 +636,7 @@ void ViewportWindow::finalizeModel(uint32_t model_id) {
buildBvhForModel(m, model_id);
m.finalized = true;
have_cached_cull_ = false;
const size_t ssbo_bytes = m.ssbo_instance_count * sizeof(InstanceGpu);
qDebug("Model %u finalized: %zu verts, %zu meshes, %zu instances, %.1f MB vram "
@@ -743,6 +745,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
m.finalized = true;
models_gpu_.emplace(model_id, std::move(m));
have_cached_cull_ = false;
qDebug("Sidecar apply: model %u %zu verts, %zu meshes, %zu instances "
"%.1f MB vram (vbo %.1f + ebo %.1f + ssbo %.1f)",
@@ -766,6 +769,7 @@ void ViewportWindow::applyLodExtension(uint32_t model_id, const SidecarData& sd)
// buildLods didn't add anything; just refresh the meshes vector in
// case lod1_* fields were touched.
m.meshes = sd.meshes;
have_cached_cull_ = false;
return;
}
@@ -781,6 +785,7 @@ void ViewportWindow::applyLodExtension(uint32_t model_id, const SidecarData& sd)
// Replace mesh metadata so cullAndUploadVisible sees the new lod1_ fields.
m.meshes = sd.meshes;
have_cached_cull_ = false;
}
void ViewportWindow::resetScene() {
@@ -796,16 +801,23 @@ void ViewportWindow::resetScene() {
}
models_gpu_.clear();
selected_object_id_ = 0;
have_cached_cull_ = false;
}
void ViewportWindow::hideModel(uint32_t model_id) {
auto it = models_gpu_.find(model_id);
if (it != models_gpu_.end()) it->second.hidden = true;
if (it != models_gpu_.end()) {
it->second.hidden = true;
have_cached_cull_ = false;
}
}
void ViewportWindow::showModel(uint32_t model_id) {
auto it = models_gpu_.find(model_id);
if (it != models_gpu_.end()) it->second.hidden = false;
if (it != models_gpu_.end()) {
it->second.hidden = false;
have_cached_cull_ = false;
}
}
void ViewportWindow::removeModel(uint32_t model_id) {
@@ -820,6 +832,7 @@ void ViewportWindow::removeModel(uint32_t model_id) {
if (it->second.visible_ssbo) gl_->glDeleteBuffers(1, &it->second.visible_ssbo);
if (it->second.indirect_buffer) gl_->glDeleteBuffers(1, &it->second.indirect_buffer);
models_gpu_.erase(it);
have_cached_cull_ = false;
}
}
@@ -1288,6 +1301,17 @@ 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<uint32_t>(indirect_scratch_.size());
// Per-model stats snapshot — summed into the frame counters regardless
// of whether this frame ran a full cull or reused the cached one.
uint32_t model_vis_obj = 0, model_vis_tri = 0;
for (const auto& cmd : indirect_scratch_) {
model_vis_tri += (cmd.count / 3) * cmd.instanceCount;
model_vis_obj += cmd.instanceCount;
}
m.cached_visible_objects = model_vis_obj;
m.cached_visible_triangles = model_vis_tri;
cull_emit_ns_ += phase_timer.nsecsElapsed();
phase_timer.restart();
@@ -1381,7 +1405,23 @@ void ViewportWindow::render() {
visible_objects_ = 0;
gl_draw_calls_ = 0;
indirect_sub_draws_ = 0;
hiz_reject_count_ = 0;
// Only reset hiz_reject_count_ on frames where we actually re-cull;
// otherwise we'd wipe the previous cull's number and print 0 every
// still frame. See the cull_this_frame branch below.
// Decide whether this frame's view+scene is identical to the last
// successful cull. If so the per-model indirect buffers / visible
// SSBOs are still valid — we just re-issue the draws from them and
// skip the expensive cull traversal entirely.
const bool camera_unchanged = have_cached_cull_
&& last_cull_view_ == view_matrix_
&& last_cull_proj_ == proj_matrix_;
const bool cull_this_frame = !camera_unchanged;
if (cull_this_frame) {
hiz_reject_count_ = 0;
} else {
++cull_skipped_frames_;
}
// Start each frame with CCW-is-front; the two-pass draw below flips
// back and forth. Harmless when culling is off.
@@ -1390,7 +1430,9 @@ void ViewportWindow::render() {
for (auto& [model_id, m] : models_gpu_) {
if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue;
cullAndUploadVisible(m, planes, focal_px, min_pixel_radius);
if (cull_this_frame) {
cullAndUploadVisible(m, planes, focal_px, min_pixel_radius);
}
if (m.indirect_command_count == 0) continue;
gl_->glBindVertexArray(m.vao);
@@ -1442,20 +1484,25 @@ void ViewportWindow::render() {
gl_->glFrontFace(GL_CCW);
}
for (const auto& cmd : indirect_scratch_) {
visible_triangles_ += (cmd.count / 3) * cmd.instanceCount;
visible_objects_ += cmd.instanceCount;
}
visible_triangles_ += m.cached_visible_triangles;
visible_objects_ += m.cached_visible_objects;
indirect_sub_draws_ += m.indirect_command_count;
}
if (cull_this_frame) {
last_cull_view_ = view_matrix_;
last_cull_proj_ = proj_matrix_;
have_cached_cull_ = true;
}
gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0);
renderAxisGizmo();
// Build HiZ from this frame's resolved depth for next frame's cull.
// Synchronous glReadPixels inside — cost ~0.5 ms at 256x128 on a
// mid-range dGPU. Skippable via IFC_NO_HIZ=1.
if (hizEnabled()) {
// mid-range dGPU. Skippable via IFC_NO_HIZ=1. Also skipped on
// still frames: if we didn't re-cull, the depth buffer is
// bit-identical to the one we already turned into a pyramid.
if (hizEnabled() && cull_this_frame) {
buildHizPyramid();
}
@@ -1503,10 +1550,12 @@ void ViewportWindow::render() {
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;
const uint32_t skipped = cull_skipped_frames_;
cull_skipped_frames_ = 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 "
"cull[clr %.2f trv %.2f emt %.2f upl %.2f]ms skipped %u/%u "
"vram %.1f MB (vbo %.1f + ebo %.1f + ssbo %.1f) models %zu (%zu hidden)",
last_fps_, 1000.0f / last_fps_,
visible_objects_, total_obj,
@@ -1514,6 +1563,7 @@ void ViewportWindow::render() {
total_meshes, gl_draw_calls_, indirect_sub_draws_,
hiz_reject_count_,
clr_ms, trv_ms, emt_ms, upl_ms,
skipped, frames_in_window,
(total_vbo + total_ebo + total_ssbo) / (1024.0*1024.0),
total_vbo / (1024.0*1024.0),
total_ebo / (1024.0*1024.0),
+16
View File
@@ -79,6 +79,13 @@ struct ModelGpuData {
std::vector<uint8_t> instance_reflected;
uint32_t ssbo_instance_count = 0;
// Stats snapshot from the last cullAndUploadVisible call. Cached so we
// can report the same numbers on skipped-cull frames (see
// have_cached_cull_ on ViewportWindow) without iterating the per-model
// scratch array again.
uint32_t cached_visible_objects = 0;
uint32_t cached_visible_triangles = 0;
// Per-instance world AABB + BVH (built at finalize). The BVH is the
// same ordering as `instances`; bvh_items[i] corresponds to instances[i].
std::vector<BvhItem> bvh_items;
@@ -268,6 +275,15 @@ private:
uint64_t cull_traverse_ns_ = 0;
uint64_t cull_emit_ns_ = 0;
uint64_t cull_upload_ns_ = 0;
uint32_t cull_skipped_frames_ = 0;
// Skip cullAndUploadVisible + buildHizPyramid when the camera and scene
// haven't changed since the last cull. The existing per-model
// indirect_buffer / visible_ssbo are still correct and just get
// redrawn. Invalidated by any function that mutates models_gpu_.
QMatrix4x4 last_cull_view_;
QMatrix4x4 last_cull_proj_;
bool have_cached_cull_ = false;
// Per-frame stats
uint32_t visible_triangles_ = 0;