Skip to main content

concinnity_render/
bvh.rs

1//! Top-down median-split bounding volume hierarchy over a set of AABBs.
2//!
3//! The renderer builds one BVH at GraphicsSystem init time from every cullable
4//! DrawObject (objects without a finite AABB go into a separate fallback list).
5//! Each frame the main pass traverses the BVH with the camera's frustum and
6//! optional distance cutoff, producing a list of DrawObject indices to render.
7//!
8//! The BVH is static after construction: it does not refit when leaf AABBs
9//! change.  Props that move at runtime (held items, animated transforms) must
10//! opt out of culling by setting a non-finite AABB (UNCULLED_BB in draw_list).
11//!
12//! Construction is O(N log N) (sort + recurse); query is O(log N + V) where V
13//! is the number of visible leaves.  Single-item scenes degenerate to a leaf
14//! at the root; an empty scene produces no nodes and trivially answers "no
15//! visible objects".
16
17use crate::render_types::DrawObject;
18use alloc::vec::Vec;
19
20use crate::frustum::{Frustum, aabb_distance_sq};
21
22#[derive(Copy, Clone, Debug)]
23struct Aabb {
24    min: [f32; 3],
25    max: [f32; 3],
26}
27
28impl Aabb {
29    fn empty() -> Self {
30        Self {
31            min: [f32::INFINITY; 3],
32            max: [f32::NEG_INFINITY; 3],
33        }
34    }
35
36    fn union(self, other: Aabb) -> Aabb {
37        Aabb {
38            min: [
39                self.min[0].min(other.min[0]),
40                self.min[1].min(other.min[1]),
41                self.min[2].min(other.min[2]),
42            ],
43            max: [
44                self.max[0].max(other.max[0]),
45                self.max[1].max(other.max[1]),
46                self.max[2].max(other.max[2]),
47            ],
48        }
49    }
50
51    fn centroid(self) -> [f32; 3] {
52        [
53            0.5 * (self.min[0] + self.max[0]),
54            0.5 * (self.min[1] + self.max[1]),
55            0.5 * (self.min[2] + self.max[2]),
56        ]
57    }
58}
59
60#[derive(Debug)]
61enum Node {
62    Internal {
63        bb: Aabb,
64        left: u32,
65        right: u32,
66    },
67    Leaf {
68        bb: Aabb,
69        // Optional per-leaf view-distance cutoff (0 = no cutoff). Stored on
70        // the leaf so the traversal can prune without a second array lookup.
71        cull_distance: f32,
72        index: u32,
73    },
74}
75
76#[derive(Debug, Default)]
77/// A bounding-volume hierarchy over cullable draw records.
78pub struct Bvh {
79    nodes: Vec<Node>,
80    root: Option<u32>,
81}
82
83/// One leaf input as supplied to [`Bvh::build`].
84#[derive(Copy, Clone, Debug)]
85pub(crate) struct BvhItem {
86    /// Lower corner of the leaf's world AABB.
87    pub bb_min: [f32; 3],
88    /// Upper corner of the leaf's world AABB.
89    pub bb_max: [f32; 3],
90    /// View-distance cutoff for this leaf; 0 means no cutoff.
91    pub cull_distance: f32,
92    /// Caller-side index passed through unchanged to the traversal callback.
93    pub index: u32,
94}
95
96impl Bvh {
97    /// Build a hierarchy over `items`. An empty input yields an empty tree.
98    pub(crate) fn build(items: &[BvhItem]) -> Self {
99        let mut bvh = Bvh::default();
100        if items.is_empty() {
101            return bvh;
102        }
103        let mut working: Vec<BvhItem> = items.to_vec();
104        let root = bvh.build_recursive(&mut working);
105        bvh.root = Some(root);
106        bvh
107    }
108
109    /// Walk the BVH and call `visit` with every leaf index whose AABB is not
110    /// fully outside the frustum and whose distance to `cam_pos` is within
111    /// the leaf's `cull_distance` (0 = always inside).
112    pub fn query<F: FnMut(u32)>(&self, frustum: &Frustum, cam_pos: [f32; 3], mut visit: F) {
113        let root = match self.root {
114            Some(r) => r,
115            None => return,
116        };
117        // Recursive-style traversal using an explicit stack avoids blowing
118        // the program stack on degenerate inputs and keeps the hot path
119        // amenable to inlining.
120        let mut stack: [u32; 64] = [0; 64];
121        let mut sp: usize = 0;
122        stack[sp] = root;
123        sp += 1;
124
125        while sp > 0 {
126            sp -= 1;
127            let idx = stack[sp];
128            match &self.nodes[idx as usize] {
129                Node::Internal { bb, left, right } => {
130                    if !frustum.intersects_aabb(bb.min, bb.max) {
131                        continue;
132                    }
133                    // Push both children. Order doesn't matter for correctness;
134                    // pushing right first means left is visited first (cache
135                    // locality if leaves were arranged by build order).
136                    if sp + 2 > stack.len() {
137                        // Tree depth exceeded the inline stack; fall back to
138                        // a heap stack for the rest of this subtree. In
139                        // practice 64 entries handles ~2^64 leaves.
140                        self.query_heap(idx, frustum, cam_pos, &mut visit);
141                        continue;
142                    }
143                    stack[sp] = *right;
144                    sp += 1;
145                    stack[sp] = *left;
146                    sp += 1;
147                }
148                Node::Leaf {
149                    bb,
150                    cull_distance,
151                    index,
152                } => {
153                    if !frustum.intersects_aabb(bb.min, bb.max) {
154                        continue;
155                    }
156                    if *cull_distance > 0.0 {
157                        let dsq = aabb_distance_sq(cam_pos, bb.min, bb.max);
158                        if dsq > (*cull_distance) * (*cull_distance) {
159                            continue;
160                        }
161                    }
162                    visit(*index);
163                }
164            }
165        }
166    }
167
168    fn query_heap<F: FnMut(u32)>(
169        &self,
170        start: u32,
171        frustum: &Frustum,
172        cam_pos: [f32; 3],
173        visit: &mut F,
174    ) {
175        let mut stack: Vec<u32> = Vec::with_capacity(32);
176        stack.push(start);
177        while let Some(idx) = stack.pop() {
178            match &self.nodes[idx as usize] {
179                Node::Internal { bb, left, right } => {
180                    if !frustum.intersects_aabb(bb.min, bb.max) {
181                        continue;
182                    }
183                    stack.push(*right);
184                    stack.push(*left);
185                }
186                Node::Leaf {
187                    bb,
188                    cull_distance,
189                    index,
190                } => {
191                    if !frustum.intersects_aabb(bb.min, bb.max) {
192                        continue;
193                    }
194                    if *cull_distance > 0.0 {
195                        let dsq = aabb_distance_sq(cam_pos, bb.min, bb.max);
196                        if dsq > (*cull_distance) * (*cull_distance) {
197                            continue;
198                        }
199                    }
200                    visit(*index);
201                }
202            }
203        }
204    }
205
206    fn build_recursive(&mut self, items: &mut [BvhItem]) -> u32 {
207        let bb = items
208            .iter()
209            .fold(Aabb::empty(), |acc, it| acc.union(item_aabb(it)));
210
211        if items.len() == 1 {
212            let it = items[0];
213            let node_idx = self.nodes.len() as u32;
214            self.nodes.push(Node::Leaf {
215                bb,
216                cull_distance: it.cull_distance,
217                index: it.index,
218            });
219            return node_idx;
220        }
221
222        let extent = [
223            bb.max[0] - bb.min[0],
224            bb.max[1] - bb.min[1],
225            bb.max[2] - bb.min[2],
226        ];
227        let axis = if extent[0] >= extent[1] && extent[0] >= extent[2] {
228            0
229        } else if extent[1] >= extent[2] {
230            1
231        } else {
232            2
233        };
234
235        items.sort_by(|a, b| {
236            let ca = item_aabb(a).centroid()[axis];
237            let cb = item_aabb(b).centroid()[axis];
238            ca.partial_cmp(&cb).unwrap_or(core::cmp::Ordering::Equal)
239        });
240
241        let mid = items.len() / 2;
242        // Reserve this internal slot first so the indices reflect a stable
243        // pre-order layout (root, left subtree, right subtree).
244        let self_idx = self.nodes.len() as u32;
245        self.nodes.push(Node::Internal {
246            bb,
247            left: u32::MAX,
248            right: u32::MAX,
249        });
250
251        let (left_items, right_items) = items.split_at_mut(mid);
252        let left = self.build_recursive(left_items);
253        let right = self.build_recursive(right_items);
254
255        match &mut self.nodes[self_idx as usize] {
256            Node::Internal {
257                left: l, right: r, ..
258            } => {
259                *l = left;
260                *r = right;
261            }
262            Node::Leaf { .. } => unreachable!("self_idx must point at an Internal node"),
263        }
264        self_idx
265    }
266}
267
268fn item_aabb(it: &BvhItem) -> Aabb {
269    Aabb {
270        min: it.bb_min,
271        max: it.bb_max,
272    }
273}
274
275/// Partition the draw list into cullable leaves (suitable for BVH insertion)
276/// and an always-drawn fallback list. Objects that opt out of culling (skybox,
277/// rooms, held props) keep their original draw order via the returned index
278/// list; the BVH owns everything else.
279pub fn partition_draw_objects(draw_objects: &[DrawObject]) -> (Bvh, Vec<u32>) {
280    let mut items: Vec<BvhItem> = Vec::new();
281    let mut always_draw: Vec<u32> = Vec::new();
282    for (i, obj) in draw_objects.iter().enumerate() {
283        if obj.cullable() {
284            items.push(BvhItem {
285                bb_min: obj.bb_min,
286                bb_max: obj.bb_max,
287                cull_distance: obj.cull_distance,
288                index: i as u32,
289            });
290        } else {
291            always_draw.push(i as u32);
292        }
293    }
294    (Bvh::build(&items), always_draw)
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    use alloc::vec;
302    fn ident_vp() -> [[f32; 4]; 4] {
303        [
304            [1.0, 0.0, 0.0, 0.0],
305            [0.0, 1.0, 0.0, 0.0],
306            [0.0, 0.0, 1.0, 0.0],
307            [0.0, 0.0, 0.0, 1.0],
308        ]
309    }
310
311    fn item(idx: u32, min: [f32; 3], max: [f32; 3]) -> BvhItem {
312        BvhItem {
313            bb_min: min,
314            bb_max: max,
315            cull_distance: 0.0,
316            index: idx,
317        }
318    }
319
320    fn collect(bvh: &Bvh, frustum: &Frustum) -> Vec<u32> {
321        let mut out = Vec::new();
322        bvh.query(frustum, [0.0, 0.0, 0.0], |i| out.push(i));
323        out.sort();
324        out
325    }
326
327    #[test]
328    fn empty_input_yields_empty_bvh() {
329        let bvh = Bvh::build(&[]);
330        assert!(bvh.root.is_none());
331        assert_eq!(bvh.nodes.len(), 0);
332        let f = Frustum::from_view_projection(ident_vp());
333        let mut hits = 0;
334        bvh.query(&f, [0.0, 0.0, 0.0], |_| hits += 1);
335        assert_eq!(hits, 0);
336    }
337
338    #[test]
339    fn single_item_creates_one_leaf() {
340        let bvh = Bvh::build(&[item(7, [-0.1, -0.1, -0.1], [0.1, 0.1, 0.1])]);
341        assert!(bvh.root.is_some());
342        assert_eq!(bvh.nodes.len(), 1);
343        let f = Frustum::from_view_projection(ident_vp());
344        assert_eq!(collect(&bvh, &f), vec![7]);
345    }
346
347    #[test]
348    fn visible_and_invisible_items_separate() {
349        let bvh = Bvh::build(&[
350            item(0, [-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]),
351            item(1, [5.0, -0.5, -0.5], [6.0, 0.5, 0.5]),
352            item(2, [-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]),
353        ]);
354        let f = Frustum::from_view_projection(ident_vp());
355        assert_eq!(collect(&bvh, &f), vec![0, 2]);
356    }
357
358    #[test]
359    fn distance_cutoff_prunes_far_leaf() {
360        let mut a = item(0, [-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]);
361        a.cull_distance = 1.0;
362        let mut b = item(1, [-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]);
363        b.cull_distance = 0.0;
364        let bvh = Bvh::build(&[a, b]);
365        let f = Frustum::from_view_projection(ident_vp());
366        let mut out = Vec::new();
367        // Camera 10 units away: `a` should drop, `b` stays.
368        bvh.query(&f, [10.0, 0.0, 0.0], |i| out.push(i));
369        out.sort();
370        assert_eq!(out, vec![1]);
371    }
372
373    #[test]
374    fn many_items_all_visible_inside_frustum() {
375        let mut items = Vec::new();
376        for i in 0..64 {
377            let x = (i as f32) * 0.001 - 0.5;
378            items.push(item(i, [x, -0.5, -0.5], [x + 0.05, 0.5, 0.5]));
379        }
380        let bvh = Bvh::build(&items);
381        let f = Frustum::from_view_projection(ident_vp());
382        let got = collect(&bvh, &f);
383        assert_eq!(got.len(), 64);
384    }
385
386    #[test]
387    fn frustum_rejecting_root_emits_nothing() {
388        let bvh = Bvh::build(&[
389            item(0, [10.0, -0.5, -0.5], [11.0, 0.5, 0.5]),
390            item(1, [10.0, -0.5, -0.5], [11.0, 0.5, 0.5]),
391        ]);
392        let f = Frustum::from_view_projection(ident_vp());
393        assert!(collect(&bvh, &f).is_empty());
394    }
395
396    // A minimal DrawObject carrying only the fields partition_draw_objects
397    // reads: the bounding box (which decides `cullable`) and the cull distance.
398    fn draw_object(bb_min: [f32; 3], bb_max: [f32; 3]) -> DrawObject {
399        DrawObject {
400            vertex_offset: 0,
401            vertex_count: 0,
402            index_offset: 0,
403            index_count: 0,
404            base_vertex: 0,
405            geometry_generation: 0,
406            shader_bucket: 0,
407            model: ident_vp(),
408            texture_slot: 0,
409            normal_map_slot: 0,
410            material: crate::render_types::MaterialUniforms::DEFAULT,
411            visible: true,
412            resident: true,
413            bb_min,
414            bb_max,
415            cull_distance: 0.0,
416            lod_alternates: Vec::new(),
417        }
418    }
419
420    #[test]
421    fn partition_splits_cullable_from_always_draw() {
422        // A NaN box opts out of culling, so it lands in the always-draw list;
423        // the two finite boxes become BVH leaves under one internal root.
424        let objs = vec![
425            draw_object([-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]), // idx 0, cullable
426            draw_object([f32::NAN, 0.0, 0.0], [1.0, 1.0, 1.0]), // idx 1, opts out
427            draw_object([-0.3, -0.3, -0.3], [0.3, 0.3, 0.3]), // idx 2, cullable
428        ];
429        let (bvh, always_draw) = partition_draw_objects(&objs);
430        assert_eq!(always_draw, vec![1]);
431        assert!(bvh.root.is_some());
432        // Two leaves plus their internal root.
433        assert_eq!(bvh.nodes.len(), 3);
434        // The BVH carries the original draw indices through to the traversal.
435        let f = Frustum::from_view_projection(ident_vp());
436        assert_eq!(collect(&bvh, &f), vec![0, 2]);
437    }
438
439    #[test]
440    fn partition_with_no_cullable_objects_yields_empty_bvh() {
441        let objs = vec![
442            draw_object([f32::NAN, 0.0, 0.0], [1.0, 1.0, 1.0]),
443            draw_object([0.0, f32::NAN, 0.0], [1.0, 1.0, 1.0]),
444        ];
445        let (bvh, always_draw) = partition_draw_objects(&objs);
446        assert_eq!(always_draw, vec![0, 1]);
447        assert!(bvh.root.is_none());
448        assert_eq!(bvh.nodes.len(), 0);
449    }
450
451    #[test]
452    fn build_splits_on_the_y_axis() {
453        // Leaves spread along y with tiny x/z extent force the y-axis split
454        // branch (extent[1] is the largest).
455        let items: Vec<BvhItem> = (0..4)
456            .map(|i| {
457                let y = i as f32 * 0.2 - 0.3;
458                item(i, [-0.05, y, -0.05], [0.05, y + 0.05, 0.05])
459            })
460            .collect();
461        let bvh = Bvh::build(&items);
462        // Four leaves + three internal nodes in the balanced median split.
463        assert_eq!(bvh.nodes.len(), 7);
464        let f = Frustum::from_view_projection(ident_vp());
465        assert_eq!(collect(&bvh, &f), vec![0, 1, 2, 3]);
466    }
467
468    #[test]
469    fn build_splits_on_the_z_axis() {
470        // Leaves spread along z with tiny x/y extent force the z-axis (else)
471        // split branch.
472        let items: Vec<BvhItem> = (0..4)
473            .map(|i| {
474                let z = i as f32 * 0.2 - 0.3;
475                item(i, [-0.05, -0.05, z], [0.05, 0.05, z + 0.05])
476            })
477            .collect();
478        let bvh = Bvh::build(&items);
479        assert_eq!(bvh.nodes.len(), 7);
480        let f = Frustum::from_view_projection(ident_vp());
481        assert_eq!(collect(&bvh, &f), vec![0, 1, 2, 3]);
482    }
483}
484
485// Build and query cost over a scene the size a stress world reaches. In-crate
486// rather than under a bench target because `Bvh::build` and `BvhItem` have no
487// consumer outside this crate -- a backend receives a built `Bvh` from
488// `partition_draw_objects` and only queries it.
489//
490//     cargo test -p concinnity-render --release -- --ignored --nocapture \
491//         --test-threads=1 bench_bvh
492#[cfg(test)]
493mod bench {
494    use super::{Bvh, BvhItem};
495    use crate::frustum::Frustum;
496    use alloc::vec::Vec;
497    use std::println;
498    use std::time::Instant;
499
500    const OBJECTS: usize = 10_000;
501    const TARGET_NS: u128 = 200_000_000;
502    const MAX_ITERS: u64 = 1 << 20;
503
504    fn bench<R>(name: &str, items: u64, mut body: impl FnMut() -> R) {
505        let mut iters: u64 = 1;
506        loop {
507            let start = Instant::now();
508            for _ in 0..iters {
509                core::hint::black_box(body());
510            }
511            if start.elapsed().as_nanos() >= TARGET_NS || iters >= MAX_ITERS {
512                break;
513            }
514            iters = iters.saturating_mul(4).min(MAX_ITERS);
515        }
516
517        let start = Instant::now();
518        for _ in 0..iters {
519            core::hint::black_box(body());
520        }
521        let per_item_ns = start.elapsed().as_secs_f64() * 1e9 / (iters * items.max(1)) as f64;
522        println!("  {name:<40} {per_item_ns:>10.2} ns/item");
523    }
524
525    // Unit boxes on a 100x100 ground grid straddling the camera plane, so the
526    // frustum query sees a real mix of accepted, rejected, and straddling
527    // nodes.
528    fn scene_items() -> Vec<BvhItem> {
529        (0..OBJECTS)
530            .map(|i| {
531                let x = (i % 100) as f32 * 3.0 - 150.0;
532                let z = (i / 100) as f32 * 6.0 - 300.0;
533                BvhItem {
534                    bb_min: [x - 0.5, 0.0, z - 0.5],
535                    bb_max: [x + 0.5, 1.0, z + 0.5],
536                    cull_distance: 0.0,
537                    index: i as u32,
538                }
539            })
540            .collect()
541    }
542
543    // Perspective view-projection (70 degree fov, 16:9, camera at the origin
544    // looking down -Z), column-major as the renderer's ViewUniforms lay it out.
545    fn camera_frustum() -> Frustum {
546        let f = 1.0 / 35.0f32.to_radians().tan();
547        let aspect = 16.0 / 9.0;
548        let (near, far) = (0.1, 400.0);
549        let mut vp = [[0.0f32; 4]; 4];
550        vp[0][0] = f / aspect;
551        vp[1][1] = f;
552        vp[2][2] = (far + near) / (near - far);
553        vp[2][3] = -1.0;
554        vp[3][2] = 2.0 * far * near / (near - far);
555        Frustum::from_view_projection(vp)
556    }
557
558    #[test]
559    #[ignore = "benchmark; run with --ignored --test-threads=1"]
560    fn bench_bvh() {
561        let items = scene_items();
562        let frustum = camera_frustum();
563
564        bench("render/bvh_build/10k", OBJECTS as u64, || {
565            Bvh::build(&items)
566        });
567
568        let bvh = Bvh::build(&items);
569        let mut visible = 0u32;
570        bvh.query(&frustum, [0.0; 3], |_| visible += 1);
571        assert!(
572            visible > 0 && (visible as usize) < OBJECTS,
573            "the query fixture must accept some objects and reject others, saw {visible}"
574        );
575        bench("render/bvh_query/10k", OBJECTS as u64, || {
576            let mut seen = 0u32;
577            bvh.query(&frustum, [0.0; 3], |_| seen += 1);
578            seen
579        });
580    }
581}