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));
+}