Per-object frustum culling with glMultiDrawElements

Track per-object AABB and index range during upload. Each frame,
extract frustum planes from the view-projection matrix and cull
objects whose AABB is entirely outside any plane. Draw only visible
objects via glMultiDrawElements. Document the three-phase rendering
performance strategy in README.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-04-11 19:50:46 +10:00
parent d33055bb72
commit bfac12dbe7
2 changed files with 108 additions and 13 deletions
+94 -10
View File
@@ -26,7 +26,9 @@
#include <QtOpenGL/QOpenGLVersionFunctionsFactory>
#include <cstring>
#include <cmath>
#include <algorithm>
#include <limits>
static const size_t INITIAL_VBO_SIZE = 64 * 1024 * 1024; // 64 MB
static const size_t INITIAL_EBO_SIZE = 32 * 1024 * 1024; // 32 MB
@@ -421,9 +423,31 @@ void ViewportWindow::uploadChunk(const UploadChunk& chunk) {
}
gl_->glNamedBufferSubData(ebo_, ebo_used_, ib_size, global_indices.data());
// Compute AABB from vertex positions in this chunk.
ObjectDrawInfo info;
info.index_offset = static_cast<uint32_t>(ebo_used_);
info.index_count = static_cast<uint32_t>(chunk.indices.size());
const size_t num_verts = chunk.vertices.size() / VERTEX_STRIDE;
if (num_verts > 0) {
info.aabb_min[0] = info.aabb_min[1] = info.aabb_min[2] = std::numeric_limits<float>::max();
info.aabb_max[0] = info.aabb_max[1] = info.aabb_max[2] = -std::numeric_limits<float>::max();
for (size_t v = 0; v < num_verts; ++v) {
const float* pos = &chunk.vertices[v * VERTEX_STRIDE];
for (int a = 0; a < 3; ++a) {
if (pos[a] < info.aabb_min[a]) info.aabb_min[a] = pos[a];
if (pos[a] > info.aabb_max[a]) info.aabb_max[a] = pos[a];
}
}
} else {
info.aabb_min[0] = info.aabb_min[1] = info.aabb_min[2] = 0.0f;
info.aabb_max[0] = info.aabb_max[1] = info.aabb_max[2] = 0.0f;
}
{
std::lock_guard<std::mutex> lock(upload_mutex_);
total_index_count_ += static_cast<uint32_t>(chunk.indices.size());
object_draw_info_.push_back(info);
}
vbo_used_ += vb_size;
@@ -442,6 +466,7 @@ void ViewportWindow::resetScene() {
vertex_count_ = 0;
total_triangles_ = 0;
selected_object_id_ = 0;
object_draw_info_.clear();
}
void ViewportWindow::setSelectedObjectId(uint32_t id) {
@@ -504,6 +529,63 @@ void ViewportWindow::updateCamera() {
proj_matrix_.perspective(45.0f, aspect, 0.1f, camera_distance_ * 10.0f);
}
void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) {
visible_counts_.clear();
visible_offsets_.clear();
std::lock_guard<std::mutex> lock(upload_mutex_);
if (object_draw_info_.empty()) return;
// Extract 6 frustum planes from the view-projection matrix.
// Each plane is (a, b, c, d) where ax + by + cz + d >= 0 is inside.
// QMatrix4x4 is stored column-major; operator(row, col) gives element.
float planes[6][4];
for (int i = 0; i < 4; ++i) {
planes[0][i] = vp(3, i) + vp(0, i); // left
planes[1][i] = vp(3, i) - vp(0, i); // right
planes[2][i] = vp(3, i) + vp(1, i); // bottom
planes[3][i] = vp(3, i) - vp(1, i); // top
planes[4][i] = vp(3, i) + vp(2, i); // near
planes[5][i] = vp(3, i) - vp(2, i); // far
}
// Normalize planes.
for (int p = 0; p < 6; ++p) {
float len = std::sqrt(planes[p][0] * planes[p][0] +
planes[p][1] * planes[p][1] +
planes[p][2] * planes[p][2]);
if (len > 0.0f) {
float inv = 1.0f / len;
planes[p][0] *= inv;
planes[p][1] *= inv;
planes[p][2] *= inv;
planes[p][3] *= inv;
}
}
visible_counts_.reserve(object_draw_info_.size());
visible_offsets_.reserve(object_draw_info_.size());
for (const auto& obj : object_draw_info_) {
bool visible = true;
for (int p = 0; p < 6; ++p) {
// p-vertex: the AABB corner most in the direction of the plane normal.
float px = planes[p][0] >= 0.0f ? obj.aabb_max[0] : obj.aabb_min[0];
float py = planes[p][1] >= 0.0f ? obj.aabb_max[1] : obj.aabb_min[1];
float pz = planes[p][2] >= 0.0f ? obj.aabb_max[2] : obj.aabb_min[2];
float dist = planes[p][0] * px + planes[p][1] * py + planes[p][2] * pz + planes[p][3];
if (dist < 0.0f) {
visible = false;
break;
}
}
if (visible) {
visible_counts_.push_back(static_cast<GLsizei>(obj.index_count));
visible_offsets_.push_back(reinterpret_cast<const void*>(
static_cast<uintptr_t>(obj.index_offset)));
}
}
}
void ViewportWindow::render() {
if (!gl_initialized_ || !isExposed()) return;
@@ -524,11 +606,12 @@ void ViewportWindow::render() {
gl_->glBindVertexArray(vao_);
{
std::lock_guard<std::mutex> lock(upload_mutex_);
if (total_index_count_ > 0) {
gl_->glDrawElements(GL_TRIANGLES, total_index_count_, GL_UNSIGNED_INT, nullptr);
}
buildVisibleList(vp);
if (!visible_counts_.empty()) {
gl_->glMultiDrawElements(GL_TRIANGLES,
visible_counts_.data(), GL_UNSIGNED_INT,
visible_offsets_.data(),
static_cast<GLsizei>(visible_counts_.size()));
}
renderAxisGizmo();
@@ -588,11 +671,12 @@ void ViewportWindow::renderPickPass() {
gl_->glBindVertexArray(vao_);
{
std::lock_guard<std::mutex> lock(upload_mutex_);
if (total_index_count_ > 0) {
gl_->glDrawElements(GL_TRIANGLES, total_index_count_, GL_UNSIGNED_INT, nullptr);
}
// Reuse the visible list from the most recent render() call.
if (!visible_counts_.empty()) {
gl_->glMultiDrawElements(GL_TRIANGLES,
visible_counts_.data(), GL_UNSIGNED_INT,
visible_offsets_.data(),
static_cast<GLsizei>(visible_counts_.size()));
}
gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0);
+14 -3
View File
@@ -36,6 +36,13 @@ struct MaterialInfo {
float r = 0.75f, g = 0.75f, b = 0.78f, a = 1.0f;
};
struct ObjectDrawInfo {
uint32_t index_offset; // byte offset into EBO
uint32_t index_count; // number of indices
float aabb_min[3]; // world-space AABB
float aabb_max[3];
};
struct UploadChunk {
// Interleaved per-vertex layout (8 floats / 32 bytes per vertex):
// pos(3 float) + normal(3 float) + object_id(1 float bitcast from uint)
@@ -77,6 +84,7 @@ private:
void buildAxisGizmo();
bool growVbo(size_t needed_total);
bool growEbo(size_t needed_total);
void buildVisibleList(const QMatrix4x4& vp);
// Mouse interaction
void handleMousePress(QMouseEvent* event);
@@ -116,12 +124,15 @@ private:
int pick_width_ = 0;
int pick_height_ = 0;
// The entire scene is a single mega-batch: per-vertex color removes the
// need to switch materials between draw calls. Indices are written into
// the EBO already offset by base_vertex so one glDrawElements covers all.
// Per-object draw metadata for frustum culling.
std::vector<ObjectDrawInfo> object_draw_info_;
uint32_t total_index_count_ = 0;
std::mutex upload_mutex_;
// Scratch buffers reused each frame to avoid allocation.
std::vector<GLsizei> visible_counts_;
std::vector<const void*> visible_offsets_;
// Camera
QVector3D camera_target_{0, 0, 0};
float camera_distance_ = 50.0f;