Compare commits

...

44 Commits

Author SHA1 Message Date
4c65781c6f improvement 2026-04-25 05:34:53 +03:00
29b49685be fix 2026-04-25 05:29:59 +03:00
92582079c5 added types impl 2026-04-25 05:29:21 +03:00
13c7f7eb5a fixed lua 2026-04-25 05:16:18 +03:00
65cb803cfb update 2026-04-25 05:09:07 +03:00
607c034be7 fix 2026-04-25 04:51:10 +03:00
0487e285ef updated for unreal 2026-04-25 04:42:52 +03:00
180f2f2afa added template + concept 2026-04-24 18:51:06 +03:00
35bb1bc3c0 Merge pull request #185 from orange-cpp/feature/unreal-engine-projection-fix
Feature/unreal engine projection fix
2026-04-23 22:46:10 +03:00
e62e8672b3 fixed tests 2026-04-23 22:32:44 +03:00
11c053e28c fixed rotation ordering 2026-04-23 21:24:46 +03:00
56ebc47553 remove axys invertion 2026-04-23 20:02:34 +03:00
b3ba9eaadf updated formulas 2026-04-23 19:48:55 +03:00
42a8a5a763 also fixed for source 2026-04-23 19:23:13 +03:00
2eccb4023f fix 2026-04-23 18:33:00 +03:00
3eb9daf10b +90 up -90 down fix for camera view angles 2026-04-23 02:05:54 +03:00
27cb511510 Merge pull request #184 from orange-cpp/feature/more-tests
Feature/more tests
2026-04-20 01:50:23 +03:00
27b24b5fe7 added gource script 2026-04-20 01:36:08 +03:00
4186ae8d76 added more tests 2026-04-20 01:17:11 +03:00
8e6e3211c2 Merge pull request #183 from orange-cpp/featue/aabb-improvement
Featue/aabb improvement
2026-04-19 23:49:25 +03:00
1c0619ff7b added new methods 2026-04-19 23:20:29 +03:00
dfd18e96fb added aabb improvemnt 2026-04-19 23:07:58 +03:00
20930c629a added method to get camera matrix 2026-04-18 15:40:38 +03:00
0845a2e863 clarified interfaces 2026-04-18 12:54:37 +03:00
f3f454b02e Merge pull request #182 from orange-cpp/feature/camera_upgrade
Feature/camera upgrade
2026-04-15 18:59:18 +03:00
0419043720 Update include/omath/engines/frostbite_engine/camera.hpp
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-15 03:48:03 +03:00
79f64d9679 fixed unreal bug, improved interface 2026-04-15 03:38:02 +03:00
dbe29926dc fixed unity bug 2026-04-15 03:25:53 +03:00
9d30446c55 added ability to get view angles from view matrix 2026-04-15 03:08:06 +03:00
ba80aebfae Merge pull request #181 from orange-cpp/feature/walk-bot
Feature/walk bot
2026-04-14 15:23:49 +03:00
9c1b6d0ba3 added tests improved API 2026-04-12 21:21:23 +03:00
ea07d17dbb improved walkbot 2026-04-12 12:05:40 +03:00
bb974da0e2 improvement 2026-04-12 11:16:39 +03:00
fde764c1fa added code 2026-04-12 11:12:17 +03:00
va_alpatov
28e86fc355 tests hotfix 2026-04-11 20:23:08 +03:00
va_alpatov
93e7a9457a fixed pathfinding bug 2026-04-11 20:06:39 +03:00
8f65183882 fixed tests 2026-04-08 15:34:10 +03:00
327db8d441 updated contributing 2026-04-03 20:59:34 +03:00
d8188de736 keeping 1 AABB type 2026-03-28 14:22:36 +03:00
33cd3f64e4 Merge pull request #180 from orange-cpp/feature/aabb-linetrace
added aabb line trace
2026-03-25 03:37:35 +03:00
67a07eed45 added aabb line trace 2026-03-25 03:14:22 +03:00
0b52b2847b Merge pull request #179 from orange-cpp/feature/aabb_check
added AABB check
2026-03-24 10:45:00 +03:00
d38895e4d7 added AABB check 2026-03-24 10:20:50 +03:00
04203d46ff patch 2026-03-24 06:44:10 +03:00
39 changed files with 3230 additions and 276 deletions

View File

@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"Bash(ls:*)"
]
}
}

10
.idea/editor.xml generated
View File

@@ -17,7 +17,7 @@
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
@@ -103,14 +103,14 @@
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppImplicitDefaultConstructorNotAvailable/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIncompatiblePointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIncompleteSwitchStatement/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInconsistentNaming/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInconsistentNaming/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIntegralToPointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInvalidLineContinuation/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppJoinDeclarationAndAssignment/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLambdaCaptureNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableMayBeConst/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableWithNonTrivialDtorIsNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableWithNonTrivialDtorIsNeverUsed/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLongFloat/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberFunctionMayBeConst/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberFunctionMayBeStatic/@EntryIndexedValue" value="SUGGESTION" type="string" />
@@ -202,7 +202,7 @@
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticDataMemberInUnnamedStruct/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticSpecifierOnAnonymousNamespaceMember/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStringLiteralToCharPointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTabsAreDisallowed/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTabsAreDisallowed/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateArgumentsCanBeDeduced/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateParameterNeverUsed/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateParameterShadowing/@EntryIndexedValue" value="WARNING" type="string" />
@@ -216,7 +216,7 @@
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnmatchedPragmaEndRegionDirective/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnmatchedPragmaRegionDirective/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnnamedNamespaceInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnnecessaryWhitespace/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnnecessaryWhitespace/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnsignedZeroComparison/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnusedIncludeDirective/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAlgorithmWithCount/@EntryIndexedValue" value="SUGGESTION" type="string" />

View File

