From d38895e4d77a3dc0f30e412f1f68c765e06308a7 Mon Sep 17 00:00:00 2001 From: Orange Date: Tue, 24 Mar 2026 10:20:50 +0300 Subject: [PATCH] added AABB check --- include/omath/3d_primitives/aabb.hpp | 16 ++ include/omath/projection/camera.hpp | 58 +++++ tests/general/unit_test_projection.cpp | 294 +++++++++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 include/omath/3d_primitives/aabb.hpp diff --git a/include/omath/3d_primitives/aabb.hpp b/include/omath/3d_primitives/aabb.hpp new file mode 100644 index 00000000..e6fbc475 --- /dev/null +++ b/include/omath/3d_primitives/aabb.hpp @@ -0,0 +1,16 @@ +// +// Created by Vladislav on 24.03.2026. +// + +#pragma once +#include "omath/linear_algebra/vector3.hpp" + +namespace omath::primitives +{ + template + struct Aabb final + { + Vector3 min; + Vector3 max; + }; +} // namespace omath::primitives diff --git a/include/omath/projection/camera.hpp b/include/omath/projection/camera.hpp index 2334abaa..801dd439 100644 --- a/include/omath/projection/camera.hpp +++ b/include/omath/projection/camera.hpp @@ -4,6 +4,7 @@ #pragma once +#include "omath/3d_primitives/aabb.hpp" #include "omath/linear_algebra/mat.hpp" #include "omath/linear_algebra/triangle.hpp" #include "omath/linear_algebra/vector3.hpp" @@ -308,6 +309,63 @@ namespace omath::projection return false; } + [[nodiscard]] bool is_aabb_culled_by_frustum(const primitives::Aabb& aabb) const noexcept + { + const auto& m = get_view_projection_matrix(); + + // Gribb-Hartmann: extract 6 frustum planes from the view-projection matrix. + // Each plane is (a, b, c, d) such that ax + by + cz + d >= 0 means inside. + // For a 4x4 matrix with rows r0..r3: + // Left = r3 + r0 + // Right = r3 - r0 + // Bottom = r3 + r1 + // Top = r3 - r1 + // Near = r3 + r2 ([-1,1]) or r2 ([0,1]) + // Far = r3 - r2 + struct Plane final + { + float a, b, c, d; + }; + + const auto extract_plane = [&m](const int sign, const int row) -> Plane + { + return { + m.at(3, 0) + static_cast(sign) * m.at(row, 0), + m.at(3, 1) + static_cast(sign) * m.at(row, 1), + m.at(3, 2) + static_cast(sign) * m.at(row, 2), + m.at(3, 3) + static_cast(sign) * m.at(row, 3), + }; + }; + + std::array planes = { + extract_plane(1, 0), // left + extract_plane(-1, 0), // right + extract_plane(1, 1), // bottom + extract_plane(-1, 1), // top + extract_plane(-1, 2), // far + }; + + // Near plane depends on NDC depth range + if constexpr (depth_range == NDCDepthRange::ZERO_TO_ONE) + planes[5] = {m.at(2, 0), m.at(2, 1), m.at(2, 2), m.at(2, 3)}; + else + planes[5] = extract_plane(1, 2); + + // For each plane, find the AABB corner most in the direction of the plane normal + // (the "positive vertex"). If it's outside, the entire AABB is outside. + for (const auto& [a, b, c, d] : planes) + { + const float px = a >= 0.f ? aabb.max.x : aabb.min.x; + const float py = b >= 0.f ? aabb.max.y : aabb.min.y; + const float pz = c >= 0.f ? aabb.max.z : aabb.min.z; + + if (a * px + b * py + c * pz + d < 0.f) + return true; + } + + return false; + } + [[nodiscard]] std::expected, Error> world_to_view_port(const Vector3& world_position, const ViewPortClipping& clipping = ViewPortClipping::AUTO) const noexcept diff --git a/tests/general/unit_test_projection.cpp b/tests/general/unit_test_projection.cpp index 72187d4a..8e54a295 100644 --- a/tests/general/unit_test_projection.cpp +++ b/tests/general/unit_test_projection.cpp @@ -4,6 +4,8 @@ #include "omath/engines/unity_engine/camera.hpp" #include #include +#include +#include #include #include #include @@ -216,4 +218,296 @@ TEST(UnitTestProjection, ScreenToWorldBottomLeftCorner) EXPECT_NEAR(screen_cords->x, initial_screen_cords.x, 0.001f); EXPECT_NEAR(screen_cords->y, initial_screen_cords.y, 0.001f); } +} + +TEST(UnitTestProjection, AabbInsideFrustumNotCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + // Small box directly in front of camera (Source Engine: +X forward, +Y left, +Z up) + const omath::primitives::Aabb aabb{{90.f, -1.f, -1.f}, {110.f, 1.f, 1.f}}; + EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbBehindCameraCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + // Box entirely behind the camera + const omath::primitives::Aabb aabb{{-200.f, -1.f, -1.f}, {-100.f, 1.f, 1.f}}; + EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbBeyondFarPlaneCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + // Box beyond far plane (1000) + const omath::primitives::Aabb aabb{{1500.f, -1.f, -1.f}, {2000.f, 1.f, 1.f}}; + EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbFarLeftCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + // Box far to the side, outside the frustum + const omath::primitives::Aabb aabb{{90.f, 4000.f, -1.f}, {110.f, 5000.f, 1.f}}; + EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbFarRightCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + // Box far to the other side, outside the frustum + const omath::primitives::Aabb aabb{{90.f, -5000.f, -1.f}, {110.f, -4000.f, 1.f}}; + EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbAboveCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + // Box far above the frustum + const omath::primitives::Aabb aabb{{90.f, -1.f, 5000.f}, {110.f, 1.f, 6000.f}}; + EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbPartiallyInsideNotCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + // Large box that straddles the frustum boundary — partially inside + const omath::primitives::Aabb aabb{{50.f, -5000.f, -5000.f}, {500.f, 5000.f, 5000.f}}; + EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbStraddlesNearPlaneNotCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + // Box that straddles the near plane — partially in front + const omath::primitives::Aabb aabb{{-5.f, -1.f, -1.f}, {5.f, 1.f, 1.f}}; + EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbStraddlesFarPlaneNotCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + // Box that straddles the far plane + const omath::primitives::Aabb aabb{{900.f, -1.f, -1.f}, {1100.f, 1.f, 1.f}}; + EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbCulledUnityEngine) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(60.f); + const auto cam = omath::unity_engine::Camera({0, 0, 0}, {}, {1280.f, 720.f}, fov, 0.03f, 1000.f); + + // Box in front — not culled + const omath::primitives::Aabb inside{{-1.f, -1.f, 50.f}, {1.f, 1.f, 100.f}}; + EXPECT_FALSE(cam.is_aabb_culled_by_frustum(inside)); + + // Box behind — culled + const omath::primitives::Aabb behind{{-1.f, -1.f, -200.f}, {1.f, 1.f, -100.f}}; + EXPECT_TRUE(cam.is_aabb_culled_by_frustum(behind)); +} + +TEST(UnitTestProjection, AabbBelowCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + // Box far below the frustum (Source Engine: +Z up) + const omath::primitives::Aabb aabb{{90.f, -1.f, -6000.f}, {110.f, 1.f, -5000.f}}; + EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbEnclosesCameraNotCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + // Huge box that fully encloses the camera + const omath::primitives::Aabb aabb{{-500.f, -500.f, -500.f}, {500.f, 500.f, 500.f}}; + EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbExactlyAtNearPlaneNotCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + // Box starting exactly at the near plane distance + const omath::primitives::Aabb aabb{{0.01f, -1.f, -1.f}, {10.f, 1.f, 1.f}}; + EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbExactlyAtFarPlaneNotCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + // Box ending exactly at the far plane distance + const omath::primitives::Aabb aabb{{990.f, -1.f, -1.f}, {1000.f, 1.f, 1.f}}; + EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbNarrowFovCulledAtEdge) +{ + // Narrow FOV — box that would be visible at 90 is culled at 30 + constexpr auto fov = omath::projection::FieldOfView::from_degrees(30.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + const omath::primitives::Aabb aabb{{100.f, 200.f, -1.f}, {110.f, 210.f, 1.f}}; + EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbWideFovNotCulledAtEdge) +{ + // Wide FOV — same box is visible + constexpr auto fov = omath::projection::FieldOfView::from_degrees(120.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + const omath::primitives::Aabb aabb{{100.f, 200.f, -1.f}, {110.f, 210.f, 1.f}}; + EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbCameraOffOrigin) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::source_engine::Camera({500.f, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, + fov, 0.01f, 1000.f); + + // Box in front of shifted camera + const omath::primitives::Aabb in_front{{600.f, -1.f, -1.f}, {700.f, 1.f, 1.f}}; + EXPECT_FALSE(cam.is_aabb_culled_by_frustum(in_front)); + + // Box behind shifted camera + const omath::primitives::Aabb behind{{0.f, -1.f, -1.f}, {100.f, 1.f, 1.f}}; + EXPECT_TRUE(cam.is_aabb_culled_by_frustum(behind)); +} + +TEST(UnitTestProjection, AabbShortFarPlaneCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + // Very short far plane + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 50.f); + + // Box at distance 100 — beyond the 50-unit far plane + const omath::primitives::Aabb aabb{{90.f, -1.f, -1.f}, {110.f, 1.f, 1.f}}; + EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb)); + + // Box at distance 30 — within range + const omath::primitives::Aabb near_box{{25.f, -1.f, -1.f}, {35.f, 1.f, 1.f}}; + EXPECT_FALSE(cam.is_aabb_culled_by_frustum(near_box)); +} + +TEST(UnitTestProjection, AabbPointSizedInsideNotCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov, + 0.01f, 1000.f); + + // Degenerate zero-volume AABB (a point) inside the frustum + const omath::primitives::Aabb aabb{{100.f, 0.f, 0.f}, {100.f, 0.f, 0.f}}; + EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbOpenGlEngineInsideNotCulled) +{ + // OpenGL: COLUMN_MAJOR, NEGATIVE_ONE_TO_ONE, inverted_z, forward = -Z + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::opengl_engine::Camera({0, 0, 0}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f); + + // Box in front of camera (OpenGL: -Z forward) + const omath::primitives::Aabb aabb{{-1.f, -1.f, -110.f}, {1.f, 1.f, -90.f}}; + EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbOpenGlEngineBehindCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::opengl_engine::Camera({0, 0, 0}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f); + + // Box behind (OpenGL: +Z is behind the camera) + const omath::primitives::Aabb aabb{{-1.f, -1.f, 100.f}, {1.f, 1.f, 200.f}}; + EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbOpenGlEngineBeyondFarCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::opengl_engine::Camera({0, 0, 0}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f); + + // Box beyond far plane along -Z + const omath::primitives::Aabb aabb{{-1.f, -1.f, -2000.f}, {1.f, 1.f, -1500.f}}; + EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbOpenGlEngineSideCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f); + const auto cam = omath::opengl_engine::Camera({0, 0, 0}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f); + + // Box far to the right (OpenGL: +X right) + const omath::primitives::Aabb aabb{{4000.f, -1.f, -110.f}, {5000.f, 1.f, -90.f}}; + EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbUnityEngineBeyondFarCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(60.f); + const auto cam = omath::unity_engine::Camera({0, 0, 0}, {}, {1280.f, 720.f}, fov, 0.03f, 500.f); + + // Box beyond 500-unit far plane (Unity: +Z forward) + const omath::primitives::Aabb aabb{{-1.f, -1.f, 600.f}, {1.f, 1.f, 700.f}}; + EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbUnityEngineSideCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(60.f); + const auto cam = omath::unity_engine::Camera({0, 0, 0}, {}, {1280.f, 720.f}, fov, 0.03f, 1000.f); + + // Box far above (Unity: +Y up) + const omath::primitives::Aabb aabb{{-1.f, 5000.f, 50.f}, {1.f, 6000.f, 100.f}}; + EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb)); +} + +TEST(UnitTestProjection, AabbUnityEngineStraddlesNearNotCulled) +{ + constexpr auto fov = omath::projection::FieldOfView::from_degrees(60.f); + const auto cam = omath::unity_engine::Camera({0, 0, 0}, {}, {1280.f, 720.f}, fov, 0.03f, 1000.f); + + // Box straddles near plane (Unity: +Z forward) + const omath::primitives::Aabb aabb{{-1.f, -1.f, -5.f}, {1.f, 1.f, 5.f}}; + EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb)); } \ No newline at end of file