Files
IfcOpenShell/src/ifcviewer/ViewportCore.cpp
T
Dion Moult 584504dcdc ifcviewer-web: stream user sidecars via Blob.slice byte ranges (#88)
Picked files are no longer copied whole into the wasm heap. The browser
File object stays in JS (Module.__ifcvFile) and is read lazily through
Blob.slice byte ranges, so a 200-500 MB sidecar never enters wasm linear
memory — only chunk-sized slices do.

Mechanism (web-only, #if __EMSCRIPTEN__):

  - JS glue (EM_JS): ifcvFileSize + ifcvReadRangeInto — slice [off,off+n)
    of the File and copy it into a caller-provided heap pointer, then call
    back _ifcv_on_range_done. No malloc across the boundary; C pre-sizes
    the destination from the read plan.
  - webReadRangesAsync: reuses planSidecarReadRanges to coalesce a range
    set into Blob.slice reads (1 MB gap — each slice is an async hop),
    scatters them into a destination laid out in input order, and fires a
    continuation when the whole set lands. An in-flight map keyed by id
    survives unordered_map rehash (scratch buffers are heap-owned).
  - loadSidecarFromBlobWeb: async metadata load — head (16 B) -> index
    count -> tail-to-EOF -> parseSidecarHead/Tail -> applyCachedModel, then
    tags the model streaming_from_blob and frames it.
  - driveStreamingLoads: blob-sourced models route to beginWebChunkLoad
    (async vertex+index range reads -> applyStreamedChunk in the callback),
    holding is_loading until the bytes arrive. The embedded MEMFS sample
    keeps the synchronous fopen path.

shell.html stashes the File and calls _load_sidecar_from_blob_c instead of
FS.writeFile'ing the whole thing; EXPORTED_RUNTIME_METHODS=['FS'] dropped.
Desktop is untouched (the new members + driveStreamingLoads branch are all
emscripten-guarded). Web links clean; desktop rebuilds; 107/107 unit tests
pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 17:43:53 +10:00

5857 lines
251 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "ViewportCore.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <limits>
#include <vector>
// wgpu-native extensions (log callback, MULTI_DRAW_INDIRECT). The web
// build (emdawnwebgpu / Dawn) doesn't ship this header — validation
// errors there go to the browser console, so the log-callback path
// simply compiles out under __EMSCRIPTEN__.
#if !defined(__EMSCRIPTEN__)
# include <webgpu/wgpu.h>
#endif
#include "CameraMath.h"
#include "InstanceCompose.h"
#include "Log.h"
namespace {
// Orbit camera around target_. World +Z up (BIM convention). Yaw is
// rotation about Z (positive = anticlockwise looking down +Z); pitch
// is elevation above the XY plane. Matches the GL viewport's
// updateCamera convention so framing aligns between backends.
Eigen::Vector3f orbitEye(const float target[3], float dist,
float yaw_deg, float pitch_deg) {
constexpr float kDeg2Rad = float(M_PI) / 180.0f;
const float yaw = yaw_deg * kDeg2Rad;
const float pit = pitch_deg * kDeg2Rad;
const float cp = std::cos(pit), sp = std::sin(pit);
const float cy = std::cos(yaw), sy = std::sin(yaw);
return Eigen::Vector3f(target[0] + dist * cp * cy,
target[1] + dist * cp * sy,
target[2] + dist * sp);
}
} // namespace
ViewportCore::ViewportCore(ViewportHost* host) : host_(host) {}
ViewportCore::~ViewportCore() = default;
// Tear down a model's per-chunk GPU resources, free its pool slices,
// and reset all the bookkeeping vectors so the slot can be reused.
// Static because callers from outside this TU still live in
// ViewportWindow.cpp; ModelGpuData.h's declaration keeps the
// inter-TU contract.
void releaseWgpuModelGpuData(ModelGpuData& m, BufferPool& pool) {
for (auto& c : m.chunks) {
if (c.bind_group) { wgpuBindGroupRelease(c.bind_group); c.bind_group = nullptr; }
if (c.vertex_slice.valid()) {
pool.free(c.vertex_slice);
c.vertex_slice = {};
}
if (c.index_slice.valid()) {
pool.free(c.index_slice);
c.index_slice = {};
}
if (c.visible_draws_buffer) { wgpuBufferRelease(c.visible_draws_buffer); c.visible_draws_buffer = nullptr; }
if (c.prefix_sums_buffer) { wgpuBufferRelease(c.prefix_sums_buffer); c.prefix_sums_buffer = nullptr; }
if (c.per_chunk_uniform) { wgpuBufferRelease(c.per_chunk_uniform); c.per_chunk_uniform = nullptr; }
}
m.chunks.clear();
m.mesh_chunk_idx.clear();
m.mesh_chunk_local_base_vertex.clear();
m.mesh_chunk_local_ebo_first_u32.clear();
m.mesh_chunk_local_lod1_first_u32.clear();
m.instance_chunk_idx.clear();
m.instance_base_vertex.clear();
m.instance_ebo_first_u32.clear();
m.instance_lod1_first_u32.clear();
if (m.mesh_storage) { wgpuBufferRelease(m.mesh_storage); m.mesh_storage = nullptr; }
if (m.instance_storage) { wgpuBufferRelease(m.instance_storage); m.instance_storage = nullptr; }
m.vertex_bytes = 0;
m.index_count = 0;
m.mesh_count = 0;
m.instance_count = 0;
m.meshes.clear();
m.instances.clear();
}
// ---- Scene mutators -------------------------------------------------------
void ViewportCore::removeModel(uint32_t model_id) {
auto it = models_gpu_.find(model_id);
if (it == models_gpu_.end()) return;
releaseWgpuModelGpuData(it->second, pool_);
models_gpu_.erase(it);
host_->requestFrame();
}
void ViewportCore::resetScene() {
for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m, pool_);
models_gpu_.clear();
host_->requestFrame();
}
void ViewportCore::hideModel(uint32_t model_id) {
auto it = models_gpu_.find(model_id);
if (it == models_gpu_.end() || it->second.hidden) return;
it->second.hidden = true;
host_->requestFrame();
}
void ViewportCore::showModel(uint32_t model_id) {
auto it = models_gpu_.find(model_id);
if (it == models_gpu_.end() || !it->second.hidden) return;
it->second.hidden = false;
host_->requestFrame();
}
void ViewportCore::setFederatedFalseOrigin(const Eigen::Matrix4d& matrix_meters) {
if (federated_false_origin_meters_ == matrix_meters) return;
federated_false_origin_meters_ = matrix_meters;
for (auto& kv : models_gpu_) recomposeAndUploadModel(kv.first);
}
void ViewportCore::setModelCoordinateOperation(uint32_t model_id,
const Eigen::Matrix4d& matrix_meters) {
auto it = models_gpu_.find(model_id);
if (it == models_gpu_.end()) return;
if (it->second.coordinate_operation_meters == matrix_meters) return;
it->second.coordinate_operation_meters = matrix_meters;
recomposeAndUploadModel(model_id);
}
void ViewportCore::setModelTransformation(uint32_t model_id,
const Eigen::Matrix4d& matrix_meters) {
auto it = models_gpu_.find(model_id);
if (it == models_gpu_.end()) return;
if (it->second.model_transformation_meters == matrix_meters) return;
it->second.model_transformation_meters = matrix_meters;
recomposeAndUploadModel(model_id);
}
// ---- Camera math ----------------------------------------------------------
void ViewportCore::buildViewProj(Eigen::Matrix4f& view_out,
Eigen::Matrix4f& proj_out) const {
const Eigen::Vector3f target(camera_target_[0], camera_target_[1], camera_target_[2]);
const Eigen::Vector3f eye = orbitEye(camera_target_, camera_distance_,
camera_yaw_deg_, camera_pitch_deg_);
// Within 1° of straight-up/down, switch up from world +Z to world +Y
// so lookAt's side vector doesn't degenerate (forward × up → 0).
const Eigen::Vector3f up = (std::abs(camera_pitch_deg_) >= 89.0f)
? Eigen::Vector3f(0.0f, 1.0f, 0.0f)
: Eigen::Vector3f(0.0f, 0.0f, 1.0f);
view_out = lookAtRH(eye, target, up);
const float aspect = (configured_h_ > 0)
? float(configured_w_) / float(configured_h_)
: 1.0f;
Eigen::Matrix4f p;
if (projection_ortho_) {
constexpr float kDeg2Rad = float(M_PI) / 180.0f;
const float half_h = camera_distance_
* std::tan(camera_fov_y_deg_ * 0.5f * kDeg2Rad);
const float half_w = half_h * aspect;
const float depth = camera_distance_ * 10.0f;
p = orthoGL(-half_w, half_w, -half_h, half_h, -depth, depth);
} else {
p = perspectiveYFovGL(camera_fov_y_deg_, aspect, camera_near_, camera_far_);
}
Eigen::Matrix4f z_remap = Eigen::Matrix4f::Identity();
z_remap(2, 2) = 0.5f;
z_remap(2, 3) = 0.5f;
proj_out = z_remap * p;
}
bool ViewportCore::computeSceneAabb(float mn[3], float mx[3]) const {
bool any = false;
for (int i = 0; i < 3; ++i) {
mn[i] = std::numeric_limits<float>::infinity();
mx[i] = -std::numeric_limits<float>::infinity();
}
for (const auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
for (const auto& inst : m.instances) {
for (int i = 0; i < 3; ++i) {
mn[i] = std::min(mn[i], inst.world_aabb_min[i]);
mx[i] = std::max(mx[i], inst.world_aabb_max[i]);
}
any = true;
}
}
return any;
}
float ViewportCore::chunkScreenAreaPx(const ModelGpuData::Chunk& c,
const Eigen::Matrix4f& vp_mat) const {
if (configured_w_ <= 0 || configured_h_ <= 0) return 0.0f;
if (c.aabb_min[0] > c.aabb_max[0]) return 0.0f;
const float full_area = float(configured_w_) * float(configured_h_);
// Eye-inside-AABB → full viewport (matches GL contribution-cull
// short-circuit). Any corner behind near plane → also full
// viewport; 8 corners can't measure true on-screen extent once
// any are behind, so over-prioritise rather than under-prioritise.
const Eigen::Vector3f eye = orbitEye(camera_target_, camera_distance_,
camera_yaw_deg_, camera_pitch_deg_);
if (eye.x() >= c.aabb_min[0] && eye.x() <= c.aabb_max[0] &&
eye.y() >= c.aabb_min[1] && eye.y() <= c.aabb_max[1] &&
eye.z() >= c.aabb_min[2] && eye.z() <= c.aabb_max[2]) {
return full_area;
}
float xmin = std::numeric_limits<float>::infinity();
float ymin = std::numeric_limits<float>::infinity();
float xmax = -std::numeric_limits<float>::infinity();
float ymax = -std::numeric_limits<float>::infinity();
int corners_in_front = 0;
int corners_behind = 0;
for (int i = 0; i < 8; ++i) {
const Eigen::Vector4f corner_world(
(i & 1) ? c.aabb_max[0] : c.aabb_min[0],
(i & 2) ? c.aabb_max[1] : c.aabb_min[1],
(i & 4) ? c.aabb_max[2] : c.aabb_min[2],
1.0f);
const Eigen::Vector4f clip = vp_mat * corner_world;
if (clip.w() <= 1e-3f) { ++corners_behind; continue; }
++corners_in_front;
const float ndc_x = clip.x() / clip.w();
const float ndc_y = clip.y() / clip.w();
const float px_x = (ndc_x * 0.5f + 0.5f) * float(configured_w_);
const float px_y = (ndc_y * 0.5f + 0.5f) * float(configured_h_);
xmin = std::min(xmin, px_x);
ymin = std::min(ymin, px_y);
xmax = std::max(xmax, px_x);
ymax = std::max(ymax, px_y);
}
if (corners_in_front == 0) return 0.0f;
if (corners_behind > 0) return full_area;
xmin = std::max(xmin, 0.0f);
ymin = std::max(ymin, 0.0f);
xmax = std::min(xmax, float(configured_w_));
ymax = std::min(ymax, float(configured_h_));
if (xmax <= xmin || ymax <= ymin) return 0.0f;
return (xmax - xmin) * (ymax - ymin);
}
void ViewportCore::recomposeAndUploadModel(uint32_t model_id) {
if (!wgpu_initialized_) return;
auto it = models_gpu_.find(model_id);
if (it == models_gpu_.end()) return;
ModelGpuData& m = it->second;
if (m.instances.empty() || m.instance_storage == nullptr) return;
std::vector<InstanceGpu> gpu(m.instances.size());
for (size_t i = 0; i < m.instances.size(); ++i) {
InstanceCpu& inst = m.instances[i];
composeInstanceFromPlacement(inst, m);
InstanceGpu& dst = gpu[i];
std::memcpy(dst.transform, inst.transform, sizeof(dst.transform));
dst.object_id = inst.object_id;
dst.color_override_rgba8 = inst.color_override_rgba8;
dst.mesh_id = inst.mesh_id;
dst._pad1 = 0;
}
wgpuQueueWriteBuffer(queue_, m.instance_storage, 0,
gpu.data(), gpu.size() * sizeof(InstanceGpu));
// Per-chunk world AABBs are derived from instance world AABBs; they
// drive chunk-level frustum cull and the streaming priority, so they
// must follow the recompose. Reset to ±inf and re-fold every chunk's
// instances. Streaming chunks that haven't yet been assigned
// instance_ids (extremely rare path) just stay at ±inf and naturally
// fall out of frustum tests until the next load completes.
for (auto& c : m.chunks) {
c.aabb_min[0] = c.aabb_min[1] = c.aabb_min[2] =
std::numeric_limits<float>::infinity();
c.aabb_max[0] = c.aabb_max[1] = c.aabb_max[2] =
-std::numeric_limits<float>::infinity();
for (uint32_t inst_idx : c.instance_ids) {
if (inst_idx >= m.instances.size()) continue;
const InstanceCpu& inst = m.instances[inst_idx];
for (int a = 0; a < 3; ++a) {
c.aabb_min[a] = std::min(c.aabb_min[a], inst.world_aabb_min[a]);
c.aabb_max[a] = std::max(c.aabb_max[a], inst.world_aabb_max[a]);
}
}
}
host_->requestFrame();
}
bool ViewportCore::findInstance(uint32_t object_id,
InstanceCompose::InstanceLookup& out) const {
return InstanceCompose::findInstanceInModels(object_id, models_gpu_, out);
}
bool ViewportCore::firstGeometryPointWorldM(uint32_t model_id,
Eigen::Vector3d& out) const {
auto it = models_gpu_.find(model_id);
if (it == models_gpu_.end()) return false;
const ModelGpuData& m = it->second;
if (m.instances.empty()) return false;
const InstanceCpu& inst0 = m.instances[0];
if (inst0.mesh_id >= m.meshes.size()) return false;
const MeshInfo& mesh0 = m.meshes[inst0.mesh_id];
// Mesh-local AABB centre — a point that's actually on the geometry.
// Using AABB centre (vs. literal vertex 0) gives a centroid-like
// anchor rather than a corner, which is more representative of where
// the mesh "is" for the false-origin guess.
const Eigen::Vector3d local_center_m(
0.5 * (double(mesh0.local_aabb_min[0]) + double(mesh0.local_aabb_max[0])),
0.5 * (double(mesh0.local_aabb_min[1]) + double(mesh0.local_aabb_max[1])),
0.5 * (double(mesh0.local_aabb_min[2]) + double(mesh0.local_aabb_max[2])));
// placement_transformation is double[16] column-major in metres,
// pre-CoordinateOperation / FederatedFalseOrigin / ModelTransformation
// (same convention as InstanceLookup above).
using Mat4dCol = Eigen::Matrix<double, 4, 4, Eigen::ColMajor>;
const Eigen::Matrix4d P =
Eigen::Map<const Mat4dCol>(inst0.placement_transformation);
out = (P * local_center_m.homogeneous()).head<3>();
return true;
}
void ViewportCore::composeInstanceFromPlacement(InstanceCpu& inst,
const ModelGpuData& m) const {
if (inst.mesh_id < m.meshes.size()) {
const MeshInfo& mi = m.meshes[inst.mesh_id];
InstanceCompose::composeInstance(
inst.placement_transformation,
federated_false_origin_meters_,
m.model_transformation_meters,
m.coordinate_operation_meters,
mi.local_aabb_min, mi.local_aabb_max,
inst.transform,
inst.world_aabb_min, inst.world_aabb_max);
} else {
// Unknown mesh id: still compose the transform (downstream may
// use it for picking / readback even without geometry), but
// emit a degenerate world AABB so cull doesn't pick this up.
const float zero[3] = {0.0f, 0.0f, 0.0f};
InstanceCompose::composeInstance(
inst.placement_transformation,
federated_false_origin_meters_,
m.model_transformation_meters,
m.coordinate_operation_meters,
zero, zero,
inst.transform,
inst.world_aabb_min, inst.world_aabb_max);
for (int a = 0; a < 3; ++a) {
inst.world_aabb_min[a] = 0.0f;
inst.world_aabb_max[a] = 0.0f;
}
}
}
// ---- Camera mutators ------------------------------------------------------
void ViewportCore::frameAabb(const float mn[3], const float mx[3],
float padding) {
constexpr float kDeg2Rad = float(M_PI) / 180.0f;
const float cx = 0.5f * (mn[0] + mx[0]);
const float cy = 0.5f * (mn[1] + mx[1]);
const float cz = 0.5f * (mn[2] + mx[2]);
camera_target_[0] = cx;
camera_target_[1] = cy;
camera_target_[2] = cz;
const float dx = mx[0] - mn[0];
const float dy = mx[1] - mn[1];
const float dz = mx[2] - mn[2];
const float radius = 0.5f * std::sqrt(dx*dx + dy*dy + dz*dz);
if (radius > 1e-4f) {
const float fovy_rad = camera_fov_y_deg_ * kDeg2Rad;
const float tan_half = std::tan(fovy_rad * 0.5f);
if (tan_half > 1e-6f) {
const int h = std::max(configured_h_, 1);
const float aspect = float(std::max(configured_w_, 1)) / float(h);
const float min_aspect = aspect < 1.0f ? aspect : 1.0f;
camera_distance_ = std::max(0.1f, (radius / (tan_half * min_aspect)) * padding);
}
}
host_->requestFrame();
}
void ViewportCore::viewAll() {
float mn[3], mx[3];
if (!computeSceneAabb(mn, mx)) return;
// Same math as GL's frameAabb(mn, mx, 1.10): target at centroid,
// distance pulls the bounding sphere just inside the tighter of
// horizontal/vertical FOV. 1.10 padding matches GL viewAll.
frameAabb(mn, mx, 1.10f);
const float cx = 0.5f * (mn[0] + mx[0]);
const float cy = 0.5f * (mn[1] + mx[1]);
const float cz = 0.5f * (mn[2] + mx[2]);
const float dx = mx[0] - mn[0];
const float dy = mx[1] - mn[1];
const float dz = mx[2] - mn[2];
const float radius = 0.5f * std::sqrt(dx*dx + dy*dy + dz*dz);
std::fprintf(stderr,
"[info] [wgpu] viewAll target=(%g, %g, %g) distance=%g (scene radius=%g)\n",
cx, cy, cz, camera_distance_, radius);
}
void ViewportCore::setCamera(float tx, float ty, float tz,
float dist, float yaw_deg, float pitch_deg) {
camera_target_[0] = tx;
camera_target_[1] = ty;
camera_target_[2] = tz;
camera_distance_ = std::max(0.01f, dist);
camera_yaw_deg_ = yaw_deg;
// Mirrors GL clamp — keep pitch just shy of the pole so orbit math
// doesn't degenerate. The standard-view top/bottom hotkeys go through
// setStandardView, which bypasses the clamp on purpose.
camera_pitch_deg_ = std::clamp(pitch_deg, -89.9f, 89.9f);
host_->requestFrame();
}
void ViewportCore::setStandardView(float yaw_deg, float pitch_deg) {
// Bypass the orbit-pitch clamp so top/bottom land exactly at ±90°.
// buildViewProj picks the up vector based on |pitch| so lookAt
// stays well-conditioned at the poles.
camera_yaw_deg_ = yaw_deg;
camera_pitch_deg_ = pitch_deg;
host_->requestFrame();
}
void ViewportCore::orbitBy(float dx_px, float dy_px) {
// 0.4 deg/px matches the GL viewport. pitch is clamped just shy of
// the pole so orbitEye() stays well-conditioned.
camera_yaw_deg_ -= dx_px * 0.4f;
camera_pitch_deg_ += dy_px * 0.4f;
camera_pitch_deg_ = std::clamp(camera_pitch_deg_, -89.9f, 89.9f);
host_->requestFrame();
}
void ViewportCore::panBy(float dx_px, float dy_px, int viewport_height_px) {
constexpr float kDeg2Rad = float(M_PI) / 180.0f;
// Pan in the camera's screen-space plane. Within 1° of straight
// up/down the world-Z up-reference degenerates (cross with forward
// is the zero vector → NaN), so switch to world-Y up — matches the
// up-vector switch in buildViewProj so top/bottom views still pan.
const Eigen::Vector3f target(camera_target_[0], camera_target_[1], camera_target_[2]);
const Eigen::Vector3f eye = orbitEye(camera_target_, camera_distance_,
camera_yaw_deg_, camera_pitch_deg_);
const Eigen::Vector3f fwd = (target - eye).normalized();
const Eigen::Vector3f world_up = (std::abs(camera_pitch_deg_) >= 89.0f)
? Eigen::Vector3f(0.0f, 1.0f, 0.0f)
: Eigen::Vector3f(0.0f, 0.0f, 1.0f);
const Eigen::Vector3f right = fwd.cross(world_up).normalized();
const Eigen::Vector3f up = right.cross(fwd).normalized();
const float half_h_world = camera_distance_
* std::tan(camera_fov_y_deg_ * 0.5f * kDeg2Rad);
const float pan_per_pixel = (viewport_height_px > 0)
? (2.0f * half_h_world / float(viewport_height_px))
: 0.0f;
const Eigen::Vector3f shift = -right * (dx_px * pan_per_pixel)
+ up * (dy_px * pan_per_pixel);
camera_target_[0] += shift.x();
camera_target_[1] += shift.y();
camera_target_[2] += shift.z();
host_->requestFrame();
}
void ViewportCore::dollyBy(float notches) {
// Each notch zooms ~10% in/out; sign matches "wheel up = in".
const float factor = std::pow(0.9f, notches);
camera_distance_ = std::max(0.01f, camera_distance_ * factor);
host_->requestFrame();
}
void ViewportCore::toggleProjection() {
projection_ortho_ = !projection_ortho_;
std::fprintf(stderr, "[info] [wgpu] projection: %s\n",
projection_ortho_ ? "ortho" : "perspective");
host_->requestFrame();
}
std::string ViewportCore::cameraString() const {
char buf[128];
std::snprintf(buf, sizeof(buf), "%.4f,%.4f,%.4f,%.4f,%.2f,%.2f",
camera_target_[0], camera_target_[1], camera_target_[2],
camera_distance_, camera_yaw_deg_, camera_pitch_deg_);
return std::string(buf);
}
ViewportCore::CameraState ViewportCore::cameraState() const {
CameraState s;
s.target = Eigen::Vector3f(camera_target_[0], camera_target_[1], camera_target_[2]);
s.distance = camera_distance_;
s.yaw = camera_yaw_deg_;
s.pitch = camera_pitch_deg_;
return s;
}
bool ViewportCore::computeObjectAabb(uint32_t object_id,
float mn[3], float mx[3]) const {
bool any = false;
for (int i = 0; i < 3; ++i) {
mn[i] = std::numeric_limits<float>::infinity();
mx[i] = -std::numeric_limits<float>::infinity();
}
for (const auto& [mid, m] : models_gpu_) {
for (const auto& inst : m.instances) {
if (inst.object_id != object_id) continue;
for (int i = 0; i < 3; ++i) {
mn[i] = std::min(mn[i], inst.world_aabb_min[i]);
mx[i] = std::max(mx[i], inst.world_aabb_max[i]);
}
any = true;
}
}
return any;
}
bool ViewportCore::computeObjectAabb(uint32_t object_id,
Eigen::Vector3f& mn,
Eigen::Vector3f& mx) const {
float fmin[3], fmax[3];
if (!computeObjectAabb(object_id, fmin, fmax)) return false;
mn = Eigen::Vector3f(fmin[0], fmin[1], fmin[2]);
mx = Eigen::Vector3f(fmax[0], fmax[1], fmax[2]);
return true;
}
// |det| of the 3×3 linear part of a column-major double[16] placement
// matrix. Picks up uniform scale + mirror so a 2× clone of a 1m³ mesh
// reports 8m³. Used by the volume readout below.
namespace {
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);
}
} // namespace
double ViewportCore::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>>
ViewportCore::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;
}
// ===========================================================================
// Pipeline construction (#84-k)
// ===========================================================================
namespace {
// WGPUStringView builder for null-terminated C strings. Used heavily by
// label fields and shader source descriptors. Tiny but worth a name.
WGPUStringView svFromCStr(const char* s) {
WGPUStringView v{};
v.data = s;
v.length = std::strlen(s);
return v;
}
static const char* MAIN_WGSL = R"(
struct InstanceRecord {
transform: mat4x4<f32>,
object_id: u32,
color_override: u32,
mesh_id: u32,
_pad1: u32,
};
struct MeshQuant {
aabb_min: vec4<f32>,
aabb_max: vec4<f32>,
};
struct FrameUniforms {
view_proj: mat4x4<f32>,
light_dir: vec4<f32>,
fill_dir: vec4<f32>,
sky_color: vec4<f32>,
ground_color: vec4<f32>,
clip_count: i32,
// Three scalar i32 pads instead of vec3<i32>: vec3 has 16-byte
// alignment so it would also pad the SUBSEQUENT clip_planes start
// up to offset 160. Three i32s pad to 144 with no further nudge,
// matching the tightly-packed C++ FrameUniforms (240 B).
_pad_clip_0: i32,
_pad_clip_1: i32,
_pad_clip_2: i32,
clip_planes: array<vec4<f32>, 6>,
// X-ray mode cap. fs_main clamps `out.a = min(in.color.a, xray_alpha_cap)`.
// Default 1.0 (no effect — the min returns in.color.a). Alt+X drops it
// toward ~0.3 to translucent-everything. The cull classifier also
// routes every instance into the transparent pass when this is < 1
// so the blend stage actually fires (an opaque-pass fragment with
// capped alpha would still overwrite the back buffer).
xray_alpha_cap: f32,
_pad_xray_0: f32,
_pad_xray_1: f32,
_pad_xray_2: f32,
};
// Returns true if `world` lies on the positive (clipped-away) side of any
// active section plane. Each plane is (n.xyz, d) and clips where
// dot(n, world) + d > 0. Both the main and pick fragments discard with
// this predicate so cuts are visible AND consistent with selection.
fn is_section_clipped(world: vec3<f32>) -> bool {
let n = u_frame.clip_count;
if (n == 0) { return false; }
for (var i = 0; i < n; i = i + 1) {
let p = u_frame.clip_planes[i];
if (dot(p.xyz, world) + p.w > 0.0) { return true; }
}
return false;
}
struct VisibleDraw {
mesh_id: u32,
instance_idx: u32,
ebo_first_u32: u32,
base_vertex: u32,
};
struct PerModel {
draw_count: u32,
total_vertex_count: u32,
_pad0: u32,
_pad1: u32,
};
@group(0) @binding(0) var<uniform> u_frame: FrameUniforms;
// Selection flags indexed by object_id. bit 0 = in selection, bit 1 = active.
// Sized to next_object_id_ on the CPU side; out-of-range reads can't happen
// because we cap the index by arrayLength before fetching.
@group(0) @binding(1) var<storage, read> sel_flags: array<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>;
@group(1) @binding(3) var<storage, read> indices: array<u32>;
@group(1) @binding(4) var<storage, read> visible_draws: array<VisibleDraw>;
@group(1) @binding(5) var<storage, read> prefix_sums: array<u32>;
@group(1) @binding(6) var<uniform> u_model: PerModel;
struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) normal: vec3<f32>,
@location(1) color: vec4<f32>,
@location(2) world_pos: vec3<f32>,
@location(3) @interpolate(flat) object_id: u32,
};
// Sign-extend an i8 packed into the byte_idx'th byte of `packed`.
fn extractI8(packed: u32, byte_idx: u32) -> i32 {
let raw = i32((packed >> (byte_idx * 8u)) & 0xFFu);
return select(raw, raw - 256, raw >= 128);
}
// Meyer et al. octahedral normal decode. Input in [-1,1]^2.
fn octDecode(e: vec2<f32>) -> vec3<f32> {
var n = vec3<f32>(e.x, e.y, 1.0 - abs(e.x) - abs(e.y));
if (n.z < 0.0) {
let tx = select(-1.0, 1.0, n.x >= 0.0);
let ty = select(-1.0, 1.0, n.y >= 0.0);
n = vec3<f32>((1.0 - abs(n.y)) * tx, (1.0 - abs(n.x)) * ty, n.z);
}
return normalize(n);
}
// Binary search for the largest i in [0, draw_count) with prefix_sums[i] <= vid.
// prefix_sums is monotonic non-decreasing and contains draw_count+1 entries
// (prefix_sums[draw_count] == total_vertex_count).
fn find_draw(vid: u32) -> u32 {
var lo: u32 = 0u;
var hi: u32 = u_model.draw_count;
while (lo + 1u < hi) {
let mid = (lo + hi) >> 1u;
if (prefix_sums[mid] <= vid) {
lo = mid;
} else {
hi = mid;
}
}
return lo;
}
@vertex
fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut {
// Saturate past the end (shouldn't happen given draw() count, but safe).
if (vid >= u_model.total_vertex_count) {
var degen: VsOut;
degen.clip_pos = vec4<f32>(0.0, 0.0, 0.0, 0.0);
return degen;
}
let draw_idx = find_draw(vid);
let local_v = vid - prefix_sums[draw_idx];
let item = visible_draws[draw_idx];
// Fetch the mesh-local index then the global vertex index.
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 w2 = vertices[v_global * 3u + 2u];
let px = f32(w0 & 0xFFFFu) / 65535.0;
let py = f32((w0 >> 16u) & 0xFFFFu) / 65535.0;
let pz = f32(w1 & 0xFFFFu) / 65535.0;
let pos_local = mix(mq.aabb_min.xyz, mq.aabb_max.xyz, vec3<f32>(px, py, pz));
let nx = f32(extractI8(w1, 2u)) / 127.0;
let ny = f32(extractI8(w1, 3u)) / 127.0;
let n_local = octDecode(vec2<f32>(nx, ny));
let r = f32(w2 & 0xFFu) / 255.0;
let g = f32((w2 >> 8u) & 0xFFu) / 255.0;
let b = f32((w2 >> 16u) & 0xFFu) / 255.0;
let a = f32((w2 >> 24u) & 0xFFu) / 255.0;
let world4 = inst.transform * vec4<f32>(pos_local, 1.0);
let rot = mat3x3<f32>(inst.transform[0].xyz,
inst.transform[1].xyz,
inst.transform[2].xyz);
let n_world = normalize(rot * n_local);
let det = determinant(rot);
let n_final = select(n_world, -n_world, det < 0.0);
var color = vec4<f32>(r, g, b, a);
if (inst.color_override != 0u) {
let cr = f32(inst.color_override & 0xFFu) / 255.0;
let cg = f32((inst.color_override >> 8u) & 0xFFu) / 255.0;
let cb = f32((inst.color_override >> 16u) & 0xFFu) / 255.0;
let ca = f32((inst.color_override >> 24u) & 0xFFu) / 255.0;
if (ca > 0.0) { color = vec4<f32>(cr, cg, cb, ca); }
}
var out: VsOut;
out.clip_pos = u_frame.view_proj * world4;
out.normal = n_final;
out.color = color;
out.world_pos = world4.xyz;
out.object_id = inst.object_id;
return out;
}
// sRGB decode — used to undo wgpu's automatic linear→sRGB write encoding
// on swap-chain BGRA8Unorm so the final bytes match what the GL backend
// writes directly. The GL pipeline outputs to a non-sRGB FB and treats
// every colour input as already-linear, so its bytes are exactly its
// shader outputs. wgpu on the same swap chain auto-encodes, which makes
// everything appear ~3× brighter unless we pre-decode once.
fn srgbToLinear(s: vec3<f32>) -> vec3<f32> {
let lo = s / 12.92;
let hi = pow((s + 0.055) / 1.055, vec3<f32>(2.4));
return select(hi, lo, s <= vec3<f32>(0.04045));
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
if (is_section_clipped(in.world_pos)) { discard; }
var n = normalize(in.normal);
// World +Z is up (BIM convention). Hemisphere ambient: faces pointing
// up read sky, faces pointing down read ground, lerp by n.z.
let hemi_t = 0.5 + 0.5 * n.z;
let ambient = mix(u_frame.ground_color.xyz, u_frame.sky_color.xyz, hemi_t);
let key = max(dot(n, u_frame.light_dir.xyz), 0.0);
let fill = max(dot(n, u_frame.fill_dir.xyz), 0.0) * 0.35;
var color = in.color.xyz * (ambient + (key + fill) * 0.7);
// Cavity shading: where adjacent fragments have a sharp normal change
// (concave creases, edges where two faces meet), darken slightly so
// shape boundaries read on flat-colour models. Matches the GL shader.
let cavity = clamp(length(fwidth(n)) * 1.5, 0.0, 0.35);
color = color * (1.0 - cavity);
// Selection tint. bit 0 = in selection (cool blue mix), bit 1 = active
// (slightly stronger blue mix). Matches the GL main shader.
if (in.object_id < arrayLength(&sel_flags)) {
let flags = sel_flags[in.object_id];
if ((flags & 1u) != 0u) { color = mix(color, vec3<f32>(0.2, 0.6, 1.0), 0.45); }
if ((flags & 2u) != 0u) { color = mix(color, vec3<f32>(0.4, 0.8, 1.0), 0.40); }
}
// Cancel the swap chain's implicit linear→sRGB encoding so the final
// bytes match the GL backend (see srgbToLinear above). Alpha is
// clamped to `xray_alpha_cap` (default 1.0 = no effect; X-ray sets
// it to ~0.3) so a global translucency override lands without
// touching any per-instance state.
let alpha_out = min(in.color.a, u_frame.xray_alpha_cap);
return vec4<f32>(srgbToLinear(color), alpha_out);
}
// --------------------------- 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,
@location(1) world_pos: vec3<f32>,
@location(2) normal: vec3<f32>,
};
// Section tool needs the actual per-fragment normal (the AABB face was
// too coarse for diagonal geometry). Two color attachments — R32UInt
// object_id at @location(0), RGBA16F packed normal at @location(1).
// We multiply-by-0.5+0.5 so unsigned half-floats keep the sign without
// extra channel allocation.
struct FsOutPick {
@location(0) object_id: u32,
@location(1) normal: vec4<f32>,
};
@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;
out.world_pos = vec3<f32>(0.0, 0.0, 0.0);
out.normal = vec3<f32>(0.0, 0.0, 1.0);
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);
// Decode the same octahedral normal as vs_main — pick needs it so
// the section tool can drop perpendicular cuts.
let nx = f32(extractI8(w1, 2u)) / 127.0;
let ny = f32(extractI8(w1, 3u)) / 127.0;
let n_local = octDecode(vec2<f32>(nx, ny));
let rot = mat3x3<f32>(inst.transform[0].xyz,
inst.transform[1].xyz,
inst.transform[2].xyz);
let n_world = normalize(rot * n_local);
let det = determinant(rot);
let n_final = select(n_world, -n_world, det < 0.0);
out.clip_pos = u_frame.view_proj * world4;
out.object_id = inst.object_id;
out.world_pos = world4.xyz;
out.normal = n_final;
return out;
}
@fragment
fn fs_pick(in: VsOutPick) -> FsOutPick {
if (is_section_clipped(in.world_pos)) { discard; }
var out: FsOutPick;
out.object_id = in.object_id;
// Pack signed normal into RGBA16F (unsigned-ish half range) as ×0.5+0.5.
out.normal = vec4<f32>(normalize(in.normal) * 0.5 + vec3<f32>(0.5), 1.0);
return out;
}
)";
} // namespace
bool ViewportCore::buildPipelines() {
// ---- Bind group layouts ----------------------------------------------
WGPUBindGroupLayoutEntry frame_entries[2] = {};
frame_entries[0].binding = 0;
frame_entries[0].visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
frame_entries[0].buffer.type = WGPUBufferBindingType_Uniform;
frame_entries[0].buffer.minBindingSize = sizeof(FrameUniforms);
frame_entries[1].binding = 1;
frame_entries[1].visibility = WGPUShaderStage_Fragment;
frame_entries[1].buffer.type = WGPUBufferBindingType_ReadOnlyStorage;
WGPUBindGroupLayoutDescriptor frame_bgl_desc = {};
frame_bgl_desc.entryCount = 2;
frame_bgl_desc.entries = frame_entries;
frame_bgl_desc.label = svFromCStr("ifcviewer-wgpu.frame_bgl");
frame_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &frame_bgl_desc);
// 6 read-only storage buffers (vertices, meshes, instances, indices,
// visible_draws, prefix_sums) + 1 uniform (per-model count). All read
// in the vertex shader. WebGPU's mandatory min is 8 storage / 12 uniform
// per stage, so we're comfortably under the cap.
WGPUBindGroupLayoutEntry model_entries[7] = {};
for (int i = 0; i < 6; ++i) {
model_entries[i].binding = uint32_t(i);
model_entries[i].visibility = WGPUShaderStage_Vertex;
model_entries[i].buffer.type = WGPUBufferBindingType_ReadOnlyStorage;
}
model_entries[6].binding = 6;
model_entries[6].visibility = WGPUShaderStage_Vertex;
model_entries[6].buffer.type = WGPUBufferBindingType_Uniform;
model_entries[6].buffer.minBindingSize = 16;
WGPUBindGroupLayoutDescriptor model_bgl_desc = {};
model_bgl_desc.entryCount = 7;
model_bgl_desc.entries = model_entries;
model_bgl_desc.label = svFromCStr("ifcviewer-wgpu.model_bgl");
model_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &model_bgl_desc);
// ---- Pipeline layout -------------------------------------------------
WGPUBindGroupLayout bgls[2] = { frame_bgl_, model_bgl_ };
WGPUPipelineLayoutDescriptor pl_desc = {};
pl_desc.bindGroupLayoutCount = 2;
pl_desc.bindGroupLayouts = bgls;
pl_desc.label = svFromCStr("ifcviewer-wgpu.pipeline_layout");
pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
// ---- Shader module ---------------------------------------------------
WGPUShaderSourceWGSL wgsl_src = {};
wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL;
wgsl_src.code = svFromCStr(MAIN_WGSL);
WGPUShaderModuleDescriptor sm_desc = {};
sm_desc.nextInChain = &wgsl_src.chain;
sm_desc.label = svFromCStr("ifcviewer-wgpu.main_wgsl");
main_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
// ---- Render pipeline -------------------------------------------------
WGPUColorTargetState color_target = {};
color_target.format = surface_format_;
color_target.writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = main_shader_module_;
frag.entryPoint = svFromCStr("fs_main");
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.main_pipeline");
rp_desc.vertex.module = main_shader_module_;
rp_desc.vertex.entryPoint = svFromCStr("vs_main");
rp_desc.vertex.bufferCount = 0; // vertex pulling: no IA bindings
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 = kViewportSampleCount;
rp_desc.multisample.mask = 0xFFFFFFFFu;
main_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
if (!main_pipeline_) {
Log::warn() << "wgpu main render pipeline creation failed";
return false;
}
// ---- Transparent variant of the main pipeline ----------------------
// Same shader, same layout, same vertex pulling, same depth test —
// differs only in:
// * depth.depthWriteEnabled = False (we still depth-test against
// the opaque pass's z-buffer, but the transparent fragment's z
// doesn't write, so further-back geometry behind the glass still
// paints over)
// * color_target.blend = SrcAlpha / OneMinusSrcAlpha (standard
// porter-duff "over" — premultiplied wouldn't help because our
// vertex colours come in straight-alpha from the IFC iterator)
// No sort, no OIT — overlapping transparent surfaces of the same
// kind will produce order-dependent artefacts but for typical IFC
// glazing (panes that don't overlap much in screen space) the
// result is "good enough".
WGPUBlendState main_blend = {};
main_blend.color.srcFactor = WGPUBlendFactor_SrcAlpha;
main_blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
main_blend.color.operation = WGPUBlendOperation_Add;
main_blend.alpha.srcFactor = WGPUBlendFactor_One;
main_blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
main_blend.alpha.operation = WGPUBlendOperation_Add;
WGPUColorTargetState color_target_transparent = color_target;
color_target_transparent.blend = &main_blend;
WGPUFragmentState frag_transparent = frag;
frag_transparent.targets = &color_target_transparent;
// depthWriteEnabled stays True so the edge-detect pass (which samples
// depth_view_ to find silhouette discontinuities) can see window
// panes — leaving it False made transparent surfaces invisible to
// the edge detector, so windows ended up as edge-less "framed holes"
// and the edges of opaque geometry behind the glass painted through
// at full intensity. Trade-off: overlapping transparent surfaces
// become depth-test-occluded by the closer one, increasing order
// sensitivity. For BIM glass (panes that don't overlap in screen
// space) this is invisible; for scenes where it matters, the right
// fix is OIT or sort-by-distance, not turning depth write off.
WGPUDepthStencilState depth_transparent = depth;
WGPURenderPipelineDescriptor rp_desc_t = rp_desc;
rp_desc_t.label = svFromCStr("ifcviewer-wgpu.main_pipeline_transparent");
rp_desc_t.fragment = &frag_transparent;
rp_desc_t.depthStencil = &depth_transparent;
main_pipeline_transparent_ =
wgpuDeviceCreateRenderPipeline(device_, &rp_desc_t);
if (!main_pipeline_transparent_) {
Log::warn() << "wgpu main transparent render pipeline creation failed";
return false;
}
// ---- Per-frame uniform buffer ---------------------------------------
WGPUBufferDescriptor fb_desc = {};
fb_desc.size = sizeof(FrameUniforms);
fb_desc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
fb_desc.label = svFromCStr("ifcviewer-wgpu.frame_uniform");
frame_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &fb_desc);
// frame_bind_group_ is built lazily once we have a selection_flags_
// buffer to bind alongside the uniform — ensureSelectionFlagsBuffer
// handles both the first creation and any subsequent resize.
return true;
}
void ViewportCore::ensureSelectionFlagsBuffer() {
// Round up to at least 64 entries (256 B — minimum useful storage) and
// grow geometrically when next_object_id_ outruns the current capacity.
const uint32_t needed = std::max<uint32_t>(next_object_id_, 64);
if (selection_flags_buffer_ && selection_flags_capacity_ >= needed) {
if (!frame_bind_group_) {
// First-time bind group creation after the buffer exists.
// (Should always be true here.)
} else {
return;
}
}
// (Re)allocate. Geometric grow so we don't recreate every frame as a
// big scene streams in.
uint32_t new_cap = selection_flags_capacity_;
if (new_cap < 64) new_cap = 64;
while (new_cap < needed) new_cap *= 2;
if (!selection_flags_buffer_ || selection_flags_capacity_ < new_cap) {
if (selection_flags_buffer_) {
wgpuBufferRelease(selection_flags_buffer_);
selection_flags_buffer_ = nullptr;
}
WGPUBufferDescriptor sb = {};
sb.size = uint64_t(new_cap) * sizeof(uint32_t);
sb.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst;
sb.label = svFromCStr("ifcviewer-wgpu.selection_flags");
selection_flags_buffer_ = wgpuDeviceCreateBuffer(device_, &sb);
selection_flags_capacity_ = new_cap;
// 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.
}
// Rebuild the frame bind group against the (possibly new) buffer.
if (frame_bind_group_) {
wgpuBindGroupRelease(frame_bind_group_);
frame_bind_group_ = nullptr;
}
WGPUBindGroupEntry fbg_entries[2] = {};
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;
WGPUBindGroupDescriptor fbg_desc = {};
fbg_desc.layout = frame_bgl_;
fbg_desc.entryCount = 2;
fbg_desc.entries = fbg_entries;
fbg_desc.label = svFromCStr("ifcviewer-wgpu.frame_bind_group");
frame_bind_group_ = wgpuDeviceCreateBindGroup(device_, &fbg_desc);
// Force a re-upload of the flags into the (possibly new) buffer.
selection_flags_scratch_.assign(selection_flags_capacity_, 0);
selection_.fillFlagsArray(selection_flags_scratch_, selection_flags_capacity_);
wgpuQueueWriteBuffer(queue_, selection_flags_buffer_, 0,
selection_flags_scratch_.data(),
selection_flags_scratch_.size() * sizeof(uint32_t));
selection_.markClean();
}
void ViewportCore::uploadSelectionFlagsIfDirty() {
if (!selection_.dirty() || !selection_flags_buffer_) return;
selection_flags_scratch_.assign(selection_flags_capacity_, 0);
selection_.fillFlagsArray(selection_flags_scratch_, selection_flags_capacity_);
wgpuQueueWriteBuffer(queue_, selection_flags_buffer_, 0,
selection_flags_scratch_.data(),
selection_flags_scratch_.size() * sizeof(uint32_t));
selection_.markClean();
}
void ViewportCore::updateFrameUniforms() {
Eigen::Matrix4f view, proj;
buildViewProj(view, proj);
const Eigen::Matrix4f view_proj = proj * view;
FrameUniforms u = {};
std::memcpy(u.view_proj, view_proj.data(), 16 * sizeof(float));
// Values match the GL viewport's main fragment shader so a side-by-side
// diff of the two backends only shows what the wgpu pipeline has yet to
// implement (edge silhouette pass, MSAA polish, etc.) — not lighting
// model differences. Key + fill are ~unit-length, ~120° apart.
Eigen::Vector3f L( 0.3f, 0.5f, 0.8f); L.normalize();
Eigen::Vector3f F(-0.3f, -0.5f, 0.8f); F.normalize();
u.light_dir[0] = L.x(); u.light_dir[1] = L.y(); u.light_dir[2] = L.z(); u.light_dir[3] = 0;
u.fill_dir [0] = F.x(); u.fill_dir [1] = F.y(); u.fill_dir [2] = F.z(); u.fill_dir [3] = 0;
u.sky_color [0] = 0.55f; u.sky_color [1] = 0.60f; u.sky_color [2] = 0.70f;
u.ground_color[0] = 0.35f; u.ground_color[1] = 0.32f; u.ground_color[2] = 0.28f;
// Pack active section planes. `is_section_clipped` (WGSL) reads
// u.clip_count and u.clip_planes[0..clip_count) and discards
// fragments on the positive side.
const int n = std::min<int>(int(section_planes_.size()), kMaxSectionPlanes);
u.clip_count = n;
for (int i = 0; i < n; ++i) {
const SectionPlane& p = section_planes_[i];
u.clip_planes[i][0] = p.n.x();
u.clip_planes[i][1] = p.n.y();
u.clip_planes[i][2] = p.n.z();
u.clip_planes[i][3] = p.d;
}
u.xray_alpha_cap = xray_alpha_cap_;
u._pad_xray[0] = u._pad_xray[1] = u._pad_xray[2] = 0.0f;
wgpuQueueWriteBuffer(queue_, frame_uniform_buffer_, 0, &u, sizeof(u));
}
// ===========================================================================
// Lifecycle (#84-l): initWgpu + createPool + shutdown
// ===========================================================================
#if defined(__EMSCRIPTEN__)
# include <emscripten/emscripten.h>
#endif
namespace {
// Drain pending async callbacks. On wgpu-native this means literally
// calling wgpuInstanceProcessEvents, which fires queued completions.
// On emdawnwebgpu/Dawn-web, queued completions are JS promise resolves
// — wgpuInstanceProcessEvents is a no-op there, so a busy-loop
// `while (!done) processEvents()` would spin forever blocking the
// browser main thread. With ASYNCIFY enabled (see CMakeLists), calling
// emscripten_sleep(0) unwinds the wasm stack, lets JS process resolved
// promises (firing our WGPU callbacks via AllowProcessEvents mode),
// then resumes the C++ caller. Net effect: the same "spin until done"
// shape works on both backends.
// Async-callback mode for WGPU futures. wgpu-native fires
// AllowProcessEvents callbacks deterministically from
// wgpuInstanceProcessEvents — that's what we want on desktop. Dawn-web
// queues those same callbacks indefinitely (waits for a specific
// instance state we don't drive); AllowSpontaneous on web lets the JS
// event loop fire the callback as soon as the underlying promise
// resolves.
constexpr WGPUCallbackMode kAsyncCbMode =
#if defined(__EMSCRIPTEN__)
WGPUCallbackMode_AllowSpontaneous;
#else
WGPUCallbackMode_AllowProcessEvents;
#endif
inline void waitTickInstance(WGPUInstance instance) {
#if defined(__EMSCRIPTEN__)
// Asyncify is OFF on web (see ifcviewer-web/CMakeLists.txt). The
// init path no longer uses this helper — it's callback-driven via
// initWgpuAsyncWeb. The remaining callers are MapAsync sync-wait
// loops (pick readback): those will spin forever here until they
// are ported to a callback-driven shape. Until then, hitting a
// pick on web hangs the page. ProcessEvents is a no-op on
// Dawn-web but harmless to call.
wgpuInstanceProcessEvents(instance);
#else
wgpuInstanceProcessEvents(instance);
#endif
}
// String-view → std::string for log output. WGPU_STRLEN is the
// sentinel meaning "nul-terminated", in which case strlen() gives the
// length.
std::string svToStr(WGPUStringView s) {
if (!s.data) return {};
const std::size_t len = (s.length == WGPU_STRLEN)
? std::strlen(s.data)
: s.length;
return std::string(s.data, len);
}
#if !defined(__EMSCRIPTEN__)
// wgpu-native callback: route every log line to Log::warn / Log::info
// so backend init problems surface in the console instead of being
// swallowed by the native runtime. Not available on emdawnwebgpu —
// see the include guard above.
void onWgpuLog(WGPULogLevel level, WGPUStringView message, void* /*userdata*/) {
const std::string m = svToStr(message);
switch (level) {
case WGPULogLevel_Error: Log::warn() << "[wgpu err] " << m; break;
case WGPULogLevel_Warn: Log::warn() << "[wgpu warn] " << m; break;
case WGPULogLevel_Info: Log::info() << "[wgpu info] " << m; break;
case WGPULogLevel_Debug: Log::info() << "[wgpu dbg] " << m; break;
case WGPULogLevel_Trace: Log::info() << "[wgpu trace] " << m; break;
default: break;
}
}
#endif
// Per-device uncaptured-error callback. Validation failures land here
// when no error scope is open. Surfacing them into Log::warn makes
// otherwise-silent driver complaints attributable.
void onUncapturedError(WGPUDevice const* /*device*/,
WGPUErrorType type, WGPUStringView message,
void* /*ud1*/, void* /*ud2*/) {
Log::warn() << "[wgpu device error " << int(type) << "] " << svToStr(message);
}
} // namespace
bool ViewportCore::createPool() {
// Size the streaming pool to *demand*: configure a modest initial
// sub-buffer and let BufferPool::addSubBuffer grow it lazily (and
// halve-retry down to its 64 MB floor on constrained devices) as the
// working set needs more.
//
// We deliberately do NOT grab the largest momentarily-allocatable
// buffer up front. Drivers can advertise an effectively unbounded
// maxBufferSize (1 TB observed on this Linux/wgpu-native stack), so a
// "probe for the biggest single buffer" walk lands on multiple GB.
// Allocating that for a 1 MB model starves the depth/MSAA/HiZ/pick
// attachments and the selection-flags buffer, OOMing the device on
// the next tiny allocation. A single chunk is capped at
// WGPU_CHUNK_VERTEX_BYTES_LIMIT (16 MB), so the initial sub-buffer
// only has to clear that floor; everything beyond is added on demand.
WGPULimits device_limits = {};
wgpuDeviceGetLimits(device_, &device_limits);
constexpr uint64_t MIN_POOL_CAPACITY = 64ull * 1024 * 1024;
#if defined(__EMSCRIPTEN__)
// Web stays at the 64 MB floor: Chrome's WebGPU process contends
// badly on large first allocations (and any other WebGPU tab on the
// same origin makes it worse), so a small first sub-buffer keeps
// first-frame cheap. See the web-init gotchas note.
constexpr uint64_t INITIAL_SUB_BUFFER = 64ull * 1024 * 1024;
#else
// Desktop has no such contention constraint; a larger initial
// sub-buffer means fewer sub-buffers (and per-chunk bind groups) for
// big models. 256 MB holds ~16 max-size chunks; if the device can't
// grant it, addSubBuffer halve-retries down to the 64 MB floor.
constexpr uint64_t INITIAL_SUB_BUFFER = 256ull * 1024 * 1024;
#endif
const WGPUBufferUsage pool_usage = WGPUBufferUsage_Storage
| WGPUBufferUsage_CopyDst;
const uint64_t per_sub = std::min<uint64_t>(
device_limits.maxBufferSize,
std::max<uint64_t>(MIN_POOL_CAPACITY, INITIAL_SUB_BUFFER));
pool_.configure(instance_, device_, pool_usage, per_sub,
"ifcviewer-wgpu.pool");
Log::info() << "wgpu: pool per-sub-buffer capacity = "
<< (per_sub / (1024 * 1024)) << " MB (grows lazily on "
<< "demand; device maxBufferSize = "
<< (device_limits.maxBufferSize / (1024 * 1024)) << " MB)";
return true;
}
bool ViewportCore::initWgpu(bool web_limits) {
#if !defined(__EMSCRIPTEN__)
wgpuSetLogCallback(onWgpuLog, nullptr);
wgpuSetLogLevel(WGPULogLevel_Warn);
#endif
Log::info() << "[initWgpu] 1/7 wgpuCreateInstance";
instance_ = wgpuCreateInstance(nullptr);
if (!instance_) {
Log::warn() << "wgpuCreateInstance returned null";
return false;
}
Log::info() << "[initWgpu] 2/7 host_->createSurface";
// Surface comes from the host (X11/HWND/CAMetalLayer on desktop;
// Emscripten canvas selector on web).
surface_ = host_->createSurface(instance_);
if (!surface_) {
Log::warn() << "host createSurface returned null";
return false;
}
// ---- Async request adapter -------------------------------------------
struct AdapterReq { WGPUAdapter adapter = nullptr; bool done = false; bool ok = false; };
AdapterReq areq;
WGPURequestAdapterOptions adapter_opts = {};
adapter_opts.compatibleSurface = surface_;
adapter_opts.powerPreference = WGPUPowerPreference_HighPerformance;
WGPURequestAdapterCallbackInfo acb = {};
acb.mode = kAsyncCbMode;
acb.callback = [](WGPURequestAdapterStatus status, WGPUAdapter adapter,
WGPUStringView message, void* ud1, void* /*ud2*/) {
auto* r = static_cast<AdapterReq*>(ud1);
r->done = true;
if (status == WGPURequestAdapterStatus_Success) {
r->adapter = adapter;
r->ok = true;
} else {
Log::warn() << "RequestAdapter failed: " << svToStr(message);
}
};
acb.userdata1 = &areq;
Log::info() << "[initWgpu] 3/7 wgpuInstanceRequestAdapter";
wgpuInstanceRequestAdapter(instance_, &adapter_opts, acb);
while (!areq.done) waitTickInstance(instance_);
if (!areq.ok) return false;
adapter_ = areq.adapter;
Log::info() << "[initWgpu] 3/7 adapter ready";
// ---- Async request device --------------------------------------------
struct DeviceReq { WGPUDevice device = nullptr; bool done = false; bool ok = false; };
DeviceReq dreq;
WGPULimits adapter_limits = {};
wgpuAdapterGetLimits(adapter_, &adapter_limits);
WGPULimits web_floor_limits = adapter_limits;
web_floor_limits.maxStorageBufferBindingSize = 128ull * 1024 * 1024;
web_floor_limits.maxBufferSize = 256ull * 1024 * 1024;
WGPUDeviceDescriptor dev_desc = {};
#if defined(__EMSCRIPTEN__)
// Dawn-web's RequestDevice hangs (no promise resolution) when we
// pass a fully-populated WGPULimits as requiredLimits — every
// non-zero field is treated as a hard requirement, and the
// browser's adapter-reported limits include values it won't grant
// back to a device. nullptr means "no specific requirements; give
// me default limits", which the spec guarantees succeeds and is
// exactly what we need: the pool sizes itself to demand in
// createPool and grows lazily, so we don't lose anything by
// deferring to defaults here.
(void)web_floor_limits;
(void)web_limits;
dev_desc.requiredLimits = nullptr;
#else
dev_desc.requiredLimits = web_limits ? &web_floor_limits : &adapter_limits;
if (web_limits) {
Log::info() << "wgpu --web-limits: requesting browser-floor limits "
"(maxStorageBufferBindingSize=128MB, maxBufferSize=256MB)";
}
#endif
#if !defined(__EMSCRIPTEN__)
// Setting only the callback (leaving uncapturedErrorCallbackInfo.mode
// = 0 default) makes Dawn-web's RequestDevice silently never resolve
// the device promise. Leave the whole struct zeroed on web; the
// browser already surfaces uncaptured errors to the JS console.
dev_desc.uncapturedErrorCallbackInfo.callback = onUncapturedError;
#endif
WGPURequestDeviceCallbackInfo dcb = {};
dcb.mode = kAsyncCbMode;
dcb.callback = [](WGPURequestDeviceStatus status, WGPUDevice device,
WGPUStringView message, void* ud1, void* /*ud2*/) {
auto* r = static_cast<DeviceReq*>(ud1);
r->done = true;
if (status == WGPURequestDeviceStatus_Success) {
r->device = device;
r->ok = true;
} else {
Log::warn() << "RequestDevice failed: " << svToStr(message);
}
};
dcb.userdata1 = &dreq;
Log::info() << "[initWgpu] 4/7a wgpuAdapterRequestDevice CALL";
#if defined(__EMSCRIPTEN__)
// Pass nullptr descriptor so Dawn-web takes the all-defaults path.
// Setting any descriptor field that Dawn-web can't grant silently
// makes the device promise never resolve, so we avoid the whole
// struct. Note: web init normally goes through initWgpuAsyncWeb,
// not this sync path; this branch is dead code on the current
// web build but kept for any future caller.
wgpuAdapterRequestDevice(adapter_, nullptr, dcb);
#else
wgpuAdapterRequestDevice(adapter_, &dev_desc, dcb);
#endif
Log::info() << "[initWgpu] 4/7b RequestDevice returned, entering spin";
int tick = 0;
while (!dreq.done) {
waitTickInstance(instance_);
if (++tick % 100 == 0) {
Log::info() << "[initWgpu] 4/7c spin tick=" << tick;
}
if (tick > 2000) {
Log::warn() << "[initWgpu] 4/7 giving up after 2000 ticks";
return false;
}
}
if (!dreq.ok) return false;
device_ = dreq.device;
queue_ = wgpuDeviceGetQueue(device_);
Log::info() << "[initWgpu] 4/7d device + queue ready";
Log::info() << "[initWgpu] 5/7 createPool";
if (!createPool()) {
Log::warn() << "wgpu: streaming pool probe failed; cannot start";
return false;
}
Log::info() << "[initWgpu] 5/7 pool created";
#if !defined(__EMSCRIPTEN__)
// Background streaming worker. On desktop std::thread spawns a real
// OS thread; under Emscripten that requires -pthread + SharedArrayBuffer
// (which itself needs COOP/COEP headers from the hosting page).
// Until the web build wires those up we run streaming inline from
// the render thread (see the `use_sync = true` branch in
// driveStreamingLoads on Emscripten).
streaming_thread_.start();
#endif
Log::info() << "[initWgpu] 6/7 wgpuSurfaceGetCapabilities";
WGPUSurfaceCapabilities caps = {};
if (wgpuSurfaceGetCapabilities(surface_, adapter_, &caps) != WGPUStatus_Success
|| caps.formatCount == 0) {
Log::warn() << "wgpuSurfaceGetCapabilities returned no formats";
return false;
}
surface_format_ = caps.formats[0];
wgpuSurfaceCapabilitiesFreeMembers(caps);
Log::info() << "wgpu init OK; surface format = " << int(surface_format_);
return true;
}
#if defined(__EMSCRIPTEN__)
namespace {
// State carrier for the nested callback chain. Heap-allocated so it
// survives across the JS event loop ticks that resolve each promise;
// freed at the leaf callback (success or any error path).
struct WebInitCtx {
ViewportCore* core;
std::function<void(bool)> on_complete;
};
} // namespace
void ViewportCore::initWgpuAsyncWeb(std::function<void(bool)> on_complete) {
// Pass a defaulted descriptor (not nullptr). On Dawn-web,
// wgpuCreateInstance(nullptr) returns a usable instance but the
// subsequent RequestDevice promise silently never resolves.
WGPUInstanceDescriptor inst_desc = {};
instance_ = wgpuCreateInstance(&inst_desc);
if (!instance_) {
Log::warn() << "[web init] wgpuCreateInstance returned null";
on_complete(false);
return;
}
auto* ctx = new WebInitCtx{this, std::move(on_complete)};
// Bare adapter options (no compatibleSurface, no powerPreference).
// Setting any field here makes the subsequent device promise
// silently never resolve on Dawn-web.
WGPURequestAdapterOptions adapter_opts = {};
WGPURequestAdapterCallbackInfo acb = {};
acb.mode = WGPUCallbackMode_AllowSpontaneous;
acb.callback = [](WGPURequestAdapterStatus status, WGPUAdapter adapter,
WGPUStringView msg, void* ud1, void* /*ud2*/) {
auto* c = static_cast<WebInitCtx*>(ud1);
if (status != WGPURequestAdapterStatus_Success || !adapter) {
Log::warn() << "[web init] RequestAdapter failed: " << svToStr(msg);
c->on_complete(false);
delete c;
return;
}
c->core->adapter_ = adapter;
WGPURequestDeviceCallbackInfo dcb = {};
dcb.mode = WGPUCallbackMode_AllowSpontaneous;
dcb.callback = [](WGPURequestDeviceStatus dstatus, WGPUDevice device,
WGPUStringView dmsg, void* ud1b, void* /*ud2*/) {
auto* c = static_cast<WebInitCtx*>(ud1b);
if (dstatus != WGPURequestDeviceStatus_Success || !device) {
Log::warn() << "[web init] RequestDevice failed: " << svToStr(dmsg);
c->on_complete(false);
delete c;
return;
}
c->core->device_ = device;
c->core->queue_ = wgpuDeviceGetQueue(device);
// Surface creation is deferred to here (post-device-ready).
// Creating it inside the adapter callback (before
// wgpuAdapterRequestDevice) makes the device promise
// silently never resolve on Dawn-web.
c->core->surface_ = c->core->host_->createSurface(c->core->instance_);
if (!c->core->surface_) {
Log::warn() << "[web init] host createSurface returned null";
c->on_complete(false);
delete c;
return;
}
if (!c->core->createPool()) {
Log::warn() << "[web init] pool create failed";
c->on_complete(false);
delete c;
return;
}
WGPUSurfaceCapabilities caps = {};
if (wgpuSurfaceGetCapabilities(c->core->surface_, c->core->adapter_, &caps)
!= WGPUStatus_Success
|| caps.formatCount == 0) {
Log::warn() << "[web init] surface has no formats";
c->on_complete(false);
delete c;
return;
}
c->core->surface_format_ = caps.formats[0];
wgpuSurfaceCapabilitiesFreeMembers(caps);
Log::info() << "[web init] wgpu device + surface ready (format="
<< int(c->core->surface_format_) << ")";
c->on_complete(true);
delete c;
};
dcb.userdata1 = c;
// Pass a zero-init local descriptor (not nullptr). Dawn-web's
// RequestDevice silently never resolves the promise when passed
// nullptr.
WGPUDeviceDescriptor dd = {};
wgpuAdapterRequestDevice(adapter, &dd, dcb);
};
acb.userdata1 = ctx;
wgpuInstanceRequestAdapter(instance_, &adapter_opts, acb);
}
#endif // __EMSCRIPTEN__
void ViewportCore::shutdown() {
// Stop streaming first so no late results land in the pool after
// we've torn down model state. Worker drains its queue then joins.
streaming_thread_.stop();
for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m, pool_);
models_gpu_.clear();
if (frame_bind_group_) { wgpuBindGroupRelease(frame_bind_group_); frame_bind_group_ = nullptr; }
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 (main_pipeline_) { wgpuRenderPipelineRelease(main_pipeline_); main_pipeline_ = nullptr; }
if (main_pipeline_transparent_) { wgpuRenderPipelineRelease(main_pipeline_transparent_); main_pipeline_transparent_ = nullptr; }
if (main_shader_module_) { wgpuShaderModuleRelease(main_shader_module_); main_shader_module_ = nullptr; }
if (pipeline_layout_) { wgpuPipelineLayoutRelease(pipeline_layout_); pipeline_layout_ = nullptr; }
if (model_bgl_) { wgpuBindGroupLayoutRelease(model_bgl_); model_bgl_ = nullptr; }
if (frame_bgl_) { wgpuBindGroupLayoutRelease(frame_bgl_); frame_bgl_ = nullptr; }
// Destroy the streaming pool while device_ is still alive — it owns
// the underlying WGPUBuffer.
pool_.destroy();
if (queue_) { wgpuQueueRelease(queue_); queue_ = nullptr; }
if (device_) { wgpuDeviceRelease(device_); device_ = nullptr; }
if (adapter_) { wgpuAdapterRelease(adapter_); adapter_ = nullptr; }
if (surface_) { wgpuSurfaceRelease(surface_); surface_ = nullptr; }
if (instance_) { wgpuInstanceRelease(instance_); instance_ = nullptr; }
wgpu_initialized_ = false;
surface_configured_ = false;
}
// ===========================================================================
// Chunk residency (#84-n): buildChunkBindGroup + applyStreamedChunk +
// loadChunkBytesAndUploadGpu + unloadChunk + makeChunkRequest +
// computeMeshLocalVolumeQuantised
// ===========================================================================
#include "StreamingLoader.h"
namespace {
// Pure function of the mesh's local AABB + index list. Computes the
// signed-tetrahedra-volume sum (divergence theorem), takes its absolute
// value, divides by 6 — gives the mesh-local volume in m³. Side effect:
// if `out_tris` is non-null, populates it with dequantised positions +
// index copy so the Area / Volume tool can later refine to a single
// triangle. Stays as a free helper because applyStreamedChunk is the
// only call site.
double computeMeshLocalVolumeQuantised(
const MeshInfo& mesh,
const std::uint8_t* vbase, const std::uint32_t* ibase,
std::uint32_t n_indices,
ModelGpuData::MeshTriangles* out_tris) {
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;
std::vector<float> positions;
positions.resize(std::size_t(mesh.vertex_count) * 3);
for (std::uint32_t v = 0; v < mesh.vertex_count; ++v) {
const std::uint8_t* p = vbase
+ std::size_t(v) * INSTANCED_VERTEX_STRIDE_BYTES;
std::uint16_t qx, qy, qz;
std::memcpy(&qx, p + 0, 2);
std::memcpy(&qy, p + 2, 2);
std::memcpy(&qz, p + 4, 2);
positions[3 * v + 0] = ax + float(qx) * inv_q * ex;
positions[3 * v + 1] = ay + float(qy) * inv_q * ey;
positions[3 * v + 2] = az + float(qz) * inv_q * ez;
}
double sum = 0.0;
for (std::uint32_t i = 0; i + 2 < n_indices; i += 3) {
const std::uint32_t i0 = ibase[i + 0];
const std::uint32_t i1 = ibase[i + 1];
const std::uint32_t i2 = ibase[i + 2];
if (i0 >= mesh.vertex_count || i1 >= mesh.vertex_count
|| i2 >= mesh.vertex_count) continue;
const float* p0 = &positions[3 * i0];
const float* p1 = &positions[3 * i1];
const float* p2 = &positions[3 * i2];
const double cx = double(p1[1]) * p2[2] - double(p1[2]) * p2[1];
const double cy = double(p1[2]) * p2[0] - double(p1[0]) * p2[2];
const double cz = double(p1[0]) * p2[1] - double(p1[1]) * p2[0];
sum += double(p0[0]) * cx + double(p0[1]) * cy + double(p0[2]) * cz;
}
if (out_tris) {
out_tris->positions = std::move(positions);
out_tris->indices.assign(ibase, ibase + n_indices);
}
return std::abs(sum) / 6.0;
}
} // namespace
void ViewportCore::buildChunkBindGroup(ModelGpuData& m, std::size_t chunk_idx) {
if (chunk_idx >= m.chunks.size()) return;
auto& c = m.chunks[chunk_idx];
if (c.bind_group) {
wgpuBindGroupRelease(c.bind_group);
c.bind_group = nullptr;
}
if (!c.vertex_slice.valid() || !c.index_slice.valid()
|| !c.visible_draws_buffer || !c.prefix_sums_buffer || !c.per_chunk_uniform
|| !m.mesh_storage || !m.instance_storage) {
return;
}
WGPUBindGroupEntry entries[7] = {};
// vertices and indices live in the shared pool. Each slice carries
// the specific sub-buffer it landed in (the pool may span several
// when scenes exceed wgpu's single-buffer cap). The other entries
// are still per-chunk small buffers (visible_draws/prefix_sums/uniform)
// or per-model (mesh/instance).
entries[0].binding = 0;
entries[0].buffer = c.vertex_slice.buffer;
entries[0].offset = c.vertex_slice.offset;
entries[0].size = c.vertex_slice.size;
entries[1].binding = 1;
entries[1].buffer = m.mesh_storage;
entries[1].size = WGPU_WHOLE_SIZE;
entries[2].binding = 2;
entries[2].buffer = m.instance_storage;
entries[2].size = WGPU_WHOLE_SIZE;
entries[3].binding = 3;
entries[3].buffer = c.index_slice.buffer;
entries[3].offset = c.index_slice.offset;
entries[3].size = c.index_slice.size;
entries[4].binding = 4;
entries[4].buffer = c.visible_draws_buffer;
entries[4].size = WGPU_WHOLE_SIZE;
entries[5].binding = 5;
entries[5].buffer = c.prefix_sums_buffer;
entries[5].size = WGPU_WHOLE_SIZE;
entries[6].binding = 6;
entries[6].buffer = c.per_chunk_uniform;
entries[6].size = 16;
WGPUBindGroupDescriptor desc = {};
desc.layout = model_bgl_;
desc.entryCount = 7;
desc.entries = entries;
desc.label = svFromCStr("ifcviewer-wgpu.chunk_bind_group");
c.bind_group = wgpuDeviceCreateBindGroup(device_, &desc);
}
bool ViewportCore::applyStreamedChunk(
ModelGpuData& m, std::size_t chunk_idx,
const std::vector<std::uint8_t>& vbytes,
const std::vector<std::uint32_t>& idx) {
auto& c = m.chunks[chunk_idx];
c.vertex_slice = pool_.alloc(vbytes.size(), 256);
if (!c.vertex_slice.valid()) return false;
wgpuQueueWriteBuffer(queue_, c.vertex_slice.buffer,
c.vertex_slice.offset,
vbytes.data(), vbytes.size());
m.vram_bytes_vbo += vbytes.size();
if (!idx.empty()) {
const std::size_t ibytes = idx.size() * sizeof(std::uint32_t);
c.index_slice = pool_.alloc(ibytes, 256);
if (!c.index_slice.valid()) {
pool_.free(c.vertex_slice);
m.vram_bytes_vbo -= c.vertex_slice.size;
c.vertex_slice = {};
return false;
}
wgpuQueueWriteBuffer(queue_, c.index_slice.buffer,
c.index_slice.offset,
idx.data(), ibytes);
m.vram_bytes_ebo += ibytes;
}
buildChunkBindGroup(m, chunk_idx);
c.is_resident = true;
c.is_loading = false;
c.loaded_frame_idx = streaming_frame_idx_;
// Per-mesh alpha probe. Scan every vertex of every mesh in this chunk
// for any alpha byte < 255 — fires the mesh_has_alpha flag the cull
// classifier reads to route instances of this mesh to the transparent
// pass. Done here (vs. once at sidecar bake time) because for the
// streaming path the bytes only arrive now; the same code services
// both the worker-result drain and the sync first-frame fallback.
if (m.mesh_has_alpha.size() == m.meshes.size()) {
for (std::uint32_t mi : c.mesh_ids) {
if (mi >= m.meshes.size()) continue;
const MeshInfo& mesh = m.meshes[mi];
if (mesh.vertex_count == 0) continue;
const std::size_t v_off =
std::size_t(m.mesh_chunk_local_base_vertex[mi])
* INSTANCED_VERTEX_STRIDE_BYTES;
const std::size_t v_end = v_off
+ std::size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (v_end > vbytes.size()) continue;
bool any_alpha = false;
// Alpha byte sits in the high byte of the vertex's 3rd u32
// (shader: `w2 >> 24`), i.e. offset 11 within the 12-byte
// vertex record. See InstancedGeometry.h's vertex layout.
for (std::uint32_t v = 0; v < mesh.vertex_count && !any_alpha; ++v) {
const std::size_t a_off = v_off
+ std::size_t(v) * INSTANCED_VERTEX_STRIDE_BYTES + 11;
if (vbytes[a_off] < 255u) any_alpha = true;
}
m.mesh_has_alpha[mi] = any_alpha
? std::uint8_t(1) : std::uint8_t(0);
}
}
// Mesh-local volumes for the meshes in this chunk. applyCachedModel
// left them zero because the bytes weren't in memory yet; the first
// chunk to deliver each mesh fills it in.
bool filled_volume = false;
if (!m.mesh_local_volumes.empty() && !idx.empty()) {
for (std::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 std::size_t v_off =
std::size_t(m.mesh_chunk_local_base_vertex[mi])
* INSTANCED_VERTEX_STRIDE_BYTES;
const std::size_t i_off = m.mesh_chunk_local_ebo_first_u32[mi];
const std::size_t v_end = v_off
+ std::size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (v_end > vbytes.size()) continue;
if (i_off + mesh.index_count > idx.size()) continue;
ModelGpuData::MeshTriangles* tris =
(mi < m.mesh_triangles_cache.size())
? &m.mesh_triangles_cache[mi]
: nullptr;
m.mesh_local_volumes[mi] = computeMeshLocalVolumeQuantised(
mesh, vbytes.data() + v_off, idx.data() + i_off, mesh.index_count,
tris);
filled_volume = true;
}
}
// Fire the tool-refresh callback once per apply if anything new filled
// in. ViewportWindow wires this to its Volume-tool HUD; non-Qt hosts
// leave the callback null (no-op).
if (filled_volume && on_volume_dirty_) on_volume_dirty_();
return true;
}
StreamingThread::Request ViewportCore::makeChunkRequest(
const ModelGpuData& m, std::size_t chunk_idx,
std::uint32_t model_id) {
const auto& c = m.chunks[chunk_idx];
StreamingThread::Request req;
req.model_id = model_id;
req.chunk_idx = chunk_idx;
req.file_path = m.streaming_file_path;
req.vertex_section_offset = m.streaming_vertex_section_offset;
req.index_section_offset = m.streaming_index_section_offset;
req.v_ranges.reserve(c.mesh_ids.size());
req.i_ranges.reserve(c.mesh_ids.size());
for (std::uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = m.meshes[mi];
const std::uint64_t v_bytes =
std::uint64_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (v_bytes > 0) {
req.v_ranges.emplace_back(std::uint64_t(mesh.vbo_byte_offset), v_bytes);
}
if (mesh.index_count > 0) {
req.i_ranges.emplace_back(
std::uint64_t(mesh.ebo_byte_offset / sizeof(std::uint32_t)),
std::uint64_t(mesh.index_count));
}
}
// LOD1 indices second pass — matches the chunk-local packing order
// (all LOD0 first, then LOD1) so the worker's concatenated index
// result lands at the offsets recorded in
// m.mesh_chunk_local_lod1_first_u32.
for (std::uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = m.meshes[mi];
if (mesh.lod1_index_count == 0) continue;
req.i_ranges.emplace_back(
std::uint64_t(mesh.lod1_ebo_byte_offset / sizeof(std::uint32_t)),
std::uint64_t(mesh.lod1_index_count));
}
return req;
}
bool ViewportCore::loadChunkBytesAndUploadGpu(ModelGpuData& m,
std::size_t chunk_idx) {
if (chunk_idx >= m.chunks.size()) return false;
auto& c = m.chunks[chunk_idx];
if (c.is_resident) return true;
if (m.streaming_file_path.empty()) return false;
// Synchronous fallback: build the request, do the disk read inline,
// apply. Used only when the async path can't be — i.e. by the
// screenshot test on first frame.
StreamingThread::Request req = makeChunkRequest(m, chunk_idx, /*model_id*/ 0);
std::vector<std::uint8_t> vbytes;
std::vector<std::uint32_t> idx;
if (!req.v_ranges.empty()) {
if (!readSidecarVertexRanges(req.file_path,
req.vertex_section_offset,
req.v_ranges, vbytes)) {
Log::warn() << "[wgpu stream] failed to read vertex chunk "
<< chunk_idx
<< " (" << req.v_ranges.size() << " ranges, total "
<< c.vertex_byte_size << " B)";
return false;
}
}
if (!req.i_ranges.empty()) {
if (!readSidecarIndexRanges(req.file_path,
req.index_section_offset,
req.i_ranges, idx)) {
Log::warn() << "[wgpu stream] failed to read index chunk "
<< chunk_idx
<< " (" << req.i_ranges.size() << " ranges, total "
<< c.index_count << " indices)";
return false;
}
}
return applyStreamedChunk(m, chunk_idx, vbytes, idx);
}
void ViewportCore::unloadChunk(ModelGpuData& m, std::size_t chunk_idx) {
if (chunk_idx >= m.chunks.size()) return;
auto& c = m.chunks[chunk_idx];
if (!c.is_resident) return;
if (c.bind_group) {
wgpuBindGroupRelease(c.bind_group);
c.bind_group = nullptr;
}
if (c.vertex_slice.valid()) {
m.vram_bytes_vbo -= c.vertex_slice.size;
pool_.free(c.vertex_slice);
c.vertex_slice = {};
}
if (c.index_slice.valid()) {
m.vram_bytes_ebo -= c.index_slice.size;
pool_.free(c.index_slice);
c.index_slice = {};
}
// Clear per-frame visibility so the chunk doesn't get re-rendered or
// re-evicted on the same frame; cull will set it again next time
// the chunk falls in the frustum.
c.total_visible_draws = 0;
c.total_visible_vertices = 0;
c.is_resident = false;
}
// ===========================================================================
// Streaming driver (#84-o): driveStreamingLoads
// ===========================================================================
#include <filesystem>
#include <set>
namespace {
// Extract the file's base name (no extension, no parent dirs) for log
// readability — replaces the previous QFileInfo(...).completeBaseName().
std::string pathStem(const std::string& path) {
if (path.empty()) return {};
return std::filesystem::path(path).stem().string();
}
} // namespace
void ViewportCore::driveStreamingLoads() {
// Bump LRU clock once per call. Resident-and-visible chunks get
// stamped with this value below; the evictor uses it to find the
// least-recently-visible non-visible resident chunk.
++streaming_frame_idx_;
// Refresh per-chunk frame state. (a) LRU stamp on frustum-visible
// residents (HiZ flicker can't un-stamp them; cull-with-HiZ would
// thrash the LRU). (b) EMA-smoothed visibility_history: how often
// the chunk has *actually* contributed pixels (post-HiZ) over the
// last ~30 frames.
constexpr float HISTORY_ALPHA = 1.0f / 30.0f;
for (auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
for (auto& c : m.chunks) {
if (c.is_resident && c.frustum_visible_count > 0) {
c.last_visible_frame_idx = streaming_frame_idx_;
}
const float current = (c.total_visible_draws > 0) ? 1.0f : 0.0f;
c.visibility_history =
c.visibility_history * (1.0f - HISTORY_ALPHA)
+ current * HISTORY_ALPHA;
}
}
// Build the camera's view-projection for the diagnostic dump below.
Eigen::Matrix4f v_mat, p_mat;
buildViewProj(v_mat, p_mat);
const Eigen::Matrix4f vp_mat = p_mat * v_mat;
// chunk.current_priority was accumulated during cullModelCpuCompute
// (one add per frustum-passing instance). No standalone walk needed
// here; the candidate/resident priority lambdas just read it.
auto chunk_screen_area_px = [&](const ModelGpuData::Chunk& c) -> float {
return c.current_priority;
};
// Resident chunks: contribution × visibility_history (floored), so
// chunks that don't actually render lose priority over time and
// become evictable. Candidates: pure contribution — best-case
// estimate. Newly-loaded chunks get a GRACE_FRAMES grace period at
// full max-history factor to stop equal-priority swap loops.
constexpr float HISTORY_FLOOR = 0.05f;
constexpr std::uint64_t GRACE_FRAMES = 30;
auto resident_priority = [&](const ModelGpuData::Chunk& c) -> float {
const std::uint64_t age = streaming_frame_idx_ - c.loaded_frame_idx;
const float vis = (age < GRACE_FRAMES)
? 1.0f
: std::max(c.visibility_history, HISTORY_FLOOR);
return chunk_screen_area_px(c) * vis;
};
auto candidate_priority = [&](const ModelGpuData::Chunk& c) -> float {
return chunk_screen_area_px(c);
};
// Per-frame load budget. 4 chunks/frame × 60fps ingests 240/sec —
// a 100-model scene fully resides in ~1s.
constexpr int MAX_STREAMING_LOADS_PER_FRAME = 4;
int loads = 0;
bool more_pending = false;
// Reset per-frame counters used by WGPU_STREAM_DEBUG output.
streaming_candidates_this_frame_ = 0;
streaming_evictions_lru_this_frame_ = 0;
streaming_evictions_pri_this_frame_ = 0;
streaming_drained_this_frame_ = 0;
streaming_blocked_oom_this_frame_ = 0;
auto pool_can_fit = [&](std::uint64_t bytes) -> bool {
if (pool_.largest_free_run_bytes() >= bytes) return true;
if (pool_.can_grow() && pool_.next_growth_size_bytes() >= bytes) return true;
return false;
};
// Phase-1 evictor: drop the LRU non-visible resident chunk. Skips
// chunks stamped on streaming_frame_idx_ to avoid yanking what cull
// just marked visible.
auto evict_one_lru = [&]() -> bool {
ModelGpuData* victim_m = nullptr;
std::size_t victim_ci = 0;
std::uint64_t victim_lru = std::numeric_limits<std::uint64_t>::max();
for (auto& [mid, m] : models_gpu_) {
for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) {
auto& c = m.chunks[ci];
if (!c.is_resident) continue;
if (c.last_visible_frame_idx == streaming_frame_idx_) continue;
if (c.last_visible_frame_idx < victim_lru) {
victim_lru = c.last_visible_frame_idx;
victim_m = &m;
victim_ci = ci;
}
}
}
if (!victim_m) return false;
unloadChunk(*victim_m, victim_ci);
++streaming_evictions_lru_this_frame_;
return true;
};
// Phase-2 evictor: when every resident is visible-this-frame but a
// higher-priority candidate needs room, drop the lowest-priority
// resident provided the candidate's contribution is meaningfully
// bigger (2× area hysteresis stops oscillation).
constexpr float EVICT_PRIORITY_RATIO = 2.0f;
// WGPU_STREAM_EVICT_LOG=1 — log every priority-eviction with the
// (candidate, victim) pair and detect direct A→B→A 2-cycles.
static const bool evict_log =
std::getenv("WGPU_STREAM_EVICT_LOG") != nullptr;
auto evict_lowest_priority_than = [&](std::uint32_t cand_mid,
std::uint32_t cand_ci,
float cand_priority) -> bool {
const float threshold = cand_priority / EVICT_PRIORITY_RATIO;
ModelGpuData* victim_m = nullptr;
std::size_t victim_ci = 0;
float victim_priority = threshold;
for (auto& [mid, m] : models_gpu_) {
for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) {
auto& c = m.chunks[ci];
if (!c.is_resident) continue;
const float p = resident_priority(c);
if (p < victim_priority) {
victim_priority = p;
victim_m = &m;
victim_ci = ci;
}
}
}
if (!victim_m) return false;
auto& victim = victim_m->chunks[victim_ci];
if (evict_log) {
const std::string cand_stem = pathStem(
models_gpu_.at(cand_mid).streaming_file_path);
const std::string vic_stem = pathStem(victim_m->streaming_file_path);
// 2-cycle detection: this victim was previously evicted by
// THIS exact candidate — the smoking gun for a swap loop.
const bool is_2_cycle =
victim.last_evicted_by_model_id == cand_mid
&& victim.last_evicted_by_chunk_idx == cand_ci
&& victim.load_count > 1;
Log::info()
<< (is_2_cycle ? "[evict 2-cycle] " : "[evict] ")
<< "kicked chunk " << victim_ci
<< " of " << vic_stem
<< " (eff=" << int(victim_priority)
<< ", load_count=" << victim.load_count
<< ") for chunk " << cand_ci
<< " of " << cand_stem
<< " (pri=" << int(cand_priority)
<< ", threshold=" << int(threshold) << ")";
}
victim.last_evicted_by_model_id = cand_mid;
victim.last_evicted_by_chunk_idx = cand_ci;
victim.last_evicted_by_priority = cand_priority;
victim.last_evicted_frame_idx = streaming_frame_idx_;
unloadChunk(*victim_m, victim_ci);
++streaming_evictions_pri_this_frame_;
return true;
};
constexpr std::uint64_t BLOCKED_COOLDOWN_FRAMES = 180;
// ---- Drain worker results -------------------------------------------
{
auto results = streaming_thread_.drainResults();
for (auto& res : results) {
auto it = models_gpu_.find(res.model_id);
if (it == models_gpu_.end()) continue; // model unloaded
auto& m = it->second;
if (res.chunk_idx >= m.chunks.size()) continue;
auto& c = m.chunks[res.chunk_idx];
c.is_loading = false;
if (!res.success) {
Log::warn() << "[wgpu stream] worker read failed for model "
<< res.model_id << " chunk " << res.chunk_idx;
continue;
}
if (!applyStreamedChunk(m, res.chunk_idx, res.vbytes, res.idx)) {
c.blocked_cooldown_until_frame_idx =
streaming_frame_idx_ + BLOCKED_COOLDOWN_FRAMES;
if (evict_log) {
Log::info()
<< "[blocked-apply] chunk " << res.chunk_idx
<< " of " << pathStem(m.streaming_file_path)
<< " — pool OOM at apply, fetched bytes discarded"
<< " — cooldown " << BLOCKED_COOLDOWN_FRAMES << "f";
}
continue;
}
++loads;
++streaming_drained_this_frame_;
++c.load_count;
c.last_visible_frame_idx = streaming_frame_idx_;
// Thrash watch — fire once per power-of-≈3 threshold.
const std::uint32_t lc = c.load_count;
if (lc == 3 || lc == 10 || lc == 30 || lc == 100
|| (lc > 100 && (lc % 100) == 0)) {
Log::info()
<< "[stream thrash] chunk " << res.chunk_idx
<< " of " << pathStem(m.streaming_file_path)
<< " loaded " << lc << "x -- pool saturated?";
}
}
}
// ---- Enqueue new requests -------------------------------------------
struct Candidate {
ModelGpuData* m;
std::size_t ci;
std::uint32_t mid;
float priority;
};
std::vector<Candidate> candidates;
candidates.reserve(64);
for (auto& [mid, m] : models_gpu_) {
if (m.streaming_file_path.empty() || m.hidden) continue;
for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) {
auto& c = m.chunks[ci];
if (c.is_resident) continue;
if (c.is_loading) continue;
if (c.frustum_visible_count == 0) continue;
if (c.blocked_cooldown_until_frame_idx > streaming_frame_idx_) continue;
candidates.push_back({&m, ci, mid, candidate_priority(c)});
}
}
streaming_candidates_this_frame_ = int(candidates.size());
std::sort(candidates.begin(), candidates.end(),
[](const Candidate& a, const Candidate& b) {
return a.priority > b.priority;
});
int enqueued = 0;
for (const Candidate& cand : candidates) {
if (enqueued >= MAX_STREAMING_LOADS_PER_FRAME) {
more_pending = true;
break;
}
auto& c = cand.m->chunks[cand.ci];
const std::uint64_t need = c.vertex_byte_size
+ c.index_count * sizeof(std::uint32_t);
while (!pool_can_fit(c.vertex_byte_size)
|| (c.index_count > 0
&& !pool_can_fit(c.index_count * sizeof(std::uint32_t)))
|| pool_.total_free_bytes() < need) {
if (evict_one_lru()) continue;
if (evict_lowest_priority_than(cand.mid, std::uint32_t(cand.ci),
cand.priority)) continue;
break;
}
if (!pool_can_fit(c.vertex_byte_size)
|| (c.index_count > 0
&& !pool_can_fit(c.index_count * sizeof(std::uint32_t)))) {
++streaming_blocked_oom_this_frame_;
c.blocked_cooldown_until_frame_idx =
streaming_frame_idx_ + BLOCKED_COOLDOWN_FRAMES;
if (evict_log) {
const std::uint64_t v_bytes = c.vertex_byte_size;
const std::uint64_t i_bytes = c.index_count * sizeof(std::uint32_t);
const double mb = 1.0 / (1024.0 * 1024.0);
Log::info()
<< "[blocked] chunk " << cand.ci
<< " of " << pathStem(cand.m->streaming_file_path)
<< " (pri=" << int(cand.priority)
<< ") -- needs v=" << double(v_bytes) * mb
<< " MB + i=" << double(i_bytes) * mb
<< " MB; pool largest_free="
<< double(pool_.largest_free_run_bytes()) * mb
<< " MB total_free="
<< double(pool_.total_free_bytes()) * mb
<< " MB can_grow=" << (pool_.can_grow() ? "Y" : "N")
<< " -- cooldown " << BLOCKED_COOLDOWN_FRAMES << "f";
}
more_pending = true;
continue;
}
// Sync fallback. Two ways to land here:
// - A screenshot capture is pending — the deferred-capture
// wait would let the window manager re-layout while we wait,
// capturing at the wrong size. Sync loads make the chunk
// appear in the same frame we enqueue.
// - Emscripten — the worker thread isn't started (no pthreads
// wired yet, #88), so streaming_thread_.enqueue would just
// queue requests with nothing to drain them. Chunks would
// never go resident.
#if defined(__EMSCRIPTEN__)
// Blob-sourced models read chunk bytes asynchronously via
// Blob.slice (the whole file is never in the heap). The chunk goes
// resident in the JS completion callback; hold is_loading until then
// so it isn't re-issued every frame. The embedded MEMFS sample falls
// through to the synchronous fopen path below.
if (cand.m->streaming_from_blob) {
c.is_loading = true;
c.last_visible_frame_idx = streaming_frame_idx_;
beginWebChunkLoad(cand.mid, cand.ci);
++enqueued;
continue;
}
const bool use_sync = true;
#else
const bool use_sync = !pending_screenshot_path_.empty();
#endif
if (use_sync) {
if (loadChunkBytesAndUploadGpu(*cand.m, cand.ci)) {
++enqueued;
c.last_visible_frame_idx = streaming_frame_idx_;
}
continue;
}
if (streaming_thread_.enqueue(makeChunkRequest(*cand.m, cand.ci, cand.mid))) {
c.is_loading = true;
++enqueued;
}
}
loads += enqueued;
if (loads > 0 || streaming_thread_.inFlightApprox() > 0) host_->requestFrame();
streaming_loads_this_frame_ = loads;
streaming_more_pending_ = more_pending;
// Click-and-track diagnostic. When the user picked an object, we
// noted which chunk holds it. If that chunk has just transitioned
// resident→evicted, dump the priority + pool state at the moment
// of loss.
if (tracked_chunk_idx_ != SIZE_MAX) {
auto it = models_gpu_.find(tracked_chunk_mid_);
if (it != models_gpu_.end()
&& tracked_chunk_idx_ < it->second.chunks.size()) {
const auto& m = it->second;
const auto& c = m.chunks[tracked_chunk_idx_];
if (tracked_was_resident_ && !c.is_resident) {
const double mb = 1.0 / (1024.0 * 1024.0);
const float my_area = chunkScreenAreaPx(c, vp_mat);
const std::uint64_t my_bytes = c.vertex_byte_size
+ c.index_count * sizeof(std::uint32_t);
Log::info()
<< "[track] chunk " << tracked_chunk_idx_
<< " (object " << tracked_object_id_
<< ", model " << tracked_chunk_mid_
<< ") EVICTED this frame";
Log::info()
<< " area=" << int(my_area) << "px2"
<< " frustum_vis=" << c.frustum_visible_count
<< " hist=" << c.visibility_history
<< " load_count=" << c.load_count
<< " size=" << double(my_bytes) * mb << "MB";
Log::info()
<< " chunk aabb "
<< (c.aabb_max[0] - c.aabb_min[0]) << "x"
<< (c.aabb_max[1] - c.aabb_min[1]) << "x"
<< (c.aabb_max[2] - c.aabb_min[2]) << "m"
<< " centre=("
<< 0.5f * (c.aabb_min[0] + c.aabb_max[0]) << ","
<< 0.5f * (c.aabb_min[1] + c.aabb_max[1]) << ","
<< 0.5f * (c.aabb_min[2] + c.aabb_max[2]) << ")";
Log::info()
<< " pool used="
<< int(double(pool_.total_used_bytes()) * mb)
<< "/"
<< int(double(pool_.total_capacity_bytes()) * mb)
<< "MB largest_free="
<< double(pool_.largest_free_run_bytes()) * mb << "MB";
Log::info()
<< " this-frame: cands=" << streaming_candidates_this_frame_
<< " enq=" << enqueued
<< " ev_lru=" << streaming_evictions_lru_this_frame_
<< " ev_pri=" << streaming_evictions_pri_this_frame_
<< " blocked=" << streaming_blocked_oom_this_frame_;
struct Stat { std::uint32_t mid; std::size_t ci; float area; };
std::vector<Stat> all;
all.reserve(64);
for (const auto& [mid2, m2] : models_gpu_) {
for (std::size_t ci2 = 0; ci2 < m2.chunks.size(); ++ci2) {
const auto& cc = m2.chunks[ci2];
if (cc.is_resident) continue;
if (cc.frustum_visible_count == 0) continue;
all.push_back({mid2, ci2, chunkScreenAreaPx(cc, vp_mat)});
}
}
std::sort(all.begin(), all.end(),
[](const Stat& a, const Stat& b){ return a.area > b.area; });
const std::size_t n = std::min<std::size_t>(5, all.size());
for (std::size_t i = 0; i < n; ++i) {
Log::info()
<< " top cand #" << i << ": model " << all[i].mid
<< " chunk " << all[i].ci
<< " area=" << int(all[i].area) << "px2";
}
}
tracked_was_resident_ = c.is_resident;
}
}
if (streaming_debug_) {
std::size_t resident = 0;
std::uint32_t max_load_count = 0;
std::size_t cycled = 0;
for (const auto& [mid, m] : models_gpu_) {
for (const auto& c : m.chunks) {
if (c.is_resident) ++resident;
if (c.load_count > max_load_count) max_load_count = c.load_count;
if (c.load_count > 1) ++cycled;
}
}
Log::info()
<< "[stream-debug] f" << streaming_frame_idx_
<< " cands=" << streaming_candidates_this_frame_
<< " enq=" << enqueued
<< " drained=" << streaming_drained_this_frame_
<< " ev_lru=" << streaming_evictions_lru_this_frame_
<< " ev_pri=" << streaming_evictions_pri_this_frame_
<< " blocked=" << streaming_blocked_oom_this_frame_
<< " resident=" << resident
<< " cycled=" << cycled
<< " max_load=" << max_load_count;
}
}
// ===========================================================================
// Cull (#84-p): cullModelCpuCompute + cullModelCpuUpload
// ===========================================================================
std::uint32_t ViewportCore::cullModelCpuCompute(
ModelGpuData& m,
const float planes[6][4],
const float eye[3],
const float forward[3],
const float right[3],
const float up[3],
float focal_px,
float min_radius_px,
float lod1_threshold_px,
const HizOccludedFn& hiz_occluded) const {
std::uint32_t hiz_rejects = 0;
if (m.instances.empty() || m.meshes.empty() || m.chunks.empty()) {
return 0;
}
const bool contrib_enabled = (min_radius_px > 0.0f);
const bool lod_enabled = (lod1_threshold_px > 0.0f);
const bool hiz_active = static_cast<bool>(hiz_occluded);
// Reset per-chunk scratch + counters at the start of each cull.
for (auto& c : m.chunks) {
c.visible_draws_scratch.clear();
c.visible_draws_scratch_transparent.clear();
c.transparent_per_draw_vertex_counts.clear();
c.prefix_sums_scratch.clear();
c.prefix_sums_scratch.push_back(0);
c.total_visible_vertices = 0;
c.total_visible_draws = 0;
c.opaque_visible_vertices = 0;
c.opaque_visible_draws = 0;
c.frustum_visible_count = 0;
c.current_priority = 0.0f;
}
// Per-chunk running vertex count for incremental prefix sums.
std::vector<std::uint32_t> running_vertex_count(m.chunks.size(), 0);
auto process_instance = [&](std::uint32_t i) {
const auto& inst = m.instances[i];
if (inst.mesh_id >= m.meshes.size()) return;
if (visibility_.isHidden(inst.object_id)) return;
// Per-instance frustum still needed: a partially-covered subtree
// descended this far means *some* leaves are visible, but not
// necessarily this one.
if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) return;
const std::uint32_t chunk_idx = m.instance_chunk_idx[i];
ModelGpuData::Chunk& c = m.chunks[chunk_idx];
// Bump the chunk's frustum-only counter before contribution / HiZ
// so the streaming loader sees a stable signal across frames.
++c.frustum_visible_count;
const MeshInfo& mesh = m.meshes[inst.mesh_id];
// Two screen-space metrics: sphere-radius projection (cheap,
// conservative — used for contribution + LOD pick) and AABB-
// rectangle projection (tight — used for streaming priority).
float projected_px = std::numeric_limits<float>::infinity();
{
const float cx = 0.5f * (inst.world_aabb_min[0] + inst.world_aabb_max[0]);
const float cy = 0.5f * (inst.world_aabb_min[1] + inst.world_aabb_max[1]);
const float cz = 0.5f * (inst.world_aabb_min[2] + inst.world_aabb_max[2]);
const float ex = inst.world_aabb_max[0] - inst.world_aabb_min[0];
const float ey = inst.world_aabb_max[1] - inst.world_aabb_min[1];
const float ez = inst.world_aabb_max[2] - inst.world_aabb_min[2];
const float radius_world = 0.5f * std::sqrt(ex*ex + ey*ey + ez*ez);
const float view_z = forward[0] * (cx - eye[0])
+ forward[1] * (cy - eye[1])
+ forward[2] * (cz - eye[2]);
if (view_z > 1e-3f) {
projected_px = radius_world * focal_px / view_z;
const float hex = 0.5f * ex;
const float hey = 0.5f * ey;
const float hez = 0.5f * ez;
const float view_he_x = std::fabs(right[0]) * hex
+ std::fabs(right[1]) * hey
+ std::fabs(right[2]) * hez;
const float view_he_y = std::fabs(up[0]) * hex
+ std::fabs(up[1]) * hey
+ std::fabs(up[2]) * hez;
const float inv_z = focal_px / view_z;
const float box_area_px2 = 4.0f
* view_he_x * inv_z
* view_he_y * inv_z;
c.current_priority += box_area_px2;
}
}
// Contribution cull before HiZ: HiZ is by far the most expensive
// per-instance test, so letting cheap contribution drops happen
// first cuts the HiZ-tested population by ~5× on real scenes.
if (contrib_enabled && projected_px < min_radius_px) return;
if (hiz_active
&& hiz_occluded(inst.world_aabb_min, inst.world_aabb_max)) {
++hiz_rejects;
return;
}
const bool use_lod1 = lod_enabled
&& mesh.lod1_index_count > 0
&& projected_px < lod1_threshold_px;
// Emit one VisibleDraw entry into the chunk that owns this
// instance's vertex range.
ModelGpuData::VisibleDrawGpu d;
d.mesh_id = inst.mesh_id;
d.instance_idx = i;
d.ebo_first_u32 = use_lod1 ? m.instance_lod1_first_u32[i]
: m.instance_ebo_first_u32[i];
d.base_vertex = m.instance_base_vertex[i];
const std::uint32_t entry_vert_count = use_lod1 ? mesh.lod1_index_count
: mesh.index_count;
// Opaque-vs-transparent classifier. Routes the draw into the
// chunk's opaque half or its transparent half. X-ray cap forces
// every instance into the transparent pass so the blend stage
// fires; otherwise a non-zero color_override_rgba8's alpha byte
// (or the mesh's baked has-alpha flag) decides.
const bool xray_active = (xray_alpha_cap_ < 1.0f);
const bool override_active = (inst.color_override_rgba8 != 0u);
const bool is_transparent = xray_active
? true
: (override_active
? (((inst.color_override_rgba8 >> 24) & 0xFFu) < 255u)
: (inst.mesh_id < m.mesh_has_alpha.size()
&& m.mesh_has_alpha[inst.mesh_id] != 0));
if (is_transparent) {
c.visible_draws_scratch_transparent.push_back(d);
c.transparent_per_draw_vertex_counts.push_back(entry_vert_count);
} else {
c.visible_draws_scratch.push_back(d);
running_vertex_count[chunk_idx] += entry_vert_count;
c.prefix_sums_scratch.push_back(running_vertex_count[chunk_idx]);
}
if (use_lod1) {
++lod1_dbg_count_;
lod1_dbg_tris_saved_ += (mesh.index_count > mesh.lod1_index_count
? (mesh.index_count - mesh.lod1_index_count) / 3
: 0);
} else if (mesh.lod1_index_count > 0) {
++lod0_dbg_eligible_count_;
} else {
++lod0_dbg_no_lod1_count_;
}
};
// Chunk-driven walk: frustum-test each chunk's AABB once, skip
// every instance inside when the chunk is off-screen. With spatial
// chunk planning this rejects most instances without ever touching
// them individually — a strict superset of the previous BVH walk's
// win, with zero traversal overhead.
for (auto& c : m.chunks) {
if (c.instance_ids.empty()) continue;
if (!aabbInFrustum(c.aabb_min, c.aabb_max, planes)) continue;
for (std::uint32_t i : c.instance_ids) process_instance(i);
}
for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) {
auto& c = m.chunks[ci];
// Snapshot opaque-half before appending transparents.
c.opaque_visible_draws = std::uint32_t(c.visible_draws_scratch.size());
c.opaque_visible_vertices = running_vertex_count[ci];
// Concatenate transparent entries onto the opaque half and
// continue the prefix-sum sequence. The fragment-pipeline split
// lives in render(): opaque-pass draws [0, opaque_visible_vertices),
// transparent-pass draws [opaque_visible_vertices, total_visible_vertices).
for (std::size_t k = 0; k < c.visible_draws_scratch_transparent.size(); ++k) {
c.visible_draws_scratch.push_back(
c.visible_draws_scratch_transparent[k]);
running_vertex_count[ci] += c.transparent_per_draw_vertex_counts[k];
c.prefix_sums_scratch.push_back(running_vertex_count[ci]);
}
c.total_visible_draws = std::uint32_t(c.visible_draws_scratch.size());
c.total_visible_vertices = running_vertex_count[ci];
}
return hiz_rejects;
}
void ViewportCore::cullModelCpuUpload(ModelGpuData& m) {
for (auto& c : m.chunks) {
if (!c.visible_draws_buffer || !c.prefix_sums_buffer || !c.per_chunk_uniform) continue;
if (c.total_visible_draws == 0) {
// Render() will skip this chunk; still zero the uniform so
// any accidental dispatch sees 0 work.
const std::uint32_t um[4] = { 0, 0, 0, 0 };
wgpuQueueWriteBuffer(queue_, c.per_chunk_uniform, 0, um, sizeof(um));
continue;
}
wgpuQueueWriteBuffer(queue_, c.visible_draws_buffer, 0,
c.visible_draws_scratch.data(),
c.visible_draws_scratch.size()
* sizeof(ModelGpuData::VisibleDrawGpu));
wgpuQueueWriteBuffer(queue_, c.prefix_sums_buffer, 0,
c.prefix_sums_scratch.data(),
c.prefix_sums_scratch.size() * sizeof(std::uint32_t));
// per_chunk_uniform layout (vec4<u32> u_model in the shader):
// [0] total_visible_draws (opaque + transparent)
// [1] total_visible_vertices (sum across the partition)
// [2] opaque_visible_vertices (firstVertex for transparent pass)
// [3] opaque_visible_draws (reserved for a future GPU-side filter)
const std::uint32_t um[4] = {
c.total_visible_draws,
c.total_visible_vertices,
c.opaque_visible_vertices,
c.opaque_visible_draws,
};
wgpuQueueWriteBuffer(queue_, c.per_chunk_uniform, 0, um, sizeof(um));
}
}
// ===========================================================================
// Sidecar / direct load (#84-q): applyCachedModel + uploadMeshChunk +
// uploadInstanceChunk + finalizeModel
// ===========================================================================
#include "ChunkPlanner.h"
#include "VertexQuantization.h"
namespace {
// Allocate a wgpu buffer of `size_bytes` with the given usage, and upload
// `data` into it via the queue. Returns nullptr when size_bytes == 0
// (wgpu rejects zero-sized buffer creation). `label` is informational;
// it shows up in validation messages when something goes wrong.
WGPUBuffer createBufferWithData(WGPUDevice device, WGPUQueue queue,
const void* data, std::size_t size_bytes,
WGPUBufferUsage usage,
const char* label) {
if (size_bytes == 0) return nullptr;
WGPUBufferDescriptor desc = {};
desc.size = std::uint64_t(size_bytes);
desc.usage = usage | WGPUBufferUsage_CopyDst;
if (label) {
desc.label.data = label;
desc.label.length = std::strlen(label);
}
WGPUBuffer buf = wgpuDeviceCreateBuffer(device, &desc);
if (buf && data) {
wgpuQueueWriteBuffer(queue, buf, 0, data, size_bytes);
}
return buf;
}
// Look up (or create) the direct-load staging entry for a given model.
// Holds a unique_ptr so address stability is preserved as the map grows.
SidecarData& getOrCreateDirectStaging(
std::unordered_map<std::uint32_t, std::unique_ptr<SidecarData>>& staging,
std::uint32_t model_id) {
auto it = staging.find(model_id);
if (it == staging.end()) {
auto [it_new, _] = staging.emplace(
model_id, std::make_unique<SidecarData>());
return *it_new->second;
}
return *it->second;
}
} // namespace
void ViewportCore::applyCachedModel(std::uint32_t model_id,
StreamingSidecar metadata) {
if (!device_ || !queue_) {
Log::warn() << "applyCachedModel without an initialised device";
return;
}
// Replace any existing state for this id.
auto it = models_gpu_.find(model_id);
if (it != models_gpu_.end()) {
releaseWgpuModelGpuData(it->second, pool_);
models_gpu_.erase(it);
}
ModelGpuData m;
m.vertex_bytes = metadata.vertex_total_bytes;
m.index_count = std::uint32_t(metadata.index_total_count);
m.mesh_count = std::uint32_t(metadata.meta.meshes.size());
m.instance_count = std::uint32_t(metadata.meta.instances.size());
m.streaming_file_path = metadata.file_path;
m.streaming_vertex_section_offset = metadata.vertex_section_offset;
m.streaming_index_section_offset = metadata.index_section_offset;
// ---- Spatial chunk plan ----------------------------------------------
// Sort meshes by 3D Morton code over centroids, then greedy-pack into
// chunks <= WGPU_CHUNK_VERTEX_BYTES_LIMIT. Each chunk's AABB ends up
// tight rather than spanning the whole model, so the distance-based
// streaming evictor can meaningfully distinguish chunks.
const std::size_t n_meshes = metadata.meta.meshes.size();
m.mesh_chunk_idx.assign(n_meshes, 0);
m.mesh_chunk_local_base_vertex.assign(n_meshes, 0);
m.mesh_chunk_local_ebo_first_u32.assign(n_meshes, 0);
m.mesh_chunk_local_lod1_first_u32.assign(n_meshes, 0);
std::vector<float> mesh_cx(n_meshes, 0.0f),
mesh_cy(n_meshes, 0.0f),
mesh_cz(n_meshes, 0.0f);
std::vector<std::uint32_t> mesh_inst_count(n_meshes, 0);
for (const auto& inst : metadata.meta.instances) {
if (inst.mesh_id >= n_meshes) continue;
mesh_cx[inst.mesh_id] += 0.5f * (inst.world_aabb_min[0] + inst.world_aabb_max[0]);
mesh_cy[inst.mesh_id] += 0.5f * (inst.world_aabb_min[1] + inst.world_aabb_max[1]);
mesh_cz[inst.mesh_id] += 0.5f * (inst.world_aabb_min[2] + inst.world_aabb_max[2]);
++mesh_inst_count[inst.mesh_id];
}
for (std::size_t i = 0; i < n_meshes; ++i) {
if (mesh_inst_count[i] > 0) {
const float inv = 1.0f / float(mesh_inst_count[i]);
mesh_cx[i] *= inv; mesh_cy[i] *= inv; mesh_cz[i] *= inv;
}
}
std::vector<std::vector<std::uint32_t>> chunk_mesh_ids;
std::vector<std::uint32_t> instance_to_chunk;
instance_to_chunk.assign(metadata.meta.instances.size(), 0);
{
std::vector<std::uint32_t> sorted_mesh_ids = ChunkPlanner::sortMeshIdsByMorton(
n_meshes, mesh_cx, mesh_cy, mesh_cz, mesh_inst_count);
std::vector<std::uint32_t> mesh_vertex_count;
mesh_vertex_count.reserve(n_meshes);
for (std::size_t i = 0; i < n_meshes; ++i) {
mesh_vertex_count.push_back(metadata.meta.meshes[i].vertex_count);
}
chunk_mesh_ids = ChunkPlanner::greedyPackChunks(
sorted_mesh_ids, mesh_vertex_count,
INSTANCED_VERTEX_STRIDE_BYTES,
WGPU_CHUNK_VERTEX_BYTES_LIMIT);
std::vector<std::uint32_t> mesh_to_chunk(n_meshes, 0);
for (std::size_t ci = 0; ci < chunk_mesh_ids.size(); ++ci) {
for (std::uint32_t mi : chunk_mesh_ids[ci]) mesh_to_chunk[mi] = std::uint32_t(ci);
}
for (std::size_t i = 0; i < metadata.meta.instances.size(); ++i) {
const std::uint32_t mi = metadata.meta.instances[i].mesh_id;
if (mi < n_meshes) instance_to_chunk[i] = mesh_to_chunk[mi];
}
}
std::vector<std::uint32_t> chunk_instance_count(chunk_mesh_ids.size(), 0);
for (std::size_t i = 0; i < instance_to_chunk.size(); ++i) {
const std::uint32_t ci = instance_to_chunk[i];
if (ci < chunk_instance_count.size()) ++chunk_instance_count[ci];
}
// ---- Allocate per-chunk state. NO pool slices yet (chunks are
// non-resident); the per-frame loader brings them in as cull marks
// them visible.
m.chunks.resize(chunk_mesh_ids.size());
struct MeshLocal {
std::uint32_t base_vertex;
std::uint32_t ebo_first;
std::uint32_t lod1_first;
};
std::vector<std::unordered_map<std::uint32_t, MeshLocal>>
chunk_mesh_offsets(chunk_mesh_ids.size());
for (std::size_t ci = 0; ci < chunk_mesh_ids.size(); ++ci) {
ModelGpuData::Chunk& c = m.chunks[ci];
c.mesh_ids = std::move(chunk_mesh_ids[ci]);
c.is_resident = false;
std::uint32_t chunk_local_v = 0;
std::uint32_t chunk_local_i = 0;
for (std::uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = metadata.meta.meshes[mi];
m.mesh_chunk_idx[mi] = std::uint32_t(ci);
m.mesh_chunk_local_base_vertex[mi] = chunk_local_v;
m.mesh_chunk_local_ebo_first_u32[mi] = chunk_local_i;
chunk_mesh_offsets[ci][mi] = MeshLocal{chunk_local_v, chunk_local_i, 0};
chunk_local_v += mesh.vertex_count;
chunk_local_i += mesh.index_count;
}
std::uint32_t chunk_local_lod1 = 0;
for (std::uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = metadata.meta.meshes[mi];
if (mesh.lod1_index_count == 0) continue;
m.mesh_chunk_local_lod1_first_u32[mi] = chunk_local_i + chunk_local_lod1;
chunk_mesh_offsets[ci][mi].lod1_first = chunk_local_i + chunk_local_lod1;
chunk_local_lod1 += mesh.lod1_index_count;
}
c.vertex_count = chunk_local_v;
c.vertex_byte_size = std::uint64_t(chunk_local_v) * INSTANCED_VERTEX_STRIDE_BYTES;
c.index_count = chunk_local_i + chunk_local_lod1;
c.lod1_index_count = chunk_local_lod1;
// Small per-chunk buffers, allocated upfront so cull can write into
// them. visible_draws_buffer cap = chunk's instance count.
const std::size_t chunk_inst = std::max<std::size_t>(chunk_instance_count[ci], 1);
const std::size_t draws_bytes = chunk_inst * sizeof(ModelGpuData::VisibleDrawGpu);
const std::size_t ps_bytes = (chunk_inst + 1) * sizeof(std::uint32_t);
WGPUBufferDescriptor vd_desc = {};
vd_desc.size = std::max<std::uint64_t>(draws_bytes, 16);
vd_desc.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst;
vd_desc.label = svFromCStr("model.chunk.visible_draws");
c.visible_draws_buffer = wgpuDeviceCreateBuffer(device_, &vd_desc);
c.visible_draws_capacity = chunk_inst;
m.vram_bytes_ssbo += vd_desc.size;
WGPUBufferDescriptor ps_desc = {};
ps_desc.size = std::max<std::uint64_t>(ps_bytes, 16);
ps_desc.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst;
ps_desc.label = svFromCStr("model.chunk.prefix_sums");
c.prefix_sums_buffer = wgpuDeviceCreateBuffer(device_, &ps_desc);
c.prefix_sums_capacity = chunk_inst + 1;
m.vram_bytes_ssbo += ps_desc.size;
WGPUBufferDescriptor mu_desc = {};
mu_desc.size = 16;
mu_desc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
mu_desc.label = svFromCStr("model.chunk.uniform");
c.per_chunk_uniform = wgpuDeviceCreateBuffer(device_, &mu_desc);
m.vram_bytes_ssbo += 16;
c.visible_draws_scratch.reserve(chunk_inst);
c.prefix_sums_scratch.reserve(chunk_inst + 1);
}
// Index section is NOT loaded upfront. Each chunk's index slice is
// range-read alongside its vertex bytes in loadChunkBytesAndUploadGpu.
// MeshGpu storage (per-mesh quant basis).
std::vector<MeshGpu> mesh_gpu;
mesh_gpu.reserve(metadata.meta.meshes.size());
for (const auto& mi : metadata.meta.meshes) {
MeshGpu mg = {};
mg.aabb_min[0] = mi.local_aabb_min[0];
mg.aabb_min[1] = mi.local_aabb_min[1];
mg.aabb_min[2] = mi.local_aabb_min[2];
mg.aabb_max[0] = mi.local_aabb_max[0];
mg.aabb_max[1] = mi.local_aabb_max[1];
mg.aabb_max[2] = mi.local_aabb_max[2];
mesh_gpu.push_back(mg);
}
const std::size_t mesh_storage_bytes = mesh_gpu.size() * sizeof(MeshGpu);
m.mesh_storage = createBufferWithData(
device_, queue_,
mesh_gpu.data(), mesh_storage_bytes,
WGPUBufferUsage_Storage,
"model.mesh_storage");
m.vram_bytes_ssbo += mesh_storage_bytes;
// InstanceGpu storage. Rebase object_ids globally.
const std::uint32_t object_id_base = next_object_id_;
std::uint32_t max_local_id = 0;
std::vector<InstanceGpu> inst_gpu;
inst_gpu.reserve(metadata.meta.instances.size());
for (auto& ic : metadata.meta.instances) {
if (ic.object_id > max_local_id) max_local_id = ic.object_id;
ic.object_id = object_id_base + ic.object_id;
InstanceGpu ig = {};
std::memcpy(ig.transform, ic.transform, sizeof(ig.transform));
ig.object_id = ic.object_id;
ig.color_override_rgba8 = ic.color_override_rgba8;
ig.mesh_id = ic.mesh_id;
inst_gpu.push_back(ig);
}
next_object_id_ = object_id_base + max_local_id + 1;
const std::size_t inst_storage_bytes = inst_gpu.size() * sizeof(InstanceGpu);
m.instance_storage = createBufferWithData(
device_, queue_,
inst_gpu.data(), inst_storage_bytes,
WGPUBufferUsage_Storage,
"model.instance_storage");
m.vram_bytes_ssbo += inst_storage_bytes;
// Hand off CPU mirrors.
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. Both volumes + Area-tool CPU shadow fill in per-chunk
// inside applyStreamedChunk as the bytes arrive.
m.mesh_local_volumes.assign(m.meshes.size(), 0.0);
m.mesh_triangles_cache.assign(m.meshes.size(), ModelGpuData::MeshTriangles{});
m.mesh_has_alpha.assign(m.meshes.size(), std::uint8_t(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 (std::uint32_t i = 0; i < std::uint32_t(m.instances.size()); ++i) {
m.object_id_to_instance.emplace(m.instances[i].object_id, i);
}
// Per-chunk world AABBs + instance-id lists from instance_to_chunk.
for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) {
m.chunks[ci].instance_ids.reserve(m.instances.size() / m.chunks.size() + 4);
}
for (std::uint32_t inst_idx = 0; inst_idx < std::uint32_t(m.instances.size()); ++inst_idx) {
const auto& inst = m.instances[inst_idx];
const std::uint32_t ci = instance_to_chunk[inst_idx];
if (ci >= m.chunks.size()) continue;
auto& c = m.chunks[ci];
for (int a = 0; a < 3; ++a) {
c.aabb_min[a] = std::min(c.aabb_min[a], inst.world_aabb_min[a]);
c.aabb_max[a] = std::max(c.aabb_max[a], inst.world_aabb_max[a]);
}
c.instance_ids.push_back(inst_idx);
}
// Populate per-instance arrays from the per-chunk per-mesh offsets
// computed during chunk construction.
{
const std::size_t n_inst = m.instances.size();
m.instance_chunk_idx.assign(n_inst, 0);
m.instance_base_vertex.assign(n_inst, 0);
m.instance_ebo_first_u32.assign(n_inst, 0);
m.instance_lod1_first_u32.assign(n_inst, 0);
for (std::size_t i = 0; i < n_inst; ++i) {
const std::uint32_t ci = instance_to_chunk[i];
const std::uint32_t mi = m.instances[i].mesh_id;
if (ci >= chunk_mesh_offsets.size()) continue;
auto it_off = chunk_mesh_offsets[ci].find(mi);
if (it_off == chunk_mesh_offsets[ci].end()) continue;
m.instance_chunk_idx[i] = ci;
m.instance_base_vertex[i] = it_off->second.base_vertex;
m.instance_ebo_first_u32[i] = it_off->second.ebo_first;
m.instance_lod1_first_u32[i] = it_off->second.lod1_first;
}
}
auto [inserted, _] = models_gpu_.emplace(model_id, std::move(m));
ModelGpuData& mref = inserted->second;
Log::info()
<< "[wgpu stream] applyCachedModel mid=" << model_id
<< " verts=" << mref.vertex_bytes << "B (deferred)"
<< " idx=" << mref.index_count
<< " meshes=" << mref.mesh_count
<< " instances=" << mref.instance_count
<< " chunks=" << mref.chunks.size();
if (!initial_view_applied_) {
viewAll();
initial_view_applied_ = true;
}
ensureSelectionFlagsBuffer();
host_->requestFrame();
}
void ViewportCore::uploadMeshChunk(const MeshChunk& chunk) {
if (chunk.vertices.empty() || chunk.indices.empty()) return;
SidecarData& s = getOrCreateDirectStaging(pending_direct_loads_, chunk.model_id);
// Streamer format: 7 floats / vertex (pos3 + normal3 + color-as-float).
// Same quantisation as SidecarBuilder::onMeshReady so direct-load and
// sidecar-load produce byte-identical GPU buffers.
const std::size_t n_verts = chunk.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS;
float bmin[3] = { std::numeric_limits<float>::infinity(),
std::numeric_limits<float>::infinity(),
std::numeric_limits<float>::infinity() };
float bmax[3] = { -std::numeric_limits<float>::infinity(),
-std::numeric_limits<float>::infinity(),
-std::numeric_limits<float>::infinity() };
for (std::size_t i = 0; i < n_verts; ++i) {
const float* v = chunk.vertices.data() + i * INSTANCED_VERTEX_STRIDE_FLOATS;
for (int a = 0; a < 3; ++a) {
if (v[a] < bmin[a]) bmin[a] = v[a];
if (v[a] > bmax[a]) bmax[a] = v[a];
}
}
float extent_recip[3];
for (int a = 0; a < 3; ++a) {
const float ext = bmax[a] - bmin[a];
extent_recip[a] = ext > 0.0f ? 1.0f / ext : 0.0f;
}
const std::size_t vb_offset = s.vertices.size();
s.vertices.resize(vb_offset + n_verts * INSTANCED_VERTEX_STRIDE_BYTES);
for (std::size_t i = 0; i < n_verts; ++i) {
quantizeVertex(chunk.vertices.data() + i * INSTANCED_VERTEX_STRIDE_FLOATS,
bmin, extent_recip,
s.vertices.data() + vb_offset
+ i * INSTANCED_VERTEX_STRIDE_BYTES);
}
const std::size_t ib_offset = s.indices.size();
s.indices.insert(s.indices.end(),
chunk.indices.begin(), chunk.indices.end());
MeshInfo info{};
info.vbo_byte_offset = std::uint32_t(vb_offset);
info.vertex_count = std::uint32_t(n_verts);
info.ebo_byte_offset = std::uint32_t(ib_offset * sizeof(std::uint32_t));
info.index_count = std::uint32_t(chunk.indices.size());
for (int a = 0; a < 3; ++a) {
info.local_aabb_min[a] = bmin[a];
info.local_aabb_max[a] = bmax[a];
}
info.first_instance = 0;
info.instance_count = 0;
info.lod1_ebo_byte_offset = 0;
info.lod1_index_count = 0;
if (s.meshes.size() <= chunk.local_mesh_id) {
s.meshes.resize(chunk.local_mesh_id + 1);
}
s.meshes[chunk.local_mesh_id] = info;
}
void ViewportCore::uploadInstanceChunk(const InstanceChunk& chunk) {
SidecarData& s = getOrCreateDirectStaging(pending_direct_loads_, chunk.model_id);
InstanceCpu inst{};
inst.mesh_id = chunk.local_mesh_id;
inst.object_id = chunk.object_id;
inst.color_override_rgba8 = chunk.color_override_rgba8;
inst.model_id = chunk.model_id;
std::memcpy(inst.placement_transformation, chunk.transform,
sizeof(inst.placement_transformation));
for (int i = 0; i < 16; ++i) {
inst.transform[i] = float(chunk.transform[i]);
}
std::memcpy(inst.world_aabb_min, chunk.world_aabb_min, sizeof(inst.world_aabb_min));
std::memcpy(inst.world_aabb_max, chunk.world_aabb_max, sizeof(inst.world_aabb_max));
s.instances.push_back(inst);
}
std::uint32_t ViewportCore::loadSidecarFromPath(const std::string& path) {
if (!device_ || !queue_) {
Log::warn() << "loadSidecarFromPath: wgpu not initialised";
return 0;
}
auto meta_opt = readSidecarMetadataOnly(path);
if (!meta_opt) {
Log::warn() << "loadSidecarFromPath: could not read sidecar metadata from " << path;
return 0;
}
const std::uint32_t mid = next_model_id_++;
applyCachedModel(mid, std::move(*meta_opt));
return mid;
}
#if defined(__EMSCRIPTEN__)
// ===========================================================================
// Web byte-range streaming (#88): Blob.slice source + async chunk loads
// ===========================================================================
//
// The desktop streaming path fopen()s the sidecar and fread()s chunk byte
// ranges synchronously from a worker thread. On web there is no worker (no
// pthreads yet) and Blob.slice() is inherently async, so chunk bytes are
// pulled through the JS event loop: webReadRangesAsync issues one Blob.slice
// per coalesced read plan, scatters the bytes into the destination, then
// invokes a continuation once the whole range set has landed. The picked
// File stays in JS (Module.__ifcvFile) — only chunk-sized slices ever enter
// the wasm heap, so a 500 MB sidecar never does.
namespace {
// Size of the JS-registered File in bytes, or 0 if none. Bounds the metadata
// tail read (index-section end .. EOF).
EM_JS(double, ifcvFileSize, (void), {
return (Module["__ifcvFile"] && Module["__ifcvFile"].size)
? Module["__ifcvFile"].size : 0;
});
// Read [offset, offset+size) of the registered File into dst (which must hold
// `size` bytes), then call back _ifcv_on_range_done(reqId, ok). Async — the
// Blob is sliced and its ArrayBuffer copied into the wasm heap on resolve.
EM_JS(void, ifcvReadRangeInto, (int reqId, double offset, double size, void* dst), {
var f = Module["__ifcvFile"];
if (!f) { Module["_ifcv_on_range_done"](reqId, 0); return; }
f.slice(offset, offset + size).arrayBuffer().then(function(buf) {
HEAPU8.set(new Uint8Array(buf), dst);
Module["_ifcv_on_range_done"](reqId, 1);
}).catch(function(e) {
Module["_ifcv_on_range_done"](reqId, 0);
});
});
// One in-flight multi-range read: a sequence of coalesced plans, each read
// into `scratch` then scattered into `out`. `done(ok, out)` fires once every
// plan has landed, or on the first failure.
struct WebRangeRead {
std::vector<SidecarReadPlan> plans;
std::size_t plan_idx = 0;
std::vector<std::uint8_t> scratch;
std::vector<std::uint8_t> out;
std::function<void(bool, std::vector<std::uint8_t>&&)> done;
};
std::unordered_map<int, WebRangeRead> g_web_reads;
int g_web_read_next = 1;
// Issue the current plan's Blob.slice, or finish (success) if all plans done.
void webIssueCurrentPlan(int id) {
auto it = g_web_reads.find(id);
if (it == g_web_reads.end()) return;
WebRangeRead& r = it->second;
if (r.plan_idx >= r.plans.size()) {
auto done = std::move(r.done);
std::vector<std::uint8_t> out = std::move(r.out);
g_web_reads.erase(it);
if (done) done(true, std::move(out));
return;
}
const SidecarReadPlan& p = r.plans[r.plan_idx];
r.scratch.assign(std::size_t(p.read_size), 0);
ifcvReadRangeInto(id, double(p.file_offset), double(p.read_size),
r.scratch.data());
}
// Read `ranges` (section-relative (offset,size)) into a destination laid out
// in input order, then call done(true, bytes). On any failure: done(false,{}).
// `section_offset` makes the offsets absolute (pass 0 if already absolute).
void webReadRangesAsync(
std::uint64_t section_offset,
const std::vector<std::pair<std::uint64_t, std::uint64_t>>& ranges,
std::function<void(bool, std::vector<std::uint8_t>&&)> done) {
std::uint64_t total = 0;
for (const auto& rg : ranges) total += rg.second;
WebRangeRead r;
r.out.assign(std::size_t(total), 0);
// Coalesce within 1 MB: each Blob.slice is an async round trip, so a
// generous gap trades a few wasted bytes for far fewer JS hops.
r.plans = planSidecarReadRanges(section_offset, ranges, std::uint64_t(1) << 20);
r.done = std::move(done);
if (r.plans.empty()) { // nothing to read — complete synchronously
if (r.done) r.done(true, std::move(r.out));
return;
}
const int id = g_web_read_next++;
g_web_reads.emplace(id, std::move(r));
webIssueCurrentPlan(id);
}
} // namespace
// JS completion callback for one Blob.slice plan. Scatters the landed bytes
// and advances to the next plan, or fails the whole read. Exported as
// _ifcv_on_range_done (see ifcviewer-web/CMakeLists.txt).
extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_on_range_done(int reqId, int ok) {
auto it = g_web_reads.find(reqId);
if (it == g_web_reads.end()) return;
WebRangeRead& r = it->second;
if (!ok) {
auto done = std::move(r.done);
g_web_reads.erase(it);
if (done) done(false, {});
return;
}
const SidecarReadPlan& p = r.plans[r.plan_idx];
for (const auto& s : p.slices) {
std::memcpy(r.out.data() + s.dst_offset,
r.scratch.data() + s.src_offset, std::size_t(s.bytes));
}
++r.plan_idx;
webIssueCurrentPlan(reqId);
}
void ViewportCore::beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_idx) {
auto it = models_gpu_.find(model_id);
if (it == models_gpu_.end()) return;
ModelGpuData& m = it->second;
if (chunk_idx >= m.chunks.size()) return;
const StreamingThread::Request req = makeChunkRequest(m, chunk_idx, model_id);
const std::uint64_t vsec = req.vertex_section_offset;
const std::uint64_t isec = req.index_section_offset;
const std::vector<std::pair<std::uint64_t, std::uint64_t>> v_ranges = req.v_ranges;
// i_ranges are (first_u32, count_u32); convert to byte ranges.
std::vector<std::pair<std::uint64_t, std::uint64_t>> i_byte_ranges;
i_byte_ranges.reserve(req.i_ranges.size());
for (const auto& [first_u32, count] : req.i_ranges)
i_byte_ranges.emplace_back(first_u32 * 4u, count * 4u);
// Read vertex ranges, then index ranges, then apply. Re-look-up the model
// in each callback: a resetScene() could have landed mid-flight, in which
// case the model id is gone and we simply drop the result.
webReadRangesAsync(vsec, v_ranges,
[this, model_id, chunk_idx, isec, i_byte_ranges]
(bool ok, std::vector<std::uint8_t>&& vbytes) {
auto mit = models_gpu_.find(model_id);
if (mit == models_gpu_.end()) return;
if (chunk_idx >= mit->second.chunks.size()) return;
if (!ok) { mit->second.chunks[chunk_idx].is_loading = false; return; }
auto vb = std::make_shared<std::vector<std::uint8_t>>(std::move(vbytes));
webReadRangesAsync(isec, i_byte_ranges,
[this, model_id, chunk_idx, vb]
(bool ok2, std::vector<std::uint8_t>&& ibytes) {
auto mit2 = models_gpu_.find(model_id);
if (mit2 == models_gpu_.end()) return;
ModelGpuData& mm = mit2->second;
if (chunk_idx >= mm.chunks.size()) return;
if (!ok2) { mm.chunks[chunk_idx].is_loading = false; return; }
std::vector<std::uint32_t> idx(ibytes.size() / sizeof(std::uint32_t));
if (!idx.empty())
std::memcpy(idx.data(), ibytes.data(),
idx.size() * sizeof(std::uint32_t));
if (!applyStreamedChunk(mm, chunk_idx, *vb, idx))
mm.chunks[chunk_idx].is_loading = false; // pool full; retry later
else
host_->requestFrame();
});
});
}
void ViewportCore::loadSidecarFromBlobWeb() {
if (!device_ || !queue_) {
Log::warn() << "loadSidecarFromBlobWeb: wgpu not initialised";
return;
}
const double fsize = ifcvFileSize();
if (fsize <= 0.0) {
Log::warn() << "loadSidecarFromBlobWeb: no File registered";
return;
}
// Head (16 B) → num_vertex_bytes; then the 4-byte index count after the
// vertex section; then the metadata tail (index-section end .. EOF). Each
// hop is a tiny Blob.slice; the bulk vertex/index sections are never read
// here — they stream per chunk through beginWebChunkLoad.
webReadRangesAsync(0, {{0, SIDECAR_HEAD_BYTES}},
[this, fsize](bool ok, std::vector<std::uint8_t>&& head) {
std::uint32_t nvb = 0;
if (!ok || !parseSidecarHead(head.data(), head.size(), nvb)) {
Log::warn() << "loadSidecarFromBlobWeb: bad sidecar head";
return;
}
const std::uint64_t vsec = SIDECAR_HEAD_BYTES;
const std::uint64_t idx_count_off = std::uint64_t(SIDECAR_HEAD_BYTES) + nvb;
webReadRangesAsync(0, {{idx_count_off, 4}},
[this, fsize, nvb, vsec, idx_count_off]
(bool ok2, std::vector<std::uint8_t>&& cnt) {
if (!ok2 || cnt.size() < 4) {
Log::warn() << "loadSidecarFromBlobWeb: short index count";
return;
}
std::uint32_t num_indices = 0;
std::memcpy(&num_indices, cnt.data(), 4);
const std::uint64_t isec = idx_count_off + 4;
const std::uint64_t tail_off = isec + std::uint64_t(num_indices) * 4u;
if (double(tail_off) > fsize) {
Log::warn() << "loadSidecarFromBlobWeb: tail offset past EOF";
return;
}
const std::uint64_t tail_len = std::uint64_t(fsize) - tail_off;
webReadRangesAsync(0, {{tail_off, tail_len}},
[this, vsec, nvb, isec, num_indices]
(bool ok3, std::vector<std::uint8_t>&& tail) {
if (!ok3) {
Log::warn() << "loadSidecarFromBlobWeb: tail read failed";
return;
}
StreamingSidecar sc;
sc.file_path = "blob:model.ifcview";
sc.vertex_section_offset = vsec;
sc.vertex_total_bytes = nvb;
sc.index_section_offset = isec;
sc.index_total_count = num_indices;
if (!parseSidecarTail(tail.data(), tail.size(), sc.meta)) {
Log::warn() << "loadSidecarFromBlobWeb: bad metadata tail";
return;
}
const std::size_t n_meshes = sc.meta.meshes.size();
const std::size_t n_instances = sc.meta.instances.size();
const std::uint32_t mid = next_model_id_++;
applyCachedModel(mid, std::move(sc));
auto mit = models_gpu_.find(mid);
if (mit != models_gpu_.end())
mit->second.streaming_from_blob = true;
viewAll();
host_->requestFrame();
Log::info() << "ifcviewer-web: loaded blob sidecar (id "
<< mid << ", " << n_meshes << " meshes, "
<< n_instances << " instances)";
});
});
});
}
#endif // __EMSCRIPTEN__
void ViewportCore::finalizeModel(std::uint32_t model_id) {
auto it = pending_direct_loads_.find(model_id);
if (it == pending_direct_loads_.end()) {
Log::warn()
<< "[wgpu direct] finalizeModel(" << model_id
<< ") with no staged data; skipping";
return;
}
std::unique_ptr<SidecarData> staging_ptr = std::move(it->second);
pending_direct_loads_.erase(it);
SidecarData& s = *staging_ptr;
if (!device_ || !queue_) {
Log::warn() << "[wgpu direct] finalizeModel without an initialised device";
return;
}
if (s.meshes.empty() || s.instances.empty()) {
Log::info() << "[wgpu direct] finalizeModel(" << model_id
<< "): empty staging (meshes=" << s.meshes.size()
<< " instances=" << s.instances.size() << ")";
return;
}
// Build a StreamingSidecar around the staging so applyCachedModel can
// run its chunk planner over the same shape it expects from on-disk
// metadata. file_path is left empty — the streaming worker keys off
// that to skip these chunks (they're already resident after the
// applyStreamedChunk loop below).
StreamingSidecar metadata;
metadata.meta = std::move(s);
metadata.vertex_section_offset = 0;
metadata.vertex_total_bytes = metadata.meta.vertices.size();
metadata.index_section_offset = 0;
metadata.index_total_count = metadata.meta.indices.size();
metadata.file_path.clear();
std::vector<std::uint8_t> raw_vertices = std::move(metadata.meta.vertices);
std::vector<std::uint32_t> raw_indices = std::move(metadata.meta.indices);
applyCachedModel(model_id, std::move(metadata));
auto model_it = models_gpu_.find(model_id);
if (model_it == models_gpu_.end()) {
Log::warn()
<< "[wgpu direct] finalizeModel(" << model_id
<< "): applyCachedModel produced no model entry";
return;
}
ModelGpuData& m = model_it->second;
// Gather each chunk's vertex + index bytes from the staged buffers.
std::size_t chunks_uploaded = 0;
for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) {
auto& c = m.chunks[ci];
if (c.mesh_ids.empty()) continue;
std::vector<std::uint8_t> vbytes(c.vertex_byte_size);
std::vector<std::uint32_t> idx;
idx.reserve(c.index_count);
for (std::uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = m.meshes[mi];
const std::size_t vsz = std::size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (vsz > 0) {
const std::size_t dst_off = std::size_t(m.mesh_chunk_local_base_vertex[mi])
* INSTANCED_VERTEX_STRIDE_BYTES;
std::memcpy(vbytes.data() + dst_off,
raw_vertices.data() + mesh.vbo_byte_offset, vsz);
}
if (mesh.index_count > 0) {
const std::uint32_t* src = raw_indices.data()
+ (mesh.ebo_byte_offset / sizeof(std::uint32_t));
idx.insert(idx.end(), src, src + mesh.index_count);
}
}
if (!applyStreamedChunk(m, ci, vbytes, idx)) {
Log::warn()
<< "[wgpu direct] finalizeModel(" << model_id
<< "): applyStreamedChunk failed on chunk " << ci
<< " (pool OOM?)";
continue;
}
++chunks_uploaded;
}
Log::info()
<< "[wgpu direct] finalizeModel mid=" << model_id
<< " meshes=" << m.meshes.size()
<< " instances=" << m.instances.size()
<< " chunks=" << chunks_uploaded << "/" << m.chunks.size()
<< " verts=" << raw_vertices.size() << "B"
<< " idx=" << raw_indices.size();
}
// ===========================================================================
// HiZ + framebuffer attachments (#84-r)
// ===========================================================================
namespace {
// Tunable per-frame log budget for WGPU_HIZ_TRACE diagnostic mode.
// Also referenced by VW's render() bench-warm gate.
const char* HIZ_WGSL = R"(
struct HizUniforms {
src_w: u32,
src_h: u32,
dst_w: u32,
dst_h: u32,
};
@group(0) @binding(0) var src_depth: texture_depth_multisampled_2d;
@group(0) @binding(1) var<uniform> u_hiz: HizUniforms;
struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
};
@vertex
fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut {
// Fullscreen triangle from a 3-vertex draw, no IA bindings.
let x = f32((vid << 1u) & 2u) * 2.0 - 1.0;
let y = f32(vid & 2u) * 2.0 - 1.0;
var out: VsOut;
out.clip_pos = vec4<f32>(x, -y, 0.0, 1.0);
return out;
}
@fragment
fn fs_main(in: VsOut) -> @builtin(frag_depth) f32 {
let dst_x = u32(in.clip_pos.x);
let dst_y = u32(in.clip_pos.y);
let sx0 = (dst_x * u_hiz.src_w) / u_hiz.dst_w;
let sx1 = ((dst_x + 1u) * u_hiz.src_w) / u_hiz.dst_w;
let sy0 = (dst_y * u_hiz.src_h) / u_hiz.dst_h;
let sy1 = ((dst_y + 1u) * u_hiz.src_h) / u_hiz.dst_h;
var max_d: f32 = 0.0;
for (var y: u32 = sy0; y < sy1; y = y + 1u) {
for (var x: u32 = sx0; x < sx1; x = x + 1u) {
let d = textureLoad(src_depth, vec2<i32>(i32(x), i32(y)), 0);
max_d = max(max_d, d);
}
}
return max_d;
}
)";
} // namespace
bool ViewportCore::buildHizPipeline() {
WGPUBindGroupLayoutEntry entries[2] = {};
entries[0].binding = 0;
entries[0].visibility = WGPUShaderStage_Fragment;
entries[0].texture.sampleType = WGPUTextureSampleType_Depth;
entries[0].texture.viewDimension = WGPUTextureViewDimension_2D;
entries[0].texture.multisampled = 1;
entries[1].binding = 1;
entries[1].visibility = WGPUShaderStage_Fragment;
entries[1].buffer.type = WGPUBufferBindingType_Uniform;
entries[1].buffer.minBindingSize = 16; // 4 u32s
WGPUBindGroupLayoutDescriptor bgl_desc = {};
bgl_desc.entryCount = 2;
bgl_desc.entries = entries;
bgl_desc.label = svFromCStr("ifcviewer-wgpu.hiz_bgl");
hiz_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
WGPUPipelineLayoutDescriptor pl_desc = {};
pl_desc.bindGroupLayoutCount = 1;
pl_desc.bindGroupLayouts = &hiz_bgl_;
pl_desc.label = svFromCStr("ifcviewer-wgpu.hiz_pipeline_layout");
hiz_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
WGPUShaderSourceWGSL wgsl_src = {};
wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL;
wgsl_src.code = svFromCStr(HIZ_WGSL);
WGPUShaderModuleDescriptor sm_desc = {};
sm_desc.nextInChain = &wgsl_src.chain;
sm_desc.label = svFromCStr("ifcviewer-wgpu.hiz_wgsl");
hiz_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
// Depth-only output, no colour target. Single-sample.
WGPUDepthStencilState depth = {};
depth.format = WGPUTextureFormat_Depth32Float;
depth.depthWriteEnabled = WGPUOptionalBool_True;
depth.depthCompare = WGPUCompareFunction_Always;
depth.stencilFront.compare = WGPUCompareFunction_Always;
depth.stencilBack.compare = WGPUCompareFunction_Always;
WGPURenderPipelineDescriptor rp_desc = {};
rp_desc.layout = hiz_pipeline_layout_;
rp_desc.label = svFromCStr("ifcviewer-wgpu.hiz_pipeline");
rp_desc.vertex.module = hiz_shader_module_;
rp_desc.vertex.entryPoint = svFromCStr("vs_main");
rp_desc.vertex.bufferCount = 0;
WGPUFragmentState frag = {};
frag.module = hiz_shader_module_;
frag.entryPoint = svFromCStr("fs_main");
frag.targetCount = 0;
rp_desc.fragment = &frag;
rp_desc.depthStencil = &depth;
rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList;
rp_desc.primitive.cullMode = WGPUCullMode_None;
rp_desc.multisample.count = 1;
rp_desc.multisample.mask = 0xFFFFFFFFu;
hiz_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
if (!hiz_pipeline_) {
Log::warn() << "wgpu hiz pipeline creation failed";
return false;
}
WGPUBufferDescriptor ub_desc = {};
ub_desc.size = 16;
ub_desc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
ub_desc.label = svFromCStr("ifcviewer-wgpu.hiz_uniform");
hiz_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &ub_desc);
return true;
}
void ViewportCore::ensureHizTextures(int viewport_w, int viewport_h) {
if (viewport_w <= 0 || viewport_h <= 0) return;
const std::uint32_t dst_w = HIZ_BASE_W;
const std::uint32_t dst_h = std::max<std::uint32_t>(
1, (std::uint32_t(viewport_h) * dst_w + std::uint32_t(viewport_w) / 2)
/ std::uint32_t(viewport_w));
if (dst_w == hiz_resolve_w_ && dst_h == hiz_resolve_h_ && hiz_resolve_view_) return;
if (hiz_resolve_view_) { wgpuTextureViewRelease(hiz_resolve_view_); hiz_resolve_view_ = nullptr; }
if (hiz_resolve_texture_) { wgpuTextureRelease(hiz_resolve_texture_); hiz_resolve_texture_ = nullptr; }
for (int s = 0; s < HIZ_SLOTS; ++s) {
if (hiz_staging_buffers_[s]) {
if (hiz_slot_state_[s] == HizSlotState::Mapped) {
wgpuBufferUnmap(hiz_staging_buffers_[s]);
}
wgpuBufferRelease(hiz_staging_buffers_[s]);
hiz_staging_buffers_[s] = nullptr;
}
hiz_slot_state_[s] = HizSlotState::Idle;
}
hiz_write_idx_ = 0;
hiz_valid_ = false;
if (hiz_bind_group_) { wgpuBindGroupRelease(hiz_bind_group_); hiz_bind_group_ = nullptr; }
WGPUTextureDescriptor desc = {};
desc.usage = WGPUTextureUsage_RenderAttachment | WGPUTextureUsage_CopySrc;
desc.dimension = WGPUTextureDimension_2D;
desc.size.width = dst_w;
desc.size.height = dst_h;
desc.size.depthOrArrayLayers = 1;
desc.format = WGPUTextureFormat_Depth32Float;
desc.mipLevelCount = 1;
desc.sampleCount = 1;
desc.label = svFromCStr("ifcviewer-wgpu.hiz_resolve");
hiz_resolve_texture_ = wgpuDeviceCreateTexture(device_, &desc);
WGPUTextureViewDescriptor vdesc = {};
vdesc.format = WGPUTextureFormat_Depth32Float;
vdesc.dimension = WGPUTextureViewDimension_2D;
vdesc.mipLevelCount = 1;
vdesc.arrayLayerCount = 1;
vdesc.aspect = WGPUTextureAspect_DepthOnly;
hiz_resolve_view_ = wgpuTextureCreateView(hiz_resolve_texture_, &vdesc);
// Two staging slots ping-pong so GPU fill of slot N overlaps CPU
// read of slot N-1. Rows padded to the WGPU spec's textureToBuffer
// bytesPerRow alignment (256 B).
constexpr std::uint64_t kWgpuBytesPerRowAlign = 256;
hiz_padded_bpr_ = std::uint32_t(
(dst_w * sizeof(float) + kWgpuBytesPerRowAlign - 1)
/ kWgpuBytesPerRowAlign * kWgpuBytesPerRowAlign);
for (int s = 0; s < HIZ_SLOTS; ++s) {
WGPUBufferDescriptor bdesc = {};
bdesc.size = std::uint64_t(hiz_padded_bpr_) * std::uint64_t(dst_h);
bdesc.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead;
bdesc.label = svFromCStr(s == 0 ? "ifcviewer-wgpu.hiz_staging[0]"
: "ifcviewer-wgpu.hiz_staging[1]");
hiz_staging_buffers_[s] = wgpuDeviceCreateBuffer(device_, &bdesc);
}
hiz_resolve_w_ = dst_w;
hiz_resolve_h_ = dst_h;
hiz_valid_ = false;
}
void ViewportCore::releaseHizResources() {
if (hiz_bind_group_) { wgpuBindGroupRelease(hiz_bind_group_); hiz_bind_group_ = nullptr; }
if (hiz_uniform_buffer_) { wgpuBufferRelease(hiz_uniform_buffer_); hiz_uniform_buffer_ = nullptr; }
if (hiz_resolve_view_) { wgpuTextureViewRelease(hiz_resolve_view_); hiz_resolve_view_ = nullptr; }
if (hiz_resolve_texture_) { wgpuTextureRelease(hiz_resolve_texture_); hiz_resolve_texture_ = nullptr; }
for (int s = 0; s < HIZ_SLOTS; ++s) {
if (hiz_staging_buffers_[s]) {
if (hiz_slot_state_[s] == HizSlotState::Mapped) {
wgpuBufferUnmap(hiz_staging_buffers_[s]);
}
wgpuBufferRelease(hiz_staging_buffers_[s]);
hiz_staging_buffers_[s] = nullptr;
}
hiz_slot_state_[s] = HizSlotState::Idle;
}
hiz_write_idx_ = 0;
if (hiz_pipeline_) { wgpuRenderPipelineRelease(hiz_pipeline_); hiz_pipeline_ = nullptr; }
if (hiz_shader_module_) { wgpuShaderModuleRelease(hiz_shader_module_); hiz_shader_module_ = nullptr; }
if (hiz_pipeline_layout_) { wgpuPipelineLayoutRelease(hiz_pipeline_layout_); hiz_pipeline_layout_ = nullptr; }
if (hiz_bgl_) { wgpuBindGroupLayoutRelease(hiz_bgl_); hiz_bgl_ = nullptr; }
hiz_resolve_w_ = hiz_resolve_h_ = hiz_padded_bpr_ = 0;
hiz_valid_ = false;
hiz_pyramid_.clear();
hiz_mip_offset_.clear();
hiz_mip_w_.clear();
hiz_mip_h_.clear();
}
int ViewportCore::encodeHizResolve(WGPUCommandEncoder enc) {
if (!hiz_enabled_ || !hiz_pipeline_ || !hiz_resolve_view_ || !depth_view_) return -1;
// Pick an idle ping-pong slot. If both slots are in flight, skip
// this frame's resolve — the cull keeps using whatever pyramid we
// already have (slightly more stale, never blocks).
int slot = -1;
for (int s = 0; s < HIZ_SLOTS; ++s) {
const int idx = (hiz_write_idx_ + s) % HIZ_SLOTS;
if (hiz_slot_state_[idx] == HizSlotState::Idle) { slot = idx; break; }
}
if (slot < 0) return -1;
hiz_write_idx_ = (slot + 1) % HIZ_SLOTS;
// Rebuild the bind group when the depth view itself was replaced
// (driven by surface resize); the resize path nulls hiz_bind_group_.
if (!hiz_bind_group_) {
WGPUBindGroupEntry entries[2] = {};
entries[0].binding = 0;
entries[0].textureView = depth_view_;
entries[1].binding = 1;
entries[1].buffer = hiz_uniform_buffer_;
entries[1].size = 16;
WGPUBindGroupDescriptor bg = {};
bg.layout = hiz_bgl_;
bg.entryCount = 2;
bg.entries = entries;
bg.label = svFromCStr("ifcviewer-wgpu.hiz_bind_group");
hiz_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg);
}
const std::uint32_t uniforms[4] = {
std::uint32_t(depth_w_), std::uint32_t(depth_h_),
hiz_resolve_w_, hiz_resolve_h_,
};
wgpuQueueWriteBuffer(queue_, hiz_uniform_buffer_, 0, uniforms, sizeof(uniforms));
WGPURenderPassDepthStencilAttachment depth_att = {};
depth_att.view = hiz_resolve_view_;
depth_att.depthLoadOp = WGPULoadOp_Clear;
depth_att.depthStoreOp = WGPUStoreOp_Store;
depth_att.depthClearValue = 0.0f;
depth_att.stencilLoadOp = WGPULoadOp_Undefined;
depth_att.stencilStoreOp = WGPUStoreOp_Undefined;
depth_att.depthReadOnly = false;
depth_att.stencilReadOnly = true;
WGPURenderPassDescriptor pass_desc = {};
pass_desc.colorAttachmentCount = 0;
pass_desc.depthStencilAttachment = &depth_att;
pass_desc.label = svFromCStr("ifcviewer-wgpu.hiz_resolve_pass");
WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
wgpuRenderPassEncoderSetPipeline(pass, hiz_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, hiz_bind_group_, 0, nullptr);
wgpuRenderPassEncoderDraw(pass, 3, 1, 0, 0);
wgpuRenderPassEncoderEnd(pass);
wgpuRenderPassEncoderRelease(pass);
WGPUTexelCopyTextureInfo src = {};
src.texture = hiz_resolve_texture_;
src.aspect = WGPUTextureAspect_DepthOnly;
WGPUTexelCopyBufferInfo dst = {};
dst.buffer = hiz_staging_buffers_[slot];
dst.layout.bytesPerRow = hiz_padded_bpr_;
dst.layout.rowsPerImage = hiz_resolve_h_;
WGPUExtent3D extent = {};
extent.width = hiz_resolve_w_;
extent.height = hiz_resolve_h_;
extent.depthOrArrayLayers = 1;
wgpuCommandEncoderCopyTextureToBuffer(enc, &src, &dst, &extent);
return slot;
}
void ViewportCore::startHizMap(int slot, const Eigen::Matrix4f& vp_used) {
if (slot < 0 || slot >= HIZ_SLOTS) return;
if (!hiz_staging_buffers_[slot] || hiz_resolve_w_ == 0) return;
hiz_slot_vp_[slot] = vp_used;
hiz_slot_state_[slot] = HizSlotState::Mapping;
struct MapCtx { ViewportCore* self; int slot; };
auto* ctx = new MapCtx{ this, slot };
WGPUBufferMapCallbackInfo mcb = {};
mcb.mode = kAsyncCbMode;
mcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*msg*/,
void* ud1, void* /*ud2*/) {
auto* c = static_cast<MapCtx*>(ud1);
if (status == WGPUMapAsyncStatus_Success) {
c->self->hiz_slot_state_[c->slot] = HizSlotState::Mapped;
} else {
c->self->hiz_slot_state_[c->slot] = HizSlotState::Idle;
}
delete c;
};
mcb.userdata1 = ctx;
const std::size_t map_size = std::size_t(hiz_padded_bpr_) * std::size_t(hiz_resolve_h_);
wgpuBufferMapAsync(hiz_staging_buffers_[slot], WGPUMapMode_Read,
0, map_size, mcb);
}
void ViewportCore::drainHizReadbacks() {
if (!hiz_enabled_ || hiz_resolve_w_ == 0) return;
// Non-blocking: wgpuInstanceProcessEvents returns immediately after
// firing any ready callbacks.
wgpuInstanceProcessEvents(instance_);
for (int slot = 0; slot < HIZ_SLOTS; ++slot) {
if (hiz_slot_state_[slot] != HizSlotState::Mapped) continue;
const std::size_t map_size =
std::size_t(hiz_padded_bpr_) * std::size_t(hiz_resolve_h_);
const std::uint8_t* mapped = static_cast<const std::uint8_t*>(
wgpuBufferGetConstMappedRange(hiz_staging_buffers_[slot], 0, map_size));
const std::uint32_t W0 = hiz_resolve_w_;
const std::uint32_t H0 = hiz_resolve_h_;
// (Re)build mip pyramid metadata if dimensions changed. Ceil-
// halving so edge rows of mip 0 always have a child texel.
if (hiz_mip_offset_.empty()
|| hiz_mip_w_.empty() || hiz_mip_w_[0] != W0
|| hiz_mip_h_.empty() || hiz_mip_h_[0] != H0) {
hiz_mip_offset_.clear();
hiz_mip_w_.clear();
hiz_mip_h_.clear();
std::uint32_t total = 0;
std::uint32_t w = W0, h = H0;
while (true) {
hiz_mip_offset_.push_back(total);
hiz_mip_w_.push_back(w);
hiz_mip_h_.push_back(h);
total += w * h;
if (w == 1 && h == 1) break;
w = std::max(1u, (w + 1u) / 2u);
h = std::max(1u, (h + 1u) / 2u);
}
hiz_pyramid_.assign(total, 0.0f);
}
// Mip 0: strip per-row padding.
for (std::uint32_t y = 0; y < H0; ++y) {
std::memcpy(&hiz_pyramid_[y * W0],
mapped + std::size_t(y) * hiz_padded_bpr_,
W0 * sizeof(float));
}
wgpuBufferUnmap(hiz_staging_buffers_[slot]);
hiz_slot_state_[slot] = HizSlotState::Idle;
// Higher mips: max-reduce 2x2 children.
for (std::size_t L = 1; L < hiz_mip_offset_.size(); ++L) {
const std::uint32_t prev_w = hiz_mip_w_[L - 1];
const std::uint32_t prev_h = hiz_mip_h_[L - 1];
const std::uint32_t this_w = hiz_mip_w_[L];
const std::uint32_t this_h = hiz_mip_h_[L];
const float* src = &hiz_pyramid_[hiz_mip_offset_[L - 1]];
float* dst = &hiz_pyramid_[hiz_mip_offset_[L]];
for (std::uint32_t y = 0; y < this_h; ++y) {
for (std::uint32_t x = 0; x < this_w; ++x) {
const std::uint32_t x0 = std::min(prev_w - 1, x * 2u);
const std::uint32_t y0 = std::min(prev_h - 1, y * 2u);
const std::uint32_t x1 = std::min(prev_w - 1, x0 + 1u);
const std::uint32_t y1 = std::min(prev_h - 1, y0 + 1u);
const float a = src[y0 * prev_w + x0];
const float b = src[y0 * prev_w + x1];
const float c = src[y1 * prev_w + x0];
const float d = src[y1 * prev_w + x1];
dst[y * this_w + x] = std::max(std::max(a, b), std::max(c, d));
}
}
}
hiz_vp_ = hiz_slot_vp_[slot];
hiz_valid_ = true;
}
}
bool ViewportCore::aabbOccludedByHiz(const float mn[3], const float mx[3]) const {
if (!hiz_valid_ || hiz_mip_offset_.empty()) return false;
// Project the 8 corners of the AABB. Track min/max NDC x,y, min
// projected z (nearest point to the camera), and whether any
// corner has clip.w <= 0 (straddles near plane).
const float* m = hiz_vp_.data();
auto applyVp = [m](float x, float y, float z, float out[4]) {
out[0] = m[0]*x + m[4]*y + m[8] *z + m[12];
out[1] = m[1]*x + m[5]*y + m[9] *z + m[13];
out[2] = m[2]*x + m[6]*y + m[10]*z + m[14];
out[3] = m[3]*x + m[7]*y + m[11]*z + m[15];
};
float nx_lo = std::numeric_limits<float>::infinity();
float ny_lo = std::numeric_limits<float>::infinity();
float nx_hi = -std::numeric_limits<float>::infinity();
float ny_hi = -std::numeric_limits<float>::infinity();
float min_z = std::numeric_limits<float>::infinity();
for (int i = 0; i < 8; ++i) {
const float x = (i & 1) ? mx[0] : mn[0];
const float y = (i & 2) ? mx[1] : mn[1];
const float z = (i & 4) ? mx[2] : mn[2];
float c[4]; applyVp(x, y, z, c);
if (c[3] <= 1e-4f) return false;
const float inv_w = 1.0f / c[3];
const float ndc_x = c[0] * inv_w;
const float ndc_y = c[1] * inv_w;
const float ndc_z = c[2] * inv_w;
nx_lo = std::min(nx_lo, ndc_x);
ny_lo = std::min(ny_lo, ndc_y);
nx_hi = std::max(nx_hi, ndc_x);
ny_hi = std::max(ny_hi, ndc_y);
min_z = std::min(min_z, ndc_z);
}
if (nx_hi < -1.0f || nx_lo > 1.0f || ny_hi < -1.0f || ny_lo > 1.0f) return false;
if (min_z < 0.0f) return false;
// NDC y is +up; HiZ-texture y is +down (framebuffer-space frag
// coords). v = 0.5 * (1 - ny) gives the mapping.
const std::uint32_t W0 = hiz_mip_w_[0];
const std::uint32_t H0 = hiz_mip_h_[0];
const float u_lo = 0.5f * (nx_lo + 1.0f);
const float u_hi = 0.5f * (nx_hi + 1.0f);
const float v_lo = 0.5f * (1.0f - ny_hi);
const float v_hi = 0.5f * (1.0f - ny_lo);
int x0 = std::max(0, int(std::floor(u_lo * float(W0))));
int x1 = std::min(int(W0) - 1, int(std::ceil (u_hi * float(W0))));
int y0 = std::max(0, int(std::floor(v_lo * float(H0))));
int y1 = std::min(int(H0) - 1, int(std::ceil (v_hi * float(H0))));
if (x1 < x0 || y1 < y0) return false;
// Pick the smallest mip level where the AABB covers <= 2 texels
// per axis. Stops at the coarsest level so 1x1 always works.
const int side = std::max(x1 - x0 + 1, y1 - y0 + 1);
int level = 0;
while (level + 1 < int(hiz_mip_offset_.size()) && (1 << level) < side) ++level;
const std::uint32_t lw = hiz_mip_w_[level];
const std::uint32_t lh = hiz_mip_h_[level];
const int lx0 = std::clamp(int(x0) >> level, 0, int(lw) - 1);
const int ly0 = std::clamp(int(y0) >> level, 0, int(lh) - 1);
const int lx1 = std::clamp(int(x1) >> level, 0, int(lw) - 1);
const int ly1 = std::clamp(int(y1) >> level, 0, int(lh) - 1);
if (lx0 > lx1 || ly0 > ly1) return false;
const float* level_data = &hiz_pyramid_[hiz_mip_offset_[level]];
float max_d = 0.0f;
for (int y = ly0; y <= ly1; ++y) {
for (int x = lx0; x <= lx1; ++x) {
max_d = std::max(max_d, level_data[y * int(lw) + x]);
}
}
// AABB occluded iff its nearest projected z is BEHIND the pyramid's
// coverage (greater in WebGPU's [0,1] z, where 0 is near).
const bool rejected = (min_z > max_d);
// WGPU_HIZ_TRACE diagnostic. Atomic budget shared across the
// parallel cull workers — fetch_sub returns the previous value.
if (rejected && hiz_trace_budget_.load(std::memory_order_relaxed) > 0) {
int prev = hiz_trace_budget_.fetch_sub(1, std::memory_order_relaxed);
if (prev > 0) {
Log::info()
<< "[hiz reject] aabb_min=(" << mn[0] << "," << mn[1] << "," << mn[2] << ")"
<< " aabb_max=(" << mx[0] << "," << mx[1] << "," << mx[2] << ")"
<< " ndc_x=[" << nx_lo << "," << nx_hi << "]"
<< " ndc_y=[" << ny_lo << "," << ny_hi << "]"
<< " min_z=" << min_z << " max_d=" << max_d
<< " gap=" << (min_z - max_d)
<< " level=" << level
<< " sample=(" << lx0 << "," << ly0 << ")-(" << lx1 << "," << ly1 << ")"
<< " mip=" << lw << "x" << lh;
}
}
return rejected;
}
void ViewportCore::ensureDepthTexture(int w, int h) {
if (w == depth_w_ && h == depth_h_ && depth_view_) return;
releaseDepthTexture();
WGPUTextureDescriptor desc = {};
// TextureBinding is needed so the HiZ resolve pass can sample this
// as a texture_depth_multisampled_2d in its fragment shader.
desc.usage = WGPUTextureUsage_RenderAttachment | WGPUTextureUsage_TextureBinding;
desc.dimension = WGPUTextureDimension_2D;
desc.size.width = std::uint32_t(w);
desc.size.height = std::uint32_t(h);
desc.size.depthOrArrayLayers = 1;
desc.format = WGPUTextureFormat_Depth32Float;
desc.mipLevelCount = 1;
desc.sampleCount = kViewportSampleCount; // matches MSAA color target
desc.label = svFromCStr("ifcviewer-wgpu.depth");
depth_texture_ = wgpuDeviceCreateTexture(device_, &desc);
WGPUTextureViewDescriptor vdesc = {};
vdesc.format = WGPUTextureFormat_Depth32Float;
vdesc.dimension = WGPUTextureViewDimension_2D;
vdesc.mipLevelCount = 1;
vdesc.arrayLayerCount = 1;
vdesc.aspect = WGPUTextureAspect_DepthOnly;
depth_view_ = wgpuTextureCreateView(depth_texture_, &vdesc);
depth_w_ = w;
depth_h_ = h;
}
void ViewportCore::releaseDepthTexture() {
if (depth_view_) { wgpuTextureViewRelease(depth_view_); depth_view_ = nullptr; }
if (depth_texture_) { wgpuTextureRelease(depth_texture_); depth_texture_ = nullptr; }
depth_w_ = depth_h_ = 0;
}
void ViewportCore::ensureMsaaColorTexture(int w, int h) {
if (w == msaa_w_ && h == msaa_h_ && msaa_color_view_) return;
releaseMsaaColorTexture();
WGPUTextureDescriptor desc = {};
desc.usage = WGPUTextureUsage_RenderAttachment;
desc.dimension = WGPUTextureDimension_2D;
desc.size.width = std::uint32_t(w);
desc.size.height = std::uint32_t(h);
desc.size.depthOrArrayLayers = 1;
desc.format = surface_format_;
desc.mipLevelCount = 1;
desc.sampleCount = kViewportSampleCount;
desc.label = svFromCStr("ifcviewer-wgpu.msaa_color");
msaa_color_texture_ = wgpuDeviceCreateTexture(device_, &desc);
msaa_color_view_ = wgpuTextureCreateView(msaa_color_texture_, nullptr);
msaa_w_ = w;
msaa_h_ = h;
}
void ViewportCore::releaseMsaaColorTexture() {
if (msaa_color_view_) { wgpuTextureViewRelease(msaa_color_view_); msaa_color_view_ = nullptr; }
if (msaa_color_texture_) { wgpuTextureRelease(msaa_color_texture_); msaa_color_texture_ = nullptr; }
msaa_w_ = msaa_h_ = 0;
}
// ===========================================================================
// Edge silhouette post-process (#84-s): buildEdgePipeline + encodeEdgePass +
// releaseEdgeResources
// ===========================================================================
namespace {
const char* EDGE_WGSL = R"(
@group(0) @binding(0) var src_depth: texture_depth_multisampled_2d;
const NEAR: f32 = 0.1;
const FAR: f32 = 10000.0;
const EDGE_SCALE: f32 = 6.0;
const EDGE_THRESHOLD: f32 = 0.004;
// Depth texture stores [0,1] z (we pre-multiply a z-remap onto Qt's GL-style
// projection in the main pipeline). Convert back to GL-NDC then reverse-
// project to view-space distance.
fn linearise(z: f32) -> f32 {
let ndc = z * 2.0 - 1.0;
return (2.0 * NEAR * FAR) / (FAR + NEAR - ndc * (FAR - NEAR));
}
@vertex
fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
let x = f32((vid << 1u) & 2u) * 2.0 - 1.0;
let y = f32(vid & 2u) * 2.0 - 1.0;
return vec4<f32>(x, y, 0.0, 1.0);
}
@fragment
fn fs_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
let p = vec2<i32>(i32(frag.x), i32(frag.y));
let dim = vec2<i32>(textureDimensions(src_depth));
let dc_raw = textureLoad(src_depth, p, 0);
// Background pixels: nothing was drawn here. Skip so we don't draw
// edges on the void / sky.
if (dc_raw >= 0.99999) { discard; }
let c = linearise(dc_raw);
let n = linearise(textureLoad(src_depth, vec2<i32>(p.x, max(p.y - 1, 0)), 0));
let s = linearise(textureLoad(src_depth, vec2<i32>(p.x, min(p.y + 1, dim.y - 1)), 0));
let e = linearise(textureLoad(src_depth, vec2<i32>(min(p.x + 1, dim.x - 1), p.y), 0));
let w = linearise(textureLoad(src_depth, vec2<i32>(max(p.x - 1, 0), p.y), 0));
let lap = abs(4.0 * c - n - s - e - w);
let t = EDGE_THRESHOLD * c;
let edge = clamp((lap - t) * EDGE_SCALE, 0.0, 0.6);
// Multiplicative blend (Dst, Zero): output rgb = (1 - edge), so the
// existing surface colour is multiplied by (1 - edge) per channel.
return vec4<f32>(vec3<f32>(1.0 - edge), 1.0);
}
)";
} // namespace
bool ViewportCore::buildEdgePipeline() {
WGPUBindGroupLayoutEntry entries[1] = {};
entries[0].binding = 0;
entries[0].visibility = WGPUShaderStage_Fragment;
entries[0].texture.sampleType = WGPUTextureSampleType_Depth;
entries[0].texture.viewDimension = WGPUTextureViewDimension_2D;
entries[0].texture.multisampled = 1;
WGPUBindGroupLayoutDescriptor bgl_desc = {};
bgl_desc.entryCount = 1;
bgl_desc.entries = entries;
bgl_desc.label = svFromCStr("ifcviewer-wgpu.edge_bgl");
edge_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
WGPUPipelineLayoutDescriptor pl_desc = {};
pl_desc.bindGroupLayoutCount = 1;
pl_desc.bindGroupLayouts = &edge_bgl_;
pl_desc.label = svFromCStr("ifcviewer-wgpu.edge_pipeline_layout");
edge_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
WGPUShaderSourceWGSL wgsl_src = {};
wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL;
wgsl_src.code = svFromCStr(EDGE_WGSL);
WGPUShaderModuleDescriptor sm_desc = {};
sm_desc.nextInChain = &wgsl_src.chain;
sm_desc.label = svFromCStr("ifcviewer-wgpu.edge_wgsl");
edge_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
// Multiplicative blend (Dst, Zero): out.rgb = src.rgb * dst.rgb.
// Fragment outputs (1 - edge, 1 - edge, 1 - edge) so the existing
// surface colour is scaled per-channel — strictly darkens, never
// brightens. Matches GL's renderEdgePass (GL_DST_COLOR, GL_ZERO).
WGPUBlendState blend = {};
blend.color.srcFactor = WGPUBlendFactor_Dst;
blend.color.dstFactor = WGPUBlendFactor_Zero;
blend.color.operation = WGPUBlendOperation_Add;
blend.alpha.srcFactor = WGPUBlendFactor_Zero;
blend.alpha.dstFactor = WGPUBlendFactor_One;
blend.alpha.operation = WGPUBlendOperation_Add;
WGPUColorTargetState target = {};
target.format = surface_format_;
target.blend = &blend;
target.writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = edge_shader_module_;
frag.entryPoint = svFromCStr("fs_main");
frag.targetCount = 1;
frag.targets = &target;
WGPURenderPipelineDescriptor rp_desc = {};
rp_desc.layout = edge_pipeline_layout_;
rp_desc.label = svFromCStr("ifcviewer-wgpu.edge_pipeline");
rp_desc.vertex.module = edge_shader_module_;
rp_desc.vertex.entryPoint = svFromCStr("vs_main");
rp_desc.vertex.bufferCount = 0;
rp_desc.fragment = &frag;
rp_desc.depthStencil = nullptr; // no depth attachment
rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList;
rp_desc.primitive.cullMode = WGPUCullMode_None;
rp_desc.multisample.count = 1;
rp_desc.multisample.mask = 0xFFFFFFFFu;
edge_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
if (!edge_pipeline_) {
Log::warn() << "wgpu edge pipeline creation failed";
return false;
}
return true;
}
void ViewportCore::encodeEdgePass(WGPUCommandEncoder enc,
WGPUTextureView surface_view) {
if (!edges_enabled_ || !edge_pipeline_ || !depth_view_ || !surface_view) return;
// Rebuild lazily when the underlying depth view was replaced (on
// resize we proactively null this alongside the HiZ bind group).
if (!edge_bind_group_) {
WGPUBindGroupEntry entry = {};
entry.binding = 0;
entry.textureView = depth_view_;
WGPUBindGroupDescriptor bg = {};
bg.layout = edge_bgl_;
bg.entryCount = 1;
bg.entries = &entry;
bg.label = svFromCStr("ifcviewer-wgpu.edge_bind_group");
edge_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg);
}
WGPURenderPassColorAttachment color = {};
color.view = surface_view;
color.loadOp = WGPULoadOp_Load;
color.storeOp = WGPUStoreOp_Store;
color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
WGPURenderPassDescriptor pass_desc = {};
pass_desc.colorAttachmentCount = 1;
pass_desc.colorAttachments = &color;
pass_desc.depthStencilAttachment = nullptr;
pass_desc.label = svFromCStr("ifcviewer-wgpu.edge_pass");
WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
wgpuRenderPassEncoderSetPipeline(pass, edge_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, edge_bind_group_, 0, nullptr);
wgpuRenderPassEncoderDraw(pass, 3, 1, 0, 0);
wgpuRenderPassEncoderEnd(pass);
wgpuRenderPassEncoderRelease(pass);
}
void ViewportCore::releaseEdgeResources() {
if (edge_bind_group_) { wgpuBindGroupRelease(edge_bind_group_); edge_bind_group_ = nullptr; }
if (edge_pipeline_) { wgpuRenderPipelineRelease(edge_pipeline_); edge_pipeline_ = nullptr; }
if (edge_shader_module_) { wgpuShaderModuleRelease(edge_shader_module_);edge_shader_module_ = nullptr; }
if (edge_pipeline_layout_) { wgpuPipelineLayoutRelease(edge_pipeline_layout_); edge_pipeline_layout_ = nullptr; }
if (edge_bgl_) { wgpuBindGroupLayoutRelease(edge_bgl_); edge_bgl_ = nullptr; }
}
// ===========================================================================
// Pick + raycast (#84-t)
// ===========================================================================
#include <unordered_set>
namespace {
// Slab method ray-AABB. inv_d is precomputed 1/dir per axis.
bool rayAabbSlab(const float ro[3], const float inv_d[3],
const float bmin[3], const float bmax[3]) {
float tmin = 0.0f, tmax = std::numeric_limits<float>::infinity();
for (int i = 0; i < 3; ++i) {
const float t1 = (bmin[i] - ro[i]) * inv_d[i];
const float t2 = (bmax[i] - ro[i]) * inv_d[i];
tmin = std::max(tmin, std::min(t1, t2));
tmax = std::min(tmax, std::max(t1, t2));
}
return tmax >= tmin && tmax >= 0.0f;
}
// Möller-Trumbore ray-triangle. Returns true on hit; t is in dir-units.
bool rayTriMT(const float ro[3], const float rd[3],
const float v0[3], const float v1[3], const float v2[3],
float& t_out) {
constexpr float EPS = 1e-7f;
const float e1[3] = {v1[0]-v0[0], v1[1]-v0[1], v1[2]-v0[2]};
const float e2[3] = {v2[0]-v0[0], v2[1]-v0[1], v2[2]-v0[2]};
const float h[3] = {
rd[1]*e2[2] - rd[2]*e2[1],
rd[2]*e2[0] - rd[0]*e2[2],
rd[0]*e2[1] - rd[1]*e2[0]
};
const float a = e1[0]*h[0] + e1[1]*h[1] + e1[2]*h[2];
if (a > -EPS && a < EPS) return false;
const float f = 1.0f / a;
const float s[3] = {ro[0]-v0[0], ro[1]-v0[1], ro[2]-v0[2]};
const float u = f * (s[0]*h[0] + s[1]*h[1] + s[2]*h[2]);
if (u < 0.0f || u > 1.0f) return false;
const float q[3] = {
s[1]*e1[2] - s[2]*e1[1],
s[2]*e1[0] - s[0]*e1[2],
s[0]*e1[1] - s[1]*e1[0]
};
const float v = f * (rd[0]*q[0] + rd[1]*q[1] + rd[2]*q[2]);
if (v < 0.0f || u + v > 1.0f) return false;
const float t = f * (e2[0]*q[0] + e2[1]*q[1] + e2[2]*q[2]);
if (t <= EPS) return false;
t_out = t;
return true;
}
// Slab-method ray-AABB intersection. Returns t_enter (clamped to >= 0)
// and the axis-aligned face normal at the entry.
bool rayAABBHit(const Eigen::Vector3f& origin, const Eigen::Vector3f& dir,
const float mn[3], const float mx[3],
float& t_enter, Eigen::Vector3f& face_normal) {
float t_min = -std::numeric_limits<float>::infinity();
float t_max = std::numeric_limits<float>::infinity();
const float o[3] = { origin.x(), origin.y(), origin.z() };
const float d[3] = { dir.x(), dir.y(), dir.z() };
int hit_axis = -1;
float hit_sign = 0.0f;
for (int i = 0; i < 3; ++i) {
if (std::abs(d[i]) < 1e-8f) {
if (o[i] < mn[i] || o[i] > mx[i]) return false;
continue;
}
float t1 = (mn[i] - o[i]) / d[i];
float t2 = (mx[i] - o[i]) / d[i];
float sign_for_t1 = -1.0f;
if (t1 > t2) { std::swap(t1, t2); sign_for_t1 = +1.0f; }
if (t1 > t_min) {
t_min = t1;
hit_axis = i;
hit_sign = sign_for_t1;
}
t_max = std::min(t_max, t2);
if (t_min > t_max) return false;
}
if (t_max < 0.0f) return false;
t_enter = std::max(t_min, 0.0f);
if (hit_axis < 0) {
face_normal = -dir;
} else {
Eigen::Vector3f n(0, 0, 0);
n[hit_axis] = hit_sign;
face_normal = n;
}
return true;
}
} // namespace
bool ViewportCore::buildPickPipeline() {
// Two color attachments: R32UInt for object_id, RGBA16F for the
// packed world-space normal so the section tool can drop
// perpendicular cuts at the picked pixel.
WGPUColorTargetState color_targets[2] = {};
color_targets[0].format = WGPUTextureFormat_R32Uint;
color_targets[0].writeMask = WGPUColorWriteMask_All;
color_targets[1].format = WGPUTextureFormat_RGBA16Float;
color_targets[1].writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = main_shader_module_;
frag.entryPoint = svFromCStr("fs_pick");
frag.targetCount = 2;
frag.targets = color_targets;
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_) {
Log::warn() << "wgpu pick pipeline creation failed";
return false;
}
return true;
}
void ViewportCore::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_normal_view_) { wgpuTextureViewRelease(pick_normal_view_); pick_normal_view_ = nullptr; }
if (pick_normal_texture_) { wgpuTextureRelease(pick_normal_texture_); pick_normal_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 = std::uint32_t(w);
cdesc.size.height = std::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 ndesc = cdesc;
ndesc.format = WGPUTextureFormat_RGBA16Float;
ndesc.label = svFromCStr("ifcviewer-wgpu.pick_normal");
pick_normal_texture_ = wgpuDeviceCreateTexture(device_, &ndesc);
pick_normal_view_ = wgpuTextureCreateView(pick_normal_texture_, nullptr);
WGPUTextureDescriptor ddesc = {};
ddesc.usage = WGPUTextureUsage_RenderAttachment;
ddesc.dimension = WGPUTextureDimension_2D;
ddesc.size.width = std::uint32_t(w);
ddesc.size.height = std::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 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);
}
if (!pick_normal_staging_buffer_) {
WGPUBufferDescriptor sb = {};
sb.size = 256;
sb.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead;
sb.label = svFromCStr("ifcviewer-wgpu.pick_normal_staging");
pick_normal_staging_buffer_ = wgpuDeviceCreateBuffer(device_, &sb);
}
pick_w_ = w;
pick_h_ = h;
}
void ViewportCore::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_normal_view_) { wgpuTextureViewRelease(pick_normal_view_); pick_normal_view_ = nullptr; }
if (pick_normal_texture_) { wgpuTextureRelease(pick_normal_texture_); pick_normal_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_normal_staging_buffer_) {
wgpuBufferRelease(pick_normal_staging_buffer_);
pick_normal_staging_buffer_ = nullptr;
}
if (pick_pipeline_) { wgpuRenderPipelineRelease(pick_pipeline_); pick_pipeline_ = nullptr; }
if (box_pick_staging_buffer_) {
wgpuBufferRelease(box_pick_staging_buffer_);
box_pick_staging_buffer_ = nullptr;
}
box_pick_staging_capacity_ = 0;
pick_w_ = pick_h_ = 0;
}
std::uint32_t ViewportCore::pickObjectAt(int x_pixels, int y_pixels,
Eigen::Vector3f* normal_out) {
if (normal_out) *normal_out = Eigen::Vector3f(0, 0, 1);
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;
if (normal_out && !pick_normal_staging_buffer_) return 0;
// The current frame's visible_draws are already on the GPU (uploaded
// by the last render's cullModelCpuUpload). Encode a one-shot pass.
WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(device_, nullptr);
WGPURenderPassColorAttachment color[2] = {};
color[0].view = pick_color_view_;
color[0].loadOp = WGPULoadOp_Clear;
color[0].storeOp = WGPUStoreOp_Store;
color[0].clearValue = { 0.0, 0.0, 0.0, 0.0 }; // object_id == 0 means miss
color[0].depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
color[1].view = pick_normal_view_;
color[1].loadOp = WGPULoadOp_Clear;
color[1].storeOp = WGPUStoreOp_Store;
color[1].clearValue = { 0.5, 0.5, 0.5, 0.0 };
color[1].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 = 2;
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) 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);
// Copy the single texel at (x, y) into the staging buffer.
WGPUTexelCopyTextureInfo src = {};
src.texture = pick_color_texture_;
src.aspect = WGPUTextureAspect_All;
src.origin.x = std::uint32_t(x_pixels);
src.origin.y = std::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);
if (normal_out) {
WGPUTexelCopyTextureInfo nsrc = {};
nsrc.texture = pick_normal_texture_;
nsrc.aspect = WGPUTextureAspect_All;
nsrc.origin.x = std::uint32_t(x_pixels);
nsrc.origin.y = std::uint32_t(y_pixels);
WGPUTexelCopyBufferInfo ndst = {};
ndst.buffer = pick_normal_staging_buffer_;
ndst.layout.bytesPerRow = 256;
ndst.layout.rowsPerImage = 1;
wgpuCommandEncoderCopyTextureToBuffer(enc, &nsrc, &ndst, &extent);
}
WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(enc, nullptr);
wgpuQueueSubmit(queue_, 1, &cmd);
wgpuCommandBufferRelease(cmd);
wgpuCommandEncoderRelease(enc);
// Sync wait — pick is rare (click), so the GPU stall is fine.
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(pick_staging_buffer_, WGPUMapMode_Read, 0, 256, mcb);
while (!req.done) waitTickInstance(instance_);
if (!req.ok) return 0;
const std::uint32_t* mapped = static_cast<const std::uint32_t*>(
wgpuBufferGetConstMappedRange(pick_staging_buffer_, 0, 256));
const std::uint32_t object_id = mapped ? mapped[0] : 0u;
wgpuBufferUnmap(pick_staging_buffer_);
if (normal_out && object_id != 0) {
MapReq nreq;
WGPUBufferMapCallbackInfo ncb = mcb;
ncb.userdata1 = &nreq;
wgpuBufferMapAsync(pick_normal_staging_buffer_, WGPUMapMode_Read, 0, 256, ncb);
while (!nreq.done) waitTickInstance(instance_);
if (nreq.ok) {
const std::uint16_t* halves = static_cast<const std::uint16_t*>(
wgpuBufferGetConstMappedRange(pick_normal_staging_buffer_, 0, 256));
if (halves) {
// IEEE 754 half → float. Standard bit-fiddle (no STL
// helper in pre-C++23).
auto h2f = [](std::uint16_t h) -> float {
const std::uint32_t sign = std::uint32_t(h & 0x8000u) << 16;
std::uint32_t exponent = std::uint32_t(h & 0x7C00u) >> 10;
std::uint32_t mantissa = std::uint32_t(h & 0x03FFu);
if (exponent == 0) {
if (mantissa == 0) {
union { std::uint32_t u; float f; } v{ sign };
return v.f;
}
while ((mantissa & 0x0400u) == 0) {
mantissa <<= 1;
--exponent;
}
++exponent;
mantissa &= 0x03FFu;
} else if (exponent == 0x1Fu) {
exponent = 0xFFu;
} else {
exponent += (127u - 15u);
}
const std::uint32_t bits = sign | (exponent << 23) | (mantissa << 13);
union { std::uint32_t u; float f; } v{ bits };
return v.f;
};
const float nx = h2f(halves[0]) * 2.0f - 1.0f;
const float ny = h2f(halves[1]) * 2.0f - 1.0f;
const float nz = h2f(halves[2]) * 2.0f - 1.0f;
Eigen::Vector3f n(nx, ny, nz);
if (n.squaredNorm() > 1e-6f) *normal_out = n.normalized();
}
wgpuBufferUnmap(pick_normal_staging_buffer_);
}
}
return object_id;
}
std::vector<std::uint32_t> ViewportCore::picksInRect(int x, int y, int w, int h) {
std::vector<std::uint32_t> out;
if (w <= 0 || h <= 0) return out;
if (!pick_pipeline_ || !device_ || !queue_ || models_gpu_.empty()) return out;
if (configured_w_ <= 0 || configured_h_ <= 0) return out;
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 out;
ensurePickAttachments(configured_w_, configured_h_);
if (!pick_color_view_ || !pick_depth_view_) return out;
// Padded bytes-per-row. R32UInt = 4 B/texel; align to 256 B.
constexpr std::uint64_t kWgpuBytesPerRowAlign = 256;
const std::uint64_t unpadded_bpr = std::uint64_t(w) * 4;
const std::uint64_t padded_bpr = (unpadded_bpr + kWgpuBytesPerRowAlign - 1)
/ kWgpuBytesPerRowAlign
* kWgpuBytesPerRowAlign;
const std::uint64_t needed_bytes = padded_bpr * std::uint64_t(h);
if (needed_bytes > box_pick_staging_capacity_) {
if (box_pick_staging_buffer_) {
wgpuBufferRelease(box_pick_staging_buffer_);
box_pick_staging_buffer_ = nullptr;
}
const std::uint64_t cap = std::max<std::uint64_t>(needed_bytes * 2, 64 * 1024);
WGPUBufferDescriptor sb = {};
sb.size = cap;
sb.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead;
sb.label = svFromCStr("ifcviewer-wgpu.box_pick_staging");
box_pick_staging_buffer_ = wgpuDeviceCreateBuffer(device_, &sb);
box_pick_staging_capacity_ = cap;
}
if (!box_pick_staging_buffer_) return out;
WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(device_, nullptr);
WGPURenderPassColorAttachment color[2] = {};
color[0].view = pick_color_view_;
color[0].loadOp = WGPULoadOp_Clear;
color[0].storeOp = WGPUStoreOp_Store;
color[0].clearValue = { 0, 0, 0, 0 };
color[0].depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
color[1].view = pick_normal_view_;
color[1].loadOp = WGPULoadOp_Clear;
color[1].storeOp = WGPUStoreOp_Store;
color[1].clearValue = { 0.5, 0.5, 0.5, 0 };
color[1].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 = 2;
pass_desc.colorAttachments = color;
pass_desc.depthStencilAttachment = &depth;
pass_desc.label = svFromCStr("ifcviewer-wgpu.box_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) 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);
WGPUTexelCopyTextureInfo src = {};
src.texture = pick_color_texture_;
src.aspect = WGPUTextureAspect_All;
src.origin.x = std::uint32_t(x);
src.origin.y = std::uint32_t(y);
WGPUTexelCopyBufferInfo dst = {};
dst.buffer = box_pick_staging_buffer_;
dst.layout.bytesPerRow = std::uint32_t(padded_bpr);
dst.layout.rowsPerImage = std::uint32_t(h);
WGPUExtent3D extent = {};
extent.width = std::uint32_t(w);
extent.height = std::uint32_t(h);
extent.depthOrArrayLayers = 1;
wgpuCommandEncoderCopyTextureToBuffer(enc, &src, &dst, &extent);
WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(enc, nullptr);
wgpuQueueSubmit(queue_, 1, &cmd);
wgpuCommandBufferRelease(cmd);
wgpuCommandEncoderRelease(enc);
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(box_pick_staging_buffer_, WGPUMapMode_Read,
0, needed_bytes, mcb);
while (!req.done) waitTickInstance(instance_);
if (!req.ok) return out;
const std::uint8_t* mapped = static_cast<const std::uint8_t*>(
wgpuBufferGetConstMappedRange(box_pick_staging_buffer_, 0, needed_bytes));
std::unordered_set<std::uint32_t> seen;
if (mapped) {
for (int row = 0; row < h; ++row) {
const std::uint32_t* line = reinterpret_cast<const std::uint32_t*>(
mapped + std::size_t(row) * std::size_t(padded_bpr));
for (int col = 0; col < w; ++col) {
const std::uint32_t id = line[col];
if (id != 0) seen.insert(id);
}
}
}
wgpuBufferUnmap(box_pick_staging_buffer_);
out.reserve(seen.size());
for (std::uint32_t id : seen) out.push_back(id);
return out;
}
bool ViewportCore::pickSurfaceAt(int x_pixels, int y_pixels,
std::uint32_t& object_id_out,
Eigen::Vector3f& world_pos_out,
Eigen::Vector3f& world_normal_out,
float* aabb_radius_out) {
if (aabb_radius_out) *aabb_radius_out = 0.0f;
Eigen::Vector3f picked_normal(0, 0, 1);
const std::uint32_t id = pickObjectAt(x_pixels, y_pixels, &picked_normal);
if (id == 0) return false;
// WebGPU forbids partial copies of Depth32Float, so ray-cast against
// each instance carrying the picked object_id rather than reading
// back per-pixel depth.
Eigen::Matrix4f view, proj;
buildViewProj(view, proj);
Eigen::Matrix4f inv_vp;
if (!tryInvert4f(proj * view, inv_vp)) return false;
const float ndc_x = (2.0f * float(x_pixels) / float(configured_w_)) - 1.0f;
const float ndc_y = 1.0f - (2.0f * float(y_pixels) / float(configured_h_));
const Eigen::Vector4f far_clip(ndc_x, ndc_y, 1.0f, 1.0f);
const Eigen::Vector4f far_w = inv_vp * far_clip;
if (std::abs(far_w.w()) < 1e-6f) return false;
const Eigen::Vector3f far_world = far_w.head<3>() / far_w.w();
const Eigen::Vector3f eye = orbitEye(camera_target_, camera_distance_,
camera_yaw_deg_, camera_pitch_deg_);
Eigen::Vector3f ray_dir = far_world - eye;
if (ray_dir.squaredNorm() < 1e-8f) return false;
ray_dir.normalize();
float best_t = std::numeric_limits<float>::infinity();
Eigen::Vector3f best_point;
Eigen::Vector3f best_normal;
float best_radius = 0.0f;
bool found = false;
for (const auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
for (const auto& inst : m.instances) {
if (inst.object_id != id) continue;
float t = 0.0f;
Eigen::Vector3f n;
if (!rayAABBHit(eye, ray_dir,
inst.world_aabb_min, inst.world_aabb_max,
t, n)) continue;
if (t < best_t) {
best_t = t;
best_point = eye + ray_dir * t;
best_normal = n;
const float dx = inst.world_aabb_max[0] - inst.world_aabb_min[0];
const float dy = inst.world_aabb_max[1] - inst.world_aabb_min[1];
const float dz = inst.world_aabb_max[2] - inst.world_aabb_min[2];
best_radius = 0.5f * std::sqrt(dx * dx + dy * dy + dz * dz);
found = true;
}
}
}
if (!found) return false;
if (aabb_radius_out) *aabb_radius_out = best_radius;
world_pos_out = best_point;
// Prefer per-fragment normal from the pick MRT; fall back to AABB face.
world_normal_out = (picked_normal.squaredNorm() > 1e-3f)
? picked_normal : best_normal;
object_id_out = id;
return true;
}
bool ViewportCore::pickMeshLocalAt(int x, int y, MeshLocalPick& out) {
std::uint32_t obj_id = 0;
Eigen::Vector3f world_pos, world_normal;
if (!pickSurfaceAt(x, y, obj_id, world_pos, world_normal)) return false;
// Use the OUTER mid (the live map key) rather than inst.model_id —
// InstanceCpu::model_id is stale across sessions.
for (const auto& [mid, m] : models_gpu_) {
auto it = m.object_id_to_instance.find(obj_id);
if (it == m.object_id_to_instance.end()) continue;
const InstanceCpu& inst = m.instances[it->second];
const Eigen::Matrix4f T = Eigen::Map<const Eigen::Matrix4f>(inst.transform);
Eigen::Matrix4f Ti;
if (!tryInvert4f(T, Ti)) return false;
if (inst.mesh_id >= m.meshes.size()) return false;
// Refine the AABB-face hit by re-projecting and Möller-Trumbore-
// ing against the picked instance's CPU mesh shadow.
Eigen::Vector3f refined_world_pos = world_pos;
Eigen::Vector3f refined_world_normal = world_normal;
if (inst.mesh_id < m.mesh_triangles_cache.size()) {
const auto& tris = m.mesh_triangles_cache[inst.mesh_id];
if (!tris.indices.empty() && configured_w_ > 0 && configured_h_ > 0) {
Eigen::Matrix4f view, proj;
buildViewProj(view, proj);
Eigen::Matrix4f inv_vp;
if (tryInvert4f(proj * view, inv_vp)) {
const float ndc_x = (2.0f * float(x) / float(configured_w_)) - 1.0f;
const float ndc_y = 1.0f - (2.0f * float(y) / float(configured_h_));
const Eigen::Vector4f far_clip(ndc_x, ndc_y, 1.0f, 1.0f);
const Eigen::Vector4f far_w = inv_vp * far_clip;
if (std::abs(far_w.w()) >= 1e-6f) {
const Eigen::Vector3f far_world = far_w.head<3>() / far_w.w();
const Eigen::Vector3f eye = orbitEye(
camera_target_, camera_distance_,
camera_yaw_deg_, camera_pitch_deg_);
Eigen::Vector3f ray_dir = far_world - eye;
if (ray_dir.squaredNorm() > 1e-8f) {
ray_dir.normalize();
const Eigen::Vector4f ro_l4 = Ti * Eigen::Vector4f(eye.x(), eye.y(), eye.z(), 1.0f);
const Eigen::Vector4f rd_l4 = Ti * Eigen::Vector4f(ray_dir.x(), ray_dir.y(), ray_dir.z(), 0.0f);
const float ro_l[3] = { ro_l4.x(), ro_l4.y(), ro_l4.z() };
const float rd_l[3] = { rd_l4.x(), rd_l4.y(), rd_l4.z() };
const float ldn = std::sqrt(
rd_l[0]*rd_l[0] + rd_l[1]*rd_l[1] + rd_l[2]*rd_l[2]);
if (ldn > 0.0f) {
float best_t_world = std::numeric_limits<float>::infinity();
std::uint32_t best_tri = UINT32_MAX;
const std::size_t n_tris = tris.indices.size() / 3;
for (std::size_t t = 0; t < n_tris; ++t) {
const std::uint32_t ia = tris.indices[3 * t + 0];
const std::uint32_t ib = tris.indices[3 * t + 1];
const std::uint32_t ic = tris.indices[3 * t + 2];
if (3 * ia + 2 >= tris.positions.size()
|| 3 * ib + 2 >= tris.positions.size()
|| 3 * ic + 2 >= tris.positions.size()) continue;
const float* va = &tris.positions[3 * ia];
const float* vb = &tris.positions[3 * ib];
const float* vc = &tris.positions[3 * ic];
float t_local = 0.0f;
if (!rayTriMT(ro_l, rd_l, va, vb, vc, t_local)) continue;
const float t_world = t_local / ldn;
if (t_world < best_t_world) {
best_t_world = t_world;
best_tri = std::uint32_t(t);
}
}
if (best_tri != UINT32_MAX) {
refined_world_pos = eye + ray_dir * best_t_world;
const std::uint32_t ia = tris.indices[3 * best_tri + 0];
const std::uint32_t ib = tris.indices[3 * best_tri + 1];
const std::uint32_t ic = tris.indices[3 * best_tri + 2];
const float* va = &tris.positions[3 * ia];
const float* vb = &tris.positions[3 * ib];
const float* vc = &tris.positions[3 * ic];
const float bax = vb[0]-va[0], bay = vb[1]-va[1], baz = vb[2]-va[2];
const float cax = vc[0]-va[0], cay = vc[1]-va[1], caz = vc[2]-va[2];
float n_local[3] = {
bay*caz - baz*cay,
baz*cax - bax*caz,
bax*cay - bay*cax,
};
const float nl = std::sqrt(
n_local[0]*n_local[0]
+ n_local[1]*n_local[1]
+ n_local[2]*n_local[2]);
if (nl > 0.0f) {
n_local[0] /= nl;
n_local[1] /= nl;
n_local[2] /= nl;
}
const float* M = inst.transform;
Eigen::Vector3f n_world(
M[0]*n_local[0] + M[4]*n_local[1] + M[8] *n_local[2],
M[1]*n_local[0] + M[5]*n_local[1] + M[9] *n_local[2],
M[2]*n_local[0] + M[6]*n_local[1] + M[10]*n_local[2]);
if (n_world.squaredNorm() > 1e-12f) {
n_world.normalize();
refined_world_normal = n_world;
}
}
}
}
}
}
}
}
const Eigen::Vector4f mp = Ti * Eigen::Vector4f(refined_world_pos.x(),
refined_world_pos.y(),
refined_world_pos.z(), 1.0f);
out.object_id = obj_id;
out.model_id = mid;
out.mesh_id = inst.mesh_id;
out.mesh_local[0] = mp.x();
out.mesh_local[1] = mp.y();
out.mesh_local[2] = mp.z();
out.world_pos [0] = refined_world_pos.x();
out.world_pos [1] = refined_world_pos.y();
out.world_pos [2] = refined_world_pos.z();
out.world_normal[0] = refined_world_normal.x();
out.world_normal[1] = refined_world_normal.y();
out.world_normal[2] = refined_world_normal.z();
std::memcpy(out.composed_transform, inst.transform,
sizeof(out.composed_transform));
return true;
}
return false;
}
bool ViewportCore::raycast(const float origin[3], const float dir[3],
RaycastHit& out) const {
float inv_d[3] = {
std::abs(dir[0]) > 1e-20f ? 1.0f / dir[0] : std::numeric_limits<float>::infinity(),
std::abs(dir[1]) > 1e-20f ? 1.0f / dir[1] : std::numeric_limits<float>::infinity(),
std::abs(dir[2]) > 1e-20f ? 1.0f / dir[2] : std::numeric_limits<float>::infinity(),
};
float best_t = std::numeric_limits<float>::infinity();
std::uint32_t best_oid = 0;
float best_normal[3] = {0, 0, 0};
for (const auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
for (std::uint32_t inst_idx = 0; inst_idx < std::uint32_t(m.instances.size()); ++inst_idx) {
const InstanceCpu& inst = m.instances[inst_idx];
if (!rayAabbSlab(origin, inv_d, inst.world_aabb_min, inst.world_aabb_max)) {
continue;
}
if (inst.mesh_id >= m.mesh_triangles_cache.size()) continue;
const auto& tris = m.mesh_triangles_cache[inst.mesh_id];
if (tris.indices.empty()) continue;
const Eigen::Matrix4f T = Eigen::Map<const Eigen::Matrix4f>(inst.transform);
Eigen::Matrix4f Ti;
if (!tryInvert4f(T, Ti)) continue;
const Eigen::Vector4f ro_local4 = Ti * Eigen::Vector4f(origin[0], origin[1], origin[2], 1.0f);
const Eigen::Vector4f rd_local4 = Ti * Eigen::Vector4f(dir[0], dir[1], dir[2], 0.0f);
const float ro_local[3] = { ro_local4.x(), ro_local4.y(), ro_local4.z() };
const float rd_local[3] = { rd_local4.x(), rd_local4.y(), rd_local4.z() };
const std::size_t n_tris = tris.indices.size() / 3;
for (std::size_t t = 0; t < n_tris; ++t) {
const std::uint32_t ia = tris.indices[3 * t + 0];
const std::uint32_t ib = tris.indices[3 * t + 1];
const std::uint32_t ic = tris.indices[3 * t + 2];
if (3 * ia + 2 >= tris.positions.size()
|| 3 * ib + 2 >= tris.positions.size()
|| 3 * ic + 2 >= tris.positions.size()) continue;
const float* va = &tris.positions[3 * ia];
const float* vb = &tris.positions[3 * ib];
const float* vc = &tris.positions[3 * ic];
float t_local = 0.0f;
if (!rayTriMT(ro_local, rd_local, va, vb, vc, t_local)) continue;
const float ldn = std::sqrt(rd_local[0]*rd_local[0]
+ rd_local[1]*rd_local[1]
+ rd_local[2]*rd_local[2]);
if (ldn <= 0.0f) continue;
const float t_world = t_local / ldn;
if (t_world >= best_t) continue;
best_t = t_world;
best_oid = inst.object_id;
const float bax = vb[0]-va[0], bay = vb[1]-va[1], baz = vb[2]-va[2];
const float cax = vc[0]-va[0], cay = vc[1]-va[1], caz = vc[2]-va[2];
float n_local[3] = {
bay * caz - baz * cay,
baz * cax - bax * caz,
bax * cay - bay * cax,
};
const float nl = std::sqrt(n_local[0]*n_local[0]
+ n_local[1]*n_local[1]
+ n_local[2]*n_local[2]);
if (nl > 0.0f) { n_local[0] /= nl; n_local[1] /= nl; n_local[2] /= nl; }
const float* M = inst.transform;
best_normal[0] = M[0]*n_local[0] + M[4]*n_local[1] + M[8]*n_local[2];
best_normal[1] = M[1]*n_local[0] + M[5]*n_local[1] + M[9]*n_local[2];
best_normal[2] = M[2]*n_local[0] + M[6]*n_local[1] + M[10]*n_local[2];
const float wnl = std::sqrt(best_normal[0]*best_normal[0]
+ best_normal[1]*best_normal[1]
+ best_normal[2]*best_normal[2]);
if (wnl > 0.0f) {
best_normal[0] /= wnl;
best_normal[1] /= wnl;
best_normal[2] /= wnl;
}
}
}
}
if (!std::isfinite(best_t)) return false;
out.object_id = best_oid;
out.distance = best_t;
out.world_pos[0] = origin[0] + best_t * dir[0];
out.world_pos[1] = origin[1] + best_t * dir[1];
out.world_pos[2] = origin[2] + best_t * dir[2];
out.world_normal[0] = best_normal[0];
out.world_normal[1] = best_normal[1];
out.world_normal[2] = best_normal[2];
return true;
}
// ===========================================================================
// Surface configuration (#84-u): configureSurface
// ===========================================================================
void ViewportCore::configureSurface(int width_px, int height_px) {
WGPUSurfaceConfiguration cfg = {};
cfg.device = device_;
cfg.format = surface_format_;
// CopySrc lets the screenshot path copy the surface texture back to
// host memory. Trivial cost on all known backends.
cfg.usage = WGPUTextureUsage_RenderAttachment | WGPUTextureUsage_CopySrc;
cfg.width = std::uint32_t(width_px);
cfg.height = std::uint32_t(height_px);
// Present mode preference order: Mailbox → Immediate → FifoRelaxed
// → Fifo. Override with WGPU_PRESENT_MODE=...; otherwise pick the
// first mode the surface actually advertises (asking for one that
// isn't listed panics wgpu-native from Rust).
//
// Why Immediate sits above FifoRelaxed: Mailbox is the right answer
// for an interactive viewer (vsync-aligned, no tearing, 1-frame
// queue) but a meaningful subset of Linux Vulkan stacks (some
// compositors, some driver/WSI combinations) silently don't expose
// it. On those stacks Fifo's 2-3 frame queue doubles input-to-
// photon latency; Immediate can tear but keeps latency at one
// render-body. FifoRelaxed is the middle option.
WGPUPresentMode preferred[4] = {
WGPUPresentMode_Mailbox,
WGPUPresentMode_Immediate,
WGPUPresentMode_FifoRelaxed,
WGPUPresentMode_Fifo,
};
const char* pm_name = "mailbox";
if (const char* s = std::getenv("WGPU_PRESENT_MODE")) {
WGPUPresentMode override_pm = WGPUPresentMode_Fifo;
bool known = true;
if (std::strcmp(s, "fifo") == 0) { override_pm = WGPUPresentMode_Fifo; pm_name = "fifo"; }
else if (std::strcmp(s, "fifo_relaxed") == 0) { override_pm = WGPUPresentMode_FifoRelaxed; pm_name = "fifo_relaxed"; }
else if (std::strcmp(s, "mailbox") == 0) { override_pm = WGPUPresentMode_Mailbox; pm_name = "mailbox"; }
else if (std::strcmp(s, "immediate") == 0) { override_pm = WGPUPresentMode_Immediate; pm_name = "immediate"; }
else {
known = false;
Log::warn()
<< "[wgpu] unknown WGPU_PRESENT_MODE=" << s
<< " (expected fifo|fifo_relaxed|mailbox|immediate);"
" falling back to preference order";
}
if (known) {
preferred[0] = override_pm;
preferred[1] = WGPUPresentMode_Fifo; // Fifo is the only guaranteed-supported mode
preferred[2] = preferred[3] = WGPUPresentMode_Fifo;
}
}
WGPUSurfaceCapabilities caps = {};
wgpuSurfaceGetCapabilities(surface_, adapter_, &caps);
auto supports = [&](WGPUPresentMode mode) {
for (std::size_t i = 0; i < caps.presentModeCount; ++i) {
if (caps.presentModes[i] == mode) return true;
}
return false;
};
// Diagnostic: dump the full advertised set on first configure. If
// Mailbox is missing here the driver doesn't expose it (drives the
// input-latency story); if Mailbox is listed but we still pick
// Fifo, the preference order has a bug.
if (!surface_configured_) {
std::string advertised;
for (std::size_t i = 0; i < caps.presentModeCount; ++i) {
const char* name = "?";
switch (caps.presentModes[i]) {
case WGPUPresentMode_Fifo: name = "fifo"; break;
case WGPUPresentMode_FifoRelaxed: name = "fifo_relaxed"; break;
case WGPUPresentMode_Mailbox: name = "mailbox"; break;
case WGPUPresentMode_Immediate: name = "immediate"; break;
default: break;
}
if (i > 0) advertised += ", ";
advertised += name;
}
Log::info() << "[wgpu] surface advertises present modes: " << advertised;
}
WGPUPresentMode pm = WGPUPresentMode_Fifo; // spec-guaranteed fallback
for (WGPUPresentMode candidate : preferred) {
if (supports(candidate)) { pm = candidate; break; }
}
switch (pm) {
case WGPUPresentMode_Mailbox: pm_name = "mailbox"; break;
case WGPUPresentMode_FifoRelaxed: pm_name = "fifo_relaxed"; break;
case WGPUPresentMode_Immediate: pm_name = "immediate"; break;
case WGPUPresentMode_Fifo: pm_name = "fifo"; break;
default: break;
}
wgpuSurfaceCapabilitiesFreeMembers(caps);
cfg.presentMode = pm;
if (!surface_configured_) {
const char* note = "";
switch (pm) {
case WGPUPresentMode_Mailbox:
note = " (vsync-aligned, no queue lag -- default)"; break;
case WGPUPresentMode_Fifo:
note = " (strict vsync, may queue 2-3 frames)"; break;
case WGPUPresentMode_FifoRelaxed:
note = " (adaptive vsync -- sync if in budget, tear if not)"; break;
case WGPUPresentMode_Immediate:
note = " (vsync OFF -- framerate uncapped, may tear)"; break;
default: break;
}
Log::info() << "[wgpu] present mode = " << pm_name << note;
}
cfg.alphaMode = WGPUCompositeAlphaMode_Auto;
wgpuSurfaceConfigure(surface_, &cfg);
configured_w_ = width_px;
configured_h_ = height_px;
surface_configured_ = true;
ensureDepthTexture(width_px, height_px);
ensureMsaaColorTexture(width_px, height_px);
ensureHizTextures(width_px, height_px);
// depth_view_ was just replaced; force the HiZ + edge bind groups
// to rebuild against the new view on next encode.
if (hiz_bind_group_) {
wgpuBindGroupRelease(hiz_bind_group_);
hiz_bind_group_ = nullptr;
}
if (edge_bind_group_) {
wgpuBindGroupRelease(edge_bind_group_);
edge_bind_group_ = nullptr;
}
}
// ===========================================================================
// Small cross-chunk + capture helpers (#84-v)
// ===========================================================================
void ViewportCore::buildModelBindGroup(ModelGpuData& m) {
if (!m.mesh_storage || !m.instance_storage) {
// Empty model — no chunks, no bind groups; the draw loop will skip.
return;
}
for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) {
buildChunkBindGroup(m, ci);
}
}
void ViewportCore::captureNextFrameToPng(const std::string& path,
bool quit_after) {
pending_screenshot_path_ = path;
pending_screenshot_quit_ = quit_after;
host_->requestFrame();
}
// ===========================================================================
// Screenshot capture encode + finalize (#84-w)
// ===========================================================================
WGPUBuffer ViewportCore::encodeScreenshotCapture(
WGPUCommandEncoder enc, WGPUTexture surface_texture,
std::uint32_t& padded_bpr_out) {
constexpr std::uint64_t kWgpuBytesPerRowAlign = 256;
const std::uint32_t row_bytes_unpadded = std::uint32_t(configured_w_) * 4u;
const std::uint32_t padded_bpr = std::uint32_t(
(row_bytes_unpadded + kWgpuBytesPerRowAlign - 1)
/ kWgpuBytesPerRowAlign * kWgpuBytesPerRowAlign);
const std::uint64_t total_bytes =
std::uint64_t(padded_bpr) * std::uint64_t(configured_h_);
WGPUBufferDescriptor bdesc = {};
bdesc.size = total_bytes;
bdesc.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead;
bdesc.label = svFromCStr("ifcviewer-wgpu.capture");
WGPUBuffer capture_buffer = wgpuDeviceCreateBuffer(device_, &bdesc);
WGPUTexelCopyTextureInfo src = {};
src.texture = surface_texture;
src.aspect = WGPUTextureAspect_All;
WGPUTexelCopyBufferInfo dst = {};
dst.buffer = capture_buffer;
dst.layout.bytesPerRow = padded_bpr;
dst.layout.rowsPerImage = std::uint32_t(configured_h_);
WGPUExtent3D extent = {};
extent.width = std::uint32_t(configured_w_);
extent.height = std::uint32_t(configured_h_);
extent.depthOrArrayLayers = 1;
wgpuCommandEncoderCopyTextureToBuffer(enc, &src, &dst, &extent);
padded_bpr_out = padded_bpr;
return capture_buffer;
}
void ViewportCore::finalizeScreenshotCapture(WGPUBuffer capture_buffer,
std::uint32_t padded_bpr) {
if (!capture_buffer) {
pending_screenshot_path_.clear();
pending_screenshot_quit_ = false;
return;
}
struct MapReq { bool done = false; bool ok = false; };
MapReq req;
WGPUBufferMapCallbackInfo mcb = {};
mcb.mode = kAsyncCbMode;
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) Log::warn() << "wgpu MapAsync failed for screenshot";
};
mcb.userdata1 = &req;
const std::uint64_t total_bytes =
std::uint64_t(padded_bpr) * std::uint64_t(configured_h_);
wgpuBufferMapAsync(capture_buffer, WGPUMapMode_Read,
0, std::size_t(total_bytes), mcb);
while (!req.done) waitTickInstance(instance_);
if (req.ok) {
const std::uint8_t* mapped = static_cast<const std::uint8_t*>(
wgpuBufferGetConstMappedRange(capture_buffer, 0, std::size_t(total_bytes)));
// Assemble tightly-packed RGBA8. Surface is BGRA8 on most
// backends (BGRA8Unorm = format 28). If a future surface is
// already RGBA, skip the per-pixel swap.
const bool is_bgra =
surface_format_ == WGPUTextureFormat_BGRA8Unorm ||
surface_format_ == WGPUTextureFormat_BGRA8UnormSrgb;
const std::uint32_t w = std::uint32_t(configured_w_);
const std::uint32_t h = std::uint32_t(configured_h_);
std::vector<std::uint8_t> rgba(std::size_t(w) * std::size_t(h) * 4);
for (std::uint32_t y = 0; y < h; ++y) {
const std::uint8_t* src_row = mapped + std::size_t(y) * padded_bpr;
std::uint8_t* dst_row = rgba.data() + std::size_t(y) * std::size_t(w) * 4;
if (is_bgra) {
for (std::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, std::size_t(w) * 4);
}
}
wgpuBufferUnmap(capture_buffer);
host_->saveScreenshotRgba8(pending_screenshot_path_,
rgba.data(), int(w), int(h));
}
wgpuBufferRelease(capture_buffer);
const bool quit_after = pending_screenshot_quit_;
pending_screenshot_path_.clear();
pending_screenshot_quit_ = false;
if (quit_after) host_->quit();
}
// ===========================================================================
// Render loop (#84-x): render()
// ===========================================================================
#include <future>
#include <iomanip>
#include <sstream>
#include "Stopwatch.h"
namespace {
// degrees → radians. Inline-only, used inside render() for the
// focal-length derivation.
constexpr float degreesToRadians(float deg) {
return deg * float(M_PI) / 180.0f;
}
// Format a float with N decimals into the running Log line. Used to
// match GL's per-frame stats output where fixed-precision matters for
// side-by-side diffs.
std::string fmtF(double v, int prec) {
std::ostringstream ss;
ss << std::fixed << std::setprecision(prec) << v;
return ss.str();
}
// sRGB → linear, used for the background-clear value so the wgpu
// surface (which is sRGB on most backends) renders the same colour as
// the GL viewport's GL_FRAMEBUFFER_SRGB-enabled pass.
inline float srgbToLinear(float c) {
return (c <= 0.04045f) ? (c * (1.0f / 12.92f))
: std::pow((c + 0.055f) * (1.0f / 1.055f), 2.4f);
}
} // namespace
void ViewportCore::render() {
if (!device_ || !queue_ || !surface_) return;
Stopwatch frame_timer;
frame_timer.start();
// Drain any HiZ async readbacks completed since last frame.
if (hiz_enabled_) drainHizReadbacks();
uploadSelectionFlagsIfDirty();
WGPUSurfaceTexture surf_tex = {};
wgpuSurfaceGetCurrentTexture(surface_, &surf_tex);
switch (surf_tex.status) {
case WGPUSurfaceGetCurrentTextureStatus_SuccessOptimal:
case WGPUSurfaceGetCurrentTextureStatus_SuccessSuboptimal:
break;
case WGPUSurfaceGetCurrentTextureStatus_Timeout:
case WGPUSurfaceGetCurrentTextureStatus_Outdated:
case WGPUSurfaceGetCurrentTextureStatus_Lost: {
int w = 0, h = 0;
host_->framebufferSize(w, h);
if (w > 0 && h > 0) configureSurface(w, h);
host_->requestFrame();
return;
}
default:
Log::warn() << "GetCurrentTexture status " << int(surf_tex.status);
return;
}
WGPUTextureView view = wgpuTextureCreateView(surf_tex.texture, nullptr);
updateFrameUniforms();
// ---- Per-frame cull --------------------------------------------------
last_visible_objects_ = 0;
last_visible_triangles_ = 0;
last_sub_draws_ = 0;
hiz_reject_count_ = 0;
Stopwatch cull_timer;
cull_timer.start();
Eigen::Matrix4f vp_this_frame;
{
const Eigen::Vector3f target(camera_target_[0], camera_target_[1], camera_target_[2]);
const Eigen::Vector3f eye = orbitEye(camera_target_, camera_distance_,
camera_yaw_deg_, camera_pitch_deg_);
Eigen::Matrix4f v, p;
buildViewProj(v, p);
const Eigen::Matrix4f vp = p * v;
vp_this_frame = vp;
float planes[6][4];
extractFrustumPlanes(vp.data(), planes);
// LOD focal: projected_px = world_radius * focal_px / view_z.
const Eigen::Vector3f fwd_q = (target - eye).normalized();
const Eigen::Vector3f world_up = (std::abs(camera_pitch_deg_) >= 89.0f)
? Eigen::Vector3f(0.0f, 1.0f, 0.0f)
: Eigen::Vector3f(0.0f, 0.0f, 1.0f);
const Eigen::Vector3f right_q = fwd_q.cross(world_up).normalized();
const Eigen::Vector3f up_q = right_q.cross(fwd_q).normalized();
const float eye_a[3] = { eye.x(), eye.y(), eye.z() };
const float fwd_a[3] = { fwd_q.x(), fwd_q.y(), fwd_q.z() };
const float right_a[3] = { right_q.x(), right_q.y(), right_q.z() };
const float up_a[3] = { up_q.x(), up_q.y(), up_q.z() };
const float focal_px = (configured_h_ > 0)
? (0.5f * float(configured_h_)
/ std::tan(degreesToRadians(camera_fov_y_deg_) * 0.5f))
: 0.0f;
// Motion detection.
const bool camera_moved = has_prev_camera_
&& (camera_target_[0] != prev_camera_target_[0]
|| camera_target_[1] != prev_camera_target_[1]
|| camera_target_[2] != prev_camera_target_[2]
|| camera_distance_ != prev_camera_distance_
|| camera_yaw_deg_ != prev_camera_yaw_deg_
|| camera_pitch_deg_ != prev_camera_pitch_deg_);
const bool use_motion_threshold =
camera_moved && motion_min_pixel_radius_ > min_pixel_radius_;
const float effective_min_px =
use_motion_threshold ? motion_min_pixel_radius_ : min_pixel_radius_;
last_cull_was_motion_ = use_motion_threshold;
// HiZ stale-VP gate. Strict by default; WGPU_HIZ_MOTION=1 trusts
// the stale pyramid across motion.
static const bool hiz_trust_stale = []{
const char* e = std::getenv("WGPU_HIZ_MOTION");
return e && e[0] == '1';
}();
const bool hiz_vp_matches = hiz_valid_
&& (hiz_trust_stale || hiz_vp_ == vp_this_frame);
const bool hiz_for_this_frame = hiz_enabled_ && hiz_vp_matches;
// WGPU_HIZ_TRACE per-frame trace budget arm.
static const bool hiz_trace_on = []{
const char* e = std::getenv("WGPU_HIZ_TRACE");
return e && e[0] == '1';
}();
if (hiz_trace_on && hiz_for_this_frame) {
constexpr int kHizTracePerFrame = 12;
hiz_trace_budget_.store(kHizTracePerFrame, std::memory_order_relaxed);
Log::info()
<< "[hiz trace] frame: vp_match="
<< (hiz_vp_ == vp_this_frame ? "exact" : "loose")
<< " pyramid_mip0=" << hiz_mip_w_[0] << "x" << hiz_mip_h_[0]
<< " budget=" << kHizTracePerFrame;
} else if (hiz_trace_on) {
hiz_trace_budget_.store(0, std::memory_order_relaxed);
}
HizOccludedFn hiz_occluded;
if (hiz_for_this_frame) {
hiz_occluded = [this](const float mn[3], const float mx[3]) {
return aabbOccludedByHiz(mn, mx);
};
}
// Force sequential on Emscripten: std::async(std::launch::async)
// without -pthread throws std::system_error from inside libstdc++,
// and we link without exceptions so that becomes abort(). Until
// COOP/COEP + -pthread wires the worker pool in #88, web stays
// on the serial path.
#if defined(__EMSCRIPTEN__)
if (false) {
#else
if (cull_threads_enabled_) {
#endif
std::vector<std::pair<std::uint32_t, std::future<std::uint32_t>>> futures;
futures.reserve(models_gpu_.size());
for (auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
auto& m_ref = m;
futures.emplace_back(mid, std::async(std::launch::async,
[this, &m_ref, &planes, &eye_a, &fwd_a, &right_a, &up_a,
focal_px, effective_min_px, &hiz_occluded]() {
return cullModelCpuCompute(
m_ref, planes, eye_a, fwd_a, right_a, up_a,
focal_px,
effective_min_px, lod1_pixel_threshold_,
hiz_occluded);
}));
}
for (auto& [mid, fut] : futures) {
hiz_reject_count_ += fut.get();
}
} else {
for (auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
hiz_reject_count_ += cullModelCpuCompute(
m, planes, eye_a, fwd_a, right_a, up_a, focal_px,
effective_min_px, lod1_pixel_threshold_,
hiz_occluded);
}
}
const double cull_compute_ms = double(cull_timer.nsecsElapsed()) / 1e6;
Stopwatch upload_timer;
upload_timer.start();
for (auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
cullModelCpuUpload(m);
for (const auto& c : m.chunks) {
last_visible_objects_ += c.total_visible_draws;
last_visible_triangles_ += c.total_visible_vertices / 3u;
if (c.total_visible_draws > 0) last_sub_draws_ += 1;
}
}
last_cull_compute_ms_ = cull_compute_ms;
last_cull_upload_ms_ = double(upload_timer.nsecsElapsed()) / 1e6;
}
const double cull_only_ms = double(cull_timer.nsecsElapsed()) / 1e6;
last_cull_ms_ = cull_only_ms;
Stopwatch stream_timer;
stream_timer.start();
driveStreamingLoads();
const double stream_ms = double(stream_timer.nsecsElapsed()) / 1e6;
last_stream_ms_ = stream_ms;
// Snapshot camera state for next frame's motion detection.
prev_camera_target_[0] = camera_target_[0];
prev_camera_target_[1] = camera_target_[1];
prev_camera_target_[2] = camera_target_[2];
prev_camera_distance_ = camera_distance_;
prev_camera_yaw_deg_ = camera_yaw_deg_;
prev_camera_pitch_deg_ = camera_pitch_deg_;
has_prev_camera_ = true;
if (bench_total_ > 0 && bench_count_ >= bench_warmup_) {
bench_cull_ms_total_ += cull_only_ms;
bench_stream_ms_total_ += stream_ms;
}
WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(device_, nullptr);
WGPURenderPassColorAttachment color = {};
color.view = msaa_color_view_;
color.resolveTarget = view;
color.loadOp = WGPULoadOp_Clear;
color.storeOp = WGPUStoreOp_Store;
color.clearValue = {
srgbToLinear(background_color_[0]),
srgbToLinear(background_color_[1]),
srgbToLinear(background_color_[2]),
1.0,
};
color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
WGPURenderPassDepthStencilAttachment depth = {};
depth.view = depth_view_;
depth.depthLoadOp = WGPULoadOp_Clear;
depth.depthStoreOp = WGPUStoreOp_Store;
depth.depthClearValue = 1.0f;
depth.stencilLoadOp = WGPULoadOp_Undefined;
depth.stencilStoreOp = WGPUStoreOp_Undefined;
depth.depthReadOnly = false;
depth.stencilReadOnly = true;
WGPURenderPassDescriptor pass_desc = {};
pass_desc.colorAttachmentCount = 1;
pass_desc.colorAttachments = &color;
pass_desc.depthStencilAttachment = depth_view_ ? &depth : nullptr;
WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
// Two-pass main render: opaque first, then transparent.
if (main_pipeline_ && main_pipeline_transparent_
&& frame_bind_group_ && !models_gpu_.empty()) {
wgpuRenderPassEncoderSetPipeline(pass, main_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr);
for (const auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
for (const auto& c : m.chunks) {
if (!c.bind_group || c.opaque_visible_vertices == 0) continue;
wgpuRenderPassEncoderSetBindGroup(pass, 1, c.bind_group, 0, nullptr);
wgpuRenderPassEncoderDraw(pass,
c.opaque_visible_vertices, 1, 0, 0);
}
}
wgpuRenderPassEncoderSetPipeline(pass, main_pipeline_transparent_);
for (const auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
for (const auto& c : m.chunks) {
if (!c.bind_group) continue;
const std::uint32_t transparent_verts =
c.total_visible_vertices - c.opaque_visible_vertices;
if (transparent_verts == 0) continue;
wgpuRenderPassEncoderSetBindGroup(pass, 1, c.bind_group, 0, nullptr);
wgpuRenderPassEncoderDraw(pass,
transparent_verts, 1,
c.opaque_visible_vertices, 0);
}
}
}
// Build the per-frame OverlayFrame snapshot.
int viewport_w_px = 0, viewport_h_px = 0;
host_->framebufferSize(viewport_w_px, viewport_h_px);
const int dpr_int = std::max(1, int(host_->dpr()));
OverlayFrame overlay_frame;
overlay_frame.view_proj = vp_this_frame;
overlay_frame.camera_target = Eigen::Vector3f(camera_target_[0],
camera_target_[1],
camera_target_[2]);
overlay_frame.camera_distance = camera_distance_;
overlay_frame.camera_yaw_deg = camera_yaw_deg_;
overlay_frame.camera_pitch_deg = camera_pitch_deg_;
overlay_frame.camera_fov_y_deg = camera_fov_y_deg_;
overlay_frame.viewport_w_px = viewport_w_px;
overlay_frame.viewport_h_px = viewport_h_px;
overlay_frame.device_pixel_ratio = dpr_int;
// In-pass overlays (section gizmos, highlight triangles, pivot,
// overlay lines/points). QtViewportHost forwards to overlays_.X().
host_->encodeOverlaysInMainPass(pass, overlay_frame);
wgpuRenderPassEncoderEnd(pass);
wgpuRenderPassEncoderRelease(pass);
// Edge silhouette + HiZ resolve, before the surface-targeted overlays.
if (edges_enabled_) encodeEdgePass(enc, view);
int hiz_submitted_slot = -1;
if (hiz_enabled_) hiz_submitted_slot = encodeHizResolve(enc);
// Post-main overlays (corner axis, marquee, labels) on the resolved
// surface. QtViewportHost forwards to overlays_.X().
host_->encodeOverlaysPostMain(enc, view, overlay_frame);
// Optional capture: encode copy on the same command buffer.
WGPUBuffer capture_buffer = nullptr;
std::uint32_t capture_padded_bpr = 0;
const bool want_capture = !pending_screenshot_path_.empty();
if (want_capture) {
capture_buffer = encodeScreenshotCapture(
enc, surf_tex.texture, capture_padded_bpr);
}
WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(enc, nullptr);
wgpuQueueSubmit(queue_, 1, &cmd);
wgpuCommandBufferRelease(cmd);
wgpuCommandEncoderRelease(enc);
wgpuTextureViewRelease(view);
if (want_capture) {
finalizeScreenshotCapture(capture_buffer, capture_padded_bpr);
}
// ---- FrameStats emission ---------------------------------------------
{
const double this_frame_ms =
double(frame_timer.nsecsElapsed()) / 1e6;
frame_time_ms_sum_ -= frame_time_ms_window_[frame_time_ms_head_];
frame_time_ms_window_[frame_time_ms_head_] = this_frame_ms;
frame_time_ms_sum_ += this_frame_ms;
frame_time_ms_head_ = (frame_time_ms_head_ + 1) % FRAME_TIME_WINDOW;
if (frame_time_ms_count_ < FRAME_TIME_WINDOW) ++frame_time_ms_count_;
const double avg_ms = frame_time_ms_count_ > 0
? frame_time_ms_sum_ / double(frame_time_ms_count_)
: 0.0;
std::uint32_t total_obj = 0, total_tri = 0, total_meshes = 0;
for (const auto& [mid, mm] : models_gpu_) {
total_obj += std::uint32_t(mm.instances.size());
total_tri += mm.index_count / 3;
total_meshes += std::uint32_t(mm.meshes.size());
}
FrameStats stats;
stats.fps = avg_ms > 0.0 ? float(1000.0 / avg_ms) : 0.0f;
stats.frame_time_ms = float(avg_ms);
stats.total_objects = total_obj;
stats.visible_objects = last_visible_objects_;
stats.total_triangles = total_tri;
stats.visible_triangles = last_visible_triangles_;
stats.unique_meshes = total_meshes;
std::uint32_t draw_calls = 0;
for (const auto& [mid, mm] : models_gpu_) {
if (mm.hidden) continue;
for (const auto& c : mm.chunks) {
if (c.is_resident && c.total_visible_draws > 0) ++draw_calls;
}
}
stats.gl_draw_calls = draw_calls;
stats.indirect_sub_draws = last_sub_draws_;
host_->onFrameStats(stats);
}
#if !defined(__EMSCRIPTEN__)
// On Dawn-web there is no explicit surface present — the browser
// composites the canvas at the end of the current requestAnimationFrame
// tick, so calling wgpuSurfacePresent aborts the wasm with
// "wgpuSurfacePresent is unsupported (use requestAnimationFrame via
// html5.h instead)". WebViewportHost::requestFrame keeps the render
// loop ticking off RAF.
wgpuSurfacePresent(surface_);
#endif
wgpuTextureRelease(surf_tex.texture);
if (last_cull_was_motion_) host_->requestFrame();
// HiZ async readback handoff.
if (hiz_enabled_ && hiz_submitted_slot >= 0) {
Stopwatch hiz_timer;
if (bench_total_ > 0) hiz_timer.start();
startHizMap(hiz_submitted_slot, vp_this_frame);
if (bench_total_ > 0 && bench_count_ >= bench_warmup_) {
bench_hiz_readback_ms_total_ += double(hiz_timer.nsecsElapsed()) / 1e6;
}
}
// ---- Interactive heartbeat log --------------------------------------
if (bench_total_ == 0) {
++interactive_frame_count_;
const float ms = float(frame_timer.nsecsElapsed()) / 1e6f;
std::uint64_t total_vbo = 0, total_ebo = 0, total_ssbo = 0;
std::uint32_t total_instances = 0;
std::size_t chunks_total = 0, chunks_resident = 0;
std::size_t chunks_frustum_vis = 0, chunks_missing = 0;
for (const auto& [mid, mo] : models_gpu_) {
total_vbo += mo.vram_bytes_vbo;
total_ebo += mo.vram_bytes_ebo;
total_ssbo += mo.vram_bytes_ssbo;
total_instances += mo.instance_count;
for (const auto& c : mo.chunks) {
++chunks_total;
if (c.is_resident) ++chunks_resident;
if (c.frustum_visible_count > 0) {
++chunks_frustum_vis;
if (!c.is_resident) ++chunks_missing;
}
}
}
const double mb = 1.0 / (1024.0 * 1024.0);
Log::info()
<< "[frame] " << fmtF(ms > 0 ? 1000.0f / ms : 0.0f, 1) << " fps"
<< " " << fmtF(ms, 2) << " ms"
<< " obj " << last_visible_objects_ << "/" << total_instances
<< " tri " << last_visible_triangles_
<< " sub_draws " << last_sub_draws_
<< " hiz_rej " << hiz_reject_count_
<< " cull " << fmtF(last_cull_ms_, 2) << "ms"
<< " stream " << fmtF(last_stream_ms_, 2) << "ms"
<< " chunks " << chunks_resident << "/" << chunks_frustum_vis
<< "/" << chunks_total << " (missing " << chunks_missing << ")"
<< " vram " << fmtF(double(total_vbo + total_ebo + total_ssbo) * mb, 1) << "MB"
<< " models " << models_gpu_.size()
<< " lod1 " << lod1_dbg_count_ << "/" << (lod1_dbg_count_ + lod0_dbg_eligible_count_)
<< " (saved " << lod1_dbg_tris_saved_ << " tris, "
<< lod0_dbg_no_lod1_count_ << " no-lod1)";
lod1_dbg_count_ = 0;
lod0_dbg_eligible_count_ = 0;
lod0_dbg_no_lod1_count_ = 0;
lod1_dbg_tris_saved_ = 0;
}
// ---- Benchmark integration + auto-quit -------------------------------
if (bench_total_ > 0) {
if (!bench_warm_done_) {
constexpr int CONVERGE_FRAMES_REQUIRED = 5;
constexpr int MAX_WARM_FRAMES = 600;
const bool worker_idle =
streaming_thread_.inFlightApprox() == 0;
if (streaming_loads_this_frame_ > 0 || !worker_idle) {
bench_warm_streak_ = 0;
} else {
++bench_warm_streak_;
}
++bench_warm_frames_total_;
const bool converged = bench_warm_streak_ >= CONVERGE_FRAMES_REQUIRED;
const bool timed_out = bench_warm_frames_total_ >= MAX_WARM_FRAMES;
if (converged) {
Log::info() << "[bench warm] converged after "
<< bench_warm_frames_total_ << " frames";
bench_warm_done_ = true;
} else if (timed_out) {
Log::warn() << "[bench warm] timed out after "
<< bench_warm_frames_total_
<< " frames without convergence; starting bench anyway";
bench_warm_done_ = true;
} else {
host_->requestFrame();
return;
}
}
const float ms = float(frame_timer.nsecsElapsed()) / 1e6f;
if (bench_count_ >= bench_warmup_) {
bench_frame_ms_.push_back(ms);
}
if ((bench_count_ % 50) == 0) {
std::uint64_t total_vbo = 0, total_ebo = 0, total_ssbo = 0;
std::uint32_t total_instances = 0;
for (const auto& [mid, mo] : models_gpu_) {
total_vbo += mo.vram_bytes_vbo;
total_ebo += mo.vram_bytes_ebo;
total_ssbo += mo.vram_bytes_ssbo;
total_instances += mo.instance_count;
}
const double mb = 1.0 / (1024.0 * 1024.0);
const double avg_n = double(std::max(1, bench_count_ - bench_warmup_ + 1));
const double cull_ms = bench_cull_ms_total_ / avg_n;
const double stream_ms2 = bench_stream_ms_total_ / avg_n;
Log::info()
<< "[frame] " << fmtF(ms > 0 ? 1000.0f / ms : 0.0f, 1) << " fps"
<< " " << fmtF(ms, 2) << " ms"
<< " obj " << last_visible_objects_ << "/" << total_instances
<< " tri " << last_visible_triangles_
<< " sub_draws " << last_sub_draws_
<< " hiz_rej " << hiz_reject_count_
<< " cull[wall " << fmtF(cull_ms, 2)
<< " | compute " << fmtF(last_cull_compute_ms_, 2)
<< " upload " << fmtF(last_cull_upload_ms_, 2) << "]ms"
<< " stream[" << fmtF(stream_ms2, 2) << "]ms"
<< " vram " << fmtF(double(total_vbo + total_ebo + total_ssbo) * mb, 1) << "MB"
<< " models " << models_gpu_.size()
<< " lod1 " << lod1_dbg_count_ << "/" << (lod1_dbg_count_ + lod0_dbg_eligible_count_)
<< " (saved " << lod1_dbg_tris_saved_ << " tris, "
<< lod0_dbg_no_lod1_count_ << " no-lod1)";
lod1_dbg_count_ = 0;
lod0_dbg_eligible_count_ = 0;
lod0_dbg_no_lod1_count_ = 0;
lod1_dbg_tris_saved_ = 0;
}
camera_yaw_deg_ = bench_yaw_start_
+ bench_yaw_speed_ * float(bench_count_ + 1);
++bench_count_;
if (bench_count_ >= bench_warmup_ + bench_total_) {
std::vector<float> times = bench_frame_ms_;
std::sort(times.begin(), times.end());
auto pct = [&times](double p) -> float {
if (times.empty()) return 0.0f;
const std::size_t idx = std::min(times.size() - 1,
std::size_t(p * double(times.size() - 1)));
return times[idx];
};
float sum = 0.0f;
for (float f : times) sum += f;
const float avg = times.empty() ? 0.0f : sum / float(times.size());
const float median = pct(0.5);
const float p1 = pct(0.01);
const float p99 = pct(0.99);
const float total_sweep = bench_yaw_speed_ * float(bench_total_);
Log::info() << "\n=== BENCHMARK (" << bench_total_ << " frames, orbit "
<< total_sweep << "deg at " << bench_yaw_speed_ << "deg/frame) ===";
Log::info() << " avg: " << avg << " ms (" << (avg > 0 ? 1000.0f/avg : 0.0f) << " fps)";
Log::info() << " median: " << median << " ms (" << (median > 0 ? 1000.0f/median : 0.0f) << " fps)";
Log::info() << " p1: " << p1 << " ms p99: " << p99 << " ms";
Log::info() << " last frame: obj " << last_visible_objects_
<< " tri " << last_visible_triangles_
<< " sub_draws " << last_sub_draws_
<< " hiz_rej " << hiz_reject_count_;
const double n = double(std::max(1, bench_total_));
Log::info() << " per-frame avg ms: cull=" << bench_cull_ms_total_ / n
<< " stream=" << bench_stream_ms_total_ / n
<< " hiz_readback=" << bench_hiz_readback_ms_total_ / n
<< " hiz=" << (hiz_enabled_ ? "on" : "off");
Log::info() << "=== END BENCHMARK ===\n";
bench_total_ = 0;
host_->quit();
} else {
host_->requestFrame();
}
}
}
// ===========================================================================
// Section planes (#84-y)
// ===========================================================================
bool ViewportCore::addSectionPlaneAtSurface(const Eigen::Vector3f& point,
const Eigen::Vector3f& normal,
float visual_radius) {
if (int(section_planes_.size()) >= kMaxSectionPlanes) {
Log::warn() << "[wgpu section] cap reached (" << kMaxSectionPlanes
<< " planes)";
return false;
}
Eigen::Vector3f n = normal;
if (n.squaredNorm() < 1e-8f) return false;
n.normalize();
// Auto-flip the normal so the camera-facing half gets cut away.
const Eigen::Vector3f eye = orbitEye(camera_target_, camera_distance_,
camera_yaw_deg_, camera_pitch_deg_);
const Eigen::Vector3f eye_dir = eye - point;
if (n.dot(eye_dir) < 0.0f) n = -n;
SectionPlane p;
p.n = n;
p.origin = point;
p.d = -n.dot(point);
p.visual_radius = (visual_radius > 0.0f) ? visual_radius : 1.0f;
section_planes_.push_back(p);
Log::info()
<< "[wgpu section] added plane #" << section_planes_.size() - 1
<< " origin=(" << point.x() << "," << point.y() << "," << point.z() << ")"
<< " normal=(" << n.x() << "," << n.y() << "," << n.z() << ")";
host_->requestFrame();
return true;
}
void ViewportCore::removeSectionPlane(int index) {
if (index < 0 || index >= int(section_planes_.size())) return;
section_planes_.erase(section_planes_.begin() + index);
Log::info() << "[wgpu section] removed plane " << index;
host_->requestFrame();
}
void ViewportCore::clearSectionPlanes() {
if (section_planes_.empty()) return;
section_planes_.clear();
Log::info() << "[wgpu section] cleared all planes";
host_->requestFrame();
}