Compare commits

...

39 Commits

Author SHA1 Message Date
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
bcbb5c1a8d fixed index 2026-03-24 06:05:56 +03:00
ba46c86664 simplified method 2026-03-24 06:03:35 +03:00
3b0470cc11 Merge pull request #178 from orange-cpp/feature/imrovements
Feature/imrovements
2026-03-24 05:55:47 +03:00
8562c5d1f2 added more unreachable checks 2026-03-24 05:28:01 +03:00
8daba25c29 added ureachable 2026-03-24 05:21:00 +03:00
29b7ac6450 Merge pull request #177 from orange-cpp/feature/custom_ndc_z_range
Feature/custom ndc z range
2026-03-24 04:20:57 +03:00
89df10b778 specifeid ndc for game engines 2026-03-24 00:08:06 +03:00
8fb96b83db removed dead code 2026-03-23 23:52:41 +03:00
4b6db0c402 updated z range 2026-03-23 23:36:19 +03:00
a9ff7868cf simplified code 2026-03-23 05:52:35 +03:00
be80a5d243 added as_vector3 to view angles 2026-03-23 05:23:53 +03:00
881d3b9a2a added fields 2026-03-22 19:07:38 +03:00
f60e18b6ba replaced with table offset 2026-03-22 18:58:07 +03:00
0769d3d079 replaced with auto 2026-03-22 17:30:25 +03:00
b6755e21f9 fix 2026-03-22 16:32:00 +03:00
2287602fa2 Merge pull request #176 from orange-cpp/feature/vtable_index
added stuff
2026-03-22 16:21:39 +03:00
663890706e test fix 2026-03-22 16:06:57 +03:00
ab103f626b swaped to std::uintptr_t 2026-03-22 16:05:09 +03:00
cc4e01b100 added stuff 2026-03-22 16:00:35 +03:00
64 changed files with 2950 additions and 167 deletions

View File

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

4
.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" />
@@ -110,7 +110,7 @@
<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" />

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

@@ -0,0 +1,28 @@
//
// Created by Vladislav on 24.03.2026.
//
#pragma once
#include "omath/linear_algebra/vector3.hpp"
namespace omath::primitives
{
template<class Type>
struct Aabb final
{
Vector3<Type> min;
Vector3<Type> max;
[[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);
}
};
} // namespace omath::primitives

View File

@@ -35,13 +35,8 @@ namespace omath::algorithm
const auto current_target_angles = camera.calc_look_at_angles(get_position(*current));
const auto best_target_angles = camera.calc_look_at_angles(get_position(*best_target));
const Vector2<float> current_angles_vec = {current_target_angles.pitch.as_degrees(),
current_target_angles.yaw.as_degrees()};
const Vector2<float> best_angles_vec = {best_target_angles.pitch.as_degrees(),
best_target_angles.yaw.as_degrees()};
const auto current_target_distance = camera_angles_vec.distance_to(current_angles_vec);
const auto best_target_distance = camera_angles_vec.distance_to(best_angles_vec);
const auto current_target_distance = camera_angles_vec.distance_to(current_target_angles.as_vector3());
const auto best_target_distance = camera_angles.as_vector3().distance_to(best_target_angles.as_vector3());
if (current_target_distance < best_target_distance)
best_target = current;
}

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>;
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, NDCDepthRange::ZERO_TO_ONE>;
} // namespace omath::cry_engine

View File

@@ -22,7 +22,8 @@ namespace omath::cry_engine
Mat4X4 rotation_matrix(const ViewAngles& angles) noexcept;
[[nodiscard]]
Mat4X4 calc_perspective_projection_matrix(float field_of_view, float aspect_ratio, float near, float far) noexcept;
Mat4X4 calc_perspective_projection_matrix(float field_of_view, float aspect_ratio, float near, float far,
NDCDepthRange ndc_depth_range = NDCDepthRange::NEGATIVE_ONE_TO_ONE) noexcept;
template<class FloatingType>
requires std::is_floating_point_v<FloatingType>

View File

@@ -18,7 +18,7 @@ namespace omath::cry_engine
static Mat4X4 calc_view_matrix(const ViewAngles& angles, const Vector3<float>& cam_origin) noexcept;
[[nodiscard]]
static Mat4X4 calc_projection_matrix(const projection::FieldOfView& fov, const projection::ViewPort& view_port,
float near, float far) noexcept;
float near, float far, NDCDepthRange ndc_depth_range) noexcept;
};
} // namespace omath::cry_engine

View File

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

View File

@@ -22,7 +22,8 @@ namespace omath::frostbite_engine
Mat4X4 rotation_matrix(const ViewAngles& angles) noexcept;
[[nodiscard]]
Mat4X4 calc_perspective_projection_matrix(float field_of_view, float aspect_ratio, float near, float far) noexcept;
Mat4X4 calc_perspective_projection_matrix(float field_of_view, float aspect_ratio, float near, float far,
NDCDepthRange ndc_depth_range = NDCDepthRange::NEGATIVE_ONE_TO_ONE) noexcept;
template<class FloatingType>
requires std::is_floating_point_v<FloatingType>

View File

@@ -18,7 +18,7 @@ namespace omath::frostbite_engine
static Mat4X4 calc_view_matrix(const ViewAngles& angles, const Vector3<float>& cam_origin) noexcept;
[[nodiscard]]
static Mat4X4 calc_projection_matrix(const projection::FieldOfView& fov, const projection::ViewPort& view_port,
float near, float far) noexcept;
float near, float far, NDCDepthRange ndc_depth_range) noexcept;
};
} // namespace omath::unreal_engine

View File

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

View File

@@ -22,7 +22,8 @@ namespace omath::iw_engine
[[nodiscard]] Mat4X4 calc_view_matrix(const ViewAngles& angles, const Vector3<float>& cam_origin) noexcept;
[[nodiscard]]
Mat4X4 calc_perspective_projection_matrix(float field_of_view, float aspect_ratio, float near, float far) noexcept;
Mat4X4 calc_perspective_projection_matrix(float field_of_view, float aspect_ratio, float near, float far,
NDCDepthRange ndc_depth_range = NDCDepthRange::NEGATIVE_ONE_TO_ONE) noexcept;
template<class FloatingType>
requires std::is_floating_point_v<FloatingType>

View File

@@ -18,7 +18,7 @@ namespace omath::iw_engine
static Mat4X4 calc_view_matrix(const ViewAngles& angles, const Vector3<float>& cam_origin) noexcept;
[[nodiscard]]
static Mat4X4 calc_projection_matrix(const projection::FieldOfView& fov, const projection::ViewPort& view_port,
float near, float far) noexcept;
float near, float far, NDCDepthRange ndc_depth_range) noexcept;
};
} // namespace omath::iw_engine

View File

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

View File

@@ -21,7 +21,8 @@ namespace omath::opengl_engine
Mat4X4 rotation_matrix(const ViewAngles& angles) noexcept;
[[nodiscard]]
Mat4X4 calc_perspective_projection_matrix(float field_of_view, float aspect_ratio, float near, float far) noexcept;
Mat4X4 calc_perspective_projection_matrix(float field_of_view, float aspect_ratio, float near, float far,
NDCDepthRange ndc_depth_range = NDCDepthRange::NEGATIVE_ONE_TO_ONE) noexcept;
template<class FloatingType>
requires std::is_floating_point_v<FloatingType>

View File

@@ -18,7 +18,7 @@ namespace omath::opengl_engine
static Mat4X4 calc_view_matrix(const ViewAngles& angles, const Vector3<float>& cam_origin) noexcept;
[[nodiscard]]
static Mat4X4 calc_projection_matrix(const projection::FieldOfView& fov, const projection::ViewPort& view_port,
float near, float far) noexcept;
float near, float far, NDCDepthRange ndc_depth_range) noexcept;
};
} // 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>;
using Camera = projection::Camera<Mat4X4, ViewAngles, CameraTrait, NDCDepthRange::ZERO_TO_ONE>;
} // namespace omath::source_engine

View File

@@ -21,7 +21,8 @@ namespace omath::source_engine
[[nodiscard]] Mat4X4 calc_view_matrix(const ViewAngles& angles, const Vector3<float>& cam_origin) noexcept;
[[nodiscard]]
Mat4X4 calc_perspective_projection_matrix(float field_of_view, float aspect_ratio, float near, float far) noexcept;
Mat4X4 calc_perspective_projection_matrix(float field_of_view, float aspect_ratio, float near, float far,
NDCDepthRange ndc_depth_range = NDCDepthRange::NEGATIVE_ONE_TO_ONE) noexcept;
template<class FloatingType>
requires std::is_floating_point_v<FloatingType>

View File

@@ -18,7 +18,7 @@ namespace omath::source_engine
static Mat4X4 calc_view_matrix(const ViewAngles& angles, const Vector3<float>& cam_origin) noexcept;
[[nodiscard]]
static Mat4X4 calc_projection_matrix(const projection::FieldOfView& fov, const projection::ViewPort& view_port,
float near, float far) noexcept;
float near, float far, NDCDepthRange ndc_depth_range) noexcept;
};
} // namespace omath::source_engine

View File

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

View File

@@ -22,7 +22,8 @@ namespace omath::unity_engine
Mat4X4 rotation_matrix(const ViewAngles& angles) noexcept;
[[nodiscard]]
Mat4X4 calc_perspective_projection_matrix(float field_of_view, float aspect_ratio, float near, float far) noexcept;
Mat4X4 calc_perspective_projection_matrix(float field_of_view, float aspect_ratio, float near, float far,
NDCDepthRange ndc_depth_range = NDCDepthRange::NEGATIVE_ONE_TO_ONE) noexcept;
template<class FloatingType>
requires std::is_floating_point_v<FloatingType>

View File

@@ -18,7 +18,7 @@ namespace omath::unity_engine
static Mat4X4 calc_view_matrix(const ViewAngles& angles, const Vector3<float>& cam_origin) noexcept;
[[nodiscard]]
static Mat4X4 calc_projection_matrix(const projection::FieldOfView& fov, const projection::ViewPort& view_port,
float near, float far) noexcept;
float near, float far, NDCDepthRange ndc_depth_range) noexcept;
};
} // namespace omath::unity_engine

View File

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

View File

