/******************************************************************************** * * * 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 . * * * ********************************************************************************/ #include "ViewportCore.h" #include #include #include #include #include #include // 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 #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::infinity(); mx[i] = -std::numeric_limits::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::infinity(); float ymin = std::numeric_limits::infinity(); float xmax = -std::numeric_limits::infinity(); float ymax = -std::numeric_limits::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 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::infinity(); c.aabb_max[0] = c.aabb_max[1] = c.aabb_max[2] = -std::numeric_limits::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; const Eigen::Matrix4d P = Eigen::Map(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::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::infinity(); mx[i] = -std::numeric_limits::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& 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> ViewportCore::volumesPerObject( const std::vector& object_ids) const { std::vector> 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, object_id: u32, color_override: u32, mesh_id: u32, _pad1: u32, }; struct MeshQuant { aabb_min: vec4, aabb_max: vec4, }; struct FrameUniforms { view_proj: mat4x4, light_dir: vec4, fill_dir: vec4, sky_color: vec4, ground_color: vec4, clip_count: i32, // Three scalar i32 pads instead of vec3: 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, 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) -> 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 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 sel_flags: array; @group(1) @binding(0) var vertices: array; @group(1) @binding(1) var meshes: array; @group(1) @binding(2) var instances: array; @group(1) @binding(3) var indices: array; @group(1) @binding(4) var visible_draws: array; @group(1) @binding(5) var prefix_sums: array; @group(1) @binding(6) var u_model: PerModel; struct VsOut { @builtin(position) clip_pos: vec4, @location(0) normal: vec3, @location(1) color: vec4, @location(2) world_pos: vec3, @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) -> vec3 { var n = vec3(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((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(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(px, py, pz)); let nx = f32(extractI8(w1, 2u)) / 127.0; let ny = f32(extractI8(w1, 3u)) / 127.0; let n_local = octDecode(vec2(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(pos_local, 1.0); let rot = mat3x3(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(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(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) -> vec3 { let lo = s / 12.92; let hi = pow((s + 0.055) / 1.055, vec3(2.4)); return select(hi, lo, s <= vec3(0.04045)); } @fragment fn fs_main(in: VsOut) -> @location(0) vec4 { 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(0.2, 0.6, 1.0), 0.45); } if ((flags & 2u) != 0u) { color = mix(color, vec3(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(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, @location(0) @interpolate(flat) object_id: u32, @location(1) world_pos: vec3, @location(2) normal: vec3, }; // 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, }; @vertex fn vs_pick(@builtin(vertex_index) vid: u32) -> VsOutPick { var out: VsOutPick; if (vid >= u_model.total_vertex_count) { out.clip_pos = vec4(0.0, 0.0, 0.0, 0.0); out.object_id = 0u; out.world_pos = vec3(0.0, 0.0, 0.0); out.normal = vec3(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(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(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(nx, ny)); let rot = mat3x3(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(normalize(in.normal) * 0.5 + vec3(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(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(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 + probeAndCreatePool + shutdown // =========================================================================== namespace { // 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::probeAndCreatePool() { // Discover the largest single buffer the runtime will grant. Each // attempt sits inside OOM + Validation error scopes so a failed // allocation doesn't surface as a noisy uncaptured-error warning. WGPULimits device_limits = {}; wgpuDeviceGetLimits(device_, &device_limits); constexpr uint64_t MIN_POOL_CAPACITY = 64ull * 1024 * 1024; constexpr uint64_t MAX_PROBE_START = 4ull * 1024 * 1024 * 1024; uint64_t try_size = std::min(device_limits.maxBufferSize, MAX_PROBE_START); if (try_size < MIN_POOL_CAPACITY) try_size = MIN_POOL_CAPACITY; const WGPUBufferUsage pool_usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst; while (try_size >= MIN_POOL_CAPACITY) { wgpuDevicePushErrorScope(device_, WGPUErrorFilter_Validation); wgpuDevicePushErrorScope(device_, WGPUErrorFilter_OutOfMemory); WGPUBufferDescriptor desc = {}; desc.usage = pool_usage; desc.size = try_size; desc.label.data = "ifcviewer-wgpu.pool_probe"; desc.label.length = std::strlen("ifcviewer-wgpu.pool_probe"); WGPUBuffer probe_buf = wgpuDeviceCreateBuffer(device_, &desc); struct PopResult { bool done = false; bool error = false; }; auto pop = [&](PopResult& pr) { WGPUPopErrorScopeCallbackInfo pcb = {}; pcb.mode = WGPUCallbackMode_AllowProcessEvents; pcb.callback = [](WGPUPopErrorScopeStatus, WGPUErrorType type, WGPUStringView, void* ud1, void* /*ud2*/) { auto* p = static_cast(ud1); p->done = true; p->error = (type != WGPUErrorType_NoError); }; pcb.userdata1 = ≺ wgpuDevicePopErrorScope(device_, pcb); while (!pr.done) wgpuInstanceProcessEvents(instance_); }; PopResult oom_pop, validation_pop; pop(oom_pop); pop(validation_pop); if (probe_buf) wgpuBufferRelease(probe_buf); if (probe_buf && !oom_pop.error && !validation_pop.error) { pool_.configure(instance_, device_, pool_usage, try_size, "ifcviewer-wgpu.pool"); Log::info() << "wgpu: pool per-sub-buffer capacity = " << (try_size / (1024 * 1024)) << " MB" << " (device maxBufferSize = " << (device_limits.maxBufferSize / (1024 * 1024)) << " MB); pool will grow on demand"; return true; } try_size /= 2; } Log::warn() << "wgpu: pool probe found no allocatable size >= " << (MIN_POOL_CAPACITY / (1024 * 1024)) << " MB"; return false; } bool ViewportCore::initWgpu(bool web_limits) { #if !defined(__EMSCRIPTEN__) wgpuSetLogCallback(onWgpuLog, nullptr); wgpuSetLogLevel(WGPULogLevel_Warn); #endif instance_ = wgpuCreateInstance(nullptr); if (!instance_) { Log::warn() << "wgpuCreateInstance returned null"; return false; } // 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 = WGPUCallbackMode_AllowProcessEvents; acb.callback = [](WGPURequestAdapterStatus status, WGPUAdapter adapter, WGPUStringView message, void* ud1, void* /*ud2*/) { auto* r = static_cast(ud1); r->done = true; if (status == WGPURequestAdapterStatus_Success) { r->adapter = adapter; r->ok = true; } else { Log::warn() << "RequestAdapter failed: " << svToStr(message); } }; acb.userdata1 = &areq; wgpuInstanceRequestAdapter(instance_, &adapter_opts, acb); while (!areq.done) wgpuInstanceProcessEvents(instance_); if (!areq.ok) return false; adapter_ = areq.adapter; // ---- 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 = {}; 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)"; } dev_desc.uncapturedErrorCallbackInfo.callback = onUncapturedError; WGPURequestDeviceCallbackInfo dcb = {}; dcb.mode = WGPUCallbackMode_AllowProcessEvents; dcb.callback = [](WGPURequestDeviceStatus status, WGPUDevice device, WGPUStringView message, void* ud1, void* /*ud2*/) { auto* r = static_cast(ud1); r->done = true; if (status == WGPURequestDeviceStatus_Success) { r->device = device; r->ok = true; } else { Log::warn() << "RequestDevice failed: " << svToStr(message); } }; dcb.userdata1 = &dreq; wgpuAdapterRequestDevice(adapter_, &dev_desc, dcb); while (!dreq.done) wgpuInstanceProcessEvents(instance_); if (!dreq.ok) return false; device_ = dreq.device; queue_ = wgpuDeviceGetQueue(device_); if (!probeAndCreatePool()) { Log::warn() << "wgpu: streaming pool probe failed; cannot start"; return false; } streaming_thread_.start(); 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; } 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 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& vbytes, const std::vector& 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 vbytes; std::vector 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 #include 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::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 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 when a screenshot is pending: the deferred- // capture wait would let the window manager re-layout the // window while we wait, capturing at the wrong size. With sync // loads the chunk appears in the same frame we enqueue. if (!pending_screenshot_path_.empty()) { 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 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(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(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 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::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 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>& 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()); 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 mesh_cx(n_meshes, 0.0f), mesh_cy(n_meshes, 0.0f), mesh_cz(n_meshes, 0.0f); std::vector 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> chunk_mesh_ids; std::vector instance_to_chunk; instance_to_chunk.assign(metadata.meta.instances.size(), 0); { std::vector sorted_mesh_ids = ChunkPlanner::sortMeshIdsByMorton( n_meshes, mesh_cx, mesh_cy, mesh_cz, mesh_inst_count); std::vector 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 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 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> 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(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(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(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 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 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::infinity(), std::numeric_limits::infinity(), std::numeric_limits::infinity() }; float bmax[3] = { -std::numeric_limits::infinity(), -std::numeric_limits::infinity(), -std::numeric_limits::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); } 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 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 raw_vertices = std::move(metadata.meta.vertices); std::vector 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 vbytes(c.vertex_byte_size); std::vector 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 u_hiz: HizUniforms; struct VsOut { @builtin(position) clip_pos: vec4, }; @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(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(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( 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 = WGPUCallbackMode_AllowProcessEvents; mcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*msg*/, void* ud1, void* /*ud2*/) { auto* c = static_cast(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( 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::infinity(); float ny_lo = std::numeric_limits::infinity(); float nx_hi = -std::numeric_limits::infinity(); float ny_hi = -std::numeric_limits::infinity(); float min_z = std::numeric_limits::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 { let x = f32((vid << 1u) & 2u) * 2.0 - 1.0; let y = f32(vid & 2u) * 2.0 - 1.0; return vec4(x, y, 0.0, 1.0); } @fragment fn fs_main(@builtin(position) frag: vec4) -> @location(0) vec4 { let p = vec2(i32(frag.x), i32(frag.y)); let dim = vec2(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(p.x, max(p.y - 1, 0)), 0)); let s = linearise(textureLoad(src_depth, vec2(p.x, min(p.y + 1, dim.y - 1)), 0)); let e = linearise(textureLoad(src_depth, vec2(min(p.x + 1, dim.x - 1), p.y), 0)); let w = linearise(textureLoad(src_depth, vec2(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(vec3(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 = ⌖ 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 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::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::infinity(); float t_max = std::numeric_limits::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 = WGPUCallbackMode_AllowProcessEvents; mcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*msg*/, void* ud1, void* /*ud2*/) { auto* r = static_cast(ud1); r->done = true; r->ok = (status == WGPUMapAsyncStatus_Success); }; mcb.userdata1 = &req; wgpuBufferMapAsync(pick_staging_buffer_, WGPUMapMode_Read, 0, 256, mcb); while (!req.done) wgpuInstanceProcessEvents(instance_); if (!req.ok) return 0; const std::uint32_t* mapped = static_cast( 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) wgpuInstanceProcessEvents(instance_); if (nreq.ok) { const std::uint16_t* halves = static_cast( 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 ViewportCore::picksInRect(int x, int y, int w, int h) { std::vector 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(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 = WGPUCallbackMode_AllowProcessEvents; mcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*msg*/, void* ud1, void* /*ud2*/) { auto* r = static_cast(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) wgpuInstanceProcessEvents(instance_); if (!req.ok) return out; const std::uint8_t* mapped = static_cast( wgpuBufferGetConstMappedRange(box_pick_staging_buffer_, 0, needed_bytes)); std::unordered_set seen; if (mapped) { for (int row = 0; row < h; ++row) { const std::uint32_t* line = reinterpret_cast( 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::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(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::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::infinity(), std::abs(dir[1]) > 1e-20f ? 1.0f / dir[1] : std::numeric_limits::infinity(), std::abs(dir[2]) > 1e-20f ? 1.0f / dir[2] : std::numeric_limits::infinity(), }; float best_t = std::numeric_limits::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(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; }