mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-01 17:36:27 +00:00
wgpu backend: --screenshot capability for visual verification
Pulls the capture half of task #10 forward so we stop flying blind from stage 3 onward. WgpuViewportWindow gains captureNextFrameToPng(path); the minimal driver wires it to a --screenshot PATH flag that renders one frame, copies the surface texture back to host memory, writes a PNG via QImage, and quits. CopySrc is added to the surface configuration usage so the surface texture can be the copy source. The texel-to-buffer copy honours WebGPU's 256-byte bytes-per-row alignment by padding rows and stripping the padding when assembling the QImage. Surface format 28 (BGRA8Unorm) is byte-swapped to RGBA on the way into QImage::Format_RGBA8888; RGBA8 surface formats are memcpy'd straight through. Verified end-to-end on /tmp/basic.ifcview: 3 cube meshes/instances render with depth, back-face cull, and the hemisphere-ambient + key+fill lighting model — top face reads sky (bright), front faces read mid-tone, exactly as the WGSL shading intended. The pixel-diff half of task #10 (comparing against a GL baseline) lands later when the GL minimal binary gets an equivalent flag. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -36,11 +36,13 @@ int main(int argc, char* argv[]) {
|
|||||||
|
|
||||||
QCommandLineParser parser;
|
QCommandLineParser parser;
|
||||||
parser.setApplicationDescription(
|
parser.setApplicationDescription(
|
||||||
"IfcOpenShell minimal wgpu IFC viewer (stage 2: sidecar load, no draw)");
|
"IfcOpenShell minimal wgpu IFC viewer");
|
||||||
parser.addHelpOption();
|
parser.addHelpOption();
|
||||||
parser.addPositionalArgument("files",
|
parser.addPositionalArgument("files",
|
||||||
"Sidecar (.ifcview) files to load. Stem-based: foo.ifc resolves to foo.ifcview.",
|
"Sidecar (.ifcview) files to load. Stem-based: foo.ifc resolves to foo.ifcview.",
|
||||||
"[files...]");
|
"[files...]");
|
||||||
|
parser.addOption({{"s", "screenshot"},
|
||||||
|
"Render one frame, save to PATH as PNG, exit.", "path"});
|
||||||
parser.process(app);
|
parser.process(app);
|
||||||
|
|
||||||
auto* viewport = new WgpuViewportWindow;
|
auto* viewport = new WgpuViewportWindow;
|
||||||
@@ -61,5 +63,10 @@ int main(int argc, char* argv[]) {
|
|||||||
viewport->queueLoadSidecar(path);
|
viewport->queueLoadSidecar(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parser.isSet("screenshot")) {
|
||||||
|
viewport->captureNextFrameToPng(parser.value("screenshot"),
|
||||||
|
/*quit_after=*/true);
|
||||||
|
}
|
||||||
|
|
||||||
return app.exec();
|
return app.exec();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ struct FrameUniforms {
|
|||||||
static_assert(sizeof(FrameUniforms) == 16 * sizeof(float) + 4 * 4 * sizeof(float),
|
static_assert(sizeof(FrameUniforms) == 16 * sizeof(float) + 4 * 4 * sizeof(float),
|
||||||
"FrameUniforms must match WGSL layout (mat4 + 4xvec4)");
|
"FrameUniforms must match WGSL layout (mat4 + 4xvec4)");
|
||||||
|
|
||||||
|
// WebGPU texture<->buffer copies require bytes-per-row to be a multiple of
|
||||||
|
// this. RGBA8 (4 B/pixel) at 1280 wide produces 5120 — already a multiple,
|
||||||
|
// but at e.g. 1281 wide we round up to 5376. Tracked as the padded row
|
||||||
|
// stride in the capture path.
|
||||||
|
static constexpr uint64_t WGPU_BYTES_PER_ROW_ALIGN = 256;
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Small helpers
|
// Small helpers
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -645,7 +651,9 @@ void WgpuViewportWindow::configureSurface(int width_px, int height_px) {
|
|||||||
WGPUSurfaceConfiguration cfg = {};
|
WGPUSurfaceConfiguration cfg = {};
|
||||||
cfg.device = device_;
|
cfg.device = device_;
|
||||||
cfg.format = surface_format_;
|
cfg.format = surface_format_;
|
||||||
cfg.usage = WGPUTextureUsage_RenderAttachment;
|
// CopySrc lets captureNextFrameToPng copy the surface texture back to
|
||||||
|
// host memory. Trivial cost on all known backends.
|
||||||
|
cfg.usage = WGPUTextureUsage_RenderAttachment | WGPUTextureUsage_CopySrc;
|
||||||
cfg.width = uint32_t(width_px);
|
cfg.width = uint32_t(width_px);
|
||||||
cfg.height = uint32_t(height_px);
|
cfg.height = uint32_t(height_px);
|
||||||
cfg.presentMode = WGPUPresentMode_Fifo;
|
cfg.presentMode = WGPUPresentMode_Fifo;
|
||||||
@@ -756,6 +764,40 @@ void WgpuViewportWindow::render() {
|
|||||||
wgpuRenderPassEncoderEnd(pass);
|
wgpuRenderPassEncoderEnd(pass);
|
||||||
wgpuRenderPassEncoderRelease(pass);
|
wgpuRenderPassEncoderRelease(pass);
|
||||||
|
|
||||||
|
// ---- 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_.isEmpty();
|
||||||
|
if (want_capture) {
|
||||||
|
const uint32_t row_bytes_unpadded = uint32_t(configured_w_) * 4u;
|
||||||
|
capture_padded_bpr = uint32_t(
|
||||||
|
(row_bytes_unpadded + WGPU_BYTES_PER_ROW_ALIGN - 1)
|
||||||
|
/ WGPU_BYTES_PER_ROW_ALIGN * WGPU_BYTES_PER_ROW_ALIGN);
|
||||||
|
const uint64_t total_bytes = uint64_t(capture_padded_bpr) * uint64_t(configured_h_);
|
||||||
|
|
||||||
|
WGPUBufferDescriptor bdesc = {};
|
||||||
|
bdesc.size = total_bytes;
|
||||||
|
bdesc.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead;
|
||||||
|
bdesc.label = svFromCStr("ifcviewer-wgpu.capture");
|
||||||
|
capture_buffer = wgpuDeviceCreateBuffer(device_, &bdesc);
|
||||||
|
|
||||||
|
WGPUTexelCopyTextureInfo src = {};
|
||||||
|
src.texture = surf_tex.texture;
|
||||||
|
src.aspect = WGPUTextureAspect_All;
|
||||||
|
|
||||||
|
WGPUTexelCopyBufferInfo dst = {};
|
||||||
|
dst.buffer = capture_buffer;
|
||||||
|
dst.layout.bytesPerRow = capture_padded_bpr;
|
||||||
|
dst.layout.rowsPerImage = uint32_t(configured_h_);
|
||||||
|
|
||||||
|
WGPUExtent3D extent = {};
|
||||||
|
extent.width = uint32_t(configured_w_);
|
||||||
|
extent.height = uint32_t(configured_h_);
|
||||||
|
extent.depthOrArrayLayers = 1;
|
||||||
|
|
||||||
|
wgpuCommandEncoderCopyTextureToBuffer(enc, &src, &dst, &extent);
|
||||||
|
}
|
||||||
|
|
||||||
WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(enc, nullptr);
|
WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(enc, nullptr);
|
||||||
wgpuQueueSubmit(queue_, 1, &cmd);
|
wgpuQueueSubmit(queue_, 1, &cmd);
|
||||||
|
|
||||||
@@ -763,6 +805,73 @@ void WgpuViewportWindow::render() {
|
|||||||
wgpuCommandEncoderRelease(enc);
|
wgpuCommandEncoderRelease(enc);
|
||||||
wgpuTextureViewRelease(view);
|
wgpuTextureViewRelease(view);
|
||||||
|
|
||||||
|
// ---- Optional capture: map + save PNG -------------------------------
|
||||||
|
if (want_capture && capture_buffer) {
|
||||||
|
struct MapReq { bool done = false; bool ok = false; };
|
||||||
|
MapReq req;
|
||||||
|
|
||||||
|
WGPUBufferMapCallbackInfo mcb = {};
|
||||||
|
mcb.mode = WGPUCallbackMode_AllowProcessEvents;
|
||||||
|
mcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView message,
|
||||||
|
void* ud1, void* /*ud2*/) {
|
||||||
|
auto* r = static_cast<MapReq*>(ud1);
|
||||||
|
r->done = true;
|
||||||
|
r->ok = (status == WGPUMapAsyncStatus_Success);
|
||||||
|
if (!r->ok) {
|
||||||
|
qWarning().noquote() << "wgpu MapAsync failed:" << sv(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mcb.userdata1 = &req;
|
||||||
|
|
||||||
|
const uint64_t total_bytes = uint64_t(capture_padded_bpr) * uint64_t(configured_h_);
|
||||||
|
wgpuBufferMapAsync(capture_buffer, WGPUMapMode_Read, 0, size_t(total_bytes), mcb);
|
||||||
|
while (!req.done) wgpuInstanceProcessEvents(instance_);
|
||||||
|
|
||||||
|
if (req.ok) {
|
||||||
|
const uint8_t* mapped = static_cast<const uint8_t*>(
|
||||||
|
wgpuBufferGetConstMappedRange(capture_buffer, 0, size_t(total_bytes)));
|
||||||
|
|
||||||
|
// Assemble tightly-packed RGBA8 image. Surface is BGRA8 on most
|
||||||
|
// backends (we saw format=28 = BGRA8Unorm), so swap R/B on the
|
||||||
|
// fly. If a future surface_format_ is RGBA8, just memcpy.
|
||||||
|
const bool is_bgra =
|
||||||
|
surface_format_ == WGPUTextureFormat_BGRA8Unorm ||
|
||||||
|
surface_format_ == WGPUTextureFormat_BGRA8UnormSrgb;
|
||||||
|
const uint32_t w = uint32_t(configured_w_);
|
||||||
|
const uint32_t h = uint32_t(configured_h_);
|
||||||
|
QImage img(int(w), int(h), QImage::Format_RGBA8888);
|
||||||
|
for (uint32_t y = 0; y < h; ++y) {
|
||||||
|
const uint8_t* src_row = mapped + size_t(y) * capture_padded_bpr;
|
||||||
|
uint8_t* dst_row = img.scanLine(int(y));
|
||||||
|
if (is_bgra) {
|
||||||
|
for (uint32_t x = 0; x < w; ++x) {
|
||||||
|
dst_row[x * 4 + 0] = src_row[x * 4 + 2]; // R <- B
|
||||||
|
dst_row[x * 4 + 1] = src_row[x * 4 + 1]; // G
|
||||||
|
dst_row[x * 4 + 2] = src_row[x * 4 + 0]; // B <- R
|
||||||
|
dst_row[x * 4 + 3] = src_row[x * 4 + 3]; // A
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
std::memcpy(dst_row, src_row, size_t(w) * 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wgpuBufferUnmap(capture_buffer);
|
||||||
|
|
||||||
|
if (img.save(pending_screenshot_path_, "PNG")) {
|
||||||
|
qInfo().noquote() << "[wgpu] saved screenshot:"
|
||||||
|
<< pending_screenshot_path_ << "(" << w << "x" << h << ")";
|
||||||
|
} else {
|
||||||
|
qWarning().noquote() << "[wgpu] QImage::save failed for"
|
||||||
|
<< pending_screenshot_path_;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wgpuBufferRelease(capture_buffer);
|
||||||
|
|
||||||
|
const bool quit_after = pending_screenshot_quit_;
|
||||||
|
pending_screenshot_path_.clear();
|
||||||
|
pending_screenshot_quit_ = false;
|
||||||
|
if (quit_after) QCoreApplication::quit();
|
||||||
|
}
|
||||||
|
|
||||||
wgpuSurfacePresent(surface_);
|
wgpuSurfacePresent(surface_);
|
||||||
wgpuTextureRelease(surf_tex.texture);
|
wgpuTextureRelease(surf_tex.texture);
|
||||||
}
|
}
|
||||||
@@ -1035,6 +1144,32 @@ void WgpuViewportWindow::viewAll() {
|
|||||||
if (isExposed()) requestUpdate();
|
if (isExposed()) requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// One-shot framebuffer capture → PNG
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// WebGPU's buffer<->texture copies require bytes-per-row to be a multiple of
|
||||||
|
// 256. For an RGBA8 (or BGRA8) source the natural row stride width*4 rarely
|
||||||
|
// satisfies that, so we round up and strip the padding when assembling the
|
||||||
|
// QImage.
|
||||||
|
//
|
||||||
|
// Capture flow:
|
||||||
|
// 1. After the render pass + before present, encode a copyTextureToBuffer
|
||||||
|
// into a CPU-mappable buffer.
|
||||||
|
// 2. Submit, then wgpuBufferMapAsync (CallbackMode_AllowProcessEvents) and
|
||||||
|
// spin wgpuInstanceProcessEvents until the callback signals completion.
|
||||||
|
// 3. Strip per-row padding into a QImage; convert BGRA↔RGBA if needed;
|
||||||
|
// save PNG; optionally quit the app.
|
||||||
|
|
||||||
|
#include <QImage>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
|
||||||
|
void WgpuViewportWindow::captureNextFrameToPng(const QString& path, bool quit_after) {
|
||||||
|
pending_screenshot_path_ = path;
|
||||||
|
pending_screenshot_quit_ = quit_after;
|
||||||
|
if (isExposed()) requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
void WgpuViewportWindow::shutdown() {
|
void WgpuViewportWindow::shutdown() {
|
||||||
// Release per-model buffers before the device they were created from.
|
// Release per-model buffers before the device they were created from.
|
||||||
for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m);
|
for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m);
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ public:
|
|||||||
// can re-invoke to re-frame.
|
// can re-invoke to re-frame.
|
||||||
void viewAll();
|
void viewAll();
|
||||||
|
|
||||||
|
// Queue a one-shot framebuffer capture: the next rendered frame is
|
||||||
|
// copied back to host memory and saved to `path` as PNG. If
|
||||||
|
// `quit_after` is true, QCoreApplication::quit() is called once the
|
||||||
|
// PNG is written. Use this for headless verification and pixel-diff
|
||||||
|
// parity testing against the GL backend.
|
||||||
|
void captureNextFrameToPng(const QString& path, bool quit_after = true);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void exposeEvent(QExposeEvent* event) override;
|
void exposeEvent(QExposeEvent* event) override;
|
||||||
void resizeEvent(QResizeEvent* event) override;
|
void resizeEvent(QResizeEvent* event) override;
|
||||||
@@ -147,6 +154,10 @@ private:
|
|||||||
// subsequent loads from snapping the camera away from where the
|
// subsequent loads from snapping the camera away from where the
|
||||||
// user pointed it.
|
// user pointed it.
|
||||||
bool initial_view_applied_ = false;
|
bool initial_view_applied_ = false;
|
||||||
|
|
||||||
|
// Pending one-shot screenshot, captured at the end of the next render().
|
||||||
|
QString pending_screenshot_path_;
|
||||||
|
bool pending_screenshot_quit_ = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // WGPUVIEWPORTWINDOW_H
|
#endif // WGPUVIEWPORTWINDOW_H
|
||||||
|
|||||||
Reference in New Issue
Block a user