use gizmo_math::Vec3;
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub enum ProjectionMode {
#[default]
Perspective,
Orthographic { height: f32 },
}
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Camera {
pub fov: f32,
pub near: f32,
pub far: f32,
pub yaw: f32,
pub pitch: f32,
pub exposure: f32, pub primary: bool,
#[serde(default)]
pub projection: ProjectionMode,
}
impl Camera {
pub fn new(
mut fov: f32,
mut near: f32,
mut far: f32,
mut yaw: f32,
mut pitch: f32,
primary: bool,
) -> Self {
fov = fov.max(0.001);
near = near.max(0.001);
far = far.max(near + 0.1);
yaw %= std::f32::consts::TAU;
pitch = pitch.clamp(
-std::f32::consts::PI / 2.0 + 0.001,
std::f32::consts::PI / 2.0 - 0.001,
);
Self {
fov,
near,
far,
yaw,
pitch,
exposure: 1.0, primary,
projection: ProjectionMode::Perspective,
}
}
pub fn toggle_projection(&mut self, distance: f32) {
self.projection = match self.projection {
ProjectionMode::Perspective => ProjectionMode::Orthographic {
height: 2.0 * distance.abs().max(0.001) * (self.fov * 0.5).tan(),
},
ProjectionMode::Orthographic { .. } => ProjectionMode::Perspective,
};
}
pub fn sanitize_angles(&mut self) {
self.yaw %= std::f32::consts::TAU;
self.pitch = self.pitch.clamp(
-std::f32::consts::PI / 2.0 + 0.001,
std::f32::consts::PI / 2.0 - 0.001,
);
}
pub fn get_projection(&self, aspect: f32) -> gizmo_math::Mat4 {
match self.projection {
ProjectionMode::Perspective => {
gizmo_math::Mat4::perspective_rh(self.fov, aspect, self.near, self.far)
}
ProjectionMode::Orthographic { height } => {
let half_h = (height * 0.5).max(0.001);
let half_w = half_h * aspect.max(0.001);
gizmo_math::Mat4::orthographic_rh(
-half_w, half_w, -half_h, half_h, self.near, self.far,
)
}
}
}
pub fn get_view(&self, position: Vec3) -> gizmo_math::Mat4 {
let front = self.get_front();
let right = self.get_right();
let up = right.cross(front);
gizmo_math::Mat4::look_at_rh(position, position + front, up)
}
pub fn forward_from(yaw: f32, pitch: f32) -> Vec3 {
let pitch = pitch.clamp(
-std::f32::consts::PI / 2.0 + 0.001,
std::f32::consts::PI / 2.0 - 0.001,
);
Vec3::new(
yaw.cos() * pitch.cos(),
pitch.sin(),
yaw.sin() * pitch.cos(),
)
.normalize()
}
pub fn yaw_pitch_from_forward(dir: Vec3, fallback_yaw: f32) -> Option<(f32, f32)> {
let d = dir.normalize_or_zero();
if d == Vec3::ZERO {
return None;
}
let pitch = d.y.clamp(-1.0, 1.0).asin();
let yaw = if d.x.abs() + d.z.abs() < 1e-4 {
fallback_yaw
} else {
d.z.atan2(d.x)
};
Some((yaw, pitch))
}
pub fn right_from(yaw: f32) -> Vec3 {
Vec3::new(-yaw.sin(), 0.0, yaw.cos())
}
pub fn get_front(&self) -> Vec3 {
Self::forward_from(self.yaw, self.pitch)
}
pub fn get_right(&self) -> Vec3 {
Self::right_from(self.yaw)
}
pub fn screen_to_ray(
&self,
screen: (f32, f32),
viewport: (f32, f32),
world_pos: Vec3,
) -> gizmo_math::Ray {
let (w, h) = (viewport.0.max(1.0), viewport.1.max(1.0));
let ndc = gizmo_math::Vec2::new((screen.0 / w) * 2.0 - 1.0, 1.0 - (screen.1 / h) * 2.0);
let view_proj_inv = (self.get_projection(w / h) * self.get_view(world_pos)).inverse();
gizmo_math::Ray::from_ndc(ndc, view_proj_inv)
}
}
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Camera2D {
pub zoom: f32,
pub primary: bool,
}
impl Camera2D {
pub fn new(zoom: f32, primary: bool) -> Self {
Self { zoom, primary }
}
pub fn get_projection(&self, width: f32, height: f32) -> gizmo_math::Mat4 {
let safe_zoom = self.zoom.max(0.001);
let hw = (width / 2.0) / safe_zoom;
let hh = (height / 2.0) / safe_zoom;
gizmo_math::Mat4::orthographic_rh(-hw, hw, -hh, hh, -1000.0, 1000.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn screen_to_ray_center_points_along_camera_front() {
let cam = Camera::new(
std::f32::consts::FRAC_PI_2,
0.1,
100.0,
-std::f32::consts::FRAC_PI_2,
0.0,
true,
);
let pos = Vec3::new(0.0, 0.0, 10.0);
let ray = cam.screen_to_ray((100.0, 50.0), (200.0, 100.0), pos);
assert!((ray.direction.z - (-1.0)).abs() < 1e-4, "centre ray looks -Z, got {:?}", ray.direction);
assert!(ray.direction.x.abs() < 1e-4 && ray.direction.y.abs() < 1e-4);
assert!((ray.direction.length() - 1.0).abs() < 1e-5, "direction normalized");
}
#[test]
fn screen_to_ray_offset_pixels_tilt_the_ray() {
let cam = Camera::new(
std::f32::consts::FRAC_PI_2,
0.1,
100.0,
-std::f32::consts::FRAC_PI_2,
0.0,
true,
);
let pos = Vec3::new(0.0, 0.0, 10.0);
let right = cam.screen_to_ray((150.0, 50.0), (200.0, 100.0), pos);
assert!(right.direction.x > 0.05, "right pixel tilts +X, got {:?}", right.direction);
let down = cam.screen_to_ray((100.0, 90.0), (200.0, 100.0), pos);
assert!(down.direction.y < -0.05, "lower pixel tilts -Y, got {:?}", down.direction);
}
#[test]
fn new_sanitizes_out_of_range_inputs() {
let cam = Camera::new(
-1.0,
-1.0,
-5.0,
10.0 * std::f32::consts::TAU + 0.5,
5.0,
true,
);
assert!(cam.fov >= 0.001);
assert!(cam.near >= 0.001);
assert!(cam.far >= cam.near + 0.1, "far must sit past near, got {}", cam.far);
assert!(cam.yaw.abs() <= std::f32::consts::TAU, "yaw wrapped, got {}", cam.yaw);
assert!(
cam.pitch < std::f32::consts::FRAC_PI_2 && cam.pitch > -std::f32::consts::FRAC_PI_2,
"pitch clamped below vertical, got {}",
cam.pitch
);
}
#[test]
fn forward_and_right_are_orthonormal() {
let (yaw, pitch) = (0.7f32, 0.3f32);
let f = Camera::forward_from(yaw, pitch);
let r = Camera::right_from(yaw);
assert!((f.length() - 1.0).abs() < 1e-5, "forward not unit: {f:?}");
assert!((r.length() - 1.0).abs() < 1e-5, "right not unit: {r:?}");
assert!(r.y.abs() < 1e-6, "right must stay horizontal: {r:?}");
assert!(f.dot(r).abs() < 1e-5, "right ⟂ forward expected, dot={}", f.dot(r));
}
#[test]
fn forward_with_zero_pitch_is_horizontal() {
let f = Camera::forward_from(0.0, 0.0);
assert!(f.y.abs() < 1e-6, "zero pitch → horizontal aim, got {f:?}");
let up = Camera::forward_from(0.0, std::f32::consts::PI); assert!(up.y.abs() < 1.0);
assert!((up.length() - 1.0).abs() < 1e-5);
}
#[test]
fn toggle_projection_is_an_involution_matching_the_fov_framing() {
let mut cam = Camera::new(std::f32::consts::FRAC_PI_2, 0.1, 100.0, 0.0, 0.0, true);
assert!(matches!(cam.projection, ProjectionMode::Perspective));
cam.toggle_projection(10.0);
match cam.projection {
ProjectionMode::Orthographic { height } => {
let expected = 2.0 * 10.0 * (cam.fov * 0.5).tan();
assert!((height - expected).abs() < 1e-3, "height {height} vs {expected}");
}
_ => panic!("expected orthographic after first toggle"),
}
cam.toggle_projection(10.0);
assert!(matches!(cam.projection, ProjectionMode::Perspective));
}
#[test]
fn sanitize_angles_wraps_yaw_and_clamps_pitch() {
let mut cam = Camera::new(std::f32::consts::FRAC_PI_2, 0.1, 100.0, 0.0, 0.0, true);
cam.yaw = 100.0;
cam.pitch = 5.0;
cam.sanitize_angles();
assert!(cam.yaw.abs() <= std::f32::consts::TAU);
assert!(cam.pitch < std::f32::consts::FRAC_PI_2 && cam.pitch > -std::f32::consts::FRAC_PI_2);
}
#[test]
fn projections_are_finite_and_camera2d_zoom_scales_the_view() {
let cam = Camera::new(std::f32::consts::FRAC_PI_2, 0.1, 100.0, 0.0, 0.0, true);
assert!(cam.get_projection(1.777).to_cols_array().iter().all(|v| v.is_finite()));
let cam2d = Camera2D::new(2.0, true);
let (w, h) = (800.0f32, 600.0f32);
let p = cam2d.get_projection(w, h);
assert!(p.to_cols_array().iter().all(|v| v.is_finite()));
let half_w = (w / 2.0) / 2.0; let clip = p.project_point3(Vec3::new(half_w, 0.0, 0.0));
assert!((clip.x - 1.0).abs() < 1e-4, "edge maps to NDC 1, got {}", clip.x);
}
}