All tutorials Mighty Professional
Build a Game Engine ยท Simulation

Spatial Partitioning

Five thousand physics bodies make 12.5 million possible collision pairs, a bullet trace that checks every object in a city costs a full scan, and a camera that tests every mesh against its view pays for objects miles behind it. Every major engine ships an acceleration structure for this: frustum culling, broad-phase collision, raycasting, range queries and nearest-neighbor lookups can't afford to test every object against every other. The structures that make these queries sublinear are uniform grids, quadtrees, octrees, BSP trees, bounding volume hierarchies, and spatial hash maps. The widgets build quadtrees and BVHs live, fire rays through a BVH, and run sweep and prune frame by frame; the page ends on the case studies that ship in Bullet, Unreal, PhysX, Jolt, Nanite, and RTX.

Time~60 min LevelJunior to mid; review for senior PrereqsYou can read C++ or pseudocode. Basic Big O. Axis-aligned bounding boxes (AABB). HardwareNone. Familiarity with GPU ray tracing helps in ยง7-9 and ยง14.
โ—‚ Build a Game Engine Phase 9 ยท Simulation Next ยท Entity Component System โ–ธ
What you'll be able to do by the end

01Why partition space

A level has 5,000 crates, barrels and ragdolls, and every frame the physics engine needs to know which ones are touching. Testing every object against every other is O(n²). A physics scene with 5,000 dynamic bodies produces 12.5 million pair tests per frame. At 60 fps on a single core that is roughly 750 million tests per second, each one requiring at least an AABB overlap check (up to six comparisons in 3D). Most of those pairs are nowhere near each other. The purpose of spatial partitioning is to skip them.

The split between broad phase and narrow phase exists for exactly this reason. The broad phase uses a spatial data structure to reduce n² candidate pairs to something closer to n or n log n, then the narrow phase runs the expensive per-pair geometry tests only on the surviving candidates.

The same structures serve other queries. Frustum culling walks the tree and prunes entire branches whose bounding volumes lie outside the camera frustum. Raycasting (mouse picking, line-of-sight checks, bullet traces) traverses the tree along the ray and skips volumes the ray does not intersect. Range queries ("give me every unit within 50 meters of this position") descend into nodes that overlap the query sphere and skip the rest. K-nearest-neighbor queries do the same with a shrinking search radius.

Without an acceleration structure, each of those queries is O(n). With one, the average case drops to O(log n) for tree-based structures or O(1) for hash-based structures, at the cost of O(n) or O(n log n) construction time and O(n) storage.

Worked example

Spread those 5,000 bodies evenly over a 1 km ร— 1 km map and drop a grid of 10 m cells on it: 10,000 cells, half a body per cell on average. Each body checks only its own cell and the eight around it, about 4.5 bodies, so the frame costs roughly 5,000 ร— 4.5 = 22,500 tests (each pair seen from both sides) instead of 12.5 million.

02A short history

Why does a 1993 shooter's level format contain a tree, and why do today's ray-tracing GPUs walk one in hardware? Spatial data structures emerged from three separate communities: computer graphics (visibility), computational geometry (range searching), and database indexing (spatial queries).

Timeline: from BSP trees (1980) to Box2D's dynamic BVH (2019)
1980
Fuchs, Kedem, and Naylor publish BSP trees. "On Visible Surface Generation by A Priori Tree Structures," SIGGRAPH '80, pp. 124-133[2]. Binary space partitioning: split the world along a hyperplane, recurse on each half. Preprocessed at build time, then traversed back-to-front at runtime for correct painter's-algorithm rendering (farthest surfaces first, so nearer ones paint over them) without a depth buffer.
1982
Meagher publishes octree encoding. "Geometric Modeling Using Octree Encoding," Computer Graphics and Image Processing 19(2):129-147[1]. Recursive 8-ary subdivision of 3D space. Storage proportional to surface area, not volume. Boolean operations (union, intersection, difference) in linear time.
1984
Guttman publishes R-trees. "R-Trees: A Dynamic Index Structure for Spatial Searching," SIGMOD '84, pp. 47-57[4]. A height-balanced tree in the B-tree family where each internal node stores the minimum bounding rectangle of its children. Designed for disk-based spatial databases, not real-time games, but the same shape as the dynamic AABB trees in today's physics engines: a tree of boxes that bound boxes.
1987
Goldsmith and Salmon automate BVH construction. "Automatic Creation of Object Hierarchies for Ray Tracing," IEEE CG&A 7(5):14-20[5]. First algorithm for automatically building a bounding volume hierarchy. Introduced the idea of using surface area as a proxy for ray-hit probability, the seed of the modern SAH.
1993
Doom ships with BSP trees. id Software's Doom[7] uses a BSP tree built offline from the level geometry. The renderer walks the tree front-to-back to draw walls in the correct order without a Z-buffer on 1993 hardware. Quake (1996) extends the approach with PVS (potentially visible set) precomputation.
2000
Ulrich publishes loose octrees. "Loose Octrees" in Game Programming Gems[8]. Expand each node's bounds by a factor of 2 so objects always fit in exactly one node. Eliminates the "straddle" problem where objects on cell boundaries must be stored in multiple nodes or bumped to the parent.
2007
Wald publishes fast SAH BVH construction. "On fast Construction of SAH-based Bounding Volume Hierarchies," IEEE Symposium on Interactive Ray Tracing, pp. 33-40[9]. Binned SAH: approximate the cost function by placing primitives into bins instead of evaluating every possible split plane. Fast enough for per-frame rebuilds. The same year, Wald, Boulos, and Shirley publish ray tracing deformable scenes with dynamic BVHs[18].
2018
NVIDIA ships RTX with hardware BVH traversal. The Turing architecture (RTX 20-series) introduces RT Cores that accelerate BVH traversal and ray-triangle intersection in dedicated silicon[19]. The APIs (DXR, later Vulkan) expose a two-level structure[17]: a top-level structure (TLAS) of instance transforms over bottom-level (BLAS) geometry.
2019
Catto presents the Box2D dynamic BVH at GDC. "Dynamic Bounding Volume Hierarchies," GDC 2019[20]. The broadphase behind Box2D: an AABB tree with enlarged (fat) leaf boxes, leaf insertion that picks the sibling adding the least surface area (found by branch and bound), and local tree rotations that reduce surface area on the way back up.

The engine-relevant milestones: BSP trees (1980)[2] and Doom's use of them (1993)[7], octrees (1982)[1], automatic BVH construction with surface area as its cost (1987)[5], loose octrees (2000)[8], binned SAH fast enough for per-frame rebuilds (2007)[9], and BVH traversal in GPU hardware (2018)[19].

03Uniform grids

A top-down shooter has a few hundred bullets and enemies, all about the same size, and needs every bullet-enemy hit each frame. The simplest spatial structure for that: divide the world into a fixed grid of equal-sized cells. Each cell holds a list of objects whose centers (or bounding volumes) overlap that cell. Insert is O(1): hash the object's position to a cell index, append. Query is O(1) in the number of cells checked: compute which cells the query region overlaps, iterate their contents.

The spatial hash function maps cell coordinates to a flat array index. The usual choice is ((cellX * 73856093) ^ (cellY * 19349663)) % tableSize; the outer parentheses matter, because in C, C++ and Rust % binds tighter than ^. Those two constants are the primes Teschner et al. published in 2003 for exactly this problem, along with 83492791 for the third dimension[3]. The paper is candid about where they came from: the authors call them "large prime numbers, in our case" those three, say they "have not systematically investigated the characteristics of hash functions", and report that hash quality matters less as the table grows. They are a working choice, not a derived optimum.

Worked example

