BVH hierarchical bounding volume principle and implementation code

In this chapter, we will learn about the BVH method used and described by Kay and Kajiya. The idea of ​​grouping bounding volumes into larger volumes that we group ourselves etc. is very simple and easy to understand.

insert image description here

Recommendation: Use NSDT Designer to quickly build programmable 3D scenes.

1. Hierarchical enclosing blocks

In Figure 2, we show a set of objects (here a box for simplicity) associated with their respective bounding volumes. By merging these bounding boxes together (usually via proximity), we obtain larger bounding volumes that represent groups of objects.

insert image description here

Figure 2: Example of a bounding volume hierarchy. Objects contained in the bounding box C do not need to be tested.

It is easy to see that if the ray does not intersect any of these larger groups, then we can avoid testing the volume enclosed by these groups, potentially rejecting many objects at the same time. Obviously, this saves a lot of rendering time.

The principle is very simple, but for efficiency, objects and volumes must be grouped by proximity. Problems can arise if they are not grouped by proximity, as shown in Figure 3. The two bounding boxes of the red teapot are grouped together even though they are far away from each other. In Figure 3, the ray intersects two bounding volumes, and it is necessary to test whether the four teapots intersect the ray. In Figure 1, only two of the teapot intersections (D and E) are tested.
insert image description here

Figure 3: Space is subdivided into smaller regions.

To group objects by proximity, Kay and Kajiya propose inserting them into a spatially partitioned data structure. This structure divides the space into subregions, and objects are usually inserted into these subregions according to their position. The process is shown in Figure 3.

If we create a box that encloses all objects in the scene, we can subdivide that box into eight equally sized subboxes (this process is illustrated in 2D in Figures 4 and 5). Each object of the scene can then be inserted (in the order they were added to the scene) into their overlapping subboxes. We can then assume that all objects contained in a subvoxel are very close to each other (at least closer than objects in other subvoxels).

insert image description here

Figure 4: A two-dimensional octree is a quadtree. The node is divided into four cells, and objects are inserted into the cells where they overlap. The process of cell subdivision can be repeated as many times as necessary. For example until there is an object in each cell or when we reach a user defined maximum depth.

As shown in Figure 4, an object may overlap multiple sub-boxes or cells. In this case, Kay and Kajiya arbitrarily insert objects into one of the overlapping cells. We choose to insert them into the cell where the centroid of the object's bounding box is (represented by the dot at the center of each teapot).

The paper proposes two spatial data structures: median cut and octree. The first has to do with the kd-tree space partitioning data structure that you may have heard of. We won't cover these structures in this lesson. The latter, octree, will be used in our implementation of Kay and Kajiya's paper. As mentioned, it divides a cube into eight cells, which are themselves subdivided, etc. The recursive process stops when all objects are inserted into the octree or when a user-defined maximum depth is reached.

2. Establish a hierarchical structure

The octree data structure is covered in detail in another lesson. However, in a nutshell, the object creation and insertion process is as follows. First, we compute the bounding volumes of all objects in the scene, and then compute the entire scene bounding volume, which is the result of combining these volumes.

From the overall volume of the scene, we can define the size of the octree (the cube at the center of the overall volume whose size is the maximum value of any volume range along the x, y, and z axes). This cube represents the top node of the octree, its root.

When we insert objects in this octree, we traverse the tree in a top-down fashion. If the node where we want to insert the object is a leaf, and the leaf does not already contain any object, then we insert the object into that node.

If the node already contains an object (unless we have reached the user-defined maximum depth), the current node will be split into eight cells, and the object contained by the node, as well as the new object, will be inserted into the cells (note that objects may overlap multiple cells) as they overlap.

insert image description here

Figure 5: Objects inserted into a quadtree.

This process is repeated until all objects are inserted into the octree.

The second step, this time traversing the octree in a bottom-up fashion. We start with leaves and compute the overall bounding boxes of the objects they contain (leaves can have one or more objects). Then, the overall volume box of the node above the leaf is computed by combining its subbounding volumes. Repeat this process until we reach the root (at this point it should be the same extent as we calculated earlier for the entire scene).

At the end of these two processes, we obtain a representation of the scene as a hierarchy of bounding volumes, where the bounding volumes are grouped according to proximity (objects contained in node leaves are grouped together, etc.). Note that ocher is not actually used as an acceleration structure (don't confuse this technique with octrees used as acceleration structures - we'll be writing a class on this technique in the future).

Octrees are actually of no real benefit for ray intersection testing. It is only used to calculate and build this volume hierarchy.

insert image description here

