mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 09:48:32 +00:00
wgpu backend: pick pass with sync R32UInt readback
Stage 4 of the wgpu port. LMB-click-without-drag now resolves the
object_id under the cursor by running a dedicated pick render and
copying back the single texel at the click position.
- Pick pipeline reuses the existing pipeline_layout_ (same bindings
as main: frame uniform at group=0, per-model storages at group=1).
Different vs / fs entry points (vs_pick / fs_pick) in the main
WGSL module — the vertex pulling logic is duplicated for now but
the bind group layout match means no pipeline_layout rebuild and
pickObjectAt can reuse the current frame's already-uploaded
visible_draws + per-model bind groups.
- Pick FBO: surface-sized R32UInt color attachment + Depth32Float
depth, both single-sample (no MSAA — pick needs exact texel
access). CopySrc on the color so we can copyTextureToBuffer the
1×1 click region. Recreated on surface resize.
- pickObjectAt: encodes a one-shot pick pass + a single texel copy
into a 256-byte staging buffer, submits, mapAsync, sync-spins
processEvents until ready. Synchronous wait is fine here — pick
runs on click, not per-frame, so a sub-ms stall is invisible.
- Mouse integration: existing LMB drag-orbit preserved. A 3-pixel
threshold promotes drag (set nav_dragged_); release without
dragging triggers pickObjectAt at the release coords (logical
Qt → physical pixels via devicePixelRatio). object_id is logged;
selection state to consume the id arrives with stage 5.
Object_id 0 means miss (clear value); the pick attachment is cleared
to 0 before each pass and the fragment writes the instance's
object_id, so any non-zero result is a real hit on a drawn instance.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -356,6 +356,53 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||
// bytes match the GL backend (see srgbToLinear above).
|
||||
return vec4<f32>(srgbToLinear(color), in.color.a);
|
||||
}
|
||||
|
||||
// --------------------------- Pick pipeline ---------------------------------
|
||||
// Same vertex pulling as vs_main, but VsOutPick carries only the object_id
|
||||
// (flat-interpolated). Fragment writes the object_id to an R32UInt target.
|
||||
// Background (no draw) reads 0 because the pick attachment is cleared to 0.
|
||||
|
||||
struct VsOutPick {
|
||||
@builtin(position) clip_pos: vec4<f32>,
|
||||
@location(0) @interpolate(flat) object_id: u32,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_pick(@builtin(vertex_index) vid: u32) -> VsOutPick {
|
||||
var out: VsOutPick;
|
||||
if (vid >= u_model.total_vertex_count) {
|
||||
out.clip_pos = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
||||
out.object_id = 0u;
|
||||
return out;
|
||||
}
|
||||
|
||||
let draw_idx = find_draw(vid);
|
||||
let local_v = vid - prefix_sums[draw_idx];
|
||||
let item = visible_draws[draw_idx];
|
||||
let mesh_local_index = indices[item.ebo_first_u32 + local_v];
|
||||
let v_global = item.base_vertex + mesh_local_index;
|
||||
let inst = instances[item.instance_idx];
|
||||
let mq = meshes[item.mesh_id];
|
||||
|
||||
let w0 = vertices[v_global * 3u + 0u];
|
||||
let w1 = vertices[v_global * 3u + 1u];
|
||||
let pos_norm = vec3<f32>(
|
||||
f32(w0 & 0xFFFFu) / 65535.0,
|
||||
f32((w0 >> 16u) & 0xFFFFu) / 65535.0,
|
||||
f32(w1 & 0xFFFFu) / 65535.0,
|
||||
);
|
||||
let pos_local = mix(mq.aabb_min.xyz, mq.aabb_max.xyz, pos_norm);
|
||||
let world4 = inst.transform * vec4<f32>(pos_local, 1.0);
|
||||
|
||||
out.clip_pos = u_frame.view_proj * world4;
|
||||
out.object_id = inst.object_id;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_pick(in: VsOutPick) -> @location(0) u32 {
|
||||
return in.object_id;
|
||||
}
|
||||
)";
|
||||
|
||||
// Helper: build a WGPUStringView from a null-terminated C string literal.
|
||||
@@ -759,6 +806,7 @@ bool WgpuViewportWindow::initWgpu() {
|
||||
if (!buildPipelines()) return false;
|
||||
if (!buildHizPipeline()) return false;
|
||||
if (!buildEdgePipeline()) return false;
|
||||
if (!buildPickPipeline()) return false;
|
||||
|
||||
qInfo() << "wgpu init OK; surface format =" << int(surface_format_);
|
||||
return true;
|
||||
@@ -1186,6 +1234,218 @@ void WgpuViewportWindow::releaseEdgeResources() {
|
||||
if (edge_bgl_) { wgpuBindGroupLayoutRelease(edge_bgl_); edge_bgl_ = nullptr; }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Pick pipeline (stage 4)
|
||||
// -----------------------------------------------------------------------------
|
||||
//
|
||||
// Same vertex pulling architecture as the main pipeline; reuses
|
||||
// pipeline_layout_ so per-frame and per-model bind groups stay shared with
|
||||
// the main draw. Differences are in the fragment (one R32UInt output) and
|
||||
// the render target attachments (single-sample, surface-sized pick FBO).
|
||||
|
||||
bool WgpuViewportWindow::buildPickPipeline() {
|
||||
WGPUColorTargetState color_target = {};
|
||||
color_target.format = WGPUTextureFormat_R32Uint;
|
||||
color_target.writeMask = WGPUColorWriteMask_All;
|
||||
|
||||
WGPUFragmentState frag = {};
|
||||
frag.module = main_shader_module_;
|
||||
frag.entryPoint = svFromCStr("fs_pick");
|
||||
frag.targetCount = 1;
|
||||
frag.targets = &color_target;
|
||||
|
||||
WGPUDepthStencilState depth = {};
|
||||
depth.format = WGPUTextureFormat_Depth32Float;
|
||||
depth.depthWriteEnabled = WGPUOptionalBool_True;
|
||||
depth.depthCompare = WGPUCompareFunction_Less;
|
||||
depth.stencilFront.compare = WGPUCompareFunction_Always;
|
||||
depth.stencilBack.compare = WGPUCompareFunction_Always;
|
||||
|
||||
WGPURenderPipelineDescriptor rp_desc = {};
|
||||
rp_desc.layout = pipeline_layout_;
|
||||
rp_desc.label = svFromCStr("ifcviewer-wgpu.pick_pipeline");
|
||||
rp_desc.vertex.module = main_shader_module_;
|
||||
rp_desc.vertex.entryPoint = svFromCStr("vs_pick");
|
||||
rp_desc.vertex.bufferCount = 0;
|
||||
rp_desc.fragment = &frag;
|
||||
rp_desc.depthStencil = &depth;
|
||||
rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList;
|
||||
rp_desc.primitive.cullMode = WGPUCullMode_Back;
|
||||
rp_desc.primitive.frontFace = WGPUFrontFace_CCW;
|
||||
rp_desc.multisample.count = 1;
|
||||
rp_desc.multisample.mask = 0xFFFFFFFFu;
|
||||
|
||||
pick_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
|
||||
if (!pick_pipeline_) {
|
||||
qWarning() << "wgpu pick pipeline creation failed";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void WgpuViewportWindow::ensurePickAttachments(int w, int h) {
|
||||
if (w <= 0 || h <= 0) return;
|
||||
if (w == pick_w_ && h == pick_h_ && pick_color_view_) return;
|
||||
|
||||
if (pick_color_view_) { wgpuTextureViewRelease(pick_color_view_); pick_color_view_ = nullptr; }
|
||||
if (pick_color_texture_) { wgpuTextureRelease(pick_color_texture_); pick_color_texture_ = nullptr; }
|
||||
if (pick_depth_view_) { wgpuTextureViewRelease(pick_depth_view_); pick_depth_view_ = nullptr; }
|
||||
if (pick_depth_texture_) { wgpuTextureRelease(pick_depth_texture_); pick_depth_texture_ = nullptr; }
|
||||
|
||||
WGPUTextureDescriptor cdesc = {};
|
||||
cdesc.usage = WGPUTextureUsage_RenderAttachment | WGPUTextureUsage_CopySrc;
|
||||
cdesc.dimension = WGPUTextureDimension_2D;
|
||||
cdesc.size.width = uint32_t(w);
|
||||
cdesc.size.height = uint32_t(h);
|
||||
cdesc.size.depthOrArrayLayers = 1;
|
||||
cdesc.format = WGPUTextureFormat_R32Uint;
|
||||
cdesc.mipLevelCount = 1;
|
||||
cdesc.sampleCount = 1;
|
||||
cdesc.label = svFromCStr("ifcviewer-wgpu.pick_color");
|
||||
pick_color_texture_ = wgpuDeviceCreateTexture(device_, &cdesc);
|
||||
pick_color_view_ = wgpuTextureCreateView(pick_color_texture_, nullptr);
|
||||
|
||||
WGPUTextureDescriptor ddesc = {};
|
||||
ddesc.usage = WGPUTextureUsage_RenderAttachment;
|
||||
ddesc.dimension = WGPUTextureDimension_2D;
|
||||
ddesc.size.width = uint32_t(w);
|
||||
ddesc.size.height = uint32_t(h);
|
||||
ddesc.size.depthOrArrayLayers = 1;
|
||||
ddesc.format = WGPUTextureFormat_Depth32Float;
|
||||
ddesc.mipLevelCount = 1;
|
||||
ddesc.sampleCount = 1;
|
||||
ddesc.label = svFromCStr("ifcviewer-wgpu.pick_depth");
|
||||
pick_depth_texture_ = wgpuDeviceCreateTexture(device_, &ddesc);
|
||||
WGPUTextureViewDescriptor dvdesc = {};
|
||||
dvdesc.format = WGPUTextureFormat_Depth32Float;
|
||||
dvdesc.dimension = WGPUTextureViewDimension_2D;
|
||||
dvdesc.mipLevelCount = 1;
|
||||
dvdesc.arrayLayerCount = 1;
|
||||
dvdesc.aspect = WGPUTextureAspect_DepthOnly;
|
||||
pick_depth_view_ = wgpuTextureCreateView(pick_depth_texture_, &dvdesc);
|
||||
|
||||
if (!pick_staging_buffer_) {
|
||||
// 256 B is the smallest aligned staging buffer that satisfies
|
||||
// WGPU_BYTES_PER_ROW_ALIGN for a single-row copy.
|
||||
WGPUBufferDescriptor sb = {};
|
||||
sb.size = 256;
|
||||
sb.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead;
|
||||
sb.label = svFromCStr("ifcviewer-wgpu.pick_staging");
|
||||
pick_staging_buffer_ = wgpuDeviceCreateBuffer(device_, &sb);
|
||||
}
|
||||
|
||||
pick_w_ = w;
|
||||
pick_h_ = h;
|
||||
}
|
||||
|
||||
void WgpuViewportWindow::releasePickResources() {
|
||||
if (pick_color_view_) { wgpuTextureViewRelease(pick_color_view_); pick_color_view_ = nullptr; }
|
||||
if (pick_color_texture_) { wgpuTextureRelease(pick_color_texture_); pick_color_texture_ = nullptr; }
|
||||
if (pick_depth_view_) { wgpuTextureViewRelease(pick_depth_view_); pick_depth_view_ = nullptr; }
|
||||
if (pick_depth_texture_) { wgpuTextureRelease(pick_depth_texture_); pick_depth_texture_ = nullptr; }
|
||||
if (pick_staging_buffer_){ wgpuBufferRelease(pick_staging_buffer_); pick_staging_buffer_ = nullptr; }
|
||||
if (pick_pipeline_) { wgpuRenderPipelineRelease(pick_pipeline_); pick_pipeline_ = nullptr; }
|
||||
pick_w_ = pick_h_ = 0;
|
||||
}
|
||||
|
||||
uint32_t WgpuViewportWindow::pickObjectAt(int x_pixels, int y_pixels) {
|
||||
if (!pick_pipeline_ || !device_ || !queue_ || models_gpu_.empty()) return 0;
|
||||
if (configured_w_ <= 0 || configured_h_ <= 0) return 0;
|
||||
if (x_pixels < 0 || y_pixels < 0 ||
|
||||
x_pixels >= configured_w_ || y_pixels >= configured_h_) return 0;
|
||||
|
||||
ensurePickAttachments(configured_w_, configured_h_);
|
||||
if (!pick_color_view_ || !pick_depth_view_ || !pick_staging_buffer_) return 0;
|
||||
|
||||
// The current frame's visible_draws are already on the GPU (uploaded
|
||||
// by the last render's cullModelCpuUpload), and the per-model bind
|
||||
// groups + frame uniform are valid. Just encode a one-shot pick pass.
|
||||
|
||||
WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(device_, nullptr);
|
||||
|
||||
WGPURenderPassColorAttachment color = {};
|
||||
color.view = pick_color_view_;
|
||||
color.loadOp = WGPULoadOp_Clear;
|
||||
color.storeOp = WGPUStoreOp_Store;
|
||||
color.clearValue = { 0.0, 0.0, 0.0, 0.0 }; // object_id == 0 means miss
|
||||
color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
|
||||
|
||||
WGPURenderPassDepthStencilAttachment depth = {};
|
||||
depth.view = pick_depth_view_;
|
||||
depth.depthLoadOp = WGPULoadOp_Clear;
|
||||
depth.depthStoreOp = WGPUStoreOp_Store;
|
||||
depth.depthClearValue = 1.0f;
|
||||
depth.stencilLoadOp = WGPULoadOp_Undefined;
|
||||
depth.stencilStoreOp = WGPUStoreOp_Undefined;
|
||||
depth.stencilReadOnly = true;
|
||||
|
||||
WGPURenderPassDescriptor pass_desc = {};
|
||||
pass_desc.colorAttachmentCount = 1;
|
||||
pass_desc.colorAttachments = &color;
|
||||
pass_desc.depthStencilAttachment = &depth;
|
||||
pass_desc.label = svFromCStr("ifcviewer-wgpu.pick_pass");
|
||||
|
||||
WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
|
||||
wgpuRenderPassEncoderSetPipeline(pass, pick_pipeline_);
|
||||
wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr);
|
||||
for (const auto& [mid, m] : models_gpu_) {
|
||||
if (m.hidden || !m.bind_group || m.total_visible_vertices == 0) continue;
|
||||
wgpuRenderPassEncoderSetBindGroup(pass, 1, m.bind_group, 0, nullptr);
|
||||
wgpuRenderPassEncoderDraw(pass, m.total_visible_vertices, 1, 0, 0);
|
||||
}
|
||||
wgpuRenderPassEncoderEnd(pass);
|
||||
wgpuRenderPassEncoderRelease(pass);
|
||||
|
||||
// Copy the single texel at (x, y) into the staging buffer's first 4 B.
|
||||
WGPUTexelCopyTextureInfo src = {};
|
||||
src.texture = pick_color_texture_;
|
||||
src.aspect = WGPUTextureAspect_All;
|
||||
src.origin.x = uint32_t(x_pixels);
|
||||
src.origin.y = uint32_t(y_pixels);
|
||||
|
||||
WGPUTexelCopyBufferInfo dst = {};
|
||||
dst.buffer = pick_staging_buffer_;
|
||||
dst.layout.bytesPerRow = 256;
|
||||
dst.layout.rowsPerImage = 1;
|
||||
|
||||
WGPUExtent3D extent = {};
|
||||
extent.width = 1;
|
||||
extent.height = 1;
|
||||
extent.depthOrArrayLayers = 1;
|
||||
|
||||
wgpuCommandEncoderCopyTextureToBuffer(enc, &src, &dst, &extent);
|
||||
|
||||
WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(enc, nullptr);
|
||||
wgpuQueueSubmit(queue_, 1, &cmd);
|
||||
wgpuCommandBufferRelease(cmd);
|
||||
wgpuCommandEncoderRelease(enc);
|
||||
|
||||
// Sync wait for the readback — pick is interactive (click) and rare,
|
||||
// so the GPU stall here is fine.
|
||||
struct MapReq { bool done = false; bool ok = false; };
|
||||
MapReq req;
|
||||
WGPUBufferMapCallbackInfo mcb = {};
|
||||
mcb.mode = WGPUCallbackMode_AllowProcessEvents;
|
||||
mcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*msg*/,
|
||||
void* ud1, void* /*ud2*/) {
|
||||
auto* r = static_cast<MapReq*>(ud1);
|
||||
r->done = true;
|
||||
r->ok = (status == WGPUMapAsyncStatus_Success);
|
||||
};
|
||||
mcb.userdata1 = &req;
|
||||
|
||||
wgpuBufferMapAsync(pick_staging_buffer_, WGPUMapMode_Read, 0, 256, mcb);
|
||||
while (!req.done) wgpuInstanceProcessEvents(instance_);
|
||||
if (!req.ok) return 0;
|
||||
|
||||
const uint32_t* mapped = static_cast<const uint32_t*>(
|
||||
wgpuBufferGetConstMappedRange(pick_staging_buffer_, 0, 256));
|
||||
const uint32_t object_id = mapped ? mapped[0] : 0u;
|
||||
wgpuBufferUnmap(pick_staging_buffer_);
|
||||
|
||||
return object_id;
|
||||
}
|
||||
|
||||
bool WgpuViewportWindow::buildHizPipeline() {
|
||||
// Bind group layout: MSAA depth texture + small uniform.
|
||||
WGPUBindGroupLayoutEntry entries[2] = {};
|
||||
@@ -2528,10 +2788,26 @@ void WgpuViewportWindow::captureNextFrameToPng(const QString& path, bool quit_af
|
||||
void WgpuViewportWindow::mousePressEvent(QMouseEvent* event) {
|
||||
nav_active_button_ = event->button();
|
||||
nav_last_pos_ = event->position().toPoint();
|
||||
nav_press_pos_ = nav_last_pos_;
|
||||
nav_dragged_ = false;
|
||||
}
|
||||
|
||||
void WgpuViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
|
||||
if (event->button() == nav_active_button_) {
|
||||
// LMB-click without drag → pick the object under the cursor.
|
||||
if (event->button() == Qt::LeftButton && !nav_dragged_) {
|
||||
const QPoint pos = event->position().toPoint();
|
||||
const int px = int(pos.x() * devicePixelRatio());
|
||||
const int py = int(pos.y() * devicePixelRatio());
|
||||
const uint32_t id = pickObjectAt(px, py);
|
||||
if (id != 0) {
|
||||
qInfo().noquote().nospace()
|
||||
<< "[wgpu pick] object_id=" << id << " at (" << pos.x()
|
||||
<< ", " << pos.y() << ")";
|
||||
} else {
|
||||
qInfo().noquote() << "[wgpu pick] miss";
|
||||
}
|
||||
}
|
||||
nav_active_button_ = Qt::NoButton;
|
||||
}
|
||||
}
|
||||
@@ -2544,6 +2820,13 @@ void WgpuViewportWindow::mouseMoveEvent(QMouseEvent* event) {
|
||||
const int dy = pos.y() - nav_last_pos_.y();
|
||||
nav_last_pos_ = pos;
|
||||
|
||||
// Promote to drag past 3 px so a wobbly click doesn't get reclassified.
|
||||
if (!nav_dragged_) {
|
||||
const int adx = std::abs(pos.x() - nav_press_pos_.x());
|
||||
const int ady = std::abs(pos.y() - nav_press_pos_.y());
|
||||
if (adx + ady > 3) nav_dragged_ = true;
|
||||
}
|
||||
|
||||
if (nav_active_button_ == Qt::LeftButton) {
|
||||
// Orbit. Sign convention matches the GL viewport: drag-right rotates
|
||||
// the world right (yaw -= dx), drag-down tilts the camera up so we
|
||||
@@ -2597,6 +2880,7 @@ void WgpuViewportWindow::shutdown() {
|
||||
releaseMsaaColorTexture();
|
||||
releaseHizResources();
|
||||
releaseEdgeResources();
|
||||
releasePickResources();
|
||||
|
||||
if (frame_bind_group_) { wgpuBindGroupRelease(frame_bind_group_); frame_bind_group_ = nullptr; }
|
||||
if (frame_uniform_buffer_) { wgpuBufferRelease(frame_uniform_buffer_); frame_uniform_buffer_ = nullptr; }
|
||||
|
||||
@@ -124,6 +124,15 @@ private:
|
||||
bool buildEdgePipeline();
|
||||
void encodeEdgePass(WGPUCommandEncoder enc, WGPUTextureView surface_view);
|
||||
void releaseEdgeResources();
|
||||
|
||||
bool buildPickPipeline();
|
||||
void ensurePickAttachments(int w, int h);
|
||||
void releasePickResources();
|
||||
// Synchronous pick: encodes a one-shot R32UInt render of the current
|
||||
// visible_draws against the click pixel, copies the single texel back,
|
||||
// waits, and returns the object_id (0 if nothing was hit). Call from
|
||||
// the main thread between renders.
|
||||
uint32_t pickObjectAt(int x_pixels, int y_pixels);
|
||||
void ensureHizTextures(int viewport_w, int viewport_h);
|
||||
void releaseHizResources();
|
||||
// Resolves the just-rendered MSAA depth into the small single-sample
|
||||
@@ -261,6 +270,20 @@ private:
|
||||
WGPUBindGroup edge_bind_group_ = nullptr;
|
||||
bool edges_enabled_ = true;
|
||||
|
||||
// Pick pass (stage 4). Single-sample R32UInt target + depth, vertex-
|
||||
// pulled from the same visible_draws / instances buffers as the main
|
||||
// pass — pick fragment outputs the instance's object_id. The pick
|
||||
// pipeline reuses pipeline_layout_ because it needs the same set of
|
||||
// bindings (frame uniform at group=0, per-model storages at group=1).
|
||||
WGPURenderPipeline pick_pipeline_ = nullptr;
|
||||
WGPUTexture pick_color_texture_ = nullptr;
|
||||
WGPUTextureView pick_color_view_ = nullptr;
|
||||
WGPUTexture pick_depth_texture_ = nullptr;
|
||||
WGPUTextureView pick_depth_view_ = nullptr;
|
||||
WGPUBuffer pick_staging_buffer_ = nullptr; // 256 B (single texel + bytes-per-row pad)
|
||||
int pick_w_ = 0;
|
||||
int pick_h_ = 0;
|
||||
|
||||
enum class HizSlotState : uint8_t { Idle, Mapping, Mapped };
|
||||
static constexpr int HIZ_SLOTS = 2;
|
||||
WGPUBuffer hiz_staging_buffers_[HIZ_SLOTS] = { nullptr, nullptr };
|
||||
@@ -335,11 +358,12 @@ private:
|
||||
bool pending_screenshot_quit_ = false;
|
||||
|
||||
// Mouse navigation state. LMB drag orbits, MMB drag pans, wheel zooms.
|
||||
// No Blender/Maya preset selection yet — that arrives with the AppSettings
|
||||
// port (post-task-12). Selection is unbound (no pick pass), so LMB is
|
||||
// safe to dedicate to orbit for now.
|
||||
// LMB-click-without-drag picks the object under the cursor. No
|
||||
// Blender/Maya preset awareness yet — that arrives with AppSettings.
|
||||
Qt::MouseButton nav_active_button_ = Qt::NoButton;
|
||||
QPoint nav_last_pos_;
|
||||
QPoint 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
|
||||
|
||||
Reference in New Issue
Block a user