从零开始学图形学:通俗讲解简单加速结构——k-d tree, SAH, BVH...
CGGraph渲染图形学图形图像技术 2444 1
实名

通过了实名认证的内容创造者

发布于 2021-4-4 16:23:06

您需要 登录 才可以下载或查看,没有账号?注册

x
从零开始学图形学:通俗讲解简单加速结构——k-d tree, SAH, BVH
文/启思

image.png

整个系列的文章归档整理于 归档整理目录 中。由于笔者能力有限,欢迎指正。
本文附有 GAMES101 实验六详解。

引入
上一篇文章中,我们讲了一种最简单的光线追踪方法:whitted-style ray tracing。其方法是从摄像机发射射线,在反射、折射的材质上反弹,在每个反弹点上计算能量,回溯时加起来做着色。之后解决了一个技术问题:如何判断射线与三角形相交。
现在,在能力上,我们已经可以用 whitted 方法渲染图像了,就如上一篇文章的代码所示。但是,考虑一下这个算法的时间复杂度:如果每条射线需要枚举所有的三角形做判断,那复杂度高达三角形个数*像素个数*平均反弹次数。
而现在想建模一个小物体的表面,往往要几千甚至几万个三角形,一个商业级产品,屏幕内甚至可能同时存在几亿个三角形。

v2-f1e42c80b6012fbc8c423396a9c8ae62_720w.jpg
虚幻五宣传demo,同屏几亿的三角形建模

这样的话,复杂度就显得太高了。有没有什么更加高效的方法?那肯定有的。
考虑一下,我们的目标是找到射线穿过的最近的三角形。尽管屏幕中三角形相当多,忽略“最近”这个条件,我们每次至多只需要检测一条射线所穿过的所有三角形即可。除非所有的三角形都整整齐齐排列成一排,跟糖葫芦一样被射线穿过,否则我们根本没必要去检测全部三角形。
换句话说,屏幕中大部分三角形,完全可以利用其在空间中的位置忽略掉,从而大大减少运行时间。
具体使用的数据结构,称其为加速结构。这里介绍两类加速结构:基于空间的、基于物体的。不过不论哪种方法,都没有完美的万金油,所以也说不上哪种更好。
基于空间的加速结构
这种方法把物体所在的空间划分成若干组,再利用数据结构把它们串起来加速查询。原本的物体则被分配到组内。查询时,如果该组内所有物体都不可能被射线穿过,那么忽略。
一个简单的例子是 k-d tree,假设在 k 维空间有若干物体,建立这么一棵树:
  • 建立根节点,代表整个空间。指定当前节点为根节点。
  • 选择第 i 维,  ,取 k-1 维超平面  ,满足  ,即一个垂直于该轴的平面。将当前节点的空间划分成两部分,分别形成左右儿子,当前节点包含的所有物体按照空间位置被划分到两个儿子内,被该超平面覆盖的物体保留在当前节点或者复制两份放到子节点。
  • 分别递归两个儿子,重复第二步,直到当前节点物体数量很少为止。

下图是 wiki 上的图,展示了物体是一个点的情况

当物体是一个点时
k-d tree方法具有很大的启发性。第二步中选择的维度 i 和超平面的唯一参数 M 是可以通过一些启发式方法得到的。
一般来说, i 是循环遍历 k 维的每一维(三维情况就是 x-axis, y-axis, z-axis 循环)。
对于 M 的选取,学问很大。比如上图中点的情况,M 一般选择所有点按该维度排序后的中位数。如果物体具有形形色色的形状(例如三角形,圆形),那么 M 可能就不能这么选了(当然,一般情况下也不会太差,可以选择中点之类来代替图形进而确定 M)。
在遍历的时候,只需要从根节点开始,判断当前射线是否穿过当前节点的空间,如果不穿过(或者不可能成为更优的答案),那么直接回溯;如果穿过,检查该节点内包含的所有物体是否与射线相交。

来源games101。随便画一画射线,就会发现总能跳过一些节点。