@@ -22,7 +22,8 @@ namespace omath::unreal_engine
Mat4X4 rotation_matrix(const ViewAngles& angles) noexcept;
[[nodiscard]]
Mat4X4 calc_perspective_projection_matrix(float field_of_view, float aspect_ratio, float near, float far) noexcept;
Mat4X4 calc_perspective_projection_matrix(float field_of_view, float aspect_ratio, float near, float far,
NDCDepthRange ndc_depth_range = NDCDepthRange::NEGATIVE_ONE_TO_ONE) noexcept;
template<class FloatingType>
requires std::is_floating_point_v<FloatingType>

View File

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

View File

@@ -37,6 +37,12 @@ namespace omath
COLUMN_MAJOR
};
enum class NDCDepthRange : uint8_t
{
NEGATIVE_ONE_TO_ONE = 0, // OpenGL: [-1.0, 1.0]
ZERO_TO_ONE // DirectX / Vulkan: [0.0, 1.0]
};
template<typename M1, typename M2> concept MatTemplateEqual
= (M1::rows == M2::rows) && (M1::columns == M2::columns)
&& std::is_same_v<typename M1::value_type, typename M2::value_type> && (M1::store_type == M2::store_type);
@@ -658,56 +664,98 @@ namespace omath
} * mat_translation<Type, St>(-camera_origin);
}
template<class Type = float, MatStoreType St = MatStoreType::ROW_MAJOR>
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
{
const float fov_half_tan = std::tan(angles::degrees_to_radians(field_of_view) / 2.f);
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}};
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}};
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}};
else
std::unreachable();
}
template<class Type = float, MatStoreType St = MatStoreType::ROW_MAJOR>
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
{
const float fov_half_tan = std::tan(angles::degrees_to_radians(field_of_view) / 2.f);
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}};
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}};
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}};
else
std::unreachable();
}
template<class Type = float, MatStoreType St = MatStoreType::ROW_MAJOR>
template<class Type = float, MatStoreType St = MatStoreType::ROW_MAJOR,
NDCDepthRange DepthRange = NDCDepthRange::NEGATIVE_ONE_TO_ONE>
[[nodiscard]]
Mat<4, 4, Type, St> mat_ortho_left_handed(const Type left, const Type right, const Type bottom, const Type top,
const Type near, const Type far) noexcept
{
return
{
{ static_cast<Type>(2) / (right - left), 0.f, 0.f, -(right + left) / (right - left)},
{ 0.f, static_cast<Type>(2) / (top - bottom), 0.f, -(top + bottom) / (top - bottom)},
{ 0.f, 0.f, static_cast<Type>(2) / (far - near), -(far + near) / (far - near) },
{ 0.f, 0.f, 0.f, 1.f }
};
if constexpr (DepthRange == NDCDepthRange::ZERO_TO_ONE)
return
{
{ static_cast<Type>(2) / (right - left), 0.f, 0.f, -(right + left) / (right - left)},
{ 0.f, static_cast<Type>(2) / (top - bottom), 0.f, -(top + bottom) / (top - bottom)},
{ 0.f, 0.f, static_cast<Type>(1) / (far - near), -near / (far - near) },
{ 0.f, 0.f, 0.f, 1.f }
};
else if constexpr (DepthRange == NDCDepthRange::NEGATIVE_ONE_TO_ONE)
return
{
{ static_cast<Type>(2) / (right - left), 0.f, 0.f, -(right + left) / (right - left)},
{ 0.f, static_cast<Type>(2) / (top - bottom), 0.f, -(top + bottom) / (top - bottom)},
{ 0.f, 0.f, static_cast<Type>(2) / (far - near), -(far + near) / (far - near) },
{ 0.f, 0.f, 0.f, 1.f }
};
else
std::unreachable();
}
template<class Type = float, MatStoreType St = MatStoreType::ROW_MAJOR>
template<class Type = float, MatStoreType St = MatStoreType::ROW_MAJOR,
NDCDepthRange DepthRange = NDCDepthRange::NEGATIVE_ONE_TO_ONE>
[[nodiscard]]
Mat<4, 4, Type, St> mat_ortho_right_handed(const Type left, const Type right, const Type bottom, const Type top,
const Type near, const Type far) noexcept
{
return
{
{ static_cast<Type>(2) / (right - left), 0.f, 0.f, -(right + left) / (right - left)},
{ 0.f, static_cast<Type>(2) / (top - bottom), 0.f, -(top + bottom) / (top - bottom)},
{ 0.f, 0.f, -static_cast<Type>(2) / (far - near), -(far + near) / (far - near) },
{ 0.f, 0.f, 0.f, 1.f }
};
if constexpr (DepthRange == NDCDepthRange::ZERO_TO_ONE)
return
{
{ static_cast<Type>(2) / (right - left), 0.f, 0.f, -(right + left) / (right - left)},
{ 0.f, static_cast<Type>(2) / (top - bottom), 0.f, -(top + bottom) / (top - bottom)},
{ 0.f, 0.f, -static_cast<Type>(1) / (far - near), -near / (far - near) },
{ 0.f, 0.f, 0.f, 1.f }
};
else if constexpr (DepthRange == NDCDepthRange::NEGATIVE_ONE_TO_ONE)
return
{
{ static_cast<Type>(2) / (right - left), 0.f, 0.f, -(right + left) / (right - left)},
{ 0.f, static_cast<Type>(2) / (top - bottom), 0.f, -(top + bottom) / (top - bottom)},
{ 0.f, 0.f, -static_cast<Type>(2) / (far - near), -(far + near) / (far - near) },
{ 0.f, 0.f, 0.f, 1.f }
};
else
std::unreachable();
}
template<class T = float, MatStoreType St = MatStoreType::COLUMN_MAJOR>
Mat<4, 4, T, St> mat_look_at_left_handed(const Vector3<T>& eye, const Vector3<T>& center, const Vector3<T>& up)

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,22 +42,31 @@ namespace omath::projection
AUTO,
MANUAL,
};
struct CameraAxes
{
bool inverted_forward = false;
bool inverted_right = false;
};
template<class T, class MatType, class ViewAnglesType>
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) {
const FieldOfView& fov, const ViewPort& viewport, float znear, float zfar,
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) } -> std::same_as<MatType>;
{ T::calc_projection_matrix(fov, viewport, znear, zfar, ndc_depth_range) } -> std::same_as<MatType>;
// 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));
requires noexcept(T::calc_projection_matrix(fov, viewport, znear, zfar, ndc_depth_range));
};
template<class Mat4X4Type, class ViewAnglesType, class TraitClass, bool inverted_z = false>
template<class Mat4X4Type, class ViewAnglesType, class TraitClass,
NDCDepthRange depth_range = NDCDepthRange::NEGATIVE_ONE_TO_ONE,
CameraAxes axes = {}>
requires CameraEngineConcept<TraitClass, Mat4X4Type, ViewAnglesType>
class Camera final
{
@@ -80,6 +90,27 @@ namespace omath::projection
{
}
[[nodiscard]]
static ViewAnglesType calc_view_angles_from_view_matrix(const Mat4X4Type& view_matrix) noexcept
{
Vector3<float> 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<float> 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<float>& target)
{
m_view_angles = TraitClass::calc_look_at_angle(m_origin, target);
@@ -97,7 +128,7 @@ namespace omath::projection
{
const auto& view_matrix = get_view_matrix();
if constexpr (inverted_z)
if constexpr (axes.inverted_forward)
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]};
}
@@ -106,6 +137,8 @@ namespace omath::projection
Vector3<float> get_right() const noexcept
{
const auto& view_matrix = get_view_matrix();
if constexpr (axes.inverted_right)
return -Vector3<float>{view_matrix[0, 0], view_matrix[0, 1], view_matrix[0, 2]};
return {view_matrix[0, 0], view_matrix[0, 1], view_matrix[0, 2]};
}
@@ -135,7 +168,8 @@ namespace omath::projection
{
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);
m_near_plane_distance, m_far_plane_distance,
depth_range);
return m_projection_matrix.value();
}
@@ -271,18 +305,94 @@ namespace omath::projection
return a[axis] < -a[3] && b[axis] < -b[3] && c[axis] < -c[3];
};
// Clip volume in clip space (OpenGL-style):
// Clip volume in clip space:
// -w <= x <= w
// -w <= y <= w
// -w <= z <= w
// z_min <= z <= w (z_min = -w for [-1,1], 0 for [0,1])
for (int i = 0; i < 3; i++)
// x and y planes
for (int i = 0; i < 2; i++)
{
if (all_outside_plane(i, c0, c1, c2, false))
return true; // x < -w (left)
return true;
if (all_outside_plane(i, c0, c1, c2, true))
return true; // x > w (right)
return true;
}
// z far plane: z > w
if (all_outside_plane(2, c0, c1, c2, true))
return true;
// z near plane
if constexpr (depth_range == NDCDepthRange::ZERO_TO_ONE)
{
// 0 <= z, so reject if z < 0 for all vertices
if (c0[2] < 0.f && c1[2] < 0.f && c2[2] < 0.f)
return true;
}
else
{
// -w <= z
if (all_outside_plane(2, c0, c1, c2, false))
return true;
}
return false;
}
[[nodiscard]] bool is_aabb_culled_by_frustum(const primitives::Aabb<float>& 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
{
float a, b, c, d;
};
const auto extract_plane = [&m](const int sign, const int row) -> Plane
{
return {
m.at(3, 0) + static_cast<float>(sign) * m.at(row, 0),
m.at(3, 1) + static_cast<float>(sign) * m.at(row, 1),
m.at(3, 2) + static_cast<float>(sign) * m.at(row, 2),
m.at(3, 3) + static_cast<float>(sign) * m.at(row, 3),
};
};
std::array<Plane, 6> planes = {
extract_plane(1, 0), // left
extract_plane(-1, 0), // right
extract_plane(1, 1), // bottom
extract_plane(-1, 1), // top
extract_plane(-1, 2), // far
};
// Near plane depends on NDC depth range
if constexpr (depth_range == NDCDepthRange::ZERO_TO_ONE)
planes[5] = {m.at(2, 0), m.at(2, 1), m.at(2, 2), m.at(2, 3)};
else
planes[5] = extract_plane(1, 2);
// For each plane, find the AABB corner most in the direction of the plane normal
// (the "positive vertex"). If it's outside, the entire AABB is outside.
for (const auto& [a, b, c, d] : planes)
{
const float px = a >= 0.f ? aabb.max.x : aabb.min.x;
const float py = b >= 0.f ? aabb.max.y : aabb.min.y;
const float pz = c >= 0.f ? aabb.max.z : aabb.min.z;
if (a * px + b * py + c * pz + d < 0.f)
return true;
}
return false;
}
@@ -306,7 +416,8 @@ namespace omath::projection
return std::unexpected(Error::WORLD_POSITION_IS_OUT_OF_SCREEN_BOUNDS);
// ReSharper disable once CppTooWideScope
const auto clipped_manually = clipping == ViewPortClipping::MANUAL && (projected.at(2, 0) < 0.0f - eps
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);
if (clipped_manually)
return std::unexpected(Error::WORLD_POSITION_IS_OUT_OF_SCREEN_BOUNDS);
@@ -368,8 +479,26 @@ namespace omath::projection
[[nodiscard]] constexpr static bool is_ndc_out_of_bounds(const Type& ndc) noexcept
{
constexpr auto eps = std::numeric_limits<float>::epsilon();
return std::ranges::any_of(ndc.raw_array(),
[](const auto& val) { return val < -1.0f - eps || val > 1.0f + eps; });
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)
return true;
if (data[1] < -1.0f - eps || data[1] > 1.0f + eps)
return true;
return is_ndc_z_value_out_of_bounds(data[2]);
}
template<class ZType>
[[nodiscard]]
constexpr static bool is_ndc_z_value_out_of_bounds(const ZType& z_ndc) noexcept
{
constexpr auto eps = std::numeric_limits<float>::epsilon();
if constexpr (depth_range == NDCDepthRange::NEGATIVE_ONE_TO_ONE)
return z_ndc < -1.0f - eps || z_ndc > 1.0f + eps;
if constexpr (depth_range == NDCDepthRange::ZERO_TO_ONE)
return z_ndc < 0.0f - eps || z_ndc > 1.0f + eps;
std::unreachable();
}
// NDC REPRESENTATION:

View File

@@ -116,6 +116,33 @@ namespace omath::rev_eng
return call_method<ReturnType>(vtable[Id], arg_list...);
}
template<std::ptrdiff_t TableOffset, std::size_t Id, class ReturnType>
ReturnType call_virtual_method(auto... arg_list)
{
auto sub_this = reinterpret_cast<void*>(
reinterpret_cast<std::uintptr_t>(this) + TableOffset);
const auto vtable = *reinterpret_cast<void***>(sub_this);
#ifdef _MSC_VER
using Fn = ReturnType(__thiscall*)(void*, decltype(arg_list)...);
#else
using Fn = ReturnType(*)(void*, decltype(arg_list)...);
#endif
return reinterpret_cast<Fn>(vtable[Id])(sub_this, arg_list...);
}
template<std::ptrdiff_t TableOffset, std::size_t Id, class ReturnType>
ReturnType call_virtual_method(auto... arg_list) const
{
auto sub_this = reinterpret_cast<const void*>(
reinterpret_cast<std::uintptr_t>(this) + TableOffset);
const auto vtable = *reinterpret_cast<void* const* const*>(sub_this);
#ifdef _MSC_VER
using Fn = ReturnType(__thiscall*)(const void*, decltype(arg_list)...);
#else
using Fn = ReturnType(*)(const void*, decltype(arg_list)...);
#endif
return reinterpret_cast<Fn>(vtable[Id])(sub_this, arg_list...);
}
private:
[[nodiscard]]
static const void* resolve_pattern(const std::string_view module_name, const std::string_view pattern)

View File

@@ -36,6 +36,7 @@ namespace omath
}
public:
using ArithmeticType = Type;
[[nodiscard]]
constexpr static Angle from_degrees(const Type& degrees) noexcept
{

View File

@@ -2,14 +2,25 @@
// Created by Orange on 11/30/2024.
//
#pragma once
#include "omath/linear_algebra/vector3.hpp"
#include <type_traits>
namespace omath
{
template<class PitchType, class YawType, class RollType>
requires std::is_same_v<typename PitchType::ArithmeticType, typename YawType::ArithmeticType>
&& std::is_same_v<typename YawType::ArithmeticType, typename RollType::ArithmeticType>
struct ViewAngles
{
using ArithmeticType = PitchType::ArithmeticType;
PitchType pitch;
YawType yaw;
RollType roll;
[[nodiscard]]
Vector3<ArithmeticType> as_vector3() const
{
return {pitch.as_degrees(), yaw.as_degrees(), roll.as_degrees()};
}
};
} // namespace omath

View File

@@ -35,8 +35,15 @@ namespace omath::cry_engine
* mat_rotation_axis_x<float, MatStoreType::ROW_MAJOR>(angles.pitch);
}
Mat4X4 calc_perspective_projection_matrix(const float field_of_view, const float aspect_ratio, const float near,
const float far) noexcept
const float far, const NDCDepthRange ndc_depth_range) noexcept
{
return mat_perspective_left_handed(field_of_view, aspect_ratio, near, far);
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);
if (ndc_depth_range == NDCDepthRange::NEGATIVE_ONE_TO_ONE)
return mat_perspective_left_handed<float, MatStoreType::ROW_MAJOR, NDCDepthRange::NEGATIVE_ONE_TO_ONE>(
field_of_view, aspect_ratio, near, far);
std::unreachable();
}
} // namespace omath::unity_engine

View File

@@ -19,8 +19,9 @@ namespace omath::cry_engine
}
Mat4X4 CameraTrait::calc_projection_matrix(const projection::FieldOfView& fov,
const projection::ViewPort& view_port, const float near,
const float far) noexcept
const float far, const NDCDepthRange ndc_depth_range) noexcept
{
return calc_perspective_projection_matrix(fov.as_degrees(), view_port.aspect_ratio(), near, far);
return calc_perspective_projection_matrix(fov.as_degrees(), view_port.aspect_ratio(), near, far,
ndc_depth_range);
}
} // namespace omath::unity_engine

View File

@@ -35,8 +35,16 @@ namespace omath::frostbite_engine
* mat_rotation_axis_x<float, MatStoreType::ROW_MAJOR>(angles.pitch);
}
Mat4X4 calc_perspective_projection_matrix(const float field_of_view, const float aspect_ratio, const float near,
const float far) noexcept
const float far, const NDCDepthRange ndc_depth_range) noexcept
{
return mat_perspective_left_handed(field_of_view, aspect_ratio, near, far);
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);
if (ndc_depth_range == NDCDepthRange::NEGATIVE_ONE_TO_ONE)
return mat_perspective_left_handed<float, MatStoreType::ROW_MAJOR, NDCDepthRange::NEGATIVE_ONE_TO_ONE>(
field_of_view, aspect_ratio, near, far);
std::unreachable();
}
} // namespace omath::unity_engine

View File

@@ -19,8 +19,9 @@ namespace omath::frostbite_engine
}
Mat4X4 CameraTrait::calc_projection_matrix(const projection::FieldOfView& fov,
const projection::ViewPort& view_port, const float near,
const float far) noexcept
const float far, const NDCDepthRange ndc_depth_range) noexcept
{
return calc_perspective_projection_matrix(fov.as_degrees(), view_port.aspect_ratio(), near, far);
return calc_perspective_projection_matrix(fov.as_degrees(), view_port.aspect_ratio(), near, far,
ndc_depth_range);
}
} // namespace omath::unity_engine

View File

@@ -36,18 +36,27 @@ namespace omath::iw_engine
}
Mat4X4 calc_perspective_projection_matrix(const float field_of_view, const float aspect_ratio, const float near,
const float far) noexcept
const float far, const NDCDepthRange ndc_depth_range) noexcept
{
// NOTE: Need magic number to fix fov calculation, since IW engine 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;
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},
};
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},
};
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},
};
std::unreachable();
};
} // namespace omath::iw_engine

View File

@@ -19,8 +19,9 @@ namespace omath::iw_engine
}
Mat4X4 CameraTrait::calc_projection_matrix(const projection::FieldOfView& fov,
const projection::ViewPort& view_port, const float near,
const float far) noexcept
const float far, const NDCDepthRange ndc_depth_range) noexcept
{
return calc_perspective_projection_matrix(fov.as_degrees(), view_port.aspect_ratio(), near, far);
return calc_perspective_projection_matrix(fov.as_degrees(), view_port.aspect_ratio(), near, far,
ndc_depth_range);
}
} // namespace omath::iw_engine

View File

@@ -8,15 +8,15 @@ namespace omath::opengl_engine
Vector3<float> forward_vector(const ViewAngles& angles) noexcept
{
const auto vec
= rotation_matrix(angles) * mat_column_from_vector<float, MatStoreType::COLUMN_MAJOR>(k_abs_forward);
const auto vec =
rotation_matrix(angles) * mat_column_from_vector<float, MatStoreType::COLUMN_MAJOR>(k_abs_forward);
return {vec.at(0, 0), vec.at(1, 0), vec.at(2, 0)};
}
Vector3<float> right_vector(const ViewAngles& angles) noexcept
{
const auto vec
= rotation_matrix(angles) * mat_column_from_vector<float, MatStoreType::COLUMN_MAJOR>(k_abs_right);
const auto vec =
rotation_matrix(angles) * mat_column_from_vector<float, MatStoreType::COLUMN_MAJOR>(k_abs_right);
return {vec.at(0, 0), vec.at(1, 0), vec.at(2, 0)};
}
@@ -28,7 +28,7 @@ namespace omath::opengl_engine
}
Mat4X4 calc_view_matrix(const ViewAngles& angles, const Vector3<float>& cam_origin) noexcept
{
return mat_look_at_right_handed(cam_origin, cam_origin+forward_vector(angles), up_vector(angles));
return mat_look_at_right_handed(cam_origin, cam_origin + forward_vector(angles), up_vector(angles));
}
Mat4X4 rotation_matrix(const ViewAngles& angles) noexcept
{
@@ -37,15 +37,16 @@ namespace omath::opengl_engine
* mat_rotation_axis_x<float, MatStoreType::COLUMN_MAJOR>(angles.pitch);
}
Mat4X4 calc_perspective_projection_matrix(const float field_of_view, const float aspect_ratio, const float near,
const float far) noexcept
const float far, const NDCDepthRange ndc_depth_range) noexcept
{
const float fov_half_tan = std::tan(angles::degrees_to_radians(field_of_view) / 2.f);
if (ndc_depth_range == NDCDepthRange::NEGATIVE_ONE_TO_ONE)
return mat_perspective_right_handed<float, MatStoreType::COLUMN_MAJOR, NDCDepthRange::NEGATIVE_ONE_TO_ONE>(
field_of_view, aspect_ratio, near, far);
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},
};
if (ndc_depth_range == NDCDepthRange::ZERO_TO_ONE)
return mat_perspective_right_handed<float, MatStoreType::COLUMN_MAJOR, NDCDepthRange::ZERO_TO_ONE>(
field_of_view, aspect_ratio, near, far);
std::unreachable();
}
} // namespace omath::opengl_engine

