Compare commits

..

23 Commits

Author SHA1 Message Date
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
308f7ed481 forgot return 2026-03-21 16:43:18 +03:00
8802ad9af1 fix 2026-03-21 16:41:03 +03:00
2ac508d6e8 fixed tests 2026-03-21 16:28:48 +03:00
eb1ca6055b added additional error code 2026-03-21 16:15:48 +03:00
b528e41de3 fixed test names 2026-03-21 15:45:22 +03:00
8615ab2b7c changed name, fixed bug 2026-03-21 15:22:02 +03:00
5a4c042fec replaced enum 2026-03-21 14:53:04 +03:00
8063c1697a improved interface 2026-03-21 14:41:07 +03:00
7567501f00 Merge pull request #175 from orange-cpp/feature/w2s_no_clip
added clip option
2026-03-21 14:12:07 +03:00
46d999f846 added clip option 2026-03-21 13:58:06 +03:00
b54601132b added doc build to release 2026-03-21 06:32:05 +03:00
5c8ce2d163 Merge pull request #174 from orange-cpp/feature/docs-pipelines
added docs pipeline
2026-03-21 06:26:21 +03:00
04a86739b4 added docs pipeline 2026-03-21 06:11:20 +03:00
20 changed files with 807 additions and 18 deletions

View File

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

62
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,62 @@
name: Documentation
on:
push:
branches: [ main ]
paths:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
pull_request:
branches: [ main ]
paths:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/docs.yml'
concurrency:
group: docs-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
name: Build Documentation
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install mkdocs and dependencies
run: pip install mkdocs mkdocs-bootswatch
- name: Build documentation
run: mkdocs build --strict
- name: Upload artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@v3
with:
path: site/
deploy:
name: Deploy to GitHub Pages
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -12,6 +12,35 @@ permissions:
contents: write
jobs:
##############################################################################
# 0) Documentation MkDocs
##############################################################################
docs-release:
name: Documentation
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install mkdocs and dependencies
run: pip install mkdocs mkdocs-bootswatch
- name: Build documentation
run: mkdocs build --strict
- name: Package
run: tar -czf omath-docs.tar.gz -C site .
- name: Upload release asset
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload "${{ github.event.release.tag_name }}" omath-docs.tar.gz --clobber
##############################################################################
# 1) Linux Clang / Ninja
##############################################################################

View File