Cell (3, 5) hashes to 3 ร— 73,856,093 = 221,568,279 XOR 5 ร— 19,349,663 = 96,748,315, which is 149,986,828, and that mod a 4,096-entry table is bucket 3,596. Two different cells can land in the same bucket; a query just tests the bucket's objects against the real cell bounds and discards strays.

Grids win when the objects are roughly uniform in size and roughly uniform in distribution. Particle systems are the canonical example: thousands of similar-sized particles spread over the simulation domain. 2D games where entities are all within a factor of two in bounding-box size (top-down shooters, roguelikes, RTS unit collision) also fit well.

The failure mode is the large-object problem. An object that spans many cells must be inserted into all of them, and every query that touches any of those cells finds it. A single large terrain chunk in a grid of small cells can appear in hundreds of cells, multiplying both insertion cost and query noise. Hierarchical grids (multiple grid resolutions, one per object-size class) mitigate this at the cost of multiple lookups per query.

When grids beat trees

For uniform distributions of similar-sized objects, a grid is usually faster than a tree. The cell index comes straight from the coordinates, so reaching a cell's contents is one or two dependent loads. A balanced BVH walks about log2 n levels to reach a leaf (ten for a thousand objects), and on a cache-cold path each level is a dependent load. Particle systems, 2D bullet-hell games, and SPH fluid sims typically use grids.

04Quadtrees

An RTS map has dense clusters of units around bases and wide stretches of empty ground, and a uniform grid spends as much on the empty ground as on the bases. A quadtree recursively subdivides 2D space into four quadrants. Each internal node has exactly four children (NW, NE, SW, SE). Objects are inserted into the deepest node that fully contains them. When a leaf exceeds a capacity threshold, it subdivides.

Two flavors exist. A point quadtree stores point data; each split plane passes through a stored point. A region quadtree splits space uniformly at the midpoint of each axis, regardless of where the points are. Region quadtrees are the usual choice in engines: the split positions are implicit (computable from the node's bounds), so the tree stores no split-plane data.

Depth limits prevent degenerate input (many objects at the same position) from producing infinite recursion. A typical max depth of 8 gives 28 = 256 leaf cells per axis, fine resolution for most 2D scenes. The per-leaf capacity (max objects before subdivision) is usually between 4 and 16.

Worked example

On a 1,024 m map with a depth limit of 8, the smallest leaves are 1,024 รท 256 = 4 m across. With a capacity of 4, the fifth unit to walk into a leaf splits it into four children half as wide and pushes the five units down into them. An empty corner of the map stays one big leaf, however many units crowd the bases.

In the widget below, each click places an entity, and a leaf subdivides as soon as it overflows.

Live ยท Quadtree builder
entities
0
tree nodes
1
max depth reached
0
Entities as purple dots and the quadtree's cells drawn over them, with readouts for entities, tree nodes and the deepest level reached. Click anywhere on the canvas to place an entity, or press +20 random. When a leaf node exceeds the "max per leaf" threshold, it subdivides into four children. The depth slider caps how deep the tree can grow. Cluster entities in one corner to see deep subdivision; spread them evenly to see shallow, balanced splits.

Grid vs quadtree: range query comparison

The practical question is how much each structure touches to answer a range query. A grid visits every cell the query rectangle overlaps, whether those cells hold objects or not. A region quadtree never subdivides empty space, so an empty area is a few large leaves and a query there touches a handful of nodes. Over a dense cluster the balance flips: the quadtree walks down through internal nodes to reach small leaves and can visit more nodes than the grid has cells under the query. For roughly uniform distributions the grid usually wins outright, since it has no traversal at all.

Live ยท Grid vs Quadtree range query
grid: cells visited ยท points tested
-
quadtree: nodes visited ยท points tested
-
results found
-
Drag the yellow query rectangle, or tap where it should go. The 80 points sit in two clusters in opposite corners. A visited node is one whose box overlaps the query, internal nodes included, which is the quadtree's counterpart of a grid cell under the query. The rectangle starts in an empty corner: the quadtree answers from two nodes (the root and the empty top-right quadrant) while the grid still opens all 15 cells beneath it. Drag it onto a cluster and the count flips: the quadtree descends through 30 to 45 nodes against the grid's 12 to 20 cells, and both end up testing about the same points. Between the clusters, near the middle, the two come out roughly even, because the quadtree has already subdivided around each cluster's edge.
Quadtree insert + range query
#include <cstddef>
#include <memory>
#include <vector>

struct Entity { float posX, posY; };
struct AABB   { float minX, minY, maxX, maxY; };

struct QuadTreeNode {
    AABB bounds;
    int depth;
    int maxDepth;
    std::size_t maxPerLeaf;   // compared against items.size(), so unsigned like it
    std::vector<Entity*> items;
    std::unique_ptr<QuadTreeNode> children[4]; // NW, NE, SW, SE

    bool contains(float px, float py) const {
        return px >= bounds.minX && px < bounds.maxX
            && py >= bounds.minY && py < bounds.maxY;
    }

    // Children must inherit the limits, or the recursion tests garbage.
    std::unique_ptr<QuadTreeNode> makeChild(AABB childBounds) const {
        auto node = std::make_unique<QuadTreeNode>();
        node->bounds     = childBounds;
        node->depth      = depth + 1;
        node->maxDepth   = maxDepth;
        node->maxPerLeaf = maxPerLeaf;
        return node;
    }

    void subdivide() {
        float midX = (bounds.minX + bounds.maxX) * 0.5f;
        float midY = (bounds.minY + bounds.maxY) * 0.5f;
        children[0] = makeChild({bounds.minX, bounds.minY, midX, midY});
        children[1] = makeChild({midX, bounds.minY, bounds.maxX, midY});
        children[2] = makeChild({bounds.minX, midY, midX, bounds.maxY});
        children[3] = makeChild({midX, midY, bounds.maxX, bounds.maxY});

        // Re-insert existing items into children
        for (Entity* entity : items) {
            for (auto& child : children) {
                if (child->contains(entity->posX, entity->posY)) {
                    child->insert(entity);
                    break;
                }
            }
        }
        items.clear();
    }

    void insert(Entity* entity) {
        if (!contains(entity->posX, entity->posY)) return;

        // Leaf: store here if under capacity or at max depth
        if (!children[0] && (items.size() < maxPerLeaf || depth >= maxDepth)) {
            items.push_back(entity);
            return;
        }
        if (!children[0]) subdivide();

        for (auto& child : children) {
            if (child->contains(entity->posX, entity->posY)) {
                child->insert(entity);
                return;
            }
        }
    }

    // Range query: collect all entities whose positions fall inside queryBox.
    void query(const AABB& queryBox, std::vector<Entity*>& results) const {
        // Prune: if this node does not overlap the query, skip entirely
        if (queryBox.maxX < bounds.minX || queryBox.minX > bounds.maxX ||
            queryBox.maxY < bounds.minY || queryBox.minY > bounds.maxY) return;

        for (Entity* entity : items) {
            if (entity->posX >= queryBox.minX && entity->posX <= queryBox.maxX &&
                entity->posY >= queryBox.minY && entity->posY <= queryBox.maxY) {
                results.push_back(entity);
            }
        }

        if (children[0]) {
            for (const auto& child : children) {
                child->query(queryBox, results);
            }
        }
    }
};
#[derive(Clone, Copy)]
struct Entity { pos_x: f32, pos_y: f32 }

#[derive(Clone, Copy)]
struct AABB { min_x: f32, min_y: f32, max_x: f32, max_y: f32 }

struct QuadTreeNode {
    bounds: AABB,
    depth: u32,
    max_depth: u32,
    max_per_leaf: usize,
    items: Vec<Entity>,
    children: Option<Box<[QuadTreeNode; 4]>>,
}

impl QuadTreeNode {
    // Children inherit the limits, matching makeChild in the C++ pane.
    fn subdivide(&mut self) {
        let mid_x = (self.bounds.min_x + self.bounds.max_x) * 0.5;
        let mid_y = (self.bounds.min_y + self.bounds.max_y) * 0.5;
        let quadrant = |min_x, min_y, max_x, max_y| QuadTreeNode {
            bounds: AABB { min_x, min_y, max_x, max_y },
            depth: self.depth + 1,
            max_depth: self.max_depth,
            max_per_leaf: self.max_per_leaf,
            items: Vec::new(),
            children: None,
        };
        self.children = Some(Box::new([
            quadrant(self.bounds.min_x, self.bounds.min_y, mid_x, mid_y),
            quadrant(mid_x, self.bounds.min_y, self.bounds.max_x, mid_y),
            quadrant(self.bounds.min_x, mid_y, mid_x, self.bounds.max_y),
            quadrant(mid_x, mid_y, self.bounds.max_x, self.bounds.max_y),
        ]));
    }

    fn contains(&self, px: f32, py: f32) -> bool {
        px >= self.bounds.min_x && px < self.bounds.max_x
            && py >= self.bounds.min_y && py < self.bounds.max_y
    }

    fn insert(&mut self, entity: Entity) {
        if !self.contains(entity.pos_x, entity.pos_y) { return; }

        if self.children.is_none()
            && (self.items.len() < self.max_per_leaf
                || self.depth >= self.max_depth)
        {
            self.items.push(entity);
            return;
        }
        if self.children.is_none() { self.subdivide(); }

        let children = self.children.as_mut().unwrap();
        for child in children.iter_mut() {
            if child.contains(entity.pos_x, entity.pos_y) {
                child.insert(entity);
                return;
            }
        }
    }

    // The named lifetime is required: every reference pushed into `results`
    // borrows from `self`, and an elided lifetime would be a fresh one with
    // no relation to `&self`. `&mut Vec<&Entity>` is invariant in its element
    // type, so the compiler cannot silently coerce one into the other.
    fn query<'a>(&'a self, query_box: &AABB, results: &mut Vec<&'a Entity>) {
        // Prune: no overlap with this node
        if query_box.max_x < self.bounds.min_x
            || query_box.min_x > self.bounds.max_x
            || query_box.max_y < self.bounds.min_y
            || query_box.min_y > self.bounds.max_y
        { return; }

        for entity in &self.items {
            if entity.pos_x >= query_box.min_x
                && entity.pos_x <= query_box.max_x
                && entity.pos_y >= query_box.min_y
                && entity.pos_y <= query_box.max_y
            {
                results.push(entity);
            }
        }

        if let Some(children) = &self.children {
            for child in children.iter() {
                child.query(query_box, results);
            }
        }
    }
}
What's intentionally missing

