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