From 09ffdd202801fc19577fe48f49903268224ca50d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 14 Apr 2026 21:05:27 +1000 Subject: [PATCH] ifcviewer: event-driven rendering, idle scenes cost zero CPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced the 16ms QTimer with QEvent::UpdateRequest delivered via requestUpdate(), posted from every state mutator (mouse/wheel, model lifecycle, selection, visibility, resize). A static BIM scene — the common case for a viewer — now does no work at all between user actions. FPS is now measured as time spent inside render() rather than wall-clock gap between frames, so idle gaps don't pollute the 1-second window and the headline number reflects real render throughput. Headline fps still caps at vsync; sub-vsync profiling lives in the cull[...] phase timers. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 53 +++++++++++++++++++++++++------- src/ifcviewer/ViewportWindow.h | 8 +++-- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 2070467362..a48ef7f6d4 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -317,10 +317,12 @@ ViewportWindow::ViewportWindow(QWindow* parent) fmt.setSamples(4); setFormat(fmt); - connect(&render_timer_, &QTimer::timeout, this, [this]() { - if (isExposed()) render(); - }); - render_timer_.setInterval(16); + // Redraw is driven by QEvent::UpdateRequest. We post one via + // requestUpdate() from every function that mutates visible state + // (mouse/wheel, model lifecycle, selection, resize). When nothing + // changes — the common case for a static BIM model — we don't burn + // CPU/GPU redrawing the same frame. Qt coalesces multiple + // requestUpdate() calls inside a single vblank. } ViewportWindow::~ViewportWindow() { @@ -381,11 +383,11 @@ void ViewportWindow::initGL() { context_->makeCurrent(this); if (on) gl_->glEnable(GL_CULL_FACE); else gl_->glDisable(GL_CULL_FACE); + requestUpdate(); }); gl_initialized_ = true; - frame_clock_.start(); - render_timer_.start(); + requestUpdate(); emit initialized(); } @@ -613,6 +615,7 @@ void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { m.total_triangles += m.meshes[chunk.local_mesh_id].index_count / 3; } have_cached_cull_ = false; + requestUpdate(); } void ViewportWindow::finalizeModel(uint32_t model_id) { @@ -637,6 +640,7 @@ void ViewportWindow::finalizeModel(uint32_t model_id) { m.finalized = true; have_cached_cull_ = false; + requestUpdate(); const size_t ssbo_bytes = m.ssbo_instance_count * sizeof(InstanceGpu); qDebug("Model %u finalized: %zu verts, %zu meshes, %zu instances, %.1f MB vram " @@ -746,6 +750,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; + requestUpdate(); qDebug("Sidecar apply: model %u %zu verts, %zu meshes, %zu instances " "%.1f MB vram (vbo %.1f + ebo %.1f + ssbo %.1f)", @@ -770,6 +775,7 @@ void ViewportWindow::applyLodExtension(uint32_t model_id, const SidecarData& sd) // case lod1_* fields were touched. m.meshes = sd.meshes; have_cached_cull_ = false; + requestUpdate(); return; } @@ -786,6 +792,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; + requestUpdate(); } void ViewportWindow::resetScene() { @@ -802,6 +809,7 @@ void ViewportWindow::resetScene() { models_gpu_.clear(); selected_object_id_ = 0; have_cached_cull_ = false; + requestUpdate(); } void ViewportWindow::hideModel(uint32_t model_id) { @@ -809,6 +817,7 @@ void ViewportWindow::hideModel(uint32_t model_id) { if (it != models_gpu_.end()) { it->second.hidden = true; have_cached_cull_ = false; + requestUpdate(); } } @@ -817,6 +826,7 @@ void ViewportWindow::showModel(uint32_t model_id) { if (it != models_gpu_.end()) { it->second.hidden = false; have_cached_cull_ = false; + requestUpdate(); } } @@ -833,10 +843,14 @@ void ViewportWindow::removeModel(uint32_t model_id) { if (it->second.indirect_buffer) gl_->glDeleteBuffers(1, &it->second.indirect_buffer); models_gpu_.erase(it); have_cached_cull_ = false; + requestUpdate(); } } -void ViewportWindow::setSelectedObjectId(uint32_t id) { selected_object_id_ = id; } +void ViewportWindow::setSelectedObjectId(uint32_t id) { + selected_object_id_ = id; + requestUpdate(); +} // --- HiZ occlusion culling (Phase 3C) ----------------------------------- @@ -1367,6 +1381,9 @@ void ViewportWindow::updateCamera() { void ViewportWindow::render() { if (!gl_initialized_ || !isExposed()) return; + QElapsedTimer frame_cost_clock; + frame_cost_clock.start(); + context_->makeCurrent(this); updateCamera(); @@ -1508,8 +1525,13 @@ void ViewportWindow::render() { context_->swapBuffers(this); - float dt = frame_clock_.restart() / 1000.0f; - accumulated_time_ += dt; + // 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. + // Reported fps = "if I rendered continuously, this is the rate I'd hit", + // which is what profiling actually wants. + const float frame_cost_s = frame_cost_clock.nsecsElapsed() * 1e-9f; + accumulated_time_ += frame_cost_s; frame_count_++; if (accumulated_time_ >= 1.0f) { last_fps_ = static_cast(frame_count_) / accumulated_time_; @@ -1649,13 +1671,19 @@ void ViewportWindow::renderAxisGizmo() { } void ViewportWindow::exposeEvent(QExposeEvent*) { - if (isExposed() && !gl_initialized_) initGL(); + if (isExposed()) { + if (!gl_initialized_) initGL(); + else requestUpdate(); + } } void ViewportWindow::resizeEvent(QResizeEvent*) { - if (gl_initialized_) render(); + if (gl_initialized_) requestUpdate(); } bool ViewportWindow::event(QEvent* e) { switch (e->type()) { + case QEvent::UpdateRequest: + if (isExposed() && gl_initialized_) render(); + return true; case QEvent::MouseButtonPress: handleMousePress(static_cast(e)); return true; case QEvent::MouseButtonRelease: handleMouseRelease(static_cast(e)); return true; case QEvent::MouseMove: handleMouseMove(static_cast(e)); return true; @@ -1673,6 +1701,7 @@ void ViewportWindow::handleMouseRelease(QMouseEvent* e) { uint32_t id = pickObjectAt(e->pos().x(), e->pos().y()); selected_object_id_ = id; emit objectPicked(id); + requestUpdate(); // selection highlight changed } active_button_ = Qt::NoButton; } @@ -1695,10 +1724,12 @@ void ViewportWindow::handleMouseMove(QMouseEvent* e) { camera_pitch_ += delta.y() * 0.3f; camera_pitch_ = qBound(-89.0f, camera_pitch_, 89.0f); } + requestUpdate(); } } void ViewportWindow::handleWheel(QWheelEvent* e) { float factor = e->angleDelta().y() > 0 ? 0.9f : 1.1f; camera_distance_ *= factor; camera_distance_ = qMax(0.1f, camera_distance_); + requestUpdate(); } diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 26c6d20b58..0a95ede077 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -23,7 +23,6 @@ #include #include #include -#include #include #include #include @@ -107,6 +106,11 @@ struct ModelGpuData { bool hidden = false; }; +// Rendering is event-driven: render() runs only when QEvent::UpdateRequest +// is delivered, posted via requestUpdate(). An idle scene costs zero CPU. +// INVARIANT: every public mutator that changes what should be on screen +// (camera, selection, model lifecycle, visibility) MUST call requestUpdate() +// before returning, or the viewport will go silently stale. class ViewportWindow : public QWindow { Q_OBJECT public: @@ -217,8 +221,6 @@ private: QOpenGLContext* context_ = nullptr; QOpenGLFunctions_4_5_Core* gl_ = nullptr; - QTimer render_timer_; - QElapsedTimer frame_clock_; bool gl_initialized_ = false; // Shaders