The quadtree above is written to be read. A shipping one adds:

05Octrees

A 3D world adds height: a flying drone, a multi-story building, a cave under a mountain. An octree is a quadtree in three dimensions. Each node splits into eight children (2³). Insert, remove, and query follow the same logic, with an extra axis.

Two storage layouts are common. A pointer-based octree stores eight child pointers per node, same as the quadtree above. A linear octree encodes each node's position as a and stores all nodes in a flat sorted array. The Morton code (the index along a Z-order curve) interleaves the bits of the x, y, z cell coordinates. A parent's code is its child's code shifted right by three bits, and neighbors are found with bit arithmetic on the interleaved coordinates, so no pointer is ever dereferenced.

Worked example

Cell (x, y, z) = (5, 3, 1) has coordinate bits x = 101, y = 011, z = 001. Interleaving them one bit at a time, z y x from the lowest bit up, gives 001 010 111 in binary, Morton code 87. Shift right by three bits and you get 001 010, which is 10: exactly the Morton code of the parent cell (2, 1, 0), each coordinate halved.

Linear layouts matter for GPU work, and three GPU structures that are often lumped together use them differently. Morton-ordered linear hierarchies are the LBVH line: Lauterbach et al. sort primitives by Morton code to build a hierarchy in parallel[10], and Karras builds every level at once from a binary radix tree over those codes[11]. Sparse voxel octrees are a different design: Laine and Karras encode topology with 64-bit child descriptors carrying a valid mask, a leaf mask, and a relative child pointer, not with Morton codes[12]. NVIDIA's GVDB takes a third route: the VDB topology, a sparse hierarchy of grids whose branching factor is configurable per level, indexing into a voxel atlas and built for volumes whose topology changes every frame. An octree is the special case with a branching factor of two per axis, and GVDB's programming guide notes that such a tree "quickly requires more than 5 levels", its default depth limit, so the usual configurations branch much wider[13].

On the CPU, engine octrees are usually depth-limited and stored either with child pointers or in flat arrays addressed by index. Unreal's generic octree, TOctree2, is the second kind: its nodes live in a TArray with parent links and a free list, which is cheaper to update in place than a web of heap-allocated nodes[25].

06BSP trees

Without a depth buffer, a renderer has to draw walls in the right order from any viewpoint, or near walls get painted over by far ones. A BSP tree partitions space with arbitrary hyperplanes, not axis-aligned splits. Each internal node stores a plane (in 2D, a line; in 3D, a plane equation ax + by + cz + d = 0). Objects or polygon fragments are classified to the front or back half-space; objects that straddle the plane are split.

The rendering trick: given a viewpoint, traverse the BSP tree in a specific order. If the viewpoint is in front of a node's plane, draw the back subtree first, then the node's polygon, then the front subtree. This gives a back-to-front ordering suitable for the painter's algorithm (draw the farthest surfaces first and let nearer ones paint over them), without a depth buffer, the per-pixel record of the nearest surface drawn so far. For a node whose plane is x โˆ’ 5 = 0, a viewpoint at (8, 0, 2) gives 8 โˆ’ 5 = 3, positive, so it's in front: draw the back subtree, then the node's wall, then the front subtree.

Doom (1993) and Quake (1996) depended on BSP trees. The BSP gave Doom's renderer an exact wall order from any viewpoint without a per-pixel depth buffer[7]. The tree was built offline by the node builder and shipped in the WAD file (the NODES, SSECTORS and SEGS lumps). Doom's renderer reversed the traversal to front-to-back and tracked which screen columns were already fully drawn, so it could stop as soon as the screen was covered instead of overdrawing the way back-to-front painting does. Quake extended BSP with a PVS (potentially visible set), precomputed per BSP leaf. At runtime one decompressed bit row per frame ruled out whole sections of the level.

BSP trees are mostly historical for rendering (modern engines use BVH-based frustum culling and GPU occlusion queries). They remain relevant for CSG (constructive solid geometry) operations: boolean union, intersection, and difference of 3D meshes. Unreal's BSP-based level geometry workflow still exists, though static meshes have replaced it for most content.

07Bounding volume hierarchies

A BVH is a tree of bounding volumes, and it's what a ray tracer uses to avoid testing a ray against a million triangles. Each leaf holds one object (or a small cluster). Each internal node holds the bounding volume that encloses all objects in its subtree. The bounding volume is almost always an AABB because AABB-AABB overlap is six comparisons and AABB-ray intersection (the slab method) is fast and branchless.

Construction is top-down or bottom-up. Top-down: start with all objects, pick a split (axis and position), partition into left and right, recurse. Bottom-up (agglomerative): start with one leaf per object, repeatedly merge the two nodes whose combined bounding volume is smallest. Top-down is simpler and faster to build. Bottom-up can produce tighter trees, but the naive version rescans every pair before each of its n−1 merges, which is O(n³); practical builders cache each cluster's nearest neighbor or search only nearby clusters to bring that down.

Worked example

