【问题标题】:Optimizing BVH traversal in ray tracer优化光线追踪器中的 BVH 遍历
【发布时间】:2020-07-28 01:29:30
【问题描述】:

在我的 CPU 光线追踪器(嗯,路径追踪器)中,大部分 CPU 时间花费在 BVH 遍历函数中。根据我的分析器,75% 的光线追踪时间花在这个函数和它调用的函数上,而 35% 的时间花在函数本身上。另外 40% 在它调用的不同交叉点测试中。

基本上,代码对所有与之相交的边界框和三角形进行 DFS 遍历。它使用堆栈上的静态分配数组来保存要探索的节点(BVHSTACKSIZE 设置为 32,它从不需要大部分空间),因此不会动态分配内存。然而,对我来说,35% 的时间都花在这里似乎很疯狂。我花了一段时间优化代码,目前它是我能做到的最快速度,但它仍然是我程序中最大的单一瓶颈。

有没有人有更多优化这个的提示?我已经有了一个不错的 BVH 构造算法,所以我认为使用不同的 BVH 不会得到任何加速。有没有人有关于如何在 Mac 上最好地进行逐行分析的提示?

作为参考,示例场景中的此代码所需时间从

谢谢!

bool BVHAccel::intersect(Ray& ray) const {
  bool hit = false;

  BVHNode* to_intersect[BVHSTACKSIZE];
  int head = 0;
  to_intersect[head++] = root;

  while (head != 0) {
    assert(head < BVHSTACKSIZE);
    BVHNode* cur = to_intersect[--head];

    if (cur->bb.intersect(ray)) { // Does not modify the ray
      if (cur->isLeaf()) {
        for (const auto& primitive : cur->primitives) {
          hit |= primitive->intersect(ray); // Modifies the ray!
        }
      } else {
        to_intersect[head++] = cur->r;
        to_intersect[head++] = cur->l;
      }
    }
  }

  return hit;
}

bool BBox::intersect(const Ray& r) const {
  double txmin = (min.x - r.o.x) * r.inv_d.x;
  double txmax = (max.x - r.o.x) * r.inv_d.x;
  double tymin = (min.y - r.o.y) * r.inv_d.y;
  double tymax = (max.y - r.o.y) * r.inv_d.y;
  double tzmin = (min.z - r.o.z) * r.inv_d.z;
  double tzmax = (max.z - r.o.z) * r.inv_d.z;

  ascending(txmin, txmax);
  ascending(tymin, tymax);
  ascending(tzmin, tzmax);

  double t0 = std::max(txmin, std::max(tymin, tzmin));
  double t1 = std::min(txmax, std::min(tymax, tzmax));

  if (t1 < t0 || t0 > r.max_t || t1 < r.min_t) {
    return false;
  }

  return true;
}

void ascending(double& a, double& b) {
  if (a > b) {
    std::swap(a, b);
  }
}

【问题讨论】:

  • 你能发布一段完整的运行代码吗?我可以收集你的代码做了什么(或多或少),但是修改一些我们可以复制粘贴并运行的东西要容易得多。
  • 我会稍微扩展一下这个例子,但是很遗憾我不能发布一个独立的例子,因为我没有写很多周围的代码并且不能发布它。
  • 但是将 BVH 视为一种深度优先遍历的二叉搜索树。这棵树大约有 10 深,遍历的每个树节点都需要一个 BBox 交集,而叶节点需要一个专门的交集(例如三角形、球体)。绝大多数 (~70%) 时间花在 DFS 遍历和 BBox 交叉点上,其中 DFS 遍历花费的时间最多。
  • 我会关注这个问题,明天再考虑一下。感谢您对 intersect 方法的澄清!
  • 没问题,感谢您的帮助!

标签: c++ c performance optimization depth-first-search


【解决方案1】:

您的代码似乎至少存在一个问题。 复制primitive 可能是一项昂贵的操作。

bool BVHAccel::intersect(Ray ray) const {
  bool hit = false;

  BVHNode* to_intersect[BVHSTACKSIZE];
  int head = 0;
  to_intersect[head++] = root;

  while (head != 0) {
    assert(head < BVHSTACKSIZE);
    BVHNode* cur = to_intersect[--head];

    if (cur->bb.intersect(ray)) { // Does not modify the ray
      if (cur->isLeaf()) {
        for (const auto& primitive : cur->primitives) { // this code made a copy of primitives on every call!
          hit |= primitive->intersect(ray); // Modifies the ray!
        }
      } else {
        to_intersect[head++] = cur->r;
        to_intersect[head++] = cur->l;
      }
    }
  }

  return hit;
}

为什么需要修改射线的副本?

编辑 1:我们可以假设 BVHNode 看起来像这样吗?

constexpr auto BVHSTACKSIZE = 32;

struct Primitive;

struct BVHNode {
    std::vector<Primitive> primitives;
    AABB        bb;   
    BVHNode*    r = nullptr;
    BVHNode*    l = nullptr;

    bool isLeaf() const { return r == nullptr && l == nullptr; }
};

