From ed6e8d831e60350bdad7ba8c9c942c3c3bf1d2e7 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 18 Apr 2026 21:27:28 +1000 Subject: [PATCH] ifcviewer: benchmark CLI, settle recull fix, and Phase 3G documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --camera tx,ty,tz,dist,yaw,pitch and --benchmark N CLI args for reproducible performance measurement. The benchmark orbits the camera (0.5°/frame yaw) for N frames after a 5-frame warmup, prints avg/median/p1/p99 frame times, then exits. Press C during interactive use to print the current camera as a --camera argument. Fix settle recull to fire after ANY camera motion (not just when IFC_MIN_PX_MOTION is set), ensuring HiZ artifacts from motion frames are always cleared when the camera stops. Document Phase 3G (motion-adaptive culling + HiZ during motion) in README with benchmark results from 1.06M-instance scene: - Baseline: 16.3 fps - IFC_MIN_PX_MOTION=10: 26.5 fps (1.6x) - IFC_HIZ_MOTION=1: 46.6 fps (2.9x) - Both combined: 51.0 fps (3.1x) - + GPU_CULL: 52.0 fps (3.2x, negligible gain) Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/MainWindow.cpp | 32 ++++++++ src/ifcviewer/MainWindow.h | 7 ++ src/ifcviewer/README.md | 121 +++++++++++++++++++++++++------ src/ifcviewer/ViewportWindow.cpp | 89 +++++++++++++++++++++-- src/ifcviewer/ViewportWindow.h | 13 ++++ src/ifcviewer/main.cpp | 11 +++ 6 files changed, 242 insertions(+), 31 deletions(-) diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer/MainWindow.cpp index e75f7cf0dd..865b464170 100644 --- a/src/ifcviewer/MainWindow.cpp +++ b/src/ifcviewer/MainWindow.cpp @@ -190,6 +190,7 @@ void MainWindow::connectStreamer(GeometryStreamer* streamer) { void MainWindow::startNextLoad() { if (load_queue_.empty()) { loading_model_id_ = 0; + applyPendingBenchmark(); return; } @@ -554,3 +555,34 @@ void MainWindow::populateProperties(uint32_t object_id) { } } } + +void MainWindow::setPendingCamera(const QString& params) { + pending_camera_ = params; +} + +void MainWindow::setPendingBenchmark(int frames) { + pending_benchmark_ = frames; +} + +void MainWindow::applyPendingBenchmark() { + if (pending_camera_.isEmpty() && pending_benchmark_ <= 0) return; + + if (!pending_camera_.isEmpty()) { + QStringList parts = pending_camera_.split(','); + if (parts.size() == 6) { + viewport_->setCamera( + parts[0].toFloat(), parts[1].toFloat(), parts[2].toFloat(), + parts[3].toFloat(), parts[4].toFloat(), parts[5].toFloat()); + qDebug("Camera set: %s", qPrintable(pending_camera_)); + } else { + qWarning("--camera expects 6 comma-separated values: tx,ty,tz,dist,yaw,pitch"); + } + pending_camera_.clear(); + } + + if (pending_benchmark_ > 0) { + qDebug("Starting benchmark: %d frames", pending_benchmark_); + viewport_->setBenchmarkFrames(pending_benchmark_); + pending_benchmark_ = 0; + } +} diff --git a/src/ifcviewer/MainWindow.h b/src/ifcviewer/MainWindow.h index 5270676af5..4f5a946998 100644 --- a/src/ifcviewer/MainWindow.h +++ b/src/ifcviewer/MainWindow.h @@ -57,6 +57,8 @@ public: ~MainWindow(); void addFiles(const QStringList& paths); + void setPendingCamera(const QString& params); + void setPendingBenchmark(int frames); private slots: void onFileOpen(); @@ -109,6 +111,11 @@ private: static uint64_t scopedKey(uint32_t model_id, int ifc_id) { return (static_cast(model_id) << 32) | static_cast(ifc_id); } + + QString pending_camera_; + int pending_benchmark_ = 0; + + void applyPendingBenchmark(); }; #endif // MAINWINDOW_H diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 81b128bdf8..17cc4b7f8f 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -694,31 +694,33 @@ thousands and the frame time drops accordingly. ##### Known caveats -- **Disabled while the camera moves.** The pyramid is aligned to the - VP matrix of the frame that produced it. On a moving camera the - stored VP no longer matches the current one, and reusing it would - pop objects in and out as the stale depth falsely claims they're - occluded. The cull now compares `hiz_vp_ == current_vp` and drops - HiZ rejection entirely when they differ, so HiZ only contributes on - still frames. The honest cost: orbiting — the exact motion where - the frame rate tends to dip — gets no HiZ help. A proper fix needs - a same-frame depth pre-pass (draw cheap depth, build HiZ from *that* - frame's VP, then issue the colour pass against it); deferred to the - GPU-compute cull rewrite in Phase 3E where we're touching this code - anyway. We also tried a 3-deep PBO ring for async readback (2-frame - stale) which produced visible flicker on fast orbits — reverted. +- **Optional during camera motion (`IFC_HIZ_MOTION=1`).** The pyramid + is aligned to the previous frame's VP. On a moving camera the stale + depth can falsely occlude objects, particularly thin geometry (pipes, + railings) at oblique angles. By default HiZ is disabled during motion + (`hiz_vp_ == current_vp` check). Setting `IFC_HIZ_MOTION=1` forces + HiZ on during motion — benchmarks show this is the single biggest + perf lever (2.9× speedup), and the artifacts are transient and minor + during active orbiting. When the camera stops, a settle recull fires + with `hiz_vp_valid_ = false`, disabling HiZ for that one frame and + re-culling the full scene. This guarantees the stationary view is + artifact-free. See Phase 3G for benchmark data. +- **Conservative occlusion test.** The original "max over coarse mip" + test was too aggressive for BIM scenes where the entire depth range + compresses into 0.99–1.00. Replaced with "all fine-mip texels must + agree" — sample at mip 1, reject only if every texel has depth less + than the AABB's nearest point, early-out on the first non-occluding + texel. Queries covering >64 texels skip HiZ entirely. Eliminates + most false occlusions at the cost of fewer true rejections. +- **Depth blit replaced with shader downsample.** The original + `glBlitFramebuffer` for scaling the resolved depth to HiZ size + produced `GL_INVALID_VALUE` on some drivers. Replaced with a + fullscreen-triangle shader writing `gl_FragDepth`. The resolve + texture uses `GL_DEPTH24_STENCIL8` to match Qt's default FBO format + (which uses D24S8 even when only depth is requested). - **Readback syncs the GPU.** `glGetTextureImage` is blocking. Measured cost is well under a millisecond at 256×128; not a - bottleneck on the machines tested. Phase 3D's compute-shader cull - removes it entirely. -- **Doesn't move the needle on overview shots.** Those scenes are - CPU-bound on the cull traversal itself, not GPU-bound on drawing, - so cutting the drawn-triangle count in half is invisible in the - frame time. `hiz_rej` still rises modestly on overviews (the frustum - hull contains everything behind visible walls) but saved GPU work - is masked by CPU cost. HiZ pays off on interior views, where the - GPU *was* the bottleneck. If a project never leaves overview, - `IFC_NO_HIZ=1` shaves the ~1 ms of HiZ cost. + bottleneck on the machines tested. - **Transparent geometry would need special handling**, but the current renderer doesn't have any, so no-op for now. @@ -961,6 +963,74 @@ doors, windows, pipe fittings) share geometry across placements. ratio (~80 k vs ~120 k), confirming per-draw overhead as the dominant cost. +#### 3G. Motion-adaptive culling + HiZ during motion — ✅ done + +The bottleneck during camera orbit is the sheer number of visible +objects and sub_draws. Two complementary strategies address this: + +##### Motion-adaptive contribution culling (`IFC_MIN_PX_MOTION`) + +During camera motion, use a larger pixel-radius threshold to hide +small objects that contribute little at interactive rates. When the +camera stops, a settle recull restores the base threshold and full +detail within one frame. No visual artifacts — objects below the +motion threshold are genuinely tiny on screen. + +##### HiZ during motion (`IFC_HIZ_MOTION=1`) + +Force the one-frame-stale HiZ pyramid to remain active during camera +motion. The stale depth causes minor false occlusions on thin +geometry at oblique angles, but these are transient during active +orbit. When the camera stops, the settle recull invalidates the HiZ +pyramid (`hiz_vp_valid_ = false`) and re-culls without HiZ, +guaranteeing the stationary view is artifact-free. + +##### Benchmark results + +Benchmarked on 1.06 M-instance / 111-model scene, 200-frame orbit +(103° arc, 0.5°/frame), GTX 1650: + +| Configuration | avg ms | fps | speedup | obj | sub_draws | hiz_rej | +|----------------------------------|--------|------|---------|-------|-----------|---------| +| Baseline (no opts) | 61.25 | 16.3 | 1.0× | 254k | 155k | 0 | +| MIN_PX_MOTION=10 | 37.67 | 26.5 | 1.6× | 70k | 56k | 0 | +| HIZ_MOTION=1 | 21.44 | 46.6 | 2.9× | 33k | 17.5k | 28k | +| HIZ_MOTION=1 + MIN_PX_MOTION=10 | 19.62 | 51.0 | 3.1× | 11.4k | 8.7k | 11.5k | +| GPU_CULL + HIZ + MIN_PX | 19.22 | 52.0 | 3.2× | 11.3k | 8.6k | 59k | + +##### Conclusions + +1. **HiZ during motion is the biggest single lever** — 2.9× alone. + Artifacts are minor and transient during orbit; the stationary view + is guaranteed correct by the settle recull. + +2. **Motion pixel culling is clean and effective** — 1.6× with zero + artifacts. + +3. **Combining both gives diminishing returns** — 3.1× vs 2.9× (HiZ + alone) or 1.6× (MIN_PX alone). They compete over the same objects. + +4. **GPU cull adds nothing** on top of these — 52.0 vs 51.0 fps. The + CPU BVH path handles the reduced visible set in ~2 ms. + +5. **The ~19 ms floor is GPU rendering**, not culling. At 8.6k + sub_draws the bottleneck shifts to draw dispatch + triangle + rasterization. Further improvement requires reducing sub_draws + (static batching) or moving to a more efficient draw model. + +##### Benchmark CLI + +Press **C** during interactive use to print the current camera as a +`--camera` argument. Then benchmark reproducibly: + +```bash +./IfcViewer --camera tx,ty,tz,dist,yaw,pitch --benchmark 200 files... +``` + +The benchmark orbits the camera (0.5°/frame yaw), measures N frames +after a 5-frame warmup, prints avg/median/p1/p99 frame times, then +exits. Env vars control the test configuration. + ### Planned follow-ups (post-Phase-3) - **Mesh shaders / meshlets.** Ceiling-raising, but overkill until the @@ -979,6 +1049,7 @@ Scene size Bottleneck Fix multi-million + occluders redundant rasterisation Phase 3C HiZ (done, CPU readback) many models, serial cull single-thread BVH trv Phase 3D parallel cull (done) single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (hybrid, done) +orbit fps on 1M+ scenes too many vis objects Phase 3G motion culling + HiZ (done, 3.1×) 90k+ unique visible meshes per-draw GPU overhead Phase 3F static batching (next) ``` @@ -996,7 +1067,7 @@ single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (hybri - [x] Reflection-aware two-pass draw for mirrored placements - [x] Backface culling (user-toggleable, default on) - [x] `reorient-shells` enabled in iterator -- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`, `IFC_NO_HIZ`, `IFC_HIZ_SIZE`, `IFC_CULL_THREADS`) +- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`, `IFC_NO_HIZ`, `IFC_HIZ_SIZE`, `IFC_CULL_THREADS`, `IFC_MIN_PX_MOTION`, `IFC_HIZ_MOTION`, `IFC_GPU_CULL`, `IFC_SUBDRAW_DIAG`) - [x] Phase 3A — screen-space contribution culling - [x] Phase 3B — distance / contribution LOD (meshoptimizer `simplifySloppy`) - [x] Phase 3C — Hierarchical-Z occlusion culling (v1, CPU-side readback) @@ -1004,6 +1075,8 @@ single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (hybri - [x] Quantized VBO (16 B/vert, sidecar v6) - [x] Event-driven rendering (zero idle CPU/GPU, cull skipped on still frames) - [x] Phase 3E — GPU compute-shader culling (hybrid: GPU frustum+contribution, async readback, CPU HiZ+LOD+emit) +- [x] Phase 3G — Motion-adaptive culling + HiZ during motion (3.1× orbit speedup on 1M-instance scene) +- [x] Benchmark CLI (`--camera`, `--benchmark`, press C to capture camera) - [ ] **Phase 3F — Static batching of single-instance meshes** (next; reduces 90k+ sub_draws to hundreds) - [ ] Vulkan/MoltenVK backend for macOS - [ ] Embedded Python scripting console diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index e67a378fd3..eb49854a91 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -22,8 +22,10 @@ #include "AppSettings.h" #include +#include #include #include +#include #include #include @@ -1203,6 +1205,44 @@ void ViewportWindow::setSelectedObjectId(uint32_t id) { requestUpdate(); } +void ViewportWindow::setCamera(float tx, float ty, float tz, + float dist, float yaw, float pitch) { + camera_target_ = QVector3D(tx, ty, tz); + camera_distance_ = dist; + camera_yaw_ = yaw; + camera_pitch_ = pitch; + have_cached_cull_ = false; + requestUpdate(); +} + +void ViewportWindow::setBenchmarkFrames(int n) { + benchmark_total_ = n; + benchmark_count_ = 0; + benchmark_warmup_ = 5; + benchmark_yaw_start_ = camera_yaw_; + benchmark_frame_times_.clear(); + benchmark_frame_times_.reserve(n); + requestUpdate(); +} + +QString ViewportWindow::cameraString() const { + return QString("%1,%2,%3,%4,%5,%6") + .arg(camera_target_.x(), 0, 'f', 4) + .arg(camera_target_.y(), 0, 'f', 4) + .arg(camera_target_.z(), 0, 'f', 4) + .arg(camera_distance_, 0, 'f', 4) + .arg(camera_yaw_, 0, 'f', 2) + .arg(camera_pitch_, 0, 'f', 2); +} + +void ViewportWindow::keyPressEvent(QKeyEvent* event) { + if (event->key() == Qt::Key_C && !(event->modifiers() & Qt::ControlModifier)) { + qDebug("--camera %s", qPrintable(cameraString())); + return; + } + QWindow::keyPressEvent(event); +} + // --- HiZ occlusion culling (Phase 3C) ----------------------------------- // Baseline HiZ resolution. 256x128 is enough to cull big occluders @@ -1983,24 +2023,23 @@ void ViewportWindow::render() { && last_cull_view_ == view_matrix_ && last_cull_proj_ == proj_matrix_; const bool camera_moving = !camera_unchanged; - // Force a re-cull on the first still frame after motion so we - // restore the base (tighter) contribution threshold. - const bool needs_settle_recull = !camera_moving - && last_cull_was_motion_ + const bool use_motion_threshold = camera_moving && motion_min_pixel_radius > base_min_pixel_radius; + // Force a re-cull on the first still frame after motion so we + // restore the base contribution threshold and clear stale HiZ. + const bool needs_settle_recull = !camera_moving + && last_cull_was_motion_; const bool cull_this_frame = camera_moving || needs_settle_recull; // Invalidate HiZ on the settle frame: the pyramid was built from the // motion frame's sparse depth (aggressive threshold hid objects whose // depth would normally populate the pyramid), causing false occlusion. if (needs_settle_recull) hiz_vp_valid_ = false; - const bool use_motion_threshold = camera_moving - && motion_min_pixel_radius > base_min_pixel_radius; const float min_pixel_radius = use_motion_threshold ? motion_min_pixel_radius : base_min_pixel_radius; if (cull_this_frame) { hiz_reject_count_.store(0, std::memory_order_relaxed); - last_cull_was_motion_ = use_motion_threshold; + last_cull_was_motion_ = camera_moving; } else { ++cull_skipped_frames_; } @@ -2353,6 +2392,42 @@ void ViewportWindow::render() { // Reported fps = "if I rendered continuously, this is the rate I'd hit", // which is what profiling actually wants. const float frame_cost_s = frame_cost_clock.nsecsElapsed() * 1e-9f; + + if (benchmark_total_ > 0) { + camera_yaw_ += benchmark_yaw_speed_; + have_cached_cull_ = false; + + if (benchmark_warmup_ > 0) { + --benchmark_warmup_; + } else { + benchmark_frame_times_.push_back(frame_cost_s * 1000.0f); + ++benchmark_count_; + } + if (benchmark_count_ >= benchmark_total_) { + std::sort(benchmark_frame_times_.begin(), benchmark_frame_times_.end()); + float sum = 0.0f; + for (float t : benchmark_frame_times_) sum += t; + float avg = sum / benchmark_frame_times_.size(); + float median = benchmark_frame_times_[benchmark_frame_times_.size() / 2]; + float p1 = benchmark_frame_times_[(size_t)(benchmark_frame_times_.size() * 0.01f)]; + float p99 = benchmark_frame_times_[(size_t)(benchmark_frame_times_.size() * 0.99f)]; + float total_arc = benchmark_yaw_speed_ * (benchmark_total_ + 5); + qDebug("\n=== BENCHMARK (%d frames, orbit %.0f° at %.1f°/frame) ===", + benchmark_total_, total_arc, benchmark_yaw_speed_); + qDebug(" avg: %.2f ms (%.1f fps)", avg, 1000.0f / avg); + qDebug(" median: %.2f ms (%.1f fps)", median, 1000.0f / median); + qDebug(" p1: %.2f ms p99: %.2f ms", p1, p99); + qDebug(" last frame: obj %u tri %u sub_draws %u hiz_rej %u", + visible_objects_, visible_triangles_, + indirect_sub_draws_, + hiz_reject_count_.load()); + qDebug("=== END BENCHMARK ===\n"); + QCoreApplication::quit(); + return; + } + requestUpdate(); + } + accumulated_time_ += frame_cost_s; frame_count_++; if (accumulated_time_ >= 1.0f) { diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index a442802176..261cef6e0f 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -174,6 +174,10 @@ public: void setSelectedObjectId(uint32_t id); uint32_t pickObjectAt(int x, int y); + void setCamera(float tx, float ty, float tz, float dist, float yaw, float pitch); + void setBenchmarkFrames(int n); + QString cameraString() const; + struct FrameStats { float fps; float frame_time_ms; @@ -194,6 +198,7 @@ signals: protected: void exposeEvent(QExposeEvent* event) override; void resizeEvent(QResizeEvent* event) override; + void keyPressEvent(QKeyEvent* event) override; bool event(QEvent* event) override; private: @@ -381,6 +386,14 @@ private: // When the camera stops, re-cull once at the base threshold. bool last_cull_was_motion_ = false; + // Benchmark mode: render N frames, collect stats, then exit. + int benchmark_total_ = 0; + int benchmark_count_ = 0; + int benchmark_warmup_ = 5; + float benchmark_yaw_start_ = 0.0f; + float benchmark_yaw_speed_ = 0.5f; // degrees per frame + std::vector benchmark_frame_times_; + // Per-frame stats uint32_t visible_triangles_ = 0; uint32_t visible_objects_ = 0; diff --git a/src/ifcviewer/main.cpp b/src/ifcviewer/main.cpp index a5bb487db8..d15a525c53 100644 --- a/src/ifcviewer/main.cpp +++ b/src/ifcviewer/main.cpp @@ -41,6 +41,10 @@ int main(int argc, char* argv[]) { parser.setApplicationDescription("IfcOpenShell IFC Viewer"); parser.addHelpOption(); parser.addPositionalArgument("files", "IFC file(s) to open", "[files...]"); + parser.addOption({{"c", "camera"}, + "Set camera: tx,ty,tz,dist,yaw,pitch", "params"}); + parser.addOption({{"b", "benchmark"}, + "Run N frames then print stats and exit", "frames"}); parser.process(app); MainWindow window; @@ -51,5 +55,12 @@ int main(int argc, char* argv[]) { window.addFiles(args); } + if (parser.isSet("camera")) { + window.setPendingCamera(parser.value("camera")); + } + if (parser.isSet("benchmark")) { + window.setPendingBenchmark(parser.value("benchmark").toInt()); + } + return app.exec(); }