Skip to content

Commit ecb84f0

Browse files
iMeaNzclaude
andcommitted
feat(render): add frustum culling to skip off-screen entities
Extract frustum planes from the camera view-projection matrix using the Gribb/Hartmann method and test each entity's world-space AABB before creating draw commands. Entities entirely outside the frustum are skipped, reducing draw calls for scenes with many off-screen objects. - Add Frustum class with AABB-plane intersection test (common/math) - Add local-space AABB (localMin/localMax) to StaticMeshComponent - Store bounding box from ModelImporter (already computed, was discarded) - Set default bounds for all built-in primitives in EntityFactory3D - Restructure RenderCommandSystem to cull per-camera Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e304ede commit ecb84f0

6 files changed

Lines changed: 234 additions & 26 deletions

File tree

common/math/Frustum.hpp

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
//// Frustum.hpp //////////////////////////////////////////////////////////////
2+
//
3+
// zzzzz zzz zzzzzzzzzzzzz zzzz zzzz zzzzzz zzzzz
4+
// zzzzzzz zzz zzzz zzzz zzzz zzzz
5+
// zzz zzz zzz zzzzzzzzzzzzz zzzz zzzz zzz
6+
// zzz zzz zzz z zzzz zzzz zzzz zzzz
7+
// zzz zzz zzzzzzzzzzzzz zzzz zzz zzzzzzz zzzzz
8+
//
9+
// Author: Mehdy MORVAN
10+
// Date: 15/02/2026
11+
// Description: Header file for frustum culling utilities
12+
//
13+
///////////////////////////////////////////////////////////////////////////////
14+
#pragma once
15+
16+
#include <glm/glm.hpp>
17+
#include <array>
18+
#include <algorithm>
19+
20+
namespace nexo::math {
21+
22+
struct Plane {
23+
glm::vec3 normal{};
24+
float distance = 0.0f;
25+
26+
void normalize()
27+
{
28+
const float len = glm::length(normal);
29+
normal /= len;
30+
distance /= len;
31+
}
32+
33+
[[nodiscard]] float distanceTo(const glm::vec3 &point) const
34+
{
35+
return glm::dot(normal, point) + distance;
36+
}
37+
};
38+
39+
class Frustum {
40+
public:
41+
explicit Frustum(const glm::mat4 &vp)
42+
{
43+
extractPlanes(vp);
44+
}
45+
46+
/**
47+
* @brief Tests whether an axis-aligned bounding box intersects the frustum.
48+
*
49+
* Uses the p-vertex optimization: for each frustum plane, finds the AABB vertex
50+
* most in the direction of the plane normal. If that vertex is behind the plane,
51+
* the entire AABB is outside the frustum.
52+
*
53+
* @param aabbMin The minimum corner of the AABB in world space.
54+
* @param aabbMax The maximum corner of the AABB in world space.
55+
* @return true if the AABB is at least partially inside the frustum.
56+
*/
57+
[[nodiscard]] bool intersectsAABB(const glm::vec3 &aabbMin, const glm::vec3 &aabbMax) const
58+
{
59+
for (const auto &plane : m_planes)
60+
{
61+
// P-vertex: the corner most in the direction of the plane normal
62+
const glm::vec3 pVertex(
63+
plane.normal.x >= 0.0f ? aabbMax.x : aabbMin.x,
64+
plane.normal.y >= 0.0f ? aabbMax.y : aabbMin.y,
65+
plane.normal.z >= 0.0f ? aabbMax.z : aabbMin.z
66+
);
67+
68+
if (plane.distanceTo(pVertex) < 0.0f)
69+
return false;
70+
}
71+
return true;
72+
}
73+
74+
private:
75+
std::array<Plane, 6> m_planes;
76+
77+
/**
78+
* @brief Extracts frustum planes from a view-projection matrix.
79+
*
80+
* Uses the Gribb/Hartmann method to extract and normalize the six frustum planes
81+
* directly from the combined view-projection matrix.
82+
*/
83+
void extractPlanes(const glm::mat4 &vp)
84+
{
85+
// Left: Row3 + Row0
86+
m_planes[0].normal.x = vp[0][3] + vp[0][0];
87+
m_planes[0].normal.y = vp[1][3] + vp[1][0];
88+
m_planes[0].normal.z = vp[2][3] + vp[2][0];
89+
m_planes[0].distance = vp[3][3] + vp[3][0];
90+
91+
// Right: Row3 - Row0
92+
m_planes[1].normal.x = vp[0][3] - vp[0][0];
93+
m_planes[1].normal.y = vp[1][3] - vp[1][0];
94+
m_planes[1].normal.z = vp[2][3] - vp[2][0];
95+
m_planes[1].distance = vp[3][3] - vp[3][0];
96+
97+
// Bottom: Row3 + Row1
98+
m_planes[2].normal.x = vp[0][3] + vp[0][1];
99+
m_planes[2].normal.y = vp[1][3] + vp[1][1];
100+
m_planes[2].normal.z = vp[2][3] + vp[2][1];
101+
m_planes[2].distance = vp[3][3] + vp[3][1];
102+
103+
// Top: Row3 - Row1
104+
m_planes[3].normal.x = vp[0][3] - vp[0][1];
105+
m_planes[3].normal.y = vp[1][3] - vp[1][1];
106+
m_planes[3].normal.z = vp[2][3] - vp[2][1];
107+
m_planes[3].distance = vp[3][3] - vp[3][1];
108+
109+
// Near: Row3 + Row2
110+
m_planes[4].normal.x = vp[0][3] + vp[0][2];
111+
m_planes[4].normal.y = vp[1][3] + vp[1][2];
112+
m_planes[4].normal.z = vp[2][3] + vp[2][2];
113+
m_planes[4].distance = vp[3][3] + vp[3][2];
114+
115+
// Far: Row3 - Row2
116+
m_planes[5].normal.x = vp[0][3] - vp[0][2];
117+
m_planes[5].normal.y = vp[1][3] - vp[1][2];
118+
m_planes[5].normal.z = vp[2][3] - vp[2][2];
119+
m_planes[5].distance = vp[3][3] - vp[3][2];
120+
121+
for (auto &plane : m_planes)
122+
plane.normalize();
123+
}
124+
};
125+
126+
/**
127+
* @brief Transforms a local-space AABB by a 4x4 matrix to produce a world-space AABB.
128+
*
129+
* Uses Arvo's method for efficient AABB-matrix transformation, producing the tightest
130+
* axis-aligned bounding box that contains the transformed original box.
131+
*/
132+
inline void transformAABB(const glm::vec3 &localMin, const glm::vec3 &localMax,
133+
const glm::mat4 &transform,
134+
glm::vec3 &worldMin, glm::vec3 &worldMax)
135+
{
136+
// Start with the translation component
137+
const glm::vec3 translation(transform[3]);
138+
worldMin = translation;
139+
worldMax = translation;
140+
141+
// Apply rotation/scale contribution using Arvo's method
142+
for (int i = 0; i < 3; ++i)
143+
{
144+
for (int j = 0; j < 3; ++j)
145+
{
146+
const float a = transform[j][i] * localMin[j];
147+
const float b = transform[j][i] * localMax[j];
148+
worldMin[i] += std::min(a, b);
149+
worldMax[i] += std::max(a, b);
150+
}
151+
}
152+
}
153+
154+
} // namespace nexo::math