Figure 6: Intersecting a quadtree. If a cell intersects, test that cell's children for an intersection. This process continues until the leaf nodes intersect. Then test the geometry contained in that leaf node.
The intersection of BVH is very simple. We start at the root node and test whether the ray intersects the node's bounding volume.

Typically, if a node's bounding volume intersects a ray, we go one level down in the tree and test for intersection with the bounding volumes of the node's children. If the node is a leaf, we test whether the ray intersects any objects it contains. This solution is very simple to implement, but it can be further optimized.

When we test a ray for intersection with the bounding volumes of a node's children, assuming the bounding volumes of multiple cells have been intersected, we should first investigate the children of the node with the smallest intersection distance, since visible objects are most likely to be contained in those cells. The idea is shown in Figure 6 (we denote the intersection with the cell bounding volume by the point where the ray intersects the cell boundary. Remember that we actually have an intersection with the cell bounding volume, represented by the sphere in the diagram, not the cell itself).

As you can see, the ray intersects the root node (black), which results in testing the root's children (red). Units are tested in the order they were created: 0, 1, 2, 3. The ray intersects the bounding volume of cells 0 and 1, so the children of these nodes are tested next. The problem, however, is that the subunits of unit 0 will be tested before the subunits of unit 1, even though unit 1's intersection distance (t1) with the bounding volume is less than the intersection distance with unit 0's volume (t0). It is more efficient to test the children in cell 1 before testing the children in cell 0, because the intersection with teapot A can be found more quickly.

Kay and Kajiya suggest using a list into which the children of the node traversed by the ray are inserted, sorted according to their intersection distance (smallest to largest). We then remove the first node from this list and test its children. If any of these children intersect the ray, they insert themselves into the list. They will be inserted in front of the existing elements in the list, since their intersection distance is necessarily less than the intersection distance of any nodes already inserted.

By following this process, we ensure that the node in the hierarchy closest to the ray origin is always tested first. If those nodes don't contain visible objects, we keep testing nodes that are further away from the ray's origin, etc., until the list is empty.

while (priority_queue is not empty) { 
    Node node = get node from the priority_queue with highest priority (and remove it) 
    if node is a leaf 
        for each object contained in node 
            if object is intersected by ray 
                 keep this object as potential visible object 
        if an object was intersected 
            return 
    else 
        // keep traversing the hierarchy by looking down the node's children
        for each children in node 
            if node's children is intersected by ray at distance t 
                insert node into priority_queue (use intersection distance t to define priority of the node in the list) 
}

insert image description here

Figure 7: The first intersecting bounding volume does not necessarily contain the closest intersecting objects.

We need to pay attention to one last detail. If you look at Figure 7, you can see that we cannot use the intersection distance to the bounding volumes to determine which of these volumes contain visible objects. Even though the distance tvb to B's bounding volume is less than the distance tvc to C's bounding volume, the ray will intersect C instead of B. We can only be sure that we can stop the traversal of the hierarchy when the distance to the intersecting object is less than the intersection distance to the bounding volume of the next node in the list.

