-
Notifications
You must be signed in to change notification settings - Fork 2
feat(render): add frustum culling #442
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| //// Frustum.hpp ////////////////////////////////////////////////////////////// | ||
| // | ||
| // zzzzz zzz zzzzzzzzzzzzz zzzz zzzz zzzzzz zzzzz | ||
| // zzzzzzz zzz zzzz zzzz zzzz zzzz | ||
| // zzz zzz zzz zzzzzzzzzzzzz zzzz zzzz zzz | ||
| // zzz zzz zzz z zzzz zzzz zzzz zzzz | ||
| // zzz zzz zzzzzzzzzzzzz zzzz zzz zzzzzzz zzzzz | ||
| // | ||
| // Author: Mehdy MORVAN | ||
| // Date: 15/02/2026 | ||
| // Description: Header file for frustum culling utilities | ||
| // | ||
| /////////////////////////////////////////////////////////////////////////////// | ||
| #pragma once | ||
|
|
||
| #include <glm/glm.hpp> | ||
| #include <array> | ||
| #include <algorithm> | ||
|
|
||
| namespace nexo::math { | ||
|
|
||
| struct Plane { | ||
| glm::vec3 normal{}; | ||
| float distance = 0.0f; | ||
|
|
||
| void normalize() | ||
| { | ||
| const float len = glm::length(normal); | ||
| normal /= len; | ||
| distance /= len; | ||
| } | ||
|
|
||
| [[nodiscard]] float distanceTo(const glm::vec3 &point) const | ||
| { | ||
| return glm::dot(normal, point) + distance; | ||
| } | ||
| }; | ||
|
|
||
| class Frustum { | ||
| public: | ||
| explicit Frustum(const glm::mat4 &vp) | ||
| { | ||
| extractPlanes(vp); | ||
| } | ||
|
|
||
| /** | ||
| * @brief Tests whether an axis-aligned bounding box intersects the frustum. | ||
| * | ||
| * Uses the p-vertex optimization: for each frustum plane, finds the AABB vertex | ||
| * most in the direction of the plane normal. If that vertex is behind the plane, | ||
| * the entire AABB is outside the frustum. | ||
| * | ||
| * @param aabbMin The minimum corner of the AABB in world space. | ||
| * @param aabbMax The maximum corner of the AABB in world space. | ||
| * @return true if the AABB is at least partially inside the frustum. | ||
| */ | ||
| [[nodiscard]] bool intersectsAABB(const glm::vec3 &aabbMin, const glm::vec3 &aabbMax) const | ||
| { | ||
| for (const auto &plane : m_planes) | ||
| { | ||
| // P-vertex: the corner most in the direction of the plane normal | ||
| const glm::vec3 pVertex( | ||
| plane.normal.x >= 0.0f ? aabbMax.x : aabbMin.x, | ||
| plane.normal.y >= 0.0f ? aabbMax.y : aabbMin.y, | ||
| plane.normal.z >= 0.0f ? aabbMax.z : aabbMin.z | ||
| ); | ||
|
|
||
| if (plane.distanceTo(pVertex) < 0.0f) | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| private: | ||
| std::array<Plane, 6> m_planes; | ||
|
|
||
| /** | ||
| * @brief Extracts frustum planes from a view-projection matrix. | ||
| * | ||
| * Uses the Gribb/Hartmann method to extract and normalize the six frustum planes | ||
| * directly from the combined view-projection matrix. | ||
| */ | ||
| void extractPlanes(const glm::mat4 &vp) | ||
| { | ||
| // Left: Row3 + Row0 | ||
| m_planes[0].normal.x = vp[0][3] + vp[0][0]; | ||
| m_planes[0].normal.y = vp[1][3] + vp[1][0]; | ||
| m_planes[0].normal.z = vp[2][3] + vp[2][0]; | ||
| m_planes[0].distance = vp[3][3] + vp[3][0]; | ||
|
|
||
| // Right: Row3 - Row0 | ||
| m_planes[1].normal.x = vp[0][3] - vp[0][0]; | ||
| m_planes[1].normal.y = vp[1][3] - vp[1][0]; | ||
| m_planes[1].normal.z = vp[2][3] - vp[2][0]; | ||
| m_planes[1].distance = vp[3][3] - vp[3][0]; | ||
|
|
||
| // Bottom: Row3 + Row1 | ||
| m_planes[2].normal.x = vp[0][3] + vp[0][1]; | ||
| m_planes[2].normal.y = vp[1][3] + vp[1][1]; | ||
| m_planes[2].normal.z = vp[2][3] + vp[2][1]; | ||
| m_planes[2].distance = vp[3][3] + vp[3][1]; | ||
|
|
||
| // Top: Row3 - Row1 | ||
| m_planes[3].normal.x = vp[0][3] - vp[0][1]; | ||
| m_planes[3].normal.y = vp[1][3] - vp[1][1]; | ||
| m_planes[3].normal.z = vp[2][3] - vp[2][1]; | ||
| m_planes[3].distance = vp[3][3] - vp[3][1]; | ||
|
|
||
| // Near: Row3 + Row2 | ||
| m_planes[4].normal.x = vp[0][3] + vp[0][2]; | ||
| m_planes[4].normal.y = vp[1][3] + vp[1][2]; | ||
| m_planes[4].normal.z = vp[2][3] + vp[2][2]; | ||
| m_planes[4].distance = vp[3][3] + vp[3][2]; | ||
|
|
||
| // Far: Row3 - Row2 | ||
| m_planes[5].normal.x = vp[0][3] - vp[0][2]; | ||
| m_planes[5].normal.y = vp[1][3] - vp[1][2]; | ||
| m_planes[5].normal.z = vp[2][3] - vp[2][2]; | ||
| m_planes[5].distance = vp[3][3] - vp[3][2]; | ||
|
|
||
| for (auto &plane : m_planes) | ||
| plane.normalize(); | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * @brief Transforms a local-space AABB by a 4x4 matrix to produce a world-space AABB. | ||
| * | ||
| * Uses Arvo's method for efficient AABB-matrix transformation, producing the tightest | ||
| * axis-aligned bounding box that contains the transformed original box. | ||
| */ | ||
| inline void transformAABB(const glm::vec3 &localMin, const glm::vec3 &localMax, | ||
| const glm::mat4 &transform, | ||
| glm::vec3 &worldMin, glm::vec3 &worldMax) | ||
| { | ||
| // Start with the translation component | ||
| const glm::vec3 translation(transform[3]); | ||
| worldMin = translation; | ||
| worldMax = translation; | ||
|
|
||
| // Apply rotation/scale contribution using Arvo's method | ||
| for (int i = 0; i < 3; ++i) | ||
| { | ||
| for (int j = 0; j < 3; ++j) | ||
| { | ||
| const float a = transform[j][i] * localMin[j]; | ||
| const float b = transform[j][i] * localMax[j]; | ||
| worldMin[i] += std::min(a, b); | ||
| worldMax[i] += std::max(a, b); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| } // namespace nexo::math | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,6 +45,9 @@ namespace nexo | |
|
|
||
| components::StaticMeshComponent mesh; | ||
| mesh.vao = renderer::NxRenderer3D::getCubeVAO(); | ||
| mesh.hasBounds = true; | ||
| mesh.localMin = glm::vec3(-0.5f); | ||
| mesh.localMax = glm::vec3(0.5f); | ||
|
|
||
| auto material = std::make_unique<components::Material>(); | ||
| material->albedoColor = color; | ||
|
|
@@ -80,6 +83,9 @@ namespace nexo | |
|
|
||
| components::StaticMeshComponent mesh; | ||
| mesh.vao = renderer::NxRenderer3D::getCubeVAO(); | ||
| mesh.hasBounds = true; | ||
| mesh.localMin = glm::vec3(-0.5f); | ||
| mesh.localMax = glm::vec3(0.5f); | ||
|
|
||
| const auto materialRef = assets::AssetCatalog::getInstance().createAsset<assets::Material>( | ||
| assets::AssetLocation("_internal::CubeMat@_internal"), | ||
|
|
@@ -174,6 +180,9 @@ namespace nexo | |
|
|
||
| components::StaticMeshComponent mesh; | ||
| mesh.vao = renderer::NxRenderer3D::getTetrahedronVAO(); | ||
| mesh.hasBounds = true; | ||
| mesh.localMin = glm::vec3(-1.0f); | ||
| mesh.localMax = glm::vec3(1.0f); | ||
|
Comment on lines
+183
to
+185
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Consider extracting default primitive bounds into named constants. All non-cube primitives use Also applies to: 217-219, 247-249, 280-282, 311-313, 344-346, 375-377, 408-410 🧰 Tools🪛 Cppcheck (2.19.0)[style] 183-183: The function 'removeEventDebugFlags' is never used. (unusedFunction) [style] 184-184: The function 'addEventDebugFlag' is never used. (unusedFunction) [style] 185-185: The function 'resetEventDebugFlags' is never used. (unusedFunction) 🤖 Prompt for AI Agents |
||
|
|
||
| auto material = std::make_unique<components::Material>(); | ||
| material->albedoColor = color; | ||
|
|
@@ -205,6 +214,9 @@ namespace nexo | |
|
|
||
| components::StaticMeshComponent mesh; | ||
| mesh.vao = renderer::NxRenderer3D::getTetrahedronVAO(); | ||
| mesh.hasBounds = true; | ||
| mesh.localMin = glm::vec3(-1.0f); | ||
| mesh.localMax = glm::vec3(1.0f); | ||
|
|
||
| const auto materialRef = assets::AssetCatalog::getInstance().createAsset<assets::Material>( | ||
| assets::AssetLocation("_internal::TetrahedronMat@_internal"), | ||
|
|
@@ -232,6 +244,9 @@ namespace nexo | |
|
|
||
| components::StaticMeshComponent mesh; | ||
| mesh.vao = renderer::NxRenderer3D::getPyramidVAO(); | ||
| mesh.hasBounds = true; | ||
| mesh.localMin = glm::vec3(-1.0f); | ||
| mesh.localMax = glm::vec3(1.0f); | ||
|
|
||
| auto material = std::make_unique<components::Material>(); | ||
| material->albedoColor = color; | ||
|
|
@@ -262,6 +277,9 @@ namespace nexo | |
|
|
||
| components::StaticMeshComponent mesh; | ||
| mesh.vao = renderer::NxRenderer3D::getPyramidVAO(); | ||
| mesh.hasBounds = true; | ||
| mesh.localMin = glm::vec3(-1.0f); | ||
| mesh.localMax = glm::vec3(1.0f); | ||
|
|
||
| const auto materialRef = assets::AssetCatalog::getInstance().createAsset<assets::Material>( | ||
| assets::AssetLocation("_internal::PyramidMat@_internal"), | ||
|
|
@@ -290,6 +308,9 @@ namespace nexo | |
|
|
||
| components::StaticMeshComponent mesh; | ||
| mesh.vao = renderer::NxRenderer3D::getCylinderVAO(nbSegment); | ||
| mesh.hasBounds = true; | ||
| mesh.localMin = glm::vec3(-1.0f); | ||
| mesh.localMax = glm::vec3(1.0f); | ||
|
|
||
| auto material = std::make_unique<components::Material>(); | ||
| material->albedoColor = color; | ||
|
|
@@ -320,6 +341,9 @@ namespace nexo | |
|
|
||
| components::StaticMeshComponent mesh; | ||
| mesh.vao = renderer::NxRenderer3D::getCylinderVAO(nbSegment); | ||
| mesh.hasBounds = true; | ||
| mesh.localMin = glm::vec3(-1.0f); | ||
| mesh.localMax = glm::vec3(1.0f); | ||
|
|
||
| const auto materialRef = assets::AssetCatalog::getInstance().createAsset<assets::Material>( | ||
| assets::AssetLocation("_internal::CylinderMat@_internal"), | ||
|
|
@@ -348,6 +372,9 @@ namespace nexo | |
|
|
||
| components::StaticMeshComponent mesh; | ||
| mesh.vao = renderer::NxRenderer3D::getSphereVAO(nbSubdivision); | ||
| mesh.hasBounds = true; | ||
| mesh.localMin = glm::vec3(-1.0f); | ||
| mesh.localMax = glm::vec3(1.0f); | ||
|
|
||
| auto material = std::make_unique<components::Material>(); | ||
| material->albedoColor = color; | ||
|
|
@@ -378,6 +405,9 @@ namespace nexo | |
|
|
||
| components::StaticMeshComponent mesh; | ||
| mesh.vao = renderer::NxRenderer3D::getSphereVAO(nbSubdivision); | ||
| mesh.hasBounds = true; | ||
| mesh.localMin = glm::vec3(-1.0f); | ||
| mesh.localMax = glm::vec3(1.0f); | ||
|
|
||
| const auto materialRef = assets::AssetCatalog::getInstance().createAsset<assets::Material>( | ||
| assets::AssetLocation("_internal::SphereMat@_internal"), | ||
|
|
@@ -489,6 +519,9 @@ namespace nexo | |
|
|
||
| components::StaticMeshComponent staticMesh; | ||
| staticMesh.vao = mesh.vao; | ||
| staticMesh.hasBounds = true; | ||
| staticMesh.localMin = mesh.localMin; | ||
| staticMesh.localMax = mesh.localMax; | ||
|
|
||
| components::RenderComponent renderComponent; | ||
| renderComponent.isRendered = true; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -24,6 +24,7 @@ | |||||||||||||||||||
| #include "components/StaticMesh.hpp" | ||||||||||||||||||||
| #include "components/Transform.hpp" | ||||||||||||||||||||
| #include "core/event/Input.hpp" | ||||||||||||||||||||
| #include "math/Frustum.hpp" | ||||||||||||||||||||
| #include "math/Projection.hpp" | ||||||||||||||||||||
| #include "math/Vector.hpp" | ||||||||||||||||||||
| #include "renderPasses/Masks.hpp" | ||||||||||||||||||||
|
|
@@ -286,36 +287,42 @@ namespace nexo::system { | |||||||||||||||||||
| const auto materialSpan = get<components::MaterialComponent>(); | ||||||||||||||||||||
| const std::span<const ecs::Entity> entitySpan = m_group->entities(); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| std::vector<renderer::DrawCommand> drawCommands; | ||||||||||||||||||||
| for (size_t i = partition->startIndex; i < partition->startIndex + partition->count; ++i) { | ||||||||||||||||||||
| const ecs::Entity entity = entitySpan[i]; | ||||||||||||||||||||
| if (coord->entityHasComponent<components::CameraComponent>(entity) && sceneType != SceneType::EDITOR) | ||||||||||||||||||||
| continue; | ||||||||||||||||||||
| const auto &transform = transformSpan[i]; | ||||||||||||||||||||
| const auto &materialAsset = materialSpan[i].material.lock(); | ||||||||||||||||||||
| std::string shaderStr = materialAsset && materialAsset->isLoaded() ? materialAsset->getData()->shader : ""; | ||||||||||||||||||||
| const auto &mesh = meshSpan[i]; | ||||||||||||||||||||
| auto shader = renderer::ShaderLibrary::getInstance().get(shaderStr); | ||||||||||||||||||||
| if (!shader) | ||||||||||||||||||||
| continue; | ||||||||||||||||||||
| drawCommands.push_back(createDrawCommand( | ||||||||||||||||||||
| entity, | ||||||||||||||||||||
| shader, | ||||||||||||||||||||
| mesh, | ||||||||||||||||||||
| materialAsset, | ||||||||||||||||||||
| transform) | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| for (auto &camera : renderContext.cameras) { | ||||||||||||||||||||
| const math::Frustum frustum(camera.viewProjectionMatrix); | ||||||||||||||||||||
| std::vector<renderer::DrawCommand> drawCommands; | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
Comment on lines
+290
to
+293
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Consider reserving the
♻️ Suggested change const math::Frustum frustum(camera.viewProjectionMatrix);
std::vector<renderer::DrawCommand> drawCommands;
+ drawCommands.reserve(partition->count);🤖 Prompt for AI Agents |
||||||||||||||||||||
| for (size_t i = partition->startIndex; i < partition->startIndex + partition->count; ++i) { | ||||||||||||||||||||
| const ecs::Entity entity = entitySpan[i]; | ||||||||||||||||||||
| if (coord->entityHasComponent<components::CameraComponent>(entity) && sceneType != SceneType::EDITOR) | ||||||||||||||||||||
| continue; | ||||||||||||||||||||
| const auto &transform = transformSpan[i]; | ||||||||||||||||||||
| const auto &mesh = meshSpan[i]; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Frustum culling: skip entities whose AABB is entirely outside the camera frustum | ||||||||||||||||||||
| if (mesh.hasBounds) | ||||||||||||||||||||
| { | ||||||||||||||||||||
| glm::vec3 worldMin, worldMax; | ||||||||||||||||||||
| math::transformAABB(mesh.localMin, mesh.localMax, transform.worldMatrix, worldMin, worldMax); | ||||||||||||||||||||
| if (!frustum.intersectsAABB(worldMin, worldMax)) | ||||||||||||||||||||
| continue; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if (coord->entityHasComponent<components::SelectedTag>(entity)) | ||||||||||||||||||||
| drawCommands.push_back(createSelectedDrawCommand(mesh, materialAsset, transform)); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| const auto &materialAsset = materialSpan[i].material.lock(); | ||||||||||||||||||||
| std::string shaderStr = materialAsset && materialAsset->isLoaded() ? materialAsset->getData()->shader : ""; | ||||||||||||||||||||
| auto shader = renderer::ShaderLibrary::getInstance().get(shaderStr); | ||||||||||||||||||||
| if (!shader) | ||||||||||||||||||||
| continue; | ||||||||||||||||||||
|
Comment on lines
+310
to
+314
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Shader lookup inside per-entity loop: silently skipping entities with no shader. When 🤖 Prompt for AI Agents |
||||||||||||||||||||
|
|
||||||||||||||||||||
| for (auto &camera : renderContext.cameras) { | ||||||||||||||||||||
| for (auto &cmd : drawCommands) { | ||||||||||||||||||||
| auto cmd = createDrawCommand(entity, shader, mesh, materialAsset, transform); | ||||||||||||||||||||
| cmd.uniforms["uViewProjection"] = camera.viewProjectionMatrix; | ||||||||||||||||||||
| cmd.uniforms["uCamPos"] = camera.cameraPosition; | ||||||||||||||||||||
| setupLights(cmd, renderContext.sceneLights); | ||||||||||||||||||||
| drawCommands.push_back(std::move(cmd)); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if (coord->entityHasComponent<components::SelectedTag>(entity)) | ||||||||||||||||||||
| drawCommands.push_back(createSelectedDrawCommand(mesh, materialAsset, transform)); | ||||||||||||||||||||
|
Comment on lines
+322
to
+323
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find the createSelectedDrawCommand function definition
rg -n 'createSelectedDrawCommand' --type cppRepository: NexoEngine/game-engine Length of output: 606 🏁 Script executed: # Search for shader files and check for uViewProjection usage
fd -e glsl -e vert -e frag -e vs -e fs -e shader | head -20Repository: NexoEngine/game-engine Length of output: 408 🏁 Script executed: # Also check for shader content in the codebase
rg -n 'uViewProjection' -t cppRepository: NexoEngine/game-engine Length of output: 924 🏁 Script executed: # Search for "Flat color" and "Albedo unshaded transparent" shader references
rg -n '"Flat color"|"Albedo unshaded transparent"' -t cpp -A3 -B3Repository: NexoEngine/game-engine Length of output: 6339 🏁 Script executed: # Check if the shaders use uViewProjection
cat engine/src/systems/RenderCommandSystem.cpp | sed -n '203,230p'Repository: NexoEngine/game-engine Length of output: 1705 🏁 Script executed: # Check the shader files
cat resources/shaders/flat_color.glsl
cat resources/shaders/albedo_unshaded_transparent.glslRepository: NexoEngine/game-engine Length of output: 1461 🏁 Script executed: # Compare with RenderBillboardSystem implementation
cat engine/src/systems/RenderBillboardSystem.cpp | sed -n '110,135p'Repository: NexoEngine/game-engine Length of output: 1785 🏁 Script executed: # Check the context around line 322-323
cat engine/src/systems/RenderCommandSystem.cpp | sed -n '315,330p'Repository: NexoEngine/game-engine Length of output: 987 Set Both "Flat color" and "Albedo unshaded transparent" shaders require Fix: set VP uniforms on the selected draw command if (coord->entityHasComponent<components::SelectedTag>(entity))
- drawCommands.push_back(createSelectedDrawCommand(mesh, materialAsset, transform));
+ {
+ auto selectedCmd = createSelectedDrawCommand(mesh, materialAsset, transform);
+ selectedCmd.uniforms["uViewProjection"] = camera.viewProjectionMatrix;
+ selectedCmd.uniforms["uCamPos"] = camera.cameraPosition;
+ drawCommands.push_back(std::move(selectedCmd));
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| camera.pipeline.addDrawCommands(drawCommands); | ||||||||||||||||||||
| if (sceneType == SceneType::EDITOR && renderContext.gridParams.enabled) | ||||||||||||||||||||
| camera.pipeline.addDrawCommand(createGridDrawCommand(camera, renderContext)); | ||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard against zero-length normal in
Plane::normalize().If a degenerate or zero view-projection matrix is passed,
glm::length(normal)can be 0, causing division-by-zero and NaN propagation through all subsequentdistanceTocalls. A simple guard avoids silent corruption.🛡️ Proposed fix
void normalize() { const float len = glm::length(normal); + if (len < 1e-8f) + return; normal /= len; distance /= len; }📝 Committable suggestion
🤖 Prompt for AI Agents