Skip to main content

editor/
picking.rs

1use libdqg::camera::Camera;
2use libdqg::ecs::Entity;
3use libdqg::glam;
4use libdqg::renderer::Model;
5use libdqg::world::{Renderable, World};
6
7/// Fixed pick radius for a camera entity's gizmo, since it has no `Renderable` to size a bounding
8/// sphere from.
9const CAMERA_GIZMO_PICK_RADIUS: f32 = 0.3;
10
11/// Casts a ray from the camera through the mouse's normalized-device-coordinate position and
12/// returns the nearest entity it intersects, if any.
13///
14/// `Model` entities are tested against their actual mesh triangles (ray/triangle intersection
15/// against each mesh's local-space geometry, transformed into world space), so picking follows
16/// the model's real silhouette rather than a loose bounding volume. `Sprite` entities still use a
17/// bounding-sphere test, since a sprite is already just a flat quad. Camera entities (no
18/// `Renderable` at all) use a fixed-radius bounding-sphere test around their gizmo instead.
19pub fn pick(world: &World, camera: &Camera, ndc_x: f32, ndc_y: f32) -> Option<Entity> {
20    let inv_view_proj = camera.build_view_projection_matrix().inverse();
21
22    let near = inv_view_proj.project_point3(glam::Vec3::new(ndc_x, ndc_y, 0.0));
23    let far = inv_view_proj.project_point3(glam::Vec3::new(ndc_x, ndc_y, 1.0));
24    let ray_origin = near;
25    let ray_dir = (far - near).normalize();
26
27    let mut best: Option<(Entity, f32)> = None;
28
29    for (entity, renderable) in world.renderables.iter() {
30        let Some(transform) = world.transforms.get(entity) else { continue };
31        let matrix = transform.to_mat4();
32
33        let hit_t = match renderable {
34            Renderable::Model(model) => pick_model(model, matrix, ray_origin, ray_dir),
35            Renderable::Sprite(_) => {
36                let center = matrix.transform_point3(renderable.bounding_center());
37                // Radius scaled by the transform's largest axis scale, since bounding_radius() is
38                // in local space but the sphere test needs to happen in world space.
39                let scale = transform.scale.abs();
40                let radius = renderable.bounding_radius() * scale.x.max(scale.y).max(scale.z);
41                ray_sphere_intersection(ray_origin, ray_dir, center, radius)
42            }
43        };
44
45        if let Some(t) = hit_t {
46            if best.is_none_or(|(_, best_t)| t < best_t) {
47                best = Some((entity, t));
48            }
49        }
50    }
51
52    for (entity, _) in world.cameras.iter() {
53        let Some(transform) = world.transforms.get(entity) else { continue };
54        if let Some(t) = ray_sphere_intersection(ray_origin, ray_dir, transform.position, CAMERA_GIZMO_PICK_RADIUS) {
55            if best.is_none_or(|(_, best_t)| t < best_t) {
56                best = Some((entity, t));
57            }
58        }
59    }
60
61    best.map(|(entity, _)| entity)
62}
63
64/// Nearest ray/triangle hit (by `t`) across every triangle of every mesh in `model`, with local
65/// mesh positions transformed into world space by `matrix` first.
66fn pick_model(model: &Model, matrix: glam::Mat4, ray_origin: glam::Vec3, ray_dir: glam::Vec3) -> Option<f32> {
67    let mut nearest: Option<f32> = None;
68
69    for mesh in &model.meshes {
70        for tri in mesh.indices.chunks_exact(3) {
71            let v0 = matrix.transform_point3(mesh.positions[tri[0] as usize]);
72            let v1 = matrix.transform_point3(mesh.positions[tri[1] as usize]);
73            let v2 = matrix.transform_point3(mesh.positions[tri[2] as usize]);
74
75            if let Some(t) = ray_triangle_intersection(ray_origin, ray_dir, v0, v1, v2) {
76                if nearest.is_none_or(|n| t < n) {
77                    nearest = Some(t);
78                }
79            }
80        }
81    }
82
83    nearest
84}
85
86/// Möller–Trumbore ray/triangle intersection. Double-sided (doesn't consider winding order),
87/// since picking should hit a triangle regardless of which way it faces the camera.
88fn ray_triangle_intersection(
89    origin: glam::Vec3, dir: glam::Vec3, v0: glam::Vec3, v1: glam::Vec3, v2: glam::Vec3,
90) -> Option<f32> {
91    const EPSILON: f32 = 1e-6;
92
93    let edge1 = v1 - v0;
94    let edge2 = v2 - v0;
95    let h = dir.cross(edge2);
96    let a = edge1.dot(h);
97    if a.abs() < EPSILON {
98        return None; // Ray is parallel to the triangle.
99    }
100
101    let f = 1.0 / a;
102    let s = origin - v0;
103    let u = f * s.dot(h);
104    if u < 0.0 || u > 1.0 {
105        return None;
106    }
107
108    let q = s.cross(edge1);
109    let v = f * dir.dot(q);
110    if v < 0.0 || u + v > 1.0 {
111        return None;
112    }
113
114    let t = f * edge2.dot(q);
115    if t > EPSILON { Some(t) } else { None }
116}
117
118fn ray_sphere_intersection(origin: glam::Vec3, dir: glam::Vec3, center: glam::Vec3, radius: f32) -> Option<f32> {
119    let m = origin - center;
120    let b = m.dot(dir);
121    let c = m.dot(m) - radius * radius;
122    if c > 0.0 && b > 0.0 {
123        return None;
124    }
125    let discriminant = b * b - c;
126    if discriminant < 0.0 {
127        return None;
128    }
129    let t = -b - discriminant.sqrt();
130    Some(t.max(0.0))
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use libdqg::world::{CameraComponent, Transform};
137
138    #[test]
139    fn pick_hits_a_camera_entity_via_its_bounding_sphere() {
140        let mut camera = Camera { position: glam::Vec3::new(0.0, 0.0, 5.0), yaw: 0.0, pitch: 0.0, aspect: 1.0, fov: 45.0, znear: 0.1, zfar: 100.0 };
141        camera.look_at(glam::Vec3::ZERO);
142        let mut world = World::new(camera);
143        let entity = world.spawn_empty("Cam", Transform::default());
144        world.set_camera(entity, CameraComponent::default());
145
146        assert_eq!(pick(&world, &camera, 0.0, 0.0), Some(entity));
147        assert_eq!(pick(&world, &camera, 0.99, 0.99), None);
148    }
149}