光线求交加速算法:边界体积层次结构(Bounding Volume Hierarchies)1-BVH引入

光线求交加速算法:边界体积层次结构(Bounding Volume Hierarchies)1

 

BVH引入

光线和物体求交的加速算法中,最常见的是物体(图元)细分和空间细分。边界体积层次结构(BVH)是一种基于图元(primitives)细分的光线相交加速方法,其中,图元被划分为不相交集的层次结构。 (相反,空间细分通常将空间划分为不相交集的层次结构。)下图显示了一个简单场景的边界框层次结构:

其中,图元存储在叶中,每个节点存储其所有子节点图元的边界框。 因此,当光线穿过树时,只要它不与节点的边界相交,就可以跳过该节点下方的子树。

对于一个二叉树形式的BVH,其在叶子结点中存储一个单一的图元,该树节点总数为2n-1,其中n是图元数。将有n个叶节点和n-1个内部节点。如果叶子存储多个图元,则需要更少的节点。BVH比kd树更快速地构建,kd树通常比BVH提供更快的射线相交测试,但是建造时间要长得多。另一方面,与kd树相比,BVH通常在数值上更健壮,并且由于舍入误差而导致的交集遗漏更少。

 

构建BVH

BVH的构建分为三个阶段。

  1. 计算有关每个图元的边界信息并将其存储在数组中,该数组将在树构建期间使用。
  2. 使用相应的分割算法来构建树,本文主要介绍四种分区算法(SAH, HLBVH, Middle, EqualCounts) ,构建的结果是一棵二叉树,其中每个内部节点持有指向其子节点的指针,每个叶子节点持有对一个或多个基元的引用。
  3. 最后该树被转换为更紧凑(因此效率更高)的无指针表示,以在渲染期间使用。

我们根据这三个阶段写出BVH构建代码:

// BVH构造函数,构造BVH2叉树
BVHAccel::BVHAccel(std::vector<std::shared_ptr<Primitive>> p,
                   int maxPrimsInNode, SplitMethod splitMethod)
    : maxPrimsInNode(std::min(255, maxPrimsInNode)),
      splitMethod(splitMethod),
      primitives(std::move(p)) {

    // 利用图元构造BVH2叉树,从以下三个阶段进行。(+)表示代码未展开 (=)表示展开后
    // 1.+初始化图元信息数组 
    // 2.+利用图元信息并使用相应的图元分割算法构建BVH
    // 3.+树的节点用深度优先排列表示
}

对于要存储在BVH中的每个图元,我们将其边界框的质心,其完整边界框及其索引存储在BVHPrimitiveInfo结构体的图元数组中。该结构体如下:

struct BVHPrimitiveInfo {
    BVHPrimitiveInfo(size_t primitiveNumber, const Bounds3f &bounds)
        : primitiveNumber(primitiveNumber), bounds(bounds),
          centroid(.5f * bounds.pMin + .5f * bounds.pMax) { }
    size_t primitiveNumber;//图元索引
    Bounds3f bounds;//图元边界框
    Point3f centroid;//图元边界框的质心
};

我们初始化图元信息数组的代码则为:

//1.=初始化图元信息数组
std::vector<BVHPrimitiveInfo> primitiveInfo(primitives.size());
for (size_t i = 0; i < primitives.size(); ++i)
    primitiveInfo[i] = { i, primitives[i]->WorldBound() };

接下来使用相应的图元分区算法以及图元信息构建BVH树,我们贴出代码:

// 2.=使用图元信息与相应的图元分割算法构建BVH树
    MemoryArena arena(1024 * 1024);
    int totalNodes = 0;
    std::vector<std::shared_ptr<Primitive>> orderedPrims;
    orderedPrims.reserve(primitives.size());
    BVHBuildNode *root;//构建完成树的根节点
    if (splitMethod == SplitMethod::HLBVH)
        root = HLBVHBuild(arena, primitiveInfo, &totalNodes, orderedPrims);
    else
        root = recursiveBuild(arena, primitiveInfo, 0, primitives.size(),
                              &totalNodes, orderedPrims);
    primitives.swap(orderedPrims);//orderedPrims是构造后排序过的图元,我们用其覆盖原图元

其中,我们把图元分区算法用枚举类型表示:

//SAH:表面积启发式法。HLBVH:线性边界体积层次结构法。Middle:中点分割法。EqualCounts:等数量分割法
enum class SplitMethod { SAH, HLBVH, Middle, EqualCounts };

