diff --git a/.idea/omath.iml b/.idea/omath.iml new file mode 100644 index 00000000..4c942354 --- /dev/null +++ b/.idea/omath.iml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/include/omath/3d_primitives/obb.hpp b/include/omath/3d_primitives/obb.hpp new file mode 100644 index 00000000..97a715e0 --- /dev/null +++ b/include/omath/3d_primitives/obb.hpp @@ -0,0 +1,37 @@ +// +// Created by Vladislav on 07.05.2026. +// + +#pragma once +#include "omath/linear_algebra/vector3.hpp" +#include +#include + +namespace omath::primitives +{ + // Oriented bounding box: a rectangular cuboid defined by a center, three + // orthonormal local axes, and the half-size along each of those axes. + template + requires std::is_floating_point_v + struct Obb final + { + Vector3 center; + Vector3 axis_x; + Vector3 axis_y; + Vector3 axis_z; + Vector3 half_extents; + + [[nodiscard]] + constexpr std::array, 8> vertices() const noexcept + { + const auto ex = axis_x * half_extents.x; + const auto ey = axis_y * half_extents.y; + const auto ez = axis_z * half_extents.z; + + return { + center - ex - ey - ez, center + ex - ey - ez, center - ex + ey - ez, center + ex + ey - ez, + center - ex - ey + ez, center + ex - ey + ez, center - ex + ey + ez, center + ex + ey + ez, + }; + } + }; +} // namespace omath::primitives diff --git a/include/omath/projection/camera.hpp b/include/omath/projection/camera.hpp index 28e97035..f5c66b67 100644 --- a/include/omath/projection/camera.hpp +++ b/include/omath/projection/camera.hpp @@ -5,6 +5,7 @@ #pragma once #include "omath/3d_primitives/aabb.hpp" +#include "omath/3d_primitives/obb.hpp" #include "omath/linear_algebra/mat.hpp" #include "omath/linear_algebra/triangle.hpp" #include "omath/linear_algebra/vector3.hpp" @@ -380,49 +381,9 @@ namespace omath::projection [[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 - { - NumericType 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) + for (const auto& [a, b, c, d] : extract_frustum_planes()) { const auto px = a >= NumericType{0} ? aabb.max.x : aabb.min.x; const auto py = b >= NumericType{0} ? aabb.max.y : aabb.min.y; @@ -435,6 +396,26 @@ namespace omath::projection return false; } + [[nodiscard]] bool is_obb_culled_by_frustum(const primitives::Obb& obb) const noexcept + { + // For each plane, project the OBB extents onto the plane normal to get the + // effective radius, then test the center's signed distance against it. + for (const auto& [a, b, c, d] : extract_frustum_planes()) + { + const Vector3 normal{a, b, c}; + + const auto center_distance = normal.dot(obb.center) + d; + const auto radius = obb.half_extents.x * std::abs(normal.dot(obb.axis_x)) + + obb.half_extents.y * std::abs(normal.dot(obb.axis_y)) + + obb.half_extents.z * std::abs(normal.dot(obb.axis_z)); + + if (center_distance + radius < NumericType{0}) + return true; + } + + return false; + } + [[nodiscard]] std::expected, Error> world_to_view_port(const Vector3& world_position, const ViewPortClipping& clipping = ViewPortClipping::AUTO) const noexcept @@ -517,6 +498,51 @@ namespace omath::projection Vector3 m_origin; private: + struct FrustumPlane final + { + NumericType a, b, c, d; + }; + + // 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 + [[nodiscard]] std::array extract_frustum_planes() const noexcept + { + const auto& m = get_view_projection_matrix(); + + const auto extract_plane = [&m](const int sign, const int row) -> FrustumPlane + { + 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); + + return planes; + } + template [[nodiscard]] constexpr static bool is_ndc_out_of_bounds(const Type& ndc) noexcept { diff --git a/tests/general/unit_test_obb.cpp b/tests/general/unit_test_obb.cpp new file mode 100644 index 00000000..5b4ac14a --- /dev/null +++ b/tests/general/unit_test_obb.cpp @@ -0,0 +1,301 @@ +// +// Created by Vladislav on 07.05.2026. +// +#include +#include +#include +#include +#include +#include +#include +#include + +using ObbF = omath::primitives::Obb; +using ObbD = omath::primitives::Obb; +using Vec3F = omath::Vector3; +using Vec3D = omath::Vector3; + +namespace +{ + constexpr ObbF axis_aligned_obb(const Vec3F& center, const Vec3F& half_extents) noexcept + { + return ObbF{center, {1.f, 0.f, 0.f}, {0.f, 1.f, 0.f}, {0.f, 0.f, 1.f}, half_extents}; + } + + ObbF rotated_around_z(const Vec3F& center, const Vec3F& half_extents, const float radians) noexcept + { + const auto c = std::cos(radians); + const auto s = std::sin(radians); + return ObbF{center, {c, s, 0.f}, {-s, c, 0.f}, {0.f, 0.f, 1.f}, half_extents}; + } + + ObbF rotated_around_y(const Vec3F& center, const Vec3F& half_extents, const float radians) noexcept + { + const auto c = std::cos(radians); + const auto s = std::sin(radians); + return ObbF{center, {c, 0.f, -s}, {0.f, 1.f, 0.f}, {s, 0.f, c}, half_extents}; + } +} // namespace + +// --- struct-level tests --- + +TEST(ObbTests, VerticesOfAxisAlignedUnitBox) +{ + constexpr auto box = axis_aligned_obb({0.f, 0.f, 0.f}, {1.f, 1.f, 1.f}); + const auto v = box.vertices(); + + EXPECT_EQ(v[0], (Vec3F{-1.f, -1.f, -1.f})); + EXPECT_EQ(v[1], (Vec3F{1.f, -1.f, -1.f})); + EXPECT_EQ(v[2], (Vec3F{-1.f, 1.f, -1.f})); + EXPECT_EQ(v[3], (Vec3F{1.f, 1.f, -1.f})); + EXPECT_EQ(v[4], (Vec3F{-1.f, -1.f, 1.f})); + EXPECT_EQ(v[5], (Vec3F{1.f, -1.f, 1.f})); + EXPECT_EQ(v[6], (Vec3F{-1.f, 1.f, 1.f})); + EXPECT_EQ(v[7], (Vec3F{1.f, 1.f, 1.f})); +} + +TEST(ObbTests, VerticesOfTranslatedBox) +{ + constexpr auto box = axis_aligned_obb({10.f, 20.f, 30.f}, {1.f, 2.f, 3.f}); + const auto v = box.vertices(); + + EXPECT_EQ(v[0], (Vec3F{9.f, 18.f, 27.f})); + EXPECT_EQ(v[7], (Vec3F{11.f, 22.f, 33.f})); +} + +TEST(ObbTests, VerticesOfRotatedBox) +{ + constexpr auto pi = std::numbers::pi_v; + const auto box = rotated_around_z({0.f, 0.f, 0.f}, {1.f, 1.f, 1.f}, pi / 2.f); + const auto v = box.vertices(); + + // After 90° rotation around Z, local +X maps to world +Y, local +Y maps to world -X. + // The eight vertices are still the same eight points (a cube is symmetric), but their + // ordering changes. Check that the corner set as a whole is still |coord| == 1. + for (const auto& corner : v) + { + EXPECT_NEAR(std::abs(corner.x), 1.f, 1e-5f); + EXPECT_NEAR(std::abs(corner.y), 1.f, 1e-5f); + EXPECT_NEAR(std::abs(corner.z), 1.f, 1e-5f); + } +} + +TEST(ObbTests, DoublePrecisionInstantiation) +{ + constexpr ObbD box{{0.0, 0.0, 0.0}, {1.0, 0.0, 0.0}, {0.0, 1.0, 0.0}, {0.0, 0.0, 1.0}, {2.0, 3.0, 4.0}}; + const auto v = box.vertices(); + EXPECT_DOUBLE_EQ(v[0].x, -2.0); + EXPECT_DOUBLE_EQ(v[0].y, -3.0); + EXPECT_DOUBLE_EQ(v[0].z, -4.0); + EXPECT_DOUBLE_EQ(v[7].x, 2.0); + EXPECT_DOUBLE_EQ(v[7].y, 3.0); + EXPECT_DOUBLE_EQ(v[7].z, 4.0); +} + +// --- frustum culling tests (Source Engine: +X forward, +Y left, +Z up) --- + +TEST(ObbTests, AxisAlignedInFrontNotCulled) +{ + 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); + + const auto obb = axis_aligned_obb({100.f, 0.f, 0.f}, {10.f, 1.f, 1.f}); + EXPECT_FALSE(cam.is_obb_culled_by_frustum(obb)); +} + +TEST(ObbTests, AxisAlignedBehindCameraCulled) +{ + 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); + + const auto obb = axis_aligned_obb({-150.f, 0.f, 0.f}, {50.f, 1.f, 1.f}); + EXPECT_TRUE(cam.is_obb_culled_by_frustum(obb)); +} + +TEST(ObbTests, AxisAlignedBeyondFarPlaneCulled) +{ + 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); + + const auto obb = axis_aligned_obb({1750.f, 0.f, 0.f}, {250.f, 1.f, 1.f}); + EXPECT_TRUE(cam.is_obb_culled_by_frustum(obb)); +} + +TEST(ObbTests, AxisAlignedFarLeftCulled) +{ + 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); + + const auto obb = axis_aligned_obb({100.f, 4500.f, 0.f}, {10.f, 500.f, 1.f}); + EXPECT_TRUE(cam.is_obb_culled_by_frustum(obb)); +} + +TEST(ObbTests, AxisAlignedFarRightCulled) +{ + 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); + + const auto obb = axis_aligned_obb({100.f, -4500.f, 0.f}, {10.f, 500.f, 1.f}); + EXPECT_TRUE(cam.is_obb_culled_by_frustum(obb)); +} + +TEST(ObbTests, AxisAlignedAboveCulled) +{ + 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); + + const auto obb = axis_aligned_obb({100.f, 0.f, 5500.f}, {10.f, 1.f, 500.f}); + EXPECT_TRUE(cam.is_obb_culled_by_frustum(obb)); +} + +TEST(ObbTests, AxisAlignedBelowCulled) +{ + 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); + + const auto obb = axis_aligned_obb({100.f, 0.f, -5500.f}, {10.f, 1.f, 500.f}); + EXPECT_TRUE(cam.is_obb_culled_by_frustum(obb)); +} + +TEST(ObbTests, MatchesAabbForAxisAlignedBox) +{ + // For axis-aligned OBBs, the result must agree with is_aabb_culled_by_frustum. + 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); + + const struct + { + Vec3F center; + Vec3F half; + } cases[] = { + {{100.f, 0.f, 0.f}, {10.f, 1.f, 1.f}}, // in front + {{-150.f, 0.f, 0.f}, {50.f, 1.f, 1.f}}, // behind + {{1750.f, 0.f, 0.f}, {250.f, 1.f, 1.f}}, // beyond far + {{100.f, 4500.f, 0.f}, {10.f, 500.f, 1.f}}, // far left + {{0.f, 0.f, 0.f}, {500.f, 500.f, 500.f}}, // encloses camera + {{275.f, 0.f, 0.f}, {225.f, 1.f, 1.f}}, // straddles near + }; + + for (const auto& [center, half]: cases) + { + const omath::primitives::Aabb aabb{center - half, center + half}; + const auto obb = axis_aligned_obb(center, half); + EXPECT_EQ(cam.is_obb_culled_by_frustum(obb), cam.is_aabb_culled_by_frustum(aabb)) + << "mismatch for center (" << center.x << "," << center.y << "," << center.z << ")"; + } +} + +TEST(ObbTests, RotationCanPullBoxIntoFrustum) +{ + // Tall thin column sitting just outside the +Y frustum boundary at X=50. + // Axis-aligned: every corner has Y≈100 at X≈50, all outside the +Y plane → culled. + // Rotated 90° around world Y: the 50-unit extent now points along world +X, so the rod + // sweeps forward to X≈100 where the +Y plane is far more permissive — front end inside, + // box no longer fully outside → not culled. + 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); + + const Vec3F center{50.f, 100.f, 0.f}; + const Vec3F half{1.f, 1.f, 50.f}; + + const auto axis_aligned = axis_aligned_obb(center, half); + EXPECT_TRUE(cam.is_obb_culled_by_frustum(axis_aligned)); + + const auto rotated = rotated_around_y(center, half, std::numbers::pi_v / 2.f); + EXPECT_FALSE(cam.is_obb_culled_by_frustum(rotated)); +} + +TEST(ObbTests, RotationCanPushBoxOutOfFrustum) +{ + // Long forward-pointing rod whose front end pokes into the frustum near the +Y boundary. + // Axis-aligned (long along X): the front end at X≈100 has Y=129 just inside the +Y plane, + // so part of the rod is visible → not culled. + // Rotated 90° around Z: the rod's long axis now points along world Y, so all corners + // shift to Y∈[80,180] at X≈50 — every corner is outside the +Y plane → culled. + 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); + + const Vec3F center{50.f, 130.f, 0.f}; + const Vec3F half{50.f, 1.f, 1.f}; + + const auto axis_aligned = axis_aligned_obb(center, half); + EXPECT_FALSE(cam.is_obb_culled_by_frustum(axis_aligned)); + + const auto rotated = rotated_around_z(center, half, std::numbers::pi_v / 2.f); + EXPECT_TRUE(cam.is_obb_culled_by_frustum(rotated)); +} + +TEST(ObbTests, RotatedBoxStraddlingFrustumNotCulled) +{ + 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 centred in front, rotated 30° — clearly straddles into the frustum. + const auto obb = rotated_around_z({200.f, 0.f, 0.f}, {50.f, 50.f, 50.f}, + std::numbers::pi_v / 6.f); + EXPECT_FALSE(cam.is_obb_culled_by_frustum(obb)); +} + +TEST(ObbTests, OpenGlEngineRotatedInFrontNotCulled) +{ + // OpenGL: -Z forward, COLUMN_MAJOR, NEGATIVE_ONE_TO_ONE + 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); + + const auto obb = rotated_around_z({0.f, 0.f, -100.f}, {5.f, 5.f, 5.f}, + std::numbers::pi_v / 4.f); + EXPECT_FALSE(cam.is_obb_culled_by_frustum(obb)); +} + +TEST(ObbTests, OpenGlEngineBehindCulled) +{ + 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); + + const auto obb = axis_aligned_obb({0.f, 0.f, 100.f}, {5.f, 5.f, 5.f}); + EXPECT_TRUE(cam.is_obb_culled_by_frustum(obb)); +} + +TEST(ObbTests, UnityEngineBeyondFarCulled) +{ + 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); + + const auto obb = rotated_around_z({0.f, 0.f, 700.f}, {5.f, 5.f, 5.f}, + std::numbers::pi_v / 4.f); + EXPECT_TRUE(cam.is_obb_culled_by_frustum(obb)); +} + +TEST(ObbTests, DegenerateZeroVolumeInsideNotCulled) +{ + 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); + + // Zero-extent OBB — collapses to a point, but still must not be culled if the centre is inside. + const auto obb = axis_aligned_obb({100.f, 0.f, 0.f}, {0.f, 0.f, 0.f}); + EXPECT_FALSE(cam.is_obb_culled_by_frustum(obb)); +} + +TEST(ObbTests, EnclosingCameraNotCulled) +{ + 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 rotated box that fully encloses the camera origin. + const auto obb = rotated_around_z({0.f, 0.f, 0.f}, {500.f, 500.f, 500.f}, + std::numbers::pi_v / 5.f); + EXPECT_FALSE(cam.is_obb_culled_by_frustum(obb)); +}