Phase 3A: screen-space contribution culling

Reject frustum-visible objects whose bounding sphere projects below a
pixel-radius threshold.  Applied at both BVH-node level (whole subtrees
pruned) and per-instance level; short-circuits when the camera is
inside the AABB so nothing-you're-standing-next-to is ever lost.
Pick pass passes threshold 0 so sub-pixel objects stay clickable.

Threshold defaults to 2 px (radius), overridable via IFC_MIN_PX env
var.  Measured on the 128 M-tri test scene (GTX 1650):

  0 px (off):   6.7 fps, 128 M tris
  2 px:        20.2 fps,  40 M tris (31%)
  4 px:        30.3 fps,  15 M tris (12%)

The metric is sphere-based (cheap: one sqrt per test) rather than
AABB-corner projection; loses a little precision on very elongated
bounds but costs ~5x less per test and the BVH-node pre-cull means
the long-tail-of-small-things case is already handled by subtree
pruning before we touch individual instances.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-04-13 09:51:09 +10:00
parent d3c21d7a81
commit 90366f8236
2 changed files with 69 additions and 5 deletions
+60 -4
View File
@@ -824,7 +824,8 @@ uint32_t ViewportWindow::pickObjectAt(int x, int y) {
return pixel;
}
void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6][4]) {
void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6][4],
float focal_px, float min_pixel_radius) {
// Per-mesh scratch, split by winding: fwd = non-reflected (CCW in screen
// space), rev = reflected (CW in screen space). Splitting lets the draw
// pass toggle glFrontFace once between two MDI calls so GL_CULL_FACE does
@@ -836,9 +837,44 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6]
visible_by_mesh_rev_[i].clear();
}
// Bounding-sphere contribution test: approximate an AABB by its enclosing
// sphere (centre = midpoint, radius = half-diagonal). Project radius to
// pixels as r_px = focal_px * r / distance (perspective). Reject if
// smaller than the threshold. Returns true when the node/instance
// should be kept.
//
// If the camera is inside the AABB the sphere-radius test would reject
// by distance going to zero / negative — we handle that by skipping the
// test whenever the camera lies within an inflated AABB. Cheap and
// conservative: never drops things you're standing next to.
const float cx = camera_eye_.x();
const float cy = camera_eye_.y();
const float cz = camera_eye_.z();
auto contributionPasses = [&](const float mn[3], const float mx[3]) -> bool {
if (min_pixel_radius <= 0.0f) return true;
// Camera inside AABB? Always keep.
if (cx >= mn[0] && cx <= mx[0] &&
cy >= mn[1] && cy <= mx[1] &&
cz >= mn[2] && cz <= mx[2]) {
return true;
}
float ex = 0.5f * (mx[0] - mn[0]);
float ey = 0.5f * (mx[1] - mn[1]);
float ez = 0.5f * (mx[2] - mn[2]);
float radius = std::sqrt(ex*ex + ey*ey + ez*ez);
float dx = 0.5f * (mx[0] + mn[0]) - cx;
float dy = 0.5f * (mx[1] + mn[1]) - cy;
float dz = 0.5f * (mx[2] + mn[2]) - cz;
float dist = std::sqrt(dx*dx + dy*dy + dz*dz);
// r_px = focal_px * radius / dist; compare r_px >= min_pixel_radius,
// rearranged to avoid the divide.
return focal_px * radius >= min_pixel_radius * dist;
};
auto test_and_push = [&](uint32_t inst_idx) {
const InstanceCpu& inst = m.instances[inst_idx];
if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) return;
if (!contributionPasses(inst.world_aabb_min, inst.world_aabb_max)) return;
if (inst.mesh_id >= m.meshes.size()) return;
const bool reflected = inst_idx < m.instance_reflected.size()
&& m.instance_reflected[inst_idx] != 0;
@@ -854,6 +890,9 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6]
uint32_t ni = stack[--sp];
const BvhNode& n = m.bvh.nodes[ni];
if (!aabbInFrustum(n.aabb_min, n.aabb_max, planes)) continue;
// Contribution cull the whole subtree: if the node's enclosing
// sphere is below threshold, every child is too.
if (!contributionPasses(n.aabb_min, n.aabb_max)) continue;
if (n.count > 0) {
for (uint32_t k = 0; k < n.count; ++k) {
uint32_t item_idx = m.bvh.item_indices[n.right_or_first + k];
@@ -939,11 +978,12 @@ void ViewportWindow::updateCamera() {
eye.setX(camera_target_.x() + camera_distance_ * cosf(pitch_rad) * cosf(yaw_rad));
eye.setY(camera_target_.y() + camera_distance_ * cosf(pitch_rad) * sinf(yaw_rad));
eye.setZ(camera_target_.z() + camera_distance_ * sinf(pitch_rad));
camera_eye_ = eye;
view_matrix_.setToIdentity();
view_matrix_.lookAt(eye, camera_target_, QVector3D(0, 0, 1));
proj_matrix_.setToIdentity();
float aspect = width() > 0 ? float(width()) / float(height()) : 1.0f;
proj_matrix_.perspective(45.0f, aspect, 0.1f, camera_distance_ * 10.0f);
proj_matrix_.perspective(camera_fov_y_deg_, aspect, 0.1f, camera_distance_ * 10.0f);
}
void ViewportWindow::render() {
@@ -961,6 +1001,20 @@ void ViewportWindow::render() {
float planes[6][4];
extractFrustumPlanes(vp, planes);
// Pixels-per-radian vertical focal length. Combined with per-instance
// world-space radius this gives screen-space pixel size for contribution
// culling below.
const float focal_px = 0.5f * static_cast<float>(h) /
std::tan(qDegreesToRadians(0.5f * camera_fov_y_deg_));
// Drop frustum-visible objects smaller than this many pixels. Override
// with IFC_MIN_PX (0 = disabled). 2 px radius = ~4x4 pixels, well below
// what's meaningful at normal viewing distances and eliminates the long
// tail of distant MEP/fixings that dominate BIM triangle counts.
static const float min_pixel_radius = []{
const char* e = std::getenv("IFC_MIN_PX");
return (e && *e) ? static_cast<float>(std::atof(e)) : 2.0f;
}();
gl_->glUseProgram(main_program_);
GLint u_vp = gl_->glGetUniformLocation(main_program_, "u_view_projection");
GLint u_light = gl_->glGetUniformLocation(main_program_, "u_light_dir");
@@ -981,7 +1035,7 @@ void ViewportWindow::render() {
for (auto& [model_id, m] : models_gpu_) {
if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue;
cullAndUploadVisible(m, planes);
cullAndUploadVisible(m, planes, focal_px, min_pixel_radius);
if (m.indirect_command_count == 0) continue;
gl_->glBindVertexArray(m.vao);
@@ -1114,7 +1168,9 @@ void ViewportWindow::renderPickPass() {
for (auto& [model_id, m] : models_gpu_) {
if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue;
cullAndUploadVisible(m, planes);
// Pick pass: contribution-cull disabled (0.0 threshold) so every
// frustum-visible object is clickable, even sub-pixel ones.
cullAndUploadVisible(m, planes, 1.0f, 0.0f);
if (m.indirect_command_count == 0) continue;
gl_->glBindVertexArray(m.vao);
+9 -1
View File
@@ -173,7 +173,13 @@ private:
// Frustum-cull m's instances (BVH if available, else linear scan),
// build the per-mesh DrawElementsIndirectCommand array + flat visible
// list, and upload both to m.indirect_buffer / m.visible_ssbo.
void cullAndUploadVisible(ModelGpuData& m, const float planes[6][4]);
//
// `min_pixel_radius` controls contribution culling: instances (and BVH
// subtrees) whose projected bounding-sphere radius would be below this
// many pixels are dropped. 0 = disabled (all frustum-visible kept),
// which is what the pick pass uses so clickable targets aren't filtered.
void cullAndUploadVisible(ModelGpuData& m, const float planes[6][4],
float focal_px, float min_pixel_radius);
// Mouse interaction
void handleMousePress(QMouseEvent* event);
@@ -224,9 +230,11 @@ private:
// Camera
QVector3D camera_target_{0, 0, 0};
QVector3D camera_eye_{0, 0, 0}; // world-space eye, set in updateCamera
float camera_distance_ = 50.0f;
float camera_yaw_ = 45.0f;
float camera_pitch_ = 30.0f;
float camera_fov_y_deg_ = 45.0f;
QMatrix4x4 view_matrix_;
QMatrix4x4 proj_matrix_;