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:
Dion Moult
2026-05-27 20:12:38 +10:00
parent f4243038bd
commit 1bbcd10bc0
2 changed files with 311 additions and 3 deletions
+284
View File
@@ -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; }
+27 -3
View File
@@ -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