Skip to main content

mirage_engine/
ray.rs

1//! The ray a pixel lies on, and the shapes a game can hit with it.
2
3use crate::math::Vec3;
4
5/// A point in the world and the direction it looks.
6///
7/// [`Camera::ray_through`](crate::Camera::ray_through) builds one from a
8/// pixel; hit it against whatever shapes a game keeps to see what that
9/// pixel points at.
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct Ray {
12    origin: Vec3,
13    direction: Vec3,
14}
15
16impl Ray {
17    /// A ray from `origin` along `direction`, which is kept one unit long so
18    /// that every hit is a distance in meters.
19    pub fn new(origin: Vec3, direction: Vec3) -> Self {
20        Self {
21            origin,
22            direction: direction.normalize_or_zero(),
23        }
24    }
25
26    /// Ray origin.
27    pub const fn origin(&self) -> Vec3 {
28        self.origin
29    }
30
31    /// The direction it points, one unit long.
32    pub const fn direction(&self) -> Vec3 {
33        self.direction
34    }
35
36    /// The point `t` meters along the ray.
37    pub fn at(&self, t: f32) -> Vec3 {
38        self.origin + self.direction * t
39    }
40
41    /// Distance in meters along the ray to `plane`; `None` where the ray is
42    /// parallel to it or points away from it.
43    pub fn hit_plane(&self, plane: Plane) -> Option<f32> {
44        reached((plane.point - self.origin).dot(plane.normal) / self.direction.dot(plane.normal))
45    }
46
47    /// Distance in meters along the ray to the sphere of `radius` meters
48    /// around `center`; `None` where the ray never intersects it.
49    ///
50    /// A ray that starts within one returns where it leaves.
51    pub fn hit_sphere(&self, center: Vec3, radius: f32) -> Option<f32> {
52        let to_center = center - self.origin;
53        let length_squared = self.direction.length_squared();
54        let along = to_center.dot(self.direction);
55        let half_chord = (along * along
56            - length_squared * (to_center.length_squared() - radius * radius))
57            .sqrt();
58
59        reached((along - half_chord) / length_squared)
60            .or_else(|| reached((along + half_chord) / length_squared))
61    }
62
63    /// Distance in meters along the ray to the bounds between `min` and
64    /// `max`; `None` where the ray never intersects them.
65    ///
66    /// A ray that starts within them returns where it leaves.
67    pub fn hit_aabb(&self, min: Vec3, max: Vec3) -> Option<f32> {
68        let (to_min, to_max) = (
69            (min - self.origin) / self.direction,
70            (max - self.origin) / self.direction,
71        );
72        let (entry, exit) = (
73            to_min.min(to_max).max_element(),
74            to_min.max(to_max).min_element(),
75        );
76
77        if entry > exit {
78            return None;
79        }
80        reached(entry).or_else(|| reached(exit))
81    }
82}
83
84/// A plane in the world, defined by a point on it and its normal.
85///
86/// [`Ray::hit_plane`] takes one; named fields keep point and normal in a
87/// fixed order. Spelled `ray::Plane`: the `Plane` beside it in
88/// `mirage_engine::prelude` is the primitive mesh.
89#[derive(Clone, Copy, Debug, PartialEq)]
90pub struct Plane {
91    /// A point the plane passes through.
92    pub point: Vec3,
93    /// Direction the plane faces.
94    pub normal: Vec3,
95}
96
97/// The distance `t`, where the ray extends that far: never behind it, and
98/// never a value the arithmetic left not finite.
99fn reached(t: f32) -> Option<f32> {
100    (t >= 0.0 && t.is_finite()).then_some(t)
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    /// The ray every hit is taken along: from the origin down the `+X` axis.
108    fn along_x() -> Ray {
109        Ray::new(Vec3::ZERO, Vec3::X)
110    }
111
112    #[test]
113    fn a_ray_is_measured_in_meters_whatever_it_was_built_from() {
114        let ray = Ray::new(Vec3::Y, Vec3::X * 8.0);
115
116        assert_eq!(ray.origin(), Vec3::Y);
117        assert_eq!(ray.direction(), Vec3::X);
118        assert_eq!(ray.at(3.0), Vec3::new(3.0, 1.0, 0.0));
119    }
120
121    #[test]
122    fn a_plane_is_hit_from_either_side_and_never_behind() {
123        let down = Ray::new(Vec3::Y * 5.0, Vec3::NEG_Y);
124
125        assert_eq!(
126            down.hit_plane(Plane {
127                point: Vec3::ZERO,
128                normal: Vec3::Y
129            }),
130            Some(5.0)
131        );
132        assert_eq!(
133            down.hit_plane(Plane {
134                point: Vec3::ZERO,
135                normal: Vec3::NEG_Y
136            }),
137            Some(5.0),
138            "a plane faces both ways"
139        );
140        assert_eq!(
141            down.hit_plane(Plane {
142                point: Vec3::Y * 5.0,
143                normal: Vec3::Y
144            }),
145            Some(0.0),
146            "a ray that starts on one hits it at once"
147        );
148        assert_eq!(
149            down.hit_plane(Plane {
150                point: Vec3::Y * 9.0,
151                normal: Vec3::Y
152            }),
153            None,
154            "the plane behind it is missed"
155        );
156    }
157
158    #[test]
159    fn a_plane_the_ray_cannot_reach_is_never_hit() {
160        let across = along_x();
161
162        assert_eq!(
163            across.hit_plane(Plane {
164                point: Vec3::ZERO,
165                normal: Vec3::Y
166            }),
167            None,
168            "parallel"
169        );
170        assert_eq!(
171            across.hit_plane(Plane {
172                point: Vec3::ZERO,
173                normal: Vec3::ZERO
174            }),
175            None,
176            "a plane with no normal is no plane"
177        );
178        assert_eq!(
179            Ray::new(Vec3::ZERO, Vec3::ZERO).hit_plane(Plane {
180                point: Vec3::X,
181                normal: Vec3::X
182            }),
183            None,
184            "a ray with no direction reaches nothing"
185        );
186    }
187
188    #[test]
189    fn a_sphere_is_hit_at_its_near_side_and_left_from_within() {
190        let ray = along_x();
191
192        assert_eq!(ray.hit_sphere(Vec3::X * 10.0, 2.0), Some(8.0));
193        assert_eq!(
194            ray.hit_sphere(Vec3::ZERO, 2.0),
195            Some(2.0),
196            "from within, where it leaves"
197        );
198        assert_eq!(ray.hit_sphere(Vec3::NEG_X * 10.0, 2.0), None, "behind");
199        assert_eq!(
200            ray.hit_sphere(Vec3::new(10.0, 3.0, 0.0), 2.0),
201            None,
202            "past it"
203        );
204    }
205
206    #[test]
207    fn a_sphere_the_ray_grazes_or_that_has_no_size_still_answers_a_distance() {
208        let ray = along_x();
209
210        assert_eq!(ray.hit_sphere(Vec3::new(5.0, 2.0, 0.0), 2.0), Some(5.0));
211        assert!(
212            ray.hit_sphere(Vec3::X * 5.0, 0.0)
213                .is_none_or(|t| t.is_finite()),
214            "a sphere of no size is a point on the ray or nothing at all"
215        );
216        assert_eq!(
217            ray.hit_sphere(Vec3::X * 5.0, -2.0),
218            Some(3.0),
219            "one turned inside out is the same sphere"
220        );
221    }
222
223    #[test]
224    fn bounds_are_hit_where_the_ray_enters_and_left_where_it_leaves() {
225        let ray = along_x();
226        let (min, max) = (Vec3::new(4.0, -1.0, -1.0), Vec3::new(6.0, 1.0, 1.0));
227
228        assert_eq!(ray.hit_aabb(min, max), Some(4.0));
229        assert_eq!(
230            ray.hit_aabb(Vec3::splat(-1.0), Vec3::ONE),
231            Some(1.0),
232            "from within, where it leaves"
233        );
234        assert_eq!(ray.hit_aabb(max, min), Some(4.0), "however they are given");
235        assert_eq!(ray.hit_aabb(-max, -min), None, "behind");
236        assert_eq!(
237            ray.hit_aabb(min + Vec3::Y * 5.0, max + Vec3::Y * 5.0),
238            None,
239            "above"
240        );
241    }
242
243    #[test]
244    fn bounds_with_no_thickness_are_hit_head_on() {
245        let down = Ray::new(Vec3::Y, Vec3::NEG_Y);
246        let (min, max) = (Vec3::new(-1.0, 0.0, -1.0), Vec3::new(1.0, 0.0, 1.0));
247
248        assert_eq!(down.hit_aabb(min, max), Some(1.0));
249        assert!(
250            down.hit_aabb(Vec3::ZERO, Vec3::ZERO)
251                .is_none_or(|t| t.is_finite()),
252            "bounds of no size at all are a point, or nothing"
253        );
254    }
255
256    #[test]
257    fn a_ray_with_no_direction_reaches_nothing() {
258        let still = Ray::new(Vec3::ZERO, Vec3::ZERO);
259        let hits = [
260            still.hit_plane(Plane {
261                point: Vec3::X,
262                normal: Vec3::X,
263            }),
264            still.hit_sphere(Vec3::ZERO, 2.0),
265            still.hit_sphere(Vec3::X * 5.0, 2.0),
266            still.hit_aabb(Vec3::splat(-1.0), Vec3::ONE),
267        ];
268
269        assert!(hits.iter().all(Option::is_none), "not even what it sits in");
270    }
271}