HLBVH方法我们利用函数HLBVHBuild()构建,其他三个方法在函数recursiveBuild()中区分构建。MemoryArena为PBRT中内存管理类,用以优化内存分配。

每个BVHBuildNode代表BVH的一个节点。 所有节点都存储一个Bounds3f,它表示该节点下所有子级的边界框。 每个内部节点在其子级中存储指向其两个子级的指针。 内部节点还记录了坐标轴,图元沿该坐标轴进行了划分,以分配给它们的两个子节点。叶子节点需要记录其中存储了哪些图元,索引从firstPrimOffset到firstPrimOffset+nPrimitives之间的图元都为该叶子结点内的图元。内部结点则不保存图元,仅保存边界框信息。BVHBuildNode结构如下:

struct BVHBuildNode {
    // 构造叶子结点
    void InitLeaf(int first, int n, const Bounds3f &b) {
        firstPrimOffset = first;
        nPrimitives = n;
        bounds = b;
        children[0] = children[1] = nullptr;
    }
    //构造内部结点
    void InitInterior(int axis, BVHBuildNode *c0, BVHBuildNode *c1) {
        children[0] = c0;
        children[1] = c1;
        bounds = Union(c0->bounds, c1->bounds);//Union表示联合c1和c2的边界框
        splitAxis = axis;
        nPrimitives = 0;
    }
    Bounds3f bounds;
    BVHBuildNode *children[2];
    int splitAxis, firstPrimOffset, nPrimitives;
};

除了用于分配节点内存的MemoryArena和保存图元信息的数组BVHPrimitiveInfo之外,recursiveBuild()还将范围[start,end)作为参数。它负责返回由从primaryInfo [start]到包括primitiveInfo [end-1]的范围表示的图元子集的BVH。如果此范围仅涵盖单个图元,则递归已触底并创建叶节点。否则,将使用一种分区算法在该范围内对数组的元素进行分区,并相应地对该范围内的数组元素进行重新排序,以使[start,mid)和[mid,end)的范围代表已分区的子集。如果分区成功,则将这两个图元集依次传递给递归调用,这些递归调用本身将返回指向当前节点的两个子节点的指针。totalNodes跟踪已创建的BVH节点的总数。orderedPrims数组用于存储已排序的图元引用。代码如下:

BVHBuildNode *BVHAccel::recursiveBuild(MemoryArena &arena,
        std::vector<BVHPrimitiveInfo> &primitiveInfo, int start,
        int end, int *totalNodes,
        std::vector<std::shared_ptr<Primitive>> &orderedPrims) {
    BVHBuildNode *node = arena.Alloc<BVHBuildNode>();
    (*totalNodes)++;
    // +计算所有BVH图元的边界框
    int nPrimitives = end - start;
    if (nPrimitives == 1) {
        // +创建叶子结点
    } else {
        // +计算图元质心的边界,选择分割的坐标轴
        // +将图元集分割成两个子集
    }
    return node;
}

首先展开计算边界框的代码:

// =计算所有SVH图元的边界框
Bounds3f bounds;
for (int i = start; i < end; ++i)
    bounds = Union(bounds, primitiveInfo[i].bounds);

在叶节点,将与叶重叠的图元附加到orderedPrims数组,并初始化叶节点对象:

// =创建叶子结点
int firstPrimOffset = orderedPrims.size();
for (int i = start; i < end; ++i) {
    int primNum = primitiveInfo[i].primitiveNumber;
    orderedPrims.push_back(primitives[primNum]);//由于前序遍历,所以保存是有序的
}
node->InitLeaf(firstPrimOffset, nPrimitives, bounds);
return node;

对于内部结点我们按什么方式为图元分区呢?我们通常选择一个坐标轴,图元覆盖该坐标轴具有最大的范围,如下图所示:


此处进行分区的总体目标是选择一个基本图元分区,该图元与两个所得基本图元集的边界框没有太多的重叠-如果存在实质性的重叠,遍历两个子树可能比更有效地分割图元的集合需要更多的计算。在讨论表面积启发式方法(SAH)时,将很快更加严格地找到有效的原始分区。

我们这里先写出寻找最大范围的坐标轴:

// =计算图元质心的边界,选择分割的坐标轴
Bounds3f centroidBounds;
for (int i = start; i < end; ++i)
    centroidBounds = Union(centroidBounds, primitiveInfo[i].centroid);
int dim = centroidBounds.MaximumExtent();

