diff --git a/.idea/editor.xml b/.idea/editor.xml
index 9b5acf8..bce786e 100644
--- a/.idea/editor.xml
+++ b/.idea/editor.xml
@@ -103,7 +103,7 @@
-
+
@@ -202,7 +202,7 @@
-
+
@@ -216,7 +216,7 @@
-
+
diff --git a/include/omath/projection/camera.hpp b/include/omath/projection/camera.hpp
index 801dd43..57a56fa 100644
--- a/include/omath/projection/camera.hpp
+++ b/include/omath/projection/camera.hpp
@@ -83,6 +83,12 @@ namespace omath::projection
{
}
+ [[nodiscard]]
+ static ViewAnglesType calc_view_angles_from_view_matrix(const Mat4X4Type& view_matrix) noexcept
+ {
+ const Vector3 forward_vector = {view_matrix[2, 0], view_matrix[2, 1], view_matrix[2, 2]};
+ return TraitClass::calc_look_at_angle({}, forward_vector);
+ }
void look_at(const Vector3& target)
{
m_view_angles = TraitClass::calc_look_at_angle(m_origin, target);
diff --git a/tests/general/unit_test_projection.cpp b/tests/general/unit_test_projection.cpp
index 8e54a29..a9ffe83 100644
--- a/tests/general/unit_test_projection.cpp
+++ b/tests/general/unit_test_projection.cpp
@@ -510,4 +510,90 @@ TEST(UnitTestProjection, AabbUnityEngineStraddlesNearNotCulled)
// 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));
+}
+
+TEST(UnitTestProjection, CalcViewAnglesFromViewMatrix_LookingForward)
+{
+ constexpr float k_eps = 1e-4f;
+ constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
+ const omath::source_engine::ViewAngles angles{
+ omath::source_engine::PitchAngle::from_degrees(0.f),
+ omath::source_engine::YawAngle::from_degrees(0.f),
+ omath::source_engine::RollAngle::from_degrees(0.f)
+ };
+ const auto cam = omath::source_engine::Camera({0, 0, 0}, angles, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
+
+ const auto result = omath::source_engine::Camera::calc_view_angles_from_view_matrix(cam.get_view_matrix());
+
+ EXPECT_NEAR(result.pitch.as_degrees(), 0.f, k_eps);
+ EXPECT_NEAR(result.yaw.as_degrees(), 0.f, k_eps);
+}
+
+TEST(UnitTestProjection, CalcViewAnglesFromViewMatrix_PositivePitchAndYaw)
+{
+ constexpr float k_eps = 1e-4f;
+ constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
+ const omath::source_engine::ViewAngles angles{
+ omath::source_engine::PitchAngle::from_degrees(30.f),
+ omath::source_engine::YawAngle::from_degrees(45.f),
+ omath::source_engine::RollAngle::from_degrees(0.f)
+ };
+ const auto cam = omath::source_engine::Camera({0, 0, 0}, angles, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
+
+ const auto result = omath::source_engine::Camera::calc_view_angles_from_view_matrix(cam.get_view_matrix());
+
+ EXPECT_NEAR(result.pitch.as_degrees(), 30.f, k_eps);
+ EXPECT_NEAR(result.yaw.as_degrees(), 45.f, k_eps);
+}
+
+TEST(UnitTestProjection, CalcViewAnglesFromViewMatrix_NegativePitchAndYaw)
+{
+ constexpr float k_eps = 1e-4f;
+ constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
+ const omath::source_engine::ViewAngles angles{
+ omath::source_engine::PitchAngle::from_degrees(-45.f),
+ omath::source_engine::YawAngle::from_degrees(-90.f),
+ omath::source_engine::RollAngle::from_degrees(0.f)
+ };
+ const auto cam = omath::source_engine::Camera({0, 0, 0}, angles, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
+
+ const auto result = omath::source_engine::Camera::calc_view_angles_from_view_matrix(cam.get_view_matrix());
+
+ EXPECT_NEAR(result.pitch.as_degrees(), -45.f, k_eps);
+ EXPECT_NEAR(result.yaw.as_degrees(), -90.f, k_eps);
+}
+
+TEST(UnitTestProjection, CalcViewAnglesFromViewMatrix_OffOriginCameraIgnored)
+{
+ // The forward vector from the view matrix does not depend on camera origin,
+ // so the same angles should be recovered regardless of position.
+ constexpr float k_eps = 1e-4f;
+ constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
+ const omath::source_engine::ViewAngles angles{
+ omath::source_engine::PitchAngle::from_degrees(20.f),
+ omath::source_engine::YawAngle::from_degrees(60.f),
+ omath::source_engine::RollAngle::from_degrees(0.f)
+ };
+ const auto cam = omath::source_engine::Camera({100.f, 200.f, -50.f}, angles, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
+
+ const auto result = omath::source_engine::Camera::calc_view_angles_from_view_matrix(cam.get_view_matrix());
+
+ EXPECT_NEAR(result.pitch.as_degrees(), 20.f, k_eps);
+ EXPECT_NEAR(result.yaw.as_degrees(), 60.f, k_eps);
+}
+
+TEST(UnitTestProjection, CalcViewAnglesFromViewMatrix_RollAlwaysZero)
+{
+ // Roll cannot be encoded in the forward vector, so it is always 0 in the result.
+ constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
+ const omath::source_engine::ViewAngles angles{
+ omath::source_engine::PitchAngle::from_degrees(10.f),
+ omath::source_engine::YawAngle::from_degrees(30.f),
+ omath::source_engine::RollAngle::from_degrees(15.f)
+ };
+ const auto cam = omath::source_engine::Camera({0, 0, 0}, angles, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
+
+ const auto result = omath::source_engine::Camera::calc_view_angles_from_view_matrix(cam.get_view_matrix());
+
+ EXPECT_FLOAT_EQ(result.roll.as_degrees(), 0.f);
}
\ No newline at end of file