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