wgpu: volume measurement tool (V hotkey, selection-driven HUD + per-object labels)

Ports Bonsai's volumeOfObjects + volumesPerObject onto WgpuViewportWindow.
Mesh-local volumes are precomputed at applyCachedModel via signed-
tetrahedra-from-origin (dequantising positions from the 12 B/vertex GPU
layout); per-instance volume is just the cached local × |det(placement)|.
No GPU readback — measurement is O(K) in the selection size.

Streaming path computes volumes per-chunk as they arrive — fills any
mesh whose chunk just delivered, then re-runs updateVolumeReadout if
the user is staring at a Volume readout while the geometry pages in.

UX matches GL: V toggles, Esc exits, selection-driven (LMB pick / marquee
/ Shift/Ctrl set ops all funnel into updateVolumeReadout). HUD shows
total + count; one overlay label per object at its AABB centre, capped
at 200 to keep the label-texture cache bounded on large marquees.

Side fixes layered on the label overlay:
- O(1) AABB lookup via object_id_to_instance instead of linear-scanning
  every model's instance list per selected object.
- Label texture cache evicts entries not touched this frame, so churning
  through "X.XXXX m³" strings doesn't pin GPU memory.
- DrawRec stores the WGPUBindGroup handle by value rather than a
  LabelTexture* pointer into the QHash — getOrCreateLabelTexture can
  rehash the table and invalidate every captured pointer, which crashed
  large marquee selections with BindGroup-no-longer-alive.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-31 20:57:44 +10:00
