1use core::ops::Range;
2
3use crate::Ray;
4use crate::math::camera::rh::proj::directx::{orthographic, perspective};
5use crate::math::camera::rh::view::look_at_mat4;
6use crate::math::{Mat3, Mat4, Quat, UVec2, Vec2, Vec3, Vec4};
7use crate::mesh::BoundingSphere;
8
9#[derive(Clone, Copy, Debug, PartialEq)]
16pub struct Camera {
17 view: View,
18 projection: Projection,
19}
20
21impl Camera {
22 pub const fn new(view: View, projection: Projection) -> Self {
24 Self { view, projection }
25 }
26
27 pub const fn view(&self) -> View {
29 self.view
30 }
31
32 pub const fn projection(&self) -> Projection {
34 self.projection
35 }
36
37 pub fn ray_through(&self, pixel: Vec2, size: UVec2) -> Ray {
44 let size = size.as_vec2();
45 let clip = clip_space(pixel, size);
46 let world = self.view_projection(size.x / size.y).inverse();
47 let near = world.project_point3(clip.extend(0.0));
48 let far = world.project_point3(clip.extend(1.0));
49
50 match self.projection.lens {
51 Lens::Perspective { .. } => Ray::new(self.view.eye, far - near),
52 Lens::Orthographic { .. } => Ray::new(near, far - near),
53 }
54 }
55
56 pub fn pixel_of(&self, point: Vec3, size: UVec2) -> Option<Vec2> {
65 self.depth_in_front(point)?;
66 let size = surface(size)?;
67 let clip = self.view_projection(size.x / size.y).project_point3(point);
68 let pixel = Vec2::new(clip.x + 1.0, 1.0 - clip.y) / 2.0 * size;
69
70 pixel.is_finite().then_some(pixel)
71 }
72
73 pub fn pixels_per_meter(&self, point: Vec3, size: UVec2) -> Option<f32> {
82 let depth = self.depth_in_front(point)?;
83 let size = surface(size)?;
84 let visible_height = match self.projection.lens {
85 Lens::Perspective { fov_degrees } => {
86 2.0 * depth * (fov_degrees.to_radians() / 2.0).tan()
87 }
88 Lens::Orthographic { world_height } => world_height,
89 };
90 let scale = size.y / visible_height;
91
92 scale.is_normal().then_some(scale)
93 }
94
95 pub fn shifted_so(&self, point: Vec3, lands_at: Vec2, size: UVec2) -> Option<Camera> {
109 let drawn = self.pixel_of(point, size)?;
110 let scale = self.pixels_per_meter(point, size)?;
111 let axes = self.view.basis()?;
112 if !lands_at.is_finite() {
113 return None;
114 }
115 let moved = lands_at - drawn;
116 let shift = (axes.upward * moved.y - axes.across * moved.x) / scale;
117 let shifted = Self::new(self.view.moved(shift), self.projection);
118
119 shifted.pixel_of(point, size).is_some().then_some(shifted)
120 }
121
122 pub fn zoomed_about(&self, point: Vec3, factor: f32, size: UVec2) -> Option<Camera> {
139 self.pixel_of(point, size)?;
140 let closer = (factor.is_finite() && factor > 0.0).then(|| 1.0 - 1.0 / factor)?;
141 let to_point = point - self.view.eye;
142
143 let line = match self.projection.lens {
144 Lens::Orthographic { .. } => {
145 let axes = self.view.basis()?;
146 to_point - axes.forward * to_point.dot(axes.forward)
147 }
148 Lens::Perspective { .. } => to_point,
149 };
150 let zoomed = Self::new(
151 self.view.moved(line * closer),
152 self.projection.zoomed(factor)?,
153 );
154
155 zoomed.pixel_of(point, size).is_some().then_some(zoomed)
156 }
157
158 pub fn turned_about(&self, point: Vec3, yaw: f32, pitch: f32) -> Option<Camera> {
172 self.depth_in_front(point)?;
173 let axes = self.view.basis()?;
174 let turn = Quat::from_axis_angle(axes.up, yaw) * Quat::from_axis_angle(axes.across, pitch);
175 let turned = Self::new(self.view.turned(point, turn), self.projection);
176
177 (turned.view.basis()?.across.dot(turn * axes.across) > 0.0).then_some(turned)
181 }
182
183 pub(crate) fn view_projection(&self, aspect: f32) -> Mat4 {
185 self.projection.matrix(aspect) * self.view.matrix()
186 }
187
188 pub(crate) fn rays_from_clip(&self, aspect: f32) -> Mat4 {
196 let turned = Mat4::from_mat3(Mat3::from_mat4(self.view.matrix()));
197
198 (self.projection.matrix(aspect) * turned).inverse()
199 }
200
201 pub(crate) fn foreshortened(&self) -> bool {
204 matches!(self.projection.lens(), Lens::Perspective { .. })
205 }
206
207 fn depth_in_front(&self, point: Vec3) -> Option<f32> {
211 let depth = -self.view.matrix().transform_point3(point).z;
212 (depth > 0.0 && depth.is_finite()).then_some(depth)
213 }
214}
215
216impl Default for Camera {
217 fn default() -> Self {
219 Self::new(
220 View::look_at(Vec3::new(0.0, 2.0, 5.0), Vec3::ZERO),
221 Projection::perspective(60.0),
222 )
223 }
224}
225
226#[derive(Clone, Copy, Debug, PartialEq)]
228pub struct View {
229 eye: Vec3,
230 target: Vec3,
231 up: Vec3,
232}
233
234impl View {
235 pub const fn look_at(eye: Vec3, target: Vec3) -> Self {
237 Self {
238 eye,
239 target,
240 up: Vec3::Y,
241 }
242 }
243
244 #[must_use]
246 pub const fn with_up(mut self, up: Vec3) -> Self {
247 self.up = up;
248 self
249 }
250
251 pub const fn eye(&self) -> Vec3 {
253 self.eye
254 }
255
256 pub const fn target(&self) -> Vec3 {
258 self.target
259 }
260
261 pub const fn up(&self) -> Vec3 {
263 self.up
264 }
265
266 pub fn direction(&self) -> Vec3 {
269 (self.target - self.eye).normalize_or_zero()
270 }
271
272 fn matrix(&self) -> Mat4 {
273 look_at_mat4(self.eye, self.target, self.up)
274 }
275
276 fn basis(&self) -> Option<Basis> {
280 let forward = self.direction();
281 if forward == Vec3::ZERO {
282 return None;
283 }
284 let across = forward.cross(self.up).try_normalize()?;
285 let up = self.up.try_normalize()?;
286
287 Some(Basis {
288 forward,
289 across,
290 upward: across.cross(forward),
291 up,
292 })
293 }
294
295 fn moved(self, offset: Vec3) -> Self {
297 Self {
298 eye: self.eye + offset,
299 target: self.target + offset,
300 up: self.up,
301 }
302 }
303
304 fn turned(self, about: Vec3, turn: Quat) -> Self {
307 Self {
308 eye: about + turn * (self.eye - about),
309 target: about + turn * (self.target - about),
310 up: self.up,
311 }
312 }
313}
314
315struct Basis {
317 forward: Vec3,
319 across: Vec3,
321 upward: Vec3,
324 up: Vec3,
327}
328
329#[derive(Clone, Copy, Debug, PartialEq)]
334pub struct Projection {
335 lens: Lens,
336 near: f32,
337 far: f32,
338}
339
340impl Projection {
341 const DEFAULT_NEAR: f32 = 0.1;
342 const DEFAULT_FAR: f32 = 1000.0;
343
344 pub const fn perspective(fov_degrees: f32) -> Self {
346 Self {
347 lens: Lens::Perspective { fov_degrees },
348 near: Self::DEFAULT_NEAR,
349 far: Self::DEFAULT_FAR,
350 }
351 }
352
353 pub const fn orthographic(world_height: f32) -> Self {
356 Self {
357 lens: Lens::Orthographic { world_height },
358 near: Self::DEFAULT_NEAR,
359 far: Self::DEFAULT_FAR,
360 }
361 }
362
363 #[must_use]
366 pub const fn clip(mut self, clip: Range<f32>) -> Self {
367 self.near = clip.start;
368 self.far = clip.end;
369 self
370 }
371
372 pub const fn lens(&self) -> Lens {
374 self.lens
375 }
376
377 pub const fn near(&self) -> f32 {
379 self.near
380 }
381
382 pub const fn far(&self) -> f32 {
384 self.far
385 }
386
387 pub(crate) fn zoomed(&self, factor: f32) -> Option<Self> {
398 let Lens::Orthographic { world_height } = self.lens else {
399 return Some(*self);
400 };
401 let world_height = world_height / factor;
402
403 (world_height.is_finite() && world_height > 0.0).then_some(Self {
404 lens: Lens::Orthographic { world_height },
405 ..*self
406 })
407 }
408
409 fn matrix(&self, aspect: f32) -> Mat4 {
410 match self.lens {
411 Lens::Perspective { fov_degrees } => {
412 perspective(fov_degrees.to_radians(), aspect, self.near, self.far)
413 }
414 Lens::Orthographic { world_height } => {
415 let half_height = world_height / 2.0;
416 let half_width = half_height * aspect;
417 orthographic(
418 -half_width,
419 half_width,
420 -half_height,
421 half_height,
422 self.near,
423 self.far,
424 )
425 }
426 }
427 }
428}
429
430#[derive(Clone, Copy, Debug, PartialEq)]
432pub enum Lens {
433 Perspective {
435 fov_degrees: f32,
437 },
438 Orthographic {
440 world_height: f32,
442 },
443}
444
445pub(crate) struct Frustum([Vec4; 6]);
448
449impl Frustum {
450 pub(crate) fn new(view_projection: Mat4) -> Self {
455 let [across, up, depth, clip] = [0, 1, 2, 3].map(|row| view_projection.row(row));
456
457 Self(
458 [
459 clip + across,
460 clip - across,
461 clip + up,
462 clip - up,
463 depth,
464 clip - depth,
465 ]
466 .map(facing_inward),
467 )
468 }
469
470 pub(crate) fn holds(&self, sphere: BoundingSphere) -> bool {
473 let center = sphere.center().extend(1.0);
474
475 !self
476 .0
477 .iter()
478 .any(|plane| plane.dot(center) < -sphere.radius())
479 }
480}
481
482fn facing_inward(plane: Vec4) -> Vec4 {
486 let reach = plane.truncate().length();
487 if reach.is_normal() {
488 plane / reach
489 } else {
490 Vec4::ZERO
491 }
492}
493
494fn surface(size: UVec2) -> Option<Vec2> {
497 (size.x > 0 && size.y > 0).then(|| size.as_vec2())
498}
499
500fn clip_space(pixel: Vec2, size: Vec2) -> Vec2 {
503 let across = pixel / size * 2.0 - Vec2::ONE;
504 Vec2::new(across.x, -across.y)
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::ray;
511
512 const SIZE: UVec2 = UVec2::new(1280, 720);
514
515 const LENSES: [Projection; 2] = [
517 Projection::perspective(60.0),
518 Projection::orthographic(20.0),
519 ];
520
521 const PIXELS: [Vec2; 5] = [
523 Vec2::ZERO,
524 Vec2::new(1280.0, 0.0),
525 Vec2::new(0.0, 720.0),
526 Vec2::new(1280.0, 720.0),
527 Vec2::new(640.0, 360.0),
528 ];
529
530 fn overhead(projection: Projection) -> Camera {
532 Camera::new(
533 View::look_at(Vec3::new(0.0, 10.0, 10.0), Vec3::ZERO),
534 projection,
535 )
536 }
537
538 fn looking(camera: &Camera) -> Vec3 {
539 (camera.view().target() - camera.view().eye()).normalize()
540 }
541
542 #[test]
543 fn the_middle_pixel_looks_where_the_camera_does() {
544 for projection in LENSES {
545 let camera = overhead(projection);
546 let ray = camera.ray_through(SIZE.as_vec2() / 2.0, SIZE);
547
548 assert!(
549 ray.direction().abs_diff_eq(looking(&camera), 1e-5),
550 "{:?} against {:?}",
551 ray.direction(),
552 looking(&camera)
553 );
554 }
555 }
556
557 #[test]
558 fn a_foreshortened_view_takes_every_ray_from_the_eye() {
559 let camera = overhead(Projection::perspective(60.0));
560
561 for pixel in PIXELS {
562 let ray = camera.ray_through(pixel, SIZE);
563 assert!(ray.origin().abs_diff_eq(camera.view().eye(), 1e-4));
564 }
565 }
566
567 #[test]
568 fn a_flat_view_takes_every_ray_from_the_near_plane_it_lies_in() {
569 let camera = overhead(Projection::orthographic(20.0));
570 let middle = camera.ray_through(SIZE.as_vec2() / 2.0, SIZE);
571 let near = camera.view().eye() + looking(&camera) * camera.projection().near();
572
573 assert!(middle.origin().abs_diff_eq(near, 1e-4));
574 for pixel in PIXELS {
575 let ray = camera.ray_through(pixel, SIZE);
576 assert!(
577 ray.direction().abs_diff_eq(middle.direction(), 1e-5),
578 "every ray is parallel, {pixel} was not"
579 );
580 assert!(
581 (ray.origin() - near).dot(middle.direction()).abs() < 1e-3,
582 "and starts in the same plane, {pixel} did not"
583 );
584 }
585 }
586
587 #[test]
588 fn a_surface_with_no_area_names_a_ray_that_reaches_nothing() {
589 for projection in LENSES {
590 let ray = overhead(projection).ray_through(Vec2::ZERO, UVec2::ZERO);
591
592 assert_eq!(ray.direction(), Vec3::ZERO);
593 assert_eq!(
594 ray.hit_plane(ray::Plane {
595 point: Vec3::ZERO,
596 normal: Vec3::Y
597 }),
598 None
599 );
600 }
601 }
602
603 fn ahead() -> Frustum {
606 Frustum::new(
607 Camera::new(
608 View::look_at(Vec3::ZERO, Vec3::NEG_Z),
609 Projection::perspective(60.0),
610 )
611 .view_projection(1.0),
612 )
613 }
614
615 fn ball(at: Vec3) -> BoundingSphere {
617 BoundingSphere::new(at, 1.0)
618 }
619
620 #[test]
621 fn a_camera_reaches_what_is_ahead_of_it_and_nothing_past_its_own_planes() {
622 let seen = ahead();
623
624 assert!(seen.holds(ball(Vec3::new(0.0, 0.0, -5.0))));
625 assert!(!seen.holds(ball(Vec3::new(0.0, 0.0, 5.0))), "behind it");
626 assert!(!seen.holds(ball(Vec3::new(0.0, 0.0, -2000.0))), "past far");
627 assert!(!seen.holds(ball(Vec3::new(20.0, 0.0, -5.0))), "beside it");
628 assert!(!seen.holds(ball(Vec3::new(0.0, 20.0, -5.0))), "above it");
629 }
630
631 #[test]
632 fn a_sphere_lying_across_a_plane_is_reached_by_the_camera_it_crosses() {
633 let seen = ahead();
634
635 assert!(
636 seen.holds(ball(Vec3::new(0.0, 0.0, 0.5))),
637 "one behind the eye still reaching in front of it is kept"
638 );
639 assert!(
640 seen.holds(ball(Vec3::new(3.0, 0.0, -5.0))),
641 "and so is one reaching in over the side"
642 );
643 assert!(
644 seen.holds(BoundingSphere::new(Vec3::new(0.0, 0.0, 400.0), 1e4)),
645 "as is one the whole view sits inside"
646 );
647 }
648
649 #[test]
650 fn a_camera_with_no_shape_to_it_keeps_every_sphere() {
651 let squashed = Frustum::new(Mat4::ZERO);
652 let nowhere = Frustum::new(
653 Camera::new(
654 View::look_at(Vec3::ZERO, Vec3::ZERO),
655 Projection::perspective(60.0),
656 )
657 .view_projection(0.0),
658 );
659
660 assert!(squashed.holds(ball(Vec3::new(0.0, 0.0, 5.0))));
661 assert!(nowhere.holds(ball(Vec3::new(0.0, 0.0, 5.0))));
662 assert!(
663 ahead().holds(ball(Vec3::NAN)),
664 "and a sphere placed nowhere is kept by any camera"
665 );
666 }
667
668 #[test]
669 fn a_ray_lands_where_the_pixel_it_came_from_draws() {
670 for projection in LENSES {
671 let camera = overhead(projection);
672
673 for pixel in PIXELS {
674 let ray = camera.ray_through(pixel, SIZE);
675 let Some(distance) = ray.hit_plane(ray::Plane {
676 point: Vec3::ZERO,
677 normal: Vec3::Y,
678 }) else {
679 panic!("{pixel} of an overhead view reaches the ground");
680 };
681 let ground = ray.at(distance);
682
683 assert!(ground.y.abs() < 1e-3, "{ground} left the ground");
684 let drawn = camera.pixel_of(ground, SIZE);
685 assert!(
686 drawn.is_some_and(|drawn| drawn.abs_diff_eq(pixel, 0.05)),
687 "{pixel} landed at {ground}, which draws at {drawn:?}"
688 );
689 }
690 }
691 }
692
693 #[test]
694 fn every_point_a_ray_reaches_draws_back_at_the_pixel_it_came_from() {
695 for projection in LENSES {
696 let camera = overhead(projection);
697
698 for pixel in PIXELS {
699 let ray = camera.ray_through(pixel, SIZE);
700
701 for distance in [0.5, 3.0, 40.0] {
702 let point = ray.at(distance);
703 let drawn = camera.pixel_of(point, SIZE);
704
705 assert!(
706 drawn.is_some_and(|drawn| drawn.abs_diff_eq(pixel, 0.05)),
707 "{pixel} reaches {point} at {distance} meters, which draws at {drawn:?}"
708 );
709 }
710 }
711 }
712 }
713
714 #[test]
715 fn a_point_behind_the_camera_draws_nowhere_and_has_no_scale() {
716 for projection in LENSES {
717 let camera = overhead(projection);
718 let behind = camera.view().eye() - looking(&camera) * 5.0;
719
720 assert_eq!(camera.pixel_of(behind, SIZE), None);
721 assert_eq!(camera.pixels_per_meter(behind, SIZE), None);
722 }
723 }
724
725 #[test]
726 fn two_points_a_meter_apart_across_the_view_draw_the_scale_apart() {
727 for projection in LENSES {
728 let camera = overhead(projection);
729 let across = looking(&camera).cross(camera.view().up()).normalize();
730 let point = camera.view().eye() + looking(&camera) * 8.0;
731
732 let (Some(scale), Some(here), Some(there)) = (
733 camera.pixels_per_meter(point, SIZE),
734 camera.pixel_of(point, SIZE),
735 camera.pixel_of(point + across, SIZE),
736 ) else {
737 panic!("{point} is in front of a {projection:?} camera");
738 };
739
740 assert!(
741 ((there - here).length() - scale).abs() < 0.05,
742 "a meter draws {} pixels across, against a scale of {scale}",
743 (there - here).length()
744 );
745 }
746 }
747
748 #[test]
749 fn depth_shrinks_the_scale_of_a_foreshortened_view_and_leaves_a_flat_one_alone() {
750 for projection in LENSES {
751 let camera = overhead(projection);
752 let [near, far] = [5.0, 20.0].map(|depth| {
753 camera.pixels_per_meter(camera.view().eye() + looking(&camera) * depth, SIZE)
754 });
755
756 let (Some(near), Some(far)) = (near, far) else {
757 panic!("both depths lie in front of a {projection:?} camera");
758 };
759
760 match projection.lens() {
761 Lens::Perspective { .. } => {
762 assert!(far < near, "{far} at 20 meters is not under {near} at 5")
763 }
764 Lens::Orthographic { .. } => assert_eq!(near, far),
765 }
766 }
767 }
768
769 #[test]
770 fn a_shifted_camera_draws_the_point_at_the_pixel_it_was_given_and_looks_the_same_way() {
771 for projection in LENSES {
772 let camera = overhead(projection);
773 let point = Vec3::new(2.0, 0.0, -1.0);
774
775 for lands_at in PIXELS {
776 let Some(shifted) = camera.shifted_so(point, lands_at, SIZE) else {
777 panic!("{point} draws under a {projection:?} camera");
778 };
779
780 let drawn = shifted.pixel_of(point, SIZE);
781 assert!(
782 drawn.is_some_and(|drawn| drawn.abs_diff_eq(lands_at, 0.01)),
783 "given {lands_at}, drew at {drawn:?}"
784 );
785 assert!(
786 looking(&shifted).abs_diff_eq(looking(&camera), 1e-6),
787 "and the camera turned to {:?}",
788 looking(&shifted)
789 );
790 assert_eq!(shifted.projection(), camera.projection());
791 }
792 }
793 }
794
795 #[test]
796 fn a_camera_zoomed_about_a_point_keeps_its_pixel_and_halves_what_the_view_covers() {
797 for projection in LENSES {
798 let camera = overhead(projection);
799 let point = Vec3::new(2.0, 0.0, -1.0);
800
801 let (Some(before), Some(zoomed)) = (
802 camera.pixel_of(point, SIZE),
803 camera.zoomed_about(point, 2.0, SIZE),
804 ) else {
805 panic!("{point} draws under a {projection:?} camera");
806 };
807
808 let drawn = zoomed.pixel_of(point, SIZE);
809 assert!(
810 drawn.is_some_and(|drawn| drawn.abs_diff_eq(before, 0.01)),
811 "{point} drew at {before} and now draws at {drawn:?}"
812 );
813 assert!(
814 looking(&zoomed).abs_diff_eq(looking(&camera), 1e-6),
815 "and the camera turned to {:?}",
816 looking(&zoomed)
817 );
818
819 match (camera.projection().lens(), zoomed.projection().lens()) {
820 (Lens::Perspective { fov_degrees }, Lens::Perspective { fov_degrees: same }) => {
821 let (was, now) = (
822 camera.view().eye().distance(point),
823 zoomed.view().eye().distance(point),
824 );
825 assert!(
826 (now - was / 2.0).abs() < 1e-4,
827 "{was} meters away became {now}"
828 );
829 assert_eq!(fov_degrees, same, "over the same field of view");
830 }
831 (
832 Lens::Orthographic { world_height },
833 Lens::Orthographic {
834 world_height: halved,
835 },
836 ) => {
837 assert!(
838 (halved - world_height / 2.0).abs() < 1e-4,
839 "{world_height} meters of world became {halved}"
840 );
841 let along = looking(&camera);
842 assert!(
843 (zoomed.view().eye() - camera.view().eye()).dot(along).abs() < 1e-4,
844 "and the eye kept its depth"
845 );
846 }
847 (lens, zoomed) => panic!("{lens:?} zoomed to a {zoomed:?}"),
848 }
849 }
850 }
851
852 fn covered(camera: &Camera) -> f32 {
855 match camera.projection().lens() {
856 Lens::Perspective { fov_degrees } => fov_degrees,
857 Lens::Orthographic { world_height } => world_height,
858 }
859 }
860
861 #[test]
862 fn every_camera_a_zoom_returns_still_draws_the_point_it_was_zoomed_about() {
863 let point = Vec3::new(2.0, 0.0, -1.0);
864 let factors = [
865 f32::MIN_POSITIVE,
866 1e-38,
867 1e-30,
868 1e-20,
869 1e-12,
870 1e-8,
871 1e-6,
872 0.5,
873 1.0,
874 2.0,
875 1e6,
876 ];
877
878 for projection in LENSES {
879 let camera = overhead(projection);
880
881 for factor in factors {
882 let Some(zoomed) = camera.zoomed_about(point, factor, SIZE) else {
883 continue;
884 };
885
886 assert!(
887 zoomed.pixel_of(point, SIZE).is_some(),
888 "a {projection:?} camera zoomed by {factor} draws nothing"
889 );
890 assert!(
891 zoomed.view().eye().is_finite() && zoomed.view().target().is_finite(),
892 "and sits at {:?} looking at {:?}",
893 zoomed.view().eye(),
894 zoomed.view().target()
895 );
896 let shape = covered(&zoomed);
897 assert!(
898 shape.is_finite() && shape > 0.0,
899 "and its lens is shaped by {shape}"
900 );
901 }
902 assert!(camera.zoomed_about(point, 2.0, SIZE).is_some());
903 }
904 }
905
906 #[test]
907 fn a_zoom_out_doubles_what_the_view_covers_and_a_zoom_of_one_changes_nothing() {
908 for projection in LENSES {
909 let camera = overhead(projection);
910 let point = Vec3::new(2.0, 0.0, -1.0);
911
912 let (Some(before), Some(out)) = (
913 camera.pixel_of(point, SIZE),
914 camera.zoomed_about(point, 0.5, SIZE),
915 ) else {
916 panic!("{point} draws under a {projection:?} camera");
917 };
918
919 let drawn = out.pixel_of(point, SIZE);
920 assert!(
921 drawn.is_some_and(|drawn| drawn.abs_diff_eq(before, 0.01)),
922 "{point} drew at {before} and now draws at {drawn:?}"
923 );
924 match projection.lens() {
925 Lens::Perspective { .. } => {
926 let (was, now) = (
927 camera.view().eye().distance(point),
928 out.view().eye().distance(point),
929 );
930 assert!((now - was * 2.0).abs() < 1e-3, "{was} meters became {now}");
931 }
932 Lens::Orthographic { world_height } => assert!(
933 (covered(&out) - world_height * 2.0).abs() < 1e-3,
934 "{world_height} meters of world became {}",
935 covered(&out)
936 ),
937 }
938
939 assert_eq!(
940 camera.zoomed_about(point, 1.0, SIZE),
941 Some(camera),
942 "and a zoom of one leaves the camera where it was"
943 );
944 }
945 }
946
947 #[test]
948 fn a_camera_turned_about_a_point_draws_it_at_the_pixel_it_drew_at() {
949 for projection in LENSES {
950 let camera = overhead(projection);
951 let point = Vec3::new(2.0, 0.0, -1.0);
952 let Some(before) = camera.pixel_of(point, SIZE) else {
953 panic!("{point} draws under a {projection:?} camera");
954 };
955
956 for (yaw, pitch) in [(0.5, 0.0), (0.0, 0.3), (-1.2, 0.4), (3.0, -0.6)] {
957 let Some(turned) = camera.turned_about(point, yaw, pitch) else {
958 panic!("a turn of {yaw} and {pitch} keeps {point} in front of the camera");
959 };
960
961 let drawn = turned.pixel_of(point, SIZE);
962 assert!(
963 drawn.is_some_and(|drawn| drawn.abs_diff_eq(before, 0.05)),
964 "{point} drew at {before} and, turned by {yaw} and {pitch}, draws at {drawn:?}"
965 );
966 assert!(
967 (turned.view().eye().distance(point) - camera.view().eye().distance(point))
968 .abs()
969 < 1e-3,
970 "and it turned to {} meters from {} away",
971 turned.view().eye().distance(point),
972 camera.view().eye().distance(point)
973 );
974 assert_eq!(turned.projection(), camera.projection());
975 }
976 }
977 }
978
979 #[test]
980 fn a_turn_keeps_the_point_at_its_pixel_under_a_view_with_an_up_of_its_own() {
981 let tilted = Camera::new(
982 View::look_at(Vec3::new(0.0, 10.0, 10.0), Vec3::ZERO)
983 .with_up(Vec3::new(0.3, 1.0, 0.0).normalize()),
984 Projection::perspective(60.0),
985 );
986 let point = Vec3::new(2.0, 0.0, -1.0);
987 let Some(before) = tilted.pixel_of(point, SIZE) else {
988 panic!("{point} draws under a camera holding its own up");
989 };
990
991 for (yaw, pitch) in [(0.7, 0.0), (0.0, 0.4), (-1.1, 0.25)] {
992 let Some(turned) = tilted.turned_about(point, yaw, pitch) else {
993 panic!("a turn of {yaw} and {pitch} keeps {point} in front of the camera");
994 };
995
996 let drawn = turned.pixel_of(point, SIZE);
997 assert!(
998 drawn.is_some_and(|drawn| drawn.abs_diff_eq(before, 0.05)),
999 "{point} drew at {before} and, turned by {yaw} and {pitch}, draws at {drawn:?}"
1000 );
1001 }
1002 }
1003
1004 #[test]
1005 fn a_turn_of_a_whole_circle_returns_the_camera_it_started_from() {
1006 for projection in LENSES {
1007 let camera = overhead(projection);
1008 let point = Vec3::new(2.0, 0.0, -1.0);
1009
1010 let Some(turned) = camera.turned_about(point, core::f32::consts::TAU, 0.0) else {
1011 panic!("a whole circle about {point} is a turn a {projection:?} camera takes");
1012 };
1013
1014 assert!(
1015 turned.view().eye().abs_diff_eq(camera.view().eye(), 1e-4),
1016 "the eye came back to {:?} from {:?}",
1017 turned.view().eye(),
1018 camera.view().eye()
1019 );
1020 assert!(
1021 turned
1022 .view()
1023 .target()
1024 .abs_diff_eq(camera.view().target(), 1e-4),
1025 "and looks at {:?}",
1026 turned.view().target()
1027 );
1028 assert_eq!(turned.projection(), camera.projection());
1029 }
1030 }
1031
1032 #[test]
1033 fn a_quarter_turn_moves_the_eye_a_quarter_of_the_way_about_the_point() {
1034 let camera = overhead(Projection::perspective(60.0));
1035 let point = Vec3::new(2.0, 0.0, -1.0);
1036
1037 let Some(turned) = camera.turned_about(point, core::f32::consts::FRAC_PI_2, 0.0) else {
1038 panic!("a quarter turn about {point} is a turn this camera takes");
1039 };
1040
1041 let (was, now) = (camera.view().eye() - point, turned.view().eye() - point);
1042 let flat = |offset: Vec3| Vec2::new(offset.x, offset.z);
1043
1044 assert!(
1045 (now.length() - was.length()).abs() < 1e-4,
1046 "{} meters out became {}",
1047 was.length(),
1048 now.length()
1049 );
1050 assert!((now.y - was.y).abs() < 1e-4, "and left the height alone");
1051 assert!(
1052 (flat(now).angle_to(flat(was)).abs() - core::f32::consts::FRAC_PI_2).abs() < 1e-4,
1053 "a quarter of the way about {point}, not {} radians",
1054 flat(now).angle_to(flat(was))
1055 );
1056 }
1057
1058 #[test]
1059 fn a_turn_that_takes_the_view_past_the_up_direction_returns_no_camera() {
1060 let level = Camera::new(
1061 View::look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::ZERO),
1062 Projection::perspective(60.0),
1063 );
1064 let short_of = core::f32::consts::FRAC_PI_2 - 0.1;
1065
1066 for pitch in [short_of, -short_of] {
1067 assert!(
1068 level.turned_about(Vec3::ZERO, 0.0, pitch).is_some(),
1069 "{pitch} radians leaves the view under the pole"
1070 );
1071 }
1072 for pitch in [
1073 short_of + 0.2,
1074 -short_of - 0.2,
1075 core::f32::consts::PI,
1076 -core::f32::consts::PI,
1077 ] {
1078 assert_eq!(
1079 level.turned_about(Vec3::ZERO, 0.0, pitch),
1080 None,
1081 "{pitch} radians takes it past the pole"
1082 );
1083 }
1084 }
1085
1086 #[test]
1087 fn a_turn_about_a_point_the_camera_does_not_draw_returns_no_camera() {
1088 for projection in LENSES {
1089 let camera = overhead(projection);
1090 let behind = camera.view().eye() - looking(&camera) * 5.0;
1091 let nowhere = Camera::new(View::look_at(Vec3::ZERO, Vec3::ZERO), projection);
1092
1093 assert_eq!(camera.turned_about(behind, 0.5, 0.0), None);
1094 assert_eq!(camera.turned_about(camera.view().eye(), 0.5, 0.0), None);
1095 assert_eq!(nowhere.turned_about(Vec3::NEG_Z, 0.5, 0.0), None);
1096
1097 for angle in [f32::NAN, f32::INFINITY] {
1098 assert_eq!(camera.turned_about(Vec3::ZERO, angle, 0.0), None, "{angle}");
1099 assert_eq!(camera.turned_about(Vec3::ZERO, 0.0, angle), None, "{angle}");
1100 }
1101 }
1102 }
1103
1104 #[test]
1105 fn a_shift_that_would_leave_the_point_undrawn_returns_no_camera() {
1106 for projection in LENSES {
1107 let camera = overhead(projection);
1108
1109 for far in [1e20, 1e25, 1e30, 1e38] {
1110 assert_eq!(
1111 camera.shifted_so(Vec3::ZERO, Vec2::splat(far), SIZE),
1112 None,
1113 "{far} pixels out of a {projection:?} camera"
1114 );
1115 }
1116 }
1117
1118 let wide = Camera::new(
1119 View::look_at(Vec3::new(0.0, 10.0, 10.0), Vec3::ZERO),
1120 Projection::orthographic(f32::MAX),
1121 );
1122
1123 assert!(
1124 wide.pixel_of(Vec3::ZERO, SIZE).is_some()
1125 && wide.pixels_per_meter(Vec3::ZERO, SIZE).is_some(),
1126 "a camera that draws on its own"
1127 );
1128 assert_eq!(wide.shifted_so(Vec3::ZERO, Vec2::splat(1e30), SIZE), None);
1129 }
1130
1131 #[test]
1132 fn neither_move_returns_a_camera_where_no_pixel_is_drawn_or_a_number_is_not_finite() {
1133 for projection in LENSES {
1134 let camera = overhead(projection);
1135 let behind = camera.view().eye() - looking(&camera) * 5.0;
1136 let nowhere = Camera::new(View::look_at(Vec3::ZERO, Vec3::ZERO), projection);
1137 let middle = SIZE.as_vec2() / 2.0;
1138
1139 assert_eq!(camera.shifted_so(behind, middle, SIZE), None);
1140 assert_eq!(camera.zoomed_about(behind, 2.0, SIZE), None);
1141 assert_eq!(nowhere.shifted_so(Vec3::NEG_Z, middle, SIZE), None);
1142 assert_eq!(nowhere.zoomed_about(Vec3::NEG_Z, 2.0, SIZE), None);
1143
1144 for size in [UVec2::ZERO, UVec2::new(1280, 0), UVec2::new(0, 720)] {
1145 assert_eq!(camera.shifted_so(Vec3::ZERO, middle, size), None, "{size}");
1146 assert_eq!(camera.zoomed_about(Vec3::ZERO, 2.0, size), None, "{size}");
1147 }
1148
1149 for lands_at in [Vec2::NAN, Vec2::INFINITY, Vec2::new(0.0, f32::NAN)] {
1150 assert_eq!(camera.shifted_so(Vec3::ZERO, lands_at, SIZE), None);
1151 }
1152 for factor in [0.0, -1.0, f32::NAN, f32::INFINITY] {
1153 assert_eq!(camera.zoomed_about(Vec3::ZERO, factor, SIZE), None);
1154 }
1155 }
1156 }
1157
1158 #[test]
1159 fn a_surface_with_no_area_and_a_view_with_no_direction_draw_no_pixel() {
1160 for projection in LENSES {
1161 let camera = overhead(projection);
1162 let nowhere = Camera::new(View::look_at(Vec3::ZERO, Vec3::ZERO), projection);
1163
1164 for size in [UVec2::ZERO, UVec2::new(1280, 0), UVec2::new(0, 720)] {
1165 assert_eq!(camera.pixel_of(Vec3::ZERO, size), None, "{size}");
1166 assert_eq!(camera.pixels_per_meter(Vec3::ZERO, size), None, "{size}");
1167 }
1168 assert_eq!(nowhere.pixel_of(Vec3::NEG_Z, SIZE), None);
1169 assert_eq!(nowhere.pixels_per_meter(Vec3::NEG_Z, SIZE), None);
1170 }
1171 }
1172}