As shown in Figure 7, the nodes in the priority list should be in the following order VB, VC, VA (because tvb < tvc < tva). When we intersect the object contained by VB (B's bounding volume), we find the intersection distance tb of object B. But tb is not smaller than tvc, so we also have to test for intersection with object C, we find that tc is lower than
tb, so C becomes a potential intersecting object instead of B. But because tc is lower than tva, we don't need to test for intersection with A, nor with any node in the list after VA. Therefore, we can stop the hierarchy traversal and return B as the visible object.

If we use all these techniques in a ray tracer, we now get the following result:

Render time                                 : 1.61 (sec)
Total number of triangles                   : 16384
Total number of primary rays                : 307200
Total number of ray-triangles tests         : 41341952
Total number of ray-triangles intersections : 59017
Total number of ray-volume tests            : 1531064

Rendering is now 1.8 times faster (compared to rendering in the previous chapter). The number of ray volume tests was reduced by 6.4 (proving that the bounding volume hierarchy is very efficient), the number of ray triangle tests was reduced by 1.95, and the number of ray triangle intersections was reduced by 1.89.

3. BVH implementation code

The complete source code can be found in the last chapter of this course.

The BVH class becomes more complex. We added an OctreeNode and an Octree class to this class. Readers interested in learning about octaves are referred to the following sections where they can find lessons on the subject. An octree node holds eight pointers to other octree nodes that are its children. The class constructor takes the scene scope as an input parameter (line 38).

This range is used to calculate and set the size and position of the root node (its centroid. Lines 40-49). New objects (more precisely their bounding volumes, but remember that the Extents class holds pointers to enclosing objects) are inserted from the root node (lines 52-53).

If the current node is an empty leaf (no object has been inserted into the leaf), insert the object into the leaf and return.

If the node is a leaf and contains at least one or more objects, but we have reached the octree maximum depth, we still insert the object into the node (line 73).

However, if we haven't reached the maximum depth, we mark the node as "internal" and reinsert the objects the node already holds as well as new objects in the octree (Lines 77-82). If the node is not a leaf, we check which children of the node the object should be inserted into (Lines 86-93). If the child node doesn't exist yet, we create it (lines 97-98) and finally insert the object into it (line 99).

The functions to build volume hierarchies are straightforward. We start at the root and work our way down the tree until we reach a leaf node. The node's bounding volume is computed by combining the bounding volumes of all objects it points to. Then, when we move up again, the current node's bounding volume is computed by combining the bounding volumes of its children (line 130).

class BVH : public AccelerationStructure
{
    static const uint8_t kNumPlaneSetNormals = 7;
    static const Vec3f planeSetNormals[kNumPlaneSetNormals];
    struct Extents
    {
        Extents()
        {
            for (uint8_t i = 0; i < kNumPlaneSetNormals; ++i)
                d[i][0] = kInfinity, d[i][1] = -kInfinity;
        }
        void extendBy(const Extents &extents)
        {
            for (uint8_t i = 0; i < kNumPlaneSetNormals; ++i) {
                if (extents.d[i][0] < d[i][0]) d[i][0] = extents.d[i][0];
                if (extents.d[i][1] > d[i][1]) d[i][1] = extents.d[i][1];
            }
        }
        bool intersect(
            const float *precomputedNumerator, const float *precomputeDenominator, 
            float &tNear, float &tFar, uint8_t &planeIndex);
        float d[kNumPlaneSetNormals][2]; // d values for each plane-set normals
        const Object *object; // pointer contained by the volume (used by octree)
    };
    Extents *extents;
    struct OctreeNode
    {
        OctreeNode *child[8];
        std::vector<const Extents *> data;
        Extents extents;
        bool isLeaf;
        uint8_t depth; // just for debugging
        OctreeNode() : isLeaf(true) { memset(child, 0x0, sizeof(OctreeNode *) * 8); }
        ~OctreeNode() { for (uint8_t i = 0; i < 8; ++i) if (child[i] != NULL) delete child[i]; }
    };
    struct Octree
    {
        Octree(const Extents &extents) : root(NULL)
        {
            float xdiff = extents.d[0][1] - extents.d[0][0];
            float ydiff = extents.d[1][1] - extents.d[1][0];
            float zdiff = extents.d[2][1] - extents.d[2][0];
            float dim = std::max(xdiff, std::max(ydiff, zdiff));
            Vec3f centroid(
                (extents.d[0][0] + extents.d[0][1]),
                (extents.d[1][0] + extents.d[1][1]),
                (extents.d[2][0] + extents.d[2][1]));
            bounds[0] = (Vec3f(centroid) - Vec3f(dim)) * 0.5f;
            bounds[1] = (Vec3f(centroid) + Vec3f(dim)) * 0.5f;
            root = new OctreeNode;
        }
        void insert(const Extents *extents)
        { insert(root, extents, bounds[0], bounds[1], 0); }
        void build()
        { build(root, bounds[0], bounds[1]); }
        ~Octree() { delete root; }
        struct QueueElement
        {
            const OctreeNode *node; // octree node held by this node in the tree
            float t; // used as key
            QueueElement(const OctreeNode *n, float thit) : node(n), t(thit) {}
            // comparator is > instead of < so priority_queue behaves like a min-heap
            friend bool operator < (const QueueElement &a, const QueueElement &b) { return a.t > b.t; }
        };
        Vec3f bounds[2];
        OctreeNode *root;
    private:
        void insert(
            OctreeNode *node, const Extents *extents, 
            Vec3f boundMin, Vec3f boundMax, int depth)
        {
            if (node->isLeaf) {
                if (node->data.size() == 0 || depth == 16) {
                    node->data.push_back(extents);
                }
                else {
                    node->isLeaf = false;
                    while (node->data.size()) {
                        insert(node, node->data.back(), boundMin, boundMax, depth);
                        node->data.pop_back();
                    }
                    insert(node, extents, boundMin, boundMax, depth);
                }
            } else {
                // insert bounding volume in the right octree cell
                Vec3f extentsCentroid = (
                    Vec3f(extents->d[0][0], extents->d[1][0], extents->d[2][0]) +
                    Vec3f(extents->d[0][1], extents->d[1][1], extents->d[2][1])) * 0.5;
                Vec3f nodeCentroid = (boundMax + boundMin) * 0.5f;
                uint8_t childIndex = 0;
                if (extentsCentroid[0] > nodeCentroid[0]) childIndex += 4;
                if (extentsCentroid[1] > nodeCentroid[1]) childIndex += 2;
                if (extentsCentroid[2] > nodeCentroid[2]) childIndex += 1;
                Vec3f childBoundMin, childBoundMax;
                Vec3f boundCentroid = (boundMin + boundMax) * 0.5;
                computeChildBound(childIndex, boundCentroid, boundMin, boundMax, childBoundMin, childBoundMax);
                if (node->child[childIndex] == NULL)
                      node->child[childIndex] = new OctreeNode, node->child[childIndex]->depth = depth;
                insert(node->child[childIndex], extents, childBoundMin, childBoundMax, depth + 1);
            }
        }
        void computeChildBound(
            const uint8_t &i, const Vec3f &boundCentroid,
            const Vec3f &boundMin, const Vec3f &boundMax,
            Vec3f &pMin, Vec3f &pMax) const
        {
            pMin[0] = (i & 4) ? boundCentroid[0] : boundMin[0];
            pMax[0] = (i & 4) ? boundMax[0] : boundCentroid[0];
            pMin[1] = (i & 2) ? boundCentroid[1] : boundMin[1];
            pMax[1] = (i & 2) ? boundMax[1] : boundCentroid[1];
            pMin[2] = (i & 1) ? boundCentroid[2] : boundMin[2];
            pMax[2] = (i & 1) ? boundMax[2] : boundCentroid[2];    
        }
        // bottom-up construction
        void build(OctreeNode *node, const Vec3f &boundMin, const Vec3f &boundMax)
        {
            if (node->isLeaf) {
                // compute leaf node bounding volume
                for (uint32_t i = 0; i < node->data.size(); ++i) {
                    node->extents.extendBy(*node->data[i]);
                }
            }
            else {
                for (uint8_t i = 0; i < 8; ++i)
                    if (node->child[i]) {
                        Vec3f childBoundMin, childBoundMax;
                        Vec3f boundCentroid = (boundMin + boundMax) * 0.5;
                        computeChildBound(i, boundCentroid, boundMin, boundMax, childBoundMin, childBoundMax);
                        build(node->child[i], childBoundMin, childBoundMax);
                        node->extents.extendBy(node->child[i]->extents);
                    }
                }
            }
        }
    };
    Octree *octree;
public:
    BVH(const RenderContext *rcx);
    const Object* intersect(const Ray<float> &ray, IsectData &isectData) const;
    ~BVH();
};