engine/src/EntityFactory3D.cpp

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ namespace nexo
4545

4646
components::StaticMeshComponent mesh;
4747
mesh.vao = renderer::NxRenderer3D::getCubeVAO();
48+
mesh.hasBounds = true;
49+
mesh.localMin = glm::vec3(-0.5f);
50+
mesh.localMax = glm::vec3(0.5f);
4851

4952
auto material = std::make_unique<components::Material>();
5053
material->albedoColor = color;
@@ -80,6 +83,9 @@ namespace nexo
8083

8184
components::StaticMeshComponent mesh;
8285
mesh.vao = renderer::NxRenderer3D::getCubeVAO();
86+
mesh.hasBounds = true;
87+
mesh.localMin = glm::vec3(-0.5f);
88+
mesh.localMax = glm::vec3(0.5f);
8389

8490
const auto materialRef = assets::AssetCatalog::getInstance().createAsset<assets::Material>(
8591
assets::AssetLocation("_internal::CubeMat@_internal"),
@@ -174,6 +180,9 @@ namespace nexo
174180

175181
components::StaticMeshComponent mesh;
176182
mesh.vao = renderer::NxRenderer3D::getTetrahedronVAO();
183+
mesh.hasBounds = true;
184+
mesh.localMin = glm::vec3(-1.0f);
185+
mesh.localMax = glm::vec3(1.0f);
177186

178187
auto material = std::make_unique<components::Material>();
179188
material->albedoColor = color;
@@ -205,6 +214,9 @@ namespace nexo
205214

206215
components::StaticMeshComponent mesh;
207216
mesh.vao = renderer::NxRenderer3D::getTetrahedronVAO();
217+
mesh.hasBounds = true;
218+
mesh.localMin = glm::vec3(-1.0f);
219+
mesh.localMax = glm::vec3(1.0f);
208220

209221
const auto materialRef = assets::AssetCatalog::getInstance().createAsset<assets::Material>(
210222
assets::AssetLocation("_internal::TetrahedronMat@_internal"),
@@ -232,6 +244,9 @@ namespace nexo
232244

233245
components::StaticMeshComponent mesh;
234246
mesh.vao = renderer::NxRenderer3D::getPyramidVAO();
247+
mesh.hasBounds = true;
248+
mesh.localMin = glm::vec3(-1.0f);
249+
mesh.localMax = glm::vec3(1.0f);
235250

236251
auto material = std::make_unique<components::Material>();
237252
material->albedoColor = color;
@@ -262,6 +277,9 @@ namespace nexo
262277

263278
components::StaticMeshComponent mesh;
264279
mesh.vao = renderer::NxRenderer3D::getPyramidVAO();
280+
mesh.hasBounds = true;
281+
mesh.localMin = glm::vec3(-1.0f);
282+
mesh.localMax = glm::vec3(1.0f);
265283

266284
const auto materialRef = assets::AssetCatalog::getInstance().createAsset<assets::Material>(
267285
assets::AssetLocation("_internal::PyramidMat@_internal"),
@@ -290,6 +308,9 @@ namespace nexo
290308

291309
components::StaticMeshComponent mesh;
292310
mesh.vao = renderer::NxRenderer3D::getCylinderVAO(nbSegment);
311+
mesh.hasBounds = true;
312+
mesh.localMin = glm::vec3(-1.0f);
313+
mesh.localMax = glm::vec3(1.0f);
293314

294315
auto material = std::make_unique<components::Material>();
295316
material->albedoColor = color;
@@ -320,6 +341,9 @@ namespace nexo
320341

321342
components::StaticMeshComponent mesh;
322343
mesh.vao = renderer::NxRenderer3D::getCylinderVAO(nbSegment);
344+
mesh.hasBounds = true;
345+
mesh.localMin = glm::vec3(-1.0f);
346+
mesh.localMax = glm::vec3(1.0f);
323347

324348
const auto materialRef = assets::AssetCatalog::getInstance().createAsset<assets::Material>(
325349
assets::AssetLocation("_internal::CylinderMat@_internal"),
@@ -348,6 +372,9 @@ namespace nexo
348372

349373
components::StaticMeshComponent mesh;
350374
mesh.vao = renderer::NxRenderer3D::getSphereVAO(nbSubdivision);
375+
mesh.hasBounds = true;
376+
mesh.localMin = glm::vec3(-1.0f);
377+
mesh.localMax = glm::vec3(1.0f);
351378

352379
auto material = std::make_unique<components::Material>();
353380
material->albedoColor = color;
@@ -378,6 +405,9 @@ namespace nexo
378405

379406
components::StaticMeshComponent mesh;
380407
mesh.vao = renderer::NxRenderer3D::getSphereVAO(nbSubdivision);
408+
mesh.hasBounds = true;
409+
mesh.localMin = glm::vec3(-1.0f);
410+
mesh.localMax = glm::vec3(1.0f);
381411

382412
const auto materialRef = assets::AssetCatalog::getInstance().createAsset<assets::Material>(
383413
assets::AssetLocation("_internal::SphereMat@_internal"),
@@ -489,6 +519,9 @@ namespace nexo
489519

490520
components::StaticMeshComponent staticMesh;
491521
staticMesh.vao = mesh.vao;
522+
staticMesh.hasBounds = true;
523+
staticMesh.localMin = mesh.localMin;
524+
staticMesh.localMax = mesh.localMax;
492525

493526
components::RenderComponent renderComponent;
494527
renderComponent.isRendered = true;

engine/src/assets/Assets/Model/Model.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ namespace nexo::assets {
2626
AssetRef<Material> material;
2727

2828
glm::vec3 localCenter = {0.0f, 0.0f, 0.0f};
29+
glm::vec3 localMin = {0.0f, 0.0f, 0.0f};
30+
glm::vec3 localMax = {0.0f, 0.0f, 0.0f};
2931
};
3032

3133
struct MeshNode {

engine/src/assets/Assets/Model/ModelImporter.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ namespace nexo::assets {
427427
}
428428

429429
LOG(NEXO_INFO, "Loaded mesh {}", mesh->mName.C_Str());
430-
return {mesh->mName.C_Str(), vao, materialComponent, centerLocal};
430+
return {mesh->mName.C_Str(), vao, materialComponent, centerLocal, minBB, maxBB};
431431
}
432432

433433
glm::mat4 ModelImporter::convertAssimpMatrixToGLM(const aiMatrix4x4& matrix)

engine/src/components/StaticMesh.hpp

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,37 @@
1616
#include "renderer/Attributes.hpp"
1717
#include "renderer/VertexArray.hpp"
1818

19+
#include <glm/glm.hpp>
20+
1921
namespace nexo::components {
2022

2123
struct StaticMeshComponent {
2224
std::shared_ptr<renderer::NxVertexArray> vao;
2325

2426
renderer::RequiredAttributes meshAttributes;
2527

28+
bool hasBounds = false;
29+
glm::vec3 localMin = {0.0f, 0.0f, 0.0f};
30+
glm::vec3 localMax = {0.0f, 0.0f, 0.0f};
31+
2632
struct Memento {
2733
std::shared_ptr<renderer::NxVertexArray> vao;
34+
bool hasBounds;
35+
glm::vec3 localMin;
36+
glm::vec3 localMax;
2837
};
2938

3039
void restore(const Memento &memento)
3140
{
3241
vao = memento.vao;
42+
hasBounds = memento.hasBounds;
43+
localMin = memento.localMin;
44+
localMax = memento.localMax;
3345
}
3446

3547
[[nodiscard]] Memento save() const
3648
{
37-
return {vao};
49+
return {vao, hasBounds, localMin, localMax};
3850
}
3951
};
4052

engine/src/systems/RenderCommandSystem.cpp

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
#include "components/StaticMesh.hpp"
2525
#include "components/Transform.hpp"
2626
#include "core/event/Input.hpp"
27+
#include "math/Frustum.hpp"
2728
#include "math/Projection.hpp"
2829
#include "math/Vector.hpp"
2930
#include "renderPasses/Masks.hpp"
@@ -286,36 +287,42 @@ namespace nexo::system {
286287
const auto materialSpan = get<components::MaterialComponent>();
287288
const std::span<const ecs::Entity> entitySpan = m_group->entities();
288289

289-
std::vector<renderer::DrawCommand> drawCommands;
290-
for (size_t i = partition->startIndex; i < partition->startIndex + partition->count; ++i) {
291-
const ecs::Entity entity = entitySpan[i];
292-
if (coord->entityHasComponent<components::CameraComponent>(entity) && sceneType != SceneType::EDITOR)
293-
continue;
294-
const auto &transform = transformSpan[i];
295-
const auto &materialAsset = materialSpan[i].material.lock();
296-
std::string shaderStr = materialAsset && materialAsset->isLoaded() ? materialAsset->getData()->shader : "";
297-
const auto &mesh = meshSpan[i];
298-
auto shader = renderer::ShaderLibrary::getInstance().get(shaderStr);
299-
if (!shader)
300-
continue;
301-
drawCommands.push_back(createDrawCommand(
302-
entity,
303-
shader,
304-
mesh,
305-
materialAsset,
306-
transform)
307-
);
290+
for (auto &camera : renderContext.cameras) {
291+
const math::Frustum frustum(camera.viewProjectionMatrix);
292+
std::vector<renderer::DrawCommand> drawCommands;
293+
294+
for (size_t i = partition->startIndex; i < partition->startIndex + partition->count; ++i) {
295+
const ecs::Entity entity = entitySpan[i];
296+
if (coord->entityHasComponent<components::CameraComponent>(entity) && sceneType != SceneType::EDITOR)
297+
continue;
298+
const auto &transform = transformSpan[i];
299+
const auto &mesh = meshSpan[i];
300+
301+
// Frustum culling: skip entities whose AABB is entirely outside the camera frustum
302+
if (mesh.hasBounds)
303+
{
304+
glm::vec3 worldMin, worldMax;
305+
math::transformAABB(mesh.localMin, mesh.localMax, transform.worldMatrix, worldMin, worldMax);
306+
if (!frustum.intersectsAABB(worldMin, worldMax))
307+
continue;
308+
}
308309

309-
if (coord->entityHasComponent<components::SelectedTag>(entity))
310-
drawCommands.push_back(createSelectedDrawCommand(mesh, materialAsset, transform));
311-
}
310+
const auto &materialAsset = materialSpan[i].material.lock();
311+
std::string shaderStr = materialAsset && materialAsset->isLoaded() ? materialAsset->getData()->shader : "";
312+
auto shader = renderer::ShaderLibrary::getInstance().get(shaderStr);
313+
if (!shader)
314+
continue;
312315

313-
for (auto &camera : renderContext.cameras) {
314-
for (auto &cmd : drawCommands) {
316+
auto cmd = createDrawCommand(entity, shader, mesh, materialAsset, transform);
315317
cmd.uniforms["uViewProjection"] = camera.viewProjectionMatrix;
316318
cmd.uniforms["uCamPos"] = camera.cameraPosition;
317319
setupLights(cmd, renderContext.sceneLights);
320+
drawCommands.push_back(std::move(cmd));
321+
322+
if (coord->entityHasComponent<components::SelectedTag>(entity))
323+
drawCommands.push_back(createSelectedDrawCommand(mesh, materialAsset, transform));
318324
}
325+
319326
camera.pipeline.addDrawCommands(drawCommands);
320327
if (sceneType == SceneType::EDITOR && renderContext.gridParams.enabled)
321328
camera.pipeline.addDrawCommand(createGridDrawCommand(camera, renderContext));

0 commit comments

Comments
 (0)