ifcviewer: x-ray marquee selects through occluders

Box select resolved hits by reading the depth-tested object_id MRT, so only
the front-most surface in each pixel could ever come back. In x-ray that is
wrong twice over: you can see the geometry behind, and you still cannot
select it.

Add a second box-pick path used only while x-ray is active. It runs the same
vs_pick geometry through fs_boxpick with depth compare Always, no depth write
and no colour targets, scissored to the marquee — so nothing culls a fragment
behind another and the pass's only output is an atomicOr of one bit per
object into a hit bitmask. Reading that back gives every object with geometry
inside the box, occluded or not.

The bitmask rides alongside sel_flags at group(0) binding 2, allocated and
bound by ensureSelectionFlagsBuffer so the two can never disagree about how
many object ids exist. The layout entry is FRAGMENT-visible only: WebGPU
forbids a read_write storage buffer in the vertex stage, and every pipeline
shares this layout. Back-face culling is off for the pass — a box landing
inside a closed solid would otherwise see none of its faces and miss it.

Outside x-ray the depth-tested read stands, so a plain marquee still takes
only what is visible. A failure to build the pipeline falls back to that path
rather than breaking box select.

Tests cover the three properties worth having: x-ray selects strictly more,
its result is a superset of the plain one (a bare count would wave through a
wrong scissor or an off-by-one in the bit decode), and turning x-ray off
restores front-most-only. They need a model with real self-occlusion, which
sidecar_bake cannot currently produce — it segfaults on any input, including
the pristine sample.ifc — so they skip with an explanation until a fixture is
supplied. See the note at the top of the spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-28 15:48:16 +10:00
parent 8cc96127ce
commit a4f2075c3d
3 changed files with 444 additions and 4 deletions
@@ -0,0 +1,151 @@
import { test, expect } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// X-ray marquee must select THROUGH occluders (#xray-boxpick).
//
// Outside x-ray a box select reads the depth-tested object_id MRT, so only the
// front-most surface in each pixel can be returned. In x-ray the viewer runs a
// depth-less pass scissored to the marquee instead, ORing one bit per object
// into a bitmask — so anything with geometry inside the box is selected whether
// or not something sits in front of it.
//
// The embedded sample is three well-separated elements with barely any mutual
// occlusion, which cannot tell the two paths apart. These need a model whose
// elements genuinely stack behind each other, served as occluders.ifcview.
//
// That fixture is NOT committed. Authoring one needs sidecar_bake, which
// currently segfaults on any input including the pristine sample.ifc — so the
// suite skips rather than failing for a reason that has nothing to do with the
// feature. To run it, drop any multi-storey .ifcview into the serve directory
// as occluders.ifcview; once sidecar_bake works again this should become a
// purpose-built fixture (three stacked plates is enough) generated by
// make_sample.py and committed alongside sample.ifcview.
const MODEL = '/IfcViewerWeb.html?model=/occluders.ifcview';
const SERVE_DIR = process.env.WEB_BUILD_DIR
? path.resolve(process.env.WEB_BUILD_DIR)
: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../build-web');
const FIXTURE = path.join(SERVE_DIR, 'occluders.ifcview');
test.beforeAll(() => {
test.skip(!fs.existsSync(FIXTURE),
`no ${FIXTURE} — see the note at the top of this file for how to supply one`);
});
async function loadModel(page) {
const loaded = page.waitForEvent('console', {
predicate: (m) => /loaded sidecar \(source/.test(m.text()),
timeout: 30_000,
});
await page.goto(MODEL);
await page.waitForFunction(
() => !!(window.Module && window.Module._app_ptr), null, { timeout: 30_000 });
await loaded;
await page.waitForTimeout(1200); // stream chunks + settle
}
// Drag the select button (Web preset: RMB) across the given canvas rect.
async function marquee(page, box, fx0, fy0, fx1, fy1) {
const px = (fx, fy) => [box.x + box.width * fx, box.y + box.height * fy];
const [x0, y0] = px(fx0, fy0);
const [x1, y1] = px(fx1, fy1);
await page.mouse.move(x0, y0);
await page.mouse.down({ button: 'right' });
await page.mouse.move((x0 + x1) / 2, (y0 + y1) / 2, { steps: 4 });
await page.mouse.move(x1, y1, { steps: 6 });
await page.mouse.up({ button: 'right' });
await page.waitForTimeout(700); // async box-pick map + apply
// ifcv_get_selection_c returns the TOTAL, so (0, 0) is a pure count.
return page.evaluate(() => window.Module._ifcv_get_selection_c(0, 0));
}
const setXray = (page, on) => page.evaluate((want) => {
if ((window.Module._xray_is_active_c() !== 0) !== want) window.Module._toggle_xray_c();
return window.Module._xray_is_active_c();
}, on);
test('x-ray marquee selects through occluders; plain marquee does not', async ({ page }) => {
const gpuErrors = [];
page.on('console', (m) => {
if (/Uncaptured WebGPU error|is invalid|Validation error/i.test(m.text()))
gpuErrors.push(m.text());
});
page.on('pageerror', (e) => gpuErrors.push('pageerror: ' + e.message));
await loadModel(page);
const box = await page.locator('#viewer-canvas').boundingBox();
// Same rect both times — the ONLY difference is x-ray.
const R = [0.3, 0.3, 0.7, 0.7];
expect(await setXray(page, false)).toBe(0);
const visibleOnly = await marquee(page, box, ...R);
await page.evaluate(() => window.Module._ifcv_apply_selection_c(0, 0, 0));
expect(await setXray(page, true)).toBe(1);
const throughAll = await marquee(page, box, ...R);
expect(visibleOnly, 'plain marquee selected nothing — the rect missed the model')
.toBeGreaterThan(0);
expect(throughAll,
`x-ray marquee (${throughAll}) did not select more than the depth-tested one ` +
`(${visibleOnly}) — it is still taking only front-most surfaces`)
.toBeGreaterThan(visibleOnly);
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
});
test('x-ray marquee result is a superset of the plain one', async ({ page }) => {
// Every element a depth-tested marquee finds is visible, so selecting through
// must still include it. Catches a box-pick that returns a *different* set
// rather than a bigger one (wrong scissor, stale bits, off-by-one in the id
// decode) — all of which a bare count comparison would wave through.
await loadModel(page);
const box = await page.locator('#viewer-canvas').boundingBox();
const R = [0.35, 0.35, 0.65, 0.65];
const ids = () => page.evaluate(() => {
const n = window.Module._ifcv_get_selection_c(0, 0);
if (!n) return [];
const ptr = window.Module._malloc(n * 4);
try {
window.Module._ifcv_get_selection_c(ptr, n);
return Array.from(window.Module.HEAPU32.subarray(ptr >>> 2, (ptr >>> 2) + n));
} finally { window.Module._free(ptr); }
});
await setXray(page, false);
await marquee(page, box, ...R);
const plain = await ids();
await page.evaluate(() => window.Module._ifcv_apply_selection_c(0, 0, 0));
await setXray(page, true);
await marquee(page, box, ...R);
const xray = new Set(await ids());
expect(plain.length).toBeGreaterThan(0);
const missing = plain.filter((id) => !xray.has(id));
expect(missing, `x-ray marquee dropped ids the plain one found: ${missing.slice(0, 8)}`)
.toEqual([]);
});
test('leaving x-ray restores front-most-only box select', async ({ page }) => {
// The depth-tested path must not be left behind by the x-ray branch.
await loadModel(page);
const box = await page.locator('#viewer-canvas').boundingBox();
const R = [0.3, 0.3, 0.7, 0.7];
await setXray(page, true);
const through = await marquee(page, box, ...R);
await page.evaluate(() => window.Module._ifcv_apply_selection_c(0, 0, 0));
expect(await setXray(page, false)).toBe(0);
const back = await marquee(page, box, ...R);
expect(back).toBeGreaterThan(0);
expect(back, 'x-ray off still selected through — the branch is sticky')
.toBeLessThan(through);
});
+257 -4
View File
@@ -820,6 +820,14 @@ struct PerModel {
// because we cap the index by arrayLength before fetching.
@group(0) @binding(1) var<storage, read> sel_flags: array<u32>;
// X-ray marquee select: one bit per object_id, set by fs_boxpick. That pass
// runs with depth testing off and the scissor clamped to the marquee rect, so
// every object with a fragment anywhere inside the box sets its bit whether it
// is occluded or not — which is the whole point of selecting through in x-ray.
// Only the box-pick pipeline writes it; the layout entry is FRAGMENT-visible
// only, because WebGPU forbids a read_write storage buffer in the vertex stage.
@group(0) @binding(2) var<storage, read_write> hit_flags: array<atomic<u32>>;
@group(1) @binding(0) var<storage, read> vertices: array<u32>;
@group(1) @binding(1) var<storage, read> meshes: array<MeshQuant>;
@group(1) @binding(2) var<storage, read> instances: array<InstanceRecord>;
@@ -1068,12 +1076,23 @@ fn fs_pick(in: VsOutPick) -> FsOutPick {
out.world_pos = vec4<f32>(in.world_pos, 1.0);
return out;
}
// X-ray marquee. No colour outputs and no depth write — the only result is the
// bit this sets, so every layer under the cursor is recorded rather than just
// the front-most fragment the depth test would leave standing.
@fragment
fn fs_boxpick(in: VsOutPick) {
if (is_section_clipped(in.world_pos)) { discard; }
let word = in.object_id >> 5u;
if (word >= arrayLength(&hit_flags)) { return; }
atomicOr(&hit_flags[word], 1u << (in.object_id & 31u));
}
)";
} // namespace
bool ViewportCore::buildPipelines() {
// ---- Bind group layouts ----------------------------------------------
WGPUBindGroupLayoutEntry frame_entries[2] = {};
WGPUBindGroupLayoutEntry frame_entries[3] = {};
frame_entries[0].binding = 0;
frame_entries[0].visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
frame_entries[0].buffer.type = WGPUBufferBindingType_Uniform;
@@ -1081,9 +1100,14 @@ bool ViewportCore::buildPipelines() {
frame_entries[1].binding = 1;
frame_entries[1].visibility = WGPUShaderStage_Fragment;
frame_entries[1].buffer.type = WGPUBufferBindingType_ReadOnlyStorage;
// X-ray marquee hit bits. Fragment-only: a read_write storage buffer is
// illegal in the vertex stage, and every pipeline shares this layout.
frame_entries[2].binding = 2;
frame_entries[2].visibility = WGPUShaderStage_Fragment;
frame_entries[2].buffer.type = WGPUBufferBindingType_Storage;
WGPUBindGroupLayoutDescriptor frame_bgl_desc = {};
frame_bgl_desc.entryCount = 2;
frame_bgl_desc.entryCount = 3;
frame_bgl_desc.entries = frame_entries;
frame_bgl_desc.label = svFromCStr("ifcviewer-wgpu.frame_bgl");
frame_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &frame_bgl_desc);
@@ -1279,6 +1303,21 @@ void ViewportCore::ensureSelectionFlagsBuffer() {
// Initialise to zero so any unused range reads as "not selected".
// wgpuQueueWriteBuffer with a small zero block is enough; the rest
// is created as zero-initialised by wgpu per the spec.
// The x-ray marquee's hit bits ride the same capacity — one BIT per
// object where the flags take a word, so a thirty-second of the size.
// CopySrc because the box pick reads it back through a staging buffer.
if (hit_flags_buffer_) {
wgpuBufferRelease(hit_flags_buffer_);
hit_flags_buffer_ = nullptr;
}
hit_flags_words_ = (new_cap + 31u) / 32u;
WGPUBufferDescriptor hb = {};
hb.size = uint64_t(hit_flags_words_) * sizeof(uint32_t);
hb.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst
| WGPUBufferUsage_CopySrc;
hb.label = svFromCStr("ifcviewer-wgpu.xray_hit_flags");
hit_flags_buffer_ = wgpuDeviceCreateBuffer(device_, &hb);
}
// Rebuild the frame bind group against the (possibly new) buffer.
@@ -1286,16 +1325,19 @@ void ViewportCore::ensureSelectionFlagsBuffer() {
wgpuBindGroupRelease(frame_bind_group_);
frame_bind_group_ = nullptr;
}
WGPUBindGroupEntry fbg_entries[2] = {};
WGPUBindGroupEntry fbg_entries[3] = {};
fbg_entries[0].binding = 0;
fbg_entries[0].buffer = frame_uniform_buffer_;
fbg_entries[0].size = sizeof(FrameUniforms);
fbg_entries[1].binding = 1;
fbg_entries[1].buffer = selection_flags_buffer_;
fbg_entries[1].size = WGPU_WHOLE_SIZE;
fbg_entries[2].binding = 2;
fbg_entries[2].buffer = hit_flags_buffer_;
fbg_entries[2].size = WGPU_WHOLE_SIZE;
WGPUBindGroupDescriptor fbg_desc = {};
fbg_desc.layout = frame_bgl_;
fbg_desc.entryCount = 2;
fbg_desc.entryCount = 3;
fbg_desc.entries = fbg_entries;
fbg_desc.label = svFromCStr("ifcviewer-wgpu.frame_bind_group");
frame_bind_group_ = wgpuDeviceCreateBindGroup(device_, &fbg_desc);
@@ -1826,6 +1868,8 @@ void ViewportCore::shutdown() {
if (frame_uniform_buffer_) { wgpuBufferRelease(frame_uniform_buffer_); frame_uniform_buffer_ = nullptr; }
if (selection_flags_buffer_) { wgpuBufferRelease(selection_flags_buffer_); selection_flags_buffer_ = nullptr; }
selection_flags_capacity_ = 0;
if (hit_flags_buffer_) { wgpuBufferRelease(hit_flags_buffer_); hit_flags_buffer_ = nullptr; }
hit_flags_words_ = 0;
if (main_pipeline_) { wgpuRenderPipelineRelease(main_pipeline_); main_pipeline_ = nullptr; }
if (main_pipeline_no_cull_) { wgpuRenderPipelineRelease(main_pipeline_no_cull_); main_pipeline_no_cull_ = nullptr; }
if (main_pipeline_transparent_) { wgpuRenderPipelineRelease(main_pipeline_transparent_); main_pipeline_transparent_ = nullptr; }
@@ -4921,6 +4965,53 @@ bool ViewportCore::buildPickPipeline() {
Log::warn() << "wgpu pick pipeline creation failed";
return false;
}
// The x-ray marquee variant rides along so no caller has to know about it.
// A failure here is not fatal: picksInRect falls back to the depth-tested
// read, which is the pre-x-ray behaviour rather than a broken viewport.
buildBoxPickPipeline();
return true;
}
bool ViewportCore::buildBoxPickPipeline() {
// No colour targets: fs_boxpick's only output is the atomic bit it sets, so
// the pass writes no image at all. A render pass still needs one attachment
// — the pick depth view serves, bound read-only.
WGPUFragmentState frag = {};
frag.module = main_shader_module_;
frag.entryPoint = svFromCStr("fs_boxpick");
frag.targetCount = 0;
frag.targets = nullptr;
// Always/no-write is the whole trick: every fragment survives, so an
// occluded object records its bit just as a front-most one does.
WGPUDepthStencilState depth = {};
depth.format = WGPUTextureFormat_Depth32Float;
depth.depthWriteEnabled = WGPUOptionalBool_False;
depth.depthCompare = WGPUCompareFunction_Always;
depth.stencilFront.compare = WGPUCompareFunction_Always;
depth.stencilBack.compare = WGPUCompareFunction_Always;
WGPURenderPipelineDescriptor rp_desc = {};
rp_desc.layout = pipeline_layout_;
rp_desc.label = svFromCStr("ifcviewer-wgpu.box_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;
// No back-face cull. A box that lands inside a closed solid would otherwise
// see none of its faces and miss the object entirely.
rp_desc.primitive.cullMode = WGPUCullMode_None;
rp_desc.primitive.frontFace = WGPUFrontFace_CCW;
rp_desc.multisample.count = 1;
rp_desc.multisample.mask = 0xFFFFFFFFu;
box_pick_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
if (!box_pick_pipeline_) {
Log::warn() << "wgpu box-pick pipeline creation failed";
return false;
}
return true;
}
@@ -5026,11 +5117,17 @@ void ViewportCore::releasePickResources() {
pick_position_staging_buffer_ = nullptr;
}
if (pick_pipeline_) { wgpuRenderPipelineRelease(pick_pipeline_); pick_pipeline_ = nullptr; }
if (box_pick_pipeline_) { wgpuRenderPipelineRelease(box_pick_pipeline_); box_pick_pipeline_ = nullptr; }
if (box_pick_staging_buffer_) {
wgpuBufferRelease(box_pick_staging_buffer_);
box_pick_staging_buffer_ = nullptr;
}
box_pick_staging_capacity_ = 0;
if (hit_flags_staging_buffer_) {
wgpuBufferRelease(hit_flags_staging_buffer_);
hit_flags_staging_buffer_ = nullptr;
}
hit_flags_staging_capacity_ = 0;
pick_w_ = pick_h_ = 0;
}
@@ -5544,7 +5641,131 @@ std::vector<std::uint32_t> ViewportCore::collectMappedBoxPickIds(
return out;
}
bool ViewportCore::encodeXrayBoxPickToStaging(int& x, int& y, int& w, int& h,
std::uint64_t& needed_bytes_out) {
if (w <= 0 || h <= 0) return false;
if (!box_pick_pipeline_ || !device_ || !queue_ || models_gpu_.empty()) return false;
if (!hit_flags_buffer_ || hit_flags_words_ == 0) return false;
if (configured_w_ <= 0 || configured_h_ <= 0) return false;
if (x < 0) { w += x; x = 0; }
if (y < 0) { h += y; y = 0; }
if (x + w > configured_w_) w = configured_w_ - x;
if (y + h > configured_h_) h = configured_h_ - y;
if (w <= 0 || h <= 0) return false;
ensurePickAttachments(configured_w_, configured_h_);
if (!pick_depth_view_) return false;
const std::uint64_t needed_bytes = std::uint64_t(hit_flags_words_) * sizeof(std::uint32_t);
if (needed_bytes > hit_flags_staging_capacity_) {
if (hit_flags_staging_buffer_) {
wgpuBufferRelease(hit_flags_staging_buffer_);
hit_flags_staging_buffer_ = nullptr;
}
const std::uint64_t cap = std::max<std::uint64_t>(needed_bytes * 2, 4 * 1024);
WGPUBufferDescriptor sb = {};
sb.size = cap;
sb.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead;
sb.label = svFromCStr("ifcviewer-wgpu.hit_flags_staging");
hit_flags_staging_buffer_ = wgpuDeviceCreateBuffer(device_, &sb);
hit_flags_staging_capacity_ = cap;
}
if (!hit_flags_staging_buffer_) return false;
WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(device_, nullptr);
// Every pick starts from no hits; the bits are pure output.
wgpuCommandEncoderClearBuffer(enc, hit_flags_buffer_, 0, needed_bytes);
// Depth is bound read-only and never compared (the pipeline is Always), so
// whatever the last pass left in it is irrelevant.
WGPURenderPassDepthStencilAttachment depth = {};
depth.view = pick_depth_view_;
depth.depthLoadOp = WGPULoadOp_Undefined;
depth.depthStoreOp = WGPUStoreOp_Undefined;
depth.depthReadOnly = true;
depth.stencilLoadOp = WGPULoadOp_Undefined;
depth.stencilStoreOp = WGPUStoreOp_Undefined;
depth.stencilReadOnly = true;
WGPURenderPassDescriptor pass_desc = {};
pass_desc.colorAttachmentCount = 0;
pass_desc.colorAttachments = nullptr;
pass_desc.depthStencilAttachment = &depth;
pass_desc.label = svFromCStr("ifcviewer-wgpu.xray_box_pick_pass");
WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
// The scissor is what makes this a BOX pick: geometry is drawn full-screen
// as usual, and only fragments landing in the marquee survive to set a bit.
wgpuRenderPassEncoderSetScissorRect(pass, std::uint32_t(x), std::uint32_t(y),
std::uint32_t(w), std::uint32_t(h));
wgpuRenderPassEncoderSetPipeline(pass, box_pick_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr);
for (const auto& [session_model_id, m] : models_gpu_) {
if (m.hidden) continue;
for (const auto& c : m.chunks) {
if (!c.bind_group || c.total_visible_vertices == 0) continue;
wgpuRenderPassEncoderSetBindGroup(pass, 1, c.bind_group, 0, nullptr);
wgpuRenderPassEncoderDraw(pass, c.total_visible_vertices, 1, 0, 0);
}
}
wgpuRenderPassEncoderEnd(pass);
wgpuRenderPassEncoderRelease(pass);
wgpuCommandEncoderCopyBufferToBuffer(enc, hit_flags_buffer_, 0,
hit_flags_staging_buffer_, 0, needed_bytes);
WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(enc, nullptr);
wgpuQueueSubmit(queue_, 1, &cmd);
wgpuCommandBufferRelease(cmd);
wgpuCommandEncoderRelease(enc);
needed_bytes_out = needed_bytes;
return true;
}
std::vector<std::uint32_t> ViewportCore::collectMappedXrayHitIds(std::uint64_t needed_bytes) {
std::vector<std::uint32_t> out;
const std::uint32_t* words = static_cast<const std::uint32_t*>(
wgpuBufferGetConstMappedRange(hit_flags_staging_buffer_, 0, needed_bytes));
if (words) {
const std::size_t n = std::size_t(needed_bytes / sizeof(std::uint32_t));
for (std::size_t i = 0; i < n; ++i) {
const std::uint32_t bits = words[i];
if (!bits) continue; // the overwhelmingly common case
for (std::uint32_t b = 0; b < 32u; ++b) {
if (!(bits & (1u << b))) continue;
const std::uint32_t id = std::uint32_t(i) * 32u + b;
if (id != 0) out.push_back(id); // 0 is the "no object" sentinel
}
}
}
wgpuBufferUnmap(hit_flags_staging_buffer_);
return out;
}
std::vector<std::uint32_t> ViewportCore::picksInRect(int x, int y, int w, int h) {
// X-ray: select through, via the depth-less bitmask pass.
if (xrayActive() && box_pick_pipeline_) {
std::uint64_t hit_bytes = 0;
if (!encodeXrayBoxPickToStaging(x, y, w, h, hit_bytes)) return {};
struct MapReq { bool done = false; bool ok = false; };
MapReq req;
WGPUBufferMapCallbackInfo mcb = {};
mcb.mode = kAsyncCbMode;
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(hit_flags_staging_buffer_, WGPUMapMode_Read, 0, hit_bytes, mcb);
while (!req.done) waitTickInstance(instance_);
if (!req.ok) return {};
return collectMappedXrayHitIds(hit_bytes);
}
std::uint64_t padded_bpr = 0, needed_bytes = 0;
if (!encodeBoxPickToStaging(x, y, w, h, padded_bpr, needed_bytes)) return {};
@@ -5570,6 +5791,38 @@ void ViewportCore::picksInRectAsync(int x, int y, int w, int h,
std::function<void(std::vector<std::uint32_t>)> cb) {
auto miss = [&cb]() { if (cb) cb({}); };
if (box_pick_async_in_flight_) { miss(); return; }
// X-ray: select through. Same spontaneous-map dance, but the mapped buffer
// is a per-object bitmask rather than a rect of the object_id image, so the
// rect dims the other callback walks are not needed here.
if (xrayActive() && box_pick_pipeline_) {
std::uint64_t hit_bytes = 0;
if (!encodeXrayBoxPickToStaging(x, y, w, h, hit_bytes)) { miss(); return; }
box_pick_async_bytes_ = hit_bytes;
box_pick_async_xray_ = true;
box_pick_async_in_flight_ = true;
box_pick_async_cb_ = std::move(cb);
WGPUBufferMapCallbackInfo xcb = {};
xcb.mode = kAsyncCbMode;
xcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*msg*/,
void* ud1, void* /*ud2*/) {
auto* self = static_cast<ViewportCore*>(ud1);
std::vector<std::uint32_t> ids;
if (status == WGPUMapAsyncStatus_Success) {
ids = self->collectMappedXrayHitIds(self->box_pick_async_bytes_);
}
auto done = std::move(self->box_pick_async_cb_);
self->box_pick_async_cb_ = nullptr;
self->box_pick_async_in_flight_ = false;
self->box_pick_async_xray_ = false;
if (done) done(std::move(ids));
};
xcb.userdata1 = this;
wgpuBufferMapAsync(hit_flags_staging_buffer_, WGPUMapMode_Read, 0, hit_bytes, xcb);
return;
}
std::uint64_t padded_bpr = 0, needed_bytes = 0;
if (!encodeBoxPickToStaging(x, y, w, h, padded_bpr, needed_bytes)) { miss(); return; }
+36
View File
@@ -734,6 +734,21 @@ public:
int w, int h,
std::uint64_t needed_bytes);
// Build the x-ray box-pick pipeline: vs_pick + fs_boxpick, depth compare
// Always with no depth write and no colour targets, so nothing culls a
// fragment behind another and the pass's only output is the hit bitmask.
bool buildBoxPickPipeline();
// Encode the depth-less box-pick pass scissored to (x,y,w,h): zero the hit
// bits, draw every visible chunk, copy the bitmask into
// hit_flags_staging_buffer_ and submit. Clamps the rect in place and reports
// the byte count to map. False if nothing is pickable or the rect is empty.
bool encodeXrayBoxPickToStaging(int& x, int& y, int& w, int& h,
std::uint64_t& needed_bytes_out);
// Read the (already-mapped) hit bitmask → the object ids whose bit is set.
// Unmaps before returning.
std::vector<std::uint32_t> collectMappedXrayHitIds(std::uint64_t needed_bytes);
// CPU half of pickSurfaceAt: cast the pixel's world ray against every
// instance carrying `object_id`, returning the closest hit's world pos,
// normal (mrt_normal if non-degenerate, else the AABB-face normal), and the
@@ -827,6 +842,11 @@ public:
// Marquee box select: encode the pick pass, copy the (x, y, w, h)
// sub-rect of the object_id MRT back, return the set of unique
// non-zero ids. Synchronous (rare interaction) — desktop only path.
//
// In x-ray this instead runs the depth-less box-pick pass (see
// encodeXrayBoxPickToStaging), so the marquee selects THROUGH occluders —
// matching what x-ray already lets you see. Outside x-ray the depth-tested
// MRT read stands, so a marquee still takes only what is actually visible.
std::vector<std::uint32_t> picksInRect(int x, int y, int w, int h);
#if defined(__EMSCRIPTEN__)
@@ -1106,6 +1126,11 @@ private:
int pick_h_ = 0;
WGPUBuffer box_pick_staging_buffer_ = nullptr;
std::uint64_t box_pick_staging_capacity_ = 0;
// Readback target for the x-ray marquee's hit bits. Separate from the
// rect staging buffer above: that one holds an image, this one a bitmask.
WGPUBuffer hit_flags_staging_buffer_ = nullptr;
std::uint64_t hit_flags_staging_capacity_ = 0;
WGPURenderPipeline box_pick_pipeline_ = nullptr;
#if defined(__EMSCRIPTEN__)
// Async object-pick state (web). Held while the staging map is in flight;
// pick_async_cb_ fires with object_id when the spontaneous map resolves.
@@ -1119,6 +1144,10 @@ private:
int box_pick_async_h_ = 0;
std::uint64_t box_pick_async_padded_bpr_ = 0;
std::uint64_t box_pick_async_bytes_ = 0;
// Which staging buffer the in-flight map belongs to: the x-ray bitmask or
// the object_id rect. Both share box_pick_async_in_flight_ so only one box
// pick can be outstanding either way.
bool box_pick_async_xray_ = false;
// Async surface pick (section tool): chained id→normal staging maps. Reuses
// pick_async_in_flight_ (same staging buffers as the single object pick).
std::function<void(SurfaceHit)> surface_async_cb_;
@@ -1143,6 +1172,13 @@ private:
uint32_t selection_flags_capacity_ = 0; // u32 entries
std::vector<uint32_t> selection_flags_scratch_;
// X-ray marquee hit bits: one bit per object_id, written by fs_boxpick and
// read back to decide the selection. Allocated and bound alongside the
// selection flags (ensureSelectionFlagsBuffer) so the two never disagree
// about how many object ids exist.
WGPUBuffer hit_flags_buffer_ = nullptr;
uint32_t hit_flags_words_ = 0; // u32 words = ceil(capacity / 32)
// Active world-space section planes (up to kMaxSectionPlanes); packed
// into the per-frame uniform every render and consumed by the WGSL
// is_section_clipped fragment gate. The section tool in