A balanced BVH over 1,000 objects is about log2 1,000 โ‰ˆ 10 levels deep. A ray that misses the root's left child box discards 500 objects with one box test, and a ray whose path narrows cleanly to one leaf tests about two boxes per level, around 20, instead of 1,000 objects.

Top-down split strategies:

The widget below shows a BVH being constructed step by step. Each step picks an axis, evaluates a split, and partitions the objects. The bounding boxes of the resulting nodes are drawn.

Live ยท BVH construction (top-down, SAH)
build step
0
nodes created
0
last action
ready
Rectangles are the objects and the colored boxes the BVH nodes, deeper levels warmer. Press step to watch one split at a time, or play, then new scene. Each step either creates a leaf (2 or fewer objects) or splits a group along the axis and position that minimizes the SAH cost. The outermost bounding box (the root) encloses all objects. Each subsequent split produces tighter child boxes. Randomize the scene to see how different object distributions produce different tree shapes.

In RTX hardware ray tracing, the BVH has . The TLAS (top-level acceleration structure) stores instance transforms and pointers to BLASes. The BLAS (bottom-level acceleration structure) stores the actual triangle geometry for each mesh. Rebuilding the TLAS per frame is cheap (it contains only instance data). Rebuilding a BLAS means processing every triangle of the mesh, so static geometry builds the BLAS once and reuses it.

08Surface area heuristic

The SAH cost model estimates the expected number of node intersection tests a random ray will perform. The key insight is that the probability a uniformly random ray hits a convex bounding volume is proportional to its surface area. The credit is usually given to Goldsmith and Salmon (1987)[5], who applied it to hierarchy construction; MacDonald and Booth's fuller derivation traces the surface-area-as-probability result itself back to Stone (1975)[6]. In 2D, surface area is the perimeter; in 3D, the actual surface area.

Worked example

A 2D node 10 ร— 10 holds ten objects: six in a tight 2 ร— 2 clump and four in a 3 ร— 3 clump. In 2D the "surface area" is the perimeter, so the node scores 40, the clumps 8 and 12. With traversal and intersection both costing 1, splitting around the clumps costs 1 + (8 รท 40) ร— 6 + (12 รท 40) ร— 4 = 3.4. Cutting the node into two 5 ร— 10 halves (perimeter 30 each) with five objects in each costs 1 + 0.75 ร— 5 + 0.75 ร— 5 = 8.5, and keeping it as a leaf costs 10. The heuristic picks the clump split, because a random ray is far less likely to hit small boxes.

For a node with bounding volume A containing children L and R, the formula below estimates what a split costs a random ray:

SAH cost C(L, R) = Ctrav + SA(L)SA(A) · NL · Cisect + SA(R)SA(A) · NR · Cisect

In words: pay once to visit the node, then pay for each child's objects weighted by the chance a ray that hits the parent also hits that child. SA(X) is the surface area of bounding volume X. NL and NR are the object counts in the left and right children. Ctrav is the cost of traversing one internal node; Cisect is the cost of intersecting one leaf object. The ratio SA(L)/SA(A) is the conditional probability that a ray hitting A also hits L.

The builder evaluates this cost for many candidate splits (every object boundary on each axis, or a fixed number of bins), picks the split with the lowest cost, and recurses. If no split is cheaper than making the node a leaf, the node becomes a leaf.

Binned SAH (Wald 2007[9]) approximates the exact evaluation by distributing objects into a fixed number of bins (typically 8 to 32) along each axis. The cost is evaluated at each bin boundary, not at every object boundary. This reduces the per-level cost from O(n log n) to O(n). In Wald's tests, a 252K-triangle animated model was rebuilt every frame in under 20 ms, which moved SAH quality into per-frame rebuilds.

09BVH traversal

A player fires a hitscan rifle, and the game needs the nearest thing along that line out of tens of thousands of objects. The canonical BVH traversal for ray queries is a stack-based depth-first search. It pushes the root, then loops: pop a node and test the ray against its AABB; on a miss, discard it; on a hit at a leaf, test the ray against the leaf's objects; on a hit at an internal node, push both children. The stack never holds more entries than the tree is deep plus one, and a million-primitive tree is at least 20 levels deep (log2 of a million), more when SAH builds it unbalanced.

The slab method for ray-AABB intersection computes entry and exit times on each axis independently, then checks if the intervals overlap. It is branchless, maps well to SIMD (instructions that do the same operation on several values at once), and is the standard in production ray tracers.

Worked example

A ray starts at (0, 0) heading (1, 0.5). Against a box spanning x = 2 to 4 and y = 0 to 3, the x slab gives t = 2 to 4 and the y slab t = 0 to 6. The latest entry is 2 and the earliest exit is 4, and since 2 < 4 the ray hits, entering at t = 2. Raise the box to y = 2.5 to 3 and the y slab becomes t = 5 to 6: entry 5 comes after exit 4, so it's a miss.

Ray-AABB intersection (slab method)
#include <algorithm>

struct Vec3 { float x, y, z; };
struct AABB { float minX, minY, minZ, maxX, maxY, maxZ; };

// Slab-method ray-AABB intersection.
// Returns true if the ray [origin, origin + dir * tmax] hits the box.
// On hit, tmin is the entry distance (may be negative if origin is inside).
bool rayAABB(const Vec3& origin, const Vec3& invDir,
             const AABB& box, float& tmin, float tmax)
{
    // invDir = 1.0 / direction, precomputed to avoid division per test
    float t1 = (box.minX - origin.x) * invDir.x;
    float t2 = (box.maxX - origin.x) * invDir.x;
    tmin = std::min(t1, t2);
    tmax = std::min(tmax, std::max(t1, t2));

    t1 = (box.minY - origin.y) * invDir.y;
    t2 = (box.maxY - origin.y) * invDir.y;
    tmin = std::max(tmin, std::min(t1, t2));
    tmax = std::min(tmax, std::max(t1, t2));

    t1 = (box.minZ - origin.z) * invDir.z;
    t2 = (box.maxZ - origin.z) * invDir.z;
    tmin = std::max(tmin, std::min(t1, t2));
    tmax = std::min(tmax, std::max(t1, t2));

    return tmax >= std::max(tmin, 0.0f);
}
#[derive(Clone, Copy)]
struct Vec3 { x: f32, y: f32, z: f32 }

#[derive(Clone, Copy)]
struct AABB {
    min_x: f32, min_y: f32, min_z: f32,
    max_x: f32, max_y: f32, max_z: f32,
}

// Slab-method ray-AABB intersection.
// Returns Some(tmin) on hit, None on miss.
fn ray_aabb(origin: Vec3, inv_dir: Vec3, aabb: &AABB, t_max: f32) -> Option<f32> {
    let t1x = (aabb.min_x - origin.x) * inv_dir.x;
    let t2x = (aabb.max_x - origin.x) * inv_dir.x;
    let mut tmin = t1x.min(t2x);
    let mut tmax = t_max.min(t1x.max(t2x));

    let t1y = (aabb.min_y - origin.y) * inv_dir.y;
    let t2y = (aabb.max_y - origin.y) * inv_dir.y;
    tmin = tmin.max(t1y.min(t2y));
    tmax = tmax.min(t1y.max(t2y));

    let t1z = (aabb.min_z - origin.z) * inv_dir.z;
    let t2z = (aabb.max_z - origin.z) * inv_dir.z;
    tmin = tmin.max(t1z.min(t2z));
    tmax = tmax.min(t1z.max(t2z));

    if tmax >= tmin.max(0.0) { Some(tmin) } else { None }
}

Two optimizations matter in practice:

The widget below fires rays through a 2D BVH built by median split. A click casts a ray from the middle of the left edge toward the click, and the traversal animates one popped node at a time: green boxes are hits, red boxes with X marks are misses, and amber dashed boxes were hit by the ray but start beyond the closest hit already found, so ordered traversal skips them.

Live ยท Ray-BVH traversal
nodes popped
0
entered
0
early-outs
0
Objects, the BVH boxes, and a ray, with readouts for nodes popped, nodes entered and early-outs. Click (or tap) anywhere on the canvas to cast a ray at that point. Each popped node's AABB goes through the slab test. A miss discards the node and its whole subtree; a node the ray does hit is still skipped if its entry distance lies beyond the closest object hit so far. "Early-outs" counts both kinds, with the number of internal nodes (whole subtrees) in parentheses. The closest object hit so far is filled yellow.

10Spatial hashing

An open-world map is too big for a dense grid array, but most of its cells are empty at any moment. A spatial hash is a grid stored as a hash table instead of a dense 2D/3D array. Insert: compute the cell coordinates from the object's position, hash them to a table index, append the object to that bucket. Query: compute which cells overlap the query region, look up each bucket, test the contents.

Cell size selection matters. If cells are too large, each cell contains too many objects and the query degenerates to brute force within the cell. If cells are too small, objects span multiple cells (the large-object problem again). A common heuristic sets the cell size near the typical object diameter (twice the average radius): an object that small can overlap at most two cells per axis, so at most four in 2D and eight in 3D, and a neighbor search around a point needs only the surrounding 3×3 block.

Worked example

With 1 m cells, a 1 m-wide crate centered at (12.3, 7.8) spans x = 11.8 to 12.8 and y = 7.3 to 8.3, so it lands in cells x = 11 and 12 by y = 7 and 8: four cells, the most a 1 m object can touch in 2D. A 10 m truck in the same grid touches up to 11 ร— 11 = 121 cells.

Multi-level hashing uses several hash tables at different cell sizes (e.g., 1x, 4x, 16x). Small objects go in the fine grid, large objects in the coarse grid. A query checks all levels. Each object then lands in a level where it spans only a few cells, which keeps mixed-size scenes out of the large-object problem at the cost of one lookup per level.

Spatial hash insert + query
#include <cmath>
#include <cstdint>
#include <vector>

struct Entity { float posX, posY; };
struct AABB   { float minX, minY, maxX, maxY; };

struct SpatialHash {
    float cellSize;
    int tableSize;
    std::vector<std::vector<Entity*>> buckets;

    SpatialHash(float cellSize, int tableSize)
        : cellSize(cellSize), tableSize(tableSize),
          buckets(tableSize) {}

    // Hash cell coordinates to a bucket index.
    // The primes are Teschner et al.'s; the unsigned cast matters.
    // On int, cellX * 73856093 overflows at cellX = 30, and signed overflow is
    // undefined behavior, so the compiler is free to assume it never happens.
    // Unsigned arithmetic is defined to wrap, which is what we actually want.
    uint32_t hash(int cellX, int cellY) const {
        uint32_t hx = static_cast<uint32_t>(cellX) * 73856093u;
        uint32_t hy = static_cast<uint32_t>(cellY) * 19349663u;
        return hx ^ hy;   // caller reduces modulo tableSize
    }

    int toCell(float coord) const {
        return static_cast<int>(std::floor(coord / cellSize));
    }

    void insert(Entity* entity) {
        int cx = toCell(entity->posX);
        int cy = toCell(entity->posY);
        int idx = static_cast<int>(hash(cx, cy) % static_cast<uint32_t>(tableSize));
        buckets[idx].push_back(entity);
    }

    // Range query: find all entities within queryBox.
    void query(const AABB& queryBox,
               std::vector<Entity*>& results) const
    {
        int minCX = toCell(queryBox.minX);
        int maxCX = toCell(queryBox.maxX);
        int minCY = toCell(queryBox.minY);
        int maxCY = toCell(queryBox.maxY);

        for (int cx = minCX; cx <= maxCX; ++cx) {
            for (int cy = minCY; cy <= maxCY; ++cy) {
                int idx = static_cast<int>(hash(cx, cy)
                          % static_cast<uint32_t>(tableSize));
                for (Entity* entity : buckets[idx]) {
                    // Two cells of this query can hash to the same bucket.
                    // Report an entity only from its own cell, or a shared
                    // bucket hands back the same entity twice.
                    if (toCell(entity->posX) != cx || toCell(entity->posY) != cy)
                        continue;
                    // Fine check: is this entity actually in the query?
                    if (entity->posX >= queryBox.minX
                        && entity->posX <= queryBox.maxX
                        && entity->posY >= queryBox.minY
                        && entity->posY <= queryBox.maxY)
                    {
                        results.push_back(entity);
                    }
                }
            }
        }
    }

    void clear() {
        for (auto& bucket : buckets) bucket.clear();
    }
};
#[derive(Clone, Copy)]
struct Entity { pos_x: f32, pos_y: f32 }

#[derive(Clone, Copy)]
struct AABB { min_x: f32, min_y: f32, max_x: f32, max_y: f32 }

struct SpatialHash {
    cell_size: f32,
    table_size: usize,
    buckets: Vec<Vec<Entity>>,
}

impl SpatialHash {
    fn new(cell_size: f32, table_size: usize) -> Self {
        SpatialHash {
            cell_size,
            table_size,
            buckets: vec![Vec::new(); table_size],
        }
    }

    // Same primes and the same reinterpret-as-unsigned as the C++ pane.
    // wrapping_mul because i32 multiplication overflows at cell_x = 30 and
    // Rust panics on that in debug builds. Reinterpret with `as u32` rather
    // than taking an absolute value: abs() would map cell -5 and cell 5 onto
    // the same bucket and give away half the table.
    fn hash(&self, cell_x: i32, cell_y: i32) -> usize {
        let raw = (cell_x.wrapping_mul(73856093))
            ^ (cell_y.wrapping_mul(19349663));
        (raw as u32 as usize) % self.table_size
    }

    fn to_cell(&self, coord: f32) -> i32 {
        (coord / self.cell_size).floor() as i32
    }

    fn insert(&mut self, entity: Entity) {
        let cx = self.to_cell(entity.pos_x);
        let cy = self.to_cell(entity.pos_y);
        let idx = self.hash(cx, cy);
        self.buckets[idx].push(entity);
    }

    fn query(&self, query_box: &AABB) -> Vec<&Entity> {
        let mut results = Vec::new();
        let min_cx = self.to_cell(query_box.min_x);
        let max_cx = self.to_cell(query_box.max_x);
        let min_cy = self.to_cell(query_box.min_y);
        let max_cy = self.to_cell(query_box.max_y);

        for cx in min_cx..=max_cx {
            for cy in min_cy..=max_cy {
                let idx = self.hash(cx, cy);
                for entity in &self.buckets[idx] {
                    // Same guard as the C++ pane: two query cells can share
                    // a bucket, so only report an entity from its own cell.
                    if self.to_cell(entity.pos_x) != cx || self.to_cell(entity.pos_y) != cy {
                        continue;
                    }
                    if entity.pos_x >= query_box.min_x
                        && entity.pos_x <= query_box.max_x
                        && entity.pos_y >= query_box.min_y
                        && entity.pos_y <= query_box.max_y
                    {
                        results.push(entity);
                    }
                }
            }
        }
        results
    }

    fn clear(&mut self) {
        for bucket in &mut self.buckets { bucket.clear(); }
    }
}
What's intentionally missing

The hash is the teaching version. Production versions add:

11Loose bounds and margin

A crowd of NPCs shuffles a few centimeters every frame, and a tree that rebuilds a branch for every centimeter spends its frame on bookkeeping. Tight bounding volumes cause excessive re-insertion in dynamic scenes. When an object moves by one pixel, its AABB changes, and the tree must remove and re-insert it. If most objects move every frame, most leaves are removed and re-inserted every frame, which costs about as much as rebuilding the tree.

