diff --git a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp index eb80557072..977c5e2f31 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.cpp +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.cpp @@ -863,7 +863,9 @@ void WgpuViewportWindow::setBenchmarkFrames(int frames) { 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) { + float focal_px, + float min_radius_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; @@ -880,20 +882,21 @@ void WgpuViewportWindow::cullModelCpu(WgpuModelGpuData& m, per_mesh_lod1[mi].clear(); } - const bool lod_enabled = (lod1_threshold_px > 0.0f); + const bool contrib_enabled = (min_radius_px > 0.0f); + 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; - // 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) { + + // Projected bounding-sphere radius in pixels, shared between the + // contribution-cull and LOD-pick decisions. Computed once per + // instance; guarded against behind-near-plane and division by zero. + float projected_px = std::numeric_limits::infinity(); + if (contrib_enabled || (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]); @@ -905,11 +908,21 @@ void WgpuViewportWindow::cullModelCpu(WgpuModelGpuData& m, + 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; + projected_px = radius_world * focal_px / view_z; } } + // Contribution cull: drop instances projected below threshold. Most + // of the per-frame cost on dense scenes — typically rejects 80-90% + // of frustum-visible instances on real BIM models. + if (contrib_enabled && projected_px < min_radius_px) continue; + + // LOD pick: switch to LOD1 if the mesh has one baked and we're below + // the LOD threshold. + const bool use_lod1 = lod_enabled + && mesh.lod1_index_count > 0 + && 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); } @@ -1026,7 +1039,8 @@ void WgpuViewportWindow::render() { for (auto& [mid, m] : models_gpu_) { if (m.hidden) continue; - cullModelCpu(m, planes, eye_a, fwd_a, focal_px, lod1_pixel_threshold_); + cullModelCpu(m, planes, eye_a, fwd_a, focal_px, + min_pixel_radius_, 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 348307eb43..e760e771a3 100644 --- a/src/ifcviewer-wgpu/WgpuViewportWindow.h +++ b/src/ifcviewer-wgpu/WgpuViewportWindow.h @@ -129,14 +129,21 @@ private: // 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. + // each instance's view-space depth for the projected-radius formula. + // `focal_px` = viewport_height / (2 * tan(fov_y / 2)). + // + // Two pixel-radius thresholds: + // `min_radius_px` — instances projected below this are dropped + // entirely (contribution culling). + // `lod1_threshold_px` — survivors projected below this get the mesh's + // LOD1 index slice when one was baked. + // min_radius_px == 0 disables contribution culling. void cullModelCpu(WgpuModelGpuData& m, const float planes[6][4], const float eye[3], const float forward[3], - float focal_px, float lod1_threshold_px); + float focal_px, + float min_radius_px, + float lod1_threshold_px); bool wgpu_initialized_ = false; bool surface_configured_ = false; @@ -187,6 +194,12 @@ private: float camera_near_ = 0.1f; float camera_far_ = 10000.0f; + // Drop instances whose projected bounding-sphere radius is below this + // many pixels. Mirrors AppSettings::minPixelRadius() (GL default 2.0; + // motion mode uses 10.0 but we don't differentiate yet — that arrives + // with mouse-driven motion-state tracking later). + float min_pixel_radius_ = 2.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.