From 244a1452559072b0ce35011e138fdfc4af1e3f67 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 27 May 2026 14:47:23 +1000 Subject: [PATCH] wgpu backend: per-instance LOD0/LOD1 pick in the cull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 8 of the wgpu port. cullModelCpu now buckets each visible instance by (mesh_id, lod) instead of (mesh_id), and emits one MeshDraw record per non-empty bucket. LOD pick projects the instance's world-space bounding sphere to pixels via projected_px = world_radius * focal_px / view_z where focal_px = viewport_h / (2 * tan(fov_y/2)) and view_z is the forward·(center-eye) depth. When projected_px < lod1_pixel_threshold_ AND the mesh has a baked LOD1 slice (MeshInfo.lod1_index_count > 0), the instance draws the LOD1 index range instead of LOD0; baseVertex and the vertex storage are shared between LODs. mesh_draws can now grow to up to 2 × meshes.size() per frame (LOD0 + LOD1 slice per mesh). The visible_buffer layout per mesh becomes [LOD0 instances | LOD1 instances] contiguous, with each MeshDraw referencing its own firstInstance offset. lod1_pixel_threshold_ defaults to 30 (mirrors AppSettings:: lod1PixelThreshold() in the GL backend); set to 0 to disable LOD1 entirely (always LOD0). AppSettings port lands in a later commit. Verified: basic.ifc (3 tiny instances, no LOD1 baked by meshoptimizer since each mesh is well under the 500-tri threshold) renders pixel- identical to pre-stage-8 — proves the all-LOD0 path is preserved. Real LOD switching needs a sidecar where buildLods produced LOD1 slices. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-wgpu/WgpuViewportWindow.cpp | 101 ++++++++++++++++++---- src/ifcviewer-wgpu/WgpuViewportWindow.h | 23 ++++- 2 files changed, 102 insertions(+), 22 deletions(-) diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp index fad03ae8c1..cb425e9b05 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp @@ -848,38 +848,91 @@ void WgpuViewportWindow::setBenchmarkFrames(int frames) { if (isExposed() && bench_total_ > 0) requestUpdate(); } -void WgpuViewportWindow::cullModelCpu(WgpuModelGpuData& m, const float planes[6][4]) { +void WgpuViewportWindow::cullModelCpu(WgpuModelGpuData& m, + const float planes[6][4], + const float eye[3], const float forward[3], + float focal_px, float lod1_threshold_px) { if (m.instances.empty() || m.meshes.empty() || !m.visible_buffer) { for (auto& d : m.mesh_draws) d.instance_count = 0; return; } - // Per-mesh visible-instance buckets. Allocated once per cull from scratch - // vectors held on the model (no fresh heap on the per-frame path). - static thread_local std::vector> per_mesh_visible; - if (per_mesh_visible.size() < m.meshes.size()) per_mesh_visible.resize(m.meshes.size()); - for (size_t mi = 0; mi < m.meshes.size(); ++mi) per_mesh_visible[mi].clear(); + // Per-mesh visible-instance buckets split by LOD. Per-frame scratch held + // thread-locally so the cull path doesn't allocate. + static thread_local std::vector> per_mesh_lod0; + static thread_local std::vector> per_mesh_lod1; + if (per_mesh_lod0.size() < m.meshes.size()) per_mesh_lod0.resize(m.meshes.size()); + if (per_mesh_lod1.size() < m.meshes.size()) per_mesh_lod1.resize(m.meshes.size()); + for (size_t mi = 0; mi < m.meshes.size(); ++mi) { + per_mesh_lod0[mi].clear(); + per_mesh_lod1[mi].clear(); + } + + const bool lod_enabled = (lod1_threshold_px > 0.0f); for (uint32_t i = 0; i < uint32_t(m.instances.size()); ++i) { const auto& inst = m.instances[i]; if (inst.mesh_id >= m.meshes.size()) continue; if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) continue; - per_mesh_visible[inst.mesh_id].push_back(i); + + // LOD pick: project the AABB's bounding sphere to pixels. Use LOD1 + // when (a) the mesh has a baked LOD1 index slice and (b) the + // projected radius is below the threshold. View-space depth from + // forward · (center - eye); guard against behind-near-plane. + const MeshInfo& mesh = m.meshes[inst.mesh_id]; + bool use_lod1 = false; + if (lod_enabled && mesh.lod1_index_count > 0) { + 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) { + const float projected_px = radius_world * focal_px / view_z; + use_lod1 = projected_px < lod1_threshold_px; + } + } + + if (use_lod1) per_mesh_lod1[inst.mesh_id].push_back(i); + else per_mesh_lod0[inst.mesh_id].push_back(i); } - // Flatten into m.visible_flat_scratch and populate per-mesh draws. + // Flatten into m.visible_flat_scratch as [mesh0 lod0 | mesh0 lod1 | mesh1 + // lod0 | mesh1 lod1 | …] and emit one MeshDraw per non-empty bucket. m.visible_flat_scratch.clear(); - m.mesh_draws.assign(m.meshes.size(), WgpuModelGpuData::MeshDraw{}); + m.mesh_draws.clear(); + m.mesh_draws.reserve(m.meshes.size() * 2); for (uint32_t mi = 0; mi < m.meshes.size(); ++mi) { const MeshInfo& mesh = m.meshes[mi]; - WgpuModelGpuData::MeshDraw& d = m.mesh_draws[mi]; - d.first_instance = uint32_t(m.visible_flat_scratch.size()); - d.instance_count = uint32_t(per_mesh_visible[mi].size()); - d.first_index = mesh.ebo_byte_offset / uint32_t(sizeof(uint32_t)); - d.base_vertex = int32_t(mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES); - d.index_count = mesh.index_count; - for (uint32_t inst_idx : per_mesh_visible[mi]) { - m.visible_flat_scratch.push_back(inst_idx); + + if (!per_mesh_lod0[mi].empty()) { + WgpuModelGpuData::MeshDraw d; + d.first_instance = uint32_t(m.visible_flat_scratch.size()); + d.instance_count = uint32_t(per_mesh_lod0[mi].size()); + d.first_index = mesh.ebo_byte_offset / uint32_t(sizeof(uint32_t)); + d.base_vertex = int32_t(mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES); + d.index_count = mesh.index_count; + for (uint32_t inst_idx : per_mesh_lod0[mi]) { + m.visible_flat_scratch.push_back(inst_idx); + } + m.mesh_draws.push_back(d); + } + if (!per_mesh_lod1[mi].empty()) { + WgpuModelGpuData::MeshDraw d; + d.first_instance = uint32_t(m.visible_flat_scratch.size()); + d.instance_count = uint32_t(per_mesh_lod1[mi].size()); + d.first_index = mesh.lod1_ebo_byte_offset / uint32_t(sizeof(uint32_t)); + d.base_vertex = int32_t(mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES); + d.index_count = mesh.lod1_index_count; + for (uint32_t inst_idx : per_mesh_lod1[mi]) { + m.visible_flat_scratch.push_back(inst_idx); + } + m.mesh_draws.push_back(d); } } @@ -947,9 +1000,21 @@ void WgpuViewportWindow::render() { const QMatrix4x4 vp = z * p * v; float planes[6][4]; extractFrustumPlanes(vp.constData(), planes); + + // LOD pick inputs: world-space eye, unit forward, vertical focal in + // pixels. focal_px maps view-space depth to projected radius: + // projected_px = world_radius * focal_px / view_z. + const QVector3D fwd_q = (target - eye).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 focal_px = (configured_h_ > 0) + ? (0.5f * float(configured_h_) + / std::tan(qDegreesToRadians(camera_fov_y_deg_) * 0.5f)) + : 0.0f; + for (auto& [mid, m] : models_gpu_) { if (m.hidden) continue; - cullModelCpu(m, planes); + cullModelCpu(m, planes, eye_a, fwd_a, focal_px, lod1_pixel_threshold_); for (const auto& d : m.mesh_draws) { if (d.instance_count == 0 || d.index_count == 0) continue; last_visible_objects_ += d.instance_count; diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.h b/src/ifcviewer-wgpu/WgpuViewportWindow.h index c69e9fac33..fc0e31cb56 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.h +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h @@ -116,10 +116,20 @@ private: bool computeSceneAabb(float mn[3], float mx[3]) const; // Cull `m`'s instances against the supplied frustum planes (world-space, - // ax+by+cz+d >= 0 means inside), bucket survivors by mesh_id, and write - // the flat visible-index list into m.visible_buffer via wgpuQueueWriteBuffer. - // After return, m.mesh_draws is the per-mesh draw schedule for the frame. - void cullModelCpu(WgpuModelGpuData& m, const float planes[6][4]); + // ax+by+cz+d >= 0 means inside), bucket survivors by (mesh_id, lod), and + // write the flat visible-index list into m.visible_buffer via + // wgpuQueueWriteBuffer. After return, m.mesh_draws is the per-mesh, + // per-LOD draw schedule for the frame. + // + // `eye` and `forward` (forward = unit (target - eye)) are used to compute + // each instance's view-space depth for the LOD-pick projected-radius + // formula. `focal_px` = viewport_height / (2 * tan(fov_y / 2)). Instances + // whose projected bounding-sphere radius is below `lod1_threshold_px` + // get the mesh's LOD1 index slice when one was baked. + void cullModelCpu(WgpuModelGpuData& m, + const float planes[6][4], + const float eye[3], const float forward[3], + float focal_px, float lod1_threshold_px); bool wgpu_initialized_ = false; bool surface_configured_ = false; @@ -170,6 +180,11 @@ private: float camera_near_ = 0.1f; float camera_far_ = 10000.0f; + // Switch to LOD1 when an instance's projected bounding-sphere radius + // drops below this many pixels. 0 disables (always LOD0). Defaults + // mirror AppSettings::lod1PixelThreshold() in the GL backend. + float lod1_pixel_threshold_ = 30.0f; + // Per-model state, keyed by viewport-assigned model_id. std::unordered_map models_gpu_; uint32_t next_model_id_ = 1;