From 5b9bc0f3f80738920b8294c3fc28e8d2f3862a9a Mon Sep 17 00:00:00 2001 From: Orange Date: Sun, 19 Jul 2026 16:18:29 +0300 Subject: [PATCH] improved prediction --- benchmark/benchmark_projectile_pred.cpp | 113 +++++++++++++++--- .../traits/pred_engine_trait.hpp | 57 +++++++-- .../proj_pred_engine_legacy.hpp | 98 ++++++++++----- tests/general/unit_test_pred_engine_trait.cpp | 72 +++++++++++ ...unit_test_proj_pred_engine_legacy_more.cpp | 111 +++++++++++++++-- 5 files changed, 388 insertions(+), 63 deletions(-) diff --git a/benchmark/benchmark_projectile_pred.cpp b/benchmark/benchmark_projectile_pred.cpp index 208a6c8..43c39ab 100644 --- a/benchmark/benchmark_projectile_pred.cpp +++ b/benchmark/benchmark_projectile_pred.cpp @@ -2,22 +2,105 @@ // Created by Vlad on 9/18/2025. // #include -#include -using namespace omath; +#include -using namespace omath::projectile_prediction; - -constexpr float simulation_time_step = 1.f / 1000.f; -constexpr float hit_distance_tolerance = 5.f; - -void source_engine_projectile_prediction(benchmark::State& state) +namespace { - constexpr Target target{.m_origin = {100, 0, 90}, .m_velocity = {0, 0, 0}, .m_is_airborne = false}; - constexpr Projectile projectile = {.m_origin = {3, 2, 1}, .m_launch_speed = 5000.f, .m_gravity_scale = 0.4f}; + using Engine = omath::projectile_prediction::ProjPredEngineLegacy<>; + using Projectile = omath::projectile_prediction::Projectile; + using Target = omath::projectile_prediction::Target; - for ([[maybe_unused]] const auto _: state) - std::ignore = ProjPredEngineLegacy<>(400.f, simulation_time_step, 50.f, hit_distance_tolerance) - .maybe_calculate_aim_point(projectile, target); -} + struct PredictionScenario + { + Projectile projectile; + Target target; + float gravity; + float simulation_time_step; + float maximum_simulation_time; + float distance_tolerance; + bool expects_solution; + }; -BENCHMARK(source_engine_projectile_prediction)->Iterations(10'000); \ No newline at end of file + void run_prediction_benchmark(benchmark::State& state, const PredictionScenario& scenario) + { + const Engine engine(scenario.gravity, scenario.simulation_time_step, scenario.maximum_simulation_time, + scenario.distance_tolerance); + auto projectile = scenario.projectile; + auto target = scenario.target; + + if (engine.maybe_calculate_aim_point(projectile, target).has_value() != scenario.expects_solution) + { + state.SkipWithError("Projectile benchmark scenario returned an unexpected result"); + return; + } + + for ([[maybe_unused]] const auto _ : state) + { + benchmark::DoNotOptimize(projectile); + benchmark::DoNotOptimize(target); + auto result = engine.maybe_calculate_aim_point(projectile, target); + benchmark::DoNotOptimize(result); + } + } + + void projectile_prediction_near_static_hit(benchmark::State& state) + { + constexpr PredictionScenario scenario{ + .projectile = {.m_origin = {3.f, 2.f, 1.f}, .m_launch_speed = 5000.f, .m_gravity_scale = 0.4f}, + .target = {.m_origin = {100.f, 0.f, 90.f}, .m_velocity = {0.f, 0.f, 0.f}, .m_is_airborne = false}, + .gravity = 400.f, + .simulation_time_step = 1.f / 1000.f, + .maximum_simulation_time = 50.f, + .distance_tolerance = 5.f, + .expects_solution = true, + }; + run_prediction_benchmark(state, scenario); + } + + void projectile_prediction_moving_hit(benchmark::State& state) + { + constexpr PredictionScenario scenario{ + .projectile = {.m_origin = {0.f, 0.f, 0.f}, .m_launch_speed = 3000.f, .m_gravity_scale = 1.f}, + .target = {.m_origin = {500.f, 100.f, 0.f}, .m_velocity = {-50.f, 20.f, 0.f}, .m_is_airborne = false}, + .gravity = 800.f, + .simulation_time_step = 1.f / 500.f, + .maximum_simulation_time = 30.f, + .distance_tolerance = 10.f, + .expects_solution = true, + }; + run_prediction_benchmark(state, scenario); + } + + void projectile_prediction_unreachable_full_scan(benchmark::State& state) + { + constexpr PredictionScenario scenario{ + .projectile = {.m_origin = {0.f, 0.f, 0.f}, .m_launch_speed = 1.f, .m_gravity_scale = 1.f}, + .target = {.m_origin = {100'000.f, 0.f, 0.f}, .m_velocity = {0.f, 0.f, 0.f}, .m_is_airborne = false}, + .gravity = 9.81f, + .simulation_time_step = 1.f / 1000.f, + .maximum_simulation_time = 2.f, + .distance_tolerance = 5.f, + .expects_solution = false, + }; + run_prediction_benchmark(state, scenario); + } + + void projectile_prediction_receding_full_scan(benchmark::State& state) + { + constexpr PredictionScenario scenario{ + .projectile = {.m_origin = {0.f, 0.f, 0.f}, .m_launch_speed = 100.f, .m_gravity_scale = 0.f}, + .target = {.m_origin = {100.f, 0.f, 0.f}, .m_velocity = {200.f, 0.f, 0.f}, .m_is_airborne = false}, + .gravity = 9.81f, + .simulation_time_step = 1.f / 1000.f, + .maximum_simulation_time = 2.f, + .distance_tolerance = 0.01f, + .expects_solution = false, + }; + run_prediction_benchmark(state, scenario); + } +} // namespace + +BENCHMARK(projectile_prediction_near_static_hit); +BENCHMARK(projectile_prediction_moving_hit); +BENCHMARK(projectile_prediction_unreachable_full_scan); +BENCHMARK(projectile_prediction_receding_full_scan); diff --git a/include/omath/engines/source_engine/traits/pred_engine_trait.hpp b/include/omath/engines/source_engine/traits/pred_engine_trait.hpp index 3fa8dcd..e4d9c7e 100644 --- a/include/omath/engines/source_engine/traits/pred_engine_trait.hpp +++ b/include/omath/engines/source_engine/traits/pred_engine_trait.hpp @@ -6,6 +6,8 @@ #include "omath/engines/source_engine/formulas.hpp" #include "omath/projectile_prediction/projectile.hpp" #include "omath/projectile_prediction/target.hpp" +#include +#include #include namespace omath::source_engine @@ -14,19 +16,58 @@ namespace omath::source_engine { public: [[nodiscard("projectile position result should not be discarded")]] - constexpr static Vector3 predict_projectile_position(const projectile_prediction::Projectile& projectile, - const float pitch, const float yaw, - const float time, const float gravity) noexcept + constexpr static Vector3 + predict_projectile_position(const projectile_prediction::Projectile& projectile, const float pitch, + const float yaw, const float time, const float gravity) noexcept { const auto launch_pos = projectile.m_origin + projectile.m_launch_offset; - auto current_pos = launch_pos - + forward_vector({PitchAngle::from_degrees(-pitch), YawAngle::from_degrees(yaw), - RollAngle::from_degrees(0)}) - * projectile.m_launch_speed * time; + const auto pitch_angle = PitchAngle::from_degrees(-pitch); + const auto yaw_angle = YawAngle::from_degrees(yaw); + const auto pitch_cos = pitch_angle.cos(); + // Roll is always zero here, so this is the exact first column of the rotation matrix. + const Vector3 forward{pitch_cos * yaw_angle.cos(), pitch_cos * yaw_angle.sin(), -pitch_angle.sin()}; + auto current_pos = launch_pos + forward * projectile.m_launch_speed * time; current_pos.z -= (gravity * projectile.m_gravity_scale) * (time * time) * 0.5f; return current_pos; } + + [[nodiscard("reachability result should not be discarded")]] + static constexpr bool + can_projectile_reach_target_at_time(const projectile_prediction::Projectile& projectile, + const Vector3& target_position, const float time, + const float gravity, const float distance_tolerance) noexcept + { + if (!(distance_tolerance >= 0.f)) + return false; + + // After undoing gravity, every possible projectile position is on a sphere with radius speed * time. + const auto launch_position = projectile.m_origin + projectile.m_launch_offset; + auto adjusted_delta = target_position - launch_position; + const auto gravity_displacement = (gravity * projectile.m_gravity_scale) * (time * time) * 0.5f; + adjusted_delta.z += gravity_displacement; + + const auto target_distance_sqr = adjusted_delta.length_sqr(); + const auto projectile_distance = std::abs(projectile.m_launch_speed * time); + const auto floating_point_margin = + std::numeric_limits::epsilon() * 8.f + * (std::abs(target_position.x) + std::abs(target_position.y) + std::abs(target_position.z) + + std::abs(launch_position.x) + std::abs(launch_position.y) + std::abs(launch_position.z) + + std::abs(gravity_displacement) + projectile_distance + distance_tolerance + 1.f); + + if (!std::isfinite(target_distance_sqr) || !std::isfinite(floating_point_margin)) [[unlikely]] + return true; + + const auto conservative_tolerance = distance_tolerance + floating_point_margin; + const auto maximum_distance = projectile_distance + conservative_tolerance; + if (target_distance_sqr > maximum_distance * maximum_distance) + return false; + + const auto minimum_distance = + projectile_distance > conservative_tolerance ? projectile_distance - conservative_tolerance : 0.f; + return target_distance_sqr >= minimum_distance * minimum_distance; + } + [[nodiscard("target position result should not be discarded")]] static constexpr Vector3 predict_target_position(const projectile_prediction::Target& target, const float time, const float gravity) noexcept @@ -78,4 +119,4 @@ namespace omath::source_engine return angles::radians_to_degrees(std::atan2(delta.y, delta.x)); }; }; -} // namespace omath::source_engine \ No newline at end of file +} // namespace omath::source_engine diff --git a/include/omath/projectile_prediction/proj_pred_engine_legacy.hpp b/include/omath/projectile_prediction/proj_pred_engine_legacy.hpp index 0800a44..953fc7e 100644 --- a/include/omath/projectile_prediction/proj_pred_engine_legacy.hpp +++ b/include/omath/projectile_prediction/proj_pred_engine_legacy.hpp @@ -9,6 +9,7 @@ #include "omath/projectile_prediction/proj_pred_engine.hpp" #include "omath/projectile_prediction/projectile.hpp" #include "omath/projectile_prediction/target.hpp" +#include #include namespace omath::projectile_prediction @@ -17,9 +18,8 @@ namespace omath::projectile_prediction concept PredEngineConcept = requires(const Projectile& projectile, const Target& target, const Vector3& vec_a, const Vector3& vec_b, - Vector3 v3, - ArithmeticType pitch, ArithmeticType yaw, ArithmeticType time, ArithmeticType gravity, - std::optional maybe_pitch) { + Vector3 v3, ArithmeticType pitch, ArithmeticType yaw, ArithmeticType time, + ArithmeticType gravity, std::optional maybe_pitch) { { T::predict_projectile_position(projectile, pitch, yaw, time, gravity) } -> std::same_as>; @@ -44,8 +44,7 @@ namespace omath::projectile_prediction class ProjPredEngineLegacy final : public ProjPredEngineInterface { public: - explicit ProjPredEngineLegacy(const ArithmeticType gravity_constant, - const ArithmeticType simulation_time_step, + explicit ProjPredEngineLegacy(const ArithmeticType gravity_constant, const ArithmeticType simulation_time_step, const ArithmeticType maximum_simulation_time, const ArithmeticType distance_tolerance) : m_gravity_constant(gravity_constant), m_simulation_time_step(simulation_time_step), @@ -54,8 +53,9 @@ namespace omath::projectile_prediction } [[nodiscard]] - std::optional> maybe_calculate_aim_point( - const Projectile& projectile, const Target& target) const override + std::optional> + maybe_calculate_aim_point(const Projectile& projectile, + const Target& target) const override { const auto solution = find_solution(projectile, target); if (!solution) @@ -66,15 +66,16 @@ namespace omath::projectile_prediction } [[nodiscard]] - std::optional> maybe_calculate_aim_angles( - const Projectile& projectile, const Target& target) const override + std::optional> + maybe_calculate_aim_angles(const Projectile& projectile, + const Target& target) const override { const auto solution = find_solution(projectile, target); if (!solution) return std::nullopt; - const auto yaw = EngineTrait::calc_direct_yaw_angle( - projectile.m_origin + projectile.m_launch_offset, solution->predicted_target_position); + const auto yaw = EngineTrait::calc_direct_yaw_angle(projectile.m_origin + projectile.m_launch_offset, + solution->predicted_target_position); return AimAngles{solution->pitch, yaw}; } @@ -89,23 +90,39 @@ namespace omath::projectile_prediction std::optional find_solution(const Projectile& projectile, const Target& target) const { - for (ArithmeticType time = ArithmeticType{0}; time < m_maximum_simulation_time; - time += m_simulation_time_step) + if (!std::isfinite(m_simulation_time_step) || m_simulation_time_step <= ArithmeticType{0} + || !std::isfinite(m_maximum_simulation_time) || m_maximum_simulation_time < ArithmeticType{0} + || !std::isfinite(projectile.m_launch_speed) || projectile.m_launch_speed <= ArithmeticType{0} + || !(m_distance_tolerance >= ArithmeticType{0})) [[unlikely]] + return std::nullopt; + + for (ArithmeticType time = ArithmeticType{0}; time <= m_maximum_simulation_time;) { const auto predicted_target_position = EngineTrait::predict_target_position(target, time, m_gravity_constant); - const auto projectile_pitch = - maybe_calculate_projectile_launch_pitch_angle(projectile, predicted_target_position); + if (is_target_potentially_reachable(projectile, predicted_target_position, time)) + { + const auto projectile_pitch = + maybe_calculate_projectile_launch_pitch_angle(projectile, predicted_target_position); - if (!projectile_pitch.has_value()) [[unlikely]] - continue; + if (projectile_pitch.has_value()) [[likely]] + { + const auto yaw = EngineTrait::calc_direct_yaw_angle( + projectile.m_origin + projectile.m_launch_offset, predicted_target_position); - if (!is_projectile_reached_target(predicted_target_position, projectile, projectile_pitch.value(), - time)) - continue; + if (is_projectile_reached_target(predicted_target_position, projectile, + projectile_pitch.value(), yaw, time)) + return Solution{predicted_target_position, projectile_pitch.value()}; + } + } - return Solution{predicted_target_position, projectile_pitch.value()}; + if (time == m_maximum_simulation_time) + break; + const auto next_time = time + m_simulation_time_step; + if (!(next_time > time)) [[unlikely]] + break; + time = next_time < m_maximum_simulation_time ? next_time : m_maximum_simulation_time; } return std::nullopt; } @@ -144,29 +161,46 @@ namespace omath::projectile_prediction const auto distance2d = EngineTrait::calc_vector_2d_distance(delta); const auto distance2d_sqr = distance2d * distance2d; const auto launch_speed_sqr = projectile.m_launch_speed * projectile.m_launch_speed; + const auto ballistic_term = + bullet_gravity * distance2d_sqr + + ArithmeticType{2} * EngineTrait::get_vector_height_coordinate(delta) * launch_speed_sqr; - ArithmeticType root = launch_speed_sqr * launch_speed_sqr - - bullet_gravity - * (bullet_gravity * distance2d_sqr - + ArithmeticType{2} * EngineTrait::get_vector_height_coordinate(delta) - * launch_speed_sqr); + ArithmeticType root = launch_speed_sqr * launch_speed_sqr - bullet_gravity * ballistic_term; if (root < ArithmeticType{0}) [[unlikely]] return std::nullopt; root = std::sqrt(root); - const ArithmeticType angle = std::atan((launch_speed_sqr - root) / (bullet_gravity * distance2d)); + // This rationalized form avoids cancellation in launch_speed_sqr - root for low-angle shots. + const ArithmeticType angle = std::atan2(ballistic_term, distance2d * (launch_speed_sqr + root)); return angles::radians_to_degrees(angle); } [[nodiscard]] - bool is_projectile_reached_target(const Vector3& target_position, - const Projectile& projectile, - const ArithmeticType pitch, const ArithmeticType time) const noexcept + bool is_target_potentially_reachable(const Projectile& projectile, + const Vector3& target_position, + const ArithmeticType time) const noexcept + { + if constexpr (requires { + { + EngineTrait::can_projectile_reach_target_at_time( + projectile, target_position, time, m_gravity_constant, m_distance_tolerance) + } -> std::same_as; + requires noexcept(EngineTrait::can_projectile_reach_target_at_time( + projectile, target_position, time, m_gravity_constant, m_distance_tolerance)); + }) + return EngineTrait::can_projectile_reach_target_at_time(projectile, target_position, time, + m_gravity_constant, m_distance_tolerance); + + return true; + } + + [[nodiscard]] + bool is_projectile_reached_target(const Vector3& target_position, + const Projectile& projectile, const ArithmeticType pitch, + const ArithmeticType yaw, const ArithmeticType time) const noexcept { - const auto yaw = EngineTrait::calc_direct_yaw_angle( - projectile.m_origin + projectile.m_launch_offset, target_position); const auto projectile_position = EngineTrait::predict_projectile_position(projectile, pitch, yaw, time, m_gravity_constant); diff --git a/tests/general/unit_test_pred_engine_trait.cpp b/tests/general/unit_test_pred_engine_trait.cpp index fd20773..fb0cd5c 100644 --- a/tests/general/unit_test_pred_engine_trait.cpp +++ b/tests/general/unit_test_pred_engine_trait.cpp @@ -1,4 +1,5 @@ // Tests for PredEngineTrait +#include #include #include #include @@ -77,6 +78,77 @@ TEST(PredEngineTrait, PredictProjectilePositionWithLaunchOffset) EXPECT_NEAR(pos_t1.z, -2.f - 9.81f * 0.5f, 1e-3f); } +TEST(PredEngineTrait, PredictProjectilePositionMatchesRotationMatrix) +{ + constexpr Projectile projectile{ + .m_origin = {10.f, -20.f, 30.f}, + .m_launch_offset = {2.f, 3.f, -4.f}, + .m_launch_speed = 750.f, + .m_gravity_scale = 0.6f, + }; + struct TestCase + { + float pitch; + float yaw; + float time; + }; + constexpr std::array test_cases{ + TestCase{0.f, 0.f, 0.f}, + TestCase{25.f, 45.f, 0.25f}, + TestCase{-60.f, -135.f, 1.5f}, + TestCase{120.f, 540.f, 2.f}, + }; + constexpr float gravity = 9.81f; + + for (const auto& test_case : test_cases) + { + const auto launch_position = projectile.m_origin + projectile.m_launch_offset; + auto expected_position = launch_position + + forward_vector({PitchAngle::from_degrees(-test_case.pitch), + YawAngle::from_degrees(test_case.yaw), RollAngle::from_degrees(0.f)}) + * projectile.m_launch_speed * test_case.time; + expected_position.z -= gravity * projectile.m_gravity_scale * test_case.time * test_case.time * 0.5f; + + const auto actual_position = PredEngineTrait::predict_projectile_position( + projectile, test_case.pitch, test_case.yaw, test_case.time, gravity); + + EXPECT_NEAR(actual_position.x, expected_position.x, 1e-5f); + EXPECT_NEAR(actual_position.y, expected_position.y, 1e-5f); + EXPECT_NEAR(actual_position.z, expected_position.z, 1e-5f); + } +} + +TEST(PredEngineTrait, ReachabilityCheckUsesDistanceTolerance) +{ + constexpr Projectile projectile{ + .m_origin = {0.f, 0.f, 0.f}, + .m_launch_speed = 100.f, + .m_gravity_scale = 0.f, + }; + + EXPECT_FALSE(PredEngineTrait::can_projectile_reach_target_at_time(projectile, {100.f, 0.f, 0.f}, 0.5f, 9.81f, 0.f)); + EXPECT_TRUE(PredEngineTrait::can_projectile_reach_target_at_time(projectile, {100.f, 0.f, 0.f}, 1.f, 9.81f, 0.f)); + EXPECT_TRUE(PredEngineTrait::can_projectile_reach_target_at_time(projectile, {101.f, 0.f, 0.f}, 1.f, 9.81f, 1.f)); + EXPECT_TRUE(PredEngineTrait::can_projectile_reach_target_at_time(projectile, {99.f, 0.f, 0.f}, 1.f, 9.81f, 1.f)); + EXPECT_FALSE(PredEngineTrait::can_projectile_reach_target_at_time(projectile, {101.f, 0.f, 0.f}, 1.f, 9.81f, 0.f)); + EXPECT_FALSE(PredEngineTrait::can_projectile_reach_target_at_time(projectile, {98.f, 0.f, 0.f}, 1.f, 9.81f, 1.f)); +} + +TEST(PredEngineTrait, ReachabilityCheckIncludesFloatingPointError) +{ + constexpr Projectile projectile{ + .m_origin = {1.f, 2.f, 3.f}, + .m_launch_offset = {0.1f, -0.2f, 0.3f}, + .m_launch_speed = 100.f, + .m_gravity_scale = 1.f, + }; + constexpr float gravity = 9.81f; + constexpr float time = 0.3f; + const auto target_position = PredEngineTrait::predict_projectile_position(projectile, 25.f, 45.f, time, gravity); + + EXPECT_TRUE(PredEngineTrait::can_projectile_reach_target_at_time(projectile, target_position, time, gravity, 0.f)); +} + TEST(PredEngineTrait, ZeroLaunchOffsetMatchesOriginalBehavior) { Projectile p; diff --git a/tests/general/unit_test_proj_pred_engine_legacy_more.cpp b/tests/general/unit_test_proj_pred_engine_legacy_more.cpp index 02e5e07..ebc2972 100644 --- a/tests/general/unit_test_proj_pred_engine_legacy_more.cpp +++ b/tests/general/unit_test_proj_pred_engine_legacy_more.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -15,25 +16,45 @@ struct FakeEngineZeroGravity { return t.m_origin; } - static Vector3 predict_projectile_position(const Projectile& /*p*/, float /*pitch*/, float /*yaw*/, float /*time*/, float /*gravity*/) noexcept + static Vector3 predict_projectile_position(const Projectile& /*p*/, float /*pitch*/, float /*yaw*/, + float /*time*/, float /*gravity*/) noexcept { // Return a fixed point matching typical target used in the test return Vector3{100.f, 0.f, 0.f}; } - static float calc_vector_2d_distance(const Vector3& v) noexcept { return std::hypot(v.x, v.y); } - static float get_vector_height_coordinate(const Vector3& v) noexcept { return v.z; } - static Vector3 calc_viewpoint_from_angles(const Projectile& /*p*/, Vector3 /*v*/, std::optional /*maybe_pitch*/) noexcept + static float calc_vector_2d_distance(const Vector3& v) noexcept + { + return std::hypot(v.x, v.y); + } + static float get_vector_height_coordinate(const Vector3& v) noexcept + { + return v.z; + } + static Vector3 calc_viewpoint_from_angles(const Projectile& /*p*/, Vector3 /*v*/, + std::optional /*maybe_pitch*/) noexcept { return Vector3{1.f, 2.f, 3.f}; } - static float calc_direct_pitch_angle(const Vector3& /*a*/, const Vector3& /*b*/) noexcept { return 12.5f; } - static float calc_direct_yaw_angle(const Vector3& /*a*/, const Vector3& /*b*/) noexcept { return 0.f; } + static float calc_direct_pitch_angle(const Vector3& /*a*/, const Vector3& /*b*/) noexcept + { + return 12.5f; + } + static float calc_direct_yaw_angle(const Vector3& /*a*/, const Vector3& /*b*/) noexcept + { + return 0.f; + } + static bool can_projectile_reach_target_at_time(const Projectile& /*projectile*/, + const Vector3& /*target_position*/, float /*time*/, + float /*gravity*/, float /*distance_tolerance*/) + { + return false; + } }; TEST(ProjPredLegacyMore, ZeroGravityUsesDirectPitchAndReturnsViewpoint) { - constexpr Projectile proj{ .m_origin = {0.f, 0.f, 0.f}, .m_launch_speed = 10.f, .m_gravity_scale = 0.f }; - constexpr Target target{ .m_origin = {100.f, 0.f, 0.f}, .m_velocity = {0.f,0.f,0.f}, .m_is_airborne = false }; + constexpr Projectile proj{.m_origin = {0.f, 0.f, 0.f}, .m_launch_speed = 10.f, .m_gravity_scale = 0.f}; + constexpr Target target{.m_origin = {100.f, 0.f, 0.f}, .m_velocity = {0.f, 0.f, 0.f}, .m_is_airborne = false}; using Engine = omath::projectile_prediction::ProjPredEngineLegacy; const Engine engine(9.8f, 0.1f, 5.f, 1e-3f); @@ -117,3 +138,77 @@ TEST(ProjPredLegacyMore, AngleComputedButMissReturnsNullopt) const auto res = engine.maybe_calculate_aim_point(proj, target); EXPECT_FALSE(res.has_value()); } + +TEST(ProjPredLegacyMore, IncludesMaximumSimulationTime) +{ + constexpr Projectile projectile{.m_origin = {0.f, 0.f, 0.f}, .m_launch_speed = 10.f, .m_gravity_scale = 0.f}; + constexpr Target target{.m_origin = {10.f, 0.f, 0.f}, .m_velocity = {0.f, 0.f, 0.f}, .m_is_airborne = false}; + const omath::projectile_prediction::ProjPredEngineLegacy<> engine(0.f, 0.1f, 1.f, 0.f); + + const auto result = engine.maybe_calculate_aim_point(projectile, target); + + ASSERT_TRUE(result.has_value()); + EXPECT_NEAR(result->x, target.m_origin.x, 1e-6f); + EXPECT_NEAR(result->y, target.m_origin.y, 1e-6f); + EXPECT_NEAR(result->z, target.m_origin.z, 1e-6f); +} + +TEST(ProjPredLegacyMore, RejectsInvalidSimulationSteps) +{ + constexpr Projectile projectile{.m_origin = {0.f, 0.f, 0.f}, .m_launch_speed = 10.f, .m_gravity_scale = 0.f}; + constexpr Target target{.m_origin = {10.f, 0.f, 0.f}, .m_velocity = {0.f, 0.f, 0.f}, .m_is_airborne = false}; + + EXPECT_FALSE(omath::projectile_prediction::ProjPredEngineLegacy<>(0.f, 0.f, 1.f, 1.f) + .maybe_calculate_aim_point(projectile, target)); + EXPECT_FALSE(omath::projectile_prediction::ProjPredEngineLegacy<>(0.f, -0.1f, 1.f, 1.f) + .maybe_calculate_aim_point(projectile, target)); + EXPECT_FALSE( + omath::projectile_prediction::ProjPredEngineLegacy<>(0.f, std::numeric_limits::infinity(), 1.f, 1.f) + .maybe_calculate_aim_point(projectile, target)); +} + +TEST(ProjPredLegacyMore, RejectsInvalidProjectileSpeedAndTolerance) +{ + constexpr Target target{.m_origin = {1.f, 0.f, 0.f}, .m_velocity = {0.f, 0.f, 0.f}, .m_is_airborne = false}; + constexpr Projectile stopped_projectile{.m_origin = {0.f, 0.f, 0.f}, .m_launch_speed = 0.f, .m_gravity_scale = 0.f}; + constexpr Projectile moving_projectile{.m_origin = {0.f, 0.f, 0.f}, .m_launch_speed = 10.f, .m_gravity_scale = 0.f}; + + EXPECT_FALSE(omath::projectile_prediction::ProjPredEngineLegacy<>(0.f, 0.1f, 1.f, 2.f) + .maybe_calculate_aim_point(stopped_projectile, target)); + EXPECT_FALSE(omath::projectile_prediction::ProjPredEngineLegacy<>(0.f, 0.1f, 1.f, -1.f) + .maybe_calculate_aim_point(moving_projectile, target)); +} + +TEST(ProjPredLegacyMore, StablePitchFindsHighSpeedLowArc) +{ + constexpr Projectile projectile{.m_origin = {0.f, 0.f, 0.f}, .m_launch_speed = 10'000.f, .m_gravity_scale = 1.f}; + constexpr Target target{.m_origin = {100.f, 0.f, 0.f}, .m_velocity = {0.f, 0.f, 0.f}, .m_is_airborne = false}; + const omath::projectile_prediction::ProjPredEngineLegacy<> engine(9.81f, 0.01f, 0.02f, 0.0001f); + + const auto result = engine.maybe_calculate_aim_angles(projectile, target); + + ASSERT_TRUE(result.has_value()); + EXPECT_GT(result->pitch, 0.f); + EXPECT_NEAR(result->yaw, 0.f, 1e-6f); +} + +TEST(ProjPredLegacyMore, CoincidentTargetReturnsLaunchOrigin) +{ + constexpr Projectile projectile{.m_origin = {5.f, 4.f, 3.f}, .m_launch_speed = 100.f, .m_gravity_scale = 1.f}; + constexpr Target target{.m_origin = projectile.m_origin, .m_velocity = {0.f, 0.f, 0.f}, .m_is_airborne = false}; + const omath::projectile_prediction::ProjPredEngineLegacy<> engine(9.81f, 0.01f, 0.01f, 0.f); + + const auto result = engine.maybe_calculate_aim_point(projectile, target); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result.value(), projectile.m_origin); +} + +TEST(ProjPredLegacyMore, TinyDistanceDoesNotUnderflowToAHit) +{ + constexpr Projectile projectile{.m_origin = {0.f, 0.f, 0.f}, .m_launch_speed = 1.f, .m_gravity_scale = 0.f}; + constexpr Target target{.m_origin = {1e-30f, 0.f, 0.f}, .m_velocity = {0.f, 0.f, 0.f}, .m_is_airborne = false}; + const omath::projectile_prediction::ProjPredEngineLegacy<> engine(0.f, 0.1f, 0.f, 0.f); + + EXPECT_FALSE(engine.maybe_calculate_aim_point(projectile, target)); +}