@@ -1,6 +1,6 @@
# Installation
# Installation Guide
## <img width="28px" src="https://vcpkg.io/assets/mark/mark.svg" /> Using vcpkg
## <img width="28px" src="https://vcpkg.io/assets/mark/mark.svg" /> Using vcpkg (recomended)
**Note**: Support vcpkg for package management
1. Install [vcpkg](https://github.com/microsoft/vcpkg)
2. Run the following command to install the orange-math package:
@@ -28,6 +28,69 @@ target("...")
add_packages("omath")
```
## <img width="28px" src="https://conan.io/favicon.png" /> Using Conan
**Note**: Support Conan for package management
1. Install [Conan](https://conan.io/downloads)
2. Run the following command to install the omath package:
```
conan install --requires="omath/[*]" --build=missing
```
conanfile.txt
```ini
[requires]
omath/[*]
[generators]
CMakeDeps
CMakeToolchain
```
CMakeLists.txt
```cmake
find_package(omath CONFIG REQUIRED)
target_link_libraries(main PRIVATE omath::omath)
```
For more details, see the [Conan documentation](https://docs.conan.io/2/).
## <img width="28px" src="https://github.githubassets.com/favicons/favicon.svg" /> Using prebuilt binaries (GitHub Releases)
**Note**: This is the fastest option if you dont want to build from source.
1. **Go to the Releases page**
- Open the projects GitHub **Releases** page and choose the latest version.
2. **Download the correct asset for your platform**
- Pick the archive that matches your OS and architecture (for example: Windows x64 / Linux x64 / macOS arm64).
3. **Extract the archive**
- You should end up with something like:
- `include/` (headers)
- `lib/` or `bin/` (library files / DLLs)
- sometimes `cmake/` (CMake package config)
4. **Use it in your project**
### Option A: CMake package (recommended if the release includes CMake config files)
If the extracted folder contains something like `lib/cmake/omath` or `cmake/omath`, you can point CMake to it:
```cmake
# Example: set this to the extracted prebuilt folder
list(APPEND CMAKE_PREFIX_PATH "path/to/omath-prebuilt")
find_package(omath CONFIG REQUIRED)
target_link_libraries(main PRIVATE omath::omath)
```
### Option B: Manual include + link (works with any layout)
If theres no CMake package config, link it manually:
```cmake
target_include_directories(main PRIVATE "path/to/omath-prebuilt/include")
# Choose ONE depending on what you downloaded:
# - Static library: .lib / .a
# - Shared library: .dll + .lib import (Windows), .so (Linux), .dylib (macOS)
target_link_directories(main PRIVATE "path/to/omath-prebuilt/lib")
target_link_libraries(main PRIVATE omath) # or the actual library filename
```
## <img width="28px" src="https://upload.wikimedia.org/wikipedia/commons/e/ef/CMake_logo.svg?" /> Build from source using CMake
1. **Preparation**
@@ -62,7 +125,7 @@ target("...")
Use **\<platform\>-\<build configuration\>** preset to build suitable version for yourself. Like **windows-release** or **linux-release**.
| Platform Name | Build Config |
|---------------|---------------|
|---------------|---------------|
| windows | release/debug |
| linux | release/debug |
| darwin | release/debug |

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

@@ -36,7 +36,11 @@ namespace omath::projection
}
};
using FieldOfView = Angle<float, 0.f, 180.f, AngleFlags::Clamped>;
enum class ViewPortClipping
{
AUTO,
MANUAL,
};
template<class T, class MatType, class ViewAnglesType>
concept CameraEngineConcept =
requires(const Vector3<float>& cam_origin, const Vector3<float>& look_at, const ViewAnglesType& angles,
@@ -218,6 +222,22 @@ namespace omath::projection
else
std::unreachable();
}
template<ScreenStart screen_start = ScreenStart::TOP_LEFT_CORNER>
[[nodiscard]] std::expected<Vector3<float>, Error>
world_to_screen_unclipped(const Vector3<float>& world_position) const noexcept
{
const auto normalized_cords = world_to_view_port(world_position, ViewPortClipping::MANUAL);
if (!normalized_cords.has_value())
return std::unexpected{normalized_cords.error()};
if constexpr (screen_start == ScreenStart::TOP_LEFT_CORNER)
return ndc_to_screen_position_from_top_left_corner(*normalized_cords);
else if constexpr (screen_start == ScreenStart::BOTTOM_LEFT_CORNER)
return ndc_to_screen_position_from_bottom_left_corner(*normalized_cords);
else
std::unreachable();
}
[[nodiscard]] bool is_culled_by_frustum(const Triangle<Vector3<float>>& triangle) const noexcept
{
@@ -267,24 +287,34 @@ namespace omath::projection
}
[[nodiscard]] std::expected<Vector3<float>, Error>
world_to_view_port(const Vector3<float>& world_position) const noexcept
world_to_view_port(const Vector3<float>& world_position,
const ViewPortClipping& clipping = ViewPortClipping::AUTO) const noexcept
{
auto projected = get_view_projection_matrix()
* mat_column_from_vector<float, Mat4X4Type::get_store_ordering()>(world_position);
const auto& w = projected.at(3, 0);
if (w <= std::numeric_limits<float>::epsilon())
return std::unexpected(Error::WORLD_POSITION_IS_OUT_OF_SCREEN_BOUNDS);
constexpr auto eps = std::numeric_limits<float>::epsilon();
if (w <= eps)
return std::unexpected(Error::PERSPECTIVE_DIVIDER_LESS_EQ_ZERO);
projected /= w;
if (is_ndc_out_of_bounds(projected))
// ReSharper disable once CppTooWideScope
const auto clipped_automatically = clipping == ViewPortClipping::AUTO && is_ndc_out_of_bounds(projected);
if (clipped_automatically)
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
|| projected.at(2, 0) > 1.0f + eps);
if (clipped_manually)
return std::unexpected(Error::WORLD_POSITION_IS_OUT_OF_SCREEN_BOUNDS);
return Vector3<float>{projected.at(0, 0), projected.at(1, 0), projected.at(2, 0)};
}
[[nodiscard]]
std::expected<Vector3<float>, Error> view_port_to_screen(const Vector3<float>& ndc) const noexcept
std::expected<Vector3<float>, Error> view_port_to_world(const Vector3<float>& ndc) const noexcept
{
const auto inv_view_proj = get_view_projection_matrix().inverted();
@@ -309,7 +339,7 @@ namespace omath::projection
[[nodiscard]]
std::expected<Vector3<float>, Error> screen_to_world(const Vector3<float>& screen_pos) const noexcept
{
return view_port_to_screen(screen_to_ndc<screen_start>(screen_pos));
return view_port_to_world(screen_to_ndc<screen_start>(screen_pos));
}
template<ScreenStart screen_start = ScreenStart::TOP_LEFT_CORNER>

View File

@@ -11,5 +11,6 @@ namespace omath::projection
{
WORLD_POSITION_IS_OUT_OF_SCREEN_BOUNDS,
INV_VIEW_PROJ_MAT_DET_EQ_ZERO,
PERSPECTIVE_DIVIDER_LESS_EQ_ZERO,
};
}

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

@@ -3,6 +3,8 @@
//
#ifdef OMATH_ENABLE_LUA
#include "omath/lua/lua.hpp"
#include "omath/omath.hpp"
#include "omath/projection/error_codes.hpp"
#include <omath/engines/cry_engine/camera.hpp>
#include <omath/engines/frostbite_engine/camera.hpp>
#include <omath/engines/iw_engine/camera.hpp>
@@ -33,6 +35,8 @@ namespace
return "world position is out of screen bounds";
case omath::projection::Error::INV_VIEW_PROJ_MAT_DET_EQ_ZERO:
return "inverse view-projection matrix determinant is zero";
case omath::projection::Error::PERSPECTIVE_DIVIDER_LESS_EQ_ZERO:
return "perspective divider is less or equal to zero";
}
return "unknown error";
}

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

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

@@ -50,6 +50,126 @@ TEST(UnitTestProjection, ScreenToNdcBottomLeft)
EXPECT_NEAR(ndc_bottom_left.y, 0.519615293f, 0.0001f);
}
TEST(UnitTestProjection, UnclippedWorldToScreenInBounds)
{
constexpr auto fov = omath::Angle<float, 0.f, 180.f, omath::AngleFlags::Clamped>::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
const auto projected = cam.world_to_screen_unclipped({1000.f, 0, 50.f});
ASSERT_TRUE(projected.has_value());
EXPECT_NEAR(projected->x, 960.f, 0.001f);
EXPECT_NEAR(projected->y, 504.f, 0.001f);
}
TEST(UnitTestProjection, UnclippedWorldToScreenMatchesWorldToScreenWhenInBounds)
{
constexpr auto fov = omath::Angle<float, 0.f, 180.f, omath::AngleFlags::Clamped>::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
const auto w2s = cam.world_to_screen({1000.f, 0, 50.f});
const auto no_clip = cam.world_to_screen_unclipped({1000.f, 0, 50.f});
ASSERT_TRUE(w2s.has_value());
ASSERT_TRUE(no_clip.has_value());
EXPECT_NEAR(w2s->x, no_clip->x, 0.001f);
EXPECT_NEAR(w2s->y, no_clip->y, 0.001f);
EXPECT_NEAR(w2s->z, no_clip->z, 0.001f);
}
TEST(UnitTestProjection, UnclippedWorldToScreenRejectsBehindCamera)
{
constexpr auto fov = omath::Angle<float, 0.f, 180.f, omath::AngleFlags::Clamped>::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
const auto projected = cam.world_to_screen_unclipped({-1000.f, 0, 0});
EXPECT_FALSE(projected.has_value());
EXPECT_EQ(projected.error(), omath::projection::Error::PERSPECTIVE_DIVIDER_LESS_EQ_ZERO);
}
TEST(UnitTestProjection, UnclippedWorldToScreenAllowsOutOfBoundsNdc)
{
constexpr auto fov = omath::Angle<float, 0.f, 180.f, omath::AngleFlags::Clamped>::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
// Point far to the side exceeds NDC [-1,1] bounds but unclipped returns it anyway
const auto projected = cam.world_to_screen_unclipped({100.f, 5000.f, 0});
EXPECT_TRUE(projected.has_value());
}
TEST(UnitTestProjection, WorldToScreenRejectsOutOfBoundsNdc)
{
constexpr auto fov = omath::Angle<float, 0.f, 180.f, omath::AngleFlags::Clamped>::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
// Same point that unclipped allows — clipped world_to_screen rejects it
const auto projected = cam.world_to_screen({100.f, 5000.f, 0});
EXPECT_FALSE(projected.has_value());
}
TEST(UnitTestProjection, UnclippedWorldToScreenBottomLeftCorner)
{
constexpr auto fov = omath::Angle<float, 0.f, 180.f, omath::AngleFlags::Clamped>::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
using ScreenStart = omath::source_engine::Camera::ScreenStart;
const auto top_left = cam.world_to_screen_unclipped<ScreenStart::TOP_LEFT_CORNER>({1000.f, 0, 50.f});
const auto bottom_left = cam.world_to_screen_unclipped<ScreenStart::BOTTOM_LEFT_CORNER>({1000.f, 0, 50.f});
ASSERT_TRUE(top_left.has_value());
ASSERT_TRUE(bottom_left.has_value());
// X should be identical, Y should differ (mirrored around center)
EXPECT_NEAR(top_left->x, bottom_left->x, 0.001f);
EXPECT_NEAR(top_left->y + bottom_left->y, 1080.f, 0.001f);
}
TEST(UnitTestProjection, UnclippedWorldToScreenRoundTrip)
{
std::mt19937 gen(42);
std::uniform_real_distribution dist_fwd(100.f, 900.f);
std::uniform_real_distribution dist_side(-400.f, 400.f);
std::uniform_real_distribution dist_up(-200.f, 200.f);
constexpr auto fov = omath::Angle<float, 0.f, 180.f, omath::AngleFlags::Clamped>::from_degrees(90.f);
const auto cam = omath::source_engine::Camera({0, 0, 0}, omath::source_engine::ViewAngles{}, {1920.f, 1080.f}, fov,
0.01f, 1000.f);
for (int i = 0; i < 100; i++)
{
const omath::Vector3<float> world_pos{dist_fwd(gen), dist_side(gen), dist_up(gen)};
const auto screen = cam.world_to_screen_unclipped(world_pos);
if (!screen.has_value())
continue;
const auto back_to_world = cam.screen_to_world(screen.value());
ASSERT_TRUE(back_to_world.has_value());
const auto back_to_screen = cam.world_to_screen_unclipped(back_to_world.value());
ASSERT_TRUE(back_to_screen.has_value());
EXPECT_NEAR(screen->x, back_to_screen->x, 0.01f);
EXPECT_NEAR(screen->y, back_to_screen->y, 0.01f);
}
}
TEST(UnitTestProjection, UnclippedWorldToScreenUnityEngine)
{
constexpr auto fov = omath::projection::FieldOfView::from_degrees(60.f);
const auto cam = omath::unity_engine::Camera({0, 0, 0}, {}, {1280.f, 720.f}, fov, 0.03f, 1000.f);
using ScreenStart = omath::unity_engine::Camera::ScreenStart;
// Point directly in front
const auto projected = cam.world_to_screen_unclipped<ScreenStart::BOTTOM_LEFT_CORNER>({0, 0, 500.f});
ASSERT_TRUE(projected.has_value());
EXPECT_NEAR(projected->x, 640.f, 0.5f);
EXPECT_NEAR(projected->y, 360.f, 0.5f);
}
TEST(UnitTestProjection, ScreenToWorldTopLeftCorner)
{
std::mt19937 gen(std::random_device{}()); // Seed with a non-deterministic source

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