use glam::{Mat4, Vec3, vec3}; /// A camera always directed at the origin. #[derive(Debug, Clone, Copy)] pub struct OrbitalCamera { /// Horizontal position (angle), in radians from +X towards +Y. pub position_yaw: f32, /// Vertical position (angle), in radians from XY plane towards +Z. pub position_pitch: f32, /// Distance from the origin. pub distance: f32, } impl OrbitalCamera { pub fn position(&self) -> Vec3 { let (y, x) = self.position_yaw.sin_cos(); let (z, xy) = self.position_pitch.sin_cos(); self.distance * vec3(xy * x, xy * y, z) } pub fn transform(&self) -> Mat4 { // for yaw=0, pitch=0: // X -> -Z // Y -> -X // Z -> Y Mat4::from_translation(vec3(0., 0., self.distance)) * Mat4::from_cols_array_2d(&[ [0., 0., -1., 0.], [-1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 0., 1.], ]) * Mat4::from_euler( glam::EulerRot::ZYZ, 0., self.position_pitch, -self.position_yaw, ) } } #[cfg(test)] mod tests { use std::f32::consts::PI; use approx::{abs_diff_eq, assert_ulps_eq}; use glam::vec3; use super::*; fn camera_deg(yaw: f32, pitch: f32) -> OrbitalCamera { OrbitalCamera { position_yaw: yaw.to_radians(), position_pitch: pitch.to_radians(), distance: 1.0, } } #[test] fn test_orbital_camera_position() { fn camera_pos_deg(yaw: f32, pitch: f32) -> Vec3 { camera_deg(yaw, pitch).position() } assert_ulps_eq!(camera_pos_deg(0., 0.), vec3(1., 0., 0.), max_ulps = 3); assert_ulps_eq!(camera_pos_deg(90., 0.), vec3(0., 1., 0.), max_ulps = 3); assert_ulps_eq!(camera_pos_deg(0., 90.), vec3(0., 0., 1.), max_ulps = 3); assert_ulps_eq!(camera_pos_deg(90., 90.), vec3(0., 0., 1.), max_ulps = 3); let s2 = 0.5f32.sqrt(); assert_ulps_eq!(camera_pos_deg(45., 0.), vec3(s2, s2, 0.), max_ulps = 3); assert_ulps_eq!(camera_pos_deg(0., 45.), vec3(s2, 0., s2), max_ulps = 3); assert_ulps_eq!(camera_pos_deg(45., 45.), vec3(0.5, 0.5, s2), max_ulps = 3); } #[test] fn test_orbital_camera_forward() { const EPSILON: f32 = 1e-5; for pitch in [0., 30., 45., 89., -30., -45., -89.] { for yaw in [0., 30., 45., 89., 90.] { let camera = camera_deg(yaw, pitch); let pos = camera.position(); let tfm = camera.transform(); let mapped = tfm.transform_vector3(-pos); assert!( abs_diff_eq!(mapped, vec3(0., 0., 1.), epsilon = EPSILON), "direction not mapped to +Z: yaw={yaw:?}°, pitch={pitch:?}°, pos={pos:?}, mapped={mapped:?}" ); let mapped = tfm.transform_point3(pos); assert!( abs_diff_eq!(mapped, Vec3::ZERO, epsilon = EPSILON), "pos not mapped to origin: yaw={yaw:?}°, pitch={pitch:?}°, pos={pos:?}, mapped={mapped:?}" ); let mapped = tfm.transform_point3(Vec3::ZERO); assert!( abs_diff_eq!(mapped, vec3(0., 0., 1.), epsilon = EPSILON), "origin not mapped to (0, 0, 1): yaw={yaw:?}°, pitch={pitch:?}°, pos={pos:?}, mapped={mapped:?}" ); let mapped = tfm.transform_vector3(vec3(0., 0., 1.)); assert!( mapped.y > 0., "up is not up: yaw={yaw:?}°, pitch={pitch:?}°, pos={pos:?}, mapped={mapped:?}" ); } } } #[test] fn test_isometric_camera() { let camera = OrbitalCamera { position_yaw: PI / 4., position_pitch: 0.5f32.sqrt().atan(), distance: 1.0, }; let s13 = (1.0f32 / 3.).sqrt(); assert_ulps_eq!(camera.position(), Vec3::splat(s13), max_ulps = 3); let tfm = camera.transform(); let o = tfm.transform_point3(vec3(0., 0., 0.)); let x = tfm.transform_point3(vec3(1., 0., 0.)); let y = tfm.transform_point3(vec3(0., 1., 0.)); let z = tfm.transform_point3(vec3(0., 0., 1.)); let s16 = (1.0f32 / 6.).sqrt(); let s12 = (1.0f32 / 2.).sqrt(); let s23 = (2.0f32 / 3.).sqrt(); let d = 1.0 - s13; assert_ulps_eq!(o, vec3(0., 0., 1.), max_ulps = 3); assert_ulps_eq!(z, vec3(0., s23, d), max_ulps = 3); assert_ulps_eq!(x, vec3(s12, -s16, d), max_ulps = 3); assert_ulps_eq!(y, vec3(-s12, -s16, d), max_ulps = 3); } }