Loose octrees (Ulrich 2000[8]) expand each node's bounds by a factor of two. A node that would normally cover a 10x10 region instead covers 20x20, centered on the same position. The expansion guarantees that any object whose center is in the original region, and whose radius is at most half the original region size, fits entirely within the expanded bounds. Insertion needs no straddle test and no multi-cell registration: the object's radius picks the depth, and its center picks the node at that depth.

in Box2D's dynamic tree[20] use a different approach. Each leaf's AABB is enlarged by a margin, and the object moves freely within the fat AABB without triggering a tree update. Only when the object's tight AABB leaves the fat AABB does the tree remove and re-insert the leaf with a new fat box. Box2D v2.4 used a 0.1 m margin and also extended the new box by four times the body's latest displacement, in the direction of motion, so a body moving steadily one way escapes it less often[24].

The trade-off: larger margins mean fewer re-insertions (good for update cost), but looser bounding volumes mean more false positives in queries (bad for query cost). Catto's GDC 2019 talk gives no formula for the margin: "There are many schemes for enlarging the AABB. Choose one that works well for your game."[20] Box2D v3 shows the balance moving with the rest of the design. Its margin is capped at 5 cm, and the source comment explains that benchmarks favored a small margin because it produces fewer contacts; the larger margin had existed to avoid insert and remove churn, which v3's broadphase no longer does, having moved to "a two-phase refit and rebuild setup similar to Jolt"[24].

Worked example

Under Box2D v2.4's rule, a body moving 5 cm per step gets a new fat box that reaches 0.1 m + 4 ร— 0.05 m = 0.3 m ahead of it. It crosses that in 0.3 รท 0.05 = 6 steps, so it's re-inserted every sixth step instead of every step. In a loose octree whose nodes are 10 m wide, each node's loose box is 20 m wide, so any object up to 5 m in radius whose center is in the node fits without straddling.

12Queries

The same tree serves the physics engine's overlap tests, the gun's raycasts, the renderer's culling and the AI's "who's nearby". All of these structures answer the same four query types, but each query traverses the tree differently:

QueryTest per nodeTraversalEngine use
Range (AABB overlap) AABB-AABB overlap (6 comparisons) Descend into every child whose AABB overlaps the query AABB. Prune the rest. Broad-phase collision, proximity triggers, area-of-effect abilities.
Ray Ray-AABB intersection (slab method) Stack-based DFS with ordered traversal (near child first). tmax tightens as closer hits are found. Mouse picking, line-of-sight, bullet traces, GPU ray tracing.
Frustum AABB-frustum test (6 plane tests, or SAT) Same as range query but with a frustum instead of an AABB. Entire subtrees outside the frustum are culled. Frustum culling for the render list. Runs every frame.
K-nearest-neighbor Distance to AABB (min-distance bounding) Priority queue ordered by minimum possible distance. Prune any node whose min-distance exceeds the current k-th nearest. AI perception ("find the 3 closest enemies"), flocking neighbors, point-cloud queries.

The key difference is how aggressively each query can prune. Ray queries get tighter with every hit (tmax shrinks). K-NN queries get tighter as the k-th neighbor gets closer. Range and frustum queries have fixed bounds and prune only by spatial overlap.

Worked example

An AI asks for its 3 nearest enemies. Midway through the search the third-nearest found so far is 12 m away. A node whose box is at least 15 m from the AI can't contain anything closer, so the whole subtree is skipped without opening it.

13Sweep and prune

Everything so far builds a structure over space and queries it. asks a narrower question: which pairs of moving objects might be touching this frame? Sweep and prune answers it with no tree at all, by keeping the objects sorted.

Give every object an AABB and project it onto each axis, so each object becomes an interval on x, another on y, another on z. Two AABBs overlap exactly when their intervals overlap on every axis. Cohen, Lin, Manocha and Ponamgi's I-COLLIDE keeps three sorted lists, one per axis, each holding all 2n endpoint values, plus one overlap flag per pair per axis. A pair is a collision candidate when all three of its flags are set[22].

Sorting 2n values per axis per frame is O(n log n), which buys nothing over testing every pair at small n. The trick is that you never do that sort. At 60 Hz an object moves a fraction of its own size between frames, so last frame's list is already almost in order. That is , and insertion sort on an almost-sorted array costs time proportional to the number of inversions, not to n log n. The paper puts it in one line: "Insertion sort works well for previously sorted lists."[22]

The flags come along for free. A pair's overlap status on an axis can only change when the sort exchanges a maximum past a minimum: two minima swapping, or two maxima swapping, leaves every interval relationship intact. So the candidate set is maintained by the sort itself, in time proportional to the number of exchanges, and is never recomputed from scratch after the first frame.

Live · Sweep and prune under coherent motion and under teleports
exchanges this frame
0
exchanges/frame avg
-
overlapping pairs
0
brute-force pair tests
0
Boxes drifting in the top panel, with a red line for every overlapping pair, and each axis's sorted endpoint list below. Compare the exchange readouts under coherent drift, then switch to teleport and change the box count. Both modes run identical code: one insertion sort per axis over the endpoint lists, with the overlapping-pair set updated only from the exchanges that sort performs and never recomputed. Right-pointing markers are interval minima, left-pointing are maxima, and anything the sort moved this frame is outlined amber. Under coherent drift at 12 boxes the lists arrive nearly in order and the sort averages about 3 exchanges a frame, against 66 brute-force pair tests. Switch to teleport and every box lands somewhere new: the lists arrive in essentially random order and the average settles near 255, just under the 276 that the expected inversion count of a random 24-element permutation predicts for two axes (each box's own minimum always precedes its maximum, which removes some inversions). Sweep and prune is then doing about four times the work of just testing every pair.

That last property is also the failure mode. The linear behavior is an expectation, not a bound: the paper states that in the worst case the number of exchanges on each axis is O(n²), "with an extremely small constant"[22]. Coherence is an assumption about the input, and games break it on purpose. A level streams in, a ragdoll comes apart, a teleport fires, a shockwave wakes a thousand sleeping bodies at once.

That is why PhysX ships a family rather than one algorithm. Its sweep-and-prune broadphase (eSAP) is documented as "a good generic choice with great performance when many objects are sleeping," whose "performance can degrade significantly though, when all objects are moving, or when large numbers of objects are added to or removed from the broad-phase." Multi box pruning (eMBP) "does not suffer from the same performance issues as eSAP when all objects are moving or when inserting large numbers of objects," but its "generic performance when many objects are sleeping might be inferior to eSAP, and it requires users to define world bounds." Automatic box pruning (eABP) manages those bounds itself, offering "the convenience of eSAP coupled to the performance of eMBP," and the documentation calls it "a good default choice for the broadphase"[14]. Each option bets on a different pattern of motion.

14Dynamic updates

A physics scene has 3,000 bodies moving every frame, and the broadphase tree has to follow them. Static scenes build the acceleration structure once and query it many times. Dynamic scenes must update the structure every frame. Three strategies:

Erin Catto's GDC 2019 talk[20] lays out the remove-and-reinsert design Box2D used. Insertion picks the sibling whose pairing adds the least surface area to the tree, found with a branch-and-bound search after Bittner et al., instead of descending greedily. On the way back up, it refits the ancestor boxes and applies local tree rotations chosen to reduce surface area (after Kensler 2008), which also repairs the damage sorted input does to an incrementally built tree. The talk frames those rotations as the BVH counterpart of AVL rotations, but the goal is lower SAH cost, not equal subtree heights.

Worked example