如果所有质心点都在同一位置(即质心边界的体积为零),则递归停止,并使用图元创建叶节点; 在这种(异常)情况下,此处的分割方法均无效。 否则,将使用所选方法对图元进行分区,并将其传递给recursiveBuild(),对其进行两个递归调用。代码如下:

// =将图元集分割成两个子集
int mid = (start + end) / 2;
if (centroidBounds.pMax[dim] == centroidBounds.pMin[dim]) {
    // +创建叶子结点
} else {
    // +根据不同的分割方法,分割图元集
    node->InitInterior(dim,
                       recursiveBuild(arena, primitiveInfo, start, mid,
                                      totalNodes, orderedPrims),
                       recursiveBuild(arena, primitiveInfo, mid, end,
                                      totalNodes, orderedPrims));
}

这里创建叶子结点代码与上部分创建叶子结点完全相同,在递归调用之前我们需要完成图元集分区,一个简单的splitMethod是Middle,它首先计算沿分割轴的图元质心的中点。根据它们的质心是在中点之上还是之下,将图元分为两组。使用C ++标准库函数std :: partition()可以轻松完成此分区,该函数接受数组中的一系列元素和比较函数,并对数组中的元素进行排序。std :: partition()返回一个指针,该指针指向该谓词具有假值的第一个元素,该元素将转换为对primitiveInfo数组的偏移量,以便我们可以将其传递给递归调用:

switch (splitMethod) {
    case SplitMethod::Middle: {
        // 中点法
        Float pmid = (centroidBounds.pMin[dim] + centroidBounds.pMax[dim]) / 2;
        BVHPrimitiveInfo *midPtr = std::partition(
            &primitiveInfo[start], &primitiveInfo[end - 1] + 1,
            [dim, pmid](const BVHPrimitiveInfo &pi) {
                return pi.centroid[dim] < pmid;
            });
        mid = midPtr - &primitiveInfo[0];
        // 对于许多带有大重叠边界框的素数,可能无法分区; 在这种情况下将不执行break跳出,而是进行下一种分区法(等数量分区法)
        if (mid != start && mid != end) break;
        }
        //...其他分区方法
}

如果所有图元都具有大的重叠边界框,则此拆分方法可能无法将图元分为两组。在这种情况下,执行将落入SplitMethod :: EqualCounts方法来重试。EqualCounts方法将图元划分为两个相等大小的子集,以使它们中的n个的前半部分是沿选定轴的质心坐标值最小的n / 2个,而后半部分是质心坐标值最大的半个子集。尽管这种方法有时效果很好,下图(b)的情况则这种方法的效果较差:

如图,a)对于某些图元分布,例如此处所示的分布,基于质心沿所选轴的中点(粗蓝线)进行分割的效果很好。(用虚线显示两个结果原始组的边界框。)(b)对于这种分布,中点是次优的选择;两个结果边界框基本重叠。(c)如果将(b)中的同一组图元沿着此处所示的线进行拆分,则生成的边界框会更小并且完全不重叠,从而在渲染时会带来更好的性能。

我们列出EqualCounts方法的代码:

switch (splitMethod) {
    //中点法
    case SplitMethod::Middle:{
        //...
    }
    //等数量法
    case SplitMethod::EqualCounts: {
        // 利用一半数量分区
        mid = (start + end) / 2;
        std::nth_element(&primitiveInfo[start], &primitiveInfo[mid],
                         &primitiveInfo[end - 1] + 1,
                         [dim](const BVHPrimitiveInfo &a,
                               const BVHPrimitiveInfo &b) {
                             return a.centroid[dim] < b.centroid[dim];
                         });
        break;
    }
}

通过标准库调用std :: nth_element()也可以轻松实现此方案。 它需要一个开始,中间和结束指针以及一个比较函数。 它对数组进行排序,以使中间指针处的元素是数组完全排序后将存在的元素,并且使得中间一个元素之前的所有元素小于中间元素,而后面一个元素的所有元素都小于中间元素 它比它更大。 这种排序可以在O(n)时间内完成,元素数量为n,这比对数组进行完全排序的O(nlogn)更有效。

EqualCounts和Middle是比较简单的方法,但同时会有比较大的不稳定性,比如一些特殊情况会使分区之后的遍历性能大大降低,如上述提到的图片示例。后面用用两篇介绍两种更好的方法,这里先不提及它们。

 

紧凑的BVH遍历

