ifcviewer: add GPU frustum-cull validation shader (IFC_GPU_CULL=1)

First Phase 3E milestone: a compute shader that reads the per-instance
world-AABB SSBO added in the last commit, tests each instance against
the 6 frustum planes, and atomicAdds a global counter.  No visible list
or indirect-buffer writes yet — the output is just a survivor count,
cross-checked each frame against the CPU cull's numbers in the stats
line (`gpu_cull[Xms in=A surv=B]`) so we can verify the plumbing end-
to-end before we hand the GPU responsibility for the actual render data.

Dispatched from render() after the CPU cull completes, only when
IFC_GPU_CULL=1 and the camera moved (the skipped-cull still-frame path
doesn't re-check either).  The readback is synchronous — that's fine
for a validation path; it'll go away once the GPU writes indirect
commands directly.

Expected invariant: gpu_cull.surv >= cpu_cull.visible_objects, since
the GPU path does frustum-only and CPU adds contribution + HiZ cuts on
top.  A large mismatch (orders of magnitude, or surv < visible) means
the SSBO upload or shader logic is wrong.

No shader/buffer bindings overlap with the draw path (compute uses
bindings 0/1, restored before drawing; draw programs rebind 0/1/2).
This commit is contained in:
Dion Moult
2026-04-16 20:57:47 +10:00
parent 2f88778c9f
commit a0cc4b874b
2 changed files with 109 additions and 0 deletions
+100
View File
@@ -247,6 +247,53 @@ static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const ch
return shader;
}
// Phase 3E compute cull (frustum-only, validation). Reads a model's
// per-instance AABB SSBO, tests against 6 planes, atomicAdds on a global
// counter. No visible list / indirect writeout yet; result is cross-checked
// against the CPU cull's visible_objects count to prove plumbing is correct
// before we hand the GPU the full emit responsibility. Gated by IFC_GPU_CULL=1.
static const char* CULL_COMPUTE_SHADER = R"(
#version 450 core
layout(local_size_x = 64) in;
// Each instance contributes two vec4 entries: (min.xyz, meshid_as_float),
// (max.xyz, flags_as_float). We ignore the w components here — they'll be
// needed once the shader also emits the per-mesh / fwd-rev buckets.
layout(std430, binding = 0) readonly buffer AabbBuf { vec4 entries[]; };
layout(std430, binding = 1) coherent buffer CountBuf { uint counter; };
uniform vec4 u_planes[6];
uniform uint u_count;
void main() {
uint gid = gl_GlobalInvocationID.x;
if (gid >= u_count) return;
vec3 mn = entries[gid * 2u].xyz;
vec3 mx = entries[gid * 2u + 1u].xyz;
for (int i = 0; i < 6; ++i) {
vec3 pv = vec3(
u_planes[i].x >= 0.0 ? mx.x : mn.x,
u_planes[i].y >= 0.0 ? mx.y : mn.y,
u_planes[i].z >= 0.0 ? mx.z : mn.z);
if (dot(u_planes[i].xyz, pv) + u_planes[i].w < 0.0) return;
}
atomicAdd(counter, 1u);
}
)";
static GLuint linkComputeProgram(QOpenGLFunctions_4_5_Core* gl, const char* src) {
GLuint cs = compileShader(gl, GL_COMPUTE_SHADER, src);
GLuint prog = gl->glCreateProgram();
gl->glAttachShader(prog, cs);
gl->glLinkProgram(prog);
GLint ok = 0;
gl->glGetProgramiv(prog, GL_LINK_STATUS, &ok);
if (!ok) {
char log[2048];
gl->glGetProgramInfoLog(prog, sizeof(log), nullptr, log);
qWarning("Compute program link error: %s", log);
}
gl->glDeleteShader(cs);
return prog;
}
static GLuint linkProgram(QOpenGLFunctions_4_5_Core* gl, GLuint vert, GLuint frag) {
GLuint prog = gl->glCreateProgram();
gl->glAttachShader(prog, vert);
@@ -415,6 +462,8 @@ ViewportWindow::~ViewportWindow() {
if (main_program_) gl_->glDeleteProgram(main_program_);
if (pick_program_) gl_->glDeleteProgram(pick_program_);
if (axis_program_) gl_->glDeleteProgram(axis_program_);
if (cull_program_) gl_->glDeleteProgram(cull_program_);
if (gpu_cull_counter_ssbo_) gl_->glDeleteBuffers(1, &gpu_cull_counter_ssbo_);
if (pick_fbo_) gl_->glDeleteFramebuffers(1, &pick_fbo_);
if (pick_color_tex_) gl_->glDeleteTextures(1, &pick_color_tex_);
if (pick_depth_rbo_) gl_->glDeleteRenderbuffers(1, &pick_depth_rbo_);
@@ -504,6 +553,10 @@ void ViewportWindow::buildShaders() {
GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, AXIS_FRAGMENT_SHADER);
axis_program_ = linkProgram(gl_, vs, fs);
}
cull_program_ = linkComputeProgram(gl_, CULL_COMPUTE_SHADER);
gl_->glCreateBuffers(1, &gpu_cull_counter_ssbo_);
gl_->glNamedBufferStorage(gpu_cull_counter_ssbo_, sizeof(uint32_t), nullptr,
GL_DYNAMIC_STORAGE_BIT);
}
void ViewportWindow::buildAxisGizmo() {
@@ -1732,6 +1785,49 @@ void ViewportWindow::render() {
cull_wall_ns_ += cull_wall_timer.nsecsElapsed();
}
// Phase 3E validation dispatch: frustum-only GPU cull, result compared
// against the CPU cull's visible_objects count. Gated, no draw-path
// effect. Synchronous readback is intentional — we want ground truth.
static const bool gpu_cull_enabled = []{
const char* e = std::getenv("IFC_GPU_CULL");
return e && e[0] == '1';
}();
if (gpu_cull_enabled && cull_this_frame && cull_program_) {
QElapsedTimer t; t.start();
uint32_t zero = 0;
gl_->glNamedBufferSubData(gpu_cull_counter_ssbo_, 0, sizeof(zero), &zero);
gl_->glUseProgram(cull_program_);
GLint u_planes = gl_->glGetUniformLocation(cull_program_, "u_planes");
GLint u_count = gl_->glGetUniformLocation(cull_program_, "u_count");
float planes_flat[24];
for (int i = 0; i < 6; ++i) {
planes_flat[i*4+0] = planes[i][0];
planes_flat[i*4+1] = planes[i][1];
planes_flat[i*4+2] = planes[i][2];
planes_flat[i*4+3] = planes[i][3];
}
gl_->glUniform4fv(u_planes, 6, planes_flat);
uint32_t total_in = 0;
gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, gpu_cull_counter_ssbo_);
for (const auto& [mid, m] : models_gpu_) {
if (m.hidden || !m.aabb_ssbo || m.instances.empty()) continue;
const uint32_t n = static_cast<uint32_t>(m.instances.size());
total_in += n;
gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.aabb_ssbo);
gl_->glUniform1ui(u_count, n);
gl_->glDispatchCompute((n + 63u) / 64u, 1, 1);
}
gl_->glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
uint32_t survivors = 0;
gl_->glGetNamedBufferSubData(gpu_cull_counter_ssbo_, 0, sizeof(survivors), &survivors);
gpu_cull_last_survivors_ = survivors;
gpu_cull_last_input_ = total_in;
gpu_cull_ns_ += t.nsecsElapsed();
gl_->glUseProgram(main_program_);
}
for (auto& [model_id, m] : models_gpu_) {
if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue;
@@ -1868,10 +1964,13 @@ void ViewportWindow::render() {
cull_wall_ns_ = 0;
const uint32_t skipped = cull_skipped_frames_;
cull_skipped_frames_ = 0;
const double gpu_cull_ms = gpu_cull_ns_ * 1e-6 * inv_frames;
gpu_cull_ns_ = 0;
qDebug("[frame] %.1f fps %.2f ms obj %u/%u tri %u/%u "
"meshes %u gl_draws %u sub_draws %u hiz_rej %u "
"cull[wall %.2f | work: clr %.2f trv %.2f emt %.2f upl %.2f]ms skipped %u/%u "
"gpu_cull[%.2fms in=%u surv=%u] "
"vram %.1f MB (vbo %.1f + ebo %.1f + ssbo %.1f) models %zu (%zu hidden)",
last_fps_, 1000.0f / last_fps_,
visible_objects_, total_obj,
@@ -1880,6 +1979,7 @@ void ViewportWindow::render() {
hiz_reject_count_.load(),
wall_ms, clr_ms, trv_ms, emt_ms, upl_ms,
skipped, frames_in_window,
gpu_cull_ms, gpu_cull_last_input_, gpu_cull_last_survivors_,
(total_vbo + total_ebo + total_ssbo) / (1024.0*1024.0),
total_vbo / (1024.0*1024.0),
total_ebo / (1024.0*1024.0),
+9
View File
@@ -267,6 +267,15 @@ private:
GLuint pick_program_ = 0;
GLuint axis_program_ = 0;
// Phase 3E compute cull (frustum-only, validation). Runs alongside the
// CPU cull when IFC_GPU_CULL=1; result is cross-checked against CPU's
// visible_objects count. No draw-path side effects yet.
GLuint cull_program_ = 0;
GLuint gpu_cull_counter_ssbo_ = 0;
uint32_t gpu_cull_last_survivors_ = 0;
uint32_t gpu_cull_last_input_ = 0;
uint64_t gpu_cull_ns_ = 0; // per-window accumulator
// Axis gizmo
GLuint axis_vao_ = 0;
GLuint axis_vbo_ = 0;