Skip to main content

concinnity_core/gfx/
pick.rs

1//! Pure picking math: a world-space ray through a window pixel, and a
2//! ray-vs-AABB intersection test. Consumed by the editor's viewport picking;
3//! nothing here touches a backend or the ECS.
4
5use crate::math::{sqrt, tan};
6
7/// A world-space ray: `origin` plus a normalized direction.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct PickRay {
10    /// World-space ray origin.
11    pub origin: [f32; 3],
12    /// Unit-length ray direction.
13    pub dir: [f32; 3],
14}
15
16/// The world-space ray through window pixel `mouse` (top-left origin, logical
17/// pixels, as sampled into `FrameInput`), for a camera described by its
18/// world-to-view matrix (the [view_matrix](#method.view_matrix) convention:
19/// column-major, right-handed, view-space forward is -Z), its world position,
20/// and its vertical field of view. Uses the un-jittered projection: TAA jitter
21/// is a backend concern and must never skew a pick.
22///
23/// Returns `None` for a degenerate viewport (either extent zero or negative,
24/// e.g. frame 0 before the backend reports a size).
25pub fn screen_ray(
26    view: &[[f32; 4]; 4],
27    cam_pos: [f32; 3],
28    fov_y_radians: f32,
29    viewport: [f32; 2],
30    mouse: [f32; 2],
31) -> Option<PickRay> {
32    let (vw, vh) = (viewport[0], viewport[1]);
33    if vw <= 0.0 || vh <= 0.0 || !vw.is_finite() || !vh.is_finite() {
34        return None;
35    }
36    let ndc_x = 2.0 * mouse[0] / vw - 1.0;
37    let ndc_y = 1.0 - 2.0 * mouse[1] / vh;
38    let tan_half = tan(fov_y_radians * 0.5);
39    if tan_half <= 0.0 || !tan_half.is_finite() {
40        return None;
41    }
42    let aspect = vw / vh;
43
44    // View-space direction of the pixel at unit depth (forward is -Z).
45    let d = [ndc_x * tan_half * aspect, ndc_y * tan_half, -1.0];
46    // The view rotation is orthonormal, so its inverse is the transpose:
47    // world = R^T * d. Column-major view[c][r] makes that a per-column dot.
48    let world = [
49        view[0][0] * d[0] + view[0][1] * d[1] + view[0][2] * d[2],
50        view[1][0] * d[0] + view[1][1] * d[1] + view[1][2] * d[2],
51        view[2][0] * d[0] + view[2][1] * d[1] + view[2][2] * d[2],
52    ];
53    let len = sqrt(world[0] * world[0] + world[1] * world[1] + world[2] * world[2]);
54    if len <= 0.0 || !len.is_finite() {
55        return None;
56    }
57    Some(PickRay {
58        origin: cam_pos,
59        dir: [world[0] / len, world[1] / len, world[2] / len],
60    })
61}
62
63/// The face a ray enters a world-space AABB through: the entry distance `t`,
64/// the axis index of the face's plane (0 = X, 1 = Y, 2 = Z), and the outward
65/// sign of the face normal along that axis (`+1.0` / `-1.0`). The normal is
66/// `sign` on `axis` and zero elsewhere -- an AABB face normal, not a surface
67/// normal of the geometry inside the box.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct AabbFace {
70    /// Ray parameter at the hit, in `dir` units from `origin`.
71    pub t: f32,
72    /// Which axis the hit face is perpendicular to: 0 = X, 1 = Y, 2 = Z.
73    pub axis: usize,
74    /// Which side of that axis was hit: -1 or +1.
75    pub sign: f32,
76}
77
78/// [ray_aabb](#method.ray_aabb) keeping the entered face. For a ray starting
79/// inside the box `t` is 0 and the face is the last slab that bounded the
80/// entry interval; a degenerate all-zero direction inside the box reports
81/// `sign` 0 (no face was crossed).
82pub fn ray_aabb_face(ray: &PickRay, bb_min: [f32; 3], bb_max: [f32; 3]) -> Option<AabbFace> {
83    if !crate::gfx::lod::bounds_finite(bb_min, bb_max) {
84        return None;
85    }
86    let mut t_enter = f32::NEG_INFINITY;
87    let mut t_exit = f32::INFINITY;
88    let mut enter_axis = None;
89    for i in 0..3 {
90        if ray.dir[i] == 0.0 {
91            // Parallel to this slab: inside it or a clean miss, with no
92            // division (0 * inf would poison the interval with NaN).
93            if ray.origin[i] < bb_min[i] || ray.origin[i] > bb_max[i] {
94                return None;
95            }
96            continue;
97        }
98        let inv = 1.0 / ray.dir[i];
99        let (t1, t2) = (
100            (bb_min[i] - ray.origin[i]) * inv,
101            (bb_max[i] - ray.origin[i]) * inv,
102        );
103        let (near, far) = if t1 <= t2 { (t1, t2) } else { (t2, t1) };
104        if near > t_enter {
105            t_enter = near;
106            enter_axis = Some(i);
107        }
108        t_exit = t_exit.min(far);
109        if t_enter > t_exit {
110            return None;
111        }
112    }
113    if t_exit < 0.0 {
114        return None;
115    }
116    let (axis, sign) = match enter_axis {
117        // The entered face opposes the ray on its axis.
118        Some(a) => (a, -ray.dir[a].signum()),
119        None => (0, 0.0),
120    };
121    Some(AabbFace {
122        t: t_enter.max(0.0),
123        axis,
124        sign,
125    })
126}
127
128/// The distance along `ray` to the world-space AABB `[bb_min, bb_max]`, via
129/// the slab test. `Some(0.0)` when the ray starts inside the box; `None` when
130/// the ray misses, the box is entirely behind the origin, or the box is not
131/// finite (the renderer's non-cullable sentinel).
132pub fn ray_aabb(ray: &PickRay, bb_min: [f32; 3], bb_max: [f32; 3]) -> Option<f32> {
133    ray_aabb_face(ray, bb_min, bb_max).map(|f| f.t)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::gfx::camera::view_matrix;
140
141    const VP: [f32; 2] = [1280.0, 720.0];
142    const FOV: f32 = core::f32::consts::FRAC_PI_2;
143
144    fn assert_dir(ray: PickRay, expect: [f32; 3]) {
145        for i in 0..3 {
146            assert!(
147                (ray.dir[i] - expect[i]).abs() < 1e-5,
148                "dir {:?} != {expect:?}",
149                ray.dir
150            );
151        }
152    }
153
154    #[test]
155    fn center_pixel_rays_along_camera_forward() {
156        // Yaw 0, pitch 0 faces -Z (the view_matrix convention).
157        let view = view_matrix([1.0, 2.0, 3.0], 0.0, 0.0);
158        let ray = screen_ray(&view, [1.0, 2.0, 3.0], FOV, VP, [640.0, 360.0]).unwrap();
159        assert_eq!(ray.origin, [1.0, 2.0, 3.0]);
160        assert_dir(ray, [0.0, 0.0, -1.0]);
161
162        // Yaw pi/2 faces -X.
163        let view = view_matrix([0.0; 3], core::f32::consts::FRAC_PI_2, 0.0);
164        let ray = screen_ray(&view, [0.0; 3], FOV, VP, [640.0, 360.0]).unwrap();
165        assert_dir(ray, [-1.0, 0.0, 0.0]);
166
167        // Pitch pi/2 faces straight up.
168        let view = view_matrix([0.0; 3], 0.0, core::f32::consts::FRAC_PI_2);
169        let ray = screen_ray(&view, [0.0; 3], FOV, VP, [640.0, 360.0]).unwrap();
170        assert_dir(ray, [0.0, 1.0, 0.0]);
171    }
172
173    #[test]
174    fn edge_pixels_span_the_field_of_view() {
175        let view = view_matrix([0.0; 3], 0.0, 0.0);
176        // Top-center pixel: the vertical view angle is fov/2 above forward.
177        let top = screen_ray(&view, [0.0; 3], FOV, VP, [640.0, 0.0]).unwrap();
178        let vert_tan = top.dir[1] / -top.dir[2];
179        assert!((vert_tan - (FOV * 0.5).tan()).abs() < 1e-4, "{vert_tan}");
180        assert!(top.dir[0].abs() < 1e-6);
181
182        // Right-center pixel: the horizontal half-angle is scaled by aspect.
183        let right = screen_ray(&view, [0.0; 3], FOV, VP, [1280.0, 360.0]).unwrap();
184        let horiz_tan = right.dir[0] / -right.dir[2];
185        let expect = (FOV * 0.5).tan() * (VP[0] / VP[1]);
186        assert!((horiz_tan - expect).abs() < 1e-4, "{horiz_tan} vs {expect}");
187        assert!(right.dir[1].abs() < 1e-6);
188    }
189
190    #[test]
191    fn degenerate_viewport_or_fov_yields_no_ray() {
192        let view = view_matrix([0.0; 3], 0.0, 0.0);
193        assert_eq!(
194            screen_ray(&view, [0.0; 3], FOV, [0.0, 720.0], [0.0; 2]),
195            None
196        );
197        assert_eq!(
198            screen_ray(&view, [0.0; 3], FOV, [1280.0, 0.0], [0.0; 2]),
199            None
200        );
201        assert_eq!(screen_ray(&view, [0.0; 3], 0.0, VP, [0.0; 2]), None);
202    }
203
204    fn ray(origin: [f32; 3], dir: [f32; 3]) -> PickRay {
205        PickRay { origin, dir }
206    }
207
208    #[test]
209    fn ray_hits_a_box_at_the_entry_distance() {
210        let r = ray([0.0, 0.0, 5.0], [0.0, 0.0, -1.0]);
211        let t = ray_aabb(&r, [-1.0, -1.0, -1.0], [1.0, 1.0, 1.0]).unwrap();
212        assert!((t - 4.0).abs() < 1e-6, "{t}");
213    }
214
215    #[test]
216    fn ray_misses_beside_behind_and_non_finite_boxes() {
217        let (mn, mx) = ([-1.0, -1.0, -1.0], [1.0, 1.0, 1.0]);
218        // Beside: aimed parallel past the box.
219        assert_eq!(
220            ray_aabb(&ray([0.0, 3.0, 5.0], [0.0, 0.0, -1.0]), mn, mx),
221            None
222        );
223        // Behind: the box is entirely behind the ray origin.
224        assert_eq!(
225            ray_aabb(&ray([0.0, 0.0, 5.0], [0.0, 0.0, 1.0]), mn, mx),
226            None
227        );
228        // The renderer's non-cullable NaN sentinel is unpickable, not infinite.
229        assert_eq!(
230            ray_aabb(
231                &ray([0.0; 3], [0.0, 0.0, -1.0]),
232                [f32::NAN; 3],
233                [f32::NAN; 3]
234            ),
235            None
236        );
237    }
238
239    #[test]
240    fn ray_inside_a_box_hits_at_zero() {
241        let t = ray_aabb(
242            &ray([0.0; 3], [0.0, 1.0, 0.0]),
243            [-1.0, -1.0, -1.0],
244            [1.0, 1.0, 1.0],
245        )
246        .unwrap();
247        assert_eq!(t, 0.0);
248    }
249
250    #[test]
251    fn entered_face_reports_axis_and_outward_sign() {
252        let (mn, mx) = ([-1.0, -1.0, -1.0], [1.0, 1.0, 1.0]);
253        // Downward onto the top: the +Y face.
254        let f = ray_aabb_face(&ray([0.0, 5.0, 0.0], [0.0, -1.0, 0.0]), mn, mx).unwrap();
255        assert_eq!((f.axis, f.sign), (1, 1.0));
256        assert!((f.t - 4.0).abs() < 1e-6);
257        // From the left: the -X face.
258        let f = ray_aabb_face(&ray([-5.0, 0.0, 0.0], [1.0, 0.0, 0.0]), mn, mx).unwrap();
259        assert_eq!((f.axis, f.sign), (0, -1.0));
260        // A shallow diagonal still enters through the near +Z face first.
261        let f = ray_aabb_face(&ray([0.0, 0.5, 5.0], [0.0, -0.0995, -0.995]), mn, mx).unwrap();
262        assert_eq!((f.axis, f.sign), (2, 1.0));
263        // A zero direction inside the box has no face to report.
264        let f = ray_aabb_face(&ray([0.0; 3], [0.0; 3]), mn, mx).unwrap();
265        assert_eq!((f.t, f.sign), (0.0, 0.0));
266    }
267
268    #[test]
269    fn axis_parallel_rays_respect_the_perpendicular_slabs() {
270        let (mn, mx) = ([-1.0, -1.0, -1.0], [1.0, 1.0, 1.0]);
271        // Sliding along Y inside the X/Z slabs: a hit.
272        assert!(ray_aabb(&ray([0.5, -5.0, 0.5], [0.0, 1.0, 0.0]), mn, mx).is_some());
273        // Same direction but outside the X slab: a miss, not a NaN artifact.
274        assert_eq!(
275            ray_aabb(&ray([2.0, -5.0, 0.5], [0.0, 1.0, 0.0]), mn, mx),
276            None
277        );
278        // Origin exactly on a slab face with zero direction on that axis.
279        assert!(ray_aabb(&ray([1.0, -5.0, 0.0], [0.0, 1.0, 0.0]), mn, mx).is_some());
280    }
281}