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 index_by_component: HashMap<ComponentId, usize>,
26
27 pending_add: Vec<ComponentId>,
29
30 pending_refit: HashSet<ComponentId>,
32
33 pending_refit_shape_indices: HashSet<usize>,
35
36 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 pub(crate) fn find_raycastable_for_renderable(
102 world: &World,
103 renderable_cid: ComponentId,
104 ) -> Option<RaycastableComponent> {
105 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 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 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 let last_index = self.shapes.len().saturating_sub(1);
156 self.shapes.swap_remove(index);
157 if index != last_index {
158 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 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 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 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 self.bvh = Some(BVH::build(&mut self.shapes));
237 }
238
239 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 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 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 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 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 if self.dirty_rebuild {
307 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 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 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 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}