ifcviewer: benchmark CLI, settle recull fix, and Phase 3G documentation

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 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-04-18 21:27:28 +10:00
parent 930678e3d2
commit ed6e8d831e
6 changed files with 242 additions and 31 deletions
+32
View File
@@ -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;
}
}
+7
View File
@@ -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<uint64_t>(model_id) << 32) | static_cast<uint32_t>(ifc_id);
}
QString pending_camera_;
int pending_benchmark_ = 0;
void applyPendingBenchmark();
};
#endif // MAINWINDOW_H
+97 -24
View File
@@ -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.991.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
+82 -7
View File
@@ -22,8 +22,10 @@
#include "AppSettings.h"
#include <QMouseEvent>
#include <QKeyEvent>
#include <QWheelEvent>
#include <QSurfaceFormat>
#include <QCoreApplication>
#include <QtMath>
#include <QtOpenGL/QOpenGLVersionFunctionsFactory>
@@ -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) {
+13
View File
@@ -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<float> benchmark_frame_times_;
// Per-frame stats
uint32_t visible_triangles_ = 0;
uint32_t visible_objects_ = 0;
+11
View File
@@ -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();
}