构建BVH树后,可以将其转换为紧凑的表示形式-这样做可以提高缓存,内存以及整体系统性能。 最终的BVH存储在内存中的线性数组中。 原始树的节点以先根深度优先布局,这意味着每个内部节点的第一个子节点在内存中紧随其后。 在这种情况下,只需显式存储每个内部节点的第二个子节点的偏移量。 相关的树形拓扑和内存中节点顺序排列的关系如下图:

利用LinearBVHNode结构存储遍历BVH所需的信息。除了每个节点的边界框外,对于叶节点,它还存储节点中图元的偏移量和图元计数。对于内部节点,它存储到第二个子节点的偏移量,以及在构建层次结构时图元沿哪个坐标轴进行分区。此信息在下面的遍历中使用,以尝试沿射线从前到后的顺序访问节点。LinearBVHNode结构如下:

struct LinearBVHNode {
    Bounds3f bounds;
    union {
        int primitivesOffset;    // 叶结点
        int secondChildOffset;   // 内部结点
    };
    uint16_t nPrimitives;  // 当是内部结点时,值为0
    uint8_t axis;          // 内部结点的分割轴(x,y,z其中之一)
    uint8_t pad[1];        // 确保整个结构大小为32整数倍
};

对该结构进行填充以确保其大小为32个字节。 这样做可以确保:如果分配的节点使第一个节点与高速缓存行对齐,则随后的节点都不会跨越高速缓存行。

我们通过flattenBVHTree()方法将生成的树转换为LinearBVHNode表示形式,该方法执行深度优先遍历并将节点以线性顺序存储在内存中:

// 3.=树的节点用深度优先排列表示
nodes = AllocAligned<LinearBVHNode>(totalNodes);
int offset = 0;
flattenBVHTree(root, &offset);

BVHAccel类再添加私有成员变量:指向LinearBVHNodes数组的指针:

//BVHAccel类 私有成员变量private:
LinearBVHNode *nodes = nullptr;

将树展平为线性表示很简单; * offset参数将当前偏移量跟踪到BVHAccel的私有成员nodes数组中:

int BVHAccel::flattenBVHTree(BVHBuildNode *node, int *offset) {
    LinearBVHNode *linearNode = &nodes[*offset];
    linearNode->bounds = node->bounds;
    int myOffset = (*offset)++;//每次调用flattenBVHTree执行偏移
    if (node->nPrimitives > 0) {//叶子结点
        linearNode->primitivesOffset = node->firstPrimOffset;
        linearNode->nPrimitives = node->nPrimitives;
    } else {//当内部结点时,创建内部flattened BVH结点
        linearNode->axis = node->splitAxis;
        linearNode->nPrimitives = 0;
        flattenBVHTree(node->children[0], offset);
        linearNode->secondChildOffset = flattenBVHTree(node->children[1], offset);
    }
    return myOffset;
}

在内部节点上,进行递归调用以展平两个子树。 第一个节点直接进行递归调用,第二个节点的偏移量需要通过递归返回。

BVH的遍历会在光线求交时进行,求交代码如下:

bool BVHAccel::Intersect(const Ray &ray,
        SurfaceInteraction *isect) const {
    bool hit = false;
    Vector3f invDir(1 / ray.d.x, 1 / ray.d.y, 1 / ray.d.z);
    int dirIsNeg[3] = { invDir.x < 0, invDir.y < 0, invDir.z < 0 };
    // +跟随ray穿过BVH节点以找到相交的图元
    return hit;
}

每当Intersect()中的while循环开始迭代时,currentNodeIndex会将偏移量保存到要访问的节点的节点数组中。它以值0开头,代表树的根。仍然需要访问的节点存储在nodesToVisit[]数组中,该数组充当栈。toVisitOffset保存到栈中下一个空闲元素的偏移量(toVisitOffset在访问内部结点时总++,相当于入栈,当访问叶子结点时则--,相当于出栈):

// =跟随ray穿过BVH节点以找到相交的图元 
int toVisitOffset = 0, currentNodeIndex = 0;
int nodesToVisit[64];
while (true) {
    const LinearBVHNode *node = &nodes[currentNodeIndex];
    // +对BVH节点检查射线
}

在每个节点处,我们检查射线是否与节点的边界框相交。如果是这样,我们将访问该节点,如果它是叶节点,则测试其与图元的相交,如果是内部节点,则对其子级进行相同处理。 如果未找到相交,则从nodesToVisit []中检索下一个要访问的节点的偏移量(如果堆栈为空,则遍历完成):

