ifcviewer (GL minimal): --screenshot for parity diff with wgpu

Closes the other half of task #10. The wgpu minimal already wrote PNGs
via wgpuCommandEncoderCopyTextureToBuffer + mapAsync; the GL backend
now has the equivalent via glReadPixels on the back buffer just before
swapBuffers.

  - ViewportWindow::captureNextFrameToPng(path, quit_after=true) queues
    a one-shot capture. render() reads the default framebuffer at full
    pixel size (width * devicePixelRatio), flips bottom-up → top-down
    into a QImage::Format_RGBA8888, saves PNG, and optionally
    QCoreApplication::quit. Synchronous glReadPixels is fine here —
    pick is interactive and rare; not used per-frame.

  - ifcviewer-minimal --screenshot PATH wires through MinimalWindow
    just like --camera / --benchmark. Honoured after all loads complete
    (applyPendingBenchmark also drains pending_screenshot_).

Lets a parity script do:
    IfcViewerMinimal      foo.ifc      --camera A,B,C,D,E,F --screenshot gl.png
    IfcViewerWgpuMinimal  foo.ifcview  --camera A,B,C,D,E,F --screenshot wgpu.png
    # then pixel-diff with whatever (ImageMagick, PIL, etc.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-27 20:33:20 +10:00
parent 4dfe27e251
commit 5810894eb8
5 changed files with 75 additions and 1 deletions
+42
View File
@@ -27,6 +27,7 @@
#include <QWheelEvent>
#include <QSurfaceFormat>
#include <QCoreApplication>
#include <QImage>
#include <QCursor>
#include <QTimer>
#include <QtMath>
@@ -1638,6 +1639,12 @@ void ViewportWindow::setBenchmarkFrames(int n) {
requestUpdate();
}
void ViewportWindow::captureNextFrameToPng(const QString& path, bool quit_after) {
pending_screenshot_path_ = path;
pending_screenshot_quit_ = quit_after;
requestUpdate();
}
QString ViewportWindow::cameraString() const {
return QString("%1,%2,%3,%4,%5,%6")
.arg(camera_target_.x(), 0, 'f', 4)
@@ -2865,6 +2872,41 @@ void ViewportWindow::render() {
buildHizPyramid();
}
// ---- One-shot framebuffer capture (parity-diff harness) -------------
// Read back the just-rendered colour buffer and write a PNG. Runs
// before swapBuffers so the source is the back buffer (still bound).
// glReadPixels here is synchronous and at full surface size — fine
// for a one-shot capture, never used per-frame.
if (!pending_screenshot_path_.isEmpty() && gl_) {
const int w = int(width() * devicePixelRatio());
const int h = int(height() * devicePixelRatio());
if (w > 0 && h > 0) {
std::vector<uint8_t> buf(size_t(w) * size_t(h) * 4);
gl_->glPixelStorei(GL_PACK_ALIGNMENT, 1);
gl_->glReadBuffer(GL_BACK);
gl_->glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, buf.data());
// glReadPixels returns bottom-up; QImage is top-down — flip rows.
QImage img(w, h, QImage::Format_RGBA8888);
for (int y = 0; y < h; ++y) {
std::memcpy(img.scanLine(h - 1 - y),
buf.data() + size_t(y) * size_t(w) * 4,
size_t(w) * 4);
}
if (img.save(pending_screenshot_path_, "PNG")) {
qInfo().noquote() << "[gl] saved screenshot:"
<< pending_screenshot_path_
<< "(" << w << "x" << h << ")";
} else {
qWarning().noquote() << "[gl] QImage::save failed for"
<< pending_screenshot_path_;
}
}
const bool quit_after = pending_screenshot_quit_;
pending_screenshot_path_.clear();
pending_screenshot_quit_ = false;
if (quit_after) QCoreApplication::quit();
}
context_->swapBuffers(this);
// Ensure one more frame runs after the last motion frame so the