mirage-engine 0.1.1

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
//! The ray a pixel lies on, and the shapes a game can hit with it.

use crate::math::Vec3;

/// A point in the world and the direction it looks.
///
/// [`Camera::ray_through`](crate::Camera::ray_through) builds one from a
/// pixel; hit it against whatever shapes a game keeps to see what that
/// pixel points at.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Ray {
    origin: Vec3,
    direction: Vec3,
}

impl Ray {
    /// A ray from `origin` along `direction`, which is kept one unit long so
    /// that every hit is a distance in meters.
    pub fn new(origin: Vec3, direction: Vec3) -> Self {
        Self {
            origin,
            direction: direction.normalize_or_zero(),
        }
    }

    /// Ray origin.
    pub const fn origin(&self) -> Vec3 {
        self.origin
    }

    /// The direction it points, one unit long.
    pub const fn direction(&self) -> Vec3 {
        self.direction
    }

    /// The point `t` meters along the ray.
    pub fn at(&self, t: f32) -> Vec3 {
        self.origin + self.direction * t
    }

    /// Distance in meters along the ray to `plane`; `None` where the ray is
    /// parallel to it or points away from it.
    pub fn hit_plane(&self, plane: Plane) -> Option<f32> {
        reached((plane.point - self.origin).dot(plane.normal) / self.direction.dot(plane.normal))
    }

    /// Distance in meters along the ray to the sphere of `radius` meters
    /// around `center`; `None` where the ray never intersects it.
    ///
    /// A ray that starts within one returns where it leaves.
    pub fn hit_sphere(&self, center: Vec3, radius: f32) -> Option<f32> {
        let to_center = center - self.origin;
        let length_squared = self.direction.length_squared();
        let along = to_center.dot(self.direction);
        let half_chord = (along * along
            - length_squared * (to_center.length_squared() - radius * radius))
            .sqrt();

        reached((along - half_chord) / length_squared)
            .or_else(|| reached((along + half_chord) / length_squared))
    }

    /// Distance in meters along the ray to the bounds between `min` and
    /// `max`; `None` where the ray never intersects them.
    ///
    /// A ray that starts within them returns where it leaves.
    pub fn hit_aabb(&self, min: Vec3, max: Vec3) -> Option<f32> {
        let (to_min, to_max) = (
            (min - self.origin) / self.direction,
            (max - self.origin) / self.direction,
        );
        let (entry, exit) = (
            to_min.min(to_max).max_element(),
            to_min.max(to_max).min_element(),
        );

        if entry > exit {
            return None;
        }
        reached(entry).or_else(|| reached(exit))
    }
}

/// A plane in the world, defined by a point on it and its normal.
///
/// [`Ray::hit_plane`] takes one; named fields keep point and normal in a
/// fixed order. Spelled `ray::Plane`: the `Plane` beside it in
/// `mirage_engine::prelude` is the primitive mesh.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Plane {
    /// A point the plane passes through.
    pub point: Vec3,
    /// Direction the plane faces.
    pub normal: Vec3,
}