// =对BVH节点检查射线
if (node->bounds.IntersectP(ray, invDir, dirIsNeg)) {
    if (node->nPrimitives > 0) {
        // +光线和BVH叶结点中的图元相交 
    } else {
        // +将远BVH节点(第二个子结点)放在nodesToVisit堆栈上,前进到下一个近节点
    }
} else {
    if (toVisitOffset == 0) break;
    currentNodeIndex = nodesToVisit[--toVisitOffset];
}

如果当前节点是叶子,则必须测试射线与它内部的图元的相交。然后从nodesToVisit堆栈中找到下一个要访问的节点。即使在当前节点中找到一个相交,也要访问其余的节点,以防其中一个产生更近的相交。但是,如果找到相交点,则射线的tMax值将更新为相交距离。这使得可以有效地丢弃比交叉点更远的所有剩余节点:

// =光线和BVH叶结点中的图元相交 
for (int i = 0; i < node->nPrimitives; ++i)
    if (primitives[node->primitivesOffset + i]->Intersect(ray, isect))
        hit = true;
if (toVisitOffset == 0) break;
currentNodeIndex = nodesToVisit[--toVisitOffset];

对于射线撞击的内部节点,需要访问其两个子节点。最好是在访问第二个孩子之前先访问射线穿过的第一个孩子(近的结点),以防光线在第一个孩子中相交,从而可以更新射线的tMax值,从而减少射线的测试次数。

一种有效方法,是使用光线方向矢量的符号作为坐标轴,在该坐标轴上为当前图元划分图元节点:如果符号为负,则应该在第一个孩子之前访问第二个孩子,因为进入第二个孩子的子树的图元在分区点的近侧。(相反为正号方向)。这样做很简单:首先将要访问的节点的偏移量复制到currentNodeIndex,将另一个节点的偏移量添加到nodesToVisit堆栈中。由于内存中节点的深度优先布局,第一个孩子紧随当前节点之后。

// =将远BVH节点(第二个子结点)放在nodesToVisit堆栈上,前进到下一个近节点
if (dirIsNeg[node->axis]) {
   nodesToVisit[toVisitOffset++] = currentNodeIndex + 1;
   currentNodeIndex = node->secondChildOffset;
} else {
   nodesToVisit[toVisitOffset++] = node->secondChildOffset;
   currentNodeIndex = currentNodeIndex + 1;
}

遍历过程图解如下图(红点为图元所在位置):

值得注意的是:即使光线已经找到和图元的交点,依旧会继续遍历其他边界框和光线有交点的结点,保证交点为最近值。

最后我们给出完整的BVHaccel类:

class BVHAccel : public Aggregate {
  public:
    enum class SplitMethod { SAH, HLBVH, Middle, EqualCounts };

    BVHAccel(std::vector<std::shared_ptr<Primitive>> p,
             int maxPrimsInNode = 1,
             SplitMethod splitMethod = SplitMethod::SAH);
    Bounds3f WorldBound() const;
    ~BVHAccel();
    bool Intersect(const Ray &ray, SurfaceInteraction *isect) const;
    bool IntersectP(const Ray &ray) const;

  private:
    BVHBuildNode *recursiveBuild(
        MemoryArena &arena, std::vector<BVHPrimitiveInfo> &primitiveInfo,
        int start, int end, int *totalNodes,
        std::vector<std::shared_ptr<Primitive>> &orderedPrims);
    BVHBuildNode *HLBVHBuild(
        MemoryArena &arena, const std::vector<BVHPrimitiveInfo> &primitiveInfo,
        int *totalNodes,
        std::vector<std::shared_ptr<Primitive>> &orderedPrims) const;
    BVHBuildNode *emitLBVH(
        BVHBuildNode *&buildNodes,
        const std::vector<BVHPrimitiveInfo> &primitiveInfo,
        MortonPrimitive *mortonPrims, int nPrimitives, int *totalNodes,
        std::vector<std::shared_ptr<Primitive>> &orderedPrims,
        std::atomic<int> *orderedPrimsOffset, int bitIndex) const;
    BVHBuildNode *buildUpperSAH(MemoryArena &arena,
                                std::vector<BVHBuildNode *> &treeletRoots,
                                int start, int end, int *totalNodes) const;
    int flattenBVHTree(BVHBuildNode *node, int *offset);

    const int maxPrimsInNode;
    const SplitMethod splitMethod;
    std::vector<std::shared_ptr<Primitive>> primitives;
    LinearBVHNode *nodes = nullptr;
};

 

  • 8
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值