diff --git a/src/ifcviewer/CMakeLists.txt b/src/ifcviewer/CMakeLists.txt
index ec79fe1740..510f8560e2 100644
--- a/src/ifcviewer/CMakeLists.txt
+++ b/src/ifcviewer/CMakeLists.txt
@@ -157,6 +157,8 @@ set(IFCVIEWER_CORE_HEADERS
LodBuilder.h
Stopwatch.h
ModelGpuData.h
+ FrameStats.h
+ OverlayFrame.h
SectionPlane.h
SelectionState.h
SidecarCache.h
diff --git a/src/ifcviewer/FrameStats.h b/src/ifcviewer/FrameStats.h
new file mode 100644
index 0000000000..40eb7766a5
--- /dev/null
+++ b/src/ifcviewer/FrameStats.h
@@ -0,0 +1,43 @@
+/********************************************************************************
+ * *
+ * This file is part of IfcOpenShell. *
+ * *
+ * IfcOpenShell is free software: you can redistribute it and/or modify *
+ * it under the terms of the Lesser GNU General Public License as published by *
+ * the Free Software Foundation, either version 3.0 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * IfcOpenShell is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * Lesser GNU General Public License for more details. *
+ * *
+ * You should have received a copy of the Lesser GNU General Public License *
+ * along with this program. If not, see . *
+ * *
+ ********************************************************************************/
+
+#ifndef IFCVIEWER_FRAMESTATS_H
+#define IFCVIEWER_FRAMESTATS_H
+
+#include
+
+// Per-frame statistics emitted from render() so the embedder can
+// surface them (bonsai's status bar, the web-side dev console, the
+// benchmark accumulator). Pure POD so it travels through ViewportHost
+// without dragging Qt along. ViewportWindow::FrameStats re-exports
+// this so existing bonsai callers still see the familiar
+// ViewportWindow::FrameStats type.
+struct FrameStats {
+ float fps;
+ float frame_time_ms;
+ std::uint32_t total_objects;
+ std::uint32_t visible_objects;
+ std::uint32_t total_triangles;
+ std::uint32_t visible_triangles;
+ std::uint32_t unique_meshes;
+ std::uint32_t gl_draw_calls; // wgpu draw-call count; name kept for bonsai parity
+ std::uint32_t indirect_sub_draws; // sub-draws packed into the chunk-indirect lists
+};
+
+#endif // IFCVIEWER_FRAMESTATS_H
diff --git a/src/ifcviewer/OverlayFrame.h b/src/ifcviewer/OverlayFrame.h
new file mode 100644
index 0000000000..97c773707b
--- /dev/null
+++ b/src/ifcviewer/OverlayFrame.h
@@ -0,0 +1,41 @@
+/********************************************************************************
+ * *
+ * This file is part of IfcOpenShell. *
+ * *
+ * IfcOpenShell is free software: you can redistribute it and/or modify *
+ * it under the terms of the Lesser GNU General Public License as published by *
+ * the Free Software Foundation, either version 3.0 of the License, or *
+ * (at your option) any later version. *
+ * *
+ * IfcOpenShell is distributed in the hope that it will be useful, *
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
+ * Lesser GNU General Public License for more details. *
+ * *
+ * You should have received a copy of the Lesser GNU General Public License *
+ * along with this program. If not, see . *
+ * *
+ ********************************************************************************/
+
+#ifndef IFCVIEWER_OVERLAYFRAME_H
+#define IFCVIEWER_OVERLAYFRAME_H
+
+#include
+
+// Per-frame snapshot of viewport state that every overlay needs. Built
+// once at the top of render() and passed by const-ref to each encodeX()
+// call so the overlay renderer never reaches back into the viewport.
+// Qt-free so ViewportHost can carry it as a callback param.
+struct OverlayFrame {
+ Eigen::Matrix4f view_proj = Eigen::Matrix4f::Identity();
+ Eigen::Vector3f camera_target = Eigen::Vector3f::Zero();
+ float camera_distance = 5.0f;
+ float camera_yaw_deg = 0.0f;
+ float camera_pitch_deg = 0.0f;
+ float camera_fov_y_deg = 45.0f;
+ int viewport_w_px = 0;
+ int viewport_h_px = 0;
+ int device_pixel_ratio = 1;
+};
+
+#endif // IFCVIEWER_OVERLAYFRAME_H
diff --git a/src/ifcviewer/OverlayRenderer.h b/src/ifcviewer/OverlayRenderer.h
index 6963dfa2f3..2b38e5fae1 100644
--- a/src/ifcviewer/OverlayRenderer.h
+++ b/src/ifcviewer/OverlayRenderer.h
@@ -30,21 +30,7 @@
#include
#include
-// Per-frame snapshot of viewport state that every overlay needs. Built once
-// at the top of render() and passed by const-ref to each encodeX() call so
-// the overlay renderer never reaches back into the viewport.
-struct OverlayFrame {
- Eigen::Matrix4f view_proj = Eigen::Matrix4f::Identity();
- Eigen::Vector3f camera_target = Eigen::Vector3f::Zero();
- float camera_distance = 5.0f;
- float camera_yaw_deg = 0.0f;
- float camera_pitch_deg = 0.0f;
- float camera_fov_y_deg = 45.0f;
- int viewport_w_px = 0;
- int viewport_h_px = 0;
- int device_pixel_ratio = 1;
-};
-
+#include "OverlayFrame.h"
#include "SectionPlane.h"
// All viewport overlays in one place: axis indicator (corner + pivot),
diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp
index 81323072ad..cef5199cc7 100644
--- a/src/ifcviewer/ViewportCore.cpp
+++ b/src/ifcviewer/ViewportCore.cpp
@@ -4711,3 +4711,560 @@ void ViewportCore::finalizeScreenshotCapture(WGPUBuffer capture_buffer,
pending_screenshot_quit_ = false;
if (quit_after) host_->quit();
}
+
+// ===========================================================================
+// Render loop (#84-x): render()
+// ===========================================================================
+
+#include
+#include
+#include
+
+#include "Stopwatch.h"
+
+namespace {
+
+// degrees → radians. Inline-only, used inside render() for the
+// focal-length derivation.
+constexpr float degreesToRadians(float deg) {
+ return deg * float(M_PI) / 180.0f;
+}
+
+// Format a float with N decimals into the running Log line. Used to
+// match GL's per-frame stats output where fixed-precision matters for
+// side-by-side diffs.
+std::string fmtF(double v, int prec) {
+ std::ostringstream ss;
+ ss << std::fixed << std::setprecision(prec) << v;
+ return ss.str();
+}
+
+// sRGB → linear, used for the background-clear value so the wgpu
+// surface (which is sRGB on most backends) renders the same colour as
+// the GL viewport's GL_FRAMEBUFFER_SRGB-enabled pass.
+inline float srgbToLinear(float c) {
+ return (c <= 0.04045f) ? (c * (1.0f / 12.92f))
+ : std::pow((c + 0.055f) * (1.0f / 1.055f), 2.4f);
+}
+
+} // namespace
+
+void ViewportCore::render() {
+ if (!device_ || !queue_ || !surface_) return;
+
+ Stopwatch frame_timer;
+ frame_timer.start();
+
+ // Drain any HiZ async readbacks completed since last frame.
+ if (hiz_enabled_) drainHizReadbacks();
+
+ uploadSelectionFlagsIfDirty();
+
+ WGPUSurfaceTexture surf_tex = {};
+ wgpuSurfaceGetCurrentTexture(surface_, &surf_tex);
+
+ switch (surf_tex.status) {
+ case WGPUSurfaceGetCurrentTextureStatus_SuccessOptimal:
+ case WGPUSurfaceGetCurrentTextureStatus_SuccessSuboptimal:
+ break;
+ case WGPUSurfaceGetCurrentTextureStatus_Timeout:
+ case WGPUSurfaceGetCurrentTextureStatus_Outdated:
+ case WGPUSurfaceGetCurrentTextureStatus_Lost: {
+ int w = 0, h = 0;
+ host_->framebufferSize(w, h);
+ if (w > 0 && h > 0) configureSurface(w, h);
+ host_->requestFrame();
+ return;
+ }
+ default:
+ Log::warn() << "GetCurrentTexture status " << int(surf_tex.status);
+ return;
+ }
+
+ WGPUTextureView view = wgpuTextureCreateView(surf_tex.texture, nullptr);
+
+ updateFrameUniforms();
+
+ // ---- Per-frame cull --------------------------------------------------
+ last_visible_objects_ = 0;
+ last_visible_triangles_ = 0;
+ last_sub_draws_ = 0;
+ hiz_reject_count_ = 0;
+ Stopwatch cull_timer;
+ cull_timer.start();
+ Eigen::Matrix4f vp_this_frame;
+ {
+ const Eigen::Vector3f target(camera_target_[0], camera_target_[1], camera_target_[2]);
+ const Eigen::Vector3f eye = orbitEye(camera_target_, camera_distance_,
+ camera_yaw_deg_, camera_pitch_deg_);
+ Eigen::Matrix4f v, p;
+ buildViewProj(v, p);
+ const Eigen::Matrix4f vp = p * v;
+ vp_this_frame = vp;
+ float planes[6][4];
+ extractFrustumPlanes(vp.data(), planes);
+
+ // LOD focal: projected_px = world_radius * focal_px / view_z.
+ const Eigen::Vector3f fwd_q = (target - eye).normalized();
+ const Eigen::Vector3f world_up = (std::abs(camera_pitch_deg_) >= 89.0f)
+ ? Eigen::Vector3f(0.0f, 1.0f, 0.0f)
+ : Eigen::Vector3f(0.0f, 0.0f, 1.0f);
+ const Eigen::Vector3f right_q = fwd_q.cross(world_up).normalized();
+ const Eigen::Vector3f up_q = right_q.cross(fwd_q).normalized();
+ const float eye_a[3] = { eye.x(), eye.y(), eye.z() };
+ const float fwd_a[3] = { fwd_q.x(), fwd_q.y(), fwd_q.z() };
+ const float right_a[3] = { right_q.x(), right_q.y(), right_q.z() };
+ const float up_a[3] = { up_q.x(), up_q.y(), up_q.z() };
+ const float focal_px = (configured_h_ > 0)
+ ? (0.5f * float(configured_h_)
+ / std::tan(degreesToRadians(camera_fov_y_deg_) * 0.5f))
+ : 0.0f;
+
+ // Motion detection.
+ const bool camera_moved = has_prev_camera_
+ && (camera_target_[0] != prev_camera_target_[0]
+ || camera_target_[1] != prev_camera_target_[1]
+ || camera_target_[2] != prev_camera_target_[2]
+ || camera_distance_ != prev_camera_distance_
+ || camera_yaw_deg_ != prev_camera_yaw_deg_
+ || camera_pitch_deg_ != prev_camera_pitch_deg_);
+ const bool use_motion_threshold =
+ camera_moved && motion_min_pixel_radius_ > min_pixel_radius_;
+ const float effective_min_px =
+ use_motion_threshold ? motion_min_pixel_radius_ : min_pixel_radius_;
+ last_cull_was_motion_ = use_motion_threshold;
+
+ // HiZ stale-VP gate. Strict by default; WGPU_HIZ_MOTION=1 trusts
+ // the stale pyramid across motion.
+ static const bool hiz_trust_stale = []{
+ const char* e = std::getenv("WGPU_HIZ_MOTION");
+ return e && e[0] == '1';
+ }();
+ const bool hiz_vp_matches = hiz_valid_
+ && (hiz_trust_stale || hiz_vp_ == vp_this_frame);
+ const bool hiz_for_this_frame = hiz_enabled_ && hiz_vp_matches;
+
+ // WGPU_HIZ_TRACE per-frame trace budget arm.
+ static const bool hiz_trace_on = []{
+ const char* e = std::getenv("WGPU_HIZ_TRACE");
+ return e && e[0] == '1';
+ }();
+ if (hiz_trace_on && hiz_for_this_frame) {
+ constexpr int kHizTracePerFrame = 12;
+ hiz_trace_budget_.store(kHizTracePerFrame, std::memory_order_relaxed);
+ Log::info()
+ << "[hiz trace] frame: vp_match="
+ << (hiz_vp_ == vp_this_frame ? "exact" : "loose")
+ << " pyramid_mip0=" << hiz_mip_w_[0] << "x" << hiz_mip_h_[0]
+ << " budget=" << kHizTracePerFrame;
+ } else if (hiz_trace_on) {
+ hiz_trace_budget_.store(0, std::memory_order_relaxed);
+ }
+
+ HizOccludedFn hiz_occluded;
+ if (hiz_for_this_frame) {
+ hiz_occluded = [this](const float mn[3], const float mx[3]) {
+ return aabbOccludedByHiz(mn, mx);
+ };
+ }
+
+ if (cull_threads_enabled_) {
+ std::vector>> futures;
+ futures.reserve(models_gpu_.size());
+ for (auto& [mid, m] : models_gpu_) {
+ if (m.hidden) continue;
+ auto& m_ref = m;
+ futures.emplace_back(mid, std::async(std::launch::async,
+ [this, &m_ref, &planes, &eye_a, &fwd_a, &right_a, &up_a,
+ focal_px, effective_min_px, &hiz_occluded]() {
+ return cullModelCpuCompute(
+ m_ref, planes, eye_a, fwd_a, right_a, up_a,
+ focal_px,
+ effective_min_px, lod1_pixel_threshold_,
+ hiz_occluded);
+ }));
+ }
+ for (auto& [mid, fut] : futures) {
+ hiz_reject_count_ += fut.get();
+ }
+ } else {
+ for (auto& [mid, m] : models_gpu_) {
+ if (m.hidden) continue;
+ hiz_reject_count_ += cullModelCpuCompute(
+ m, planes, eye_a, fwd_a, right_a, up_a, focal_px,
+ effective_min_px, lod1_pixel_threshold_,
+ hiz_occluded);
+ }
+ }
+
+ const double cull_compute_ms = double(cull_timer.nsecsElapsed()) / 1e6;
+ Stopwatch upload_timer;
+ upload_timer.start();
+ for (auto& [mid, m] : models_gpu_) {
+ if (m.hidden) continue;
+ cullModelCpuUpload(m);
+ for (const auto& c : m.chunks) {
+ last_visible_objects_ += c.total_visible_draws;
+ last_visible_triangles_ += c.total_visible_vertices / 3u;
+ if (c.total_visible_draws > 0) last_sub_draws_ += 1;
+ }
+ }
+ last_cull_compute_ms_ = cull_compute_ms;
+ last_cull_upload_ms_ = double(upload_timer.nsecsElapsed()) / 1e6;
+ }
+
+ const double cull_only_ms = double(cull_timer.nsecsElapsed()) / 1e6;
+ last_cull_ms_ = cull_only_ms;
+
+ Stopwatch stream_timer;
+ stream_timer.start();
+ driveStreamingLoads();
+ const double stream_ms = double(stream_timer.nsecsElapsed()) / 1e6;
+ last_stream_ms_ = stream_ms;
+
+ // Snapshot camera state for next frame's motion detection.
+ prev_camera_target_[0] = camera_target_[0];
+ prev_camera_target_[1] = camera_target_[1];
+ prev_camera_target_[2] = camera_target_[2];
+ prev_camera_distance_ = camera_distance_;
+ prev_camera_yaw_deg_ = camera_yaw_deg_;
+ prev_camera_pitch_deg_ = camera_pitch_deg_;
+ has_prev_camera_ = true;
+ if (bench_total_ > 0 && bench_count_ >= bench_warmup_) {
+ bench_cull_ms_total_ += cull_only_ms;
+ bench_stream_ms_total_ += stream_ms;
+ }
+
+ WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(device_, nullptr);
+
+ WGPURenderPassColorAttachment color = {};
+ color.view = msaa_color_view_;
+ color.resolveTarget = view;
+ color.loadOp = WGPULoadOp_Clear;
+ color.storeOp = WGPUStoreOp_Store;
+ color.clearValue = {
+ srgbToLinear(background_color_[0]),
+ srgbToLinear(background_color_[1]),
+ srgbToLinear(background_color_[2]),
+ 1.0,
+ };
+ color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
+
+ WGPURenderPassDepthStencilAttachment depth = {};
+ depth.view = depth_view_;
+ depth.depthLoadOp = WGPULoadOp_Clear;
+ depth.depthStoreOp = WGPUStoreOp_Store;
+ depth.depthClearValue = 1.0f;
+ depth.stencilLoadOp = WGPULoadOp_Undefined;
+ depth.stencilStoreOp = WGPUStoreOp_Undefined;
+ depth.depthReadOnly = false;
+ depth.stencilReadOnly = true;
+
+ WGPURenderPassDescriptor pass_desc = {};
+ pass_desc.colorAttachmentCount = 1;
+ pass_desc.colorAttachments = &color;
+ pass_desc.depthStencilAttachment = depth_view_ ? &depth : nullptr;
+
+ WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
+
+ // Two-pass main render: opaque first, then transparent.
+ if (main_pipeline_ && main_pipeline_transparent_
+ && frame_bind_group_ && !models_gpu_.empty()) {
+ wgpuRenderPassEncoderSetPipeline(pass, main_pipeline_);
+ wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr);
+
+ for (const auto& [mid, m] : models_gpu_) {
+ if (m.hidden) continue;
+ for (const auto& c : m.chunks) {
+ if (!c.bind_group || c.opaque_visible_vertices == 0) continue;
+ wgpuRenderPassEncoderSetBindGroup(pass, 1, c.bind_group, 0, nullptr);
+ wgpuRenderPassEncoderDraw(pass,
+ c.opaque_visible_vertices, 1, 0, 0);
+ }
+ }
+
+ wgpuRenderPassEncoderSetPipeline(pass, main_pipeline_transparent_);
+ for (const auto& [mid, m] : models_gpu_) {
+ if (m.hidden) continue;
+ for (const auto& c : m.chunks) {
+ if (!c.bind_group) continue;
+ const std::uint32_t transparent_verts =
+ c.total_visible_vertices - c.opaque_visible_vertices;
+ if (transparent_verts == 0) continue;
+ wgpuRenderPassEncoderSetBindGroup(pass, 1, c.bind_group, 0, nullptr);
+ wgpuRenderPassEncoderDraw(pass,
+ transparent_verts, 1,
+ c.opaque_visible_vertices, 0);
+ }
+ }
+ }
+
+ // Build the per-frame OverlayFrame snapshot.
+ int viewport_w_px = 0, viewport_h_px = 0;
+ host_->framebufferSize(viewport_w_px, viewport_h_px);
+ const int dpr_int = std::max(1, int(host_->dpr()));
+
+ OverlayFrame overlay_frame;
+ overlay_frame.view_proj = vp_this_frame;
+ overlay_frame.camera_target = Eigen::Vector3f(camera_target_[0],
+ camera_target_[1],
+ camera_target_[2]);
+ overlay_frame.camera_distance = camera_distance_;
+ overlay_frame.camera_yaw_deg = camera_yaw_deg_;
+ overlay_frame.camera_pitch_deg = camera_pitch_deg_;
+ overlay_frame.camera_fov_y_deg = camera_fov_y_deg_;
+ overlay_frame.viewport_w_px = viewport_w_px;
+ overlay_frame.viewport_h_px = viewport_h_px;
+ overlay_frame.device_pixel_ratio = dpr_int;
+
+ // In-pass overlays (section gizmos, highlight triangles, pivot,
+ // overlay lines/points). QtViewportHost forwards to overlays_.X().
+ host_->encodeOverlaysInMainPass(pass, overlay_frame);
+
+ wgpuRenderPassEncoderEnd(pass);
+ wgpuRenderPassEncoderRelease(pass);
+
+ // Edge silhouette + HiZ resolve, before the surface-targeted overlays.
+ if (edges_enabled_) encodeEdgePass(enc, view);
+
+ int hiz_submitted_slot = -1;
+ if (hiz_enabled_) hiz_submitted_slot = encodeHizResolve(enc);
+
+ // Post-main overlays (corner axis, marquee, labels) on the resolved
+ // surface. QtViewportHost forwards to overlays_.X().
+ host_->encodeOverlaysPostMain(enc, view, overlay_frame);
+
+ // Optional capture: encode copy on the same command buffer.
+ WGPUBuffer capture_buffer = nullptr;
+ std::uint32_t capture_padded_bpr = 0;
+ const bool want_capture = !pending_screenshot_path_.empty();
+ if (want_capture) {
+ capture_buffer = encodeScreenshotCapture(
+ enc, surf_tex.texture, capture_padded_bpr);
+ }
+
+ WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(enc, nullptr);
+ wgpuQueueSubmit(queue_, 1, &cmd);
+
+ wgpuCommandBufferRelease(cmd);
+ wgpuCommandEncoderRelease(enc);
+ wgpuTextureViewRelease(view);
+
+ if (want_capture) {
+ finalizeScreenshotCapture(capture_buffer, capture_padded_bpr);
+ }
+
+ // ---- FrameStats emission ---------------------------------------------
+ {
+ const double this_frame_ms =
+ double(frame_timer.nsecsElapsed()) / 1e6;
+ frame_time_ms_sum_ -= frame_time_ms_window_[frame_time_ms_head_];
+ frame_time_ms_window_[frame_time_ms_head_] = this_frame_ms;
+ frame_time_ms_sum_ += this_frame_ms;
+ frame_time_ms_head_ = (frame_time_ms_head_ + 1) % FRAME_TIME_WINDOW;
+ if (frame_time_ms_count_ < FRAME_TIME_WINDOW) ++frame_time_ms_count_;
+ const double avg_ms = frame_time_ms_count_ > 0
+ ? frame_time_ms_sum_ / double(frame_time_ms_count_)
+ : 0.0;
+
+ std::uint32_t total_obj = 0, total_tri = 0, total_meshes = 0;
+ for (const auto& [mid, mm] : models_gpu_) {
+ total_obj += std::uint32_t(mm.instances.size());
+ total_tri += mm.index_count / 3;
+ total_meshes += std::uint32_t(mm.meshes.size());
+ }
+
+ FrameStats stats;
+ stats.fps = avg_ms > 0.0 ? float(1000.0 / avg_ms) : 0.0f;
+ stats.frame_time_ms = float(avg_ms);
+ stats.total_objects = total_obj;
+ stats.visible_objects = last_visible_objects_;
+ stats.total_triangles = total_tri;
+ stats.visible_triangles = last_visible_triangles_;
+ stats.unique_meshes = total_meshes;
+ std::uint32_t draw_calls = 0;
+ for (const auto& [mid, mm] : models_gpu_) {
+ if (mm.hidden) continue;
+ for (const auto& c : mm.chunks) {
+ if (c.is_resident && c.total_visible_draws > 0) ++draw_calls;
+ }
+ }
+ stats.gl_draw_calls = draw_calls;
+ stats.indirect_sub_draws = last_sub_draws_;
+ host_->onFrameStats(stats);
+ }
+
+ wgpuSurfacePresent(surface_);
+ wgpuTextureRelease(surf_tex.texture);
+
+ if (last_cull_was_motion_) host_->requestFrame();
+
+ // HiZ async readback handoff.
+ if (hiz_enabled_ && hiz_submitted_slot >= 0) {
+ Stopwatch hiz_timer;
+ if (bench_total_ > 0) hiz_timer.start();
+ startHizMap(hiz_submitted_slot, vp_this_frame);
+ if (bench_total_ > 0 && bench_count_ >= bench_warmup_) {
+ bench_hiz_readback_ms_total_ += double(hiz_timer.nsecsElapsed()) / 1e6;
+ }
+ }
+
+ // ---- Interactive heartbeat log --------------------------------------
+ if (bench_total_ == 0) {
+ ++interactive_frame_count_;
+ const float ms = float(frame_timer.nsecsElapsed()) / 1e6f;
+ std::uint64_t total_vbo = 0, total_ebo = 0, total_ssbo = 0;
+ std::uint32_t total_instances = 0;
+ std::size_t chunks_total = 0, chunks_resident = 0;
+ std::size_t chunks_frustum_vis = 0, chunks_missing = 0;
+ for (const auto& [mid, mo] : models_gpu_) {
+ total_vbo += mo.vram_bytes_vbo;
+ total_ebo += mo.vram_bytes_ebo;
+ total_ssbo += mo.vram_bytes_ssbo;
+ total_instances += mo.instance_count;
+ for (const auto& c : mo.chunks) {
+ ++chunks_total;
+ if (c.is_resident) ++chunks_resident;
+ if (c.frustum_visible_count > 0) {
+ ++chunks_frustum_vis;
+ if (!c.is_resident) ++chunks_missing;
+ }
+ }
+ }
+ const double mb = 1.0 / (1024.0 * 1024.0);
+ Log::info()
+ << "[frame] " << fmtF(ms > 0 ? 1000.0f / ms : 0.0f, 1) << " fps"
+ << " " << fmtF(ms, 2) << " ms"
+ << " obj " << last_visible_objects_ << "/" << total_instances
+ << " tri " << last_visible_triangles_
+ << " sub_draws " << last_sub_draws_
+ << " hiz_rej " << hiz_reject_count_
+ << " cull " << fmtF(last_cull_ms_, 2) << "ms"
+ << " stream " << fmtF(last_stream_ms_, 2) << "ms"
+ << " chunks " << chunks_resident << "/" << chunks_frustum_vis
+ << "/" << chunks_total << " (missing " << chunks_missing << ")"
+ << " vram " << fmtF(double(total_vbo + total_ebo + total_ssbo) * mb, 1) << "MB"
+ << " models " << models_gpu_.size()
+ << " lod1 " << lod1_dbg_count_ << "/" << (lod1_dbg_count_ + lod0_dbg_eligible_count_)
+ << " (saved " << lod1_dbg_tris_saved_ << " tris, "
+ << lod0_dbg_no_lod1_count_ << " no-lod1)";
+ lod1_dbg_count_ = 0;
+ lod0_dbg_eligible_count_ = 0;
+ lod0_dbg_no_lod1_count_ = 0;
+ lod1_dbg_tris_saved_ = 0;
+ }
+
+ // ---- Benchmark integration + auto-quit -------------------------------
+ if (bench_total_ > 0) {
+ if (!bench_warm_done_) {
+ constexpr int CONVERGE_FRAMES_REQUIRED = 5;
+ constexpr int MAX_WARM_FRAMES = 600;
+ const bool worker_idle =
+ streaming_thread_.inFlightApprox() == 0;
+ if (streaming_loads_this_frame_ > 0 || !worker_idle) {
+ bench_warm_streak_ = 0;
+ } else {
+ ++bench_warm_streak_;
+ }
+ ++bench_warm_frames_total_;
+ const bool converged = bench_warm_streak_ >= CONVERGE_FRAMES_REQUIRED;
+ const bool timed_out = bench_warm_frames_total_ >= MAX_WARM_FRAMES;
+ if (converged) {
+ Log::info() << "[bench warm] converged after "
+ << bench_warm_frames_total_ << " frames";
+ bench_warm_done_ = true;
+ } else if (timed_out) {
+ Log::warn() << "[bench warm] timed out after "
+ << bench_warm_frames_total_
+ << " frames without convergence; starting bench anyway";
+ bench_warm_done_ = true;
+ } else {
+ host_->requestFrame();
+ return;
+ }
+ }
+
+ const float ms = float(frame_timer.nsecsElapsed()) / 1e6f;
+
+ if (bench_count_ >= bench_warmup_) {
+ bench_frame_ms_.push_back(ms);
+ }
+
+ if ((bench_count_ % 50) == 0) {
+ std::uint64_t total_vbo = 0, total_ebo = 0, total_ssbo = 0;
+ std::uint32_t total_instances = 0;
+ for (const auto& [mid, mo] : models_gpu_) {
+ total_vbo += mo.vram_bytes_vbo;
+ total_ebo += mo.vram_bytes_ebo;
+ total_ssbo += mo.vram_bytes_ssbo;
+ total_instances += mo.instance_count;
+ }
+ const double mb = 1.0 / (1024.0 * 1024.0);
+ const double avg_n = double(std::max(1, bench_count_ - bench_warmup_ + 1));
+ const double cull_ms = bench_cull_ms_total_ / avg_n;
+ const double stream_ms2 = bench_stream_ms_total_ / avg_n;
+ Log::info()
+ << "[frame] " << fmtF(ms > 0 ? 1000.0f / ms : 0.0f, 1) << " fps"
+ << " " << fmtF(ms, 2) << " ms"
+ << " obj " << last_visible_objects_ << "/" << total_instances
+ << " tri " << last_visible_triangles_
+ << " sub_draws " << last_sub_draws_
+ << " hiz_rej " << hiz_reject_count_
+ << " cull[wall " << fmtF(cull_ms, 2)
+ << " | compute " << fmtF(last_cull_compute_ms_, 2)
+ << " upload " << fmtF(last_cull_upload_ms_, 2) << "]ms"
+ << " stream[" << fmtF(stream_ms2, 2) << "]ms"
+ << " vram " << fmtF(double(total_vbo + total_ebo + total_ssbo) * mb, 1) << "MB"
+ << " models " << models_gpu_.size()
+ << " lod1 " << lod1_dbg_count_ << "/" << (lod1_dbg_count_ + lod0_dbg_eligible_count_)
+ << " (saved " << lod1_dbg_tris_saved_ << " tris, "
+ << lod0_dbg_no_lod1_count_ << " no-lod1)";
+ lod1_dbg_count_ = 0;
+ lod0_dbg_eligible_count_ = 0;
+ lod0_dbg_no_lod1_count_ = 0;
+ lod1_dbg_tris_saved_ = 0;
+ }
+ camera_yaw_deg_ = bench_yaw_start_
+ + bench_yaw_speed_ * float(bench_count_ + 1);
+ ++bench_count_;
+
+ if (bench_count_ >= bench_warmup_ + bench_total_) {
+ std::vector times = bench_frame_ms_;
+ std::sort(times.begin(), times.end());
+ auto pct = [×](double p) -> float {
+ if (times.empty()) return 0.0f;
+ const std::size_t idx = std::min(times.size() - 1,
+ std::size_t(p * double(times.size() - 1)));
+ return times[idx];
+ };
+ float sum = 0.0f;
+ for (float f : times) sum += f;
+ const float avg = times.empty() ? 0.0f : sum / float(times.size());
+ const float median = pct(0.5);
+ const float p1 = pct(0.01);
+ const float p99 = pct(0.99);
+
+ const float total_sweep = bench_yaw_speed_ * float(bench_total_);
+ Log::info() << "\n=== BENCHMARK (" << bench_total_ << " frames, orbit "
+ << total_sweep << "deg at " << bench_yaw_speed_ << "deg/frame) ===";
+ Log::info() << " avg: " << avg << " ms (" << (avg > 0 ? 1000.0f/avg : 0.0f) << " fps)";
+ Log::info() << " median: " << median << " ms (" << (median > 0 ? 1000.0f/median : 0.0f) << " fps)";
+ Log::info() << " p1: " << p1 << " ms p99: " << p99 << " ms";
+ Log::info() << " last frame: obj " << last_visible_objects_
+ << " tri " << last_visible_triangles_
+ << " sub_draws " << last_sub_draws_
+ << " hiz_rej " << hiz_reject_count_;
+ const double n = double(std::max(1, bench_total_));
+ Log::info() << " per-frame avg ms: cull=" << bench_cull_ms_total_ / n
+ << " stream=" << bench_stream_ms_total_ / n
+ << " hiz_readback=" << bench_hiz_readback_ms_total_ / n
+ << " hiz=" << (hiz_enabled_ ? "on" : "off");
+ Log::info() << "=== END BENCHMARK ===\n";
+
+ bench_total_ = 0;
+ host_->quit();
+ } else {
+ host_->requestFrame();
+ }
+ }
+}
diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h
index 8088c987c5..3df584bdfb 100644
--- a/src/ifcviewer/ViewportCore.h
+++ b/src/ifcviewer/ViewportCore.h
@@ -355,6 +355,19 @@ public:
void finalizeScreenshotCapture(WGPUBuffer capture_buffer,
std::uint32_t padded_bpr);
+ // ---- Render loop (#84-x) ----------------------------------------------
+ //
+ // Encode one frame: acquire the swapchain texture, run cull (parallel
+ // when WGPU_CULL_THREADS!=0), drive streaming residency, encode the
+ // two-pass main draw, edge pass, HiZ resolve, and (optionally) the
+ // screenshot capture. Calls host_->encodeOverlaysInMainPass and
+ // host_->encodeOverlaysPostMain for the overlay layers (still
+ // Qt-bound), and host_->onFrameStats for the bench / status bar
+ // listeners. Idempotent re: framebuffer size — reconfigures the
+ // surface on Outdated/Lost. Returns early when the surface query
+ // fails so the next frame retries cleanly.
+ void render();
+
// ---- Surface configuration (#84-u) ------------------------------------
//
// Configure the swapchain at the given physical size. Picks a present
@@ -790,6 +803,78 @@ private:
std::unordered_map>
pending_direct_loads_;
+ // ---- Render-loop state (#84-x) ---------------------------------------
+
+ // Contribution-cull thresholds. min_pixel_radius_ is the still-frame
+ // floor; motion_min_pixel_radius_ kicks in during orbit/pan/zoom to
+ // drop more sub-pixel work. lod1_pixel_threshold_ chooses LOD1 over
+ // LOD0 when an instance projects below that radius.
+ float min_pixel_radius_ = 3.0f;
+ float motion_min_pixel_radius_ = 15.0f;
+ float lod1_pixel_threshold_ = 30.0f;
+ // WGPU_CULL_THREADS=0 forces sequential cull (one model after another)
+ // for parallel-vs-serial benchmarking. Default ON.
+ bool cull_threads_enabled_ = true;
+
+ // Per-frame stats latched by render() for FrameStats emission +
+ // the interactive heartbeat / bench per-frame line.
+ std::uint32_t last_visible_objects_ = 0;
+ std::uint32_t last_visible_triangles_ = 0;
+ std::uint32_t last_sub_draws_ = 0;
+ double last_cull_ms_ = 0.0;
+ double last_cull_compute_ms_ = 0.0;
+ double last_cull_upload_ms_ = 0.0;
+ double last_stream_ms_ = 0.0;
+ // True when the cull just used motion_min_pixel_radius_ — render()
+ // schedules one more frame so the camera-now-stopped state recomputes
+ // the cull at the still threshold and previously dropped sub-pixel
+ // instances pop back in.
+ bool last_cull_was_motion_ = false;
+
+ // Previous frame's camera state for the motion-vs-still decision.
+ float prev_camera_target_[3] = { 0, 0, 0 };
+ float prev_camera_distance_ = 0.0f;
+ float prev_camera_yaw_deg_ = 0.0f;
+ float prev_camera_pitch_deg_ = 0.0f;
+ bool has_prev_camera_ = false;
+
+ // Rolling-average FPS readout (60-frame window). frame_time_ms_sum_
+ // tracks the running sum so FrameStats can divide-by-count without
+ // re-summing.
+ static constexpr int FRAME_TIME_WINDOW = 60;
+ double frame_time_ms_window_[FRAME_TIME_WINDOW] = {};
+ int frame_time_ms_count_ = 0;
+ int frame_time_ms_head_ = 0;
+ double frame_time_ms_sum_ = 0.0;
+
+ // Benchmark-mode state. Activated by setBenchmarkFrames(n); render()
+ // orbits the camera at bench_yaw_speed_ deg/frame for bench_total_
+ // frames after a bench_warmup_ settle period, collects per-frame ms,
+ // emits a percentile summary, and calls host_->quit().
+ int bench_total_ = 0;
+ int bench_count_ = 0;
+ int bench_warmup_ = 5;
+ float bench_yaw_start_ = 0.0f;
+ float bench_yaw_speed_ = 0.5f; // degrees per frame
+ std::vector bench_frame_ms_;
+
+ // Cold-load warmup gate counters. The orbit sweep waits until
+ // streaming has converged for CONVERGE_FRAMES_REQUIRED consecutive
+ // frames before starting the sample collection.
+ int bench_warm_streak_ = 0;
+ int bench_warm_frames_total_ = 0;
+ bool bench_warm_done_ = false;
+
+ // Per-frame timing accumulators for the bench summary. Each
+ // accumulator is divided by bench_total_ when the run finishes.
+ double bench_cull_ms_total_ = 0.0;
+ double bench_stream_ms_total_ = 0.0;
+ double bench_hiz_readback_ms_total_ = 0.0;
+
+ // Interactive [frame] heartbeat counter. Used to rate-limit the
+ // stream-health summary + WGPU_STREAM_DEEP_DEBUG dump.
+ int interactive_frame_count_ = 0;
+
// Auto-viewAll suppression. Flipped true by the first applyCachedModel
// (so a fresh scene frames itself) or by any explicit setCamera (so a
// user/bonsai-side camera write isn't overridden by the next model
diff --git a/src/ifcviewer/ViewportHost.h b/src/ifcviewer/ViewportHost.h
index 97db91e853..8a82ac7588 100644
--- a/src/ifcviewer/ViewportHost.h
+++ b/src/ifcviewer/ViewportHost.h
@@ -47,6 +47,9 @@
#include
#include
+#include "FrameStats.h"
+#include "OverlayFrame.h"
+
class ViewportHost {
public:
virtual ~ViewportHost() = default;
@@ -91,12 +94,23 @@ public:
int /*modifiers*/) {}
virtual void onToolModeChanged(int /*tool_mode*/) {}
virtual void onToolBackspacePressed() {}
- // FrameStats is a Qt-using struct declared inside ViewportWindow
- // today; once the move to ViewportCore happens it'll come back here
- // as a plain POD. For B4 the desktop ViewportWindow keeps the
- // existing typed signal and this is just the placeholder.
- virtual void onFrameStats(double /*frame_ms*/, double /*cull_ms*/,
- uint32_t /*draws*/, uint32_t /*tris*/) {}
+
+ // Per-frame statistics. Fired once per render() after the main pass
+ // is encoded; QtViewportHost forwards to `emit frameStatsUpdated(...)`.
+ virtual void onFrameStats(const FrameStats& /*stats*/) {}
+
+ // Overlay encode hooks. ViewportCore::render() calls these mid-
+ // frame so the Qt-bound OverlayRenderer (which carries QString
+ // labels for the HUD) can encode its passes without core having
+ // to include OverlayRenderer.h. `inMainPass` runs inside the MSAA
+ // pass (section gizmos, highlight triangles, pivot, overlay
+ // lines/points); `postMain` runs on the resolved surface after
+ // the edge pass (corner axis, marquee, labels).
+ virtual void encodeOverlaysInMainPass(WGPURenderPassEncoder /*pass*/,
+ const OverlayFrame& /*frame*/) {}
+ virtual void encodeOverlaysPostMain(WGPUCommandEncoder /*enc*/,
+ WGPUTextureView /*surface_view*/,
+ const OverlayFrame& /*frame*/) {}
// Encode + save the screenshot. Called from the render path after
// wgpu has mapped the surface-copy staging buffer back to host
diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp
index 575d168d1b..4e8264b72a 100644
--- a/src/ifcviewer/ViewportWindow.cpp
+++ b/src/ifcviewer/ViewportWindow.cpp
@@ -252,7 +252,37 @@ ViewportWindow::ViewportWindow(QWindow* parent)
lod0_dbg_eligible_count_(core_.lod0_dbg_eligible_count_),
lod0_dbg_no_lod1_count_ (core_.lod0_dbg_no_lod1_count_),
lod1_dbg_tris_saved_ (core_.lod1_dbg_tris_saved_),
- initial_view_applied_ (core_.initial_view_applied_) {
+ initial_view_applied_ (core_.initial_view_applied_),
+ min_pixel_radius_ (core_.min_pixel_radius_),
+ motion_min_pixel_radius_ (core_.motion_min_pixel_radius_),
+ lod1_pixel_threshold_ (core_.lod1_pixel_threshold_),
+ cull_threads_enabled_ (core_.cull_threads_enabled_),
+ prev_camera_target_ (core_.prev_camera_target_),
+ prev_camera_distance_ (core_.prev_camera_distance_),
+ prev_camera_yaw_deg_ (core_.prev_camera_yaw_deg_),
+ prev_camera_pitch_deg_ (core_.prev_camera_pitch_deg_),
+ has_prev_camera_ (core_.has_prev_camera_),
+ last_cull_was_motion_ (core_.last_cull_was_motion_),
+ bench_warm_streak_ (core_.bench_warm_streak_),
+ bench_warm_frames_total_ (core_.bench_warm_frames_total_),
+ bench_warm_done_ (core_.bench_warm_done_),
+ bench_total_ (core_.bench_total_),
+ bench_count_ (core_.bench_count_),
+ bench_warmup_ (core_.bench_warmup_),
+ bench_yaw_start_ (core_.bench_yaw_start_),
+ bench_yaw_speed_ (core_.bench_yaw_speed_),
+ bench_frame_ms_ (core_.bench_frame_ms_),
+ last_visible_objects_ (core_.last_visible_objects_),
+ last_visible_triangles_ (core_.last_visible_triangles_),
+ last_sub_draws_ (core_.last_sub_draws_),
+ bench_cull_ms_total_ (core_.bench_cull_ms_total_),
+ bench_stream_ms_total_ (core_.bench_stream_ms_total_),
+ bench_hiz_readback_ms_total_(core_.bench_hiz_readback_ms_total_),
+ last_cull_ms_ (core_.last_cull_ms_),
+ last_cull_compute_ms_ (core_.last_cull_compute_ms_),
+ last_cull_upload_ms_ (core_.last_cull_upload_ms_),
+ last_stream_ms_ (core_.last_stream_ms_),
+ interactive_frame_count_ (core_.interactive_frame_count_) {
// wgpu doesn't need a GL context; we just need a real native window
// whose backing layer matches the GPU API wgpu will drive.
//
@@ -391,6 +421,34 @@ void ViewportWindow::onToolBackspacePressed() {
emit toolBackspacePressed();
}
+void ViewportWindow::onFrameStats(const FrameStats& stats) {
+ emit frameStatsUpdated(stats);
+}
+
+void ViewportWindow::encodeOverlaysInMainPass(WGPURenderPassEncoder pass,
+ const OverlayFrame& frame) {
+ // Section gizmos, highlight triangles, pivot, overlay lines / points
+ // — drawn inside the MSAA pass so depth-test correctly hides them
+ // behind closer geometry. (Corner axis / marquee / labels run on the
+ // resolved surface; see encodeOverlaysPostMain.)
+ overlays_.encodeSectionGizmos(pass, frame, section_planes_);
+ overlays_.encodeHighlightTriangles(pass, frame);
+ overlays_.encodePivot(pass, frame, pivot_indicator_visible_);
+ overlays_.encodeOverlayLines(pass, frame);
+ overlays_.encodeOverlayPoints(pass, frame);
+}
+
+void ViewportWindow::encodeOverlaysPostMain(WGPUCommandEncoder enc,
+ WGPUTextureView surface_view,
+ const OverlayFrame& frame) {
+ overlays_.encodeCornerAxis(enc, surface_view, frame);
+ overlays_.encodeMarquee(enc, surface_view, frame,
+ box_select_start_pos_,
+ box_select_current_pos_,
+ box_select_active_);
+ overlays_.encodeLabels(enc, surface_view, frame);
+}
+
void ViewportWindow::saveScreenshotRgba8(const std::string& path,
const std::uint8_t* rgba,
int w, int h) {
@@ -1392,960 +1450,12 @@ void ViewportWindow::setBenchmarkFrames(int frames) {
// cullModelCpuUpload moved to ViewportCore (#84-p).
void ViewportWindow::render() {
- // Time the whole render() body (cull + encode + present) for the
- // benchmark stats. Started before any wgpu work so cull is included.
- Stopwatch frame_timer;
- frame_timer.start();
-
- // Advance fly-mode camera by wall-clock dt since the last frame so the
- // frame we're about to render already reflects the move. Driving this
- // from render() (rather than a QTimer) means a long frame costs one
- // missed step, not a backlog.
+ // The Qt-side prelude that has to run before each frame: fpsIntegrate
+ // (fly-mode WASD camera step) and the isExposed() guard. After that,
+ // the wgpu work is all in core_.render().
+ if (!isExposed()) return;
fpsIntegrate();
-
- // Drain any HiZ async readbacks that completed since last frame so the
- // pyramid is as fresh as it can be before cull runs.
- if (hiz_enabled_) core_.drainHizReadbacks();
-
- // Flush any pending selection changes to GPU.
- uploadSelectionFlagsIfDirty();
-
- WGPUSurfaceTexture surf_tex = {};
- wgpuSurfaceGetCurrentTexture(surface_, &surf_tex);
-
- switch (surf_tex.status) {
- case WGPUSurfaceGetCurrentTextureStatus_SuccessOptimal:
- case WGPUSurfaceGetCurrentTextureStatus_SuccessSuboptimal:
- break; // proceed
- case WGPUSurfaceGetCurrentTextureStatus_Timeout:
- case WGPUSurfaceGetCurrentTextureStatus_Outdated:
- case WGPUSurfaceGetCurrentTextureStatus_Lost: {
- // Reconfigure and try again next frame.
- const int w = int(width() * devicePixelRatio());
- const int h = int(height() * devicePixelRatio());
- if (w > 0 && h > 0) core_.configureSurface(w, h);
- requestUpdate();
- return;
- }
- default:
- Log::warn() << "GetCurrentTexture status" << int(surf_tex.status);
- return;
- }
-
- WGPUTextureView view = wgpuTextureCreateView(surf_tex.texture, nullptr);
-
- core_.updateFrameUniforms();
-
- // Per-frame cull: extract frustum planes from the same VP we just wrote
- // into the uniform, then run cullModelCpu on every visible model. The
- // cull writes its results directly into each model's visible_buffer via
- // wgpuQueueWriteBuffer — these writes are sequenced before the draw
- // commands we encode next.
- last_visible_objects_ = 0;
- last_visible_triangles_ = 0;
- last_sub_draws_ = 0;
- hiz_reject_count_ = 0;
- Stopwatch cull_timer;
- cull_timer.start();
- Eigen::Matrix4f vp_this_frame;
- {
- const Eigen::Vector3f target(camera_target_[0], camera_target_[1], camera_target_[2]);
- const Eigen::Vector3f eye = orbitEye(camera_target_, camera_distance_,
- camera_yaw_deg_, camera_pitch_deg_);
- Eigen::Matrix4f v, p;
- core_.buildViewProj(v, p);
- const Eigen::Matrix4f vp = p * v;
- vp_this_frame = vp;
- float planes[6][4];
- extractFrustumPlanes(vp.data(), planes);
-
- // LOD pick inputs: world-space eye, unit forward, vertical focal in
- // pixels. focal_px maps view-space depth to projected radius:
- // projected_px = world_radius * focal_px / view_z.
- const Eigen::Vector3f fwd_q = (target - eye).normalized();
- // World-up convention: Z-up. Near the poles lookAt degenerates,
- // so swap to Y-up — mirrors buildViewProj's pitch gate at line
- // 4701 so cull's camera basis matches the actual view matrix.
- const Eigen::Vector3f world_up = (std::abs(camera_pitch_deg_) >= 89.0f)
- ? Eigen::Vector3f(0.0f, 1.0f, 0.0f)
- : Eigen::Vector3f(0.0f, 0.0f, 1.0f);
- const Eigen::Vector3f right_q = fwd_q.cross(world_up).normalized();
- const Eigen::Vector3f up_q = right_q.cross(fwd_q).normalized();
- const float eye_a[3] = { eye.x(), eye.y(), eye.z() };
- const float fwd_a[3] = { fwd_q.x(), fwd_q.y(), fwd_q.z() };
- const float right_a[3] = { right_q.x(), right_q.y(), right_q.z() };
- const float up_a[3] = { up_q.x(), up_q.y(), up_q.z() };
- const float focal_px = (configured_h_ > 0)
- ? (0.5f * float(configured_h_)
- / std::tan(qDegreesToRadians(camera_fov_y_deg_) * 0.5f))
- : 0.0f;
-
- // Motion detection: any change in camera state since last frame
- // bumps the contribution threshold to motion_min_pixel_radius_
- // (mirrors GL's NavPreset behaviour, drops more sub-pixel work
- // during orbit/pan/zoom).
- const bool camera_moved = has_prev_camera_
- && (camera_target_[0] != prev_camera_target_[0]
- || camera_target_[1] != prev_camera_target_[1]
- || camera_target_[2] != prev_camera_target_[2]
- || camera_distance_ != prev_camera_distance_
- || camera_yaw_deg_ != prev_camera_yaw_deg_
- || camera_pitch_deg_ != prev_camera_pitch_deg_);
- const bool use_motion_threshold =
- camera_moved && motion_min_pixel_radius_ > min_pixel_radius_;
- const float effective_min_px =
- use_motion_threshold ? motion_min_pixel_radius_ : min_pixel_radius_;
- last_cull_was_motion_ = use_motion_threshold;
-
- // HiZ stale-VP gate. The depth pyramid is async — the pyramid
- // resident in hiz_pyramid_ was captured one or more frames ago
- // at hiz_vp_. If the current VP differs, AABBs project through
- // a stale matrix to wrong screen-space positions and sample
- // depth captured for what was at THOSE positions in the old
- // view — incorrect rejections. Strict by default: HiZ on only
- // when current VP exactly matches the pyramid's. WGPU_HIZ_MOTION=1
- // trusts the stale pyramid across motion (matches GL's default
- // behaviour; the env var name mirrors GL's IFC_HIZ_MOTION knob
- // but the wgpu default is inverted toward strictness).
- static const bool hiz_trust_stale = []{
- const char* e = std::getenv("WGPU_HIZ_MOTION");
- return e && e[0] == '1';
- }();
- const bool hiz_vp_matches = hiz_valid_
- && (hiz_trust_stale || hiz_vp_ == vp_this_frame);
- const bool hiz_for_this_frame = hiz_enabled_ && hiz_vp_matches;
-
- // WGPU_HIZ_TRACE: arm rejection logging when HiZ is about to
- // fire post-settle. Reports per-frame budget, dumps a snapshot
- // of the pyramid's bottom rows (the band the post-stop bug
- // manifests in), and the per-rejection details land via the
- // hiz_trace_budget_ atomic checked inside aabbOccludedByHiz.
- static const bool hiz_trace_on = []{
- const char* e = std::getenv("WGPU_HIZ_TRACE");
- return e && e[0] == '1';
- }();
- if (hiz_trace_on && hiz_for_this_frame) {
- constexpr int kHizTracePerFrame = 12;
- hiz_trace_budget_.store(kHizTracePerFrame, std::memory_order_relaxed);
- // One-shot per-frame log so the user can correlate rejections
- // with what they were looking at.
- Log::info().noquote().nospace()
- << "[hiz trace] frame: vp_match="
- << (hiz_vp_ == vp_this_frame ? "exact" : "loose")
- << " pyramid_mip0=" << hiz_mip_w_[0] << "x" << hiz_mip_h_[0]
- << " budget=" << kHizTracePerFrame;
- // Dump the bottom 3 rows of mip 0, evenly sampled across width.
- // If the bug is "pyramid bottom rows hold near-zero depth"
- // these values will be visibly small.
- const uint32_t W0 = hiz_mip_w_[0];
- const uint32_t H0 = hiz_mip_h_[0];
- const float* L0 = &hiz_pyramid_[hiz_mip_offset_[0]];
- for (int dy = 2; dy >= 0; --dy) {
- const uint32_t y = H0 - 1 - uint32_t(dy);
- QString row;
- for (int s = 0; s < 8; ++s) {
- const uint32_t x = (s * (W0 - 1)) / 7;
- row += QString::asprintf("%.4f ", L0[y * W0 + x]);
- }
- Log::info().noquote().nospace()
- << "[hiz trace] pyramid row " << y << " (8 samples): " << row;
- }
- } else if (hiz_trace_on) {
- hiz_trace_budget_.store(0, std::memory_order_relaxed);
- }
-
- // HiZ occlusion callback. Null when HiZ is disabled or its VP is
- // stale; otherwise wraps aabbOccludedByHiz (still VW-side because
- // the HiZ pyramid + readback orchestration hasn't migrated yet).
- // The pyramid's reads are atomic-friendly, so the parallel cull
- // workers can share this callback safely.
- ViewportCore::HizOccludedFn hiz_occluded;
- if (hiz_for_this_frame) {
- hiz_occluded = [this](const float mn[3], const float mx[3]) {
- return core_.aabbOccludedByHiz(mn, mx);
- };
- }
-
- // Cull each model on its own worker thread. wgpu queue writes are
- // serialised on the main thread after the parallel compute joins —
- // wgpu-native doesn't guarantee thread-safety on queue ops.
- // WGPU_CULL_THREADS=0 forces the sequential path for measurement.
- if (cull_threads_enabled_) {
- std::vector>> futures;
- futures.reserve(models_gpu_.size());
- for (auto& [mid, m] : models_gpu_) {
- if (m.hidden) continue;
- auto& m_ref = m;
- futures.emplace_back(mid, std::async(std::launch::async,
- [this, &m_ref, &planes, &eye_a, &fwd_a, &right_a, &up_a,
- focal_px, effective_min_px, &hiz_occluded]() {
- return core_.cullModelCpuCompute(
- m_ref, planes, eye_a, fwd_a, right_a, up_a,
- focal_px,
- effective_min_px, lod1_pixel_threshold_,
- hiz_occluded);
- }));
- }
- for (auto& [mid, fut] : futures) {
- hiz_reject_count_ += fut.get();
- }
- } else {
- for (auto& [mid, m] : models_gpu_) {
- if (m.hidden) continue;
- hiz_reject_count_ += core_.cullModelCpuCompute(
- m, planes, eye_a, fwd_a, right_a, up_a, focal_px,
- effective_min_px, lod1_pixel_threshold_,
- hiz_occluded);
- }
- }
-
- // Split timer: how much of the "cull" cost is the upload phase
- // (sequential queueWriteBuffer × 3 per resident chunk × ~120
- // chunks ≈ 360 wgpu calls/frame). If upload >> compute the parallel
- // cull is doing its job and the bottleneck is somewhere else.
- const double cull_compute_ms = double(cull_timer.nsecsElapsed()) / 1e6;
- Stopwatch upload_timer;
- upload_timer.start();
- for (auto& [mid, m] : models_gpu_) {
- if (m.hidden) continue;
- core_.cullModelCpuUpload(m);
- for (const auto& c : m.chunks) {
- last_visible_objects_ += c.total_visible_draws;
- last_visible_triangles_ += c.total_visible_vertices / 3u;
- // One CPU drawcall per non-empty chunk.
- if (c.total_visible_draws > 0) last_sub_draws_ += 1;
- }
- }
- last_cull_compute_ms_ = cull_compute_ms;
- last_cull_upload_ms_ = double(upload_timer.nsecsElapsed()) / 1e6;
- }
-
- // Stop the cull-only timer before streaming, so the benchmark
- // attribution doesn't lump disk I/O into "cull".
- const double cull_only_ms = double(cull_timer.nsecsElapsed()) / 1e6;
- last_cull_ms_ = cull_only_ms;
-
- // Streaming: bring non-resident chunks that the cull just flagged
- // visible into residency. Runs before draw encoding so newly-loaded
- // chunks render the same frame. Timed separately because synchronous
- // disk reads here can dwarf the cull itself on big scenes.
- Stopwatch stream_timer;
- stream_timer.start();
- driveStreamingLoads();
- const double stream_ms = double(stream_timer.nsecsElapsed()) / 1e6;
- last_stream_ms_ = stream_ms;
-
- // Snapshot camera state for next frame's motion detection.
- prev_camera_target_[0] = camera_target_[0];
- prev_camera_target_[1] = camera_target_[1];
- prev_camera_target_[2] = camera_target_[2];
- prev_camera_distance_ = camera_distance_;
- prev_camera_yaw_deg_ = camera_yaw_deg_;
- prev_camera_pitch_deg_ = camera_pitch_deg_;
- has_prev_camera_ = true;
- if (bench_total_ > 0 && bench_count_ >= bench_warmup_) {
- bench_cull_ms_total_ += cull_only_ms;
- bench_stream_ms_total_ += stream_ms;
- }
-
- WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(device_, nullptr);
-
- WGPURenderPassColorAttachment color = {};
- color.view = msaa_color_view_; // render into 4× MSAA target
- color.resolveTarget = view; // resolve to surface texture
- color.loadOp = WGPULoadOp_Clear;
- color.storeOp = WGPUStoreOp_Store;
- color.clearValue = {
- srgbToLinear(background_color_[0]),
- srgbToLinear(background_color_[1]),
- srgbToLinear(background_color_[2]),
- 1.0,
- };
- color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
-
- WGPURenderPassDepthStencilAttachment depth = {};
- depth.view = depth_view_;
- depth.depthLoadOp = WGPULoadOp_Clear;
- depth.depthStoreOp = WGPUStoreOp_Store;
- depth.depthClearValue = 1.0f;
- depth.stencilLoadOp = WGPULoadOp_Undefined;
- depth.stencilStoreOp = WGPUStoreOp_Undefined;
- depth.depthReadOnly = false;
- depth.stencilReadOnly = true;
-
- WGPURenderPassDescriptor pass_desc = {};
- pass_desc.colorAttachmentCount = 1;
- pass_desc.colorAttachments = &color;
- pass_desc.depthStencilAttachment = depth_view_ ? &depth : nullptr;
-
- WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
-
- // Two-pass main render: opaque first (depth write on, no blend), then
- // transparent (depth write off, alpha blend on). Each chunk's
- // visible_draws_scratch is laid out as [opaque][transparent]; the
- // draw calls slice into the same shared buffer via firstVertex +
- // vertexCount. Skip a half when it's empty.
- if (main_pipeline_ && main_pipeline_transparent_
- && frame_bind_group_ && !models_gpu_.empty()) {
- // ---- Opaque pass ------------------------------------------------
- wgpuRenderPassEncoderSetPipeline(pass, main_pipeline_);
- wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr);
-
- for (const auto& [mid, m] : models_gpu_) {
- if (m.hidden) continue;
- for (const auto& c : m.chunks) {
- if (!c.bind_group || c.opaque_visible_vertices == 0) continue;
- wgpuRenderPassEncoderSetBindGroup(pass, 1, c.bind_group, 0, nullptr);
- wgpuRenderPassEncoderDraw(pass,
- c.opaque_visible_vertices,
- 1, 0, 0);
- }
- }
-
- // ---- Transparent pass ------------------------------------------
- // Same bind groups, different pipeline. Each chunk's transparent
- // range starts at firstVertex = opaque_visible_vertices and runs
- // for (total - opaque) vertices.
- wgpuRenderPassEncoderSetPipeline(pass, main_pipeline_transparent_);
- // Frame bind group is already set; bind group 0 layout is identical.
-
- for (const auto& [mid, m] : models_gpu_) {
- if (m.hidden) continue;
- for (const auto& c : m.chunks) {
- if (!c.bind_group) continue;
- const uint32_t transparent_verts =
- c.total_visible_vertices - c.opaque_visible_vertices;
- if (transparent_verts == 0) continue;
- wgpuRenderPassEncoderSetBindGroup(pass, 1, c.bind_group, 0, nullptr);
- wgpuRenderPassEncoderDraw(pass,
- transparent_verts, 1,
- c.opaque_visible_vertices, 0);
- }
- }
- }
-
- // Snapshot the per-frame inputs every overlay needs. Built once and
- // passed by const-ref so OverlayRenderer never reaches back into
- // this viewport.
- OverlayFrame overlay_frame;
- overlay_frame.view_proj = vp_this_frame;
- overlay_frame.camera_target = Eigen::Vector3f(camera_target_[0],
- camera_target_[1],
- camera_target_[2]);
- overlay_frame.camera_distance = camera_distance_;
- overlay_frame.camera_yaw_deg = camera_yaw_deg_;
- overlay_frame.camera_pitch_deg = camera_pitch_deg_;
- overlay_frame.camera_fov_y_deg = camera_fov_y_deg_;
- overlay_frame.viewport_w_px = int(width() * devicePixelRatio());
- overlay_frame.viewport_h_px = int(height() * devicePixelRatio());
- overlay_frame.device_pixel_ratio = int(devicePixelRatio());
-
- // Section planes — translucent overlay quads showing where each
- // active clip plane cuts. Drawn inside the main MSAA pass.
- overlays_.encodeSectionGizmos(pass, overlay_frame, section_planes_);
-
- // Highlight triangles (Area-tool patch shading). Drawn inside the
- // main MSAA pass so depth-test correctly hides patches behind closer
- // geometry; depth-write off so the corner gizmo / labels still render
- // on top.
- overlays_.encodeHighlightTriangles(pass, overlay_frame);
-
- // Pivot indicator. Encoded inside the main MSAA pass after geometry so
- // depth interaction is correct — the indicator vanishes behind closer
- // surfaces. Visibility is driven by orbit/wheel UI handlers.
- overlays_.encodePivot(pass, overlay_frame, pivot_indicator_visible_);
-
- // Overlay line groups (measurement / dimension annotation lines).
- // Depth-tested against geometry so they hide behind closer surfaces;
- // depth-write off so the corner gizmo + marquee can still draw over
- // them on the resolved surface afterwards.
- overlays_.encodeOverlayLines(pass, overlay_frame);
-
- // Overlay point sprites (measurement endpoints, snap candidates).
- // Drawn after lines so the sprite halo correctly covers any line
- // ends at the same world position.
- overlays_.encodeOverlayPoints(pass, overlay_frame);
-
- wgpuRenderPassEncoderEnd(pass);
- wgpuRenderPassEncoderRelease(pass);
-
- // ---- Edge silhouette post-process — reads MSAA depth, blends dark
- // lines onto the resolved surface colour. Encoded before HiZ resolve
- // so HiZ uses the same MSAA depth that produced the edges.
- if (edges_enabled_) {
- core_.encodeEdgePass(enc, view);
- }
-
- // Corner axis gizmo. Encoded after the edge pass on the resolved
- // surface, so the laplacian can't darken its lines or its background.
- overlays_.encodeCornerAxis(enc, view, overlay_frame);
-
- // Marquee box-select drag rect (visible only while a drag is active).
- // Drawn on the resolved surface so the rect outline isn't affected by
- // the edge silhouette pass.
- overlays_.encodeMarquee(enc, view, overlay_frame,
- box_select_start_pos_,
- box_select_current_pos_,
- box_select_active_);
-
- // Labels + HUD text. Drawn last so they stack on top of every other
- // overlay (no depth test, alpha-blended on the resolved surface).
- overlays_.encodeLabels(enc, view, overlay_frame);
-
- // ---- HiZ: resolve MSAA depth → small single-sample → ping-pong slot
- int hiz_submitted_slot = -1;
- if (hiz_enabled_) {
- hiz_submitted_slot = core_.encodeHizResolve(enc);
- }
-
- // ---- Optional capture: encode copy on the same command buffer -------
- WGPUBuffer capture_buffer = nullptr;
- uint32_t capture_padded_bpr = 0;
- const bool want_capture = !pending_screenshot_path_.empty();
- if (want_capture) {
- capture_buffer = core_.encodeScreenshotCapture(
- enc, surf_tex.texture, capture_padded_bpr);
- }
-
- WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(enc, nullptr);
- wgpuQueueSubmit(queue_, 1, &cmd);
-
- wgpuCommandBufferRelease(cmd);
- wgpuCommandEncoderRelease(enc);
- wgpuTextureViewRelease(view);
-
- // Map the staging buffer back, BGRA→RGBA swap, hand to the host's
- // saveScreenshotRgba8 (QImage::save on desktop, stb_image_write on web).
- if (want_capture) {
- core_.finalizeScreenshotCapture(capture_buffer, capture_padded_bpr);
- }
-
- // Emit per-frame stats before present so external listeners (bonsai's
- // status bar) see fresh numbers in the same UI tick. fps is a
- // rolling 60-sample average; the first window after startup is
- // computed against the partial sample count so the readout settles
- // immediately rather than starting at 0.
- {
- const double this_frame_ms =
- double(frame_timer.nsecsElapsed()) / 1e6;
- frame_time_ms_sum_ -= frame_time_ms_window_[frame_time_ms_head_];
- frame_time_ms_window_[frame_time_ms_head_] = this_frame_ms;
- frame_time_ms_sum_ += this_frame_ms;
- frame_time_ms_head_ = (frame_time_ms_head_ + 1) % FRAME_TIME_WINDOW;
- if (frame_time_ms_count_ < FRAME_TIME_WINDOW) ++frame_time_ms_count_;
- const double avg_ms = frame_time_ms_count_ > 0
- ? frame_time_ms_sum_ / double(frame_time_ms_count_)
- : 0.0;
-
- uint32_t total_obj = 0, total_tri = 0, total_meshes = 0;
- for (const auto& [mid, mm] : models_gpu_) {
- total_obj += uint32_t(mm.instances.size());
- total_tri += mm.index_count / 3;
- total_meshes += uint32_t(mm.meshes.size());
- }
-
- FrameStats stats;
- stats.fps = avg_ms > 0.0 ? float(1000.0 / avg_ms) : 0.0f;
- stats.frame_time_ms = float(avg_ms);
- stats.total_objects = total_obj;
- stats.visible_objects = last_visible_objects_;
- stats.total_triangles = total_tri;
- stats.visible_triangles = last_visible_triangles_;
- stats.unique_meshes = total_meshes;
- // Wgpu does one indirect dispatch per resident chunk; mirror that
- // into the GL-named field bonsai's status string consumes.
- uint32_t draw_calls = 0;
- for (const auto& [mid, mm] : models_gpu_) {
- if (mm.hidden) continue;
- for (const auto& c : mm.chunks) {
- if (c.is_resident && c.total_visible_draws > 0) ++draw_calls;
- }
- }
- stats.gl_draw_calls = draw_calls;
- stats.indirect_sub_draws = last_sub_draws_;
- emit frameStatsUpdated(stats);
- }
-
- wgpuSurfacePresent(surface_);
- wgpuTextureRelease(surf_tex.texture);
-
- // Settle frame: if this frame applied the motion contribution threshold,
- // schedule one more frame so the camera-now-stopped state recomputes
- // the cull at the still threshold and the previously dropped sub-pixel
- // instances pop back in. Matches GL's behaviour.
- if (last_cull_was_motion_) requestUpdate();
-
- // ---- HiZ async readback handoff -------------------------------------
- // Don't block — just kick off the mapAsync for the slot we filled this
- // frame. Drainage happens at the top of the *next* frame via
- // core_.drainHizReadbacks(), giving the GPU at least one frame of headroom.
- if (hiz_enabled_ && hiz_submitted_slot >= 0) {
- Stopwatch hiz_timer;
- if (bench_total_ > 0) hiz_timer.start();
- core_.startHizMap(hiz_submitted_slot, vp_this_frame);
- if (bench_total_ > 0 && bench_count_ >= bench_warmup_) {
- bench_hiz_readback_ms_total_ += double(hiz_timer.nsecsElapsed()) / 1e6;
- }
- }
-
- // ---- Interactive heartbeat log -------------------------------------
- // Prints a per-frame stats line every 30 frames when not in
- // benchmark mode, so the user can diagnose performance and
- // visibility issues at runtime without firing up --benchmark.
- // Includes "missing" (chunks the cull marked frustum-visible but
- // are not resident this frame) — that's the diagnostic for "things
- // I expected to see aren't showing up." Healthy steady state has
- // missing == 0; pool-bound scenes will show missing > 0 for the
- // chunks that don't fit.
- if (bench_total_ == 0) {
- ++interactive_frame_count_;
- // Log every render (frames in interactive mode only fire on
- // actual activity — camera motion, model load, streaming loads
- // in flight — so this is naturally rate-limited and shows the
- // user what's happening as they interact).
- {
- const float ms = float(frame_timer.nsecsElapsed()) / 1e6f;
- uint64_t total_vbo = 0, total_ebo = 0, total_ssbo = 0;
- uint32_t total_instances = 0, total_meshes = 0;
- size_t chunks_total = 0, chunks_resident = 0;
- size_t chunks_frustum_vis = 0, chunks_missing = 0;
- for (const auto& [mid, mo] : models_gpu_) {
- total_vbo += mo.vram_bytes_vbo;
- total_ebo += mo.vram_bytes_ebo;
- total_ssbo += mo.vram_bytes_ssbo;
- total_instances += mo.instance_count;
- total_meshes += mo.mesh_count;
- for (const auto& c : mo.chunks) {
- ++chunks_total;
- if (c.is_resident) ++chunks_resident;
- if (c.frustum_visible_count > 0) {
- ++chunks_frustum_vis;
- if (!c.is_resident) ++chunks_missing;
- }
- }
- }
- const double mb = 1.0 / (1024.0 * 1024.0);
- Log::info().noquote().nospace()
- << "[frame] " << QString::number(ms > 0 ? 1000.0f / ms : 0.0f, 'f', 1) << " fps"
- << " " << QString::number(ms, 'f', 2) << " ms"
- << " obj " << last_visible_objects_ << "/" << total_instances
- << " tri " << last_visible_triangles_
- << " sub_draws " << last_sub_draws_
- << " hiz_rej " << hiz_reject_count_
- << " cull " << QString::number(last_cull_ms_, 'f', 2) << "ms"
- << " stream " << QString::number(last_stream_ms_, 'f', 2) << "ms"
- << " chunks " << chunks_resident << "/" << chunks_frustum_vis
- << "/" << chunks_total << " (missing " << chunks_missing << ")"
- << " vram " << QString::number(double(total_vbo + total_ebo + total_ssbo) * mb, 'f', 1) << "MB"
- << " models " << models_gpu_.size()
- << " lod1 " << lod1_dbg_count_ << "/" << (lod1_dbg_count_ + lod0_dbg_eligible_count_)
- << " (saved " << lod1_dbg_tris_saved_ << " tris, "
- << lod0_dbg_no_lod1_count_ << " no-lod1)";
- lod1_dbg_count_ = 0;
- lod0_dbg_eligible_count_ = 0;
- lod0_dbg_no_lod1_count_ = 0;
- lod1_dbg_tris_saved_ = 0;
-
- // Lightweight stream-health summary, every ~5s (300 frames at
- // 60 fps / 5s at 60), only when there's something missing AND
- // something cycling. Single line — no multi-line spew. Tells
- // the user "working set > pool, this many chunks thrashing"
- // without the deep-dump volume.
- if (chunks_missing > 0
- && (interactive_frame_count_ % 300) == 0) {
- size_t cycled = 0;
- uint32_t max_load = 0;
- for (const auto& [mid, mo] : models_gpu_) {
- for (const auto& c : mo.chunks) {
- if (c.load_count > 1) ++cycled;
- if (c.load_count > max_load) max_load = c.load_count;
- }
- }
- if (cycled > 0 || max_load > 1) {
- const char* diag = (cycled > 10)
- ? "thrashing — working set > pool"
- : (max_load > 5)
- ? "few chunks cycling (hysteresis boundary)"
- : "loading";
- Log::info().noquote().nospace()
- << "[stream] " << chunks_resident << " resident, "
- << chunks_missing << " missing, " << cycled
- << " cycled (max load=" << max_load << ")"
- << " — " << diag;
- }
- }
- // Verbose investigation dump — top-8 models by missing-count,
- // top 20 missing chunks by priority, bottom 5 residents by
- // effective priority, every chunk of a tracked model. Volume
- // is too high for steady-state console; gated behind
- // WGPU_STREAM_DEEP_DEBUG so it stays available when something
- // needs investigating but doesn't drown the normal log.
- if (chunks_missing > 0
- && std::getenv("WGPU_STREAM_DEEP_DEBUG") != nullptr
- && (interactive_frame_count_ % 120) == 0) {
- // Build the camera VP matrix and project AABB corners
- // — same metric driveStreamingLoads uses for priority,
- // duplicated here so the heartbeat dump can show what
- // the loader is actually scoring chunks at.
- Eigen::Matrix4f v_dbg, p_dbg;
- core_.buildViewProj(v_dbg, p_dbg);
- const Eigen::Matrix4f vp_dbg = p_dbg * v_dbg;
- auto chunk_priority_px2 = [&](const ModelGpuData::Chunk& c) -> float {
- if (configured_w_ <= 0 || configured_h_ <= 0 ||
- c.aabb_min[0] > c.aabb_max[0]) return 0.0f;
- float xmin = std::numeric_limits::infinity();
- float ymin = std::numeric_limits::infinity();
- float xmax = -std::numeric_limits::infinity();
- float ymax = -std::numeric_limits::infinity();
- int cif = 0;
- for (int i = 0; i < 8; ++i) {
- const Eigen::Vector4f corner(
- (i & 1) ? c.aabb_max[0] : c.aabb_min[0],
- (i & 2) ? c.aabb_max[1] : c.aabb_min[1],
- (i & 4) ? c.aabb_max[2] : c.aabb_min[2],
- 1.0f);
- const Eigen::Vector4f clip = vp_dbg * corner;
- if (clip.w() <= 1e-3f) continue;
- ++cif;
- const float px_x = (clip.x() / clip.w() * 0.5f + 0.5f) * float(configured_w_);
- const float px_y = (clip.y() / clip.w() * 0.5f + 0.5f) * float(configured_h_);
- xmin = std::min(xmin, px_x); ymin = std::min(ymin, px_y);
- xmax = std::max(xmax, px_x); ymax = std::max(ymax, px_y);
- }
- if (cif == 0) return 0.0f;
- xmin = std::max(xmin, 0.0f); ymin = std::max(ymin, 0.0f);
- xmax = std::min(xmax, float(configured_w_));
- ymax = std::min(ymax, float(configured_h_));
- if (xmax <= xmin || ymax <= ymin) return 0.0f;
- return (xmax - xmin) * (ymax - ymin);
- };
- struct Probe {
- QString name;
- float priority;
- float ex, ey, ez;
- float history;
- };
- std::vector missing_set, resident_set;
- missing_set.reserve(64);
- resident_set.reserve(256);
- for (const auto& [mid, mo] : models_gpu_) {
- QFileInfo fi(QString::fromStdString(mo.streaming_file_path));
- const QString base = fi.completeBaseName();
- for (const auto& c : mo.chunks) {
- Probe p;
- p.name = base;
- p.priority = chunk_priority_px2(c);
- p.ex = c.aabb_max[0] - c.aabb_min[0];
- p.ey = c.aabb_max[1] - c.aabb_min[1];
- p.ez = c.aabb_max[2] - c.aabb_min[2];
- p.history = c.visibility_history;
- if (c.is_resident) {
- resident_set.push_back(p);
- } else if (c.frustum_visible_count > 0) {
- missing_set.push_back(p);
- }
- }
- }
- // Top 20 missing by priority. 20 (not 5) because the
- // chunks the user actually cares about — e.g. brace
- // model chunks — may be ranked below the absolute top
- // but well above the bottom residents. We need to see
- // them to evaluate whether the metric is right.
- std::partial_sort(missing_set.begin(),
- missing_set.begin() + std::min(20, missing_set.size()),
- missing_set.end(),
- [](const Probe& a, const Probe& b) {
- return a.priority > b.priority;
- });
- // Bottom 5 residents by EFFECTIVE priority (× history) —
- // these are the chunks a candidate would need to beat
- // to swap in.
- std::partial_sort(resident_set.begin(),
- resident_set.begin() + std::min(5, resident_set.size()),
- resident_set.end(),
- [](const Probe& a, const Probe& b) {
- const float ha = std::max(a.history, 0.05f);
- const float hb = std::max(b.history, 0.05f);
- return a.priority * ha < b.priority * hb;
- });
- Log::info().noquote() << " [missing per model — top 8 by missing-count]";
- struct Row {
- QString name;
- size_t resident = 0;
- size_t frustum = 0;
- size_t missing = 0;
- };
- std::vector rows;
- rows.reserve(models_gpu_.size());
- for (const auto& [mid, mo] : models_gpu_) {
- Row r;
- QFileInfo fi(QString::fromStdString(mo.streaming_file_path));
- r.name = fi.completeBaseName();
- for (const auto& c : mo.chunks) {
- if (c.is_resident) ++r.resident;
- if (c.frustum_visible_count > 0) {
- ++r.frustum;
- if (!c.is_resident) ++r.missing;
- }
- }
- if (r.missing > 0) rows.push_back(std::move(r));
- }
- std::sort(rows.begin(), rows.end(),
- [](const Row& a, const Row& b) {
- return a.missing > b.missing;
- });
- const size_t cap = std::min(rows.size(), 8);
- for (size_t i = 0; i < cap; ++i) {
- const Row& r = rows[i];
- Log::info().noquote().nospace()
- << " " << r.name
- << " resident=" << r.resident
- << " frustum=" << r.frustum
- << " missing=" << r.missing;
- }
- Log::info().noquote() << " [top 20 MISSING chunks by priority (px², want these loaded)]";
- for (size_t i = 0; i < std::min(20, missing_set.size()); ++i) {
- const Probe& p = missing_set[i];
- Log::info().noquote().nospace()
- << " pri=" << QString::number(p.priority, 'f', 0)
- << " aabb=" << QString::number(p.ex, 'f', 1) << "x"
- << QString::number(p.ey, 'f', 1) << "x"
- << QString::number(p.ez, 'f', 1) << "m"
- << " in " << p.name;
- }
- Log::info().noquote() << " [bottom 5 RESIDENT chunks by effective priority (must beat with 2× hysteresis)]";
- for (size_t i = 0; i < std::min(5, resident_set.size()); ++i) {
- const Probe& p = resident_set[i];
- const float eff = p.priority * std::max(p.history, 0.05f);
- Log::info().noquote().nospace()
- << " pri=" << QString::number(p.priority, 'f', 0)
- << " hist=" << QString::number(p.history, 'f', 2)
- << " eff=" << QString::number(eff, 'f', 0)
- << " aabb=" << QString::number(p.ex, 'f', 1) << "x"
- << QString::number(p.ey, 'f', 1) << "x"
- << QString::number(p.ez, 'f', 1) << "m"
- << " in " << p.name;
- }
- }
- }
- }
-
- // ---- Benchmark integration + auto-quit -------------------------------
- if (bench_total_ > 0) {
- // Cold-load gate: don't start the orbit sweep until streaming has
- // converged for a few consecutive frames. Converged = 0 loads.
- // bench_warm_done_ latches on first satisfaction so the gate is
- // evaluated only during warmup, not every frame after.
- if (!bench_warm_done_) {
- constexpr int CONVERGE_FRAMES_REQUIRED = 5;
- constexpr int MAX_WARM_FRAMES = 600;
- // With async I/O, "no main-thread work this frame" isn't
- // enough — a worker thread might still be reading. The
- // streaming is truly settled only when the worker queue is
- // empty AND no chunks are awaiting drain.
- const bool worker_idle =
- streaming_thread_.inFlightApprox() == 0;
- if (streaming_loads_this_frame_ > 0 || !worker_idle) {
- bench_warm_streak_ = 0;
- } else {
- ++bench_warm_streak_;
- }
- ++bench_warm_frames_total_;
- const bool converged = bench_warm_streak_ >= CONVERGE_FRAMES_REQUIRED;
- const bool timed_out = bench_warm_frames_total_ >= MAX_WARM_FRAMES;
- if (converged) {
- Log::info().noquote().nospace()
- << "[bench warm] converged after "
- << bench_warm_frames_total_ << " frames";
- bench_warm_done_ = true;
- } else if (timed_out) {
- // Walk every chunk in every model to summarise the steady-
- // state shape: how many frustum-visible chunks are missing,
- // how many residents have load_count > 1 (cycled), the
- // chunk that's been re-loaded the most times, total pool
- // usage. This is the smoking gun for working-set > pool:
- // high "missing" with high "cycled" means we're stuck in
- // an evict-reload loop. Low "missing" with low "cycled"
- // means convergence just needs more frames.
- size_t total_chunks = 0;
- size_t resident = 0;
- size_t missing_visible = 0;
- size_t cycled = 0;
- uint32_t max_load = 0;
- for (const auto& [mid, m] : models_gpu_) {
- for (const auto& c : m.chunks) {
- ++total_chunks;
- if (c.is_resident) ++resident;
- else if (c.frustum_visible_count > 0) ++missing_visible;
- if (c.load_count > 1) ++cycled;
- if (c.load_count > max_load) max_load = c.load_count;
- }
- }
- const double mb = 1.0 / (1024.0 * 1024.0);
- // Estimate the typical "would fit" pressure: avg byte size
- // of the missing-visible chunks. If that's much larger than
- // largest_free_run, fragmentation is the smoking gun even
- // when total_free would be enough.
- uint64_t missing_bytes_total = 0;
- uint32_t missing_count_for_avg = 0;
- for (const auto& [mid, m] : models_gpu_) {
- for (const auto& c : m.chunks) {
- if (!c.is_resident && c.frustum_visible_count > 0) {
- missing_bytes_total += c.vertex_byte_size
- + c.index_count * sizeof(uint32_t);
- ++missing_count_for_avg;
- }
- }
- }
- const uint64_t avg_missing_bytes = missing_count_for_avg > 0
- ? missing_bytes_total / missing_count_for_avg : 0;
- const uint64_t largest_free = pool_.largest_free_run_bytes();
- const bool fragmented = missing_visible > 0
- && avg_missing_bytes > largest_free
- && pool_.total_free_bytes() > avg_missing_bytes;
-
- const char* diag;
- if (fragmented) {
- diag = "POOL FRAGMENTED (total free OK but no contiguous run big enough)";
- } else if (missing_visible > 0 && cycled > 10) {
- diag = "WORKING SET > POOL (thrashing — many chunks cycling)";
- } else if (missing_visible > 0 && max_load > 5) {
- diag = "FEW-CHUNK CYCLE (one+ chunks keep reloading, likely hysteresis-boundary)";
- } else if (missing_visible > 0) {
- diag = "still loading (try MAX_WARM_FRAMES↑)";
- } else {
- diag = "converged, just below the gate's 5-frame streak";
- }
- Log::warn().noquote().nospace()
- << "[bench warm] timed out after " << bench_warm_frames_total_
- << " frames without convergence (last loads="
- << streaming_loads_this_frame_ << ")\n"
- << " chunks: " << resident << " resident, "
- << missing_visible << " visible-but-missing, "
- << total_chunks << " total\n"
- << " cycled (loaded >1×): " << cycled
- << ", max load_count: " << max_load << "\n"
- << " pool: "
- << QString::number(double(pool_.total_used_bytes()) * mb, 'f', 0)
- << " / "
- << QString::number(double(pool_.total_capacity_bytes()) * mb, 'f', 0)
- << " MB used, "
- << QString::number(double(largest_free) * mb, 'f', 0)
- << " MB largest free run, "
- << QString::number(double(pool_.total_free_bytes()) * mb, 'f', 0)
- << " MB total free\n"
- << " avg missing chunk: "
- << QString::number(double(avg_missing_bytes) * mb, 'f', 1) << " MB\n"
- << " diagnosis: " << diag
- << "; starting bench anyway";
- bench_warm_done_ = true;
- } else {
- requestUpdate();
- return;
- }
- }
-
- const float ms = float(frame_timer.nsecsElapsed()) / 1e6f;
-
- // Warm-up frames are dropped from the sample. The yaw advance starts
- // immediately so the warmup frames already exercise different views.
- if (bench_count_ >= bench_warmup_) {
- bench_frame_ms_.push_back(ms);
- }
-
- // Per-frame line (every 50 frames so the log stays readable). Format
- // approximates GL's per-frame stats so a side-by-side script can
- // diff them. cull is the wall-clock cull cost from the timer above.
- if ((bench_count_ % 50) == 0) {
- uint64_t total_vbo = 0, total_ebo = 0, total_ssbo = 0;
- uint32_t total_instances = 0, total_meshes = 0;
- for (const auto& [mid, mo] : models_gpu_) {
- total_vbo += mo.vram_bytes_vbo;
- total_ebo += mo.vram_bytes_ebo;
- total_ssbo += mo.vram_bytes_ssbo;
- total_instances += mo.instance_count;
- total_meshes += mo.mesh_count;
- }
- const double mb = 1.0 / (1024.0 * 1024.0);
- const double avg_n = double(std::max(1, bench_count_ - bench_warmup_ + 1));
- const double cull_ms = bench_cull_ms_total_ / avg_n;
- const double stream_ms = bench_stream_ms_total_ / avg_n;
- Log::info().noquote().nospace()
- << "[frame] " << QString::number(ms > 0 ? 1000.0f / ms : 0.0f, 'f', 1) << " fps"
- << " " << QString::number(ms, 'f', 2) << " ms"
- << " obj " << last_visible_objects_ << "/" << total_instances
- << " tri " << last_visible_triangles_
- << " meshes " << total_meshes
- << " sub_draws " << last_sub_draws_
- << " hiz_rej " << hiz_reject_count_
- << " cull[wall " << QString::number(cull_ms, 'f', 2)
- << " | compute " << QString::number(last_cull_compute_ms_, 'f', 2)
- << " upload " << QString::number(last_cull_upload_ms_, 'f', 2) << "]ms"
- << " stream[" << QString::number(stream_ms, 'f', 2) << "]ms"
- << " vram " << QString::number(double(total_vbo + total_ebo + total_ssbo) * mb, 'f', 1) << "MB"
- << " (vbo " << QString::number(double(total_vbo) * mb, 'f', 1)
- << " + ebo " << QString::number(double(total_ebo) * mb, 'f', 1)
- << " + ssbo " << QString::number(double(total_ssbo) * mb, 'f', 1) << ")"
- << " models " << models_gpu_.size()
- << " lod1 " << lod1_dbg_count_ << "/" << (lod1_dbg_count_ + lod0_dbg_eligible_count_)
- << " (saved " << lod1_dbg_tris_saved_ << " tris, "
- << lod0_dbg_no_lod1_count_ << " no-lod1)";
- lod1_dbg_count_ = 0;
- lod0_dbg_eligible_count_ = 0;
- lod0_dbg_no_lod1_count_ = 0;
- lod1_dbg_tris_saved_ = 0;
- }
- camera_yaw_deg_ = bench_yaw_start_
- + bench_yaw_speed_ * float(bench_count_ + 1);
- ++bench_count_;
-
- if (bench_count_ >= bench_warmup_ + bench_total_) {
- // Final frame — assemble stats and emit. Format mirrors the GL
- // minimal so output is line-diffable across backends.
- std::vector times = bench_frame_ms_;
- std::sort(times.begin(), times.end());
- auto pct = [×](double p) -> float {
- if (times.empty()) return 0.0f;
- const size_t idx = std::min(times.size() - 1,
- size_t(p * double(times.size() - 1)));
- return times[idx];
- };
- float sum = 0.0f;
- for (float f : times) sum += f;
- const float avg = times.empty() ? 0.0f : sum / float(times.size());
- const float median = pct(0.5);
- const float p1 = pct(0.01);
- const float p99 = pct(0.99);
-
- const float total_sweep = bench_yaw_speed_ * float(bench_total_);
- Log::info().noquote().nospace()
- << "\n=== BENCHMARK (" << bench_total_ << " frames, orbit "
- << total_sweep << "° at " << bench_yaw_speed_ << "°/frame) ===";
- Log::info().noquote().nospace()
- << " avg: " << avg << " ms (" << (avg > 0 ? 1000.0f/avg : 0.0f) << " fps)";
- Log::info().noquote().nospace()
- << " median: " << median << " ms (" << (median > 0 ? 1000.0f/median : 0.0f) << " fps)";
- Log::info().noquote().nospace()
- << " p1: " << p1 << " ms p99: " << p99 << " ms";
- Log::info().noquote().nospace()
- << " last frame: obj " << last_visible_objects_
- << " tri " << last_visible_triangles_
- << " sub_draws " << last_sub_draws_
- << " hiz_rej " << hiz_reject_count_;
- const double n = double(std::max(1, bench_total_));
- Log::info().noquote().nospace()
- << " per-frame avg ms: cull=" << bench_cull_ms_total_ / n
- << " stream=" << bench_stream_ms_total_ / n
- << " hiz_readback=" << bench_hiz_readback_ms_total_ / n
- << " hiz=" << (hiz_enabled_ ? "on" : "off");
- Log::info().noquote() << "=== END BENCHMARK ===\n";
-
- bench_total_ = 0;
- QCoreApplication::quit();
- } else {
- requestUpdate();
- }
- }
+ core_.render();
}
// -----------------------------------------------------------------------------
diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h
index 11374cf42d..9a683f98b6 100644
--- a/src/ifcviewer/ViewportWindow.h
+++ b/src/ifcviewer/ViewportWindow.h
@@ -39,6 +39,7 @@
#include
#include
+#include "FrameStats.h"
#include "SidecarCache.h"
#include "BufferPool.h"
#include "InstanceCompose.h"
@@ -88,6 +89,12 @@ public:
void onSurfacePickedInTool(int x_px, int y_px, int modifiers) override;
void onToolModeChanged(int tool_mode) override;
void onToolBackspacePressed() override;
+ void onFrameStats(const FrameStats& stats) override;
+ void encodeOverlaysInMainPass(WGPURenderPassEncoder pass,
+ const OverlayFrame& frame) override;
+ void encodeOverlaysPostMain(WGPUCommandEncoder enc,
+ WGPUTextureView surface_view,
+ const OverlayFrame& frame) override;
void saveScreenshotRgba8(const std::string& path, const std::uint8_t* rgba,
int w, int h) override;
@@ -463,17 +470,11 @@ public:
// ViewportWindow::FrameStats so bonsai's status bar binding ports
// unchanged. gl_draw_calls is the wgpu draw-call count (named for
// continuity with the GL field bonsai's status format string uses).
- struct FrameStats {
- float fps;
- float frame_time_ms;
- uint32_t total_objects;
- uint32_t visible_objects;
- uint32_t total_triangles;
- uint32_t visible_triangles;
- uint32_t unique_meshes;
- uint32_t gl_draw_calls; // wgpu draw-call count; name kept for bonsai parity
- uint32_t indirect_sub_draws; // sub-draws packed into the chunk-indirect lists
- };
+ // ViewportWindow::FrameStats is a using-alias for ::FrameStats (moved
+ // to its own Qt-free header so ViewportHost::onFrameStats can carry
+ // it without dragging Qt into core). Existing bonsai signal binding
+ // (frameStatsUpdated) keeps using the qualified name.
+ using FrameStats = ::FrameStats;
signals:
// Selection moved by a pick / marquee. Emitted with the active id
@@ -830,14 +831,16 @@ private:
// rate matches GL's; on the federation scene this lands obj/tri
// counts within ~10% of GL's across an orbit (vs ~3× without the
// bump). Override at runtime via WGPU_MIN_PX / WGPU_MIN_PX_MOTION.
- float min_pixel_radius_ = 3.0f;
- float motion_min_pixel_radius_ = 15.0f;
+ // Cull-tuning aliases (storage in core_, #84-x). VW's env-var prelude
+ // still mutates these through the alias.
+ float& min_pixel_radius_;
+ float& motion_min_pixel_radius_;
// Whether driveCull dispatches per-model work via std::async. ON by
// default; setting WGPU_CULL_THREADS=0 forces sequential cull for
// measurement (does std::async actually parallelize on this libstdc++?
// and is per-model the right granularity?).
- bool cull_threads_enabled_ = true;
+ bool& cull_threads_enabled_;
public:
// Master switch for HiZ occlusion. OFF by default — has two issues vs
@@ -893,16 +896,14 @@ public:
// 0 loads (convergence) before starting the orbit sweep, capped by
// MAX_WARM_FRAMES so chronically thrashing scenes still produce
// numbers. Both reset implicitly per bench run via setBenchmarkFrames.
- int bench_warm_streak_ = 0;
- int bench_warm_frames_total_ = 0;
- bool bench_warm_done_ = false; // latch: once true, gate is open for this run
+ int& bench_warm_streak_;
+ int& bench_warm_frames_total_;
+ bool& bench_warm_done_;
private:
- // Switch to LOD1 when an instance's projected bounding-sphere radius
- // drops below this many pixels. 0 disables (always LOD0). Defaults
- // mirror AppSettings::lod1PixelThreshold() in the GL backend.
- float lod1_pixel_threshold_ = 30.0f;
+ // LOD1 pixel threshold alias (storage in core_, #84-x).
+ float& lod1_pixel_threshold_;
// Per-model state aliases (storage in core_).
std::unordered_map& models_gpu_;
@@ -917,22 +918,13 @@ private:
// alias so VW::setCamera can flip it without poking through core_.
bool& initial_view_applied_;
- // Camera state at the previous render() for motion detection. Any
- // change means we apply the motion contribution threshold this frame
- // (drops more sub-pixel work mid-orbit; matches GL behaviour).
- float prev_camera_target_[3] = { 0, 0, 0 };
- float prev_camera_distance_ = 0.0f;
- float prev_camera_yaw_deg_ = 0.0f;
- float prev_camera_pitch_deg_ = 0.0f;
- bool has_prev_camera_ = false;
-
- // True iff the last cull used the motion threshold. Render schedules a
- // single settle frame after motion stops so the previously dropped
- // sub-pixel instances reappear at the still threshold. Without this,
- // event-driven rendering would leave those instances missing forever
- // because no further frame is requested after the user releases the
- // mouse. Matches GL's last_cull_was_motion_ behaviour.
- bool last_cull_was_motion_ = false;
+ // Camera-state aliases for motion detection (storage in core_, #84-x).
+ float (&prev_camera_target_)[3];
+ float& prev_camera_distance_;
+ float& prev_camera_yaw_deg_;
+ float& prev_camera_pitch_deg_;
+ bool& has_prev_camera_;
+ bool& last_cull_was_motion_;
// Pending one-shot screenshot path alias (storage in core_).
// Captured at the end of the next render(); driveStreamingLoads
@@ -949,51 +941,27 @@ private:
Eigen::Vector2i nav_press_pos_;
bool nav_dragged_ = false;
- // Benchmark mode. setBenchmarkFrames(N) arms it; render() integrates the
- // yaw, captures per-frame ms after warmup, and prints + quits when the
- // target frame count is hit.
- int bench_total_ = 0;
- int bench_count_ = 0;
- int bench_warmup_ = 5;
- float bench_yaw_start_ = 0.0f;
- float bench_yaw_speed_ = 0.5f; // degrees per frame
- std::vector bench_frame_ms_;
-
- // Per-frame stat snapshot from the last cull. Sum of m.mesh_draws across
- // visible models. Exposed via the benchmark summary; will grow into a
- // proper FrameStats signal when stage 11's host integration arrives.
- uint32_t last_visible_objects_ = 0;
- uint32_t last_visible_triangles_ = 0;
- uint32_t last_sub_draws_ = 0;
-
- // Phase-time accumulators for benchmark mode. Each window measures a
- // distinct slice of render() so we can attribute frame cost. Totals
- // across the timed window are divided by bench_total_ on print.
- double bench_cull_ms_total_ = 0.0;
- double bench_stream_ms_total_ = 0.0; // driveStreamingLoads only
- double bench_hiz_readback_ms_total_ = 0.0;
- double bench_submit_ms_total_ = 0.0;
-
- // Last-frame per-phase times. Available in interactive mode (no
- // bench) so the periodic [frame] heartbeat log can show cull /
- // stream cost without needing the bench averaging machinery.
- double last_cull_ms_ = 0.0;
- double last_cull_compute_ms_ = 0.0; // parallel per-model cull
- double last_cull_upload_ms_ = 0.0; // sequential queueWriteBuffer pass
- double last_stream_ms_ = 0.0;
-
- // Tick count for the interactive (non-bench) [frame] heartbeat log.
- // Increments every render() and prints stats every N frames.
- int interactive_frame_count_ = 0;
-
- // Rolling 60-sample frame-time window for the smoothed fps emitted
- // via frameStatsUpdated. Index wraps; sum kept incrementally to
- // avoid a per-frame reduction.
- static constexpr int FRAME_TIME_WINDOW = 60;
- double frame_time_ms_window_[FRAME_TIME_WINDOW] = {};
- int frame_time_ms_count_ = 0;
- int frame_time_ms_head_ = 0;
- double frame_time_ms_sum_ = 0.0;
+ // Benchmark + frame-stats aliases (storage in core_, #84-x).
+ // setBenchmarkFrames(N) writes the bench_* fields through these
+ // aliases; the heartbeat / FrameStats path in core_.render reads
+ // and resets them.
+ int& bench_total_;
+ int& bench_count_;
+ int& bench_warmup_;
+ float& bench_yaw_start_;
+ float& bench_yaw_speed_;
+ std::vector& bench_frame_ms_;
+ uint32_t& last_visible_objects_;
+ uint32_t& last_visible_triangles_;
+ uint32_t& last_sub_draws_;
+ double& bench_cull_ms_total_;
+ double& bench_stream_ms_total_;
+ double& bench_hiz_readback_ms_total_;
+ double& last_cull_ms_;
+ double& last_cull_compute_ms_;
+ double& last_cull_upload_ms_;
+ double& last_stream_ms_;
+ int& interactive_frame_count_;
// FederatedFalseOrigin matrix, in metres. Default identity. Stored
// but not yet applied to per-instance composed transforms — the