/// The distance `t`, where the ray extends that far: never behind it, and
/// never a value the arithmetic left not finite.
fn reached(t: f32) -> Option<f32> {
    (t >= 0.0 && t.is_finite()).then_some(t)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The ray every hit is taken along: from the origin down the `+X` axis.
    fn along_x() -> Ray {
        Ray::new(Vec3::ZERO, Vec3::X)
    }

    #[test]
    fn a_ray_is_measured_in_meters_whatever_it_was_built_from() {
        let ray = Ray::new(Vec3::Y, Vec3::X * 8.0);

        assert_eq!(ray.origin(), Vec3::Y);
        assert_eq!(ray.direction(), Vec3::X);
        assert_eq!(ray.at(3.0), Vec3::new(3.0, 1.0, 0.0));
    }

    #[test]
    fn a_plane_is_hit_from_either_side_and_never_behind() {
        let down = Ray::new(Vec3::Y * 5.0, Vec3::NEG_Y);

        assert_eq!(
            down.hit_plane(Plane {
                point: Vec3::ZERO,
                normal: Vec3::Y
            }),
            Some(5.0)
        );
        assert_eq!(
            down.hit_plane(Plane {
                point: Vec3::ZERO,
                normal: Vec3::NEG_Y
            }),
            Some(5.0),
            "a plane faces both ways"
        );
        assert_eq!(
            down.hit_plane(Plane {
                point: Vec3::Y * 5.0,
                normal: Vec3::Y
            }),
            Some(0.0),
            "a ray that starts on one hits it at once"
        );
        assert_eq!(
            down.hit_plane(Plane {
                point: Vec3::Y * 9.0,
                normal: Vec3::Y
            }),
            None,
            "the plane behind it is missed"
        );
    }

    #[test]
    fn a_plane_the_ray_cannot_reach_is_never_hit() {
        let across = along_x();

        assert_eq!(
            across.hit_plane(Plane {
                point: Vec3::ZERO,
                normal: Vec3::Y
            }),
            None,
            "parallel"
        );
        assert_eq!(
            across.hit_plane(Plane {
                point: Vec3::ZERO,
                normal: Vec3::ZERO
            }),
            None,
            "a plane with no normal is no plane"
        );
        assert_eq!(
            Ray::new(Vec3::ZERO, Vec3::ZERO).hit_plane(Plane {
                point: Vec3::X,
                normal: Vec3::X
            }),
            None,
            "a ray with no direction reaches nothing"
        );
    }

    #[test]
    fn a_sphere_is_hit_at_its_near_side_and_left_from_within() {
        let ray = along_x();

        assert_eq!(ray.hit_sphere(Vec3::X * 10.0, 2.0), Some(8.0));
        assert_eq!(
            ray.hit_sphere(Vec3::ZERO, 2.0),
            Some(2.0),
            "from within, where it leaves"
        );
        assert_eq!(ray.hit_sphere(Vec3::NEG_X * 10.0, 2.0), None, "behind");
        assert_eq!(
            ray.hit_sphere(Vec3::new(10.0, 3.0, 0.0), 2.0),
            None,
            "past it"
        );
    }

    #[test]
    fn a_sphere_the_ray_grazes_or_that_has_no_size_still_answers_a_distance() {
        let ray = along_x();

        assert_eq!(ray.hit_sphere(Vec3::new(5.0, 2.0, 0.0), 2.0), Some(5.0));
        assert!(
            ray.hit_sphere(Vec3::X * 5.0, 0.0)
                .is_none_or(|t| t.is_finite()),
            "a sphere of no size is a point on the ray or nothing at all"
        );
        assert_eq!(
            ray.hit_sphere(Vec3::X * 5.0, -2.0),
            Some(3.0),
            "one turned inside out is the same sphere"
        );
    }

    #[test]
    fn bounds_are_hit_where_the_ray_enters_and_left_where_it_leaves() {
        let ray = along_x();
        let (min, max) = (Vec3::new(4.0, -1.0, -1.0), Vec3::new(6.0, 1.0, 1.0));

        assert_eq!(ray.hit_aabb(min, max), Some(4.0));
        assert_eq!(
            ray.hit_aabb(Vec3::splat(-1.0), Vec3::ONE),
            Some(1.0),
            "from within, where it leaves"
        );
        assert_eq!(ray.hit_aabb(max, min), Some(4.0), "however they are given");
        assert_eq!(ray.hit_aabb(-max, -min), None, "behind");
        assert_eq!(
            ray.hit_aabb(min + Vec3::Y * 5.0, max + Vec3::Y * 5.0),
            None,
            "above"
        );
    }

    #[test]
    fn bounds_with_no_thickness_are_hit_head_on() {
        let down = Ray::new(Vec3::Y, Vec3::NEG_Y);
        let (min, max) = (Vec3::new(-1.0, 0.0, -1.0), Vec3::new(1.0, 0.0, 1.0));

        assert_eq!(down.hit_aabb(min, max), Some(1.0));
        assert!(
            down.hit_aabb(Vec3::ZERO, Vec3::ZERO)
                .is_none_or(|t| t.is_finite()),
            "bounds of no size at all are a point, or nothing"
        );
    }

    #[test]
    fn a_ray_with_no_direction_reaches_nothing() {
        let still = Ray::new(Vec3::ZERO, Vec3::ZERO);
        let hits = [
            still.hit_plane(Plane {
                point: Vec3::X,
                normal: Vec3::X,
            }),
            still.hit_sphere(Vec3::ZERO, 2.0),
            still.hit_sphere(Vec3::X * 5.0, 2.0),
            still.hit_aabb(Vec3::splat(-1.0), Vec3::ONE),
        ];

        assert!(hits.iter().all(Option::is_none), "not even what it sits in");
    }
}