ifcviewer: move render() body into ViewportCore (#84-x)

The frame loop — surface acquisition, parallel cull dispatch, streaming
drive, two-pass main render, HiZ resolve, edge pass, screenshot
capture, FrameStats emission, interactive / bench heartbeat, bench
summary + auto-quit — all live in ViewportCore now.
ViewportWindow::render() shrinks to the Qt-only prelude:
isExposed() guard, fpsIntegrate() (fly-mode WASD step), then
core_.render().

The overlay renderer stays Qt-bound (OverlayRenderer.h carries
QString labels). Core reaches it via two new ViewportHost virtuals:
encodeOverlaysInMainPass (section gizmos, highlights, pivot, lines,
points — in-MSAA-pass) and encodeOverlaysPostMain (corner axis,
marquee, labels — on the resolved surface). The QtViewportHost
implementation in ViewportWindow forwards each to overlays_.X().

FrameStats moves to its own Qt-free header (FrameStats.h) with
ViewportWindow::FrameStats re-exported as a using-alias so the
bonsai-side signal binding keeps working. ViewportHost::onFrameStats
replaces the placeholder 4-double signature with the typed POD.
OverlayFrame moves alongside (OverlayFrame.h) so the host overlay
callbacks can carry it without dragging Qt into core.

Bench + frame-stats + cull-tuning state (min_pixel_radius_,
motion_min_pixel_radius_, lod1_pixel_threshold_, cull_threads_enabled_,
prev_camera_*, has_prev_camera_, last_cull_was_motion_, last_visible_*,
last_cull_*ms_, last_stream_ms_, bench_*, interactive_frame_count_,
frame_time_ms_window_/_sum_/_count_/_head_) move to ViewportCore; VW
keeps reference aliases so the env-var prelude, setBenchmarkFrames,
and the various tool keybind setters keep compiling unchanged.

Smoke checks: --screenshot path renders + saves a clean PNG;
--benchmark 10 runs the warm gate, prints the per-frame log + summary,
and exits cleanly via host_->quit().
This commit is contained in:
Dion Moult
2026-06-06 20:27:33 +10:00
parent 8fb0a4b24f
commit ebb9c91bdf
9 changed files with 863 additions and 1057 deletions
+2
View File
@@ -157,6 +157,8 @@ set(IFCVIEWER_CORE_HEADERS
LodBuilder.h
Stopwatch.h
ModelGpuData.h
FrameStats.h
OverlayFrame.h
SectionPlane.h
SelectionState.h
SidecarCache.h
+43
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCVIEWER_FRAMESTATS_H
#define IFCVIEWER_FRAMESTATS_H
#include <cstdint>
// 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
+41
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCVIEWER_OVERLAYFRAME_H
#define IFCVIEWER_OVERLAYFRAME_H
#include <Eigen/Dense>
// 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
+1 -15
View File
@@ -30,21 +30,7 @@
#include <cstdint>
#include <vector>
// 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),
+557
View File
@@ -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 <future>
#include <iomanip>
#include <sstream>
#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<std::pair<std::uint32_t, std::future<std::uint32_t>>> 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<float> times = bench_frame_ms_;
std::sort(times.begin(), times.end());
auto pct = [&times](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();
}
}
}
+85
View File
@@ -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<std::uint32_t, std::unique_ptr<SidecarData>>
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<float> 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
+20 -6
View File
@@ -47,6 +47,9 @@
#include <cstdint>
#include <string>
#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
File diff suppressed because it is too large Load Diff
+50 -82
View File
@@ -39,6 +39,7 @@
#include <unordered_map>
#include <unordered_set>
#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<uint32_t, ModelGpuData>& 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<float> 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<float>& 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