Skip to main content

anvilkit_render/renderer/
raycast.rs

1//! # 屏幕→世界射线投射
2//!
3//! 提供鼠标拾取和射线测试所需的数学工具函数。
4//!
5//! ## 功能
6//!
7//! - [`screen_to_ray`] — 将屏幕坐标转换为世界空间射线
8//! - [`ray_plane_intersection`] — 射线与水平平面相交测试
9//! - [`ray_sphere_intersection`] — 射线与球体相交测试
10//!
11//! ## 使用示例
12//!
13//! ```rust
14//! use anvilkit_render::renderer::raycast::*;
15//! use glam::{Vec2, Vec3, Mat4};
16//!
17//! // 从屏幕中心发射射线
18//! let view_proj = Mat4::perspective_lh(60f32.to_radians(), 16.0 / 9.0, 0.1, 100.0)
19//!     * Mat4::look_at_lh(Vec3::new(0.0, 5.0, -10.0), Vec3::ZERO, Vec3::Y);
20//! let (origin, dir) = screen_to_ray(Vec2::new(640.0, 360.0), Vec2::new(1280.0, 720.0), &view_proj);
21//!
22//! // 测试射线是否命中 y=0 平面
23//! if let Some(hit) = ray_plane_intersection(origin, dir, 0.0) {
24//!     println!("Hit at {:?}", hit);
25//! }
26//! ```
27
28use glam::{Mat4, Vec2, Vec3};
29
30/// 将屏幕坐标转换为世界空间射线
31///
32/// 通过反投影变换将 2D 屏幕坐标映射为 3D 世界空间的射线原点和方向。
33///
34/// # 参数
35///
36/// - `mouse_pos`: 鼠标屏幕坐标 (像素),左上角为 (0,0)
37/// - `window_size`: 窗口尺寸 (宽, 高) 像素
38/// - `view_proj`: 视图-投影矩阵 (projection * view)
39///
40/// # 返回
41///
42/// `(origin, direction)` — 射线起点和归一化方向向量
43///
44/// # 示例
45///
46/// ```rust
47/// use anvilkit_render::renderer::raycast::screen_to_ray;
48/// use glam::{Vec2, Vec3, Mat4};
49///
50/// let vp = Mat4::IDENTITY;
51/// let (origin, dir) = screen_to_ray(Vec2::new(640.0, 360.0), Vec2::new(1280.0, 720.0), &vp);
52/// ```
53pub fn screen_to_ray(mouse_pos: Vec2, window_size: Vec2, view_proj: &Mat4) -> (Vec3, Vec3) {
54    // Convert screen coordinates to NDC [-1, 1]
55    let ndc_x = 2.0 * mouse_pos.x / window_size.x - 1.0;
56    let ndc_y = 1.0 - 2.0 * mouse_pos.y / window_size.y;
57
58    let inv_vp = view_proj.inverse();
59
60    // Unproject near and far points
61    let near_clip = inv_vp * glam::Vec4::new(ndc_x, ndc_y, 0.0, 1.0);
62    let far_clip = inv_vp * glam::Vec4::new(ndc_x, ndc_y, 1.0, 1.0);
63
64    // Perspective divide
65    let near_world = Vec3::new(
66        near_clip.x / near_clip.w,
67        near_clip.y / near_clip.w,
68        near_clip.z / near_clip.w,
69    );
70    let far_world = Vec3::new(
71        far_clip.x / far_clip.w,
72        far_clip.y / far_clip.w,
73        far_clip.z / far_clip.w,
74    );
75
76    let direction = (far_world - near_world).normalize();
77    (near_world, direction)
78}
79
80/// 射线与水平平面相交测试
81///
82/// 测试从 `origin` 沿 `direction` 发射的射线是否与 y=`plane_y` 的水平平面相交。
83///
84/// # 参数
85///
86/// - `origin`: 射线起点
87/// - `direction`: 射线方向(应为归一化向量)
88/// - `plane_y`: 平面 Y 坐标
89///
90/// # 返回
91///
92/// `Some(hit_point)` — 交点的世界坐标,`None` — 射线与平面平行或交点在射线背后
93///
94/// # 示例
95///
96/// ```rust
97/// use anvilkit_render::renderer::raycast::ray_plane_intersection;
98/// use glam::Vec3;
99///
100/// let hit = ray_plane_intersection(
101///     Vec3::new(0.0, 10.0, 0.0),
102///     Vec3::new(0.0, -1.0, 0.0),
103///     0.0,
104/// );
105/// assert!(hit.is_some());
106/// ```
107pub fn ray_plane_intersection(origin: Vec3, direction: Vec3, plane_y: f32) -> Option<Vec3> {
108    // Avoid division by near-zero
109    if direction.y.abs() < 1e-7 {
110        return None;
111    }
112
113    let t = (plane_y - origin.y) / direction.y;
114    if t < 0.0 {
115        return None;
116    }
117
118    Some(origin + direction * t)
119}
120
121/// 射线与球体相交测试
122///
123/// 使用解析法测试射线是否与球体相交,返回最近交点的参数 t 值。
124///
125/// # 参数
126///
127/// - `origin`: 射线起点
128/// - `direction`: 射线方向(应为归一化向量)
129/// - `center`: 球体中心
130/// - `radius`: 球体半径
131///
132/// # 返回
133///
134/// `Some(t)` — 最近交点的参数值 (hit = origin + direction * t),`None` — 未命中
135///
136/// # 示例
137///
138/// ```rust
139/// use anvilkit_render::renderer::raycast::ray_sphere_intersection;
140/// use glam::Vec3;
141///
142/// let t = ray_sphere_intersection(
143///     Vec3::new(0.0, 0.0, -5.0),
144///     Vec3::new(0.0, 0.0, 1.0),
145///     Vec3::ZERO,
146///     1.0,
147/// );
148/// assert!(t.is_some());
149/// assert!((t.unwrap() - 4.0).abs() < 1e-5);
150/// ```
151pub fn ray_sphere_intersection(
152    origin: Vec3,
153    direction: Vec3,
154    center: Vec3,
155    radius: f32,
156) -> Option<f32> {
157    let oc = origin - center;
158    let a = direction.dot(direction);
159    let b = 2.0 * oc.dot(direction);
160    let c = oc.dot(oc) - radius * radius;
161    let discriminant = b * b - 4.0 * a * c;
162
163    if discriminant < 0.0 {
164        return None;
165    }
166
167    let sqrt_disc = discriminant.sqrt();
168    let inv_2a = 1.0 / (2.0 * a);
169
170    // Try the nearest intersection first
171    let t1 = (-b - sqrt_disc) * inv_2a;
172    if t1 >= 0.0 {
173        return Some(t1);
174    }
175
176    // If the nearest is behind, try the far intersection (ray starts inside sphere)
177    let t2 = (-b + sqrt_disc) * inv_2a;
178    if t2 >= 0.0 {
179        return Some(t2);
180    }
181
182    None
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_ray_plane_straight_down() {
191        let hit = ray_plane_intersection(
192            Vec3::new(0.0, 10.0, 0.0),
193            Vec3::new(0.0, -1.0, 0.0),
194            0.0,
195        );
196        assert!(hit.is_some());
197        let p = hit.unwrap();
198        assert!((p.x).abs() < 1e-5);
199        assert!((p.y).abs() < 1e-5);
200        assert!((p.z).abs() < 1e-5);
201    }
202
203    #[test]
204    fn test_ray_plane_diagonal() {
205        let hit = ray_plane_intersection(
206            Vec3::new(0.0, 10.0, 0.0),
207            Vec3::new(1.0, -1.0, 0.0).normalize(),
208            0.0,
209        );
210        assert!(hit.is_some());
211        let p = hit.unwrap();
212        assert!((p.x - 10.0).abs() < 1e-4);
213        assert!((p.y).abs() < 1e-4);
214    }
215
216    #[test]
217    fn test_ray_plane_parallel() {
218        let hit = ray_plane_intersection(
219            Vec3::new(0.0, 5.0, 0.0),
220            Vec3::new(1.0, 0.0, 0.0),
221            0.0,
222        );
223        assert!(hit.is_none());
224    }
225
226    #[test]
227    fn test_ray_plane_behind() {
228        // Shooting upward from below the plane at y=10
229        let hit = ray_plane_intersection(
230            Vec3::new(0.0, 5.0, 0.0),
231            Vec3::new(0.0, -1.0, 0.0),
232            10.0,
233        );
234        assert!(hit.is_none());
235    }
236
237    #[test]
238    fn test_ray_sphere_hit() {
239        let t = ray_sphere_intersection(
240            Vec3::new(0.0, 0.0, -5.0),
241            Vec3::new(0.0, 0.0, 1.0),
242            Vec3::ZERO,
243            1.0,
244        );
245        assert!(t.is_some());
246        assert!((t.unwrap() - 4.0).abs() < 1e-5);
247    }
248
249    #[test]
250    fn test_ray_sphere_miss() {
251        let t = ray_sphere_intersection(
252            Vec3::new(0.0, 5.0, -5.0),
253            Vec3::new(0.0, 0.0, 1.0),
254            Vec3::ZERO,
255            1.0,
256        );
257        assert!(t.is_none());
258    }
259
260    #[test]
261    fn test_ray_sphere_inside() {
262        // Ray starts inside the sphere
263        let t = ray_sphere_intersection(
264            Vec3::ZERO,
265            Vec3::new(0.0, 0.0, 1.0),
266            Vec3::ZERO,
267            2.0,
268        );
269        assert!(t.is_some());
270        assert!((t.unwrap() - 2.0).abs() < 1e-5);
271    }
272
273    #[test]
274    fn test_ray_sphere_tangent() {
275        // Ray tangent to a unit sphere at (0, 1, 0)
276        let t = ray_sphere_intersection(
277            Vec3::new(-5.0, 1.0, 0.0),
278            Vec3::new(1.0, 0.0, 0.0),
279            Vec3::ZERO,
280            1.0,
281        );
282        // Should hit at t=5.0 (tangent point)
283        assert!(t.is_some());
284        assert!((t.unwrap() - 5.0).abs() < 1e-4);
285    }
286
287    #[test]
288    fn test_screen_to_ray_center() {
289        // Simple identity matrix — should produce a ray going into +Z
290        let vp = Mat4::IDENTITY;
291        let (origin, dir) = screen_to_ray(
292            Vec2::new(640.0, 360.0),
293            Vec2::new(1280.0, 720.0),
294            &vp,
295        );
296        // Center of screen in identity projection maps to (0, 0, z)
297        assert!((origin.x).abs() < 1e-3);
298        assert!((origin.y).abs() < 1e-3);
299        // Direction should be along +Z axis
300        assert!(dir.z > 0.9);
301    }
302
303    #[test]
304    fn test_screen_to_ray_with_perspective() {
305        let view = Mat4::look_at_lh(
306            Vec3::new(0.0, 10.0, 0.0),
307            Vec3::ZERO,
308            Vec3::Z,
309        );
310        let proj = Mat4::perspective_lh(60f32.to_radians(), 1.0, 0.1, 100.0);
311        let vp = proj * view;
312
313        let (origin, dir) = screen_to_ray(
314            Vec2::new(400.0, 400.0), // center of 800x800
315            Vec2::new(800.0, 800.0),
316            &vp,
317        );
318
319        // Camera is at y=10 looking down, ray should go downward
320        assert!(dir.y < 0.0, "Expected downward ray, got dir.y={}", dir.y);
321
322        // Ray should hit y=0 plane
323        let hit = ray_plane_intersection(origin, dir, 0.0);
324        assert!(hit.is_some(), "Expected ray to hit y=0 plane");
325    }
326}