View File

@@ -20,8 +20,9 @@ namespace omath::opengl_engine
}
Mat4X4 CameraTrait::calc_projection_matrix(const projection::FieldOfView& fov,
const projection::ViewPort& view_port, const float near,
const float far) noexcept
const float far, const NDCDepthRange ndc_depth_range) noexcept
{
return calc_perspective_projection_matrix(fov.as_degrees(), view_port.aspect_ratio(), near, far);
return calc_perspective_projection_matrix(fov.as_degrees(), view_port.aspect_ratio(), near, far,
ndc_depth_range);
}
} // namespace omath::opengl_engine

View File

@@ -36,18 +36,27 @@ namespace omath::source_engine
}
Mat4X4 calc_perspective_projection_matrix(const float field_of_view, const float aspect_ratio, const float near,
const float far) noexcept
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;
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},
};
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},
};
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},
};
std::unreachable();
}
} // namespace omath::source_engine

View File

@@ -20,8 +20,9 @@ namespace omath::source_engine
}
Mat4X4 CameraTrait::calc_projection_matrix(const projection::FieldOfView& fov,
const projection::ViewPort& view_port, const float near,
const float far) noexcept
const float far, const NDCDepthRange ndc_depth_range) noexcept
{
return calc_perspective_projection_matrix(fov.as_degrees(), view_port.aspect_ratio(), near, far);
return calc_perspective_projection_matrix(fov.as_degrees(), view_port.aspect_ratio(), near, far,
ndc_depth_range);
}
} // namespace omath::source_engine

View File

@@ -35,8 +35,15 @@ namespace omath::unity_engine
* mat_rotation_axis_x<float, MatStoreType::ROW_MAJOR>(angles.pitch);
}
Mat4X4 calc_perspective_projection_matrix(const float field_of_view, const float aspect_ratio, const float near,
const float far) noexcept
const float far, const NDCDepthRange ndc_depth_range) noexcept
{
return omath::mat_perspective_right_handed(field_of_view, aspect_ratio, near, far);
if (ndc_depth_range == NDCDepthRange::ZERO_TO_ONE)
return omath::mat_perspective_right_handed<float, MatStoreType::ROW_MAJOR, NDCDepthRange::ZERO_TO_ONE>(
field_of_view, aspect_ratio, near, far);
if (ndc_depth_range == NDCDepthRange::NEGATIVE_ONE_TO_ONE)
return omath::mat_perspective_right_handed<float, MatStoreType::ROW_MAJOR,
NDCDepthRange::NEGATIVE_ONE_TO_ONE>(field_of_view, aspect_ratio,
near, far);
std::unreachable();
}
} // namespace omath::unity_engine

View File

@@ -19,8 +19,9 @@ namespace omath::unity_engine
}
Mat4X4 CameraTrait::calc_projection_matrix(const projection::FieldOfView& fov,
const projection::ViewPort& view_port, const float near,
const float far) noexcept
const float far, const NDCDepthRange ndc_depth_range) noexcept
{
return calc_perspective_projection_matrix(fov.as_degrees(), view_port.aspect_ratio(), near, far);
return calc_perspective_projection_matrix(fov.as_degrees(), view_port.aspect_ratio(), near, far,
ndc_depth_range);
}
} // namespace omath::unity_engine

View File

@@ -35,8 +35,12 @@ namespace omath::unreal_engine
* mat_rotation_axis_y<float, MatStoreType::ROW_MAJOR>(angles.pitch);
}
Mat4X4 calc_perspective_projection_matrix(const float field_of_view, const float aspect_ratio, const float near,
const float far) noexcept
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);
}
} // namespace omath::unreal_engine

View File

@@ -19,8 +19,9 @@ namespace omath::unreal_engine
}
Mat4X4 CameraTrait::calc_projection_matrix(const projection::FieldOfView& fov,
const projection::ViewPort& view_port, const float near,
const float far) noexcept
const float far, const NDCDepthRange ndc_depth_range) noexcept
{
return calc_perspective_projection_matrix(fov.as_degrees(), view_port.aspect_ratio(), near, far);
return calc_perspective_projection_matrix(fov.as_degrees(), view_port.aspect_ratio(), near, far,
ndc_depth_range);
}
} // namespace omath::unreal_engine

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

@@ -237,4 +237,54 @@ TEST(unit_test_cry_engine, loook_at_random_z_axis)
failed_points++;
}
EXPECT_LE(failed_points, 100);
}
TEST(unit_test_cry_engine, ViewAnglesAsVector3Zero)
{
const omath::cry_engine::ViewAngles angles{};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 0.f);
EXPECT_FLOAT_EQ(vec.y, 0.f);
EXPECT_FLOAT_EQ(vec.z, 0.f);
}
TEST(unit_test_cry_engine, ViewAnglesAsVector3Values)
{
const omath::cry_engine::ViewAngles angles{
omath::cry_engine::PitchAngle::from_degrees(45.f),
omath::cry_engine::YawAngle::from_degrees(-90.f),
omath::cry_engine::RollAngle::from_degrees(30.f)
};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 45.f);
EXPECT_FLOAT_EQ(vec.y, -90.f);
EXPECT_FLOAT_EQ(vec.z, 30.f);
}
TEST(unit_test_cry_engine, ViewAnglesAsVector3ClampedPitch)
{
// Pitch is clamped to [-90, 90]
const omath::cry_engine::ViewAngles angles{
omath::cry_engine::PitchAngle::from_degrees(120.f),
omath::cry_engine::YawAngle::from_degrees(0.f),
omath::cry_engine::RollAngle::from_degrees(0.f)
};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 90.f);
}
TEST(unit_test_cry_engine, ViewAnglesAsVector3NormalizedYaw)
{
// Yaw is normalized to [-180, 180], 270 wraps to -90
const omath::cry_engine::ViewAngles angles{
omath::cry_engine::PitchAngle::from_degrees(0.f),
omath::cry_engine::YawAngle::from_degrees(270.f),
omath::cry_engine::RollAngle::from_degrees(0.f)
};
const auto vec = angles.as_vector3();
EXPECT_NEAR(vec.y, -90.f, 0.01f);
}

View File

@@ -405,3 +405,51 @@ TEST(unit_test_frostbite_engine, look_at_down)
std::views::zip(dir_vector.as_array(), (-omath::frostbite_engine::k_abs_up).as_array()))
EXPECT_NEAR(result, etalon, 0.0001f);
}
TEST(unit_test_frostbite_engine, ViewAnglesAsVector3Zero)
{
const omath::frostbite_engine::ViewAngles angles{};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 0.f);
EXPECT_FLOAT_EQ(vec.y, 0.f);
EXPECT_FLOAT_EQ(vec.z, 0.f);
}
TEST(unit_test_frostbite_engine, ViewAnglesAsVector3Values)
{
const omath::frostbite_engine::ViewAngles angles{
omath::frostbite_engine::PitchAngle::from_degrees(45.f),
omath::frostbite_engine::YawAngle::from_degrees(-90.f),
omath::frostbite_engine::RollAngle::from_degrees(30.f)
};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 45.f);
EXPECT_FLOAT_EQ(vec.y, -90.f);
EXPECT_FLOAT_EQ(vec.z, 30.f);
}
TEST(unit_test_frostbite_engine, ViewAnglesAsVector3ClampedPitch)
{
const omath::frostbite_engine::ViewAngles angles{
omath::frostbite_engine::PitchAngle::from_degrees(120.f),
omath::frostbite_engine::YawAngle::from_degrees(0.f),
omath::frostbite_engine::RollAngle::from_degrees(0.f)
};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 90.f);
}
TEST(unit_test_frostbite_engine, ViewAnglesAsVector3NormalizedYaw)
{
const omath::frostbite_engine::ViewAngles angles{
omath::frostbite_engine::PitchAngle::from_degrees(0.f),
omath::frostbite_engine::YawAngle::from_degrees(270.f),
omath::frostbite_engine::RollAngle::from_degrees(0.f)
};
const auto vec = angles.as_vector3();
EXPECT_NEAR(vec.y, -90.f, 0.01f);
}

View File

@@ -280,4 +280,54 @@ TEST(unit_test_iw_engine, look_at_down)
EXPECT_NEAR(dir_vector.z, -0.99984f, 0.0001f);
EXPECT_NEAR(dir_vector.x,- 0.017f, 0.01f);
EXPECT_NEAR(dir_vector.y, 0.f, 0.001f);
}
TEST(unit_test_iw_engine, ViewAnglesAsVector3Zero)
{
const omath::iw_engine::ViewAngles angles{};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 0.f);
EXPECT_FLOAT_EQ(vec.y, 0.f);
EXPECT_FLOAT_EQ(vec.z, 0.f);
}
TEST(unit_test_iw_engine, ViewAnglesAsVector3Values)
{
const omath::iw_engine::ViewAngles angles{
omath::iw_engine::PitchAngle::from_degrees(45.f),
omath::iw_engine::YawAngle::from_degrees(-90.f),
omath::iw_engine::RollAngle::from_degrees(30.f)
};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 45.f);
EXPECT_FLOAT_EQ(vec.y, -90.f);
EXPECT_FLOAT_EQ(vec.z, 30.f);
}
TEST(unit_test_iw_engine, ViewAnglesAsVector3ClampedPitch)
{
// Pitch is clamped to [-89, 89]
const omath::iw_engine::ViewAngles angles{
omath::iw_engine::PitchAngle::from_degrees(120.f),
omath::iw_engine::YawAngle::from_degrees(0.f),
omath::iw_engine::RollAngle::from_degrees(0.f)
};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 89.f);
}
TEST(unit_test_iw_engine, ViewAnglesAsVector3NormalizedYaw)
{
// Yaw is normalized to [-180, 180], 270 wraps to -90
const omath::iw_engine::ViewAngles angles{
omath::iw_engine::PitchAngle::from_degrees(0.f),
omath::iw_engine::YawAngle::from_degrees(270.f),
omath::iw_engine::RollAngle::from_degrees(0.f)
};
const auto vec = angles.as_vector3();
EXPECT_NEAR(vec.y, -90.f, 0.01f);
}

