wgpu backend: per-instance contribution culling in cullModelCpu

Quick win before the proper HiZ stage. Adds a min_pixel_radius
threshold (defaults 2.0 to match AppSettings::minPixelRadius() in GL):
instances whose projected bounding-sphere radius falls below it are
dropped from the per-mesh buckets entirely.

The projected_px math (radius_world * focal_px / view_z) is now
computed once per instance and shared with the LOD pick that uses the
same number. Saves one square root per instance per frame on dense
scenes vs the previous code path that only computed it inside the LOD
branch.

Expected impact on real BIM benchmarks: visible-objects count drops by
roughly 10×, matching the GL backend's number. Without this fix, wgpu
was drawing every frustum-surviving sub-pixel instance — most of the
work and most of the geometry the GL backend wasn't even submitting.

Motion-mode threshold bump (10.0 in GL during camera drag) lands
later when mouse-driven motion tracking is wired up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-27 15:04:42 +10:00
parent 7893135790
commit 6ce2b564c1
2 changed files with 43 additions and 16 deletions
+25 -11
View File
@@ -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<float>::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;
+18 -5
View File
@@ -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.