Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions include/omath/3d_primitives/aabb.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// Created by Vladislav on 24.03.2026.
//

#pragma once
#include "omath/linear_algebra/vector3.hpp"

namespace omath::primitives
{
template<class Type>
struct Aabb final
{
Vector3<Type> min;
Vector3<Type> max;
};
} // namespace omath::primitives
58 changes: 58 additions & 0 deletions include/omath/projection/camera.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -308,6 +309,63 @@ namespace omath::projection
return false;
}

[[nodiscard]] bool is_aabb_culled_by_frustum(const primitives::Aabb<float>& aabb) const noexcept
{
Comment on lines +312 to +313
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API naming is inconsistent: this introduces is_aabb_culled_by_frustum(...) while an existing overload uses is_culled_by_frustum(...) for triangles. Consider making this an overload named is_culled_by_frustum(const primitives::Aabb<float>&) (or otherwise aligning the naming) to improve discoverability and consistency.

Copilot uses AI. Check for mistakes.
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<float>(sign) * m.at(row, 0),
m.at(3, 1) + static_cast<float>(sign) * m.at(row, 1),
m.at(3, 2) + static_cast<float>(sign) * m.at(row, 2),
m.at(3, 3) + static_cast<float>(sign) * m.at(row, 3),
};
};

std::array<Plane, 6> 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<Vector3<float>, Error>
world_to_view_port(const Vector3<float>& world_position,
const ViewPortClipping& clipping = ViewPortClipping::AUTO) const noexcept
Expand Down
294 changes: 294 additions & 0 deletions tests/general/unit_test_projection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
#include "omath/engines/unity_engine/camera.hpp"
#include <complex>
#include <gtest/gtest.h>
#include <omath/3d_primitives/aabb.hpp>
#include <omath/engines/opengl_engine/camera.hpp>
#include <omath/engines/source_engine/camera.hpp>
#include <omath/projection/camera.hpp>
#include <print>
Expand Down Expand Up @@ -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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> inside{{-1.f, -1.f, 50.f}, {1.f, 1.f, 100.f}};
EXPECT_FALSE(cam.is_aabb_culled_by_frustum(inside));

Comment on lines +322 to +330
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name is misleading: AabbCulledUnityEngine contains both an "inside" (not culled) assertion and a "behind" (culled) assertion. Consider renaming it to reflect both behaviors or splitting into two focused tests so failures are easier to interpret.

Copilot uses AI. Check for mistakes.
// Box behind — culled
const omath::primitives::Aabb<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> 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<float> aabb{{-1.f, 5000.f, 50.f}, {1.f, 6000.f, 100.f}};
Comment on lines +495 to +501
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AabbUnityEngineSideCulled appears to be testing an AABB far above the frustum (per the comment and Y values), not a generic "side" cull. Renaming the test to match what it checks (e.g., above/top culling) would reduce confusion when reading failures.

Copilot uses AI. Check for mistakes.
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<float> aabb{{-1.f, -1.f, -5.f}, {1.f, 1.f, 5.f}};
EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb));
}
Loading