View File

@@ -394,4 +394,52 @@ TEST(unit_test_opengl_engine, look_at_down)
const auto dir_vector = omath::opengl_engine::forward_vector(angles);
for (const auto& [result, etalon] : std::views::zip(dir_vector.as_array(), (-omath::opengl_engine::k_abs_up).as_array()))
EXPECT_NEAR(result, etalon, 0.0001f);
}
TEST(unit_test_opengl, ViewAnglesAsVector3Zero)
{
const omath::opengl_engine::ViewAngles angles{};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 0.f);
EXPECT_FLOAT_EQ(vec.y, 0.f);
EXPECT_FLOAT_EQ(vec.z, 0.f);
}
TEST(unit_test_opengl, ViewAnglesAsVector3Values)
{
const omath::opengl_engine::ViewAngles angles{
omath::opengl_engine::PitchAngle::from_degrees(45.f),
omath::opengl_engine::YawAngle::from_degrees(-90.f),
omath::opengl_engine::RollAngle::from_degrees(30.f)
};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 45.f);
EXPECT_FLOAT_EQ(vec.y, -90.f);
EXPECT_FLOAT_EQ(vec.z, 30.f);
}
TEST(unit_test_opengl, ViewAnglesAsVector3ClampedPitch)
{
const omath::opengl_engine::ViewAngles angles{
omath::opengl_engine::PitchAngle::from_degrees(120.f),
omath::opengl_engine::YawAngle::from_degrees(0.f),
omath::opengl_engine::RollAngle::from_degrees(0.f)
};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 90.f);
}
TEST(unit_test_opengl, ViewAnglesAsVector3NormalizedYaw)
{
const omath::opengl_engine::ViewAngles angles{
omath::opengl_engine::PitchAngle::from_degrees(0.f),
omath::opengl_engine::YawAngle::from_degrees(270.f),
omath::opengl_engine::RollAngle::from_degrees(0.f)
};
const auto vec = angles.as_vector3();
EXPECT_NEAR(vec.y, -90.f, 0.01f);
}

View File

@@ -422,4 +422,54 @@ TEST(unit_test_source_engine, look_at_down)
EXPECT_NEAR(dir_vector.z, -0.99984f, 0.0001f);
EXPECT_NEAR(dir_vector.x,- 0.017f, 0.01f);
EXPECT_NEAR(dir_vector.y, 0.f, 0.001f);
}
TEST(unit_test_source_engine, ViewAnglesAsVector3Zero)
{
const omath::source_engine::ViewAngles angles{};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 0.f);
EXPECT_FLOAT_EQ(vec.y, 0.f);
EXPECT_FLOAT_EQ(vec.z, 0.f);
}
TEST(unit_test_source_engine, ViewAnglesAsVector3Values)
{
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(30.f)
};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 45.f);
EXPECT_FLOAT_EQ(vec.y, -90.f);
EXPECT_FLOAT_EQ(vec.z, 30.f);
}
TEST(unit_test_source_engine, ViewAnglesAsVector3ClampedPitch)
{
// Pitch is clamped to [-89, 89]
const omath::source_engine::ViewAngles angles{
omath::source_engine::PitchAngle::from_degrees(120.f),
omath::source_engine::YawAngle::from_degrees(0.f),
omath::source_engine::RollAngle::from_degrees(0.f)
};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 89.f);
}
TEST(unit_test_source_engine, ViewAnglesAsVector3NormalizedYaw)
{
// Yaw is normalized to [-180, 180], 270 wraps to -90
const omath::source_engine::ViewAngles angles{
omath::source_engine::PitchAngle::from_degrees(0.f),
omath::source_engine::YawAngle::from_degrees(270.f),
omath::source_engine::RollAngle::from_degrees(0.f)
};
const auto vec = angles.as_vector3();
EXPECT_NEAR(vec.y, -90.f, 0.01f);
}

View File