【讨论】:

  • 你在这两个方面都是完全正确的,事实证明我在试图让我的代码在 SO 上更具可读性时搞砸了(实际上它使用现有的迭代器来迭代原语)。但是,在实际代码中,它不会进行任何复制。感谢您找到它!
【解决方案2】:

我认为在您开始考虑您的硬件并从中挤出每个字节和每个周期之前,您可以进行算法改进。

我相信你感兴趣的是你的射线上的第一击。您不会从沿射线的多次命中中进行任何累积,对吗? (好像原语半透明或什么的)。是吗?

所以 - 如果上述情况属实,我发现您的遍历顺序存在问题。如果我正确阅读了您的代码,那么您将无条件地先遍历cur-&gt;l,然后再遍历cur-&gt;r。如果你的光线首先击中左边的盒子,这很好。但是 - 它可能来自场景的另一侧 - 然后你执行的交叉点比需要的多,基本上是沿着你的光线来回遍历。

相反,你想沿着你的射线从前到后遍历,希望你早点击中一些东西,这样你就可以跳过大部分进一步的遍历测试。

幸运的是,到边界框的距离已经在交集函数中计算出来,您可以轻松检查哪个首先相交。它只需要返回一个到交叉点的float 距离而不是bool(例如+infinity 失败)。您需要在将内容推送到您的 to_intersect 堆栈之前调用它。

所以,在伪代码中,我会这样遍历:

while stack not empty:
    cur = pop from top of stack;
    //we already know that we want to enter this node!
    if cur is leaf:
        intersect primitives
    else:
        t_left = intersect bbox of cur->l
        t_right = intersect bbox of cur->r
        if both intersected:
            if t_left < t_right:
                push cur->r, cur->l in that order (so that cur->l will be on top)
            else:
                push cur->l, cur->r in that order (so that cur->r will be on top)
        else if one intersected:
            push only that one
        else:
            push nothing

一旦您实施了上述操作,您可能会注意到还有一项改进需要改进。如果你在 push 和 pop 之间点击了一些原语并且你的 ray.max_t 被减少了,那么“我们已经知道我们想要进入这个节点”的条件不一定是真的。考虑到这一点,您可能需要在堆栈中存储一对 (t_enter, node)。然后,当你弹出时,你用你当前的ray.max_t 仔细检查t_enter。这可以为您节省最多 h 次遍历检查,其中 h 是树的高度。

可能的陷阱 - 您可能已经知道这一点,但以防万一 - 仅仅因为您从前向后遍历并不意味着您可以在找到第一个命中后立即终止遍历。 BVH 节点可以重叠。这就是为什么要比较 t_enterray.max_t 的原因。

【讨论】:

  • 这是一个我没有想到的好主意!稍后我会尝试并告诉你加速。
  • 刚刚完成测试,它提供了约 15% 的加速!谢谢,除非其他人有更好的东西,否则您将获得赏金。
  • 坦率地说,我希望超过 15%,但我想这在很大程度上取决于屏幕。
  • 是的,场景相对密集,大多数边界框都有点重叠。
  • 另外,15% 仍然值得注意!我事先已经做了很多优化,每一个都小于 15%。
【解决方案3】:

我看到您可以进行三项改进。

第一个大问题(很难)是您的代码中有许多条件分支,这肯定会减慢您的 CPU,因为它无法很好地预测代码路径(编译时也是如此)。例如,我看到你先相交,然后测试节点是否是叶子,然后与所有 prims 相交。你能先测试它是否是一片叶子,然后做正确的交叉点吗?这会稍微减少分支。

其次,你的 BVH 的内存布局是什么?您能否对其进行优化以使其对您的遍历友好。您可以尝试查看遍历期间发生的缓存未命中次数,这将很好地表明您的内存是否具有正确的布局。虽然没有直接关系,但现在你的平台和底层硬件很好。我推荐阅读this

最后,这是对性能影响最大的地方,使用 SSE/AVX!通过对交集代码进行一些重构,您可以一次与四个边界框相交,从而在您的应用程序中得到很好的提升。你可以看看 embree 做了什么(英特尔跟踪器),尤其是在数学库中。

另外,我刚刚看到您使用的是double。这有什么原因吗?我们的 pathtracer 根本不使用 double,因为在任何情况下您都不需要这种精度来进行渲染。

希望对你有帮助!

编辑:如果您想尝试一下,我制作了您的 bbox 交叉点的 sse 版本。它部分基于我们的代码,但我不确定它是否会工作,您应该对其进行基准测试!

#include <xmmintrin.h>
#include <emmintrin.h>
#include <smmintrin.h>

#include <cmath>
#include <limits>

constexpr float pos_inf = std::numeric_limits<float>::max();
constexpr float neg_inf = std::numeric_limits<float>::min();

size_t __bsf(size_t v)
{
  size_t r = 0; asm ("bsf %1,%0" : "=r"(r) : "r"(v));
  return r;
}

__m128 mini(const __m128 a, const __m128 b)
{
  return _mm_castsi128_ps(_mm_min_epi32(_mm_castps_si128(a),_mm_castps_si128(b)));
}

