diff --git a/include/omath/projection/camera.hpp b/include/omath/projection/camera.hpp index f0b0a72..6356173 100644 --- a/include/omath/projection/camera.hpp +++ b/include/omath/projection/camera.hpp @@ -36,7 +36,11 @@ namespace omath::projection } }; using FieldOfView = Angle; - + enum class ViewPortClipping + { + AUTO, + MANUAL, + }; template concept CameraEngineConcept = requires(const Vector3& cam_origin, const Vector3& look_at, const ViewAnglesType& angles, @@ -222,7 +226,7 @@ namespace omath::projection [[nodiscard]] std::expected, Error> not_clip_world_to_screen(const Vector3& world_position) const noexcept { - const auto normalized_cords = world_to_view_port(world_position, false); + const auto normalized_cords = world_to_view_port(world_position, ViewPortClipping::MANUAL); if (!normalized_cords.has_value()) return std::unexpected{normalized_cords.error()}; @@ -283,7 +287,8 @@ namespace omath::projection } [[nodiscard]] std::expected, Error> - world_to_view_port(const Vector3& world_position, const bool auto_clip = true) const noexcept + world_to_view_port(const Vector3& world_position, + const ViewPortClipping& clipping = ViewPortClipping::AUTO) const noexcept { auto projected = get_view_projection_matrix() * mat_column_from_vector(world_position); @@ -294,7 +299,7 @@ namespace omath::projection projected /= w; - if (auto_clip && is_ndc_out_of_bounds(projected)) + if (clipping == ViewPortClipping::MANUAL && is_ndc_out_of_bounds(projected)) return std::unexpected(Error::WORLD_POSITION_IS_OUT_OF_SCREEN_BOUNDS); return Vector3{projected.at(0, 0), projected.at(1, 0), projected.at(2, 0)}; diff --git a/tests/general/unit_test_projection.cpp b/tests/general/unit_test_projection.cpp index f507b01..5828bef 100644 --- a/tests/general/unit_test_projection.cpp +++ b/tests/general/unit_test_projection.cpp @@ -50,6 +50,127 @@ TEST(UnitTestProjection, ScreenToNdcBottomLeft) EXPECT_NEAR(ndc_bottom_left.y, 0.519615293f, 0.0001f); } +TEST(UnitTestProjection, NotClipWorldToScreenInBounds) +{ + constexpr auto fov = omath::Angle::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 projected = cam.not_clip_world_to_screen({1000.f, 0, 50.f}); + ASSERT_TRUE(projected.has_value()); + EXPECT_NEAR(projected->x, 960.f, 0.001f); + EXPECT_NEAR(projected->y, 504.f, 0.001f); +} + +TEST(UnitTestProjection, NotClipWorldToScreenMatchesWorldToScreenWhenInBounds) +{ + constexpr auto fov = omath::Angle::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 w2s = cam.world_to_screen({1000.f, 0, 50.f}); + const auto no_clip = cam.not_clip_world_to_screen({1000.f, 0, 50.f}); + + ASSERT_TRUE(w2s.has_value()); + ASSERT_TRUE(no_clip.has_value()); + EXPECT_NEAR(w2s->x, no_clip->x, 0.001f); + EXPECT_NEAR(w2s->y, no_clip->y, 0.001f); + EXPECT_NEAR(w2s->z, no_clip->z, 0.001f); +} + +TEST(UnitTestProjection, NotClipWorldToScreenRejectsBehindCamera) +{ + constexpr auto fov = omath::Angle::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 projected = cam.not_clip_world_to_screen({-1000.f, 0, 0}); + EXPECT_FALSE(projected.has_value()); + EXPECT_EQ(projected.error(), omath::projection::Error::WORLD_POSITION_IS_OUT_OF_SCREEN_BOUNDS); +} + +TEST(UnitTestProjection, NotClipWorldToScreenRejectsOutOfBoundsNdc) +{ + constexpr auto fov = omath::Angle::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); + + // Point far to the side should exceed NDC [-1,1] bounds + const auto projected = cam.not_clip_world_to_screen({100.f, 5000.f, 0}); + EXPECT_FALSE(projected.has_value()); + EXPECT_EQ(projected.error(), omath::projection::Error::WORLD_POSITION_IS_OUT_OF_SCREEN_BOUNDS); +} + +TEST(UnitTestProjection, WorldToScreenAllowsOutOfBoundsNdc) +{ + constexpr auto fov = omath::Angle::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); + + // Same point that not_clip rejects should succeed with world_to_screen + const auto projected = cam.world_to_screen({100.f, 5000.f, 0}); + EXPECT_TRUE(projected.has_value()); +} + +TEST(UnitTestProjection, NotClipWorldToScreenBottomLeftCorner) +{ + constexpr auto fov = omath::Angle::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); + using ScreenStart = omath::source_engine::Camera::ScreenStart; + + const auto top_left = cam.not_clip_world_to_screen({1000.f, 0, 50.f}); + const auto bottom_left = cam.not_clip_world_to_screen({1000.f, 0, 50.f}); + + ASSERT_TRUE(top_left.has_value()); + ASSERT_TRUE(bottom_left.has_value()); + // X should be identical, Y should differ (mirrored around center) + EXPECT_NEAR(top_left->x, bottom_left->x, 0.001f); + EXPECT_NEAR(top_left->y + bottom_left->y, 1080.f, 0.001f); +} + +TEST(UnitTestProjection, NotClipWorldToScreenRoundTrip) +{ + std::mt19937 gen(42); + std::uniform_real_distribution dist_fwd(100.f, 900.f); + std::uniform_real_distribution dist_side(-400.f, 400.f); + std::uniform_real_distribution dist_up(-200.f, 200.f); + + constexpr auto fov = omath::Angle::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); + + for (int i = 0; i < 100; i++) + { + const omath::Vector3 world_pos{dist_fwd(gen), dist_side(gen), dist_up(gen)}; + const auto screen = cam.not_clip_world_to_screen(world_pos); + if (!screen.has_value()) + continue; + + const auto back_to_world = cam.screen_to_world(screen.value()); + ASSERT_TRUE(back_to_world.has_value()); + + const auto back_to_screen = cam.not_clip_world_to_screen(back_to_world.value()); + ASSERT_TRUE(back_to_screen.has_value()); + + EXPECT_NEAR(screen->x, back_to_screen->x, 0.01f); + EXPECT_NEAR(screen->y, back_to_screen->y, 0.01f); + } +} + +TEST(UnitTestProjection, NotClipWorldToScreenUnityEngine) +{ + 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); + using ScreenStart = omath::unity_engine::Camera::ScreenStart; + + // Point directly in front + const auto projected = cam.not_clip_world_to_screen({0, 0, 500.f}); + ASSERT_TRUE(projected.has_value()); + EXPECT_NEAR(projected->x, 640.f, 0.5f); + EXPECT_NEAR(projected->y, 360.f, 0.5f); +} + TEST(UnitTestProjection, ScreenToWorldTopLeftCorner) { std::mt19937 gen(std::random_device{}()); // Seed with a non-deterministic source