@@ -21,6 +21,9 @@
#include <omath/engines/unreal_engine/traits/camera_trait.hpp>
#include <omath/engines/source_engine/traits/pred_engine_trait.hpp>
#include <omath/engines/source_engine/traits/camera_trait.hpp>
#include <omath/engines/cry_engine/traits/camera_trait.hpp>
#include <omath/projectile_prediction/projectile.hpp>
#include <omath/projectile_prediction/target.hpp>
@@ -218,9 +221,14 @@ TEST(TraitTests, Frostbite_Pred_And_Mesh_And_Camera)
// CameraTrait look at should be callable
const auto angles = e::CameraTrait::calc_look_at_angle({0, 0, 0}, {0, 1, 1});
(void)angles;
const auto proj = e::CameraTrait::calc_projection_matrix(projection::FieldOfView::from_degrees(60.f), {1280.f, 720.f}, 0.1f, 1000.f);
const auto proj = e::CameraTrait::calc_projection_matrix(projection::FieldOfView::from_degrees(60.f), {1280.f, 720.f}, 0.1f, 1000.f, NDCDepthRange::NEGATIVE_ONE_TO_ONE);
const auto expected = e::calc_perspective_projection_matrix(60.f, 1280.f / 720.f, 0.1f, 1000.f);
expect_matrix_near(proj, expected);
const auto proj_zo = e::CameraTrait::calc_projection_matrix(projection::FieldOfView::from_degrees(60.f), {1280.f, 720.f}, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
const auto expected_zo = e::calc_perspective_projection_matrix(60.f, 1280.f / 720.f, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
expect_matrix_near(proj_zo, expected_zo);
EXPECT_NE(proj, proj_zo);
}
TEST(TraitTests, IW_Pred_And_Mesh_And_Camera)
@@ -264,10 +272,15 @@ TEST(TraitTests, IW_Pred_And_Mesh_And_Camera)
e::ViewAngles va;
expect_matrix_near(e::MeshTrait::rotation_matrix(va), e::rotation_matrix(va));
const auto proj = e::CameraTrait::calc_projection_matrix(projection::FieldOfView::from_degrees(45.f), {1920.f, 1080.f}, 0.1f, 1000.f);
const auto proj = e::CameraTrait::calc_projection_matrix(projection::FieldOfView::from_degrees(45.f), {1920.f, 1080.f}, 0.1f, 1000.f, NDCDepthRange::NEGATIVE_ONE_TO_ONE);
const auto expected = e::calc_perspective_projection_matrix(45.f, 1920.f / 1080.f, 0.1f, 1000.f);
expect_matrix_near(proj, expected);
const auto proj_zo = e::CameraTrait::calc_projection_matrix(projection::FieldOfView::from_degrees(45.f), {1920.f, 1080.f}, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
const auto expected_zo = e::calc_perspective_projection_matrix(45.f, 1920.f / 1080.f, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
expect_matrix_near(proj_zo, expected_zo);
EXPECT_NE(proj, proj_zo);
// non-airborne
t.m_is_airborne = false;
const auto pred_ground_iw = e::PredEngineTrait::predict_target_position(t, 2.f, 9.81f);
@@ -314,10 +327,15 @@ TEST(TraitTests, OpenGL_Pred_And_Mesh_And_Camera)
e::ViewAngles va;
expect_matrix_near(e::MeshTrait::rotation_matrix(va), e::rotation_matrix(va));
const auto proj = e::CameraTrait::calc_projection_matrix(projection::FieldOfView::from_degrees(60.f), {1280.f, 720.f}, 0.1f, 1000.f);
const auto proj = e::CameraTrait::calc_projection_matrix(projection::FieldOfView::from_degrees(60.f), {1280.f, 720.f}, 0.1f, 1000.f, NDCDepthRange::NEGATIVE_ONE_TO_ONE);
const auto expected = e::calc_perspective_projection_matrix(60.f, 1280.f / 720.f, 0.1f, 1000.f);
expect_matrix_near(proj, expected);
const auto proj_zo = e::CameraTrait::calc_projection_matrix(projection::FieldOfView::from_degrees(60.f), {1280.f, 720.f}, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
const auto expected_zo = e::calc_perspective_projection_matrix(60.f, 1280.f / 720.f, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
expect_matrix_near(proj_zo, expected_zo);
EXPECT_NE(proj, proj_zo);
// non-airborne
t.m_is_airborne = false;
const auto pred_ground_gl = e::PredEngineTrait::predict_target_position(t, 2.f, 9.81f);
@@ -364,10 +382,15 @@ TEST(TraitTests, Unity_Pred_And_Mesh_And_Camera)
e::ViewAngles va;
expect_matrix_near(e::MeshTrait::rotation_matrix(va), e::rotation_matrix(va));
const auto proj = e::CameraTrait::calc_projection_matrix(projection::FieldOfView::from_degrees(60.f), {1280.f, 720.f}, 0.1f, 1000.f);
const auto proj = e::CameraTrait::calc_projection_matrix(projection::FieldOfView::from_degrees(60.f), {1280.f, 720.f}, 0.1f, 1000.f, NDCDepthRange::NEGATIVE_ONE_TO_ONE);
const auto expected = e::calc_perspective_projection_matrix(60.f, 1280.f / 720.f, 0.1f, 1000.f);
expect_matrix_near(proj, expected);
const auto proj_zo = e::CameraTrait::calc_projection_matrix(projection::FieldOfView::from_degrees(60.f), {1280.f, 720.f}, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
const auto expected_zo = e::calc_perspective_projection_matrix(60.f, 1280.f / 720.f, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
expect_matrix_near(proj_zo, expected_zo);
EXPECT_NE(proj, proj_zo);
// non-airborne
t.m_is_airborne = false;
const auto pred_ground_unity = e::PredEngineTrait::predict_target_position(t, 2.f, 9.81f);
@@ -414,12 +437,237 @@ TEST(TraitTests, Unreal_Pred_And_Mesh_And_Camera)
e::ViewAngles va;
expect_matrix_near(e::MeshTrait::rotation_matrix(va), e::rotation_matrix(va));
const auto proj = e::CameraTrait::calc_projection_matrix(projection::FieldOfView::from_degrees(60.f), {1280.f, 720.f}, 0.1f, 1000.f);
const auto proj = e::CameraTrait::calc_projection_matrix(projection::FieldOfView::from_degrees(60.f), {1280.f, 720.f}, 0.1f, 1000.f, NDCDepthRange::NEGATIVE_ONE_TO_ONE);
const auto expected = e::calc_perspective_projection_matrix(60.f, 1280.f / 720.f, 0.1f, 1000.f);
expect_matrix_near(proj, expected);
const auto proj_zo = e::CameraTrait::calc_projection_matrix(projection::FieldOfView::from_degrees(60.f), {1280.f, 720.f}, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
const auto expected_zo = e::calc_perspective_projection_matrix(60.f, 1280.f / 720.f, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
expect_matrix_near(proj_zo, expected_zo);
EXPECT_NE(proj, proj_zo);
// non-airborne
t.m_is_airborne = false;
const auto pred_ground_unreal = e::PredEngineTrait::predict_target_position(t, 2.f, 9.81f);
EXPECT_NEAR(pred_ground_unreal.x, 4.f, 1e-6f);
}
// ── NDC Depth Range tests for Source and CryEngine camera traits ────────────
TEST(NDCDepthRangeTests, Source_BothDepthRanges)
{
namespace e = omath::source_engine;
const auto proj_no = e::CameraTrait::calc_projection_matrix(
projection::FieldOfView::from_degrees(90.f), {1920.f, 1080.f}, 0.1f, 1000.f,
NDCDepthRange::NEGATIVE_ONE_TO_ONE);
const auto expected_no = e::calc_perspective_projection_matrix(
90.f, 1920.f / 1080.f, 0.1f, 1000.f, NDCDepthRange::NEGATIVE_ONE_TO_ONE);
expect_matrix_near(proj_no, expected_no);
const auto proj_zo = e::CameraTrait::calc_projection_matrix(
projection::FieldOfView::from_degrees(90.f), {1920.f, 1080.f}, 0.1f, 1000.f,
NDCDepthRange::ZERO_TO_ONE);
const auto expected_zo = e::calc_perspective_projection_matrix(
90.f, 1920.f / 1080.f, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
expect_matrix_near(proj_zo, expected_zo);
EXPECT_NE(proj_no, proj_zo);
}
TEST(NDCDepthRangeTests, CryEngine_BothDepthRanges)
{
namespace e = omath::cry_engine;
const auto proj_no = e::CameraTrait::calc_projection_matrix(
projection::FieldOfView::from_degrees(90.f), {1920.f, 1080.f}, 0.1f, 1000.f,
NDCDepthRange::NEGATIVE_ONE_TO_ONE);
const auto expected_no = e::calc_perspective_projection_matrix(
90.f, 1920.f / 1080.f, 0.1f, 1000.f, NDCDepthRange::NEGATIVE_ONE_TO_ONE);
expect_matrix_near(proj_no, expected_no);
const auto proj_zo = e::CameraTrait::calc_projection_matrix(
projection::FieldOfView::from_degrees(90.f), {1920.f, 1080.f}, 0.1f, 1000.f,
NDCDepthRange::ZERO_TO_ONE);
const auto expected_zo = e::calc_perspective_projection_matrix(
90.f, 1920.f / 1080.f, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
expect_matrix_near(proj_zo, expected_zo);
EXPECT_NE(proj_no, proj_zo);
}
// ── 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)
{
auto clip = proj * mat_column_from_vector<float>({0, 0, z});
return clip.at(2, 0) / clip.at(3, 0);
}
TEST(NDCDepthRangeTests, Source_ZeroToOne_ZRange)
{
namespace e = omath::source_engine;
// Source is left-handed
const auto proj = e::calc_perspective_projection_matrix(90.f, 16.f / 9.f, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
EXPECT_NEAR(project_z_lh(proj, 0.1f), 0.0f, 1e-4f);
EXPECT_NEAR(project_z_lh(proj, 1000.f), 1.0f, 1e-4f);
EXPECT_GT(project_z_lh(proj, 500.f), 0.0f);
EXPECT_LT(project_z_lh(proj, 500.f), 1.0f);
}
TEST(NDCDepthRangeTests, IW_ZeroToOne_ZRange)
{
namespace e = omath::iw_engine;
// IW is left-handed
const auto proj = e::calc_perspective_projection_matrix(90.f, 16.f / 9.f, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
EXPECT_NEAR(project_z_lh(proj, 0.1f), 0.0f, 1e-4f);
EXPECT_NEAR(project_z_lh(proj, 1000.f), 1.0f, 1e-4f);
EXPECT_GT(project_z_lh(proj, 500.f), 0.0f);
EXPECT_LT(project_z_lh(proj, 500.f), 1.0f);
}
TEST(NDCDepthRangeTests, OpenGL_ZeroToOne_ZRange)
{
namespace e = omath::opengl_engine;
// OpenGL is right-handed (negative z forward), column-major
const auto proj = e::calc_perspective_projection_matrix(90.f, 16.f / 9.f, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
// OpenGL engine uses column-major matrices, project manually
auto proj_z = [&](float z) {
auto clip = proj * mat_column_from_vector<float, MatStoreType::COLUMN_MAJOR>({0, 0, z});
return clip.at(2, 0) / clip.at(3, 0);
};
EXPECT_NEAR(proj_z(-0.1f), 0.0f, 1e-4f);
EXPECT_NEAR(proj_z(-1000.f), 1.0f, 1e-4f);
EXPECT_GT(proj_z(-500.f), 0.0f);
EXPECT_LT(proj_z(-500.f), 1.0f);
}
TEST(NDCDepthRangeTests, Frostbite_ZeroToOne_ZRange)
{
namespace e = omath::frostbite_engine;
const auto proj = e::calc_perspective_projection_matrix(90.f, 16.f / 9.f, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
EXPECT_NEAR(project_z_lh(proj, 0.1f), 0.0f, 1e-4f);
EXPECT_NEAR(project_z_lh(proj, 1000.f), 1.0f, 1e-4f);
EXPECT_GT(project_z_lh(proj, 500.f), 0.0f);
EXPECT_LT(project_z_lh(proj, 500.f), 1.0f);
}
TEST(NDCDepthRangeTests, Unity_ZeroToOne_ZRange)
{
namespace e = omath::unity_engine;
// Unity is right-handed, row-major
const auto proj = e::calc_perspective_projection_matrix(90.f, 16.f / 9.f, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
auto proj_z = [&](float z) {
auto clip = proj * mat_column_from_vector<float>({0, 0, z});
return clip.at(2, 0) / clip.at(3, 0);
};
EXPECT_NEAR(proj_z(-0.1f), 0.0f, 1e-4f);
EXPECT_NEAR(proj_z(-1000.f), 1.0f, 1e-4f);
EXPECT_GT(proj_z(-500.f), 0.0f);
EXPECT_LT(proj_z(-500.f), 1.0f);
}
TEST(NDCDepthRangeTests, Unreal_ZeroToOne_ZRange)
{
namespace e = omath::unreal_engine;
const auto proj = e::calc_perspective_projection_matrix(90.f, 16.f / 9.f, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
EXPECT_NEAR(project_z_lh(proj, 0.1f), 0.0f, 1e-4f);
EXPECT_NEAR(project_z_lh(proj, 1000.f), 1.0f, 1e-4f);
EXPECT_GT(project_z_lh(proj, 500.f), 0.0f);
EXPECT_LT(project_z_lh(proj, 500.f), 1.0f);
}
TEST(NDCDepthRangeTests, CryEngine_ZeroToOne_ZRange)
{
namespace e = omath::cry_engine;
const auto proj = e::calc_perspective_projection_matrix(90.f, 16.f / 9.f, 0.1f, 1000.f, NDCDepthRange::ZERO_TO_ONE);
EXPECT_NEAR(project_z_lh(proj, 0.1f), 0.0f, 1e-4f);
EXPECT_NEAR(project_z_lh(proj, 1000.f), 1.0f, 1e-4f);
EXPECT_GT(project_z_lh(proj, 500.f), 0.0f);
EXPECT_LT(project_z_lh(proj, 500.f), 1.0f);
}
// ── Verify Z mapping for NEGATIVE_ONE_TO_ONE across all engines ─────────────
TEST(NDCDepthRangeTests, Source_NegativeOneToOne_ZRange)
{
namespace e = omath::source_engine;
const auto proj = e::calc_perspective_projection_matrix(90.f, 16.f / 9.f, 0.1f, 1000.f, NDCDepthRange::NEGATIVE_ONE_TO_ONE);
EXPECT_NEAR(project_z_lh(proj, 0.1f), -1.0f, 1e-3f);
EXPECT_NEAR(project_z_lh(proj, 1000.f), 1.0f, 1e-3f);
}
TEST(NDCDepthRangeTests, IW_NegativeOneToOne_ZRange)
{
namespace e = omath::iw_engine;
const auto proj = e::calc_perspective_projection_matrix(90.f, 16.f / 9.f, 0.1f, 1000.f, NDCDepthRange::NEGATIVE_ONE_TO_ONE);
EXPECT_NEAR(project_z_lh(proj, 0.1f), -1.0f, 1e-3f);
EXPECT_NEAR(project_z_lh(proj, 1000.f), 1.0f, 1e-3f);
}
TEST(NDCDepthRangeTests, Frostbite_NegativeOneToOne_ZRange)
{
namespace e = omath::frostbite_engine;
const auto proj = e::calc_perspective_projection_matrix(90.f, 16.f / 9.f, 0.1f, 1000.f, NDCDepthRange::NEGATIVE_ONE_TO_ONE);
EXPECT_NEAR(project_z_lh(proj, 0.1f), -1.0f, 1e-3f);
EXPECT_NEAR(project_z_lh(proj, 1000.f), 1.0f, 1e-3f);
}
TEST(NDCDepthRangeTests, Unreal_NegativeOneToOne_ZRange)
{
namespace e = omath::unreal_engine;
const auto proj = e::calc_perspective_projection_matrix(90.f, 16.f / 9.f, 0.1f, 1000.f, NDCDepthRange::NEGATIVE_ONE_TO_ONE);
EXPECT_NEAR(project_z_lh(proj, 0.1f), -1.0f, 1e-3f);
EXPECT_NEAR(project_z_lh(proj, 1000.f), 1.0f, 1e-3f);
}
TEST(NDCDepthRangeTests, CryEngine_NegativeOneToOne_ZRange)
{
namespace e = omath::cry_engine;
const auto proj = e::calc_perspective_projection_matrix(90.f, 16.f / 9.f, 0.1f, 1000.f, NDCDepthRange::NEGATIVE_ONE_TO_ONE);
EXPECT_NEAR(project_z_lh(proj, 0.1f), -1.0f, 1e-3f);
EXPECT_NEAR(project_z_lh(proj, 1000.f), 1.0f, 1e-3f);
}
TEST(NDCDepthRangeTests, OpenGL_NegativeOneToOne_ZRange)
{
namespace e = omath::opengl_engine;
const auto proj = e::calc_perspective_projection_matrix(90.f, 16.f / 9.f, 0.1f, 1000.f, NDCDepthRange::NEGATIVE_ONE_TO_ONE);
auto proj_z = [&](float z) {
auto clip = proj * mat_column_from_vector<float, MatStoreType::COLUMN_MAJOR>({0, 0, z});
return clip.at(2, 0) / clip.at(3, 0);
};
EXPECT_NEAR(proj_z(-0.1f), -1.0f, 1e-3f);
EXPECT_NEAR(proj_z(-1000.f), 1.0f, 1e-3f);
}
TEST(NDCDepthRangeTests, Unity_NegativeOneToOne_ZRange)
{
namespace e = omath::unity_engine;
const auto proj = e::calc_perspective_projection_matrix(90.f, 16.f / 9.f, 0.1f, 1000.f, NDCDepthRange::NEGATIVE_ONE_TO_ONE);
auto proj_z = [&](float z) {
auto clip = proj * mat_column_from_vector<float>({0, 0, z});
return clip.at(2, 0) / clip.at(3, 0);
};
EXPECT_NEAR(proj_z(-0.1f), -1.0f, 1e-3f);
EXPECT_NEAR(proj_z(-1000.f), 1.0f, 1e-3f);
}

View File

@@ -417,3 +417,51 @@ TEST(unit_test_unity_engine, look_at_down)
std::views::zip(dir_vector.as_array(), (-omath::unity_engine::k_abs_up).as_array()))
EXPECT_NEAR(result, etalon, 0.0001f);
}
TEST(unit_test_unity_engine, ViewAnglesAsVector3Zero)
{
const omath::unity_engine::ViewAngles angles{};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 0.f);
EXPECT_FLOAT_EQ(vec.y, 0.f);
EXPECT_FLOAT_EQ(vec.z, 0.f);
}
TEST(unit_test_unity_engine, ViewAnglesAsVector3Values)
{
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(30.f)
};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 45.f);
EXPECT_FLOAT_EQ(vec.y, -90.f);
EXPECT_FLOAT_EQ(vec.z, 30.f);
}
TEST(unit_test_unity_engine, ViewAnglesAsVector3ClampedPitch)
{
const omath::unity_engine::ViewAngles angles{
omath::unity_engine::PitchAngle::from_degrees(120.f),
omath::unity_engine::YawAngle::from_degrees(0.f),
omath::unity_engine::RollAngle::from_degrees(0.f)
};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 90.f);
}
TEST(unit_test_unity_engine, ViewAnglesAsVector3NormalizedYaw)
{
const omath::unity_engine::ViewAngles angles{
omath::unity_engine::PitchAngle::from_degrees(0.f),
omath::unity_engine::YawAngle::from_degrees(270.f),
omath::unity_engine::RollAngle::from_degrees(0.f)
};
const auto vec = angles.as_vector3();
EXPECT_NEAR(vec.y, -90.f, 0.01f);
}

View File

@@ -417,4 +417,52 @@ TEST(unit_test_unreal_engine, look_at_down)
const auto dir_vector = omath::unreal_engine::forward_vector(angles);
for (const auto& [result, etalon] : std::views::zip(dir_vector.as_array(), (-omath::unreal_engine::k_abs_up).as_array()))
EXPECT_NEAR(result, etalon, 0.0001f);
}
TEST(unit_test_unreal_engine, ViewAnglesAsVector3Zero)
{
const omath::unreal_engine::ViewAngles angles{};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 0.f);
EXPECT_FLOAT_EQ(vec.y, 0.f);
EXPECT_FLOAT_EQ(vec.z, 0.f);
}
TEST(unit_test_unreal_engine, ViewAnglesAsVector3Values)
{
const omath::unreal_engine::ViewAngles angles{
omath::unreal_engine::PitchAngle::from_degrees(45.f),
omath::unreal_engine::YawAngle::from_degrees(-90.f),
omath::unreal_engine::RollAngle::from_degrees(30.f)
};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 45.f);
EXPECT_FLOAT_EQ(vec.y, -90.f);
EXPECT_FLOAT_EQ(vec.z, 30.f);
}
TEST(unit_test_unreal_engine, ViewAnglesAsVector3ClampedPitch)
{
const omath::unreal_engine::ViewAngles angles{
omath::unreal_engine::PitchAngle::from_degrees(120.f),
omath::unreal_engine::YawAngle::from_degrees(0.f),
omath::unreal_engine::RollAngle::from_degrees(0.f)
};
const auto vec = angles.as_vector3();
EXPECT_FLOAT_EQ(vec.x, 90.f);
}
TEST(unit_test_unreal_engine, ViewAnglesAsVector3NormalizedYaw)
{
const omath::unreal_engine::ViewAngles angles{
omath::unreal_engine::PitchAngle::from_degrees(0.f),
omath::unreal_engine::YawAngle::from_degrees(270.f),
omath::unreal_engine::RollAngle::from_degrees(0.f)
};
const auto vec = angles.as_vector3();
EXPECT_NEAR(vec.y, -90.f, 0.01f);
}

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,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

@@ -240,4 +240,126 @@ TEST(UnitTestMatStandalone, MatPerspectiveLeftHanded)
projected /= projected.at(3, 0);
EXPECT_TRUE(projected.at(2, 0) > -1.0f && projected.at(2, 0) < 0.f);
}
TEST(UnitTestMatStandalone, MatPerspectiveLeftHandedZeroToOne)
{
const auto proj = mat_perspective_left_handed<float, MatStoreType::ROW_MAJOR, NDCDepthRange::ZERO_TO_ONE>(
90.f, 16.f / 9.f, 0.1f, 1000.f);
// Near plane point should map to z ~ 0
auto near_pt = proj * mat_column_from_vector<float>({0, 0, 0.1f});
near_pt /= near_pt.at(3, 0);
EXPECT_NEAR(near_pt.at(2, 0), 0.0f, 1e-4f);
// Far plane point should map to z ~ 1
auto far_pt = proj * mat_column_from_vector<float>({0, 0, 1000.f});
far_pt /= far_pt.at(3, 0);
EXPECT_NEAR(far_pt.at(2, 0), 1.0f, 1e-4f);
// Mid-range point should be in [0, 1]
auto mid_pt = proj * mat_column_from_vector<float>({0, 0, 500.f});
mid_pt /= mid_pt.at(3, 0);
EXPECT_GT(mid_pt.at(2, 0), 0.0f);
EXPECT_LT(mid_pt.at(2, 0), 1.0f);
}
TEST(UnitTestMatStandalone, MatPerspectiveRightHandedZeroToOne)
{
const auto proj = mat_perspective_right_handed<float, MatStoreType::ROW_MAJOR, NDCDepthRange::ZERO_TO_ONE>(
90.f, 16.f / 9.f, 0.1f, 1000.f);
// Near plane point (negative z for right-handed) should map to z ~ 0
auto near_pt = proj * mat_column_from_vector<float>({0, 0, -0.1f});
near_pt /= near_pt.at(3, 0);
EXPECT_NEAR(near_pt.at(2, 0), 0.0f, 1e-4f);
// Far plane point should map to z ~ 1
auto far_pt = proj * mat_column_from_vector<float>({0, 0, -1000.f});
far_pt /= far_pt.at(3, 0);
EXPECT_NEAR(far_pt.at(2, 0), 1.0f, 1e-4f);
// Mid-range point should be in [0, 1]
auto mid_pt = proj * mat_column_from_vector<float>({0, 0, -500.f});
mid_pt /= mid_pt.at(3, 0);
EXPECT_GT(mid_pt.at(2, 0), 0.0f);
EXPECT_LT(mid_pt.at(2, 0), 1.0f);
}
TEST(UnitTestMatStandalone, MatPerspectiveNegativeOneToOneRange)
{
// Verify existing [-1, 1] behavior with explicit template arg matches default
const auto proj_default = mat_perspective_left_handed(90.f, 16.f / 9.f, 0.1f, 1000.f);
const auto proj_explicit = mat_perspective_left_handed<float, MatStoreType::ROW_MAJOR,
NDCDepthRange::NEGATIVE_ONE_TO_ONE>(90.f, 16.f / 9.f, 0.1f, 1000.f);
EXPECT_EQ(proj_default, proj_explicit);
// Near plane should map to z ~ -1
auto near_pt = proj_default * mat_column_from_vector<float>({0, 0, 0.1f});
near_pt /= near_pt.at(3, 0);
EXPECT_NEAR(near_pt.at(2, 0), -1.0f, 1e-3f);
// Far plane should map to z ~ 1
auto far_pt = proj_default * mat_column_from_vector<float>({0, 0, 1000.f});
far_pt /= far_pt.at(3, 0);
EXPECT_NEAR(far_pt.at(2, 0), 1.0f, 1e-3f);
}
TEST(UnitTestMatStandalone, MatPerspectiveZeroToOneEquanity)
{
// LH and RH should produce same NDC for mirrored z
constexpr omath::Vector3<float> left_handed = {0, 2, 10};
constexpr omath::Vector3<float> right_handed = {0, 2, -10};
const auto proj_lh = mat_perspective_left_handed<float, MatStoreType::ROW_MAJOR, NDCDepthRange::ZERO_TO_ONE>(
90.f, 16.f / 9.f, 0.1f, 1000.f);
const auto proj_rh = mat_perspective_right_handed<float, MatStoreType::ROW_MAJOR, NDCDepthRange::ZERO_TO_ONE>(
90.f, 16.f / 9.f, 0.1f, 1000.f);
auto ndc_lh = proj_lh * mat_column_from_vector(left_handed);
auto ndc_rh = proj_rh * mat_column_from_vector(right_handed);
ndc_lh /= ndc_lh.at(3, 0);
ndc_rh /= ndc_rh.at(3, 0);
EXPECT_EQ(ndc_lh, ndc_rh);
}
TEST(UnitTestMatStandalone, MatOrthoLeftHandedZeroToOne)
{
const auto ortho = mat_ortho_left_handed<float, MatStoreType::ROW_MAJOR, NDCDepthRange::ZERO_TO_ONE>(
-1.f, 1.f, -1.f, 1.f, 0.1f, 100.f);
// Near plane should map to z ~ 0
auto near_pt = ortho * mat_column_from_vector<float>({0, 0, 0.1f});
EXPECT_NEAR(near_pt.at(2, 0), 0.0f, 1e-4f);
// Far plane should map to z ~ 1
auto far_pt = ortho * mat_column_from_vector<float>({0, 0, 100.f});
EXPECT_NEAR(far_pt.at(2, 0), 1.0f, 1e-4f);
}
TEST(UnitTestMatStandalone, MatOrthoRightHandedZeroToOne)
{
const auto ortho = mat_ortho_right_handed<float, MatStoreType::ROW_MAJOR, NDCDepthRange::ZERO_TO_ONE>(
-1.f, 1.f, -1.f, 1.f, 0.1f, 100.f);
// Near plane (negative z for RH) should map to z ~ 0
auto near_pt = ortho * mat_column_from_vector<float>({0, 0, -0.1f});
EXPECT_NEAR(near_pt.at(2, 0), 0.0f, 1e-4f);
// Far plane should map to z ~ 1
auto far_pt = ortho * mat_column_from_vector<float>({0, 0, -100.f});
EXPECT_NEAR(far_pt.at(2, 0), 1.0f, 1e-4f);
}
TEST(UnitTestMatStandalone, MatOrthoNegativeOneToOneDefault)
{
// Verify explicit [-1, 1] matches default
const auto ortho_default = mat_ortho_left_handed(-1.f, 1.f, -1.f, 1.f, 0.1f, 100.f);
const auto ortho_explicit = mat_ortho_left_handed<float, MatStoreType::ROW_MAJOR,
NDCDepthRange::NEGATIVE_ONE_TO_ONE>(-1.f, 1.f, -1.f, 1.f, 0.1f, 100.f);
EXPECT_EQ(ortho_default, ortho_explicit);
}

View File

@@ -4,7 +4,13 @@
#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/projection/camera.hpp>
#include <print>
#include <random>
@@ -216,4 +222,722 @@ TEST(UnitTestProjection, ScreenToWorldBottomLeftCorner)
EXPECT_NEAR(screen_cords->x, initial_screen_cords.x, 0.001f);
EXPECT_NEAR(screen_cords->y, initial_screen_cords.y, 0.001f);
}
}
}
TEST(UnitTestProjection, AabbInsideFrustumNotCulled)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
// Small box directly in front of camera (Source Engine: +X forward, +Y left, +Z up)
const omath::primitives::Aabb<float> aabb{{90.f, -1.f, -1.f}, {110.f, 1.f, 1.f}};
EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb));
}
TEST(UnitTestProjection, AabbBehindCameraCulled)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
// Box entirely behind the camera
const omath::primitives::Aabb<float> aabb{{-200.f, -1.f, -1.f}, {-100.f, 1.f, 1.f}};
EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb));
}
TEST(UnitTestProjection, AabbBeyondFarPlaneCulled)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
// Box beyond far plane (1000)
const omath::primitives::Aabb<float> aabb{{1500.f, -1.f, -1.f}, {2000.f, 1.f, 1.f}};
EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb));
}
TEST(UnitTestProjection, AabbFarLeftCulled)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
// Box far to the side, outside the frustum
const omath::primitives::Aabb<float> aabb{{90.f, 4000.f, -1.f}, {110.f, 5000.f, 1.f}};
EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb));
}
TEST(UnitTestProjection, AabbFarRightCulled)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
// Box far to the other side, outside the frustum
const omath::primitives::Aabb<float> aabb{{90.f, -5000.f, -1.f}, {110.f, -4000.f, 1.f}};
EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb));
}
TEST(UnitTestProjection, AabbAboveCulled)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
// Box far above the frustum
const omath::primitives::Aabb<float> aabb{{90.f, -1.f, 5000.f}, {110.f, 1.f, 6000.f}};
EXPECT_TRUE(cam.is_aabb_culled_by_frustum(aabb));
}
TEST(UnitTestProjection, AabbPartiallyInsideNotCulled)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
// Large box that straddles the frustum boundary — partially inside
const omath::primitives::Aabb<float> aabb{{50.f, -5000.f, -5000.f}, {500.f, 5000.f, 5000.f}};
EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb));
}
TEST(UnitTestProjection, AabbStraddlesNearPlaneNotCulled)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
// Box that straddles the near plane — partially in front
const omath::primitives::Aabb<float> aabb{{-5.f, -1.f, -1.f}, {5.f, 1.f, 1.f}};
EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb));
}
TEST(UnitTestProjection, AabbStraddlesFarPlaneNotCulled)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
// Box that straddles the far plane
const omath::primitives::Aabb<float> aabb{{900.f, -1.f, -1.f}, {1100.f, 1.f, 1.f}};
EXPECT_FALSE(cam.is_aabb_culled_by_frustum(aabb));
}
TEST(UnitTestProjection, AabbCulledUnityEngine)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(60.f);
const auto cam = omath::unity_engine::Camera({0, 0, 0}, {}, {1280.f, 720.f}, fov, 0.03f, 1000.f);
// Box in front — not culled
const omath::primitives::Aabb<float> inside{{-1.f, -1.f, 50.f}, {1.f, 1.f, 100.f}};
EXPECT_FALSE(cam.is_aabb_culled_by_frustum(inside));
// 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_forward();
const auto right = cam.get_right();
const auto up = cam.get_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_forward();
const auto right = cam.get_right();
const auto up = cam.get_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_forward();
const auto right = cam.get_right();
const auto up = cam.get_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_forward();
const auto right = cam.get_right();
const auto up = cam.get_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_forward();
const auto right = cam.get_right();
const auto up = cam.get_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_forward();
const auto right = cam.get_right();
const auto up = cam.get_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_forward();
const auto right = cam.get_right();
const auto up = cam.get_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);
}