For those 3,000 bodies, a rebuild costs on the order of 3,000 ร— log2 3,000 โ‰ˆ 35,000 steps per frame. Incremental updates with fat margins, if a tenth of the bodies leave their margin in a typical frame, cost about 300 ร— log2 3,000 โ‰ˆ 3,500. Refitting touches each of the tree's nodes once, a few thousand box updates, but never fixes a tree whose shape has gone stale.

The widget below shows 15 objects moving. Compare "fat AABBs" mode (objects move freely within their margins, and re-insertion happens only when the tight box leaves the fat box) with "tight AABBs" mode (the stored box is the object's own box, so every move forces a re-insertion). The re-insertion counters show the difference.

Live ยท Dynamic AABB tree: fat vs tight
total re-insertions
0
frames elapsed
0
re-inserts/frame
-
Fifteen moving objects with their stored boxes, and readouts for total re-insertions and re-inserts per frame. Switch between fat and tight AABBs and compare the counter. In fat-AABB mode, the dashed outlines are the fat boxes the tree stores: green while the object is still inside its margin, red on the frame the object escapes and is re-inserted. In tight-AABB mode the stored box is the object's own box, so every object is re-inserted every frame and the rate sits at 15 per frame. With the 12-unit margin, fat mode runs well under one re-insertion per frame.

15Case studies

Shipping engines rarely use one structure for everything; they pick per job.

Bullet Physics: DbvtBroadphase

Bullet's default broadphase is btDbvtBroadphase[21], a pair of dynamic AABB trees: one for proxies that have moved recently and one for proxies that have stayed still. The btDbvt class is a dynamic bounding volume tree with incremental insertion and removal. A new leaf descends toward whichever child box is closer by a cheap center-distance test, not an SAH cost, and an incremental optimization pass removes and re-inserts a few leaves each frame, following a different root-to-leaf path each time. Proxies migrate from the moving tree to the still one once they stop, and back when they move.

Unreal Engine: TOctree2

Unreal's generic octree, TOctree2, keeps its nodes in flat arrays (a node array, parent links, and a free list of recycled node slots) rather than as individually allocated nodes[25]. Elements are added and removed individually, so a moving object is removed and re-added rather than the tree being rebuilt. Physics overlap and trace queries do not go through it: they run against the physics engine's own acceleration structures. Nanite's cluster culling uses yet another structure, covered below.

PhysX (and Unity): broadphase versus scene queries

PhysX, which Unity integrates for 3D physics, does not use a BVH for its broadphase. Its documented broadphase types are sweep-and-prune (eSAP), multi box pruning (eMBP), automatic box pruning (eABP), a parallel variant (ePABP), and a GPU implementation[14]. PhysX's AABB tree is the scene-query pruner, serving raycasts and overlaps, which is a separate stage from pair-finding. Keeping those two apart matters: the broadphase answers "which pairs might touch this frame", the scene-query structure answers "what does this ray hit". Jolt, used on Horizon Forbidden West, takes a third route: its broadphase is a quadtree of AABBs (four children per node) whose nodes are expanded in place as bodies move, and a tight replacement tree is built in the background during the next physics step and swapped in before the step ends[15].

Nanite: cluster culling hierarchy

Nanite (Unreal Engine 5) groups triangles into clusters of 128 and simplifies them into a hierarchy that is a DAG rather than a tree, because the merge-and-split step leaves a simplified parent shared by the groups it came from[23]. Culling runs against a second structure built over the clusters: an eight-wide BVH whose leaves are the group-sized cluster lists and whose internal nodes carry the maximum of their children's ParentError. Keying on ParentError rather than the cluster's own error is the key design choice, and the talk gives the reason: any cluster whose parent error is already under the threshold can be culled, so "an acceleration structure for LOD culling should be based on ParentError, not the ClusterError itself." Both structures are built offline at asset import, not at runtime.

RTX TLAS/BLAS

The DirectX and Vulkan ray tracing APIs expose a two-level BVH. The BLAS contains per-mesh triangle geometry, built once (or refit or rebuilt for deformable meshes). The TLAS contains instance transforms and BLAS references, rebuilt or refit every frame. On NVIDIA hardware, RT Cores accelerate BVH traversal and ray-triangle intersection[19]. What the driver does inside the build cannot be observed through the API: both DXR and Vulkan describe the acceleration structure as opaque and specify no construction algorithm[17]. The application's levers are how geometry is split into BLASes and instances, the build-quality hints, update-versus-rebuild, and compaction. Read PREFER_FAST_TRACE as what the spec actually promises, a rule of thumb of "about 2-3 times the build time than default in order to get better tracing performance", rather than as an instruction to run a binned SAH build.

16Pitfalls

17What's next

In this series the next page is Entity Component System, which organizes the game objects these structures index. Beyond that, GPU BVH construction is an active research area. Karras (2012) builds every node of the tree in parallel from sorted Morton codes[11], and Apetrei (2014) folded the bottom-up bounding-box pass into the same parallel sweep. Vendor builders behind DXR, Vulkan RT and OptiX are mostly closed, so which algorithm a given driver runs is generally not public.

On the CPU, Embree ships separate builders selected by build quality rather than one layered build, documented as "a high-quality SAH builder using spatial splits, a standard SAH builder, and a very fast Morton builder", plus a refit path[16]. Mixing Morton and SAH construction in one tree is also a real technique. Lauterbach et al.'s hybrid builds the highest levels with LBVH and the rest with SAH, and reports that it "attains roughly half the construction speed of LBVH while retaining essentially all the quality of the pure SAH-based construction"[10].

Learned index structures for spatial data are an emerging research direction. Kraska et al. (2018) showed that neural networks can replace B-tree indexes for 1D data. Extending this to spatial data (replacing R-trees or BVHs with learned models that predict which region of space contains relevant objects) is an active area, though nothing has shipped in a game engine yet.

Compressed octrees for voxel data are relevant to voxel engines and sparse volume rendering. Kรคmpe, Sintorn and Assarsson (2013) turned the sparse voxel octree into a DAG (a directed acyclic graph in which identical subtrees are stored once and shared) and report node counts one to three orders of magnitude lower in every scene they tested, irregular ones included[26].

18Sources

  1. Donald Meagher. "Geometric Modeling Using Octree Encoding." Computer Graphics and Image Processing, 19(2):129-147, June 1982. ScienceDirect. Introduces octree encoding for 3D solid modeling. Storage proportional to surface area; linear-time boolean operations.
  2. Henry Fuchs, Zvi M. Kedem, Bruce F. Naylor. "On Visible Surface Generation by A Priori Tree Structures." Computer Graphics (SIGGRAPH '80 Proceedings), 14(3):124-133, July 1980. ResearchGate. The foundational BSP tree paper. Introduces binary space partitioning for front-to-back polygon rendering.
  3. Matthias Teschner, Bruno Heidelberger, Matthias Müller, Danat Pomeranets, Markus Gross. "Optimized Spatial Hashing for Collision Detection of Deformable Objects." VMV 2003, pp. 47-54. cgl.ethz.ch (PDF). The origin of the constants in §3 and §10: "p1, p2, p3 are large prime numbers, in our case 73856093, 19349663, 83492791". The paper also states the authors "have not systematically investigated the characteristics of hash functions", and that "the quality of the hash function is less important for larger hash tables".
  4. Antonin Guttman. "R-Trees: A Dynamic Index Structure for Spatial Searching." Proceedings of the 1984 ACM SIGMOD International Conference on Management of Data, pp. 47-57, 1984. ACM DL. The R-tree: a height-balanced tree of bounding rectangles for spatial indexing, the database-side counterpart of the AABB trees used in game physics.
  5. Jeffrey Goldsmith, John Salmon. "Automatic Creation of Object Hierarchies for Ray Tracing." IEEE Computer Graphics and Applications, 7(5):14-20, May 1987. IEEE Xplore. First automatic BVH construction algorithm. Introduced surface area as a proxy for ray-hit probability.
  6. J. David MacDonald, Kellogg S. Booth. "Heuristics for ray tracing using space subdivision." The Visual Computer 6(3), 1990, pp. 153-166. rose-hulman.edu (PDF). The primary derivation behind the SAH in §8, and the correct attribution chain: it credits the surface-area-as-probability result to Stone (1975), with Goldsmith and Salmon (1987) applying it to hierarchies.
  7. Fabien Sanglard. Game Engine Black Book: Doom. Self-published, 2018. fabiensanglard.net/gebbdoom. Detailed technical analysis of Doom's BSP-based renderer, including the node builder and the runtime traversal.
  8. Thatcher Ulrich. "Loose Octrees." In Game Programming Gems, Charles River Media, 2000. tulrich.com/geekstuff/partitioning.html. Expand node bounds by 2x to eliminate the straddle problem. Objects always fit in exactly one node.
  9. Ingo Wald. "On fast Construction of SAH-based Bounding Volume Hierarchies." Proceedings of the IEEE Symposium on Interactive Ray Tracing, pp. 33-40, 2007. PDF. Binned SAH: approximate the cost function with bins for O(n) per-level construction. Enables per-frame BVH rebuilds.
  10. Christian Lauterbach, Michael Garland, Shubhabrata Sengupta, David Luebke, Dinesh Manocha. "Fast BVH Construction on GPUs." Computer Graphics Forum 28(2), Eurographics 2009. luebke.us (PDF). The LBVH origin paper, and the source of the SAH-over-LBVH hybrid in §17: building the top levels with LBVH and the rest with SAH "attains roughly half the construction speed of LBVH while retaining essentially all the quality of the pure SAH-based construction."
  11. Tero Karras. "Maximizing Parallelism in the Construction of BVHs, Octrees, and k-d Trees." HPG 2012. research.nvidia.com (PDF). Builds on the Morton-code ordering of Lauterbach et al. [10] and removes its level-by-level serialization: "We present a novel approach that improves scalability by constructing the entire tree in parallel", via an in-place binary radix tree over the sorted codes.
  12. Samuli Laine, Tero Karras. "Efficient Sparse Voxel Octrees." I3D 2010, NVIDIA Research. research.nvidia.com (PDF). Cited in §5 for what an SVO stores: "We encode the topology of the octree using 64-bit child descriptors", each holding a child pointer, a valid mask and a leaf mask.
  13. Rama Hoetzlein. "GVDB: Raytracing Sparse Voxel Database Structures on the GPU." HPG 2016, and the NVIDIA GVDB Voxels Programming Guide. ramakarl.com (PDF); programming guide (GitHub). Cited in §5: GVDB is a VDB-style "sparse hierarchy of grids" with per-level branching, in which a <1,1,1,1,1> configuration "defines an octree"; the guide notes that "an octree quickly requires more than 5 levels."
  14. NVIDIA. "Rigid Body Collision" (Broad-phase Algorithms), PhysX 5.4 documentation. nvidia-omniverse.github.io. The source for §15: PhysX's broadphase types are sweep-and-prune, multi box pruning, automatic box pruning, its parallel variant, and a GPU implementation. None is a BVH.
  15. Jorrit Rouwe. "Jolt Physics Architecture." github.com/jrouwe/JoltPhysics. Cited in §15: "Our broad phase is a quad tree, which means each node has 4 children", with a tight-fitting replacement tree built in the background during the physics step and swapped in before it ends.
  16. Intel. Embree API documentation (rtcSetSceneBuildQuality and the BVH Builder tutorial). github.com/RenderKit/embree. Cited in §17: Embree exposes "a high-quality SAH builder using spatial splits, a standard SAH builder, and a very fast Morton builder" as separate build qualities, plus a refit path.
  17. Microsoft. DirectX Raytracing (DXR) Functional Spec, v1.48. microsoft.github.io/DirectX-Specs. The two-level TLAS/BLAS model (§2, §15), and the point that driver BVH construction is not observable: the API asks the system "to build an opaque acceleration structure", specifies no algorithm, and describes PREFER_FAST_TRACE only as a rule of thumb of "about 2-3 times the build time than default". Vulkan uses the same opaque framing.
  18. Ingo Wald, Solomon Boulos, Peter Shirley. "Ray Tracing Deformable Scenes Using Dynamic Bounding Volume Hierarchies." ACM Transactions on Graphics, 26(1), January 2007. ACM DL. Demonstrates BVH refitting for deformable scenes with minimal quality loss compared to full rebuilds.
  19. NVIDIA. NVIDIA Turing GPU Architecture (whitepaper WP-09183-001), 2018. nvidia.com (PDF). "RT Cores accelerate Bounding Volume Hierarchy (BVH) traversal and ray/triangle intersection testing (ray casting) functions."
  20. Erin Catto. "Dynamic Bounding Volume Hierarchies." GDC 2019. PDF. The broadphase behind Box2D: enlarged AABBs, remove/re-insert on movement, SAH-cost sibling selection by branch and bound, and surface-area-reducing tree rotations (citing Kensler 2008 and Bittner 2015).
  21. Erwin Coumans. "Bullet Physics Library." Open source, 2003-present. GitHub. btDbvtBroadphase and btDbvt (src/BulletCollision/BroadphaseCollision): two dynamic AABB trees, one for moving and one for resting proxies, proximity-based leaf insertion, and incremental re-insertion as the optimization pass. Bullet is the rigid-body engine in Blender and was Godot 3's default 3D physics backend.
  22. Jonathan D. Cohen, Ming C. Lin, Dinesh Manocha, Madhav Ponamgi. "I-COLLIDE: An Interactive and Exact Collision Detection System for Large-Scale Environments." I3D 1995, pp. 189-196. cs.princeton.edu (PDF). The sweep-and-prune reference, including the coherence argument this tutorial relies on: keeping the sorted lists between frames means "the lists will be nearly sorted, so we can sort in expected O(n) time."
  23. Brian Karis, Rune Stubbe, Graham Wihlidal. "Nanite: A Deep Dive." SIGGRAPH 2021 Advances in Real-Time Rendering in Games. advances.realtimerendering.com (PDF). Cited in §15 for the cluster hierarchy: 128-triangle clusters, a BVH8 over a DAG rather than a tree, and internal nodes keyed on the max of children's ParentError, since "an acceleration structure for LOD culling should be based on ParentError, not the ClusterError itself."
  24. Erin Catto. Box2D source: v2.4.1 b2_common.h and b2_dynamic_tree.cpp; v3 constants.h. The fat-AABB margins in §11: v2.4's b2_aabbExtension of 0.1 m and b2_aabbMultiplier of 4 applied to the displacement in MoveProxy; v3's B2_MAX_AABB_MARGIN of 5 cm, with the comment that the broadphase "now uses a two-phase refit and rebuild setup similar to Jolt".
  25. Epic Games. "TOctree2." Unreal Engine API reference (Math/GenericOctree.h). dev.epicgames.com. Lists the class's storage in §5 and §15: TreeNodes (a TArray of nodes), ParentLinks, TreeElements and a FreeList of reusable node slots, with per-element AddElement and RemoveElement.
  26. Viktor Kämpe, Erik Sintorn, Ulf Assarsson. "High Resolution Sparse Voxel DAGs." ACM Transactions on Graphics 32(4), SIGGRAPH 2013. chalmers.se (PDF). Cited in §17: "In all tested scenes, even the highly irregular ones, the number of nodes is reduced by one to three orders of magnitude."

See also