__m128 maxi(const __m128 a, const __m128 b)
{
  return _mm_castsi128_ps(_mm_max_epi32(_mm_castps_si128(a),_mm_castps_si128(b)));
}

__m128 abs(const __m128 a)
{
  return _mm_andnot_ps(_mm_set1_ps(-0.0f), a);
}

__m128 select(const __m128 mask, const __m128 t, const __m128 f)
{ 
  return _mm_blendv_ps(f, t, mask); 
}

template<size_t i0, size_t i1, size_t i2, size_t i3>
__m128 shuffle(const __m128 b)
{
  return _mm_castsi128_ps(_mm_shuffle_epi32(_mm_castps_si128(b), _MM_SHUFFLE(i3, i2, i1, i0)));
}

__m128 min(const __m128 a, const __m128 b) { return _mm_min_ps(a, b); }

__m128 max(const __m128 a, const __m128 b) { return _mm_max_ps(a, b); }

__m128 vreduce_min(const __m128 v)
{ 
  __m128 h = min(shuffle<1,0,3,2>(v),v);
  return min(shuffle<2,3,0,1>(h),h);
}

__m128 vreduce_max(const __m128 v)
{ 
  __m128 h = max(shuffle<1,0,3,2>(v),v);
  return max(shuffle<2,3,0,1>(h),h);
}

size_t select_min(__m128 valid, __m128 v)
{
  const __m128 a = select(valid, v, _mm_set_ps1(pos_inf));
  return __bsf(_mm_movemask_ps(_mm_and_ps(valid, (a == vreduce_min(a)))));
}

size_t select_max(const __m128 valid, const __m128 v)
{ 
  const __m128 a = select(valid, v, _mm_set_ps1(neg_inf));
  return __bsf(_mm_movemask_ps(_mm_and_ps(valid, (a == vreduce_max(a)))));
}

struct Ray
{
  vec3 o, inv_d;

  float min_t, max_t;
};

struct BBox
{
  vec3 min, max;

  bool intersect(const Ray& r) const;
};

bool BBox::intersect(const Ray& r) const
{
  const __m128 lowerSlab = _mm_mul_ps(_mm_sub_ps(max.m128, r.o.m128), r.inv_d.m128);
  const __m128 upperSlab = _mm_mul_ps(_mm_sub_ps(min.m128, r.o.m128), r.inv_d.m128);

  __m128 tmin = mini(lowerSlab, upperSlab);
  __m128 tmax = maxi(lowerSlab, upperSlab);

  reinterpret_cast<float*>(&tmin)[3] = r.min_t;
  reinterpret_cast<float*>(&tmax)[3] = r.max_t;

  const __m128 maskmin = _mm_castsi128_ps(_mm_cmpeq_epi32(tmin, tmin));
  const __m128 maskmax = _mm_castsi128_ps(_mm_cmpeq_epi32(tmax, tmax));

  const float tNear = abs(tmin[select_max(maskmin, tmin)]); // select the max non NaN value and ensure the result is positive using abs
  const float tFar  =     tmax[select_min(maskmax, tmax)]; // select the min non NaN value
  return tNear <= tFar;
}

【讨论】:

  • 谢谢!这些都是很好的建议。我真的不知道如何减少分支的数量,对于 isLeaf() 我仍然必须以任何一种方式进行交集,所以我看不到如何更改它(在大多数迭代中它也是错误的,所以它会可能被正确预测)。 BVH 的内存布局有点随机。目前每个节点都是自行分配的,但我将尝试将它们存储在一个数组中。最后,有人告诉我双精度数和浮点数一样快(只是在内存带宽方面更慢,这里没有太多),但我会自己尝试检查一下。
  • 将所有节点粘贴在一个连续的数组中,在一系列运行中平均可以提高 2% 左右的速度,谢谢!
  • 你应该明确地将你的节点保存在一个数组中或使用某种内存池,这确实会提高你的性能 w.r.t。缓存行。关于 float 与 double 的速度,这确实是一个棘手的问题,实际上取决于您在做什么。如果您缺少很多 L1 缓存,那么 double 将比 float 花费更多,因为硬件必须将更多数据加载到寄存器中。如果您决定使用 SSE,那么浮点数是一个真正的优势,因为 CPU 可以处理 4 个浮点运算,而双精度运算只有两个。
  • 这也让我想到:您是否在交集代码中使用了 std 中的任何数学函数?如果是这样,您真的应该考虑将它们更改为不那么健壮但速度更快的版本(我主要考虑的是 exp 和 sqrt)。
  • 目前最常见的交集是 BBox 交集,它只使用 *,-,+,。其他交集函数(三角形和球体)确实使用了更复杂的函数(除法、sqrt),但只占执行时间的 7%,所以我还没有费心去优化它们。
猜你喜欢
  • 2015-02-05
  • 2018-11-19
  • 2021-04-30
  • 1970-01-01
  • 1970-01-01
  • 2012-11-13
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多