In the intersect method of the BVH class (line 37), we first test whether the ray intersects the bounding volume of the entire scene (the extent of the ocher root node).

If so, we initialize a priority_queue list with the node and the intersection distance from the ray origin to its bounding volume. We overload the operator< (line 63 above) to behave like a min-heap (by default, it behaves like a max-heap, setting the element with the highest key value as the highest priority. We want the opposite).

The rest of the code is similar to the pseudocode given above. We get the first node at the top of the list (which we also remove from the list. Lines 72-73). If the node is a leaf, we test whether the ray intersects any objects contained by the node, and keep track of the intersection minimum distance (Line 79). If it's an internal node, we test whether the ray intersects the bounding volume of the node's children, and when it does, we add the child to the list (using the intersection distance as a key to set the element's priority in the list. Line 93).

This process is repeated until the list is empty, but can be stopped sooner if the intersection distance of the first node of the list is greater than the distance to the intersecting object (line 71).

BVH::BVH(const RenderContext *rcx) : AccelerationStructure(rcx), extents(NULL), octree(NULL) 
{ 
    Extents sceneExtents; 
    extents = new Extents[rcx->objects.size()]; 
    for (uint32_t i = 0; i < rcx->objects.size(); ++i) { 
        for (uint8_t j = 0; j < kNumPlaneSetNormals; ++j) { 
            rcx->objects[i]->computeBounds(planeSetNormals[j], extents[i].d[j][0], extents[i].d[j][1]); 
        } 
        extents[i].object = rcx->objects[i]; 
        sceneExtents.extendBy(extents[i]); 
    } 
    // create hierarchy                                                                                                                                                                                     
    octree = new Octree(sceneExtents); 
    for (uint32_t i = 0; i < rcx->objects.size(); ++i) { 
        octree->insert(extents + i); 
    } 
    octree->build(); 
} 
 
inline bool BVH::Extents::intersect( 
    const float *precomputedNumerator, const float *precomputeDenominator, 
    float &tNear, float &tFar, uint8_t &planeIndex) 
{ 
    __sync_fetch_and_add(&numRayVolumeTests, 1); 
    for (uint8_t i = 0; i < kNumPlaneSetNormals; ++i) { 
        float tn = (d[i][0] - precomputedNumerator[i]) / precomputeDenominator[i]; 
        float tf = (d[i][1] - precomputedNumerator[i]) / precomputeDenominator[i]; 
        if (precomputeDenominator[i] < 0) std::swap(tn, tf); 
        if (tn > tNear) tNear = tn, planeIndex = i; 
        if (tf < tFar) tFar = tf; 
        if (tNear > tFar) return false;  //test for an early stop 
    } 

    return true; 
} 
 
const Object* BVH::intersect(const Ray<float> &ray, IsectData &isectData) const 
{ 
    const Object *hitObject = NULL; 
    float precomputedNumerator[BVH::kNumPlaneSetNormals], precomputeDenominator[BVH::kNumPlaneSetNormals]; 
    for (uint8_t i = 0; i < kNumPlaneSetNormals; ++i) { 
        precomputedNumerator[i] = dot(planeSetNormals[i], ray.orig); 
        precomputeDenominator[i] = dot(planeSetNormals[i], ray.dir);; 
    } 
#if 0 
    float tClosest = ray.tmax; 
    for (uint32_t i = 0; i < rc->objects.size(); ++i) { 
        __sync_fetch_and_add(&numRayVolumeTests, 1); 
        float tNear = -kInfinity, tFar = kInfinity; 
        uint8_t planeIndex; 
        if (extents[i].intersect(precomputedNumerator, precomputeDenominator, tNear, tFar, planeIndex)) { 
            IsectData isectDataCurrent; 
            if (rc->objects[i]->intersect(ray, isectDataCurrent)) { 
                if (isectDataCurrent.t < tClosest && isectDataCurrent.t > ray.tmin) { 
                    isectData = isectDataCurrent; 
                    hitObject = rc->objects[i]; 
                    tClosest = isectDataCurrent.t; 
                } 
            } 
        } 
    } 
#else 
    uint8_t planeIndex = 0; 
    float tNear = 0, tFar = ray.tmax; 
    if (!octree->root->extents.intersect(precomputedNumerator, precomputeDenominator, tNear, tFar, planeIndex) 
        || tFar < 0 || tNear > ray.tmax) 
        return NULL; 
    float tMin = tFar; 
    std::priority_queue<BVH::Octree::QueueElement> queue; 
    queue.push(BVH::Octree::QueueElement(octree->root, 0)); 
    while(!queue.empty() && queue.top().t < tMin) { 
        const OctreeNode *node = queue.top().node; 
        queue.pop(); 
        if (node->isLeaf) { 
            for (uint32_t i = 0; i < node->data.size(); ++i) { 
                IsectData isectDataCurrent; 
                if (node->data[i]->object->intersect(ray, isectDataCurrent)) { 
                    if (isectDataCurrent.t < tMin) { 
                        tMin = isectDataCurrent.t; 
                        hitObject = node->data[i]->object; 
                        isectData = isectDataCurrent; 
                    } 
                } 
            } 
        } 
        else { 
            for (uint8_t i = 0; i < 8; ++i) { 
                if (node->child[i] != NULL) { 
                    float tNearChild = 0, tFarChild = tFar; 
                    if (node->child[i]->extents.intersect(precomputedNumerator, precomputeDenominator, 
                        tNearChild, tFarChild, planeIndex)) { 
                        float t = (tNearChild < 0 && tFarChild >= 0) ? tFarChild : tNearChild; 
                        queue.push(BVH::Octree::QueueElement(node->child[i], t)); 
                    } 
                } 
            } 
        } 
    } 
#endif 

    return hitObject; 
}

Usually, the objects we should insert in BVH are triangles rather than meshes. In order to get the basics done as quickly as possible, we'll explain how to do this in a later edition of this course. But in the next chapter, we will show how to insert triangles into mesh acceleration structures. As an exercise, you can try to modify the BVH code found in this course to support the interpolation of triangles, using the mesh implementation as an example.


Original link: BVH principle and implementation code - BimAnt

Guess you like

Origin blog.csdn.net/shebao3333/article/details/131845085