View File

@@ -27,6 +27,53 @@ inline const void* get_vtable_entry(const void* obj, const std::size_t index)
return vtable[index];
}
class BaseA
{
public:
int m_field_a{42};
[[nodiscard]] virtual int get_a() const { return 10; }
[[nodiscard]] virtual int get_a2() const { return 11; }
};
class BaseB
{
public:
float m_field_b{3.14f};
double m_field_b2{2.71};
[[nodiscard]] virtual int get_b() const { return 20; }
[[nodiscard]] virtual int get_b2() const { return 21; }
};
class MultiPlayer final : public BaseA, public BaseB
{
public:
int m_own_field{999};
[[nodiscard]] int get_a() const override { return 100; }
[[nodiscard]] int get_a2() const override { return 101; }
[[nodiscard]] int get_b() const override { return 200; }
[[nodiscard]] int get_b2() const override { return 201; }
};
// BaseA layout: [vptr_a][m_field_a(int)] — sizeof(BaseA) gives the full subobject size
// BaseB starts right after BaseA in MultiPlayer's layout
constexpr std::ptrdiff_t BASE_B_OFFSET = static_cast<std::ptrdiff_t>(sizeof(BaseA));
class RevMultiPlayer final : omath::rev_eng::InternalReverseEngineeredObject
{
public:
// Table at offset 0 (BaseA vtable): index 0 = get_a, 1 = get_a2
[[nodiscard]] int rev_get_a() const { return call_virtual_method<0, 0, int>(); }
[[nodiscard]] int rev_get_a2() const { return call_virtual_method<0, 1, int>(); }
// Table at BaseB offset (BaseB vtable): index 0 = get_b, 1 = get_b2
[[nodiscard]] int rev_get_b() const { return call_virtual_method<BASE_B_OFFSET, 0, int>(); }
[[nodiscard]] int rev_get_b2() const { return call_virtual_method<BASE_B_OFFSET, 1, int>(); }
// Non-const versions
int rev_get_a_mut() { return call_virtual_method<0, 0, int>(); }
int rev_get_b_mut() { return call_virtual_method<BASE_B_OFFSET, 0, int>(); }
};
class RevPlayer final : omath::rev_eng::InternalReverseEngineeredObject
{
public:
@@ -117,4 +164,54 @@ TEST(unit_test_reverse_enineering, call_virtual_method_delegates_to_call_method)
EXPECT_EQ(1, rev->rev_foo());
EXPECT_EQ(2, rev->rev_bar());
EXPECT_EQ(2, rev->rev_bar_const());
}
TEST(unit_test_reverse_enineering, multi_player_base_b_offset_is_correct)
{
// Verify our compile-time offset matches the actual layout
MultiPlayer mp;
const auto* mp_addr = reinterpret_cast<const char*>(&mp);
const auto* b_addr = reinterpret_cast<const char*>(static_cast<const BaseB*>(&mp));
EXPECT_EQ(b_addr - mp_addr, BASE_B_OFFSET);
}
TEST(unit_test_reverse_enineering, call_virtual_method_table_index_first_table)
{
MultiPlayer mp;
const auto* rev = reinterpret_cast<const RevMultiPlayer*>(&mp);
EXPECT_EQ(mp.get_a(), rev->rev_get_a());
EXPECT_EQ(mp.get_a2(), rev->rev_get_a2());
EXPECT_EQ(100, rev->rev_get_a());
EXPECT_EQ(101, rev->rev_get_a2());
}
TEST(unit_test_reverse_enineering, call_virtual_method_table_index_second_table)
{
constexpr MultiPlayer mp;
const auto* rev = reinterpret_cast<const RevMultiPlayer*>(&mp);
EXPECT_EQ(mp.get_b(), rev->rev_get_b());
EXPECT_EQ(mp.get_b2(), rev->rev_get_b2());
EXPECT_EQ(200, rev->rev_get_b());
EXPECT_EQ(201, rev->rev_get_b2());
}
TEST(unit_test_reverse_enineering, call_virtual_method_table_index_non_const)
{
MultiPlayer mp;
auto* rev = reinterpret_cast<RevMultiPlayer*>(&mp);
EXPECT_EQ(100, rev->rev_get_a_mut());
EXPECT_EQ(200, rev->rev_get_b_mut());
}
TEST(unit_test_reverse_enineering, call_virtual_method_table_zero_matches_default)
{
// Table 0 with the TableIndex overload should match the original non-TableIndex overload
constexpr MultiPlayer mp;
const auto* rev = reinterpret_cast<const RevMultiPlayer*>(&mp);
// Both access table 0, method index 1 — should return the same value
EXPECT_EQ(rev->rev_get_a(), 100);
}

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);
}