parent 3ac7a79b7a
commit 0774398d4e
4 changed files with 359 additions and 9 deletions
+267
View File
@@ -91,6 +91,13 @@ static constexpr uint64_t WGPU_BYTES_PER_ROW_ALIGN = 256;
static QVector3D orbitEye(const float target[3], float dist,
float yaw_deg, float pitch_deg);
// Forward declaration — defined alongside the Volume tool. Called from
// both applyCachedModel (full load) and applyStreamedChunk (per-chunk
// fill in streaming mode) so the same quantised-bytes path runs in both.
static double computeMeshLocalVolumeQuantised(
const MeshInfo& mesh,
const uint8_t* vbase, const uint32_t* ibase, uint32_t n_indices);
// -----------------------------------------------------------------------------
// Small helpers
// -----------------------------------------------------------------------------
@@ -930,6 +937,20 @@ void WgpuViewportWindow::applyCachedModelStreaming(uint32_t model_id,
m.meshes = std::move(metadata.meta.meshes);
m.instances = std::move(metadata.meta.instances);
// Streaming defers per-mesh vertex data until the owning chunk is
// loaded, so mesh-local volumes can't be precomputed here. Volume
// tool returns 0 for unloaded meshes; once we add lazy per-chunk
// volume computation this assign() becomes the seed.
m.mesh_local_volumes.assign(m.meshes.size(), 0.0);
// object_id → instance index lookup. Volume tool reads it on every
// selection mutation; per-pick latency stays O(K) instead of O(K*N).
m.object_id_to_instance.clear();
m.object_id_to_instance.reserve(m.instances.size());
for (uint32_t i = 0; i < uint32_t(m.instances.size()); ++i) {
m.object_id_to_instance.emplace(m.instances[i].object_id, i);
}
// Compute per-chunk world AABBs + instance-id lists from the
// instance_to_chunk mapping. Under spatial bucketing this captures
// each bucket's actual instance extent; under mesh-keyed it's
@@ -1283,6 +1304,29 @@ void WgpuViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
m.meshes = std::move(data.meshes);
m.instances = std::move(data.instances);
// Mesh-local volumes (m³). Computed once per mesh by signed-tetrahedra-
// from-origin over the LOD0 triangles; the Volume measurement tool
// later just multiplies by |det(placement_3x3)| per instance. Helper
// works on raw quantised bytes so the streaming path can reuse it.
m.mesh_local_volumes.assign(m.meshes.size(), 0.0);
for (size_t mi = 0; mi < m.meshes.size(); ++mi) {
const MeshInfo& mesh = m.meshes[mi];
if (mesh.vertex_count == 0 || mesh.index_count < 3) continue;
const uint8_t* vbase = data.vertices.data() + mesh.vbo_byte_offset;
const uint32_t* ibase = data.indices.data()
+ (mesh.ebo_byte_offset / sizeof(uint32_t));
m.mesh_local_volumes[mi] = computeMeshLocalVolumeQuantised(
mesh, vbase, ibase, mesh.index_count);
}
// object_id → instance index lookup. Volume tool reads it on every
// selection mutation; per-pick latency stays O(K) instead of O(K*N).
m.object_id_to_instance.clear();
m.object_id_to_instance.reserve(m.instances.size());
for (uint32_t i = 0; i < uint32_t(m.instances.size()); ++i) {
m.object_id_to_instance.emplace(m.instances[i].object_id, i);
}
// Per-chunk world AABB + instance-id list. Same logic as the
// streaming path. Lets cull frustum-test each chunk's AABB once
// and skip every instance inside in one shot when the chunk is
@@ -2860,6 +2904,181 @@ void WgpuViewportWindow::setHudText(const QString& text) {
if (isExposed()) requestUpdate();
}
// |det(upper-left 3×3)| of a column-major 4×4 placement. Picks up
// mapped-item scale / mirror so a uniformly-scaled clone of a 1 m³ mesh
// reports its actual volume.
static double det3OfPlacement(const double M[16]) {
const double m00 = M[0], m10 = M[1], m20 = M[2];
const double m01 = M[4], m11 = M[5], m21 = M[6];
const double m02 = M[8], m12 = M[9], m22 = M[10];
return m00 * (m11 * m22 - m12 * m21)
- m01 * (m10 * m22 - m12 * m20)
+ m02 * (m10 * m21 - m11 * m20);
}
// Local-frame volume of a mesh from its raw quantised vertex+index bytes.
// `vbase` points at the first vertex (12 B/vertex, 3×uint16 pos quantised
// against mesh.local_aabb), `ibase` at the first u32 index in mesh-local
// numbering, `n_indices` is the LOD0 index count. Signed-tetrahedra-
// from-origin → |sum|/6 so winding doesn't matter. Same algorithm as
// Bonsai's meshLocalVolume; takes the dequant step from
// INSTANCED_VERTEX_STRIDE_BYTES layout.
static double computeMeshLocalVolumeQuantised(
const MeshInfo& mesh,
const uint8_t* vbase, const uint32_t* ibase, uint32_t n_indices) {
if (n_indices < 3 || vbase == nullptr || ibase == nullptr) return 0.0;
const float ax = mesh.local_aabb_min[0];
const float ay = mesh.local_aabb_min[1];
const float az = mesh.local_aabb_min[2];
const float ex = mesh.local_aabb_max[0] - ax;
const float ey = mesh.local_aabb_max[1] - ay;
const float ez = mesh.local_aabb_max[2] - az;
const float inv_q = 1.0f / 65535.0f;
auto dequant = [&](uint32_t vi, double out[3]) {
const uint8_t* v = vbase + size_t(vi) * INSTANCED_VERTEX_STRIDE_BYTES;
uint16_t qx, qy, qz;
std::memcpy(&qx, v + 0, 2);
std::memcpy(&qy, v + 2, 2);
std::memcpy(&qz, v + 4, 2);
out[0] = double(ax + float(qx) * inv_q * ex);
out[1] = double(ay + float(qy) * inv_q * ey);
out[2] = double(az + float(qz) * inv_q * ez);
};
double sum = 0.0;
for (uint32_t i = 0; i + 2 < n_indices; i += 3) {
double p0[3], p1[3], p2[3];
dequant(ibase[i + 0], p0);
dequant(ibase[i + 1], p1);
dequant(ibase[i + 2], p2);
const double cx = p1[1] * p2[2] - p1[2] * p2[1];
const double cy = p1[2] * p2[0] - p1[0] * p2[2];
const double cz = p1[0] * p2[1] - p1[1] * p2[0];
sum += p0[0] * cx + p0[1] * cy + p0[2] * cz;
}
return std::abs(sum) / 6.0;
}
void WgpuViewportWindow::setToolMode(ToolMode m) {
if (tool_mode_ == m) return;
tool_mode_ = m;
switch (tool_mode_) {
case ToolMode::NoTool:
// Drop any HUD/labels the previous tool left behind. We don't
// own the GL backend's per-tool clear callbacks, so the tool's
// own state lives in the overlay renderer.
overlays_.setHudText(QString());
overlays_.setOverlayLabels({});
qInfo() << "[wgpu measure] tool off";
break;
case ToolMode::Volume:
qInfo() << "[wgpu measure] volume tool — pick / marquee objects, Esc to exit";
updateVolumeReadout();
break;
}
if (isExposed()) requestUpdate();
}
double WgpuViewportWindow::volumeOfObjects(
const std::vector<uint32_t>& object_ids) const {
if (object_ids.empty()) return 0.0;
double total = 0.0;
for (uint32_t oid : object_ids) {
for (const auto& [mid, m] : models_gpu_) {
auto it = m.object_id_to_instance.find(oid);
if (it == m.object_id_to_instance.end()) continue;
const InstanceCpu& inst = m.instances[it->second];
if (inst.mesh_id >= m.mesh_local_volumes.size()) break;
const double v_local = m.mesh_local_volumes[inst.mesh_id];
const double det = std::abs(det3OfPlacement(inst.placement_transformation));
total += v_local * det;
break; // object_id is globally unique → at most one hit
}
}
return total;
}
std::vector<std::pair<uint32_t, double>>
WgpuViewportWindow::volumesPerObject(
const std::vector<uint32_t>& object_ids) const {
std::vector<std::pair<uint32_t, double>> out;
if (object_ids.empty()) return out;
out.reserve(object_ids.size());
for (uint32_t oid : object_ids) {
for (const auto& [mid, m] : models_gpu_) {
auto it = m.object_id_to_instance.find(oid);
if (it == m.object_id_to_instance.end()) continue;
const InstanceCpu& inst = m.instances[it->second];
if (inst.mesh_id >= m.mesh_local_volumes.size()) break;
const double v_local = m.mesh_local_volumes[inst.mesh_id];
const double det = std::abs(det3OfPlacement(inst.placement_transformation));
out.emplace_back(oid, v_local * det);
break;
}
}
return out;
}
void WgpuViewportWindow::updateVolumeReadout() {
if (tool_mode_ != ToolMode::Volume) return;
const auto& sel = selection_.ids();
if (sel.empty()) {
overlays_.setHudText(QString());
overlays_.setOverlayLabels({});
return;
}
const std::vector<uint32_t> ids(sel.begin(), sel.end());
const auto per_obj = volumesPerObject(ids);
// Per-object label cap. Each label allocates one wgpu texture +
// bind group on first sight; rendering thousands of unique
// "X.XXXX m³" strings drives the label-texture cache off a cliff
// and the QPainter rasterise per label dominates the click cost.
// The HUD total stays correct above the cap — only the per-object
// overlay labels are suppressed. 200 fits a normal multi-object
// selection and keeps both memory and per-frame draw count bounded.
static constexpr size_t kMaxPerObjectLabels = 200;
const bool show_labels = per_obj.size() <= kMaxPerObjectLabels;
double total = 0.0;
std::vector<WgpuOverlayRenderer::Label> labels;
if (show_labels) labels.reserve(per_obj.size());
for (const auto& [oid, v] : per_obj) {
total += v;
if (!show_labels) continue;
// O(1) instance lookup via object_id_to_instance, then read the
// world AABB from the cached InstanceCpu directly — same data
// computeObjectAabb's linear scan would have produced for the
// first matching instance. For label placement at the AABB
// centre this is identical-looking; only the rare multi-
// representation object_id sees a slightly smaller union.
for (const auto& [mid, m] : models_gpu_) {
auto it = m.object_id_to_instance.find(oid);
if (it == m.object_id_to_instance.end()) continue;
const InstanceCpu& inst = m.instances[it->second];
WgpuOverlayRenderer::Label lbl;
lbl.world_pos[0] = (inst.world_aabb_min[0] + inst.world_aabb_max[0]) * 0.5f;
lbl.world_pos[1] = (inst.world_aabb_min[1] + inst.world_aabb_max[1]) * 0.5f;
lbl.world_pos[2] = (inst.world_aabb_min[2] + inst.world_aabb_max[2]) * 0.5f;
lbl.text = QString::number(v, 'f', 4) + QStringLiteral("");
labels.push_back(std::move(lbl));
break;
}
}
QString hud = QStringLiteral("Volume: %1 m³ (%2 object%3)")
.arg(total, 0, 'f', 4)
.arg(per_obj.size())
.arg(per_obj.size() == 1 ? "" : "s");
if (!show_labels) {
hud += QStringLiteral("\n(per-object labels hidden above %1)")
.arg(kMaxPerObjectLabels);
}
overlays_.setHudText(hud);
overlays_.setOverlayLabels(labels);
}
// Project a world point to LOGICAL pixel coords (Qt's mouse-event units).
// Returns false if behind the camera.
static bool projectWorldToLogicalScreen(const QMatrix4x4& vp,
@@ -4925,6 +5144,39 @@ bool WgpuViewportWindow::applyStreamedChunk(
c.is_resident = true;
c.is_loading = false;
c.loaded_frame_idx = streaming_frame_idx_;
// Mesh-local volumes for the meshes in this chunk. applyCachedModelStreaming
// left them zero because the bytes weren't in memory yet; the first
// chunk to deliver each mesh fills it in. Spatial-bucket mode may
// re-enter for the same mesh from a different chunk — the != 0 guard
// skips the redundant work. Indices are mesh-local (numbered against
// the mesh's own vertex range), so vbase + ibase are per-mesh slices
// into the chunk's freshly-arrived bytes.
bool filled_volume = false;
if (!m.mesh_local_volumes.empty() && !idx.empty()) {
for (uint32_t mi : c.mesh_ids) {
if (mi >= m.meshes.size() || mi >= m.mesh_local_volumes.size()) continue;
if (m.mesh_local_volumes[mi] != 0.0) continue;
const MeshInfo& mesh = m.meshes[mi];
if (mesh.vertex_count == 0 || mesh.index_count < 3) continue;
const size_t v_off = size_t(m.mesh_chunk_local_base_vertex[mi])
* INSTANCED_VERTEX_STRIDE_BYTES;
const size_t i_off = m.mesh_chunk_local_ebo_first_u32[mi];
const size_t v_end = v_off
+ size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (v_end > vbytes.size()) continue;
if (i_off + mesh.index_count > idx.size()) continue;
m.mesh_local_volumes[mi] = computeMeshLocalVolumeQuantised(
mesh, vbytes.data() + v_off, idx.data() + i_off, mesh.index_count);
filled_volume = true;
}
}
// If the user is staring at a Volume readout while chunks page in,
// refresh as soon as a chunk delivers a mesh we just filled — they'd
// otherwise see 0 m³ for the whole selection until they click again.
if (filled_volume && tool_mode_ == ToolMode::Volume) {
updateVolumeReadout();
}
return true;
}
@@ -6041,6 +6293,7 @@ void WgpuViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
}
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
updateVolumeReadout();
requestUpdate();
return;
}
@@ -6149,6 +6402,7 @@ void WgpuViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
tracked_object_id_ = 0;
tracked_chunk_idx_ = SIZE_MAX;
}
updateVolumeReadout();
requestUpdate();
}
nav_active_button_ = Qt::NoButton;
@@ -6389,6 +6643,19 @@ void WgpuViewportWindow::keyPressEvent(QKeyEvent* event) {
}
}
// Measurement tools. V toggles Volume; Esc exits whichever tool is
// active. Mirrors GL ViewportWindow + Bonsai's bind_shortcut(V).
if (key == Qt::Key_V && mods == Qt::NoModifier && !event->isAutoRepeat()) {
setToolMode(tool_mode_ == ToolMode::Volume ? ToolMode::NoTool
: ToolMode::Volume);
return;
}
if (tool_mode_ != ToolMode::NoTool && key == Qt::Key_Escape
&& !event->isAutoRepeat()) {
setToolMode(ToolMode::NoTool);
return;
}
// GL-parity viewport hotkeys.
if (key == Qt::Key_F && mods == Qt::NoModifier && !event->isAutoRepeat()) {
focusOnSelectedObject();