@@ -1,32 +1,36 @@
## 🤝 Contributing to OMath or other Orange's Projects
# Contributing
### ❕ Prerequisites
## Prerequisites
- A working up-to-date OMath installation
- C++ knowledge
- Git knowledge
- Ability to ask for help (Feel free to create empty pull-request or PM a maintainer
in [Telegram](https://t.me/orange_cpp))
- C++ compiler with C++23 support (Clang 18+, GCC 14+, MSVC 19.38+)
- CMake 3.25+
- Git
- Familiarity with the codebase (see `INSTALL.md` for setup)
### ⏬ Setting up OMath
For questions, create a draft PR or reach out via [Telegram](https://t.me/orange_cpp).
Please read INSTALL.md file in repository
## Workflow
### 🔀 Pull requests and Branches
1. [Fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) the repository.
2. Create a feature branch from `main`.
3. Make your changes, ensuring tests pass.
4. Open a [pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork) against `main`.
In order to send code back to the official OMath repository, you must first create a copy of OMath on your github
account ([fork](https://help.github.com/articles/creating-a-pull-request-from-a-fork/)) and
then [create a pull request](https://help.github.com/articles/creating-a-pull-request-from-a-fork/) back to OMath.
## Code Style
OMath development is performed on multiple branches. Changes are then pull requested into master. By default, changes
merged into master will not roll out to stable build users unless the `stable` tag is updated.
Follow the project `.clang-format`. Run `clang-format` before committing.
### 📜 Code-Style
## Building
The orange code-style can be found in `.clang-format`.
Use one of the CMake presets defined in `CMakePresets.json`:
### 📦 Building
```bash
cmake --preset <preset-name> -DOMATH_BUILD_TESTS=ON
cmake --build --preset <preset-name>
```
OMath has already created the `cmake-build` and `out` directories where cmake/bin files are located. By default, you
can build OMath by running `cmake --build cmake-build/build/windows-release --target omath -j 6` in the source
directory.
Run `cmake --list-presets` to see available configurations.
## Tests
All new functionality must include unit tests. Run the test binary after building to verify nothing is broken.

View File

@@ -71,18 +71,18 @@ void drawChar(char c, float x, float y, float scale, const Color& color, std::ve
lines.push_back(x + x1 * w);
lines.push_back(y + y1 * h);
lines.push_back(0.0f);
lines.push_back(color.x);
lines.push_back(color.y);
lines.push_back(color.z);
lines.push_back(color.value().x);
lines.push_back(color.value().y);
lines.push_back(color.value().z);
lines.push_back(1.0f); // size
lines.push_back(1.0f); // isLine
lines.push_back(x + x2 * w);
lines.push_back(y + y2 * h);
lines.push_back(0.0f);
lines.push_back(color.x);
lines.push_back(color.y);
lines.push_back(color.z);
lines.push_back(color.value().x);
lines.push_back(color.value().y);
lines.push_back(color.value().z);
lines.push_back(1.0f); // size
lines.push_back(1.0f); // isLine
};

View File

@@ -318,22 +318,22 @@ int main()
glfwPollEvents();
omath::Vector3<float> move_dir;
if (glfwGetKey(window, GLFW_KEY_W))
move_dir += camera.get_forward();
move_dir += camera.get_abs_forward();
if (glfwGetKey(window, GLFW_KEY_A))
move_dir -= camera.get_right();
move_dir -= camera.get_abs_right();
if (glfwGetKey(window, GLFW_KEY_S))
move_dir -= camera.get_forward();
move_dir -= camera.get_abs_forward();
if (glfwGetKey(window, GLFW_KEY_D))
move_dir += camera.get_right();
move_dir += camera.get_abs_right();
if (glfwGetKey(window, GLFW_KEY_SPACE))
move_dir += camera.get_up();
move_dir += camera.get_abs_up();
if (glfwGetKey(window, GLFW_KEY_LEFT_CONTROL))
move_dir -= camera.get_up();
move_dir -= camera.get_abs_up();
auto delta = glfwGetTime() - old_mouse_time;

View File

@@ -0,0 +1,67 @@
//
// Created by Vladislav on 24.03.2026.
//
#pragma once
#include "omath/linear_algebra/vector3.hpp"
namespace omath::primitives
{
enum class UpAxis { X, Y, Z };
template<class Type>
struct Aabb final
{
Vector3<Type> min;
Vector3<Type> max;
[[nodiscard]]
constexpr Vector3<Type> center() const noexcept
{
return (min + max) / static_cast<Type>(2);
}
[[nodiscard]]
constexpr Vector3<Type> extents() const noexcept
{
return (max - min) / static_cast<Type>(2);
}
template<UpAxis Up = UpAxis::Y>
[[nodiscard]]
constexpr Vector3<Type> top() const noexcept
{
const auto aabb_center = center();
if constexpr (Up == UpAxis::Z)
return {aabb_center.x, aabb_center.y, max.z};
else if constexpr (Up == UpAxis::X)
return {max.x, aabb_center.y, aabb_center.z};
else if constexpr (Up == UpAxis::Y)
return {aabb_center.x, max.y, aabb_center.z};
else
std::unreachable();
}
template<UpAxis Up = UpAxis::Y>
[[nodiscard]]
constexpr Vector3<Type> bottom() const noexcept
{
const auto aabb_center = center();
if constexpr (Up == UpAxis::Z)
return {aabb_center.x, aabb_center.y, min.z};
else if constexpr (Up == UpAxis::X)
return {min.x, aabb_center.y, aabb_center.z};
else if constexpr (Up == UpAxis::Y)
return {aabb_center.x, min.y, aabb_center.z};
else
std::unreachable();
}
[[nodiscard]]
constexpr bool is_collide(const Aabb& other) const noexcept
{
return min.x <= other.max.x && max.x >= other.min.x &&
min.y <= other.max.y && max.y >= other.min.y &&min.z <= other.max.z && max.z >= other.min.z;
}
};
} // namespace omath::primitives

View File

@@ -3,6 +3,7 @@
//
#pragma once
#include "omath/3d_primitives/aabb.hpp"
#include "omath/linear_algebra/triangle.hpp"
#include "omath/linear_algebra/vector3.hpp"
@@ -34,6 +35,7 @@ namespace omath::collision
class LineTracer final
{
using TriangleType = Triangle<typename RayType::VectorType>;
using AABBType = primitives::Aabb<typename RayType::VectorType::ContainedType>;
public:
LineTracer() = delete;
@@ -87,6 +89,54 @@ namespace omath::collision
return ray.start + ray_dir * t_hit;
}
// Slab method ray-AABB intersection
// Returns the hit point on the AABB surface, or ray.end if no intersection
[[nodiscard]]
constexpr static auto get_ray_hit_point(const RayType& ray, const AABBType& aabb) noexcept
{
using T = typename RayType::VectorType::ContainedType;
const auto dir = ray.direction_vector();
auto t_min = -std::numeric_limits<T>::infinity();
auto t_max = std::numeric_limits<T>::infinity();
const auto process_axis = [&](const T& d, const T& origin, const T& box_min,
const T& box_max) -> bool
{
constexpr T k_epsilon = std::numeric_limits<T>::epsilon();
if (std::abs(d) < k_epsilon)
return origin >= box_min && origin <= box_max;
const T inv = T(1) / d;
T t0 = (box_min - origin) * inv;
T t1 = (box_max - origin) * inv;
if (t0 > t1)
std::swap(t0, t1);
t_min = std::max(t_min, t0);
t_max = std::min(t_max, t1);
return t_min <= t_max;
};
if (!process_axis(dir.x, ray.start.x, aabb.min.x, aabb.max.x))
return ray.end;
if (!process_axis(dir.y, ray.start.y, aabb.min.y, aabb.max.y))
return ray.end;
if (!process_axis(dir.z, ray.start.z, aabb.min.z, aabb.max.z))
return ray.end;
// t_hit: use entry point if in front of origin, otherwise 0 (started inside)
const T t_hit = std::max(T(0), t_min);
if (t_max < T(0))
return ray.end; // box entirely behind origin
if (!ray.infinite_length && t_hit > T(1))
return ray.end; // box beyond ray endpoint
return ray.start + dir * t_hit;
}
template<class MeshType>
[[nodiscard]]
constexpr static auto get_ray_hit_point(const RayType& ray, const MeshType& mesh) noexcept

View File

@@ -9,5 +9,5 @@
namespace omath::cry_engine
{
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, false, NDCDepthRange::ZERO_TO_ONE>;
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, NDCDepthRange::ZERO_TO_ONE>;
} // namespace omath::cry_engine

View File

@@ -9,5 +9,5 @@
namespace omath::frostbite_engine
{
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, false, NDCDepthRange::ZERO_TO_ONE>;
} // namespace omath::unity_engine
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, NDCDepthRange::ZERO_TO_ONE>;
} // namespace omath::frostbite_engine

View File

@@ -9,5 +9,5 @@
namespace omath::iw_engine
{
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, false, NDCDepthRange::ZERO_TO_ONE>;
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, NDCDepthRange::ZERO_TO_ONE>;
} // namespace omath::iw_engine

View File

@@ -8,5 +8,5 @@
namespace omath::opengl_engine
{
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, true, NDCDepthRange::NEGATIVE_ONE_TO_ONE>;
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, NDCDepthRange::NEGATIVE_ONE_TO_ONE, {.inverted_forward = true}>;
} // namespace omath::opengl_engine

View File

@@ -7,5 +7,5 @@
#include "traits/camera_trait.hpp"
namespace omath::source_engine
{
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, false, NDCDepthRange::ZERO_TO_ONE>;
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, NDCDepthRange::ZERO_TO_ONE>;
} // namespace omath::source_engine

View File

@@ -9,5 +9,5 @@
namespace omath::unity_engine
{
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, false, NDCDepthRange::ZERO_TO_ONE>;
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, NDCDepthRange::ZERO_TO_ONE, {.inverted_forward = true}>;
} // namespace omath::unity_engine

View File

@@ -9,5 +9,5 @@
namespace omath::unreal_engine
{
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, false, NDCDepthRange::ZERO_TO_ONE>;
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, NDCDepthRange::ZERO_TO_ONE, {}, double>;
} // namespace omath::unreal_engine

View File

@@ -11,16 +11,16 @@
namespace omath::unreal_engine
{
constexpr Vector3<float> k_abs_up = {0, 0, 1};
constexpr Vector3<float> k_abs_right = {0, 1, 0};
constexpr Vector3<float> k_abs_forward = {1, 0, 0};
constexpr Vector3<double> k_abs_up = {0, 0, 1};
constexpr Vector3<double> k_abs_right = {0, 1, 0};
constexpr Vector3<double> k_abs_forward = {1, 0, 0};
using Mat4X4 = Mat<4, 4, float, MatStoreType::ROW_MAJOR>;
using Mat3X3 = Mat<4, 4, float, MatStoreType::ROW_MAJOR>;
using Mat1X3 = Mat<1, 3, float, MatStoreType::ROW_MAJOR>;
using PitchAngle = Angle<float, -90.f, 90.f, AngleFlags::Clamped>;
using YawAngle = Angle<float, -180.f, 180.f, AngleFlags::Normalized>;
using RollAngle = Angle<float, -180.f, 180.f, AngleFlags::Normalized>;
using Mat4X4 = Mat<4, 4, double, MatStoreType::ROW_MAJOR>;
using Mat3X3 = Mat<4, 4, double, MatStoreType::ROW_MAJOR>;
using Mat1X3 = Mat<1, 3, double, MatStoreType::ROW_MAJOR>;
using PitchAngle = Angle<double, -90., 90., AngleFlags::Clamped>;
using YawAngle = Angle<double, -180., 180., AngleFlags::Normalized>;
using RollAngle = Angle<double, -180., 180., AngleFlags::Normalized>;
using ViewAngles = omath::ViewAngles<PitchAngle, YawAngle, RollAngle>;
} // namespace omath::unreal_engine

View File

@@ -8,21 +8,21 @@
namespace omath::unreal_engine
{
[[nodiscard]]
Vector3<float> forward_vector(const ViewAngles& angles) noexcept;
Vector3<double> forward_vector(const ViewAngles& angles) noexcept;
[[nodiscard]]
Vector3<float> right_vector(const ViewAngles& angles) noexcept;
Vector3<double> right_vector(const ViewAngles& angles) noexcept;
[[nodiscard]]
Vector3<float> up_vector(const ViewAngles& angles) noexcept;
Vector3<double> up_vector(const ViewAngles& angles) noexcept;
[[nodiscard]] Mat4X4 calc_view_matrix(const ViewAngles& angles, const Vector3<float>& cam_origin) noexcept;
[[nodiscard]] Mat4X4 calc_view_matrix(const ViewAngles& angles, const Vector3<double>& cam_origin) noexcept;
[[nodiscard]]
Mat4X4 rotation_matrix(const ViewAngles& angles) noexcept;
[[nodiscard]]
Mat4X4 calc_perspective_projection_matrix(float field_of_view, float aspect_ratio, float near, float far,
Mat4X4 calc_perspective_projection_matrix(double field_of_view, double aspect_ratio, double near, double far,
NDCDepthRange ndc_depth_range = NDCDepthRange::NEGATIVE_ONE_TO_ONE) noexcept;
template<class FloatingType>

View File

@@ -12,13 +12,13 @@ namespace omath::unreal_engine
{
public:
[[nodiscard]]
static ViewAngles calc_look_at_angle(const Vector3<float>& cam_origin, const Vector3<float>& look_at) noexcept;
static ViewAngles calc_look_at_angle(const Vector3<double>& cam_origin, const Vector3<double>& look_at) noexcept;
[[nodiscard]]
static Mat4X4 calc_view_matrix(const ViewAngles& angles, const Vector3<float>& cam_origin) noexcept;
static Mat4X4 calc_view_matrix(const ViewAngles& angles, const Vector3<double>& cam_origin) noexcept;
[[nodiscard]]
static Mat4X4 calc_projection_matrix(const projection::FieldOfView& fov, const projection::ViewPort& view_port,
float near, float far, NDCDepthRange ndc_depth_range) noexcept;
double near, double far, NDCDepthRange ndc_depth_range) noexcept;
};
} // namespace omath::unreal_engine

View File

@@ -17,9 +17,11 @@ namespace omath::unreal_engine
const float time, const float gravity) noexcept
{
const auto launch_pos = projectile.m_origin + projectile.m_launch_offset;
const auto fwd_d = forward_vector({PitchAngle::from_degrees(-pitch), YawAngle::from_degrees(yaw),
RollAngle::from_degrees(0)});
auto current_pos = launch_pos
+ forward_vector({PitchAngle::from_degrees(-pitch), YawAngle::from_degrees(yaw),
RollAngle::from_degrees(0)})
+ Vector3<float>{static_cast<float>(fwd_d.x), static_cast<float>(fwd_d.y),
static_cast<float>(fwd_d.z)}
* projectile.m_launch_speed * time;
current_pos.y -= (gravity * projectile.m_gravity_scale) * (time * time) * 0.5f;

View File

@@ -667,21 +667,21 @@ namespace omath
template<class Type = float, MatStoreType St = MatStoreType::ROW_MAJOR,
NDCDepthRange DepthRange = NDCDepthRange::NEGATIVE_ONE_TO_ONE>
[[nodiscard]]
Mat<4, 4, Type, St> mat_perspective_left_handed(const float field_of_view, const float aspect_ratio,
const float near, const float far) noexcept
Mat<4, 4, Type, St> mat_perspective_left_handed(const Type field_of_view, const Type aspect_ratio,
const Type near, const Type far) noexcept
{
const float fov_half_tan = std::tan(angles::degrees_to_radians(field_of_view) / 2.f);
const auto fov_half_tan = std::tan(angles::degrees_to_radians(field_of_view) / Type{2});
if constexpr (DepthRange == NDCDepthRange::ZERO_TO_ONE)
return {{1.f / (aspect_ratio * fov_half_tan), 0.f, 0.f, 0.f},
{0.f, 1.f / fov_half_tan, 0.f, 0.f},
{0.f, 0.f, far / (far - near), -(near * far) / (far - near)},
{0.f, 0.f, 1.f, 0.f}};
return {{Type{1} / (aspect_ratio * fov_half_tan), Type{0}, Type{0}, Type{0}},
{Type{0}, Type{1} / fov_half_tan, Type{0}, Type{0}},
{Type{0}, Type{0}, far / (far - near), -(near * far) / (far - near)},
{Type{0}, Type{0}, Type{1}, Type{0}}};
else if constexpr (DepthRange == NDCDepthRange::NEGATIVE_ONE_TO_ONE)
return {{1.f / (aspect_ratio * fov_half_tan), 0.f, 0.f, 0.f},
{0.f, 1.f / fov_half_tan, 0.f, 0.f},
{0.f, 0.f, (far + near) / (far - near), -(2.f * near * far) / (far - near)},
{0.f, 0.f, 1.f, 0.f}};
return {{Type{1} / (aspect_ratio * fov_half_tan), Type{0}, Type{0}, Type{0}},
{Type{0}, Type{1} / fov_half_tan, Type{0}, Type{0}},
{Type{0}, Type{0}, (far + near) / (far - near), -(Type{2} * near * far) / (far - near)},
{Type{0}, Type{0}, Type{1}, Type{0}}};
else
std::unreachable();
}
@@ -689,21 +689,74 @@ namespace omath
template<class Type = float, MatStoreType St = MatStoreType::ROW_MAJOR,
NDCDepthRange DepthRange = NDCDepthRange::NEGATIVE_ONE_TO_ONE>
[[nodiscard]]
Mat<4, 4, Type, St> mat_perspective_right_handed(const float field_of_view, const float aspect_ratio,
const float near, const float far) noexcept
Mat<4, 4, Type, St> mat_perspective_right_handed(const Type field_of_view, const Type aspect_ratio,
const Type near, const Type far) noexcept
{
const float fov_half_tan = std::tan(angles::degrees_to_radians(field_of_view) / 2.f);
const auto fov_half_tan = std::tan(angles::degrees_to_radians(field_of_view) / Type{2});
if constexpr (DepthRange == NDCDepthRange::ZERO_TO_ONE)
return {{1.f / (aspect_ratio * fov_half_tan), 0.f, 0.f, 0.f},
{0.f, 1.f / fov_half_tan, 0.f, 0.f},
{0.f, 0.f, -far / (far - near), -(near * far) / (far - near)},
{0.f, 0.f, -1.f, 0.f}};
return {{Type{1} / (aspect_ratio * fov_half_tan), Type{0}, Type{0}, Type{0}},
{Type{0}, Type{1} / fov_half_tan, Type{0}, Type{0}},
{Type{0}, Type{0}, -far / (far - near), -(near * far) / (far - near)},
{Type{0}, Type{0}, -Type{1}, Type{0}}};
else if constexpr (DepthRange == NDCDepthRange::NEGATIVE_ONE_TO_ONE)
return {{1.f / (aspect_ratio * fov_half_tan), 0.f, 0.f, 0.f},
{0.f, 1.f / fov_half_tan, 0.f, 0.f},
{0.f, 0.f, -(far + near) / (far - near), -(2.f * near * far) / (far - near)},
{0.f, 0.f, -1.f, 0.f}};
return {{Type{1} / (aspect_ratio * fov_half_tan), Type{0}, Type{0}, Type{0}},
{Type{0}, Type{1} / fov_half_tan, Type{0}, Type{0}},
{Type{0}, Type{0}, -(far + near) / (far - near), -(Type{2} * near * far) / (far - near)},
{Type{0}, Type{0}, -Type{1}, Type{0}}};
else
std::unreachable();
}
// Horizontal-FOV variants — use these when the engine reports FOV as
// horizontal (UE's FMinimalViewInfo::FOV, Quake-family fov_x, etc.).
// X and Y scales derived as: X = 1 / tan(hfov/2), Y = aspect / tan(hfov/2).
template<class Type = float, MatStoreType St = MatStoreType::ROW_MAJOR,
NDCDepthRange DepthRange = NDCDepthRange::NEGATIVE_ONE_TO_ONE>
[[nodiscard]]
Mat<4, 4, Type, St> mat_perspective_left_handed_horizontal_fov(const Type horizontal_fov,
const Type aspect_ratio, const Type near,
const Type far) noexcept
{
const auto inv_tan_half_hfov = Type{1} / std::tan(angles::degrees_to_radians(horizontal_fov) / Type{2});
const auto x_axis = inv_tan_half_hfov;
const auto y_axis = inv_tan_half_hfov * aspect_ratio;
if constexpr (DepthRange == NDCDepthRange::ZERO_TO_ONE)
return {{x_axis, Type{0}, Type{0}, Type{0}},
{Type{0}, y_axis, Type{0}, Type{0}},
{Type{0}, Type{0}, far / (far - near), -(near * far) / (far - near)},
{Type{0}, Type{0}, Type{1}, Type{0}}};
else if constexpr (DepthRange == NDCDepthRange::NEGATIVE_ONE_TO_ONE)
return {{x_axis, Type{0}, Type{0}, Type{0}},
{Type{0}, y_axis, Type{0}, Type{0}},
{Type{0}, Type{0}, (far + near) / (far - near), -(2.f * near * far) / (far - near)},
{Type{0}, Type{0}, Type{1}, Type{0}}};
else
std::unreachable();
}
template<class Type = float, MatStoreType St = MatStoreType::ROW_MAJOR,
NDCDepthRange DepthRange = NDCDepthRange::NEGATIVE_ONE_TO_ONE>
[[nodiscard]]
Mat<4, 4, Type, St> mat_perspective_right_handed_horizontal_fov(const Type horizontal_fov,
const Type aspect_ratio, const Type near,
const Type far) noexcept
{
const auto inv_tan_half_hfov = Type{1} / std::tan(angles::degrees_to_radians(horizontal_fov) / Type{2});
const auto x_axis = inv_tan_half_hfov;
const auto y_axis = inv_tan_half_hfov * aspect_ratio;
if constexpr (DepthRange == NDCDepthRange::ZERO_TO_ONE)
return {{x_axis, Type{0}, Type{0}, Type{0}},
{Type{0}, y_axis, Type{0}, Type{0}},
{Type{0}, Type{0}, -far / (far - near), -(near * far) / (far - near)},
{Type{0}, Type{0}, -Type{1}, Type{0}}};
else if constexpr (DepthRange == NDCDepthRange::NEGATIVE_ONE_TO_ONE)
return {{x_axis, Type{0}, Type{0}, Type{0}},
{Type{0}, y_axis, Type{0}, Type{0}},
{Type{0}, Type{0}, -(far + near) / (far - near), -(2.f * near * far) / (far - near)},
{Type{0}, Type{0}, -Type{1}, Type{0}}};
else
std::unreachable();
}

View File

@@ -0,0 +1,46 @@
//
// Created by orange on 4/12/2026.
//
#pragma once
#include "navigation_mesh.hpp"
#include "omath/linear_algebra/vector3.hpp"
#include <functional>
#include <memory>
namespace omath::pathfinding
{
enum class WalkBotStatus
{
IDLE,
PATHING,
FINISHED
};
class WalkBot
{
public:
WalkBot() = default;
explicit WalkBot(const std::shared_ptr<NavigationMesh>& mesh, float min_node_distance = 1.f);
void set_nav_mesh(const std::shared_ptr<NavigationMesh>& mesh);
void set_min_node_distance(float distance);
void set_target(const Vector3<float>& target);
// Clear navigation state so the bot can be re-routed without stale
// visited-node memory.
void reset();
// Call every game tick with the current bot world position.
void update(const Vector3<float>& bot_position);
void on_path(const std::function<void(const Vector3<float>&)>& callback);
void on_status(const std::function<void(WalkBotStatus)>& callback);
private:
std::weak_ptr<NavigationMesh> m_nav_mesh;
std::optional<std::function<void(const Vector3<float>&)>> m_on_next_path_node;
std::optional<std::function<void(WalkBotStatus)>> m_on_status_update;
std::optional<Vector3<float>> m_last_visited;
std::optional<Vector3<float>> m_target;
float m_min_node_distance{1.f};
};
} // namespace omath::pathfinding

View File

@@ -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"
@@ -41,25 +42,32 @@ namespace omath::projection
AUTO,
MANUAL,
};
template<class T, class MatType, class ViewAnglesType>
struct CameraAxes
{
bool inverted_forward = false;
bool inverted_right = false;
};
template<class T, class MatType, class ViewAnglesType, class NumericType>
concept CameraEngineConcept =
requires(const Vector3<float>& cam_origin, const Vector3<float>& look_at, const ViewAnglesType& angles,
const FieldOfView& fov, const ViewPort& viewport, float znear, float zfar,
NDCDepthRange ndc_depth_range) {
requires(const Vector3<NumericType>& cam_origin, const Vector3<NumericType>& look_at,
const ViewAnglesType& angles, const FieldOfView& fov, const ViewPort& viewport, NumericType z_near,
NumericType z_far, NDCDepthRange ndc_depth_range) {
// Presence + return types
{ T::calc_look_at_angle(cam_origin, look_at) } -> std::same_as<ViewAnglesType>;
{ T::calc_view_matrix(angles, cam_origin) } -> std::same_as<MatType>;
{ T::calc_projection_matrix(fov, viewport, znear, zfar, ndc_depth_range) } -> std::same_as<MatType>;
{ T::calc_projection_matrix(fov, viewport, z_near, z_far, ndc_depth_range) } -> std::same_as<MatType>;
requires std::is_floating_point_v<NumericType>;
// Enforce noexcept as in the trait declaration
requires noexcept(T::calc_look_at_angle(cam_origin, look_at));
requires noexcept(T::calc_view_matrix(angles, cam_origin));
requires noexcept(T::calc_projection_matrix(fov, viewport, znear, zfar, ndc_depth_range));
requires noexcept(T::calc_projection_matrix(fov, viewport, z_near, z_far, ndc_depth_range));
};
template<class Mat4X4Type, class ViewAnglesType, class TraitClass, bool inverted_z = false,
NDCDepthRange depth_range = NDCDepthRange::NEGATIVE_ONE_TO_ONE>
requires CameraEngineConcept<TraitClass, Mat4X4Type, ViewAnglesType>
template<class Mat4X4Type, class ViewAnglesType, class TraitClass,
NDCDepthRange depth_range = NDCDepthRange::NEGATIVE_ONE_TO_ONE, CameraAxes axes = {},
class NumericType = float>
requires CameraEngineConcept<TraitClass, Mat4X4Type, ViewAnglesType, NumericType>
class Camera final
{
#ifdef OMATH_BUILD_TESTS
@@ -75,48 +83,110 @@ namespace omath::projection
};
~Camera() = default;
Camera(const Vector3<float>& position, const ViewAnglesType& view_angles, const ViewPort& view_port,
const FieldOfView& fov, const float near, const float far) noexcept
Camera(const Vector3<NumericType>& position, const ViewAnglesType& view_angles, const ViewPort& view_port,
const FieldOfView& fov, const NumericType near, const NumericType far) noexcept
: m_view_port(view_port), m_field_of_view(fov), m_far_plane_distance(far), m_near_plane_distance(near),
m_view_angles(view_angles), m_origin(position)
{
}
void look_at(const Vector3<float>& target)
struct ProjectionParams final
{
FieldOfView fov;
NumericType aspect_ratio{};
};
// Recovers vertical FOV and aspect ratio from a perspective projection matrix
// built by any of the engine traits. Both variants (ZERO_TO_ONE and
// NEGATIVE_ONE_TO_ONE) share the same m[0,0]/m[1,1] layout, so this works
// regardless of the NDC depth range.
[[nodiscard]]
static ProjectionParams extract_projection_params(const Mat4X4Type& proj_matrix) noexcept
{
// m[1,1] == 1 / tan(fov/2) => fov = 2 * atan(1 / m[1,1])
const auto f = proj_matrix.at(1, 1);
// m[0,0] == m[1,1] / aspect_ratio => aspect = m[1,1] / m[0,0]
return {FieldOfView::from_radians(NumericType{2} * std::atan(NumericType{1} / f)),
f / proj_matrix.at(0, 0)};
}
[[nodiscard]]
static ViewAnglesType calc_view_angles_from_view_matrix(const Mat4X4Type& view_matrix) noexcept
{
Vector3<NumericType> forward_vector = {view_matrix[2, 0], view_matrix[2, 1], view_matrix[2, 2]};
if constexpr (axes.inverted_forward)
forward_vector = -forward_vector;
return TraitClass::calc_look_at_angle({}, forward_vector);
}
[[nodiscard]]
static Vector3<NumericType> calc_origin_from_view_matrix(const Mat4X4Type& view_matrix) noexcept
{
// The view matrix is R * T(-origin), so the last column stores t = -R * origin.
// Recovering origin: origin = -R^T * t
return {
-(view_matrix[0, 0] * view_matrix[0, 3] + view_matrix[1, 0] * view_matrix[1, 3]
+ view_matrix[2, 0] * view_matrix[2, 3]),
-(view_matrix[0, 1] * view_matrix[0, 3] + view_matrix[1, 1] * view_matrix[1, 3]
+ view_matrix[2, 1] * view_matrix[2, 3]),
-(view_matrix[0, 2] * view_matrix[0, 3] + view_matrix[1, 2] * view_matrix[1, 3]
+ view_matrix[2, 2] * view_matrix[2, 3]),
};
}
void look_at(const Vector3<NumericType>& target)
{
m_view_angles = TraitClass::calc_look_at_angle(m_origin, target);
m_view_projection_matrix = std::nullopt;
m_view_matrix = std::nullopt;
}
[[nodiscard]]
ViewAnglesType calc_look_at_angles(const Vector3<float>& look_to) const
ViewAnglesType calc_look_at_angles(const Vector3<NumericType>& look_to) const
{
return TraitClass::calc_look_at_angle(m_origin, look_to);
}
[[nodiscard]]
Vector3<float> get_forward() const noexcept
Vector3<NumericType> get_forward() const noexcept
{
const auto& view_matrix = get_view_matrix();
if constexpr (inverted_z)
return -Vector3<float>{view_matrix[2, 0], view_matrix[2, 1], view_matrix[2, 2]};
return {view_matrix[2, 0], view_matrix[2, 1], view_matrix[2, 2]};
}
[[nodiscard]]
Vector3<float> get_right() const noexcept
Vector3<NumericType> get_right() const noexcept
{
const auto& view_matrix = get_view_matrix();
return {view_matrix[0, 0], view_matrix[0, 1], view_matrix[0, 2]};
}
[[nodiscard]]
Vector3<float> get_up() const noexcept
Vector3<NumericType> get_up() const noexcept
{
const auto& view_matrix = get_view_matrix();
return {view_matrix[1, 0], view_matrix[1, 1], view_matrix[1, 2]};
}
[[nodiscard]]
Vector3<NumericType> get_abs_forward() const noexcept
{
if constexpr (axes.inverted_forward)
return -get_forward();
return get_forward();
}
[[nodiscard]]
Vector3<NumericType> get_abs_right() const noexcept
{
if constexpr (axes.inverted_right)
return -get_right();
return get_right();
}
[[nodiscard]]
Vector3<NumericType> get_abs_up() const noexcept
{
return get_up();
}
[[nodiscard]] const Mat4X4Type& get_view_projection_matrix() const noexcept
{
@@ -136,9 +206,8 @@ namespace omath::projection
[[nodiscard]] const Mat4X4Type& get_projection_matrix() const noexcept
{
if (!m_projection_matrix.has_value())
m_projection_matrix = TraitClass::calc_projection_matrix(m_field_of_view, m_view_port,
m_near_plane_distance, m_far_plane_distance,
depth_range);
m_projection_matrix = TraitClass::calc_projection_matrix(
m_field_of_view, m_view_port, m_near_plane_distance, m_far_plane_distance, depth_range);
return m_projection_matrix.value();
}
@@ -150,14 +219,14 @@ namespace omath::projection
m_projection_matrix = std::nullopt;
}
void set_near_plane(const float near_plane) noexcept
void set_near_plane(const NumericType near_plane) noexcept
{
m_near_plane_distance = near_plane;
m_view_projection_matrix = std::nullopt;
m_projection_matrix = std::nullopt;
}
void set_far_plane(const float far_plane) noexcept
void set_far_plane(const NumericType far_plane) noexcept
{
m_far_plane_distance = far_plane;
m_view_projection_matrix = std::nullopt;
@@ -171,7 +240,7 @@ namespace omath::projection
m_view_matrix = std::nullopt;
}
void set_origin(const Vector3<float>& origin) noexcept
void set_origin(const Vector3<NumericType>& origin) noexcept
{
m_origin = origin;
m_view_projection_matrix = std::nullopt;
@@ -189,12 +258,12 @@ namespace omath::projection
return m_field_of_view;
}
[[nodiscard]] const float& get_near_plane() const noexcept
[[nodiscard]] const NumericType& get_near_plane() const noexcept
{
return m_near_plane_distance;
}
[[nodiscard]] const float& get_far_plane() const noexcept
[[nodiscard]] const NumericType& get_far_plane() const noexcept
{
return m_far_plane_distance;
}
@@ -204,14 +273,14 @@ namespace omath::projection
return m_view_angles;
}
[[nodiscard]] const Vector3<float>& get_origin() const noexcept
[[nodiscard]] const Vector3<NumericType>& get_origin() const noexcept
{
return m_origin;
}
template<ScreenStart screen_start = ScreenStart::TOP_LEFT_CORNER>
[[nodiscard]] std::expected<Vector3<float>, Error>
world_to_screen(const Vector3<float>& world_position) const noexcept
[[nodiscard]] std::expected<Vector3<NumericType>, Error>
world_to_screen(const Vector3<NumericType>& world_position) const noexcept
{
const auto normalized_cords = world_to_view_port(world_position);
@@ -226,8 +295,8 @@ namespace omath::projection
std::unreachable();
}
template<ScreenStart screen_start = ScreenStart::TOP_LEFT_CORNER>
[[nodiscard]] std::expected<Vector3<float>, Error>
world_to_screen_unclipped(const Vector3<float>& world_position) const noexcept
[[nodiscard]] std::expected<Vector3<NumericType>, Error>
world_to_screen_unclipped(const Vector3<NumericType>& world_position) const noexcept
{
const auto normalized_cords = world_to_view_port(world_position, ViewPortClipping::MANUAL);
@@ -242,14 +311,14 @@ namespace omath::projection
std::unreachable();
}
[[nodiscard]] bool is_culled_by_frustum(const Triangle<Vector3<float>>& triangle) const noexcept
[[nodiscard]] bool is_culled_by_frustum(const Triangle<Vector3<NumericType>>& triangle) const noexcept
{
// Transform to clip space (before perspective divide)
auto to_clip = [this](const Vector3<float>& point)
auto to_clip = [this](const Vector3<NumericType>& point)
{
auto clip = get_view_projection_matrix()
* mat_column_from_vector<float, Mat4X4Type::get_store_ordering()>(point);
return std::array<float, 4>{
* mat_column_from_vector<NumericType, Mat4X4Type::get_store_ordering()>(point);
return std::array<NumericType, 4>{
clip.at(0, 0), // x
clip.at(1, 0), // y
clip.at(2, 0), // z
@@ -262,12 +331,13 @@ namespace omath::projection
const auto c2 = to_clip(triangle.m_vertex3);
// If all vertices are behind the camera (w <= 0), trivially reject
if (c0[3] <= 0.f && c1[3] <= 0.f && c2[3] <= 0.f)
if (c0[3] <= NumericType{0} && c1[3] <= NumericType{0} && c2[3] <= NumericType{0})
return true;
// Helper: all three vertices outside the same clip plane
auto all_outside_plane = [](const int axis, const std::array<float, 4>& a, const std::array<float, 4>& b,
const std::array<float, 4>& c, const bool positive_side)
auto all_outside_plane = [](const int axis, const std::array<NumericType, 4>& a,
const std::array<NumericType, 4>& b, const std::array<NumericType, 4>& c,
const bool positive_side)
{
if (positive_side)
return a[axis] > a[3] && b[axis] > b[3] && c[axis] > c[3];
@@ -308,15 +378,72 @@ namespace omath::projection
return false;
}
[[nodiscard]] std::expected<Vector3<float>, Error>
world_to_view_port(const Vector3<float>& world_position,
[[nodiscard]] bool is_aabb_culled_by_frustum(const primitives::Aabb<NumericType>& 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<NumericType>(sign) * m.at(row, 0),
m.at(3, 1) + static_cast<NumericType>(sign) * m.at(row, 1),
m.at(3, 2) + static_cast<NumericType>(sign) * m.at(row, 2),
m.at(3, 3) + static_cast<NumericType>(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 auto px = a >= NumericType{0} ? aabb.max.x : aabb.min.x;
const auto py = b >= NumericType{0} ? aabb.max.y : aabb.min.y;
const auto pz = c >= NumericType{0} ? aabb.max.z : aabb.min.z;
if (a * px + b * py + c * pz + d < NumericType{0})
return true;
}
return false;
}
[[nodiscard]] std::expected<Vector3<NumericType>, Error>
world_to_view_port(const Vector3<NumericType>& world_position,
const ViewPortClipping& clipping = ViewPortClipping::AUTO) const noexcept
{
auto projected = get_view_projection_matrix()
* mat_column_from_vector<float, Mat4X4Type::get_store_ordering()>(world_position);
* mat_column_from_vector<NumericType, Mat4X4Type::get_store_ordering()>(world_position);
const auto& w = projected.at(3, 0);
constexpr auto eps = std::numeric_limits<float>::epsilon();
constexpr auto eps = std::numeric_limits<NumericType>::epsilon();
if (w <= eps)
return std::unexpected(Error::PERSPECTIVE_DIVIDER_LESS_EQ_ZERO);
@@ -328,16 +455,17 @@ namespace omath::projection
return std::unexpected(Error::WORLD_POSITION_IS_OUT_OF_SCREEN_BOUNDS);
// ReSharper disable once CppTooWideScope
constexpr auto z_min = depth_range == NDCDepthRange::ZERO_TO_ONE ? 0.0f : -1.0f;
const auto clipped_manually = clipping == ViewPortClipping::MANUAL && (projected.at(2, 0) < z_min - eps
|| projected.at(2, 0) > 1.0f + eps);
constexpr auto z_min = depth_range == NDCDepthRange::ZERO_TO_ONE ? NumericType{0} : -NumericType{1};
const auto clipped_manually =
clipping == ViewPortClipping::MANUAL
&& (projected.at(2, 0) < z_min - eps || projected.at(2, 0) > NumericType{1} + eps);
if (clipped_manually)
return std::unexpected(Error::WORLD_POSITION_IS_OUT_OF_SCREEN_BOUNDS);
return Vector3<float>{projected.at(0, 0), projected.at(1, 0), projected.at(2, 0)};
return Vector3<NumericType>{projected.at(0, 0), projected.at(1, 0), projected.at(2, 0)};
}
[[nodiscard]]
std::expected<Vector3<float>, Error> view_port_to_world(const Vector3<float>& ndc) const noexcept
std::expected<Vector3<NumericType>, Error> view_port_to_world(const Vector3<NumericType>& ndc) const noexcept
{
const auto inv_view_proj = get_view_projection_matrix().inverted();
@@ -345,70 +473,72 @@ namespace omath::projection
return std::unexpected(Error::INV_VIEW_PROJ_MAT_DET_EQ_ZERO);
auto inverted_projection =
inv_view_proj.value() * mat_column_from_vector<float, Mat4X4Type::get_store_ordering()>(ndc);
inv_view_proj.value() * mat_column_from_vector<NumericType, Mat4X4Type::get_store_ordering()>(ndc);
const auto& w = inverted_projection.at(3, 0);
if (std::abs(w) < std::numeric_limits<float>::epsilon())
if (std::abs(w) < std::numeric_limits<NumericType>::epsilon())
return std::unexpected(Error::WORLD_POSITION_IS_OUT_OF_SCREEN_BOUNDS);
inverted_projection /= w;
return Vector3<float>{inverted_projection.at(0, 0), inverted_projection.at(1, 0),
inverted_projection.at(2, 0)};
return Vector3<NumericType>{inverted_projection.at(0, 0), inverted_projection.at(1, 0),
inverted_projection.at(2, 0)};
}
template<ScreenStart screen_start = ScreenStart::TOP_LEFT_CORNER>
[[nodiscard]]
std::expected<Vector3<float>, Error> screen_to_world(const Vector3<float>& screen_pos) const noexcept
std::expected<Vector3<NumericType>, Error>
screen_to_world(const Vector3<NumericType>& screen_pos) const noexcept
{
return view_port_to_world(screen_to_ndc<screen_start>(screen_pos));
}
template<ScreenStart screen_start = ScreenStart::TOP_LEFT_CORNER>
[[nodiscard]]
std::expected<Vector3<float>, Error> screen_to_world(const Vector2<float>& screen_pos) const noexcept
std::expected<Vector3<NumericType>, Error>
screen_to_world(const Vector2<NumericType>& screen_pos) const noexcept
{
const auto& [x, y] = screen_pos;
return screen_to_world<screen_start>({x, y, 1.f});
return screen_to_world<screen_start>({x, y, 1});
}
protected:
ViewPort m_view_port{};
Angle<float, 0.f, 180.f, AngleFlags::Clamped> m_field_of_view;
FieldOfView m_field_of_view;
mutable std::optional<Mat4X4Type> m_view_projection_matrix;
mutable std::optional<Mat4X4Type> m_projection_matrix;
mutable std::optional<Mat4X4Type> m_view_matrix;
float m_far_plane_distance;
float m_near_plane_distance;
NumericType m_far_plane_distance;
NumericType m_near_plane_distance;
ViewAnglesType m_view_angles;
Vector3<float> m_origin;
Vector3<NumericType> m_origin;
private:
template<class Type>
[[nodiscard]] constexpr static bool is_ndc_out_of_bounds(const Type& ndc) noexcept
{
constexpr auto eps = std::numeric_limits<float>::epsilon();
constexpr auto eps = std::numeric_limits<NumericType>::epsilon();
const auto& data = ndc.raw_array();
// x and y are always in [-1, 1]
if (data[0] < -1.0f - eps || data[0] > 1.0f + eps)
if (data[0] < -NumericType{1} - eps || data[0] > NumericType{1} + eps)
return true;
if (data[1] < -1.0f - eps || data[1] > 1.0f + eps)
if (data[1] < -NumericType{1} - eps || data[1] > NumericType{1} + eps)
return true;
return is_ndc_z_value_out_of_bounds(data[2]);
}
template<class ZType>
[[nodiscard]]
[[nodiscard]]
constexpr static bool is_ndc_z_value_out_of_bounds(const ZType& z_ndc) noexcept
{
constexpr auto eps = std::numeric_limits<float>::epsilon();
constexpr auto eps = std::numeric_limits<NumericType>::epsilon();
if constexpr (depth_range == NDCDepthRange::NEGATIVE_ONE_TO_ONE)
return z_ndc < -NumericType{1} - eps || z_ndc > NumericType{1} + eps;
if constexpr (depth_range == NDCDepthRange::ZERO_TO_ONE)
return z_ndc < 0.0f - eps || z_ndc > 1.0f + eps;
if constexpr (depth_range == NDCDepthRange::ZERO_TO_ONE)
return z_ndc < -1.0f - eps || z_ndc > 1.0f + eps;
return z_ndc < NumericType{0} - eps || z_ndc > NumericType{1} + eps;
std::unreachable();
}
@@ -427,8 +557,8 @@ namespace omath::projection
v
*/
[[nodiscard]] Vector3<float>
ndc_to_screen_position_from_top_left_corner(const Vector3<float>& ndc) const noexcept
[[nodiscard]] Vector3<NumericType>
ndc_to_screen_position_from_top_left_corner(const Vector3<NumericType>& ndc) const noexcept
{
/*
+------------------------>
@@ -441,11 +571,12 @@ namespace omath::projection
|
*/
return {(ndc.x + 1.f) / 2.f * m_view_port.m_width, (ndc.y / -2.f + 0.5f) * m_view_port.m_height, ndc.z};
return {(ndc.x + NumericType{1}) / NumericType{2} * m_view_port.m_width,
(ndc.y / -NumericType{2} + NumericType{0.5}) * m_view_port.m_height, ndc.z};
}
[[nodiscard]] Vector3<float>
ndc_to_screen_position_from_bottom_left_corner(const Vector3<float>& ndc) const noexcept
[[nodiscard]] Vector3<NumericType>
ndc_to_screen_position_from_bottom_left_corner(const Vector3<NumericType>& ndc) const noexcept
{
/*
^
@@ -458,18 +589,19 @@ namespace omath::projection
| (0, 0)
+------------------------>
*/
return {(ndc.x + 1.f) / 2.f * m_view_port.m_width, (ndc.y / 2.f + 0.5f) * m_view_port.m_height, ndc.z};
return {(ndc.x + NumericType{1}) / NumericType{2} * m_view_port.m_width,
(ndc.y / NumericType{2} + NumericType{0.5}) * m_view_port.m_height, ndc.z};
}
template<ScreenStart screen_start = ScreenStart::TOP_LEFT_CORNER>
[[nodiscard]] Vector3<float> screen_to_ndc(const Vector3<float>& screen_pos) const noexcept
[[nodiscard]] Vector3<NumericType> screen_to_ndc(const Vector3<NumericType>& screen_pos) const noexcept
{
if constexpr (screen_start == ScreenStart::TOP_LEFT_CORNER)
return {screen_pos.x / m_view_port.m_width * 2.f - 1.f, 1.f - screen_pos.y / m_view_port.m_height * 2.f,
screen_pos.z};
return {screen_pos.x / m_view_port.m_width * NumericType{2} - NumericType{1},
NumericType{1} - screen_pos.y / m_view_port.m_height * NumericType{2}, screen_pos.z};
else if constexpr (screen_start == ScreenStart::BOTTOM_LEFT_CORNER)
return {screen_pos.x / m_view_port.m_width * 2.f - 1.f,
(screen_pos.y / m_view_port.m_height - 0.5f) * 2.f, screen_pos.z};
return {screen_pos.x / m_view_port.m_width * NumericType{2} - NumericType{1},
(screen_pos.y / m_view_port.m_height - NumericType{0.5}) * NumericType{2}, screen_pos.z};
else
std::unreachable();
}

61
scripts/gource-timelapse.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/bin/bash
# =============================================================================
# Gource Timelapse — renders the repository history as a video
# Requires: gource, ffmpeg
# =============================================================================
set -euo pipefail
# --- Config (override via env vars) ---
OUTPUT="${OUTPUT:-gource-timelapse.mp4}"
SECONDS_PER_DAY="${SECONDS_PER_DAY:-0.1}"
RESOLUTION="${RESOLUTION:-1920x1080}"
FPS="${FPS:-60}"
TITLE="${TITLE:-omath}"
# --- Dependency checks ---
for cmd in gource ffmpeg; do
if ! command -v "$cmd" &>/dev/null; then
echo "Error: '$cmd' is not installed."
echo " macOS: brew install $cmd"
echo " Linux: sudo apt install $cmd"
exit 1
fi
done
echo "----------------------------------------------------"
echo "Rendering gource timelapse → $OUTPUT"
echo " Resolution : $RESOLUTION"
echo " FPS : $FPS"
echo " Speed : ${SECONDS_PER_DAY}s per day"
echo "----------------------------------------------------"
gource \
--title "$TITLE" \
--seconds-per-day "$SECONDS_PER_DAY" \
--auto-skip-seconds 0.1 \
--time-scale 3 \
--max-files 0 \
--hide filenames \
--date-format "%Y-%m-%d" \
--multi-sampling \
--bloom-multiplier 0.5 \
--elasticity 0.05 \
--${RESOLUTION%x*}x${RESOLUTION#*x} \
--output-framerate "$FPS" \
--output-ppm-stream - \
| ffmpeg -y \
-r "$FPS" \
-f image2pipe \
-vcodec ppm \
-i - \
-vcodec libx264 \
-preset fast \
-pix_fmt yuv420p \
-crf 18 \
"$OUTPUT"
echo "----------------------------------------------------"
echo "Done: $OUTPUT"
echo "----------------------------------------------------"

View File

@@ -38,25 +38,24 @@ namespace omath::source_engine
Mat4X4 calc_perspective_projection_matrix(const float field_of_view, const float aspect_ratio, const float near,
const float far, const NDCDepthRange ndc_depth_range) noexcept
{
// NOTE: Need magic number to fix fov calculation, since source inherit Quake proj matrix calculation
constexpr auto k_multiply_factor = 0.75f;
const float fov_half_tan = std::tan(angles::degrees_to_radians(field_of_view) / 2.f) * k_multiply_factor;
// Source (inherited from Quake) stores FOV as horizontal FOV at a 4:3
// reference aspect. Convert to true vertical FOV, then delegate to the
// standard vertical-FOV left-handed builder with the caller's actual
// aspect ratio.
// vfov = 2 · atan( tan(hfov_4:3 / 2) / (4/3) )
constexpr float k_source_reference_aspect = 4.f / 3.f;
const float half_hfov_4_3 = angles::degrees_to_radians(field_of_view) / 2.f;
const float vfov_deg = angles::radians_to_degrees(
2.f * std::atan(std::tan(half_hfov_4_3) / k_source_reference_aspect));
if (ndc_depth_range == NDCDepthRange::ZERO_TO_ONE)
return {
{1.f / (aspect_ratio * fov_half_tan), 0, 0, 0},
{0, 1.f / (fov_half_tan), 0, 0},
{0, 0, far / (far - near), -(near * far) / (far - near)},
{0, 0, 1, 0},
};
return mat_perspective_left_handed<
float, MatStoreType::ROW_MAJOR, NDCDepthRange::ZERO_TO_ONE>(
vfov_deg, aspect_ratio, near, far);
if (ndc_depth_range == NDCDepthRange::NEGATIVE_ONE_TO_ONE)
return {
{1.f / (aspect_ratio * fov_half_tan), 0, 0, 0},
{0, 1.f / (fov_half_tan), 0, 0},
{0, 0, (far + near) / (far - near), -(2.f * far * near) / (far - near)},
{0, 0, 1, 0},
};
return mat_perspective_left_handed<
float, MatStoreType::ROW_MAJOR, NDCDepthRange::NEGATIVE_ONE_TO_ONE>(
vfov_deg, aspect_ratio, near, far);
std::unreachable();
}
} // namespace omath::source_engine

View File

@@ -2,45 +2,56 @@
// Created by Vlad on 3/22/2025.
//
#include "omath/engines/unreal_engine/formulas.hpp"
namespace omath::unreal_engine
{
Vector3<float> forward_vector(const ViewAngles& angles) noexcept
Vector3<double> forward_vector(const ViewAngles& angles) noexcept
{
const auto vec = rotation_matrix(angles) * mat_column_from_vector(k_abs_forward);
return {vec.at(0, 0), vec.at(1, 0), vec.at(2, 0)};
}
Vector3<float> right_vector(const ViewAngles& angles) noexcept
Vector3<double> right_vector(const ViewAngles& angles) noexcept
{
const auto vec = rotation_matrix(angles) * mat_column_from_vector(k_abs_right);
return {vec.at(0, 0), vec.at(1, 0), vec.at(2, 0)};
}
Vector3<float> up_vector(const ViewAngles& angles) noexcept
Vector3<double> up_vector(const ViewAngles& angles) noexcept
{
const auto vec = rotation_matrix(angles) * mat_column_from_vector(k_abs_up);
return {vec.at(0, 0), vec.at(1, 0), vec.at(2, 0)};
}
Mat4X4 calc_view_matrix(const ViewAngles& angles, const Vector3<float>& cam_origin) noexcept
Mat4X4 calc_view_matrix(const ViewAngles& angles, const Vector3<double>& cam_origin) noexcept
{
return mat_camera_view<float, MatStoreType::ROW_MAJOR>(forward_vector(angles), -right_vector(angles),
return mat_camera_view<double, MatStoreType::ROW_MAJOR>(forward_vector(angles), right_vector(angles),
up_vector(angles), cam_origin);
}
Mat4X4 rotation_matrix(const ViewAngles& angles) noexcept
{
return mat_rotation_axis_x<float, MatStoreType::ROW_MAJOR>(angles.roll)
* mat_rotation_axis_z<float, MatStoreType::ROW_MAJOR>(angles.yaw)
* mat_rotation_axis_y<float, MatStoreType::ROW_MAJOR>(angles.pitch);
// UE FRotator is intrinsic Z-Y-X (Yaw → Pitch → Roll applied in local
// frame), which for column-vector composition is Rz·Ry·Rx.
// Pitch and roll axes in omath spin opposite to UE's convention, so
// both carry a sign flip.
return mat_rotation_axis_z<double, MatStoreType::ROW_MAJOR>(angles.yaw)
* mat_rotation_axis_y<double, MatStoreType::ROW_MAJOR>(-angles.pitch)
* mat_rotation_axis_x<double, MatStoreType::ROW_MAJOR>(-angles.roll);
}
Mat4X4 calc_perspective_projection_matrix(const float field_of_view, const float aspect_ratio, const float near,
const float far, const NDCDepthRange ndc_depth_range) noexcept
{
if (ndc_depth_range == NDCDepthRange::ZERO_TO_ONE)
return mat_perspective_left_handed<float, MatStoreType::ROW_MAJOR, NDCDepthRange::ZERO_TO_ONE>(
field_of_view, aspect_ratio, near, far);
return mat_perspective_left_handed(field_of_view, aspect_ratio, near, far);
Mat4X4 calc_perspective_projection_matrix(const double field_of_view, const double aspect_ratio, const double near,
const double far, const NDCDepthRange ndc_depth_range) noexcept
{
// UE stores horizontal FOV in FMinimalViewInfo — use the left-handed
// horizontal-FOV builder directly.
if (ndc_depth_range == NDCDepthRange::ZERO_TO_ONE)
return mat_perspective_left_handed_horizontal_fov<
double, MatStoreType::ROW_MAJOR, NDCDepthRange::ZERO_TO_ONE>(
field_of_view, aspect_ratio, near, far);
if (ndc_depth_range == NDCDepthRange::NEGATIVE_ONE_TO_ONE)
return mat_perspective_left_handed_horizontal_fov<
double, MatStoreType::ROW_MAJOR, NDCDepthRange::NEGATIVE_ONE_TO_ONE>(
field_of_view, aspect_ratio, near, far);
std::unreachable();
}
} // namespace omath::unreal_engine

View File

@@ -6,20 +6,20 @@
namespace omath::unreal_engine
{
ViewAngles CameraTrait::calc_look_at_angle(const Vector3<float>& cam_origin, const Vector3<float>& look_at) noexcept
ViewAngles CameraTrait::calc_look_at_angle(const Vector3<double>& cam_origin, const Vector3<double>& look_at) noexcept
{
const auto direction = (look_at - cam_origin).normalized();
return {PitchAngle::from_radians(-std::asin(direction.z)),
return {PitchAngle::from_radians(std::asin(direction.z)),
YawAngle::from_radians(std::atan2(direction.y, direction.x)), RollAngle::from_radians(0.f)};
}
Mat4X4 CameraTrait::calc_view_matrix(const ViewAngles& angles, const Vector3<float>& cam_origin) noexcept
Mat4X4 CameraTrait::calc_view_matrix(const ViewAngles& angles, const Vector3<double>& cam_origin) noexcept
{
return unreal_engine::calc_view_matrix(angles, cam_origin);
}
Mat4X4 CameraTrait::calc_projection_matrix(const projection::FieldOfView& fov,
const projection::ViewPort& view_port, const float near,
const float far, const NDCDepthRange ndc_depth_range) noexcept
const projection::ViewPort& view_port, const double near,
const double far, const NDCDepthRange ndc_depth_range) noexcept
{
return calc_perspective_projection_matrix(fov.as_degrees(), view_port.aspect_ratio(), near, far,
ndc_depth_range);

View File

@@ -78,7 +78,8 @@ namespace
}
// Register an engine: alias shared types, register unique Camera
template<class EngineTraits>
template<class EngineTraits, class ArithmeticType = float>
requires std::is_arithmetic_v<ArithmeticType>
void register_engine(sol::table& omath_table, const char* subtable_name)
{
using PitchAngle = typename EngineTraits::PitchAngle;
@@ -92,9 +93,9 @@ namespace
engine_table.new_usertype<Camera>(
"Camera",
sol::constructors<Camera(const omath::Vector3<float>&, const ViewAngles&,
sol::constructors<Camera(const omath::Vector3<ArithmeticType>&, const ViewAngles&,
const omath::projection::ViewPort&, const omath::projection::FieldOfView&,
float, float)>(),
ArithmeticType, ArithmeticType)>(),
"look_at", &Camera::look_at, "get_forward", &Camera::get_forward, "get_right", &Camera::get_right,
"get_up", &Camera::get_up, "get_origin", &Camera::get_origin, "get_view_angles",
&Camera::get_view_angles, "get_near_plane", &Camera::get_near_plane, "get_far_plane",
@@ -104,8 +105,8 @@ namespace
&Camera::set_near_plane, "set_far_plane", &Camera::set_far_plane,
"world_to_screen",
[](const Camera& cam, const omath::Vector3<float>& pos)
-> std::tuple<sol::optional<omath::Vector3<float>>, sol::optional<std::string>>
[](const Camera& cam, const omath::Vector3<ArithmeticType>& pos)
-> std::tuple<sol::optional<omath::Vector3<ArithmeticType>>, sol::optional<std::string>>
{
auto result = cam.world_to_screen(pos);
if (result)
@@ -114,8 +115,8 @@ namespace
},
"screen_to_world",
[](const Camera& cam, const omath::Vector3<float>& pos)
-> std::tuple<sol::optional<omath::Vector3<float>>, sol::optional<std::string>>
[](const Camera& cam, const omath::Vector3<ArithmeticType>& pos)
-> std::tuple<sol::optional<omath::Vector3<ArithmeticType>>, sol::optional<std::string>>
{
auto result = cam.screen_to_world(pos);
if (result)
@@ -224,7 +225,7 @@ namespace omath::lua
register_engine<IWEngineTraits>(omath_table, "iw");
register_engine<SourceEngineTraits>(omath_table, "source");
register_engine<UnityEngineTraits>(omath_table, "unity");
register_engine<UnrealEngineTraits>(omath_table, "unreal");
register_engine<UnrealEngineTraits, double>(omath_table, "unreal");
register_engine<CryEngineTraits>(omath_table, "cry");
}
} // namespace omath::lua::detail

View File

@@ -87,11 +87,11 @@ namespace omath::pathfinding
const auto current_node = current_node_it->second;
closed_list.emplace(current, current_node);
if (current == end_vertex)
return reconstruct_final_path(closed_list, current);
closed_list.emplace(current, current_node);
for (const auto& neighbor: nav_mesh.get_neighbors(current))
{
if (closed_list.contains(neighbor))

View File

@@ -0,0 +1,92 @@
//
// Created by orange on 4/12/2026.
//
#include "omath/pathfinding/walk_bot.hpp"
#include "omath/pathfinding/a_star.hpp"
namespace omath::pathfinding
{
WalkBot::WalkBot(const std::shared_ptr<NavigationMesh>& mesh, const float min_node_distance)
: m_nav_mesh(mesh), m_min_node_distance(min_node_distance) {}
void WalkBot::set_nav_mesh(const std::shared_ptr<NavigationMesh>& mesh)
{
m_nav_mesh = mesh;
}
void WalkBot::set_min_node_distance(const float distance)
{
m_min_node_distance = distance;
}
void WalkBot::set_target(const Vector3<float>& target)
{
m_target = target;
}
void WalkBot::reset()
{
m_last_visited.reset();
}
void WalkBot::update(const Vector3<float>& bot_position)
{
if (!m_target.has_value())
return;
if (m_target->distance_to(bot_position) <= m_min_node_distance)
{
if (m_on_status_update.has_value())
m_on_status_update->operator()(WalkBotStatus::FINISHED);
return;
}
if (!m_on_next_path_node.has_value())
return;
const auto nav_mesh = m_nav_mesh.lock();
if (!nav_mesh)
{
if (m_on_status_update.has_value())
m_on_status_update->operator()(WalkBotStatus::IDLE);
return;
}
const auto path = Astar::find_path(bot_position, *m_target, *nav_mesh);
if (path.empty())
{
if (m_on_status_update.has_value())
m_on_status_update->operator()(WalkBotStatus::IDLE);
return;
}
const auto& nearest = path.front();
// Record the nearest node as visited once we are close enough to it.
if (nearest.distance_to(bot_position) <= m_min_node_distance)
m_last_visited = nearest;
// If the nearest node was already visited, advance to the next one so
// we never oscillate back to a node we just left.
// If the bot was displaced (blown back), nearest will be an unvisited
// node, so we route to it first before continuing forward.
if (m_last_visited.has_value() && *m_last_visited == nearest && path.size() > 1)
m_on_next_path_node->operator()(path[1]);
else
m_on_next_path_node->operator()(nearest);
if (m_on_status_update.has_value())
m_on_status_update->operator()(WalkBotStatus::PATHING);
}
void WalkBot::on_path(const std::function<void(const Vector3<float>&)>& callback)
{
m_on_next_path_node = callback;
}
void WalkBot::on_status(const std::function<void(WalkBotStatus)>& callback)
{
m_on_status_update = callback;
}
} // namespace omath::pathfinding

View File

@@ -453,3 +453,184 @@ TEST(unit_test_frostbite_engine, ViewAnglesAsVector3NormalizedYaw)
EXPECT_NEAR(vec.y, -90.f, 0.01f);
}
// ---------------------------------------------------------------------------
// extract_projection_params
// ---------------------------------------------------------------------------
// Tolerance: tan/atan round-trip in single precision introduces ~1e-5 rad
// error, which is ~5.7e-4 degrees.
static constexpr float k_fov_tolerance_deg = 0.001f;
static constexpr float k_aspect_tolerance = 1e-5f;
TEST(unit_test_frostbite_engine, ExtractProjectionParams_BasicRoundTrip)
{
// Build a matrix with known inputs and verify both outputs are recovered.
constexpr float fov_deg = 60.f;
constexpr float aspect = 16.f / 9.f;
const auto mat = omath::frostbite_engine::calc_perspective_projection_matrix(
fov_deg, aspect, 0.1f, 1000.f, omath::NDCDepthRange::ZERO_TO_ONE);
const auto [fov, ar] = omath::frostbite_engine::Camera::extract_projection_params(mat);
EXPECT_NEAR(fov.as_degrees(), fov_deg, k_fov_tolerance_deg);
EXPECT_NEAR(ar, aspect, k_aspect_tolerance);
}
TEST(unit_test_frostbite_engine, ExtractProjectionParams_NegOneToOneDepthRange)
{
// The FOV/aspect encoding in rows 0 and 1 is identical for both NDC
// depth ranges, so extraction must work the same way.
constexpr float fov_deg = 75.f;
constexpr float aspect = 4.f / 3.f;
const auto mat = omath::frostbite_engine::calc_perspective_projection_matrix(
fov_deg, aspect, 0.1f, 500.f, omath::NDCDepthRange::NEGATIVE_ONE_TO_ONE);
const auto [fov, ar] = omath::frostbite_engine::Camera::extract_projection_params(mat);
EXPECT_NEAR(fov.as_degrees(), fov_deg, k_fov_tolerance_deg);
EXPECT_NEAR(ar, aspect, k_aspect_tolerance);
}
TEST(unit_test_frostbite_engine, ExtractProjectionParams_Fov45)
{
constexpr float fov_deg = 45.f;
constexpr float aspect = 16.f / 9.f;
const auto mat = omath::frostbite_engine::calc_perspective_projection_matrix(
fov_deg, aspect, 0.01f, 1000.f);
const auto [fov, ar] = omath::frostbite_engine::Camera::extract_projection_params(mat);
EXPECT_NEAR(fov.as_degrees(), fov_deg, k_fov_tolerance_deg);
EXPECT_NEAR(ar, aspect, k_aspect_tolerance);
}
TEST(unit_test_frostbite_engine, ExtractProjectionParams_Fov90)
{
constexpr float fov_deg = 90.f;
constexpr float aspect = 16.f / 9.f;
const auto mat = omath::frostbite_engine::calc_perspective_projection_matrix(
fov_deg, aspect, 0.01f, 1000.f);
const auto [fov, ar] = omath::frostbite_engine::Camera::extract_projection_params(mat);
EXPECT_NEAR(fov.as_degrees(), fov_deg, k_fov_tolerance_deg);
EXPECT_NEAR(ar, aspect, k_aspect_tolerance);
}
TEST(unit_test_frostbite_engine, ExtractProjectionParams_Fov120)
{
constexpr float fov_deg = 120.f;
constexpr float aspect = 16.f / 9.f;
const auto mat = omath::frostbite_engine::calc_perspective_projection_matrix(
fov_deg, aspect, 0.01f, 1000.f);
const auto [fov, ar] = omath::frostbite_engine::Camera::extract_projection_params(mat);
EXPECT_NEAR(fov.as_degrees(), fov_deg, k_fov_tolerance_deg);
EXPECT_NEAR(ar, aspect, k_aspect_tolerance);
}
TEST(unit_test_frostbite_engine, ExtractProjectionParams_AspectRatio_4by3)
{
constexpr float fov_deg = 60.f;
constexpr float aspect = 4.f / 3.f;
const auto mat = omath::frostbite_engine::calc_perspective_projection_matrix(
fov_deg, aspect, 0.1f, 500.f);
const auto [fov, ar] = omath::frostbite_engine::Camera::extract_projection_params(mat);
EXPECT_NEAR(fov.as_degrees(), fov_deg, k_fov_tolerance_deg);
EXPECT_NEAR(ar, aspect, k_aspect_tolerance);
}
TEST(unit_test_frostbite_engine, ExtractProjectionParams_AspectRatio_Ultrawide)
{
constexpr float fov_deg = 90.f;
constexpr float aspect = 21.f / 9.f;
const auto mat = omath::frostbite_engine::calc_perspective_projection_matrix(
fov_deg, aspect, 0.1f, 500.f);
const auto [fov, ar] = omath::frostbite_engine::Camera::extract_projection_params(mat);
EXPECT_NEAR(fov.as_degrees(), fov_deg, k_fov_tolerance_deg);
EXPECT_NEAR(ar, aspect, k_aspect_tolerance);
}
TEST(unit_test_frostbite_engine, ExtractProjectionParams_AspectRatio_Square)
{
constexpr float fov_deg = 90.f;
constexpr float aspect = 1.f;
const auto mat = omath::frostbite_engine::calc_perspective_projection_matrix(
fov_deg, aspect, 0.1f, 500.f);
const auto [fov, ar] = omath::frostbite_engine::Camera::extract_projection_params(mat);
EXPECT_NEAR(fov.as_degrees(), fov_deg, k_fov_tolerance_deg);
EXPECT_NEAR(ar, aspect, k_aspect_tolerance);
}
TEST(unit_test_frostbite_engine, ExtractProjectionParams_FovAndAspectAreIndependent)
{
// Changing only FOV must not affect recovered aspect ratio, and vice versa.
constexpr float aspect = 16.f / 9.f;
for (const float fov_deg : {45.f, 60.f, 90.f, 110.f})
{
const auto mat = omath::frostbite_engine::calc_perspective_projection_matrix(
fov_deg, aspect, 0.1f, 1000.f);
const auto [fov, ar] = omath::frostbite_engine::Camera::extract_projection_params(mat);
EXPECT_NEAR(fov.as_degrees(), fov_deg, k_fov_tolerance_deg);
EXPECT_NEAR(ar, aspect, k_aspect_tolerance);
}
}
TEST(unit_test_frostbite_engine, ExtractProjectionParams_ViaCamera_RoundTrip)
{
// End-to-end: construct a Camera, retrieve its projection matrix, then
// recover the FOV and aspect ratio and compare against the original inputs.
constexpr auto fov_in = omath::projection::FieldOfView::from_degrees(90.f);
constexpr float aspect = 1920.f / 1080.f;
const auto cam = omath::frostbite_engine::Camera(
{0.f, 0.f, 0.f}, {}, {1920.f, 1080.f}, fov_in, 0.01f, 1000.f);
const auto [fov_out, ar_out] =
omath::frostbite_engine::Camera::extract_projection_params(cam.get_projection_matrix());
EXPECT_NEAR(fov_out.as_degrees(), fov_in.as_degrees(), k_fov_tolerance_deg);
EXPECT_NEAR(ar_out, aspect, k_aspect_tolerance);
}
TEST(unit_test_frostbite_engine, ExtractProjectionParams_ViaCamera_AfterFovChange)
{
// Verify that the extracted FOV tracks the camera's FOV after set_field_of_view().
auto cam = omath::frostbite_engine::Camera(
{0.f, 0.f, 0.f}, {}, {1920.f, 1080.f},
omath::projection::FieldOfView::from_degrees(60.f), 0.01f, 1000.f);
cam.set_field_of_view(omath::projection::FieldOfView::from_degrees(110.f));
const auto [fov, ar] =
omath::frostbite_engine::Camera::extract_projection_params(cam.get_projection_matrix());
EXPECT_NEAR(fov.as_degrees(), 110.f, k_fov_tolerance_deg);
EXPECT_NEAR(ar, 1920.f / 1080.f, k_aspect_tolerance);
}
TEST(unit_test_frostbite_engine, ExtractProjectionParams_ViaCamera_AfterViewportChange)
{
// Verify that the extracted aspect ratio tracks the viewport after set_view_port().
auto cam = omath::frostbite_engine::Camera(
{0.f, 0.f, 0.f}, {}, {1920.f, 1080.f},
omath::projection::FieldOfView::from_degrees(90.f), 0.01f, 1000.f);
cam.set_view_port({1280.f, 720.f});
const auto [fov, ar] =
omath::frostbite_engine::Camera::extract_projection_params(cam.get_projection_matrix());
EXPECT_NEAR(fov.as_degrees(), 90.f, k_fov_tolerance_deg);
EXPECT_NEAR(ar, 1280.f / 720.f, k_aspect_tolerance);
}

View File

@@ -499,10 +499,11 @@ TEST(NDCDepthRangeTests, CryEngine_BothDepthRanges)
// ── Verify Z mapping for ZERO_TO_ONE across all engines ─────────────────────
// Helper: projects a point at given z through a left-handed projection matrix and returns NDC z
static float project_z_lh(const Mat<4, 4>& proj, float z)
template<class Type = float, MatStoreType Store = MatStoreType::ROW_MAJOR>
static float project_z_lh(const Mat<4, 4, Type, Store>& proj, float z)
{
auto clip = proj * mat_column_from_vector<float>({0, 0, z});
return clip.at(2, 0) / clip.at(3, 0);
auto clip = proj * mat_column_from_vector<Type, Store>({0, 0, static_cast<Type>(z)});
return static_cast<float>(clip.at(2, 0) / clip.at(3, 0));
}
TEST(NDCDepthRangeTests, Source_ZeroToOne_ZRange)

View File

@@ -32,7 +32,7 @@ TEST(unit_test_unreal_engine, ForwardVectorRotationPitch)
{
omath::unreal_engine::ViewAngles angles;
angles.pitch = omath::unreal_engine::PitchAngle::from_degrees(-90.f);
angles.pitch = omath::unreal_engine::PitchAngle::from_degrees(90.f);
const auto forward = omath::unreal_engine::forward_vector(angles);
EXPECT_NEAR(forward.x, omath::unreal_engine::k_abs_up.x, 0.00001f);
@@ -44,7 +44,7 @@ TEST(unit_test_unreal_engine, ForwardVectorRotationRoll)
{
omath::unreal_engine::ViewAngles angles;
angles.roll = omath::unreal_engine::RollAngle::from_degrees(-90.f);
angles.roll = omath::unreal_engine::RollAngle::from_degrees(90.f);
const auto forward = omath::unreal_engine::up_vector(angles);
EXPECT_NEAR(forward.x, omath::unreal_engine::k_abs_right.x, 0.00001f);
@@ -111,7 +111,7 @@ TEST(unit_test_unreal_engine, CameraSetAndGetOrigin)
{
auto cam = omath::unreal_engine::Camera({0, 0, 0}, {}, {1920.f, 1080.f}, {}, 0.01f, 1000.f);
EXPECT_EQ(cam.get_origin(), omath::Vector3<float>{});
EXPECT_EQ(cam.get_origin(), omath::Vector3<double>{});
cam.set_field_of_view(omath::projection::FieldOfView::from_degrees(50.f));
EXPECT_EQ(cam.get_field_of_view().as_degrees(), 50.f);
@@ -129,7 +129,7 @@ TEST(unit_test_unreal_engine, loook_at_random_all_axis)
std::size_t failed_points = 0;
for (int i = 0; i < 100; i++)
{
const auto position_to_look = omath::Vector3<float>{dist(gen), dist(gen), dist(gen)};
const auto position_to_look = omath::Vector3<double>{dist(gen), dist(gen), dist(gen)};
if (cam.get_origin().distance_to(position_to_look) < 10)
continue;
@@ -151,7 +151,7 @@ TEST(unit_test_unreal_engine, loook_at_random_all_axis)
TEST(unit_test_unreal_engine, loook_at_random_x_axis)
{
std::mt19937 gen(std::random_device{}()); // Seed with a non-deterministic source
std::uniform_real_distribution<float> dist(-1000.f, 1000.f);
std::uniform_real_distribution<double> dist(-1000.f, 1000.f);
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
auto cam = omath::unreal_engine::Camera({0, 0, 0}, {}, {1920.f, 1080.f}, fov, 0.001f, 10000.f);
@@ -159,7 +159,7 @@ TEST(unit_test_unreal_engine, loook_at_random_x_axis)
std::size_t failed_points = 0;
for (int i = 0; i < 1000; i++)
{
const auto position_to_look = omath::Vector3<float>{dist(gen), dist(gen), dist(gen)};
const auto position_to_look = omath::Vector3<double>{dist(gen), dist(gen), dist(gen)};
if (cam.get_origin().distance_to(position_to_look) < 10)
continue;
@@ -190,7 +190,7 @@ TEST(unit_test_unreal_engine, loook_at_random_y_axis)
std::size_t failed_points = 0;
for (int i = 0; i < 1000; i++)
{
const auto position_to_look = omath::Vector3<float>{0.f, dist(gen), 0.f};
const auto position_to_look = omath::Vector3<double>{0.f, dist(gen), 0.f};
if (cam.get_origin().distance_to(position_to_look) < 10)
continue;
@@ -221,7 +221,7 @@ TEST(unit_test_unreal_engine, loook_at_random_z_axis)
std::size_t failed_points = 0;
for (int i = 0; i < 1000; i++)
{
const auto position_to_look = omath::Vector3<float>{0.f, 0.f, dist(gen)};
const auto position_to_look = omath::Vector3<double>{0.f, 0.f, dist(gen)};
if (cam.get_origin().distance_to(position_to_look) < 10)
continue;

View File

@@ -20,19 +20,19 @@
#include <vector>
#if defined(__linux__)
# include <unistd.h>
# include <fcntl.h>
# if defined(__ANDROID__)
# if __ANDROID_API__ >= 30
# include <sys/mman.h>
# define OMATH_TEST_USE_MEMFD 1
# endif
#include <fcntl.h>
#include <unistd.h>
#if defined(__ANDROID__)
#if __ANDROID_API__ >= 30
#include <sys/mman.h>
#define OMATH_TEST_USE_MEMFD 1
#endif
// Android < 30: fall through to tmpfile() path below
# else
#else
// Desktop Linux: memfd_create available since glibc 2.27 / kernel 3.17
# include <sys/mman.h>
# define OMATH_TEST_USE_MEMFD 1
# endif
#include <sys/mman.h>
#define OMATH_TEST_USE_MEMFD 1
#endif
#endif
class MemFdFile
@@ -57,9 +57,11 @@ public:
MemFdFile(MemFdFile&& o) noexcept
: m_path(std::move(o.m_path))
#if defined(OMATH_TEST_USE_MEMFD)
, m_fd(o.m_fd)
,
m_fd(o.m_fd)
#else
, m_temp_path(std::move(o.m_temp_path))
,
m_temp_path(std::move(o.m_temp_path))
#endif
{
#if defined(OMATH_TEST_USE_MEMFD)
@@ -69,9 +71,15 @@ public:
#endif
}
[[nodiscard]] bool valid() const { return !m_path.empty(); }
[[nodiscard]] bool valid() const
{
return !m_path.empty();
}
[[nodiscard]] const std::filesystem::path& path() const { return m_path; }
[[nodiscard]] const std::filesystem::path& path() const
{
return m_path;
}
static MemFdFile create(const std::vector<std::uint8_t>& data)
{
@@ -101,7 +109,7 @@ public:
std::mt19937_64 rng(std::random_device{}());
const auto unique_name = "omath_test_" + std::to_string(rng()) + ".bin";
f.m_temp_path = (tmp_dir / unique_name).string();
f.m_path = f.m_temp_path;
f.m_path = f.m_temp_path;
std::ofstream out(f.m_temp_path, std::ios::binary | std::ios::trunc);
if (!out.is_open())
@@ -153,38 +161,40 @@ private:
// ---------------------------------------------------------------------------
inline std::vector<std::uint8_t> build_minimal_pe(const std::vector<std::uint8_t>& section_bytes)
{
constexpr std::uint32_t e_lfanew = 0x80u;
constexpr std::uint16_t size_opt = 0xF0u;
constexpr std::size_t nt_off = e_lfanew;
constexpr std::size_t fh_off = nt_off + 4;
constexpr std::size_t oh_off = fh_off + 20;
constexpr std::size_t sh_off = oh_off + size_opt;
constexpr std::size_t data_off = sh_off + 44;
constexpr std::uint32_t e_lfanew = 0x80u;
constexpr std::uint16_t size_opt = 0xF0u;
constexpr std::size_t nt_off = e_lfanew;
constexpr std::size_t fh_off = nt_off + 4;
constexpr std::size_t oh_off = fh_off + 20;
constexpr std::size_t sh_off = oh_off + size_opt;
constexpr std::size_t data_off = sh_off + 44;
std::vector<std::uint8_t> buf(data_off + section_bytes.size(), 0u);
buf[0] = 'M'; buf[1] = 'Z';
buf[0] = 'M';
buf[1] = 'Z';
std::memcpy(buf.data() + 0x3Cu, &e_lfanew, 4);
buf[nt_off] = 'P'; buf[nt_off + 1] = 'E';
buf[nt_off] = 'P';
buf[nt_off + 1] = 'E';
const std::uint16_t machine = 0x8664u, num_sections = 1u;
std::memcpy(buf.data() + fh_off, &machine, 2);
std::memcpy(buf.data() + fh_off + 2, &num_sections, 2);
std::memcpy(buf.data() + fh_off + 16, &size_opt, 2);
constexpr std::uint16_t machine = 0x8664u, num_sections = 1u;
std::memcpy(buf.data() + fh_off, &machine, 2);
std::memcpy(buf.data() + fh_off + 2, &num_sections, 2);
std::memcpy(buf.data() + fh_off + 16, &size_opt, 2);
const std::uint16_t magic = 0x20Bu;
constexpr std::uint16_t magic = 0x20Bu;
std::memcpy(buf.data() + oh_off, &magic, 2);
const char name[8] = {'.','t','e','x','t',0,0,0};
constexpr char name[8] = {'.', 't', 'e', 'x', 't', 0, 0, 0};
std::memcpy(buf.data() + sh_off, name, 8);
const auto vsize = static_cast<std::uint32_t>(section_bytes.size());
const std::uint32_t vaddr = 0x1000u;
const auto ptr_raw = static_cast<std::uint32_t>(data_off);
std::memcpy(buf.data() + sh_off + 8, &vsize, 4);
std::memcpy(buf.data() + sh_off + 12, &vaddr, 4);
std::memcpy(buf.data() + sh_off + 16, &vsize, 4);
const auto vsize = static_cast<std::uint32_t>(section_bytes.size());
constexpr std::uint32_t vaddr = 0x1000u;
constexpr auto ptr_raw = static_cast<std::uint32_t>(data_off);
std::memcpy(buf.data() + sh_off + 8, &vsize, 4);
std::memcpy(buf.data() + sh_off + 12, &vaddr, 4);
std::memcpy(buf.data() + sh_off + 16, &vsize, 4);
std::memcpy(buf.data() + sh_off + 20, &ptr_raw, 4);
std::memcpy(buf.data() + data_off, section_bytes.data(), section_bytes.size());

View File

@@ -40,8 +40,9 @@ TEST(AStarExtra, TrivialNeighbor)
nav.m_vertex_map[v2] = {v1};
const auto path = Astar::find_path(v1, v2, nav);
ASSERT_EQ(path.size(), 1u);
EXPECT_EQ(path.front(), v2);
ASSERT_EQ(path.size(), 2u);
EXPECT_EQ(path.front(), v1);
EXPECT_EQ(path.back(), v2);
}
TEST(AStarExtra, StartEqualsGoal)
@@ -101,7 +102,7 @@ TEST(AStarExtra, LongerPathAvoidsBlock)
constexpr Vector3<float> goal = idx(2, 1);
const auto path = Astar::find_path(start, goal, nav);
ASSERT_FALSE(path.empty());
EXPECT_EQ(path.front(), goal);
EXPECT_EQ(path.back(), goal);
}
TEST(AstarTests, TrivialDirectNeighborPath)
@@ -114,8 +115,9 @@ TEST(AstarTests, TrivialDirectNeighborPath)
nav.m_vertex_map.emplace(v2, std::vector<Vector3<float>>{v1});
const auto path = Astar::find_path(v1, v2, nav);
ASSERT_EQ(path.size(), 1u);
EXPECT_EQ(path.front(), v2);
ASSERT_EQ(path.size(), 2u);
EXPECT_EQ(path.front(), v1);
EXPECT_EQ(path.back(), v2);
}
TEST(AstarTests, NoPathWhenDisconnected)

View File

@@ -0,0 +1,240 @@
//
// Created by Vladislav on 19.04.2026.
//
#include <gtest/gtest.h>
#include "omath/3d_primitives/aabb.hpp"
using AABB = omath::primitives::Aabb<float>;
using Vec3 = omath::Vector3<float>;
// --- center() ---
TEST(AabbTests, CenterOfSymmetricBox)
{
constexpr AABB box{{-1.f, -1.f, -1.f}, {1.f, 1.f, 1.f}};
constexpr auto c = box.center();
EXPECT_FLOAT_EQ(c.x, 0.f);
EXPECT_FLOAT_EQ(c.y, 0.f);
EXPECT_FLOAT_EQ(c.z, 0.f);
}
TEST(AabbTests, CenterOfOffsetBox)
{
constexpr AABB box{{1.f, 2.f, 3.f}, {3.f, 6.f, 7.f}};
constexpr auto c = box.center();
EXPECT_FLOAT_EQ(c.x, 2.f);
EXPECT_FLOAT_EQ(c.y, 4.f);
EXPECT_FLOAT_EQ(c.z, 5.f);
}
TEST(AabbTests, CenterOfDegenerateBox)
{
constexpr AABB box{{5.f, 5.f, 5.f}, {5.f, 5.f, 5.f}};
constexpr auto c = box.center();
EXPECT_FLOAT_EQ(c.x, 5.f);
EXPECT_FLOAT_EQ(c.y, 5.f);
EXPECT_FLOAT_EQ(c.z, 5.f);
}
// --- extents() ---
TEST(AabbTests, ExtentsOfSymmetricBox)
{
constexpr AABB box{{-2.f, -3.f, -4.f}, {2.f, 3.f, 4.f}};
constexpr auto e = box.extents();
EXPECT_FLOAT_EQ(e.x, 2.f);
EXPECT_FLOAT_EQ(e.y, 3.f);
EXPECT_FLOAT_EQ(e.z, 4.f);
}
TEST(AabbTests, ExtentsOfUnitBox)
{
constexpr AABB box{{0.f, 0.f, 0.f}, {2.f, 2.f, 2.f}};
constexpr auto e = box.extents();
EXPECT_FLOAT_EQ(e.x, 1.f);
EXPECT_FLOAT_EQ(e.y, 1.f);
EXPECT_FLOAT_EQ(e.z, 1.f);
}
TEST(AabbTests, ExtentsOfDegenerateBox)
{
constexpr AABB box{{3.f, 3.f, 3.f}, {3.f, 3.f, 3.f}};
constexpr auto e = box.extents();
EXPECT_FLOAT_EQ(e.x, 0.f);
EXPECT_FLOAT_EQ(e.y, 0.f);
EXPECT_FLOAT_EQ(e.z, 0.f);
}
using UpAxis = omath::primitives::UpAxis;
// --- top() ---
TEST(AabbTests, TopYUpSymmetricBox)
{
constexpr AABB box{{-1.f, -2.f, -3.f}, {1.f, 2.f, 3.f}};
constexpr auto t = box.top<UpAxis::Y>();
EXPECT_FLOAT_EQ(t.x, 0.f);
EXPECT_FLOAT_EQ(t.y, 2.f);
EXPECT_FLOAT_EQ(t.z, 0.f);
}
TEST(AabbTests, TopYUpOffsetBox)
{
constexpr AABB box{{1.f, 4.f, 2.f}, {3.f, 10.f, 6.f}};
constexpr auto t = box.top<UpAxis::Y>();
EXPECT_FLOAT_EQ(t.x, 2.f);
EXPECT_FLOAT_EQ(t.y, 10.f);
EXPECT_FLOAT_EQ(t.z, 4.f);
}
TEST(AabbTests, TopZUpSymmetricBox)
{
constexpr AABB box{{-1.f, -2.f, -3.f}, {1.f, 2.f, 3.f}};
constexpr auto t = box.top<UpAxis::Z>();
EXPECT_FLOAT_EQ(t.x, 0.f);
EXPECT_FLOAT_EQ(t.y, 0.f);
EXPECT_FLOAT_EQ(t.z, 3.f);
}
TEST(AabbTests, TopZUpOffsetBox)
{
constexpr AABB box{{1.f, 4.f, 2.f}, {3.f, 10.f, 6.f}};
constexpr auto t = box.top<UpAxis::Z>();
EXPECT_FLOAT_EQ(t.x, 2.f);
EXPECT_FLOAT_EQ(t.y, 7.f);
EXPECT_FLOAT_EQ(t.z, 6.f);
}
TEST(AabbTests, TopDefaultIsYUp)
{
constexpr AABB box{{0.f, 0.f, 0.f}, {2.f, 4.f, 6.f}};
EXPECT_EQ(box.top(), box.top<UpAxis::Y>());
}
// --- bottom() ---
TEST(AabbTests, BottomYUpSymmetricBox)
{
constexpr AABB box{{-1.f, -2.f, -3.f}, {1.f, 2.f, 3.f}};
constexpr auto b = box.bottom<UpAxis::Y>();
EXPECT_FLOAT_EQ(b.x, 0.f);
EXPECT_FLOAT_EQ(b.y, -2.f);
EXPECT_FLOAT_EQ(b.z, 0.f);
}
TEST(AabbTests, BottomYUpOffsetBox)
{
constexpr AABB box{{1.f, 4.f, 2.f}, {3.f, 10.f, 6.f}};
constexpr auto b = box.bottom<UpAxis::Y>();
EXPECT_FLOAT_EQ(b.x, 2.f);
EXPECT_FLOAT_EQ(b.y, 4.f);
EXPECT_FLOAT_EQ(b.z, 4.f);
}
TEST(AabbTests, BottomZUpSymmetricBox)
{
constexpr AABB box{{-1.f, -2.f, -3.f}, {1.f, 2.f, 3.f}};
constexpr auto b = box.bottom<UpAxis::Z>();
EXPECT_FLOAT_EQ(b.x, 0.f);
EXPECT_FLOAT_EQ(b.y, 0.f);
EXPECT_FLOAT_EQ(b.z, -3.f);
}
TEST(AabbTests, BottomZUpOffsetBox)
{
constexpr AABB box{{1.f, 4.f, 2.f}, {3.f, 10.f, 6.f}};
constexpr auto b = box.bottom<UpAxis::Z>();
EXPECT_FLOAT_EQ(b.x, 2.f);
EXPECT_FLOAT_EQ(b.y, 7.f);
EXPECT_FLOAT_EQ(b.z, 2.f);
}
TEST(AabbTests, BottomDefaultIsYUp)
{
constexpr AABB box{{0.f, 0.f, 0.f}, {2.f, 4.f, 6.f}};
EXPECT_EQ(box.bottom(), box.bottom<UpAxis::Y>());
}
TEST(AabbTests, TopAndBottomAreSymmetric)
{
constexpr AABB box{{-1.f, -2.f, -3.f}, {1.f, 2.f, 3.f}};
EXPECT_FLOAT_EQ(box.top<UpAxis::Y>().y, -box.bottom<UpAxis::Y>().y);
EXPECT_FLOAT_EQ(box.top<UpAxis::Z>().z, -box.bottom<UpAxis::Z>().z);
}
// --- is_collide() ---
TEST(AabbTests, OverlappingBoxesCollide)
{
constexpr AABB a{{-1.f, -1.f, -1.f}, {1.f, 1.f, 1.f}};
constexpr AABB b{{0.f, 0.f, 0.f}, {2.f, 2.f, 2.f}};
EXPECT_TRUE(a.is_collide(b));
EXPECT_TRUE(b.is_collide(a));
}
TEST(AabbTests, SeparatedBoxesDoNotCollide)
{
constexpr AABB a{{-1.f, -1.f, -1.f}, {1.f, 1.f, 1.f}};
constexpr AABB b{{2.f, 2.f, 2.f}, {4.f, 4.f, 4.f}};
EXPECT_FALSE(a.is_collide(b));
EXPECT_FALSE(b.is_collide(a));
}
TEST(AabbTests, TouchingFacesCollide)
{
constexpr AABB a{{-1.f, -1.f, -1.f}, {1.f, 1.f, 1.f}};
constexpr AABB b{{1.f, -1.f, -1.f}, {3.f, 1.f, 1.f}};
EXPECT_TRUE(a.is_collide(b));
EXPECT_TRUE(b.is_collide(a));
}
TEST(AabbTests, ContainedBoxCollides)
{
constexpr AABB outer{{-3.f, -3.f, -3.f}, {3.f, 3.f, 3.f}};
constexpr AABB inner{{-1.f, -1.f, -1.f}, {1.f, 1.f, 1.f}};
EXPECT_TRUE(outer.is_collide(inner));
EXPECT_TRUE(inner.is_collide(outer));
}
TEST(AabbTests, SeparatedOnXAxisDoNotCollide)
{
constexpr AABB a{{0.f, 0.f, 0.f}, {1.f, 1.f, 1.f}};
constexpr AABB b{{2.f, 0.f, 0.f}, {3.f, 1.f, 1.f}};
EXPECT_FALSE(a.is_collide(b));
}
TEST(AabbTests, SeparatedOnYAxisDoNotCollide)
{
constexpr AABB a{{0.f, 0.f, 0.f}, {1.f, 1.f, 1.f}};
constexpr AABB b{{0.f, 2.f, 0.f}, {1.f, 3.f, 1.f}};
EXPECT_FALSE(a.is_collide(b));
}
TEST(AabbTests, SeparatedOnZAxisDoNotCollide)
{
constexpr AABB a{{0.f, 0.f, 0.f}, {1.f, 1.f, 1.f}};
constexpr AABB b{{0.f, 0.f, 2.f}, {1.f, 1.f, 3.f}};
EXPECT_FALSE(a.is_collide(b));
}
TEST(AabbTests, IdenticalBoxesCollide)
{
constexpr AABB a{{-1.f, -1.f, -1.f}, {1.f, 1.f, 1.f}};
EXPECT_TRUE(a.is_collide(a));
}
TEST(AabbTests, DegeneratePointBoxCollidesWhenInsideOther)
{
constexpr AABB box{{-1.f, -1.f, -1.f}, {1.f, 1.f, 1.f}};
constexpr AABB point{{0.f, 0.f, 0.f}, {0.f, 0.f, 0.f}};
EXPECT_TRUE(box.is_collide(point));
EXPECT_TRUE(point.is_collide(box));
}
TEST(AabbTests, DegeneratePointBoxDoesNotCollideWhenOutside)
{
constexpr AABB box{{-1.f, -1.f, -1.f}, {1.f, 1.f, 1.f}};
constexpr AABB point{{5.f, 0.f, 0.f}, {5.f, 0.f, 0.f}};
EXPECT_FALSE(box.is_collide(point));
EXPECT_FALSE(point.is_collide(box));
}

View File

@@ -0,0 +1,275 @@
//
// Created by Vladislav on 20.04.2026.
//
#include <gtest/gtest.h>
#include <omath/engines/cry_engine/traits/pred_engine_trait.hpp>
#include <omath/projectile_prediction/projectile.hpp>
#include <omath/projectile_prediction/target.hpp>
using namespace omath;
using namespace omath::cry_engine;
// ---- predict_projectile_position ----
TEST(CryPredEngineTrait, PredictProjectilePositionAtTimeZero)
{
projectile_prediction::Projectile p;
p.m_origin = {1.f, 2.f, 3.f};
p.m_launch_offset = {4.f, 5.f, 6.f};
p.m_launch_speed = 100.f;
p.m_gravity_scale = 1.f;
const auto pos = PredEngineTrait::predict_projectile_position(p, 0.f, 0.f, 0.f, 9.81f);
// At t=0 no velocity is applied, just origin+offset
EXPECT_NEAR(pos.x, 5.f, 1e-4f);
EXPECT_NEAR(pos.y, 7.f, 1e-4f);
EXPECT_NEAR(pos.z, 9.f, 1e-4f);
}
TEST(CryPredEngineTrait, PredictProjectilePositionZeroAnglesForwardIsY)
{
// Cry engine forward = +Y. At pitch=0, yaw=0 the projectile travels along +Y.
projectile_prediction::Projectile p;
p.m_origin = {0.f, 0.f, 0.f};
p.m_launch_speed = 10.f;
p.m_gravity_scale = 0.f; // no gravity so we isolate direction
const auto pos = PredEngineTrait::predict_projectile_position(p, 0.f, 0.f, 1.f, 9.81f);
EXPECT_NEAR(pos.x, 0.f, 1e-4f);
EXPECT_NEAR(pos.y, 10.f, 1e-4f);
EXPECT_NEAR(pos.z, 0.f, 1e-4f);
}
TEST(CryPredEngineTrait, PredictProjectilePositionGravityDropsZ)
{
projectile_prediction::Projectile p;
p.m_origin = {0.f, 0.f, 0.f};
p.m_launch_speed = 10.f;
p.m_gravity_scale = 1.f;
const auto pos = PredEngineTrait::predict_projectile_position(p, 0.f, 0.f, 2.f, 9.81f);
// z = 0 - (9.81 * 1) * (4) * 0.5 = -19.62
EXPECT_NEAR(pos.z, -9.81f * 4.f * 0.5f, 1e-3f);
}
TEST(CryPredEngineTrait, PredictProjectilePositionGravityScaleZeroNoZDrop)
{
projectile_prediction::Projectile p;
p.m_origin = {0.f, 0.f, 0.f};
p.m_launch_speed = 10.f;
p.m_gravity_scale = 0.f;
const auto pos = PredEngineTrait::predict_projectile_position(p, 0.f, 0.f, 3.f, 9.81f);
EXPECT_NEAR(pos.z, 0.f, 1e-4f);
}
TEST(CryPredEngineTrait, PredictProjectilePositionWithLaunchOffset)
{
projectile_prediction::Projectile p;
p.m_origin = {5.f, 0.f, 0.f};
p.m_launch_offset = {0.f, 0.f, 2.f};
p.m_launch_speed = 10.f;
p.m_gravity_scale = 0.f;
const auto pos = PredEngineTrait::predict_projectile_position(p, 0.f, 0.f, 1.f, 0.f);
// launch position = {5, 0, 2}, travels along +Y by 10
EXPECT_NEAR(pos.x, 5.f, 1e-4f);
EXPECT_NEAR(pos.y, 10.f, 1e-4f);
EXPECT_NEAR(pos.z, 2.f, 1e-4f);
}
// ---- predict_target_position ----
TEST(CryPredEngineTrait, PredictTargetPositionGroundedStationary)
{
projectile_prediction::Target t;
t.m_origin = {10.f, 20.f, 5.f};
t.m_velocity = {0.f, 0.f, 0.f};
t.m_is_airborne = false;
const auto pred = PredEngineTrait::predict_target_position(t, 5.f, 9.81f);
EXPECT_NEAR(pred.x, 10.f, 1e-6f);
EXPECT_NEAR(pred.y, 20.f, 1e-6f);
EXPECT_NEAR(pred.z, 5.f, 1e-6f);
}
TEST(CryPredEngineTrait, PredictTargetPositionGroundedMoving)
{
projectile_prediction::Target t;
t.m_origin = {0.f, 0.f, 0.f};
t.m_velocity = {3.f, 4.f, 0.f};
t.m_is_airborne = false;
const auto pred = PredEngineTrait::predict_target_position(t, 2.f, 9.81f);
EXPECT_NEAR(pred.x, 6.f, 1e-6f);
EXPECT_NEAR(pred.y, 8.f, 1e-6f);
EXPECT_NEAR(pred.z, 0.f, 1e-6f); // grounded — no gravity
}
TEST(CryPredEngineTrait, PredictTargetPositionAirborneGravityDropsZ)
{
projectile_prediction::Target t;
t.m_origin = {0.f, 0.f, 20.f};
t.m_velocity = {0.f, 0.f, 0.f};
t.m_is_airborne = true;
const auto pred = PredEngineTrait::predict_target_position(t, 2.f, 9.81f);
// z = 20 - 9.81 * 4 * 0.5 = 20 - 19.62 = 0.38
EXPECT_NEAR(pred.z, 20.f - 9.81f * 4.f * 0.5f, 1e-4f);
}
TEST(CryPredEngineTrait, PredictTargetPositionAirborneMovingWithGravity)
{
projectile_prediction::Target t;
t.m_origin = {0.f, 0.f, 50.f};
t.m_velocity = {10.f, 5.f, 0.f};
t.m_is_airborne = true;
const auto pred = PredEngineTrait::predict_target_position(t, 3.f, 9.81f);
EXPECT_NEAR(pred.x, 30.f, 1e-4f);
EXPECT_NEAR(pred.y, 15.f, 1e-4f);
EXPECT_NEAR(pred.z, 50.f - 9.81f * 9.f * 0.5f, 1e-4f);
}
// ---- calc_vector_2d_distance ----
TEST(CryPredEngineTrait, CalcVector2dDistance_3_4_5)
{
EXPECT_NEAR(PredEngineTrait::calc_vector_2d_distance({3.f, 4.f, 999.f}), 5.f, 1e-5f);
}
TEST(CryPredEngineTrait, CalcVector2dDistance_ZeroVector)
{
EXPECT_NEAR(PredEngineTrait::calc_vector_2d_distance({0.f, 0.f, 0.f}), 0.f, 1e-6f);
}
TEST(CryPredEngineTrait, CalcVector2dDistance_ZIgnored)
{
// Z does not affect the 2D distance
EXPECT_NEAR(PredEngineTrait::calc_vector_2d_distance({0.f, 5.f, 100.f}),
PredEngineTrait::calc_vector_2d_distance({0.f, 5.f, 0.f}), 1e-6f);
}
// ---- get_vector_height_coordinate ----
TEST(CryPredEngineTrait, GetVectorHeightCoordinate_ReturnsZ)
{
// Cry engine up = +Z
EXPECT_FLOAT_EQ(PredEngineTrait::get_vector_height_coordinate({1.f, 2.f, 7.f}), 7.f);
}
// ---- calc_direct_pitch_angle ----
TEST(CryPredEngineTrait, CalcDirectPitchAngle_Flat)
{
// Target at same height → pitch = 0
EXPECT_NEAR(PredEngineTrait::calc_direct_pitch_angle({0.f, 0.f, 0.f}, {0.f, 100.f, 0.f}), 0.f, 1e-4f);
}
TEST(CryPredEngineTrait, CalcDirectPitchAngle_LookingUp)
{
// Target at 45° above (equal XY distance and Z height)
// direction to {0, 1, 1} normalized = {0, 0.707, 0.707}, asin(0.707) = 45°
EXPECT_NEAR(PredEngineTrait::calc_direct_pitch_angle({0.f, 0.f, 0.f}, {0.f, 1.f, 1.f}), 45.f, 1e-3f);
}
TEST(CryPredEngineTrait, CalcDirectPitchAngle_LookingDown)
{
// Target directly below
EXPECT_NEAR(PredEngineTrait::calc_direct_pitch_angle({0.f, 0.f, 10.f}, {0.f, 0.f, 0.f}), -90.f, 1e-3f);
}
TEST(CryPredEngineTrait, CalcDirectPitchAngle_LookingDirectlyUp)
{
EXPECT_NEAR(PredEngineTrait::calc_direct_pitch_angle({0.f, 0.f, 0.f}, {0.f, 0.f, 100.f}), 90.f, 1e-3f);
}
// ---- calc_direct_yaw_angle ----
TEST(CryPredEngineTrait, CalcDirectYawAngle_ForwardAlongY)
{
// Cry engine forward = +Y → yaw = 0
EXPECT_NEAR(PredEngineTrait::calc_direct_yaw_angle({0.f, 0.f, 0.f}, {0.f, 100.f, 0.f}), 0.f, 1e-4f);
}
TEST(CryPredEngineTrait, CalcDirectYawAngle_AlongPositiveX)
{
// direction = {1, 0, 0}, yaw = -atan2(1, 0) = -90°
EXPECT_NEAR(PredEngineTrait::calc_direct_yaw_angle({0.f, 0.f, 0.f}, {100.f, 0.f, 0.f}), -90.f, 1e-3f);
}
TEST(CryPredEngineTrait, CalcDirectYawAngle_AlongNegativeX)
{
// direction = {-1, 0, 0}, yaw = -atan2(-1, 0) = 90°
EXPECT_NEAR(PredEngineTrait::calc_direct_yaw_angle({0.f, 0.f, 0.f}, {-100.f, 0.f, 0.f}), 90.f, 1e-3f);
}
TEST(CryPredEngineTrait, CalcDirectYawAngle_BackwardAlongNegY)
{
// direction = {0, -1, 0}, yaw = -atan2(0, -1) = ±180°
const float yaw = PredEngineTrait::calc_direct_yaw_angle({0.f, 0.f, 0.f}, {0.f, -100.f, 0.f});
EXPECT_NEAR(std::abs(yaw), 180.f, 1e-3f);
}
TEST(CryPredEngineTrait, CalcDirectYawAngle_OffOriginCamera)
{
// Same relative direction regardless of camera position
const float yaw_a = PredEngineTrait::calc_direct_yaw_angle({0.f, 0.f, 0.f}, {0.f, 100.f, 0.f});
const float yaw_b = PredEngineTrait::calc_direct_yaw_angle({50.f, 50.f, 0.f}, {50.f, 150.f, 0.f});
EXPECT_NEAR(yaw_a, yaw_b, 1e-4f);
}
// ---- calc_viewpoint_from_angles ----
TEST(CryPredEngineTrait, CalcViewpointFromAngles_45Degrees)
{
projectile_prediction::Projectile p;
p.m_origin = {0.f, 0.f, 0.f};
p.m_launch_speed = 10.f;
// Target along +Y at distance 10; pitch=45° → height = 10 * tan(45°) = 10
const Vector3<float> target{0.f, 10.f, 0.f};
const auto vp = PredEngineTrait::calc_viewpoint_from_angles(p, target, 45.f);
EXPECT_NEAR(vp.x, 0.f, 1e-4f);
EXPECT_NEAR(vp.y, 10.f, 1e-4f);
EXPECT_NEAR(vp.z, 10.f, 1e-3f);
}
TEST(CryPredEngineTrait, CalcViewpointFromAngles_ZeroPitch)
{
projectile_prediction::Projectile p;
p.m_origin = {0.f, 0.f, 5.f};
p.m_launch_speed = 1.f;
const Vector3<float> target{3.f, 4.f, 0.f};
const auto vp = PredEngineTrait::calc_viewpoint_from_angles(p, target, 0.f);
// tan(0) = 0 → viewpoint Z = origin.z + 0 = 5
EXPECT_NEAR(vp.x, 3.f, 1e-4f);
EXPECT_NEAR(vp.y, 4.f, 1e-4f);
EXPECT_NEAR(vp.z, 5.f, 1e-4f);
}
TEST(CryPredEngineTrait, CalcViewpointXYMatchesPredictedTargetXY)
{
projectile_prediction::Projectile p;
p.m_origin = {1.f, 2.f, 3.f};
p.m_launch_speed = 50.f;
const Vector3<float> target{10.f, 20.f, 5.f};
const auto vp = PredEngineTrait::calc_viewpoint_from_angles(p, target, 30.f);
// X and Y always match the predicted target position
EXPECT_NEAR(vp.x, target.x, 1e-4f);
EXPECT_NEAR(vp.y, target.y, 1e-4f);
}

View File

@@ -0,0 +1,125 @@
//
// Created by Vlad on 3/25/2025.
//
#include "omath/collision/line_tracer.hpp"
#include "omath/3d_primitives/aabb.hpp"
#include <gtest/gtest.h>
using Vec3 = omath::Vector3<float>;
using Ray = omath::collision::Ray<>;
using LineTracer = omath::collision::LineTracer<>;
using AABB = omath::primitives::Aabb<float>;
static Ray make_ray(Vec3 start, Vec3 end, bool infinite = false)
{
Ray r;
r.start = start;
r.end = end;
r.infinite_length = infinite;
return r;
}
// Ray passing straight through the center along Z axis
TEST(LineTracerAABBTests, HitCenterAlongZ)
{
const AABB box{{-1.f, -1.f, -1.f}, {1.f, 1.f, 1.f}};
const auto ray = make_ray({0.f, 0.f, -5.f}, {0.f, 0.f, 5.f});
const auto hit = LineTracer::get_ray_hit_point(ray, box);
EXPECT_NE(hit, ray.end);
EXPECT_NEAR(hit.z, -1.f, 1e-4f);
EXPECT_NEAR(hit.x, 0.f, 1e-4f);
EXPECT_NEAR(hit.y, 0.f, 1e-4f);
}
// Ray passing straight through the center along X axis
TEST(LineTracerAABBTests, HitCenterAlongX)
{
const AABB box{{-1.f, -1.f, -1.f}, {1.f, 1.f, 1.f}};
const auto ray = make_ray({-5.f, 0.f, 0.f}, {5.f, 0.f, 0.f});
const auto hit = LineTracer::get_ray_hit_point(ray, box);
EXPECT_NE(hit, ray.end);
EXPECT_NEAR(hit.x, -1.f, 1e-4f);
}
// Ray that misses entirely (too far in Y)
TEST(LineTracerAABBTests, MissReturnsEnd)
{
const AABB box{{-1.f, -1.f, -1.f}, {1.f, 1.f, 1.f}};
const auto ray = make_ray({0.f, 5.f, -5.f}, {0.f, 5.f, 5.f});
const auto hit = LineTracer::get_ray_hit_point(ray, box);
EXPECT_EQ(hit, ray.end);
}
// Ray that stops short before reaching the box
TEST(LineTracerAABBTests, RayTooShortReturnsEnd)
{
const AABB box{{3.f, -1.f, -1.f}, {5.f, 1.f, 1.f}};
const auto ray = make_ray({0.f, 0.f, 0.f}, {2.f, 0.f, 0.f});
const auto hit = LineTracer::get_ray_hit_point(ray, box);
EXPECT_EQ(hit, ray.end);
}
// Infinite ray that starts before the box should hit
TEST(LineTracerAABBTests, InfiniteRayHits)
{
const AABB box{{3.f, -1.f, -1.f}, {5.f, 1.f, 1.f}};
const auto ray = make_ray({0.f, 0.f, 0.f}, {2.f, 0.f, 0.f}, true);
const auto hit = LineTracer::get_ray_hit_point(ray, box);
EXPECT_NE(hit, ray.end);
EXPECT_NEAR(hit.x, 3.f, 1e-4f);
}
// Ray starting inside the box — t_min=0, so hit point equals ray.start
TEST(LineTracerAABBTests, RayStartsInsideBox)
{
const AABB box{{-1.f, -1.f, -1.f}, {1.f, 1.f, 1.f}};
const auto ray = make_ray({0.f, 0.f, 0.f}, {0.f, 0.f, 5.f});
const auto hit = LineTracer::get_ray_hit_point(ray, box);
EXPECT_NE(hit, ray.end);
// t_min is clamped to 0, so hit == start
EXPECT_NEAR(hit.x, 0.f, 1e-4f);
EXPECT_NEAR(hit.y, 0.f, 1e-4f);
EXPECT_NEAR(hit.z, 0.f, 1e-4f);
}
// Ray parallel to XY plane, pointing along X, at Z outside the box
TEST(LineTracerAABBTests, ParallelRayOutsideSlabMisses)
{
const AABB box{{-1.f, -1.f, -1.f}, {1.f, 1.f, 1.f}};
// Z component of ray is 3.0 — outside box's Z slab
const auto ray = make_ray({-5.f, 0.f, 3.f}, {5.f, 0.f, 3.f});
const auto hit = LineTracer::get_ray_hit_point(ray, box);
EXPECT_EQ(hit, ray.end);
}
// Ray parallel to XY plane, pointing along X, at Z inside the box
TEST(LineTracerAABBTests, ParallelRayInsideSlabHits)
{
const AABB box{{-1.f, -1.f, -1.f}, {1.f, 1.f, 1.f}};
const auto ray = make_ray({-5.f, 0.f, 0.f}, {5.f, 0.f, 0.f});
const auto hit = LineTracer::get_ray_hit_point(ray, box);
EXPECT_NE(hit, ray.end);
EXPECT_NEAR(hit.x, -1.f, 1e-4f);
}
// Diagonal ray hitting a corner region
TEST(LineTracerAABBTests, DiagonalRayHits)
{
const AABB box{{0.f, 0.f, 0.f}, {2.f, 2.f, 2.f}};
const auto ray = make_ray({-1.f, -1.f, -1.f}, {3.f, 3.f, 3.f});
const auto hit = LineTracer::get_ray_hit_point(ray, box);
EXPECT_NE(hit, ray.end);
// Entry point should be at (0,0,0)
EXPECT_NEAR(hit.x, 0.f, 1e-4f);
EXPECT_NEAR(hit.y, 0.f, 1e-4f);
EXPECT_NEAR(hit.z, 0.f, 1e-4f);
}

View File

@@ -220,8 +220,8 @@ TEST(UnitTestMatStandalone, Equanity)
constexpr omath::Vector3<float> left_handed = {0, 2, 10};
constexpr omath::Vector3<float> right_handed = {0, 2, -10};
const auto proj_left_handed = omath::mat_perspective_left_handed(90.f, 16.f / 9.f, 0.1, 1000);
const auto proj_right_handed = omath::mat_perspective_right_handed(90.f, 16.f / 9.f, 0.1, 1000);
const auto proj_left_handed = omath::mat_perspective_left_handed(90.f, 16.f / 9.f, 0.1f, 1000.f);
const auto proj_right_handed = omath::mat_perspective_right_handed(90.f, 16.f / 9.f, 0.1f, 1000.f);
auto ndc_left_handed = proj_left_handed * omath::mat_column_from_vector(left_handed);
auto ndc_right_handed = proj_right_handed * omath::mat_column_from_vector(right_handed);

View File

@@ -4,7 +4,14 @@
#include "omath/engines/unity_engine/camera.hpp"
#include <complex>
#include <gtest/gtest.h>
#include <omath/3d_primitives/aabb.hpp>
#include <omath/engines/cry_engine/camera.hpp>
#include <omath/engines/frostbite_engine/camera.hpp>
#include <omath/engines/iw_engine/camera.hpp>
#include <omath/engines/opengl_engine/camera.hpp>
#include <omath/engines/source_engine/camera.hpp>
#include <omath/engines/unreal_engine/camera.hpp>
#include <omath/linear_algebra/triangle.hpp>
#include <omath/projection/camera.hpp>
#include <print>
#include <random>
@@ -217,3 +224,965 @@ TEST(UnitTestProjection, ScreenToWorldBottomLeftCorner)
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));
// 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}};
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));
}
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);
}
TEST(UnitTestProjection, CalcOriginFromViewMatrix_AtOrigin)
{
constexpr float k_eps = 1e-4f;
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
const auto origin = omath::source_engine::Camera::calc_origin_from_view_matrix(cam.get_view_matrix());
EXPECT_NEAR(origin.x, 0.f, k_eps);
EXPECT_NEAR(origin.y, 0.f, k_eps);
EXPECT_NEAR(origin.z, 0.f, k_eps);
}
TEST(UnitTestProjection, CalcOriginFromViewMatrix_ArbitraryPosition)
{
constexpr float k_eps = 1e-3f;
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({100.f, 200.f, -50.f}, angles, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
const auto origin = omath::source_engine::Camera::calc_origin_from_view_matrix(cam.get_view_matrix());
EXPECT_NEAR(origin.x, 100.f, k_eps);
EXPECT_NEAR(origin.y, 200.f, k_eps);
EXPECT_NEAR(origin.z, -50.f, k_eps);
}
TEST(UnitTestProjection, CalcOriginFromViewMatrix_WithRotation)
{
// Origin recovery must work even when the camera is rotated.
constexpr float k_eps = 1e-3f;
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({300.f, -100.f, 75.f}, angles, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
const auto origin = omath::source_engine::Camera::calc_origin_from_view_matrix(cam.get_view_matrix());
EXPECT_NEAR(origin.x, 300.f, k_eps);
EXPECT_NEAR(origin.y, -100.f, k_eps);
EXPECT_NEAR(origin.z, 75.f, k_eps);
}
TEST(UnitTestProjection, CalcOriginFromViewMatrix_IndependentOfAngles)
{
// Same position, different orientations — should always recover the same origin.
constexpr float k_eps = 1e-3f;
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
constexpr omath::Vector3<float> expected_origin{50.f, 50.f, 50.f};
const omath::source_engine::ViewAngles angles_a{
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 omath::source_engine::ViewAngles angles_b{
omath::source_engine::PitchAngle::from_degrees(-60.f),
omath::source_engine::YawAngle::from_degrees(135.f),
omath::source_engine::RollAngle::from_degrees(0.f)
};
const auto cam_a = omath::source_engine::Camera(expected_origin, angles_a, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
const auto cam_b = omath::source_engine::Camera(expected_origin, angles_b, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
const auto origin_a = omath::source_engine::Camera::calc_origin_from_view_matrix(cam_a.get_view_matrix());
const auto origin_b = omath::source_engine::Camera::calc_origin_from_view_matrix(cam_b.get_view_matrix());
EXPECT_NEAR(origin_a.x, expected_origin.x, k_eps);
EXPECT_NEAR(origin_a.y, expected_origin.y, k_eps);
EXPECT_NEAR(origin_a.z, expected_origin.z, k_eps);
EXPECT_NEAR(origin_b.x, expected_origin.x, k_eps);
EXPECT_NEAR(origin_b.y, expected_origin.y, k_eps);
EXPECT_NEAR(origin_b.z, expected_origin.z, k_eps);
}
// ---- Unity engine camera tests ----
TEST(UnitTestProjection, Unity_CalcViewAnglesFromViewMatrix_LookingForward)
{
constexpr float k_eps = 1e-4f;
constexpr auto fov = omath::projection::FieldOfView::from_degrees(60.f);
const omath::unity_engine::ViewAngles angles{
omath::unity_engine::PitchAngle::from_degrees(0.f),
omath::unity_engine::YawAngle::from_degrees(0.f),
omath::unity_engine::RollAngle::from_degrees(0.f)
};
const auto cam = omath::unity_engine::Camera({0, 0, 0}, angles, {1280.f, 720.f}, fov, 0.03f, 1000.f);
const auto result = omath::unity_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, Unity_CalcViewAnglesFromViewMatrix_PositivePitchAndYaw)
{
constexpr float k_eps = 1e-4f;
constexpr auto fov = omath::projection::FieldOfView::from_degrees(60.f);
const omath::unity_engine::ViewAngles angles{
omath::unity_engine::PitchAngle::from_degrees(30.f),
omath::unity_engine::YawAngle::from_degrees(45.f),
omath::unity_engine::RollAngle::from_degrees(0.f)
};
const auto cam = omath::unity_engine::Camera({0, 0, 0}, angles, {1280.f, 720.f}, fov, 0.03f, 1000.f);
const auto result = omath::unity_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, Unity_CalcViewAnglesFromViewMatrix_NegativePitchAndYaw)
{
constexpr float k_eps = 1e-4f;
constexpr auto fov = omath::projection::FieldOfView::from_degrees(60.f);
const omath::unity_engine::ViewAngles angles{
omath::unity_engine::PitchAngle::from_degrees(-45.f),
omath::unity_engine::YawAngle::from_degrees(-90.f),
omath::unity_engine::RollAngle::from_degrees(0.f)
};
const auto cam = omath::unity_engine::Camera({0, 0, 0}, angles, {1280.f, 720.f}, fov, 0.03f, 1000.f);
const auto result = omath::unity_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, Unity_CalcOriginFromViewMatrix_AtOrigin)
{
constexpr float k_eps = 1e-4f;
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);
const auto origin = omath::unity_engine::Camera::calc_origin_from_view_matrix(cam.get_view_matrix());
EXPECT_NEAR(origin.x, 0.f, k_eps);
EXPECT_NEAR(origin.y, 0.f, k_eps);
EXPECT_NEAR(origin.z, 0.f, k_eps);
}
TEST(UnitTestProjection, Unity_CalcOriginFromViewMatrix_ArbitraryPosition)
{
constexpr float k_eps = 1e-3f;
constexpr auto fov = omath::projection::FieldOfView::from_degrees(60.f);
const omath::unity_engine::ViewAngles angles{
omath::unity_engine::PitchAngle::from_degrees(0.f),
omath::unity_engine::YawAngle::from_degrees(0.f),
omath::unity_engine::RollAngle::from_degrees(0.f)
};
const auto cam = omath::unity_engine::Camera({100.f, 200.f, -50.f}, angles, {1280.f, 720.f}, fov, 0.03f, 1000.f);
const auto origin = omath::unity_engine::Camera::calc_origin_from_view_matrix(cam.get_view_matrix());
EXPECT_NEAR(origin.x, 100.f, k_eps);
EXPECT_NEAR(origin.y, 200.f, k_eps);
EXPECT_NEAR(origin.z, -50.f, k_eps);
}
TEST(UnitTestProjection, Unity_CalcOriginFromViewMatrix_WithRotation)
{
constexpr float k_eps = 1e-3f;
constexpr auto fov = omath::projection::FieldOfView::from_degrees(60.f);
const omath::unity_engine::ViewAngles angles{
omath::unity_engine::PitchAngle::from_degrees(30.f),
omath::unity_engine::YawAngle::from_degrees(45.f),
omath::unity_engine::RollAngle::from_degrees(0.f)
};
const auto cam = omath::unity_engine::Camera({300.f, -100.f, 75.f}, angles, {1280.f, 720.f}, fov, 0.03f, 1000.f);
const auto origin = omath::unity_engine::Camera::calc_origin_from_view_matrix(cam.get_view_matrix());
EXPECT_NEAR(origin.x, 300.f, k_eps);
EXPECT_NEAR(origin.y, -100.f, k_eps);
EXPECT_NEAR(origin.z, 75.f, k_eps);
}
// ---- Camera basis vectors at zero angles ----
TEST(UnitTestProjection, SourceEngine_ZeroAngles_BasisVectors)
{
constexpr float k_eps = 1e-5f;
const auto cam = omath::source_engine::Camera({}, {}, {1920.f, 1080.f},
omath::projection::FieldOfView::from_degrees(90.f), 0.01f, 1000.f);
const auto fwd = cam.get_abs_forward();
const auto right = cam.get_abs_right();
const auto up = cam.get_abs_up();
EXPECT_NEAR(fwd.x, omath::source_engine::k_abs_forward.x, k_eps);
EXPECT_NEAR(fwd.y, omath::source_engine::k_abs_forward.y, k_eps);
EXPECT_NEAR(fwd.z, omath::source_engine::k_abs_forward.z, k_eps);
EXPECT_NEAR(right.x, omath::source_engine::k_abs_right.x, k_eps);
EXPECT_NEAR(right.y, omath::source_engine::k_abs_right.y, k_eps);
EXPECT_NEAR(right.z, omath::source_engine::k_abs_right.z, k_eps);
EXPECT_NEAR(up.x, omath::source_engine::k_abs_up.x, k_eps);
EXPECT_NEAR(up.y, omath::source_engine::k_abs_up.y, k_eps);
EXPECT_NEAR(up.z, omath::source_engine::k_abs_up.z, k_eps);
}
TEST(UnitTestProjection, UnityEngine_ZeroAngles_BasisVectors)
{
constexpr float k_eps = 1e-5f;
const auto cam = omath::unity_engine::Camera({}, {}, {1280.f, 720.f},
omath::projection::FieldOfView::from_degrees(60.f), 0.03f, 1000.f);
const auto fwd = cam.get_abs_forward();
const auto right = cam.get_abs_right();
const auto up = cam.get_abs_up();
EXPECT_NEAR(fwd.x, omath::unity_engine::k_abs_forward.x, k_eps);
EXPECT_NEAR(fwd.y, omath::unity_engine::k_abs_forward.y, k_eps);
EXPECT_NEAR(fwd.z, omath::unity_engine::k_abs_forward.z, k_eps);
EXPECT_NEAR(right.x, omath::unity_engine::k_abs_right.x, k_eps);
EXPECT_NEAR(right.y, omath::unity_engine::k_abs_right.y, k_eps);
EXPECT_NEAR(right.z, omath::unity_engine::k_abs_right.z, k_eps);
EXPECT_NEAR(up.x, omath::unity_engine::k_abs_up.x, k_eps);
EXPECT_NEAR(up.y, omath::unity_engine::k_abs_up.y, k_eps);
EXPECT_NEAR(up.z, omath::unity_engine::k_abs_up.z, k_eps);
}
TEST(UnitTestProjection, OpenGLEngine_ZeroAngles_BasisVectors)
{
constexpr float k_eps = 1e-5f;
const auto cam = omath::opengl_engine::Camera({}, {}, {1920.f, 1080.f},
omath::projection::FieldOfView::from_degrees(90.f), 0.01f, 1000.f);
const auto fwd = cam.get_abs_forward();
const auto right = cam.get_abs_right();
const auto up = cam.get_abs_up();
EXPECT_NEAR(fwd.x, omath::opengl_engine::k_abs_forward.x, k_eps);
EXPECT_NEAR(fwd.y, omath::opengl_engine::k_abs_forward.y, k_eps);
EXPECT_NEAR(fwd.z, omath::opengl_engine::k_abs_forward.z, k_eps);
EXPECT_NEAR(right.x, omath::opengl_engine::k_abs_right.x, k_eps);
EXPECT_NEAR(right.y, omath::opengl_engine::k_abs_right.y, k_eps);
EXPECT_NEAR(right.z, omath::opengl_engine::k_abs_right.z, k_eps);
EXPECT_NEAR(up.x, omath::opengl_engine::k_abs_up.x, k_eps);
EXPECT_NEAR(up.y, omath::opengl_engine::k_abs_up.y, k_eps);
EXPECT_NEAR(up.z, omath::opengl_engine::k_abs_up.z, k_eps);
}
TEST(UnitTestProjection, UnrealEngine_ZeroAngles_BasisVectors)
{
constexpr float k_eps = 1e-5f;
const auto cam = omath::unreal_engine::Camera({}, {}, {1920.f, 1080.f},
omath::projection::FieldOfView::from_degrees(90.f), 0.01f, 1000.f);
const auto fwd = cam.get_abs_forward();
const auto right = cam.get_abs_right();
const auto up = cam.get_abs_up();
EXPECT_NEAR(fwd.x, omath::unreal_engine::k_abs_forward.x, k_eps);
EXPECT_NEAR(fwd.y, omath::unreal_engine::k_abs_forward.y, k_eps);
EXPECT_NEAR(fwd.z, omath::unreal_engine::k_abs_forward.z, k_eps);
EXPECT_NEAR(right.x, omath::unreal_engine::k_abs_right.x, k_eps);
EXPECT_NEAR(right.y, omath::unreal_engine::k_abs_right.y, k_eps);
EXPECT_NEAR(right.z, omath::unreal_engine::k_abs_right.z, k_eps);
EXPECT_NEAR(up.x, omath::unreal_engine::k_abs_up.x, k_eps);
EXPECT_NEAR(up.y, omath::unreal_engine::k_abs_up.y, k_eps);
EXPECT_NEAR(up.z, omath::unreal_engine::k_abs_up.z, k_eps);
}
TEST(UnitTestProjection, FrostbiteEngine_ZeroAngles_BasisVectors)
{
constexpr float k_eps = 1e-5f;
const auto cam = omath::frostbite_engine::Camera({}, {}, {1920.f, 1080.f},
omath::projection::FieldOfView::from_degrees(90.f), 0.01f, 1000.f);
const auto fwd = cam.get_abs_forward();
const auto right = cam.get_abs_right();
const auto up = cam.get_abs_up();
EXPECT_NEAR(fwd.x, omath::frostbite_engine::k_abs_forward.x, k_eps);
EXPECT_NEAR(fwd.y, omath::frostbite_engine::k_abs_forward.y, k_eps);
EXPECT_NEAR(fwd.z, omath::frostbite_engine::k_abs_forward.z, k_eps);
EXPECT_NEAR(right.x, omath::frostbite_engine::k_abs_right.x, k_eps);
EXPECT_NEAR(right.y, omath::frostbite_engine::k_abs_right.y, k_eps);
EXPECT_NEAR(right.z, omath::frostbite_engine::k_abs_right.z, k_eps);
EXPECT_NEAR(up.x, omath::frostbite_engine::k_abs_up.x, k_eps);
EXPECT_NEAR(up.y, omath::frostbite_engine::k_abs_up.y, k_eps);
EXPECT_NEAR(up.z, omath::frostbite_engine::k_abs_up.z, k_eps);
}
TEST(UnitTestProjection, CryEngine_ZeroAngles_BasisVectors)
{
constexpr float k_eps = 1e-5f;
const auto cam = omath::cry_engine::Camera({}, {}, {1920.f, 1080.f},
omath::projection::FieldOfView::from_degrees(90.f), 0.01f, 1000.f);
const auto fwd = cam.get_abs_forward();
const auto right = cam.get_abs_right();
const auto up = cam.get_abs_up();
EXPECT_NEAR(fwd.x, omath::cry_engine::k_abs_forward.x, k_eps);
EXPECT_NEAR(fwd.y, omath::cry_engine::k_abs_forward.y, k_eps);
EXPECT_NEAR(fwd.z, omath::cry_engine::k_abs_forward.z, k_eps);
EXPECT_NEAR(right.x, omath::cry_engine::k_abs_right.x, k_eps);
EXPECT_NEAR(right.y, omath::cry_engine::k_abs_right.y, k_eps);
EXPECT_NEAR(right.z, omath::cry_engine::k_abs_right.z, k_eps);
EXPECT_NEAR(up.x, omath::cry_engine::k_abs_up.x, k_eps);
EXPECT_NEAR(up.y, omath::cry_engine::k_abs_up.y, k_eps);
EXPECT_NEAR(up.z, omath::cry_engine::k_abs_up.z, k_eps);
}
TEST(UnitTestProjection, IWEngine_ZeroAngles_BasisVectors)
{
constexpr float k_eps = 1e-5f;
const auto cam = omath::iw_engine::Camera({}, {}, {1920.f, 1080.f},
omath::projection::FieldOfView::from_degrees(90.f), 0.01f, 1000.f);
const auto fwd = cam.get_abs_forward();
const auto right = cam.get_abs_right();
const auto up = cam.get_abs_up();
EXPECT_NEAR(fwd.x, omath::iw_engine::k_abs_forward.x, k_eps);
EXPECT_NEAR(fwd.y, omath::iw_engine::k_abs_forward.y, k_eps);
EXPECT_NEAR(fwd.z, omath::iw_engine::k_abs_forward.z, k_eps);
EXPECT_NEAR(right.x, omath::iw_engine::k_abs_right.x, k_eps);
EXPECT_NEAR(right.y, omath::iw_engine::k_abs_right.y, k_eps);
EXPECT_NEAR(right.z, omath::iw_engine::k_abs_right.z, k_eps);
EXPECT_NEAR(up.x, omath::iw_engine::k_abs_up.x, k_eps);
EXPECT_NEAR(up.y, omath::iw_engine::k_abs_up.y, k_eps);
EXPECT_NEAR(up.z, omath::iw_engine::k_abs_up.z, k_eps);
}
// ---- extract_projection_params ----
TEST(UnitTestProjection, ExtractProjectionParams_FovRoundTrip)
{
// Source engine applies a 0.75 scale factor to its projection matrix, so
// extract_projection_params (standard formula) does not round-trip with it.
// Use Unity engine, which uses a standard projection matrix.
constexpr float k_eps = 1e-4f;
constexpr auto fov = omath::projection::FieldOfView::from_degrees(75.f);
const auto cam = omath::unity_engine::Camera({}, {}, {1280.f, 720.f}, fov, 0.03f, 1000.f);
const auto params = omath::unity_engine::Camera::extract_projection_params(cam.get_projection_matrix());
EXPECT_NEAR(params.fov.as_degrees(), 75.f, k_eps);
}
TEST(UnitTestProjection, ExtractProjectionParams_AspectRatioRoundTrip)
{
constexpr float k_eps = 1e-4f;
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
const auto params = omath::source_engine::Camera::extract_projection_params(cam.get_projection_matrix());
EXPECT_NEAR(params.aspect_ratio, 1920.f / 1080.f, k_eps);
}
TEST(UnitTestProjection, ExtractProjectionParams_UnityEngine)
{
constexpr float k_eps = 1e-4f;
constexpr auto fov = omath::projection::FieldOfView::from_degrees(60.f);
const auto cam = omath::unity_engine::Camera({}, {}, {1280.f, 720.f}, fov, 0.03f, 1000.f);
const auto params = omath::unity_engine::Camera::extract_projection_params(cam.get_projection_matrix());
EXPECT_NEAR(params.fov.as_degrees(), 60.f, k_eps);
EXPECT_NEAR(params.aspect_ratio, 1280.f / 720.f, k_eps);
}
// ---- Accessors ----
TEST(UnitTestProjection, Accessors_GetFovNearFarOrigin)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const omath::Vector3<float> origin{10.f, 20.f, 30.f};
const auto cam = omath::source_engine::Camera(origin, {}, {1920.f, 1080.f}, fov, 0.1f, 500.f);
EXPECT_NEAR(cam.get_field_of_view().as_degrees(), 90.f, 1e-4f);
EXPECT_FLOAT_EQ(cam.get_near_plane(), 0.1f);
EXPECT_FLOAT_EQ(cam.get_far_plane(), 500.f);
EXPECT_FLOAT_EQ(cam.get_origin().x, 10.f);
EXPECT_FLOAT_EQ(cam.get_origin().y, 20.f);
EXPECT_FLOAT_EQ(cam.get_origin().z, 30.f);
}
// ---- Setters + cache invalidation ----
TEST(UnitTestProjection, SetFieldOfView_InvalidatesProjection)
{
constexpr auto fov_a = omath::projection::FieldOfView::from_degrees(90.f);
constexpr auto fov_b = omath::projection::FieldOfView::from_degrees(45.f);
auto cam = omath::source_engine::Camera({}, {}, {1920.f, 1080.f}, fov_a, 0.01f, 1000.f);
const auto proj_before = cam.get_projection_matrix();
cam.set_field_of_view(fov_b);
const auto proj_after = cam.get_projection_matrix();
EXPECT_NE(proj_before.at(0, 0), proj_after.at(0, 0));
EXPECT_NEAR(cam.get_field_of_view().as_degrees(), 45.f, 1e-4f);
}
TEST(UnitTestProjection, SetNearPlane_InvalidatesProjection)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
auto cam = omath::source_engine::Camera({}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
const auto proj_before = cam.get_projection_matrix();
cam.set_near_plane(1.f);
const auto proj_after = cam.get_projection_matrix();
EXPECT_FLOAT_EQ(cam.get_near_plane(), 1.f);
EXPECT_NE(proj_before.at(2, 2), proj_after.at(2, 2));
}
TEST(UnitTestProjection, SetFarPlane_InvalidatesProjection)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
auto cam = omath::source_engine::Camera({}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
const auto proj_before = cam.get_projection_matrix();
cam.set_far_plane(100.f);
const auto proj_after = cam.get_projection_matrix();
EXPECT_FLOAT_EQ(cam.get_far_plane(), 100.f);
EXPECT_NE(proj_before.at(2, 2), proj_after.at(2, 2));
}
TEST(UnitTestProjection, SetOrigin_InvalidatesViewMatrix)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
auto cam = omath::source_engine::Camera({0.f, 0.f, 0.f}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
// Target is off to the side — stays at the same world position while camera
// moves laterally, so the projected X must change.
const auto screen_before = cam.world_to_screen({500.f, 100.f, 0.f});
cam.set_origin({0.f, 100.f, 0.f}); // lateral shift
const auto screen_after = cam.world_to_screen({500.f, 100.f, 0.f});
ASSERT_TRUE(screen_before.has_value());
ASSERT_TRUE(screen_after.has_value());
EXPECT_NE(screen_before->x, screen_after->x);
EXPECT_FLOAT_EQ(cam.get_origin().y, 100.f);
}
TEST(UnitTestProjection, SetViewPort_InvalidatesProjection)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
auto cam = omath::source_engine::Camera({}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
const auto proj_before = cam.get_projection_matrix();
// 1280x800 is 8:5, different aspect ratio from 1920x1080 (16:9)
cam.set_view_port({1280.f, 800.f});
const auto proj_after = cam.get_projection_matrix();
EXPECT_NE(proj_before.at(0, 0), proj_after.at(0, 0));
}
TEST(UnitTestProjection, SetViewAngles_InvalidatesViewMatrix)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
auto cam = omath::source_engine::Camera({}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
const auto view_before = cam.get_view_matrix();
const omath::source_engine::ViewAngles rotated{
omath::source_engine::PitchAngle::from_degrees(30.f),
omath::source_engine::YawAngle::from_degrees(45.f),
omath::source_engine::RollAngle::from_degrees(0.f)
};
cam.set_view_angles(rotated);
const auto view_after = cam.get_view_matrix();
EXPECT_NE(view_before.at(0, 0), view_after.at(0, 0));
}
// ---- calc_look_at_angles / look_at ----
TEST(UnitTestProjection, CalcLookAtAngles_ForwardTarget)
{
// Source engine: +X is forward. Camera at origin, target on +X axis.
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0.f, 0.f, 0.f}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
const auto angles = cam.calc_look_at_angles({100.f, 0.f, 0.f});
EXPECT_NEAR(angles.pitch.as_degrees(), 0.f, 1e-4f);
EXPECT_NEAR(angles.yaw.as_degrees(), 0.f, 1e-4f);
}
TEST(UnitTestProjection, LookAt_ForwardVectorPointsAtTarget)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
auto cam = omath::source_engine::Camera({0.f, 0.f, 0.f}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
cam.look_at({200.f, 0.f, 0.f});
const auto fwd = cam.get_abs_forward();
// After pointing at +X target the forward vector should be ~(1,0,0)
EXPECT_NEAR(fwd.x, 1.f, 1e-4f);
EXPECT_NEAR(fwd.y, 0.f, 1e-4f);
EXPECT_NEAR(fwd.z, 0.f, 1e-4f);
}
// ---- is_culled_by_frustum (triangle) ----
TEST(UnitTestProjection, TriangleInsideFrustumNotCulled)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0.f, 0.f, 0.f}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
// Small triangle directly in front (Source: +X forward)
const omath::Triangle<omath::Vector3<float>> tri{
{100.f, 0.f, 1.f},
{100.f, 1.f, -1.f},
{100.f, -1.f, -1.f}
};
EXPECT_FALSE(cam.is_culled_by_frustum(tri));
}
TEST(UnitTestProjection, TriangleBehindCameraCulled)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0.f, 0.f, 0.f}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
// Triangle entirely behind the camera (-X)
const omath::Triangle<omath::Vector3<float>> tri{
{-100.f, 0.f, 1.f},
{-100.f, 1.f, -1.f},
{-100.f, -1.f, -1.f}
};
EXPECT_TRUE(cam.is_culled_by_frustum(tri));
}
TEST(UnitTestProjection, TriangleBeyondFarPlaneCulled)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0.f, 0.f, 0.f}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
// Triangle beyond the 1000-unit far plane
const omath::Triangle<omath::Vector3<float>> tri{
{2000.f, 0.f, 1.f},
{2000.f, 1.f, -1.f},
{2000.f, -1.f, -1.f}
};
EXPECT_TRUE(cam.is_culled_by_frustum(tri));
}
TEST(UnitTestProjection, TriangleFarToSideCulled)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0.f, 0.f, 0.f}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
// Triangle far outside the side frustum planes
const omath::Triangle<omath::Vector3<float>> tri{
{100.f, 5000.f, 0.f},
{100.f, 5001.f, 1.f},
{100.f, 5001.f, -1.f}
};
EXPECT_TRUE(cam.is_culled_by_frustum(tri));
}
TEST(UnitTestProjection, TriangleStraddlingFrustumNotCulled)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0.f, 0.f, 0.f}, {}, {1920.f, 1080.f}, fov, 0.01f, 1000.f);
// Large triangle with vertices on both sides of the frustum — should not be culled
const omath::Triangle<omath::Vector3<float>> tri{
{ 100.f, 0.f, 0.f},
{ 100.f, 5000.f, 0.f},
{ 100.f, 0.f, 5000.f}
};
EXPECT_FALSE(cam.is_culled_by_frustum(tri));
}

View File

@@ -0,0 +1,640 @@
// Unit tests for omath::pathfinding::WalkBot
// Covers all status transitions, callback behaviour, and a full walk simulation.
#include <gtest/gtest.h>
#include "omath/pathfinding/walk_bot.hpp"
#include "omath/pathfinding/navigation_mesh.hpp"
using namespace omath;
using namespace omath::pathfinding;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Build a simple bidirectional linear chain:
// (0,0,0) <-> (1,0,0) <-> (2,0,0) <-> ... <-> (n-1,0,0)
static std::shared_ptr<NavigationMesh> make_linear_mesh(int n)
{
auto mesh = std::make_shared<NavigationMesh>();
for (int i = 0; i < n; ++i)
{
const Vector3<float> v{static_cast<float>(i), 0.f, 0.f};
std::vector<Vector3<float>> neighbors;
if (i > 0)
neighbors.push_back(Vector3<float>{static_cast<float>(i - 1), 0.f, 0.f});
if (i + 1 < n)
neighbors.push_back(Vector3<float>{static_cast<float>(i + 1), 0.f, 0.f});
mesh->m_vertex_map[v] = neighbors;
}
return mesh;
}
// Collect every status update fired during one update() call.
static auto make_status_recorder(std::vector<WalkBotStatus>& out)
{
return [&out](WalkBotStatus s) { out.push_back(s); };
}
// Collect every "next node" hint fired during one update() call.
static auto make_node_recorder(std::vector<Vector3<float>>& out)
{
return [&out](const Vector3<float>& v) { out.push_back(v); };
}
// ---------------------------------------------------------------------------
// Construction
// ---------------------------------------------------------------------------
TEST(WalkBotTests, DefaultConstructedBotIsInert)
{
// A default-constructed bot with no mesh, no target, and no callbacks must
// not crash.
WalkBot bot;
EXPECT_NO_THROW(bot.update({0.f, 0.f, 0.f}));
}
TEST(WalkBotTests, ConstructWithMeshAndDistance)
{
auto mesh = make_linear_mesh(3);
EXPECT_NO_THROW(WalkBot bot(mesh, 0.5f));
}
// ---------------------------------------------------------------------------
// Status: FINISHED
// ---------------------------------------------------------------------------
TEST(WalkBotTests, FiresFinishedWhenBotIsAtTarget)
{
auto mesh = make_linear_mesh(3);
WalkBot bot(mesh, 0.5f);
bot.set_target({2.f, 0.f, 0.f});
std::vector<WalkBotStatus> statuses;
bot.on_status(make_status_recorder(statuses));
// bot_position == target_position -> distance == 0, well within threshold
bot.update({2.f, 0.f, 0.f});
ASSERT_FALSE(statuses.empty());
EXPECT_EQ(statuses.front(), WalkBotStatus::FINISHED);
}
TEST(WalkBotTests, FiresFinishedWhenBotIsWithinMinDistance)
{
auto mesh = make_linear_mesh(3);
WalkBot bot(mesh, 0.5f);
bot.set_target({0.4f, 0.f, 0.f});
std::vector<WalkBotStatus> statuses;
bot.on_status(make_status_recorder(statuses));
// distance between (0,0,0) and (0.4,0,0) is 0.4 < 0.5 threshold
bot.update({0.f, 0.f, 0.f});
ASSERT_FALSE(statuses.empty());
EXPECT_EQ(statuses.front(), WalkBotStatus::FINISHED);
}
TEST(WalkBotTests, NoFinishedWhenOutsideMinDistance)
{
auto mesh = make_linear_mesh(5);
WalkBot bot(mesh, 0.5f);
bot.set_target({4.f, 0.f, 0.f});
std::vector<WalkBotStatus> statuses;
bot.on_status(make_status_recorder(statuses));
// Attach path callback so we get further status updates
std::vector<Vector3<float>> nodes;
bot.on_path(make_node_recorder(nodes));
bot.update({0.f, 0.f, 0.f});
// FINISHED must NOT appear in the status list
for (auto s : statuses)
EXPECT_NE(s, WalkBotStatus::FINISHED);
}
TEST(WalkBotTests, FinishedFiredEvenWithoutPathCallback)
{
// FINISHED is emitted before the on_path guard, so it fires regardless.
auto mesh = make_linear_mesh(2);
WalkBot bot(mesh, 0.5f);
bot.set_target({1.f, 0.f, 0.f});
std::vector<WalkBotStatus> statuses;
bot.on_status(make_status_recorder(statuses));
// Intentionally do NOT register on_path callback.
bot.update({1.f, 0.f, 0.f});
ASSERT_EQ(statuses.size(), 1u);
EXPECT_EQ(statuses[0], WalkBotStatus::FINISHED);
}
TEST(WalkBotTests, FinishedDoesNotFallThroughToPathing)
{
// update() must return after FINISHED — PATHING must not fire on the same tick.
auto mesh = make_linear_mesh(3);
WalkBot bot(mesh, 0.5f);
bot.set_target({1.f, 0.f, 0.f});
std::vector<WalkBotStatus> statuses;
bot.on_status(make_status_recorder(statuses));
std::vector<Vector3<float>> nodes;
bot.on_path(make_node_recorder(nodes));
bot.update({1.f, 0.f, 0.f}); // bot is at target
ASSERT_EQ(statuses.size(), 1u);
EXPECT_EQ(statuses[0], WalkBotStatus::FINISHED);
EXPECT_TRUE(nodes.empty());
}
// ---------------------------------------------------------------------------
// No target set
// ---------------------------------------------------------------------------
TEST(WalkBotTests, NoUpdateWithoutTarget)
{
auto mesh = make_linear_mesh(3);
WalkBot bot(mesh, 0.5f);
// Intentionally do NOT call set_target()
std::vector<WalkBotStatus> statuses;
bot.on_status(make_status_recorder(statuses));
std::vector<Vector3<float>> nodes;
bot.on_path(make_node_recorder(nodes));
bot.update({0.f, 0.f, 0.f});
EXPECT_TRUE(statuses.empty());
EXPECT_TRUE(nodes.empty());
}
// ---------------------------------------------------------------------------
// Status: IDLE — no path callback registered
// ---------------------------------------------------------------------------
TEST(WalkBotTests, NoPathCallbackMeansNoPathingStatus)
{
auto mesh = make_linear_mesh(4);
WalkBot bot(mesh, 0.5f);
bot.set_target({3.f, 0.f, 0.f});
std::vector<WalkBotStatus> statuses;
bot.on_status(make_status_recorder(statuses));
// No on_path registered -> update returns early after FINISHED check
bot.update({0.f, 0.f, 0.f});
EXPECT_TRUE(statuses.empty());
}
// ---------------------------------------------------------------------------
// Status: IDLE — null/expired navigation mesh
// ---------------------------------------------------------------------------
TEST(WalkBotTests, FiresIdleWhenNavMeshIsNull)
{
WalkBot bot; // no mesh assigned
bot.set_target({5.f, 0.f, 0.f});
std::vector<WalkBotStatus> statuses;
bot.on_status(make_status_recorder(statuses));
std::vector<Vector3<float>> nodes;
bot.on_path(make_node_recorder(nodes));
bot.update({0.f, 0.f, 0.f});
ASSERT_FALSE(statuses.empty());
EXPECT_EQ(statuses.back(), WalkBotStatus::IDLE);
EXPECT_TRUE(nodes.empty());
}
TEST(WalkBotTests, FiresIdleWhenNavMeshExpires)
{
auto mesh = make_linear_mesh(4);
WalkBot bot(mesh, 0.5f);
bot.set_target({3.f, 0.f, 0.f});
std::vector<WalkBotStatus> statuses;
bot.on_status(make_status_recorder(statuses));
std::vector<Vector3<float>> nodes;
bot.on_path(make_node_recorder(nodes));
// Let the shared_ptr expire — WalkBot holds only a weak_ptr.
mesh.reset();
bot.update({0.f, 0.f, 0.f});
ASSERT_FALSE(statuses.empty());
EXPECT_EQ(statuses.back(), WalkBotStatus::IDLE);
EXPECT_TRUE(nodes.empty());
}
TEST(WalkBotTests, SetNavMeshRestoresPathing)
{
WalkBot bot; // starts with no mesh
bot.set_target({3.f, 0.f, 0.f});
std::vector<WalkBotStatus> statuses;
bot.on_status(make_status_recorder(statuses));
std::vector<Vector3<float>> nodes;
bot.on_path(make_node_recorder(nodes));
// First call — no mesh -> IDLE
bot.update({0.f, 0.f, 0.f});
ASSERT_EQ(statuses.back(), WalkBotStatus::IDLE);
// Assign a mesh and call again. Keep the shared_ptr alive so the
// weak_ptr inside WalkBot does not expire before update() is called.
statuses.clear();
nodes.clear();
auto new_mesh = make_linear_mesh(4);
bot.set_nav_mesh(new_mesh);
bot.set_min_node_distance(0.5f);
bot.update({0.f, 0.f, 0.f});
ASSERT_FALSE(statuses.empty());
EXPECT_EQ(statuses.back(), WalkBotStatus::PATHING);
EXPECT_FALSE(nodes.empty());
}
// ---------------------------------------------------------------------------
// Status: IDLE — A* finds no path
// ---------------------------------------------------------------------------
TEST(WalkBotTests, FiresIdleWhenNoPathExists)
{
// Disconnected graph: two isolated vertices
auto mesh = std::make_shared<NavigationMesh>();
mesh->m_vertex_map[{0.f, 0.f, 0.f}] = {};
mesh->m_vertex_map[{10.f, 0.f, 0.f}] = {};
WalkBot bot(mesh, 0.5f);
bot.set_target({10.f, 0.f, 0.f});
std::vector<WalkBotStatus> statuses;
bot.on_status(make_status_recorder(statuses));
std::vector<Vector3<float>> nodes;
bot.on_path(make_node_recorder(nodes));
bot.update({0.f, 0.f, 0.f});
ASSERT_FALSE(statuses.empty());
EXPECT_EQ(statuses.back(), WalkBotStatus::IDLE);
EXPECT_TRUE(nodes.empty());
}
// ---------------------------------------------------------------------------
// Status: PATHING — normal routing
// ---------------------------------------------------------------------------
TEST(WalkBotTests, FiresPathingAndProvidesNextNode)
{
auto mesh = make_linear_mesh(4);
WalkBot bot(mesh, 0.5f);
bot.set_target({3.f, 0.f, 0.f});
std::vector<WalkBotStatus> statuses;
bot.on_status(make_status_recorder(statuses));
std::vector<Vector3<float>> nodes;
bot.on_path(make_node_recorder(nodes));
bot.update({0.f, 0.f, 0.f});
ASSERT_FALSE(statuses.empty());
EXPECT_EQ(statuses.back(), WalkBotStatus::PATHING);
ASSERT_FALSE(nodes.empty());
}
TEST(WalkBotTests, NextNodeIsOnThePath)
{
auto mesh = make_linear_mesh(5);
WalkBot bot(mesh, 0.5f);
bot.set_target({4.f, 0.f, 0.f});
std::vector<Vector3<float>> nodes;
bot.on_path(make_node_recorder(nodes));
bot.update({0.f, 0.f, 0.f});
// The suggested node must be a mesh vertex (x in [0..4], y=0, z=0)
ASSERT_FALSE(nodes.empty());
const auto& hint = nodes.front();
EXPECT_GE(hint.x, 0.f);
EXPECT_LE(hint.x, 4.f);
EXPECT_FLOAT_EQ(hint.y, 0.f);
EXPECT_FLOAT_EQ(hint.z, 0.f);
}
// ---------------------------------------------------------------------------
// set_min_node_distance
// ---------------------------------------------------------------------------
TEST(WalkBotTests, MinNodeDistanceAffectsFinishedThreshold)
{
auto mesh = make_linear_mesh(3);
WalkBot bot(mesh, 0.1f); // very tight threshold
bot.set_target({1.f, 0.f, 0.f});
std::vector<WalkBotStatus> statuses;
bot.on_status(make_status_recorder(statuses));
// distance 0.4 — outside 0.1 threshold
bot.update({0.6f, 0.f, 0.f});
EXPECT_TRUE(statuses.empty() ||
std::find(statuses.begin(), statuses.end(), WalkBotStatus::FINISHED) == statuses.end());
statuses.clear();
bot.set_min_node_distance(0.5f); // widen threshold
bot.update({0.6f, 0.f, 0.f}); // now 0.4 < 0.5 -> FINISHED
ASSERT_FALSE(statuses.empty());
EXPECT_EQ(statuses.front(), WalkBotStatus::FINISHED);
}
// ---------------------------------------------------------------------------
// reset()
// ---------------------------------------------------------------------------
TEST(WalkBotTests, ResetClearsLastVisited)
{
auto mesh = make_linear_mesh(3);
WalkBot bot(mesh, 0.5f);
bot.set_target({2.f, 0.f, 0.f});
std::vector<Vector3<float>> nodes;
bot.on_path(make_node_recorder(nodes));
// Tick 1: mark node 0 visited -> hint is node 1
bot.update({0.05f, 0.f, 0.f});
ASSERT_FALSE(nodes.empty());
EXPECT_FLOAT_EQ(nodes.back().x, 1.f);
// Without reset, a second tick from the same position also gives node 1.
nodes.clear();
bot.reset();
// After reset, m_last_visited is cleared. The nearest node is 0 again,
// it is within threshold so it gets marked visited and we advance to 1.
// The hint should still be node 1 (same outcome, but state was cleanly reset).
bot.update({0.05f, 0.f, 0.f});
ASSERT_FALSE(nodes.empty());
// Confirm the bot still routes correctly after reset.
EXPECT_GE(nodes.back().x, 0.f);
}
// ---------------------------------------------------------------------------
// Node advancement — visited node causes skip to next
// ---------------------------------------------------------------------------
TEST(WalkBotTests, AdvancesWhenNearestNodeAlreadyVisited)
{
// Chain: (0,0,0) -> (1,0,0) -> (2,0,0)
auto mesh = make_linear_mesh(3);
WalkBot bot(mesh, 0.5f);
bot.set_target({2.f, 0.f, 0.f});
std::vector<Vector3<float>> nodes;
bot.on_path(make_node_recorder(nodes));
// Tick 1: bot is very close to node 0 -> node 0 marked visited -> hint is node 1.
bot.update({0.05f, 0.f, 0.f});
ASSERT_FALSE(nodes.empty());
EXPECT_FLOAT_EQ(nodes.back().x, 1.f);
nodes.clear();
// Tick 2: bot has moved to near node 1 -> node 1 marked visited -> hint advances to node 2.
bot.update({1.05f, 0.f, 0.f});
ASSERT_FALSE(nodes.empty());
EXPECT_GT(nodes.back().x, 1.f);
}
// ---------------------------------------------------------------------------
// Displacement recovery — bot knocked back to unvisited node
// ---------------------------------------------------------------------------
TEST(WalkBotTests, RecoverAfterDisplacementToUnvisitedNode)
{
// Chain: 0 -> 1 -> 2 -> 3 -> 4
auto mesh = make_linear_mesh(5);
WalkBot bot(mesh, 0.5f);
bot.set_target({4.f, 0.f, 0.f});
std::vector<Vector3<float>> nodes;
bot.on_path(make_node_recorder(nodes));
// Walk forward through nodes 0-3 to build visited state.
for (int i = 0; i <= 3; ++i)
{
nodes.clear();
bot.update(Vector3<float>{static_cast<float>(i) + 0.1f, 0.f, 0.f});
}
// Displace the bot back to near node 1. The bot should route toward node 1
// first rather than skipping ahead to node 4.
nodes.clear();
bot.update({1.1f, 0.f, 0.f});
ASSERT_FALSE(nodes.empty());
EXPECT_LE(nodes.back().x, 3.f);
}
// ---------------------------------------------------------------------------
// Callback wiring
// ---------------------------------------------------------------------------
TEST(WalkBotTests, ReplacingPathCallbackTakesEffect)
{
auto mesh = make_linear_mesh(4);
WalkBot bot(mesh, 0.5f);
bot.set_target({3.f, 0.f, 0.f});
int first_cb_calls = 0;
int second_cb_calls = 0;
bot.on_path([&](const Vector3<float>&) { ++first_cb_calls; });
bot.update({0.f, 0.f, 0.f});
bot.on_path([&](const Vector3<float>&) { ++second_cb_calls; });
bot.update({0.f, 0.f, 0.f});
EXPECT_EQ(first_cb_calls, 1);
EXPECT_EQ(second_cb_calls, 1);
}
TEST(WalkBotTests, ReplacingStatusCallbackTakesEffect)
{
auto mesh = make_linear_mesh(4);
WalkBot bot(mesh, 0.5f);
bot.set_target({3.f, 0.f, 0.f});
std::vector<Vector3<float>> nodes;
bot.on_path(make_node_recorder(nodes));
int cb1 = 0, cb2 = 0;
bot.on_status([&](WalkBotStatus) { ++cb1; });
bot.update({0.f, 0.f, 0.f});
bot.on_status([&](WalkBotStatus) { ++cb2; });
bot.update({0.f, 0.f, 0.f});
EXPECT_EQ(cb1, 1);
EXPECT_EQ(cb2, 1);
}
// ---------------------------------------------------------------------------
// Full walk simulation — bot traverses a linear mesh step by step
// ---------------------------------------------------------------------------
// Simulates one game-loop tick and immediately "teleports" the bot to the
// suggested node so the next tick starts from there.
struct WalkSimulator
{
WalkBot bot;
Vector3<float> position;
std::vector<Vector3<float>> visited_nodes;
std::vector<WalkBotStatus> status_history;
bool finished{false};
WalkSimulator(const std::shared_ptr<NavigationMesh>& mesh,
const Vector3<float>& start,
const Vector3<float>& goal,
float threshold)
: position(start)
{
bot = WalkBot(mesh, threshold);
bot.set_target(goal);
bot.on_path([this](const Vector3<float>& next) {
visited_nodes.push_back(next);
position = next; // teleport to the suggested node
});
bot.on_status([this](WalkBotStatus s) {
status_history.push_back(s);
if (s == WalkBotStatus::FINISHED)
finished = true;
});
}
void run(int max_ticks = 100)
{
for (int tick = 0; tick < max_ticks && !finished; ++tick)
bot.update(position);
}
};
TEST(WalkBotSimulation, BotReachesTargetOnLinearMesh)
{
auto mesh = make_linear_mesh(5);
WalkSimulator sim(mesh, {0.f, 0.f, 0.f}, {4.f, 0.f, 0.f}, 0.5f);
sim.run(50);
EXPECT_TRUE(sim.finished);
}
TEST(WalkBotSimulation, StatusTransitionSequenceIsCorrect)
{
// Expect: one or more PATHING updates, then exactly FINISHED at the end.
auto mesh = make_linear_mesh(4);
WalkSimulator sim(mesh, {0.f, 0.f, 0.f}, {3.f, 0.f, 0.f}, 0.5f);
sim.run(50);
ASSERT_TRUE(sim.finished);
ASSERT_FALSE(sim.status_history.empty());
// All entries before the last must be PATHING
for (std::size_t i = 0; i + 1 < sim.status_history.size(); ++i)
EXPECT_EQ(sim.status_history[i], WalkBotStatus::PATHING);
EXPECT_EQ(sim.status_history.back(), WalkBotStatus::FINISHED);
}
TEST(WalkBotSimulation, BotVisitsNodesInForwardOrder)
{
auto mesh = make_linear_mesh(5);
WalkSimulator sim(mesh, {0.f, 0.f, 0.f}, {4.f, 0.f, 0.f}, 0.5f);
sim.run(50);
ASSERT_FALSE(sim.visited_nodes.empty());
// Verify that x-coordinates are non-decreasing (forward progress only).
for (std::size_t i = 1; i < sim.visited_nodes.size(); ++i)
EXPECT_GE(sim.visited_nodes[i].x, sim.visited_nodes[i - 1].x - 1e-3f);
}
TEST(WalkBotSimulation, TwoNodePathReachesGoal)
{
auto mesh = make_linear_mesh(2); // (0,0,0) <-> (1,0,0)
WalkSimulator sim(mesh, {0.f, 0.f, 0.f}, {1.f, 0.f, 0.f}, 0.5f);
sim.run(10);
EXPECT_TRUE(sim.finished);
}
TEST(WalkBotSimulation, LongChainEventuallyFinishes)
{
constexpr int kLength = 20;
auto mesh = make_linear_mesh(kLength);
WalkSimulator sim(mesh,
{0.f, 0.f, 0.f},
{static_cast<float>(kLength - 1), 0.f, 0.f},
0.5f);
sim.run(200);
EXPECT_TRUE(sim.finished);
}
TEST(WalkBotSimulation, StartAlreadyAtTargetFinishesImmediately)
{
auto mesh = make_linear_mesh(3);
WalkSimulator sim(mesh, {1.f, 0.f, 0.f}, {1.f, 0.f, 0.f}, 0.5f);
sim.run(5);
EXPECT_TRUE(sim.finished);
EXPECT_EQ(sim.status_history.front(), WalkBotStatus::FINISHED);
EXPECT_EQ(sim.status_history.size(), 1u);
}
TEST(WalkBotSimulation, NoIdleEmittedDuringSuccessfulWalk)
{
auto mesh = make_linear_mesh(4);
WalkSimulator sim(mesh, {0.f, 0.f, 0.f}, {3.f, 0.f, 0.f}, 0.5f);
sim.run(50);
for (auto s : sim.status_history)
EXPECT_NE(s, WalkBotStatus::IDLE);
}
// ---------------------------------------------------------------------------
// Walk simulation on a branching mesh
// ---------------------------------------------------------------------------
TEST(WalkBotSimulation, BotNavigatesBranchingMesh)
{
// Diamond topology:
// (1,1,0)
// / \
// (0,0,0) (2,0,0)
// \ /
// (1,-1,0)
auto mesh = std::make_shared<NavigationMesh>();
const Vector3<float> start{0.f, 0.f, 0.f};
const Vector3<float> top{1.f, 1.f, 0.f};
const Vector3<float> bot_node{1.f, -1.f, 0.f};
const Vector3<float> goal{2.f, 0.f, 0.f};
mesh->m_vertex_map[start] = {top, bot_node};
mesh->m_vertex_map[top] = {start, goal};
mesh->m_vertex_map[bot_node] = {start, goal};
mesh->m_vertex_map[goal] = {top, bot_node};
WalkSimulator sim(mesh, start, goal, 0.5f);
sim.run(20);
EXPECT_TRUE(sim.finished);
}