对于 k-d tree 的优点,首先它很好实现,二叉树的组织方式效率也较高;其次树中每个节点都是一个平行于坐标轴的包围盒,判断射线是否跟包围盒相交很简单,计算量很少。
缺点就是,对于 M 的选择,很有可能很多物体都与该超平面相交,会造成很多麻烦。对于这些物体,不论你是都放到子节点,还是保留在该节点,都不会是最优的,甚至复制还会造成内存占用问题。
基于物体的加速结构
基于空间的加速结构有一个问题,那就是在空间上做划分对物体本身不友好,例如一个物体被超平面穿过。
基于物体的方法,直接对物体做类别划分,并组合成树形结构。保证一个物体只属于一个类别,但是每类的空间占用有可能重合。
Bounding Volume Hierarchy (BVH) 方法就是其中一种。按包围盒进行树形结构组织。

图源games101课件

建立的过程看图就明白了。与 k-d tree 相同的是,BVH 在建树时也需要做划分,满足如果射线不穿过父节点,那么肯定不穿过子节点。但是划分不是在空间结构上,而是对物体的集合做划分。这也就导致空间会重合。
查询跟 k-d tree 一样,就不赘述了。
Surface Area Heuristic (SAH
实际上是一种基于空间的划分,把它单列出来是怕行文太长。
简述一下基本思路。首先对 u 的子树的 cost 做了一个建模:

其中 C_u 表示 u 节点的花费,p_x 表示光线追踪时生成的射线穿过 x 节点的概率。C_{trav} 是一些额外花费,例如计算与包围盒是否相交之类的。可以看做与 u 有关的常数。
假设有 A 个物体划分给 x 子节点, B 个物体划分给 y 子节点,并且认为概率 p 与包围盒大小成正比,那么有如下近似:

其中 C_{isect} 是判断射线与物体是否相交的花费。
于是我们得到了优化目标:划分空间时,让这个式子最小。
对于每一个轴,将其等距离划分成 B 份,这样得到 B-1 个划分平面。对于第 i 个平面,该平面把三角形的中点集合划分成两份,则就按这两份计算花费,选取花费最小的平面。
三个轴都算一下,选最小的轴。

实验

渲染一个 Stanford bunny,其包含 69451 个三角形。
  • 实现判断包围盒是否相交
  • 实现利用 BVH 加速结构查询最近相交点
  • 实现 SAH

解析
首先展示一下结果。

包围盒是否相交,只需要判断射线 进入与离开包围盒的时间即可。
对于包围盒的六个平面,互相平行的三对,只需要每一对挨个计算。假设对垂直于 x 轴那一对计算,实际上就是解 的 t 。具体实现如下:
inline bool Bounds3::IntersectP(const Ray& ray, const Vector3f& invDir,                                                                              const std::array<int, 3>& dirIsNeg) const
{   
   // invDir: ray direction(x,y,z), invDir=(1.0/x,1.0/y,1.0/z), use this because Multiply is faster that Division   
  // dirIsNeg: ray direction(x,y,z), dirIsNeg=[int(x>0),int(y>0),int(z>0)], use this to simplify your logic   
  // TODO test if ray bound intersects   
  auto t0 = (pMin - ray.origin) * invDir;   
  auto t1 = (pMax - ray.origin) * invDir;   

  auto v0 = Vector3f(std::min(t0.x, t1.x), std::min(t0.y, t1.y), std::min(t0.z, t1.z));
  auto v1 = Vector3f(std::max(t0.x, t1.x), std::max(t0.y, t1.y), std::max(t0.z, t1.z));   
  
  float tenter = std::max(v0.x, std::max(v0.y, v0.z));   
  float texit = std::min(v1.x, std::min(v1.y, v1.z));   
  return tenter < texit && texit >= 0;
}
  对于 BVH 的查询过程,先判断是否跟当前包围盒相交,然后判断是否是叶子,如果是叶子的话遍历所有三角形,否则递归,返回最小值。
Intersection BVHAccel::getIntersection(BVHBuildNode* node, const Ray& ray) const
{   
  // TODO Traverse the BVH to find intersection   
  std::array<int, 3> dirIsNeg = {ray.direction.x>0, ray.direction.y>0, ray.direction.z>0};   
  if(!node->bounds.IntersectP(ray, ray.direction_inv, dirIsNeg)) {        
     return Intersection();   
   } else {
       if(node->left && node->right) {
           auto L = getIntersection(node->left, ray);
           auto R = getIntersection(node->right, ray);
           if(L.distance > R.distance) return R;
            return L;
        } else {
           return node->object->getIntersection(ray);
        }
    }
}
   一个简单的查询优化是,计算与包围盒是否相交时顺便返回 t_enter,如果一个子节点的递归答案已经早于另一个子节点的 t_enter 了,那么就不再递归。实测了一下没啥用……引入的额外计算反而有点慢。

  对于 SAH ,查询和上面一样,主要是划分过程的实现。这里的 B 随便指定的 50,可以随意改。
        if(splitMethod == SplitMethod::SAH) {                     
            
             struct Data {
                 std::vector<Object*> lobjs, robjs;
                double C;
           };
           auto deal = [](std::vector<Object*> objects, int axis_id) {
                              
                     auto getValue = [](Object* obj, int id) {
                    if(id == 0) return obj->getBounds().Centroid().x;
                    if(id == 1) return obj->getBounds().Centroid().y;
                    if(id == 2) return obj->getBounds().Centroid().z;
                    assert(0);
               };
               int B = 50;
               double maxV = -std::numeric_limits<double>::infinity();
               double minV = std::numeric_limits<double>::infinity();
               for(auto obj : objects) {
                    maxV = std::max(maxV, (double)getValue(obj, axis_id));
                    minV = std::min(minV, (double)getValue(obj, axis_id));
               }
               std::vector<std::vector<Object*>> buckets(B);
                std::vector<Bounds3> lbd(B), rbd(B);
               for(auto obj : objects) {
                    auto val = getValue(obj, axis_id);
                   int i = std::min((int)((val-minV)/(maxV-minV) * B), B-1);
                   buckets.push_back(obj);
                   lbd = Union(lbd, obj->getBounds());
               }
               for(int i = 0;i < B;i ++) rbd = lbd;
               for(int i = 1;i < B;i ++) lbd = Union(lbd, lbd[i-1]);
                for(int i = B-2;i >= 0;i --) rbd = Union(rbd, rbd[i+1]);
               Bounds3 bd = lbd[B-1];
               int lsz = 0, rsz = objects.size(), sz = objects.size();
                double C = std::numeric_limits<double>::infinity();
               int partition = -1;
               for(int i = 0;i < B - 1;i ++) {
                   lsz += buckets.size();
                    rsz -= buckets.size();
                   if(lsz && rsz) {
                       double nowC = 1.0*lbd.area()/bd.area()*lsz  
                                             + 1.0*rbd[i+1].area()/bd.area()*rsz;
                                                if(nowC < C) {

                                                     C = nowC;
                                                     partition = i;
                       }
                   }
               }
               assert(partition != -1);
               
               Data ret;
               ret.C = C;
               for(auto obj : objects) {
                   auto val = getValue(obj, axis_id);
                    int i = std::min((int)((val-minV)/(maxV-minV) * B), B-1);
                    if(i <= partition) ret.lobjs.push_back(obj);
                   else ret.robjs.push_back(obj);
                }

               return ret;
           };

            Data x_result = deal(objects, 0);
            Data y_result = deal(objects, 1);
            Data z_result = deal(objects, 2);
           if(x_result.C > y_result.C) std::swap(x_result, y_result);
           if(x_result.C > z_result.C) std::swap(x_result, z_result);
           //std::cout << x_result.C << std::endl;
           node->left = recursiveBuild(x_result.lobjs);
           node->right = recursiveBuild(x_result.robjs);
            node->bounds = Union(node->left->bounds, node->right->bounds);
        }

对比一下,SAH 是 6s,BVH 是 7s,差别不大。

END




评分

参与人数 1活跃度 +18 展开 理由
元素小行家... + 18 太强了

查看全部评分

本帖被以下画板推荐:

天若有情天亦老, 人间正道是沧桑。
使用道具 <
落雪成白  发表于 2021-4-8 04:09:17  
2#
凑个热闹
回复 收起回复
使用道具
您需要登录后才可以回帖 登录 | 注册

本版积分规则

快速回复 返回顶部 返回列表