omath::projection::Camera — Generic, trait-driven camera with screen/world conversion
Header:
omath/projection/camera.hpp(this header) Namespace:omath::projectionTemplate:Camera<Mat4X4Type, ViewAnglesType, TraitClass>Requires:CameraEngineConcept<TraitClass, Mat4X4Type, ViewAnglesType>Key features: lazy view-projection caching, world↔screen helpers, pluggable math via a Trait
Overview
Camera is a small, zero-allocation camera wrapper. It delegates the math for view, projection, and look-at to a Trait (TraitClass), which lets you plug in different coordinate systems or conventions without changing the camera code. The class caches the View×Projection matrix and invalidates it when any parameter changes.
Alongside the camera, the header defines:
struct ViewPort { float m_width, m_height; float aspect_ratio() const; }using FieldOfView = Angle<float, 0.f, 180.f, AngleFlags::Clamped>;
Template & trait requirements
template<class T, class MatType, class ViewAnglesType>
concept CameraEngineConcept = requires(
const omath::Vector3<float>& cam_origin,
const omath::Vector3<float>& look_at,
const ViewAnglesType& angles,
const omath::projection::FieldOfView& fov,
const omath::projection::ViewPort& viewport,
float znear, float zfar
) {
{ T::calc_look_at_angle(cam_origin, look_at) } noexcept -> std::same_as<ViewAnglesType>;
{ T::calc_view_matrix(angles, cam_origin) } noexcept -> std::same_as<MatType>;
{ T::calc_projection_matrix(fov, viewport, znear, zfar)}noexcept -> std::same_as<MatType>;
};
Your Mat4X4Type must behave like the library’s Mat<4,4,...> (supports *, /, inverted(), .at(r,c), .raw_array(), and static constexpr get_store_ordering()).
Quick start
using Mat4 = omath::Mat<4,4,float, omath::MatStoreType::COLUMN_MAJOR>;
// Example trait (sketch): assumes Y-up, column-major, left-handed
struct MyCamTrait {
static ViewAnglesType calc_look_at_angle(const Vector3<float>& eye,
const Vector3<float>& at) noexcept;
static Mat4 calc_view_matrix(const ViewAnglesType& ang,
const Vector3<float>& eye) noexcept;
static Mat4 calc_projection_matrix(const FieldOfView& fov,
const ViewPort& vp,
float znear, float zfar) noexcept;
};
using Camera = omath::projection::Camera<Mat4, MyViewAngles, MyCamTrait>;
omath::projection::ViewPort vp{1920, 1080};
omath::projection::FieldOfView fov = omath::angles::degrees(70.f);
Camera cam(/*position*/ {0,1.7f, -3},
/*angles*/ MyViewAngles{/*...*/},
/*viewport*/ vp, fov,
/*near*/ 0.1f,
/*far*/ 1000.f);
// Project world → screen (origin top-left)
auto s = cam.world_to_screen<Camera::ScreenStart::TOP_LEFT_CORNER>({1, 1, 0});
if (s) {
// s->x, s->y in pixels; s->z in NDC depth
}
API
enum class ScreenStart { TOP_LEFT_CORNER, BOTTOM_LEFT_CORNER };
class Camera final {
public:
~Camera() = default;
Camera(const Vector3<float>& position,
const ViewAnglesType& view_angles,
const ViewPort& view_port,
const FieldOfView& fov,
float near, float far) noexcept;
void look_at(const Vector3<float>& target); // recomputes view angles; invalidates cache
// Lazily computed and cached:
const Mat4X4Type& get_view_projection_matrix() const noexcept;
// Setters (all invalidate cached VP):
void set_field_of_view(const FieldOfView&) noexcept;
void set_near_plane(float) noexcept;
void set_far_plane(float) noexcept;
void set_view_angles(const ViewAnglesType&) noexcept;
void set_origin(const Vector3<float>&) noexcept;
void set_view_port(const ViewPort&) noexcept;
// Getters:
const FieldOfView& get_field_of_view() const noexcept;
const float& get_near_plane() const noexcept;
const float& get_far_plane() const noexcept;
const ViewAnglesType& get_view_angles() const noexcept;
const Vector3<float>& get_origin() const noexcept;
// World → Screen (pixels) via NDC; choose screen origin:
template<ScreenStart screen_start = ScreenStart::TOP_LEFT_CORNER>
std::expected<Vector3<float>, Error>
world_to_screen(const Vector3<float>& world) const noexcept;
// World → NDC (aka “viewport” in this code) ∈ [-1,1]^3
std::expected<Vector3<float>, Error>
world_to_view_port(const Vector3<float>& world) const noexcept;
// NDC → World (uses inverse VP)
std::expected<Vector3<float>, Error>
view_port_to_screen(const Vector3<float>& ndc) const noexcept;
// Screen (pixels) → World
std::expected<Vector3<float>, Error>
screen_to_world(const Vector3<float>& screen) const noexcept;
// 2D overload (z defaults to 1, i.e., far plane ray-end in NDC)
std::expected<Vector3<float>, Error>
screen_to_world(const Vector2<float>& screen) const noexcept;
protected:
ViewPort m_view_port{};
FieldOfView m_field_of_view;
mutable std::optional<Mat4X4Type> m_view_projection_matrix;
float m_far_plane_distance{};
float m_near_plane_distance{};
ViewAnglesType m_view_angles;
Vector3<float> m_origin;
private:
static constexpr bool is_ndc_out_of_bounds(const Mat4X4Type& ndc) noexcept;
Vector3<float> ndc_to_screen_position_from_top_left_corner(const Vector3<float>& ndc) const noexcept;
Vector3<float> ndc_to_screen_position_from_bottom_left_corner(const Vector3<float>& ndc) const noexcept;
Vector3<float> screen_to_ndc(const Vector3<float>& screen) const noexcept;
};
Error handling
All conversions return std::expected<..., Error> with errors from error_codes.hpp, notably:
Error::WORLD_POSITION_IS_OUT_OF_SCREEN_BOUNDS— clip space W=0 or NDC outside[-1,1].Error::INV_VIEW_PROJ_MAT_DET_EQ_ZERO— non-invertible View×Projection matrix.
Coordinate spaces & conversions
World → NDC (world_to_view_port)
- Build (or reuse cached)
VP = P * V(projection * view). - Multiply by homogeneous column from the world point.
- Reject if
w == 0. - Perspective divide → NDC in
[-1,1]^3. - Reject if any component is out of range.
Returns {x_ndc, y_ndc, z_ndc}.
NDC → Screen (pixels)
The class offers two origins:
- Top-left (default)
x_px = (x_ndc + 1)/2 * width
y_px = ( -y_ndc/2 + 0.5) * height // flips Y
* Bottom-left
x_px = (x_ndc + 1)/2 * width
y_px = ( y_ndc/2 + 0.5) * height
Screen (pixels) → NDC
x_ndc = screen_x / width * 2 - 1
y_ndc = 1 - screen_y / height * 2 // Top-left screen origin assumed here
z_ndc = screen_z // Caller-provided (e.g., 0..1 depth)
NDC → World (view_port_to_screen)
Despite the method name, this function unprojects an NDC point back to world space:
- Compute
VP^{-1}; if not invertible → error. - Multiply by NDC (homogeneous 4D) and divide by
w. - Return world point.
Tip: to build a world-space ray from a screen pixel, unproject at
z=0(near) andz=1(far).
Caching & invalidation
get_view_projection_matrix()computesP*Vonce and caches it.- Any setter (
set_*) orlook_at()clears the cache (m_view_projection_matrix = std::nullopt).
Notes & gotchas
- Matrix order: The camera multiplies
P * V. Make sure your Trait matches this convention. - Store ordering: The
Mat4X4Type::get_store_ordering()is used when building homogeneous columns; ensure it’s consistent with your matrix implementation. - Naming quirk:
view_port_to_screen()returns a world point from NDC (it’s an unproject). Consider renaming tondc_to_world()in your codebase for clarity. - FOV units:
FieldOfViewuses the project’sAngletype; pass degrees viaangles::degrees(...).
Minimal trait sketch (column-major, left-handed)
struct LHCTrait {
static MyAngles calc_look_at_angle(const Vector3<float>& eye,
const Vector3<float>& at) noexcept { /* ... */ }
static Mat4 calc_view_matrix(const MyAngles& ang,
const Vector3<float>& eye) noexcept {
// Build from forward/right/up and translation
}
static Mat4 calc_projection_matrix(const FieldOfView& fov,
const ViewPort& vp,
float zn, float zf) noexcept {
return omath::mat_perspective_left_handed<float, omath::MatStoreType::COLUMN_MAJOR>(
fov.as_degrees(), vp.aspect_ratio(), zn, zf
);
}
};
Testing checklist
- World point centered in view projects to screen center.
- Points outside frustum →
WORLD_POSITION_IS_OUT_OF_SCREEN_BOUNDS. - Inverting
VPfails gracefully for singular matrices. ScreenStartswitch flips Y as expected.- Screen→World ray: unproject
(x,y,0)and(x,y,1)and verify direction passes through the camera frustum.
See Also
- Engine-Specific Camera Traits - Camera implementations for different game engines
- View Angles Documentation - Understanding pitch/yaw/roll
- Getting Started Guide - Quick start with projection
- Tutorials - World-to-Screen - Complete projection tutorial
Last updated: 1 Nov 2025