use core::ops::Range;
use crate::Ray;
use crate::math::camera::rh::proj::directx::{orthographic, perspective};
use crate::math::camera::rh::view::look_at_mat4;
use crate::math::{Mat3, Mat4, Quat, UVec2, Vec2, Vec3, Vec4};
use crate::mesh::BoundingSphere;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Camera {
view: View,
projection: Projection,
}
impl Camera {
pub const fn new(view: View, projection: Projection) -> Self {
Self { view, projection }
}
pub const fn view(&self) -> View {
self.view
}
pub const fn projection(&self) -> Projection {
self.projection
}
pub fn ray_through(&self, pixel: Vec2, size: UVec2) -> Ray {
let size = size.as_vec2();
let clip = clip_space(pixel, size);
let world = self.view_projection(size.x / size.y).inverse();
let near = world.project_point3(clip.extend(0.0));
let far = world.project_point3(clip.extend(1.0));
match self.projection.lens {
Lens::Perspective { .. } => Ray::new(self.view.eye, far - near),
Lens::Orthographic { .. } => Ray::new(near, far - near),
}
}
pub fn pixel_of(&self, point: Vec3, size: UVec2) -> Option<Vec2> {
self.depth_in_front(point)?;
let size = surface(size)?;
let clip = self.view_projection(size.x / size.y).project_point3(point);
let pixel = Vec2::new(clip.x + 1.0, 1.0 - clip.y) / 2.0 * size;
pixel.is_finite().then_some(pixel)
}
pub fn pixels_per_meter(&self, point: Vec3, size: UVec2) -> Option<f32> {
let depth = self.depth_in_front(point)?;
let size = surface(size)?;
let visible_height = match self.projection.lens {
Lens::Perspective { fov_degrees } => {
2.0 * depth * (fov_degrees.to_radians() / 2.0).tan()
}
Lens::Orthographic { world_height } => world_height,
};
let scale = size.y / visible_height;
scale.is_normal().then_some(scale)
}
pub fn shifted_so(&self, point: Vec3, lands_at: Vec2, size: UVec2) -> Option<Camera> {
let drawn = self.pixel_of(point, size)?;
let scale = self.pixels_per_meter(point, size)?;
let axes = self.view.basis()?;
if !lands_at.is_finite() {
return None;
}
let moved = lands_at - drawn;
let shift = (axes.upward * moved.y - axes.across * moved.x) / scale;
let shifted = Self::new(self.view.moved(shift), self.projection);
shifted.pixel_of(point, size).is_some().then_some(shifted)
}
pub fn zoomed_about(&self, point: Vec3, factor: f32, size: UVec2) -> Option<Camera> {
self.pixel_of(point, size)?;
let closer = (factor.is_finite() && factor > 0.0).then(|| 1.0 - 1.0 / factor)?;
let to_point = point - self.view.eye;
let line = match self.projection.lens {
Lens::Orthographic { .. } => {
let axes = self.view.basis()?;
to_point - axes.forward * to_point.dot(axes.forward)
}
Lens::Perspective { .. } => to_point,
};
let zoomed = Self::new(
self.view.moved(line * closer),
self.projection.zoomed(factor)?,
);
zoomed.pixel_of(point, size).is_some().then_some(zoomed)
}
pub fn turned_about(&self, point: Vec3, yaw: f32, pitch: f32) -> Option<Camera> {
self.depth_in_front(point)?;
let axes = self.view.basis()?;
let turn = Quat::from_axis_angle(axes.up, yaw) * Quat::from_axis_angle(axes.across, pitch);
let turned = Self::new(self.view.turned(point, turn), self.projection);
(turned.view.basis()?.across.dot(turn * axes.across) > 0.0).then_some(turned)
}
pub(crate) fn view_projection(&self, aspect: f32) -> Mat4 {
self.projection.matrix(aspect) * self.view.matrix()
}
pub(crate) fn rays_from_clip(&self, aspect: f32) -> Mat4 {
let turned = Mat4::from_mat3(Mat3::from_mat4(self.view.matrix()));
(self.projection.matrix(aspect) * turned).inverse()
}
pub(crate) fn foreshortened(&self) -> bool {
matches!(self.projection.lens(), Lens::Perspective { .. })
}
fn depth_in_front(&self, point: Vec3) -> Option<f32> {
let depth = -self.view.matrix().transform_point3(point).z;
(depth > 0.0 && depth.is_finite()).then_some(depth)
}
}
impl Default for Camera {
fn default() -> Self {
Self::new(
View::look_at(Vec3::new(0.0, 2.0, 5.0), Vec3::ZERO),
Projection::perspective(60.0),
)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct View {
eye: Vec3,
target: Vec3,
up: Vec3,
}
impl View {
pub const fn look_at(eye: Vec3, target: Vec3) -> Self {
Self {
eye,
target,
up: Vec3::Y,
}
}
#[must_use]
pub const fn with_up(mut self, up: Vec3) -> Self {
self.up = up;
self
}
pub const fn eye(&self) -> Vec3 {
self.eye
}
pub const fn target(&self) -> Vec3 {
self.target
}
pub const fn up(&self) -> Vec3 {
self.up
}
pub fn direction(&self) -> Vec3 {
(self.target - self.eye).normalize_or_zero()
}
fn matrix(&self) -> Mat4 {
look_at_mat4(self.eye, self.target, self.up)
}
fn basis(&self) -> Option<Basis> {
let forward = self.direction();
if forward == Vec3::ZERO {
return None;
}
let across = forward.cross(self.up).try_normalize()?;
let up = self.up.try_normalize()?;
Some(Basis {
forward,
across,
upward: across.cross(forward),
up,
})
}
fn moved(self, offset: Vec3) -> Self {
Self {
eye: self.eye + offset,
target: self.target + offset,
up: self.up,
}
}
fn turned(self, about: Vec3, turn: Quat) -> Self {
Self {
eye: about + turn * (self.eye - about),
target: about + turn * (self.target - about),
up: self.up,
}
}
}
struct Basis {
forward: Vec3,
across: Vec3,
upward: Vec3,
up: Vec3,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Projection {
lens: Lens,
near: f32,
far: f32,
}
impl Projection {
const DEFAULT_NEAR: f32 = 0.1;
const DEFAULT_FAR: f32 = 1000.0;
pub const fn perspective(fov_degrees: f32) -> Self {
Self {
lens: Lens::Perspective { fov_degrees },
near: Self::DEFAULT_NEAR,
far: Self::DEFAULT_FAR,
}
}
pub const fn orthographic(world_height: f32) -> Self {
Self {
lens: Lens::Orthographic { world_height },
near: Self::DEFAULT_NEAR,
far: Self::DEFAULT_FAR,
}
}
#[must_use]
pub const fn clip(mut self, clip: Range<f32>) -> Self {
self.near = clip.start;
self.far = clip.end;
self
}
pub const fn lens(&self) -> Lens {
self.lens
}
pub const fn near(&self) -> f32 {
self.near
}
pub const fn far(&self) -> f32 {
self.far
}
pub(crate) fn zoomed(&self, factor: f32) -> Option<Self> {
let Lens::Orthographic { world_height } = self.lens else {
return Some(*self);
};
let world_height = world_height / factor;
(world_height.is_finite() && world_height > 0.0).then_some(Self {
lens: Lens::Orthographic { world_height },
..*self
})
}
fn matrix(&self, aspect: f32) -> Mat4 {
match self.lens {
Lens::Perspective { fov_degrees } => {
perspective(fov_degrees.to_radians(), aspect, self.near, self.far)
}
Lens::Orthographic { world_height } => {
let half_height = world_height / 2.0;
let half_width = half_height * aspect;
orthographic(
-half_width,
half_width,
-half_height,
half_height,
self.near,
self.far,
)
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Lens {
Perspective {
fov_degrees: f32,
},
Orthographic {
world_height: f32,
},
}
pub(crate) struct Frustum([Vec4; 6]);
impl Frustum {
pub(crate) fn new(view_projection: Mat4) -> Self {
let [across, up, depth, clip] = [0, 1, 2, 3].map(|row| view_projection.row(row));
Self(
[
clip + across,
clip - across,
clip + up,
clip - up,
depth,
clip - depth,
]
.map(facing_inward),
)
}
pub(crate) fn holds(&self, sphere: BoundingSphere) -> bool {
let center = sphere.center().extend(1.0);
!self
.0
.iter()
.any(|plane| plane.dot(center) < -sphere.radius())
}
}
fn facing_inward(plane: Vec4) -> Vec4 {
let reach = plane.truncate().length();
if reach.is_normal() {
plane / reach
} else {
Vec4::ZERO
}
}
fn surface(size: UVec2) -> Option<Vec2> {
(size.x > 0 && size.y > 0).then(|| size.as_vec2())
}
fn clip_space(pixel: Vec2, size: Vec2) -> Vec2 {
let across = pixel / size * 2.0 - Vec2::ONE;
Vec2::new(across.x, -across.y)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ray;
const SIZE: UVec2 = UVec2::new(1280, 720);
const LENSES: [Projection; 2] = [
Projection::perspective(60.0),
Projection::orthographic(20.0),
];
const PIXELS: [Vec2; 5] = [
Vec2::ZERO,
Vec2::new(1280.0, 0.0),
Vec2::new(0.0, 720.0),
Vec2::new(1280.0, 720.0),
Vec2::new(640.0, 360.0),
];
fn overhead(projection: Projection) -> Camera {
Camera::new(
View::look_at(Vec3::new(0.0, 10.0, 10.0), Vec3::ZERO),
projection,
)
}
fn looking(camera: &Camera) -> Vec3 {
(camera.view().target() - camera.view().eye()).normalize()
}
#[test]
fn the_middle_pixel_looks_where_the_camera_does() {
for projection in LENSES {
let camera = overhead(projection);
let ray = camera.ray_through(SIZE.as_vec2() / 2.0, SIZE);
assert!(
ray.direction().abs_diff_eq(looking(&camera), 1e-5),
"{:?} against {:?}",
ray.direction(),
looking(&camera)
);
}
}
#[test]
fn a_foreshortened_view_takes_every_ray_from_the_eye() {
let camera = overhead(Projection::perspective(60.0));
for pixel in PIXELS {
let ray = camera.ray_through(pixel, SIZE);
assert!(ray.origin().abs_diff_eq(camera.view().eye(), 1e-4));
}
}
#[test]
fn a_flat_view_takes_every_ray_from_the_near_plane_it_lies_in() {
let camera = overhead(Projection::orthographic(20.0));
let middle = camera.ray_through(SIZE.as_vec2() / 2.0, SIZE);
let near = camera.view().eye() + looking(&camera) * camera.projection().near();
assert!(middle.origin().abs_diff_eq(near, 1e-4));
for pixel in PIXELS {
let ray = camera.ray_through(pixel, SIZE);
assert!(
ray.direction().abs_diff_eq(middle.direction(), 1e-5),
"every ray is parallel, {pixel} was not"
);
assert!(
(ray.origin() - near).dot(middle.direction()).abs() < 1e-3,
"and starts in the same plane, {pixel} did not"
);
}
}
#[test]
fn a_surface_with_no_area_names_a_ray_that_reaches_nothing() {
for projection in LENSES {
let ray = overhead(projection).ray_through(Vec2::ZERO, UVec2::ZERO);
assert_eq!(ray.direction(), Vec3::ZERO);
assert_eq!(
ray.hit_plane(ray::Plane {
point: Vec3::ZERO,
normal: Vec3::Y
}),
None
);
}
}
fn ahead() -> Frustum {
Frustum::new(
Camera::new(
View::look_at(Vec3::ZERO, Vec3::NEG_Z),
Projection::perspective(60.0),
)
.view_projection(1.0),
)
}
fn ball(at: Vec3) -> BoundingSphere {
BoundingSphere::new(at, 1.0)
}
#[test]
fn a_camera_reaches_what_is_ahead_of_it_and_nothing_past_its_own_planes() {
let seen = ahead();
assert!(seen.holds(ball(Vec3::new(0.0, 0.0, -5.0))));
assert!(!seen.holds(ball(Vec3::new(0.0, 0.0, 5.0))), "behind it");
assert!(!seen.holds(ball(Vec3::new(0.0, 0.0, -2000.0))), "past far");
assert!(!seen.holds(ball(Vec3::new(20.0, 0.0, -5.0))), "beside it");
assert!(!seen.holds(ball(Vec3::new(0.0, 20.0, -5.0))), "above it");
}
#[test]
fn a_sphere_lying_across_a_plane_is_reached_by_the_camera_it_crosses() {
let seen = ahead();
assert!(
seen.holds(ball(Vec3::new(0.0, 0.0, 0.5))),
"one behind the eye still reaching in front of it is kept"
);
assert!(
seen.holds(ball(Vec3::new(3.0, 0.0, -5.0))),
"and so is one reaching in over the side"
);
assert!(
seen.holds(BoundingSphere::new(Vec3::new(0.0, 0.0, 400.0), 1e4)),
"as is one the whole view sits inside"
);
}
#[test]
fn a_camera_with_no_shape_to_it_keeps_every_sphere() {
let squashed = Frustum::new(Mat4::ZERO);
let nowhere = Frustum::new(
Camera::new(
View::look_at(Vec3::ZERO, Vec3::ZERO),
Projection::perspective(60.0),
)
.view_projection(0.0),
);
assert!(squashed.holds(ball(Vec3::new(0.0, 0.0, 5.0))));
assert!(nowhere.holds(ball(Vec3::new(0.0, 0.0, 5.0))));
assert!(
ahead().holds(ball(Vec3::NAN)),
"and a sphere placed nowhere is kept by any camera"
);
}
#[test]
fn a_ray_lands_where_the_pixel_it_came_from_draws() {
for projection in LENSES {
let camera = overhead(projection);
for pixel in PIXELS {
let ray = camera.ray_through(pixel, SIZE);
let Some(distance) = ray.hit_plane(ray::Plane {
point: Vec3::ZERO,
normal: Vec3::Y,
}) else {
panic!("{pixel} of an overhead view reaches the ground");
};
let ground = ray.at(distance);
assert!(ground.y.abs() < 1e-3, "{ground} left the ground");
let drawn = camera.pixel_of(ground, SIZE);
assert!(
drawn.is_some_and(|drawn| drawn.abs_diff_eq(pixel, 0.05)),
"{pixel} landed at {ground}, which draws at {drawn:?}"
);
}
}
}
#[test]
fn every_point_a_ray_reaches_draws_back_at_the_pixel_it_came_from() {
for projection in LENSES {
let camera = overhead(projection);
for pixel in PIXELS {
let ray = camera.ray_through(pixel, SIZE);
for distance in [0.5, 3.0, 40.0] {
let point = ray.at(distance);
let drawn = camera.pixel_of(point, SIZE);
assert!(
drawn.is_some_and(|drawn| drawn.abs_diff_eq(pixel, 0.05)),
"{pixel} reaches {point} at {distance} meters, which draws at {drawn:?}"
);
}
}
}
}
#[test]
fn a_point_behind_the_camera_draws_nowhere_and_has_no_scale() {
for projection in LENSES {
let camera = overhead(projection);
let behind = camera.view().eye() - looking(&camera) * 5.0;
assert_eq!(camera.pixel_of(behind, SIZE), None);
assert_eq!(camera.pixels_per_meter(behind, SIZE), None);
}
}
#[test]
fn two_points_a_meter_apart_across_the_view_draw_the_scale_apart() {
for projection in LENSES {
let camera = overhead(projection);
let across = looking(&camera).cross(camera.view().up()).normalize();
let point = camera.view().eye() + looking(&camera) * 8.0;
let (Some(scale), Some(here), Some(there)) = (
camera.pixels_per_meter(point, SIZE),
camera.pixel_of(point, SIZE),
camera.pixel_of(point + across, SIZE),
) else {
panic!("{point} is in front of a {projection:?} camera");
};
assert!(
((there - here).length() - scale).abs() < 0.05,
"a meter draws {} pixels across, against a scale of {scale}",
(there - here).length()
);
}
}
#[test]
fn depth_shrinks_the_scale_of_a_foreshortened_view_and_leaves_a_flat_one_alone() {
for projection in LENSES {
let camera = overhead(projection);
let [near, far] = [5.0, 20.0].map(|depth| {
camera.pixels_per_meter(camera.view().eye() + looking(&camera) * depth, SIZE)
});
let (Some(near), Some(far)) = (near, far) else {
panic!("both depths lie in front of a {projection:?} camera");
};
match projection.lens() {
Lens::Perspective { .. } => {
assert!(far < near, "{far} at 20 meters is not under {near} at 5")
}
Lens::Orthographic { .. } => assert_eq!(near, far),
}
}
}
#[test]
fn a_shifted_camera_draws_the_point_at_the_pixel_it_was_given_and_looks_the_same_way() {
for projection in LENSES {
let camera = overhead(projection);
let point = Vec3::new(2.0, 0.0, -1.0);
for lands_at in PIXELS {
let Some(shifted) = camera.shifted_so(point, lands_at, SIZE) else {
panic!("{point} draws under a {projection:?} camera");
};
let drawn = shifted.pixel_of(point, SIZE);
assert!(
drawn.is_some_and(|drawn| drawn.abs_diff_eq(lands_at, 0.01)),
"given {lands_at}, drew at {drawn:?}"
);
assert!(
looking(&shifted).abs_diff_eq(looking(&camera), 1e-6),
"and the camera turned to {:?}",
looking(&shifted)
);
assert_eq!(shifted.projection(), camera.projection());
}
}
}
#[test]
fn a_camera_zoomed_about_a_point_keeps_its_pixel_and_halves_what_the_view_covers() {
for projection in LENSES {
let camera = overhead(projection);
let point = Vec3::new(2.0, 0.0, -1.0);
let (Some(before), Some(zoomed)) = (
camera.pixel_of(point, SIZE),
camera.zoomed_about(point, 2.0, SIZE),
) else {
panic!("{point} draws under a {projection:?} camera");
};
let drawn = zoomed.pixel_of(point, SIZE);
assert!(
drawn.is_some_and(|drawn| drawn.abs_diff_eq(before, 0.01)),
"{point} drew at {before} and now draws at {drawn:?}"
);
assert!(
looking(&zoomed).abs_diff_eq(looking(&camera), 1e-6),
"and the camera turned to {:?}",
looking(&zoomed)
);
match (camera.projection().lens(), zoomed.projection().lens()) {
(Lens::Perspective { fov_degrees }, Lens::Perspective { fov_degrees: same }) => {
let (was, now) = (
camera.view().eye().distance(point),
zoomed.view().eye().distance(point),
);
assert!(
(now - was / 2.0).abs() < 1e-4,
"{was} meters away became {now}"
);
assert_eq!(fov_degrees, same, "over the same field of view");
}
(
Lens::Orthographic { world_height },
Lens::Orthographic {
world_height: halved,
},
) => {
assert!(
(halved - world_height / 2.0).abs() < 1e-4,
"{world_height} meters of world became {halved}"
);
let along = looking(&camera);
assert!(
(zoomed.view().eye() - camera.view().eye()).dot(along).abs() < 1e-4,
"and the eye kept its depth"
);
}
(lens, zoomed) => panic!("{lens:?} zoomed to a {zoomed:?}"),
}
}
}
fn covered(camera: &Camera) -> f32 {
match camera.projection().lens() {
Lens::Perspective { fov_degrees } => fov_degrees,
Lens::Orthographic { world_height } => world_height,
}
}
#[test]
fn every_camera_a_zoom_returns_still_draws_the_point_it_was_zoomed_about() {
let point = Vec3::new(2.0, 0.0, -1.0);
let factors = [
f32::MIN_POSITIVE,
1e-38,
1e-30,
1e-20,
1e-12,
1e-8,
1e-6,
0.5,
1.0,
2.0,
1e6,
];
for projection in LENSES {
let camera = overhead(projection);
for factor in factors {
let Some(zoomed) = camera.zoomed_about(point, factor, SIZE) else {
continue;
};
assert!(
zoomed.pixel_of(point, SIZE).is_some(),
"a {projection:?} camera zoomed by {factor} draws nothing"
);
assert!(
zoomed.view().eye().is_finite() && zoomed.view().target().is_finite(),
"and sits at {:?} looking at {:?}",
zoomed.view().eye(),
zoomed.view().target()
);
let shape = covered(&zoomed);
assert!(
shape.is_finite() && shape > 0.0,
"and its lens is shaped by {shape}"
);
}
assert!(camera.zoomed_about(point, 2.0, SIZE).is_some());
}
}
#[test]
fn a_zoom_out_doubles_what_the_view_covers_and_a_zoom_of_one_changes_nothing() {
for projection in LENSES {
let camera = overhead(projection);
let point = Vec3::new(2.0, 0.0, -1.0);
let (Some(before), Some(out)) = (
camera.pixel_of(point, SIZE),
camera.zoomed_about(point, 0.5, SIZE),
) else {
panic!("{point} draws under a {projection:?} camera");
};
let drawn = out.pixel_of(point, SIZE);
assert!(
drawn.is_some_and(|drawn| drawn.abs_diff_eq(before, 0.01)),
"{point} drew at {before} and now draws at {drawn:?}"
);
match projection.lens() {
Lens::Perspective { .. } => {
let (was, now) = (
camera.view().eye().distance(point),
out.view().eye().distance(point),
);
assert!((now - was * 2.0).abs() < 1e-3, "{was} meters became {now}");
}
Lens::Orthographic { world_height } => assert!(
(covered(&out) - world_height * 2.0).abs() < 1e-3,
"{world_height} meters of world became {}",
covered(&out)
),
}
assert_eq!(
camera.zoomed_about(point, 1.0, SIZE),
Some(camera),
"and a zoom of one leaves the camera where it was"
);
}
}
#[test]
fn a_camera_turned_about_a_point_draws_it_at_the_pixel_it_drew_at() {
for projection in LENSES {
let camera = overhead(projection);
let point = Vec3::new(2.0, 0.0, -1.0);
let Some(before) = camera.pixel_of(point, SIZE) else {
panic!("{point} draws under a {projection:?} camera");
};
for (yaw, pitch) in [(0.5, 0.0), (0.0, 0.3), (-1.2, 0.4), (3.0, -0.6)] {
let Some(turned) = camera.turned_about(point, yaw, pitch) else {
panic!("a turn of {yaw} and {pitch} keeps {point} in front of the camera");
};
let drawn = turned.pixel_of(point, SIZE);
assert!(
drawn.is_some_and(|drawn| drawn.abs_diff_eq(before, 0.05)),
"{point} drew at {before} and, turned by {yaw} and {pitch}, draws at {drawn:?}"
);
assert!(
(turned.view().eye().distance(point) - camera.view().eye().distance(point))
.abs()
< 1e-3,
"and it turned to {} meters from {} away",
turned.view().eye().distance(point),
camera.view().eye().distance(point)
);
assert_eq!(turned.projection(), camera.projection());
}
}
}
#[test]
fn a_turn_keeps_the_point_at_its_pixel_under_a_view_with_an_up_of_its_own() {
let tilted = Camera::new(
View::look_at(Vec3::new(0.0, 10.0, 10.0), Vec3::ZERO)
.with_up(Vec3::new(0.3, 1.0, 0.0).normalize()),
Projection::perspective(60.0),
);
let point = Vec3::new(2.0, 0.0, -1.0);
let Some(before) = tilted.pixel_of(point, SIZE) else {
panic!("{point} draws under a camera holding its own up");
};
for (yaw, pitch) in [(0.7, 0.0), (0.0, 0.4), (-1.1, 0.25)] {
let Some(turned) = tilted.turned_about(point, yaw, pitch) else {
panic!("a turn of {yaw} and {pitch} keeps {point} in front of the camera");
};
let drawn = turned.pixel_of(point, SIZE);
assert!(
drawn.is_some_and(|drawn| drawn.abs_diff_eq(before, 0.05)),
"{point} drew at {before} and, turned by {yaw} and {pitch}, draws at {drawn:?}"
);
}
}
#[test]
fn a_turn_of_a_whole_circle_returns_the_camera_it_started_from() {
for projection in LENSES {
let camera = overhead(projection);
let point = Vec3::new(2.0, 0.0, -1.0);
let Some(turned) = camera.turned_about(point, core::f32::consts::TAU, 0.0) else {
panic!("a whole circle about {point} is a turn a {projection:?} camera takes");
};
assert!(
turned.view().eye().abs_diff_eq(camera.view().eye(), 1e-4),
"the eye came back to {:?} from {:?}",
turned.view().eye(),
camera.view().eye()
);
assert!(
turned
.view()
.target()
.abs_diff_eq(camera.view().target(), 1e-4),
"and looks at {:?}",
turned.view().target()
);
assert_eq!(turned.projection(), camera.projection());
}
}
#[test]
fn a_quarter_turn_moves_the_eye_a_quarter_of_the_way_about_the_point() {
let camera = overhead(Projection::perspective(60.0));
let point = Vec3::new(2.0, 0.0, -1.0);
let Some(turned) = camera.turned_about(point, core::f32::consts::FRAC_PI_2, 0.0) else {
panic!("a quarter turn about {point} is a turn this camera takes");
};
let (was, now) = (camera.view().eye() - point, turned.view().eye() - point);
let flat = |offset: Vec3| Vec2::new(offset.x, offset.z);
assert!(
(now.length() - was.length()).abs() < 1e-4,
"{} meters out became {}",
was.length(),
now.length()
);
assert!((now.y - was.y).abs() < 1e-4, "and left the height alone");
assert!(
(flat(now).angle_to(flat(was)).abs() - core::f32::consts::FRAC_PI_2).abs() < 1e-4,
"a quarter of the way about {point}, not {} radians",
flat(now).angle_to(flat(was))
);
}
#[test]
fn a_turn_that_takes_the_view_past_the_up_direction_returns_no_camera() {
let level = Camera::new(
View::look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::ZERO),
Projection::perspective(60.0),
);
let short_of = core::f32::consts::FRAC_PI_2 - 0.1;
for pitch in [short_of, -short_of] {
assert!(
level.turned_about(Vec3::ZERO, 0.0, pitch).is_some(),
"{pitch} radians leaves the view under the pole"
);
}
for pitch in [
short_of + 0.2,
-short_of - 0.2,
core::f32::consts::PI,
-core::f32::consts::PI,
] {
assert_eq!(
level.turned_about(Vec3::ZERO, 0.0, pitch),
None,
"{pitch} radians takes it past the pole"
);
}
}
#[test]
fn a_turn_about_a_point_the_camera_does_not_draw_returns_no_camera() {
for projection in LENSES {
let camera = overhead(projection);
let behind = camera.view().eye() - looking(&camera) * 5.0;
let nowhere = Camera::new(View::look_at(Vec3::ZERO, Vec3::ZERO), projection);
assert_eq!(camera.turned_about(behind, 0.5, 0.0), None);
assert_eq!(camera.turned_about(camera.view().eye(), 0.5, 0.0), None);
assert_eq!(nowhere.turned_about(Vec3::NEG_Z, 0.5, 0.0), None);
for angle in [f32::NAN, f32::INFINITY] {
assert_eq!(camera.turned_about(Vec3::ZERO, angle, 0.0), None, "{angle}");
assert_eq!(camera.turned_about(Vec3::ZERO, 0.0, angle), None, "{angle}");
}
}
}
#[test]
fn a_shift_that_would_leave_the_point_undrawn_returns_no_camera() {
for projection in LENSES {
let camera = overhead(projection);
for far in [1e20, 1e25, 1e30, 1e38] {
assert_eq!(
camera.shifted_so(Vec3::ZERO, Vec2::splat(far), SIZE),
None,
"{far} pixels out of a {projection:?} camera"
);
}
}
let wide = Camera::new(
View::look_at(Vec3::new(0.0, 10.0, 10.0), Vec3::ZERO),
Projection::orthographic(f32::MAX),
);
assert!(
wide.pixel_of(Vec3::ZERO, SIZE).is_some()
&& wide.pixels_per_meter(Vec3::ZERO, SIZE).is_some(),
"a camera that draws on its own"
);
assert_eq!(wide.shifted_so(Vec3::ZERO, Vec2::splat(1e30), SIZE), None);
}
#[test]
fn neither_move_returns_a_camera_where_no_pixel_is_drawn_or_a_number_is_not_finite() {
for projection in LENSES {
let camera = overhead(projection);
let behind = camera.view().eye() - looking(&camera) * 5.0;
let nowhere = Camera::new(View::look_at(Vec3::ZERO, Vec3::ZERO), projection);
let middle = SIZE.as_vec2() / 2.0;
assert_eq!(camera.shifted_so(behind, middle, SIZE), None);
assert_eq!(camera.zoomed_about(behind, 2.0, SIZE), None);
assert_eq!(nowhere.shifted_so(Vec3::NEG_Z, middle, SIZE), None);
assert_eq!(nowhere.zoomed_about(Vec3::NEG_Z, 2.0, SIZE), None);
for size in [UVec2::ZERO, UVec2::new(1280, 0), UVec2::new(0, 720)] {
assert_eq!(camera.shifted_so(Vec3::ZERO, middle, size), None, "{size}");
assert_eq!(camera.zoomed_about(Vec3::ZERO, 2.0, size), None, "{size}");
}
for lands_at in [Vec2::NAN, Vec2::INFINITY, Vec2::new(0.0, f32::NAN)] {
assert_eq!(camera.shifted_so(Vec3::ZERO, lands_at, SIZE), None);
}
for factor in [0.0, -1.0, f32::NAN, f32::INFINITY] {
assert_eq!(camera.zoomed_about(Vec3::ZERO, factor, SIZE), None);
}
}
}
#[test]
fn a_surface_with_no_area_and_a_view_with_no_direction_draw_no_pixel() {
for projection in LENSES {
let camera = overhead(projection);
let nowhere = Camera::new(View::look_at(Vec3::ZERO, Vec3::ZERO), projection);
for size in [UVec2::ZERO, UVec2::new(1280, 0), UVec2::new(0, 720)] {
assert_eq!(camera.pixel_of(Vec3::ZERO, size), None, "{size}");
assert_eq!(camera.pixels_per_meter(Vec3::ZERO, size), None, "{size}");
}
assert_eq!(nowhere.pixel_of(Vec3::NEG_Z, SIZE), None);
assert_eq!(nowhere.pixels_per_meter(Vec3::NEG_Z, SIZE), None);
}
}
}