Skip to main content

mittens_engine/engine/ecs/system/
bvh_system.rs

1use crate::engine::ecs::ComponentId;
2use crate::engine::ecs::World;
3use crate::engine::ecs::component::RenderableComponent;
4use crate::engine::ecs::component::{BoundsComponent, RaycastableComponent};
5use crate::engine::ecs::system::TransformSystem;
6use crate::engine::graphics::VisualWorld;
7use crate::engine::graphics::primitives::{CpuMeshHandle, TransformMatrix};
8use crate::engine::user_input::InputState;
9use bvh::aabb::{AABB, Bounded};
10use bvh::bounding_hierarchy::BHShape;
11use bvh::bvh::BVH;
12use bvh::bvh::BVHNode;
13use bvh::ray::Ray;
14use bvh::{Point3, Vector3};
15use std::collections::{HashMap, HashSet};
16use std::sync::OnceLock;
17use std::time::Instant;
18
19#[derive(Debug, Default)]
20pub struct BvhSystem {
21    shapes: Vec<RenderableAabb>,
22    bvh: Option<BVH>,
23
24    /// Map ECS ComponentId -> shape index in `shapes`.
25    index_by_component: HashMap<ComponentId, usize>,
26
27    /// Renderables that were registered this frame (via command flush).
28    pending_add: Vec<ComponentId>,
29
30    /// Renderables whose AABBs need updating due to transform propagation.
31    pending_refit: HashSet<ComponentId>,
32
33    /// Shape indices that need refitting in the BVH.
34    pending_refit_shape_indices: HashSet<usize>,
35
36    /// True when shapes were added/removed and we need a full rebuild.
37    dirty_rebuild: bool,
38}
39
40#[derive(Debug, Clone)]
41struct RenderableAabb {
42    component: ComponentId,
43    aabb: AABB,
44    node_index: usize,
45}
46
47impl RenderableAabb {
48    fn new(component: ComponentId, min: [f32; 3], max: [f32; 3]) -> Self {
49        let min = Point3::new(min[0], min[1], min[2]);
50        let max = Point3::new(max[0], max[1], max[2]);
51        Self {
52            component,
53            aabb: AABB::with_bounds(min, max),
54            node_index: 0,
55        }
56    }
57}
58
59impl Bounded for RenderableAabb {
60    fn aabb(&self) -> AABB {
61        self.aabb
62    }
63}
64
65impl BHShape for RenderableAabb {
66    fn set_bh_node_index(&mut self, index: usize) {
67        self.node_index = index;
68    }
69
70    fn bh_node_index(&self) -> usize {
71        self.node_index
72    }
73}
74
75impl BvhSystem {
76    pub fn has_index(&self) -> bool {
77        self.bvh.is_some()
78    }
79
80    fn profile_spatial_enabled() -> bool {
81        static ENABLED: OnceLock<bool> = OnceLock::new();
82        *ENABLED.get_or_init(|| {
83            matches!(
84                std::env::var("CAT_PROFILE_SPATIAL")
85                    .unwrap_or_default()
86                    .trim()
87                    .to_ascii_lowercase()
88                    .as_str(),
89                "1" | "true" | "yes" | "on"
90            )
91        })
92    }
93
94    pub(crate) fn renderable_is_raycastable(world: &World, renderable_cid: ComponentId) -> bool {
95        Self::find_raycastable_for_renderable(world, renderable_cid).is_some()
96    }
97
98    /// Return the `RaycastableComponent` governing a renderable (enabled only).
99    ///
100    /// Checks children of the renderable first (common panel topology), then walks ancestors.
101    pub(crate) fn find_raycastable_for_renderable(
102        world: &World,
103        renderable_cid: ComponentId,
104    ) -> Option<RaycastableComponent> {
105        // Raycastable is commonly authored as a sidecar of a transform/layout node rather
106        // than as a structural ancestor of the renderable generated for that node's Style.
107        // Treat an immediate child marker as belonging to its owner at every ancestry level.
108        // Nearest owner still wins, so a row can override its containing panel.
109        let mut owner = Some(renderable_cid);
110        let mut governing = None;
111        let mut inherited_priority = 0;
112        while let Some(component_id) = owner {
113            if let Some(rc) = raycastable_marker_on_node(world, component_id) {
114                if governing.is_none() {
115                    if !rc.enable {
116                        return None;
117                    }
118                    governing = Some(rc);
119                }
120                if rc.enable {
121                    inherited_priority = inherited_priority.max(rc.interaction_priority);
122                }
123            }
124            owner = world.parent_of(component_id);
125        }
126
127        governing.map(|mut rc| {
128            // A close control owns enable/pointer-event behavior, while a containing interaction
129            // layer can raise its priority over scene geometry.
130            rc.interaction_priority = inherited_priority;
131            rc
132        })
133    }
134
135    pub fn queue_renderable_added(&mut self, component: ComponentId) {
136        if self.index_by_component.contains_key(&component) {
137            return;
138        }
139        if self.pending_add.contains(&component) {
140            return;
141        }
142        self.pending_add.push(component);
143    }
144
145    pub fn queue_renderable_removed(&mut self, component: ComponentId) {
146        // If it's still pending add (not committed to shapes yet), just drop it.
147        self.pending_add.retain(|&c| c != component);
148        self.pending_refit.remove(&component);
149
150        let Some(index) = self.index_by_component.remove(&component) else {
151            return;
152        };
153
154        // Remove by swap_remove to keep O(1).
155        let last_index = self.shapes.len().saturating_sub(1);
156        self.shapes.swap_remove(index);
157        if index != last_index {
158            // We swapped some other shape into `index`; fix its index mapping.
159            if let Some(swapped) = self.shapes.get(index) {
160                self.index_by_component.insert(swapped.component, index);
161            }
162        }
163
164        self.dirty_rebuild = true;
165        self.pending_refit_shape_indices.clear();
166    }
167
168    /// Queue all renderables under the given transform subtree for BVH refit.
169    pub fn queue_transform_subtree(&mut self, world: &World, transform_root: ComponentId) {
170        let mut stack = vec![transform_root];
171        while let Some(node) = stack.pop() {
172            if world
173                .get_component_by_id_as::<RenderableComponent>(node)
174                .is_some()
175            {
176                self.pending_refit.insert(node);
177            }
178
179            let children: Vec<ComponentId> = world.children_of(node).to_vec();
180            for ch in children {
181                stack.push(ch);
182            }
183        }
184    }
185
186    fn placeholder_aabb() -> AABB {
187        // Far away and tiny so it won't get hit.
188        let p = 1.0e9_f32;
189        AABB::with_bounds(
190            Point3::new(p, p, p),
191            Point3::new(p + 0.001, p + 0.001, p + 0.001),
192        )
193    }
194
195    fn compute_aabb_for_renderable(world: &World, cid: ComponentId) -> Option<AABB> {
196        let r = world.get_component_by_id_as::<RenderableComponent>(cid)?;
197        let model = TransformSystem::world_model(world, cid)?;
198
199        if let Some(local) = world.children_of(cid).iter().find_map(|&child| {
200            world
201                .get_component_by_id_as::<BoundsComponent>(child)
202                .map(|bounds| bounds.local)
203        }) {
204            let world_bounds = local.transformed(model);
205            return Some(AABB::with_bounds(
206                Point3::new(
207                    world_bounds.min[0],
208                    world_bounds.min[1],
209                    world_bounds.min[2],
210                ),
211                Point3::new(
212                    world_bounds.max[0],
213                    world_bounds.max[1],
214                    world_bounds.max[2],
215                ),
216            ));
217        }
218
219        // Use base mesh so UV-baked variants (text glyphs) still behave like their primitive.
220        let mesh = r.renderable.base_mesh;
221        let (min, max) = aabb_from_world_matrix_for_mesh(mesh, model)?;
222
223        Some(AABB::with_bounds(
224            Point3::new(min[0], min[1], min[2]),
225            Point3::new(max[0], max[1], max[2]),
226        ))
227    }
228
229    fn rebuild_from_shapes(&mut self) {
230        if self.shapes.is_empty() {
231            self.bvh = None;
232            return;
233        }
234
235        // Build the BVH in-place (sets node indices on shapes).
236        self.bvh = Some(BVH::build(&mut self.shapes));
237    }
238
239    /// Apply any queued add/remove/refit requests.
240    ///
241    /// Intended to be called once after `CommandQueue::flush` completes.
242    pub fn flush_pending(&mut self, world: &World) {
243        let profile = Self::profile_spatial_enabled();
244        let started = profile.then(Instant::now);
245        let queued_adds = self.pending_add.len();
246        let queued_refits = self.pending_refit.len();
247        let rebuilding = self.dirty_rebuild || queued_adds > 0;
248        // Commit pending adds.
249        if !self.pending_add.is_empty() {
250            for cid in std::mem::take(&mut self.pending_add) {
251                if self.index_by_component.contains_key(&cid) {
252                    continue;
253                }
254
255                if !Self::renderable_is_raycastable(world, cid) {
256                    continue;
257                }
258
259                let aabb = Self::compute_aabb_for_renderable(world, cid)
260                    .unwrap_or_else(Self::placeholder_aabb);
261
262                let idx = self.shapes.len();
263                let mut shape = RenderableAabb {
264                    component: cid,
265                    aabb,
266                    node_index: 0,
267                };
268                // If we already have a BVH, this will be overwritten by rebuild.
269                shape.set_bh_node_index(0);
270
271                self.shapes.push(shape);
272                self.index_by_component.insert(cid, idx);
273            }
274
275            self.dirty_rebuild = true;
276        }
277
278        // Update AABBs for moved renderables.
279        if !self.pending_refit.is_empty() {
280            let moved = std::mem::take(&mut self.pending_refit);
281            for cid in moved {
282                let Some(&shape_index) = self.index_by_component.get(&cid) else {
283                    continue;
284                };
285
286                // If the renderable disappeared, drop it.
287                if world
288                    .get_component_by_id_as::<RenderableComponent>(cid)
289                    .is_none()
290                {
291                    self.queue_renderable_removed(cid);
292                    continue;
293                }
294
295                let new_aabb = Self::compute_aabb_for_renderable(world, cid)
296                    .unwrap_or_else(Self::placeholder_aabb);
297
298                if let Some(s) = self.shapes.get_mut(shape_index) {
299                    s.aabb = new_aabb;
300                    self.pending_refit_shape_indices.insert(shape_index);
301                }
302            }
303        }
304
305        // Rebuild if topology changed.
306        if self.dirty_rebuild {
307            // If any shapes were removed via swap_remove, their indices changed; safest is to
308            // refit nothing and rebuild.
309            self.pending_refit_shape_indices.clear();
310            self.rebuild_from_shapes();
311            self.dirty_rebuild = false;
312            if let Some(started) = started {
313                let line = format!(
314                    "[spatial-profile][bvh] shapes={} add={} refit={} rebuild=true elapsed_ms={:.3}",
315                    self.shapes.len(),
316                    queued_adds,
317                    queued_refits,
318                    started.elapsed().as_secs_f64() * 1000.0
319                );
320                eprintln!("{line}");
321                crate::utils::profile_log::append("spatial-profile", &line);
322            }
323            return;
324        }
325
326        // Otherwise, update the existing BVH's AABBs and do cheap incremental optimization.
327        if !self.pending_refit_shape_indices.is_empty() {
328            match self.bvh.as_mut() {
329                None => {
330                    self.rebuild_from_shapes();
331                }
332                Some(bvh) => {
333                    bvh.optimize(&self.pending_refit_shape_indices, &self.shapes);
334                }
335            }
336            self.pending_refit_shape_indices.clear();
337        }
338
339        if let Some(started) = started
340            && (queued_adds > 0 || queued_refits > 0 || rebuilding)
341        {
342            let line = format!(
343                "[spatial-profile][bvh] shapes={} add={} refit={} rebuild={} elapsed_ms={:.3}",
344                self.shapes.len(),
345                queued_adds,
346                queued_refits,
347                rebuilding,
348                started.elapsed().as_secs_f64() * 1000.0
349            );
350            eprintln!("{line}");
351            crate::utils::profile_log::append("spatial-profile", &line);
352        }
353    }
354
355    pub fn raycast_renderables(
356        &self,
357        origin: [f32; 3],
358        dir: [f32; 3],
359        max_distance: f32,
360    ) -> Option<(ComponentId, f32)> {
361        let Some(bvh) = &self.bvh else {
362            return None;
363        };
364
365        let origin = Point3::new(origin[0], origin[1], origin[2]);
366        let dir = Vector3::new(dir[0], dir[1], dir[2]);
367        let ray = Ray::new(origin, dir);
368
369        let candidates = bvh.traverse(&ray, &self.shapes);
370
371        let mut best: Option<(ComponentId, f32)> = None;
372        for s in candidates {
373            let min = [s.aabb.min.x, s.aabb.min.y, s.aabb.min.z];
374            let max = [s.aabb.max.x, s.aabb.max.y, s.aabb.max.z];
375
376            let Some(t) = ray_aabb(
377                [origin.x, origin.y, origin.z],
378                [dir.x, dir.y, dir.z],
379                min,
380                max,
381            ) else {
382                continue;
383            };
384
385            if t < 0.0 || t > max_distance {
386                continue;
387            }
388
389            match best {
390                None => best = Some((s.component, t)),
391                Some((_, bt)) if t < bt => best = Some((s.component, t)),
392                _ => {}
393            }
394        }
395
396        best
397    }
398
399    /// Return ray/AABB hit candidates (sorted by ascending t).
400    ///
401    /// This is intended to support narrow-phase hit tests that may reject the closest AABB hit
402    /// and need to fall through to the next-best candidates.
403    pub fn raycast_renderables_candidates(
404        &self,
405        origin: [f32; 3],
406        dir: [f32; 3],
407        max_distance: f32,
408        limit: usize,
409    ) -> Vec<(ComponentId, f32)> {
410        let Some(bvh) = &self.bvh else {
411            return Vec::new();
412        };
413
414        let origin_p = Point3::new(origin[0], origin[1], origin[2]);
415        let dir_v = Vector3::new(dir[0], dir[1], dir[2]);
416        let ray = Ray::new(origin_p, dir_v);
417
418        let candidates = bvh.traverse(&ray, &self.shapes);
419
420        let mut hits: Vec<(ComponentId, f32)> = Vec::new();
421        for s in candidates {
422            let min = [s.aabb.min.x, s.aabb.min.y, s.aabb.min.z];
423            let max = [s.aabb.max.x, s.aabb.max.y, s.aabb.max.z];
424
425            let Some(t) = ray_aabb(origin, dir, min, max) else {
426                continue;
427            };
428
429            if !t.is_finite() || t < 0.0 || t > max_distance {
430                continue;
431            }
432            hits.push((s.component, t));
433        }
434
435        hits.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
436        if limit > 0 && hits.len() > limit {
437            hits.truncate(limit);
438        }
439        hits
440    }
441
442    pub fn query_point(&self, point: [f32; 3]) -> Vec<ComponentId> {
443        let Some(bvh) = &self.bvh else {
444            return Vec::new();
445        };
446
447        if self.shapes.is_empty() || bvh.nodes.is_empty() {
448            return Vec::new();
449        }
450
451        let p = Point3::new(point[0], point[1], point[2]);
452
453        let mut hits = Vec::new();
454        let mut stack = vec![0usize];
455        while let Some(node_index) = stack.pop() {
456            match bvh.nodes[node_index] {
457                BVHNode::Node {
458                    child_l_index,
459                    child_l_aabb,
460                    child_r_index,
461                    child_r_aabb,
462                    ..
463                } => {
464                    if child_l_aabb.contains(&p) {
465                        stack.push(child_l_index);
466                    }
467                    if child_r_aabb.contains(&p) {
468                        stack.push(child_r_index);
469                    }
470                }
471                BVHNode::Leaf { shape_index, .. } => {
472                    if let Some(s) = self.shapes.get(shape_index) {
473                        if s.aabb.contains(&p) {
474                            hits.push(s.component);
475                        }
476                    }
477                }
478            }
479        }
480
481        hits
482    }
483
484    pub fn query_aabb(&self, min: [f32; 3], max: [f32; 3]) -> Vec<ComponentId> {
485        let Some(bvh) = &self.bvh else {
486            return Vec::new();
487        };
488
489        if self.shapes.is_empty() || bvh.nodes.is_empty() {
490            return Vec::new();
491        }
492
493        let query = AABB::with_bounds(
494            Point3::new(min[0], min[1], min[2]),
495            Point3::new(max[0], max[1], max[2]),
496        );
497
498        let mut hits = Vec::new();
499        let mut stack = vec![0usize];
500        while let Some(node_index) = stack.pop() {
501            match bvh.nodes[node_index] {
502                BVHNode::Node {
503                    child_l_index,
504                    child_l_aabb,
505                    child_r_index,
506                    child_r_aabb,
507                    ..
508                } => {
509                    if aabb_overlap_bvh(&query, &child_l_aabb) {
510                        stack.push(child_l_index);
511                    }
512                    if aabb_overlap_bvh(&query, &child_r_aabb) {
513                        stack.push(child_r_index);
514                    }
515                }
516                BVHNode::Leaf { shape_index, .. } => {
517                    if let Some(s) = self.shapes.get(shape_index) {
518                        if aabb_overlap_bvh(&query, &s.aabb) {
519                            hits.push(s.component);
520                        }
521                    }
522                }
523            }
524        }
525
526        hits
527    }
528}
529
530fn raycastable_marker_on_node(
531    world: &World,
532    component_id: ComponentId,
533) -> Option<RaycastableComponent> {
534    world
535        .get_component_by_id_as::<RaycastableComponent>(component_id)
536        .copied()
537        .or_else(|| {
538            world.children_of(component_id).iter().find_map(|&child| {
539                world
540                    .get_component_by_id_as::<RaycastableComponent>(child)
541                    .copied()
542            })
543        })
544}
545
546fn aabb_overlap_bvh(a: &AABB, b: &AABB) -> bool {
547    !(a.max.x < b.min.x
548        || a.min.x > b.max.x
549        || a.max.y < b.min.y
550        || a.min.y > b.max.y
551        || a.max.z < b.min.z
552        || a.min.z > b.max.z)
553}
554
555impl crate::engine::ecs::system::System for BvhSystem {
556    fn tick(
557        &mut self,
558        world: &mut World,
559        _visuals: &mut VisualWorld,
560        _input: &InputState,
561        _dt_sec: f32,
562    ) {
563        self.flush_pending(world);
564    }
565}
566
567fn aabb_from_world_matrix_for_mesh(
568    mesh: CpuMeshHandle,
569    m: TransformMatrix,
570) -> Option<([f32; 3], [f32; 3])> {
571    let local = crate::engine::graphics::bounds::mesh_local_aabb(mesh)?;
572    let world = local.transformed(m);
573    Some((world.min, world.max))
574}
575
576fn ray_aabb(
577    origin: [f32; 3],
578    dir: [f32; 3],
579    aabb_min: [f32; 3],
580    aabb_max: [f32; 3],
581) -> Option<f32> {
582    // Slab test. Returns nearest positive t.
583    let mut tmin = 0.0f32;
584    let mut tmax = f32::INFINITY;
585
586    for axis in 0..3 {
587        let o = origin[axis];
588        let d = dir[axis];
589        let min = aabb_min[axis];
590        let max = aabb_max[axis];
591
592        if d.abs() < 1e-6 {
593            if o < min || o > max {
594                return None;
595            }
596            continue;
597        }
598
599        let inv_d = 1.0 / d;
600        let mut t0 = (min - o) * inv_d;
601        let mut t1 = (max - o) * inv_d;
602        if t0 > t1 {
603            std::mem::swap(&mut t0, &mut t1);
604        }
605        tmin = tmin.max(t0);
606        tmax = tmax.min(t1);
607        if tmax < tmin {
608            return None;
609        }
610    }
611
612    if tmin >= 0.0 {
613        Some(tmin)
614    } else if tmax >= 0.0 {
615        Some(tmax)
616    } else {
617        None
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::BvhSystem;
624    use crate::engine::ecs::World;
625    use crate::engine::ecs::component::{
626        BoundsComponent, RaycastableComponent, RenderableComponent, TransformComponent,
627    };
628    use crate::engine::graphics::bounds::Aabb;
629    use crate::engine::graphics::primitives::{CpuMeshHandle, MaterialHandle, Renderable};
630
631    #[test]
632    fn imported_renderable_uses_cached_local_bounds() {
633        let mut world = World::default();
634        let transform = world.add_component(TransformComponent::new().with_position(2.0, 3.0, 4.0));
635        let renderable = world.add_component(RenderableComponent::new(Renderable::new(
636            CpuMeshHandle(999),
637            MaterialHandle::TOON_MESH,
638        )));
639        let bounds = world.add_component(BoundsComponent::new(Aabb {
640            min: [-1.0, -2.0, -3.0],
641            max: [1.0, 2.0, 3.0],
642        }));
643        let _ = world.add_child(transform, renderable);
644        let _ = world.add_child(renderable, bounds);
645
646        let aabb = BvhSystem::compute_aabb_for_renderable(&world, renderable)
647            .expect("cached imported bounds should produce a BVH shape");
648        assert_eq!([aabb.min.x, aabb.min.y, aabb.min.z], [1.0, 1.0, 1.0]);
649        assert_eq!([aabb.max.x, aabb.max.y, aabb.max.z], [3.0, 5.0, 7.0]);
650    }
651
652    #[test]
653    fn transform_raycastable_sidecar_governs_generated_descendant_renderable() {
654        let mut world = World::default();
655        let panel_row = world.add_component(TransformComponent::new());
656        let raycastable = world.add_component(RaycastableComponent::click_only());
657        let generated_style_transform = world.add_component(TransformComponent::new());
658        let generated_background = world.add_component(RenderableComponent::cube());
659        world.add_child(panel_row, raycastable).unwrap();
660        world
661            .add_child(panel_row, generated_style_transform)
662            .unwrap();
663        world
664            .add_child(generated_style_transform, generated_background)
665            .unwrap();
666
667        let governing = BvhSystem::find_raycastable_for_renderable(&world, generated_background)
668            .expect("panel row sidecar should govern its generated background");
669        assert_eq!(
670            governing.pointer_events,
671            crate::engine::ecs::component::PointerEvents::ClickOnly
672        );
673    }
674
675    #[test]
676    fn nearest_raycastable_sidecar_can_disable_parent_opt_in() {
677        let mut world = World::default();
678        let panel = world.add_component(TransformComponent::new());
679        let panel_raycastable = world.add_component(RaycastableComponent::enabled());
680        let marker = world.add_component(TransformComponent::new());
681        let marker_raycastable = world.add_component(RaycastableComponent::disabled());
682        let renderable = world.add_component(RenderableComponent::cube());
683        world.add_child(panel, panel_raycastable).unwrap();
684        world.add_child(panel, marker).unwrap();
685        world.add_child(marker, marker_raycastable).unwrap();
686        world.add_child(marker, renderable).unwrap();
687
688        assert!(BvhSystem::find_raycastable_for_renderable(&world, renderable).is_none());
689    }
690
691    #[test]
692    fn nearest_control_inherits_containing_interaction_layer_priority() {
693        let mut world = World::default();
694        let panel = world.add_component(TransformComponent::new());
695        let panel_layer =
696            world.add_component(RaycastableComponent::enabled().with_interaction_priority(100));
697        let row = world.add_component(TransformComponent::new());
698        let row_click = world.add_component(RaycastableComponent::click_only());
699        let renderable = world.add_component(RenderableComponent::cube());
700        world.add_child(panel, panel_layer).unwrap();
701        world.add_child(panel, row).unwrap();
702        world.add_child(row, row_click).unwrap();
703        world.add_child(row, renderable).unwrap();
704
705        let governing = BvhSystem::find_raycastable_for_renderable(&world, renderable).unwrap();
706        assert_eq!(
707            governing.pointer_events,
708            crate::engine::ecs::component::PointerEvents::ClickOnly
709        );
710        assert_eq!(governing.interaction_priority, 100);
711    }
712}