物体列表的AABB 上一章中基本讲完了各个类的aabb写法,还剩最后一个,物体列表类的aabb生成,下面给出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 ... #include "aabb.h" ... class hittable_list : public hittable { public : ... virtual bool hit ( const ray& r, double t_min, double t_max, hit_record& rec) const override ; virtual bool bounding_box ( double time0, double time1, aabb& output_box) const override ; ... }; ... bool hittable_list::bounding_box (double time0, double time1, aabb& output_box) const { if (objects.empty ()) return false ; aabb temp_box; bool first_box = true ; for (const auto & object : objects) { if (!object->bounding_box (time0, time1, temp_box)) return false ; output_box = first_box ? temp_box : surrounding_box (output_box, temp_box); first_box = false ; } return true ; }
物体列表的aabb的求法就是逐个子物体遍历,求它们这些物体共同的包围盒。其中使用到了上一章中提到的计算两个包围盒的包围盒的函数surrounding_box。
自此,我们已有的物体类:球类、移动的球类和物体列表类都接入了生成包围盒的函数。
BVH类 是时候把这一切组织起来了,记得上一章中提到的对象划分已经对象划分下的树状结构吗?我们还没有给它起名字呢:我们将创建一个树状结构,树的每一个节点都是一个包围盒或者一个物体,包围盒一层层的嵌套,物体作为叶子节点被一层一层的包裹着,这种结构叫层次包围盒 (Bounding Volume Hierarchies),或称BVH 。
BVH既然是一棵树,我们首先得创建它的节点,和其他所有树结构一样,它的节点得储存孩子信息。如果我们把事情再想得简单一点,每个大包围盒内部包着两个子包围盒,那BVH结构就变成了一个二叉树,看代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 #ifndef BVH_H #define BVH_H #include "rtweekend.h" #include "hittable.h" #include "hittable_list.h" class bvh_node : public hittable { public : bvh_node (); bvh_node (const hittable_list& list, double time0, double time1) : bvh_node (list.objects, 0 , list.objects.size (), time0, time1) {} bvh_node ( const std::vector<shared_ptr<hittable>>& src_objects, size_t start, size_t end, double time0, double time1); virtual bool hit ( const ray& r, double t_min, double t_max, hit_record& rec) const override ; virtual bool bounding_box (double time0, double time1, aabb& output_box) const override ; public : shared_ptr<hittable> left; shared_ptr<hittable> right; aabb box; }; bool bvh_node::bounding_box (double time0, double time1, aabb& output_box) const { output_box = box; return true ; } #endif
来看一下hit函数,在这里你会看到包围盒是如何减少计算的——你连我的盒子都碰不到,那我们就没有继续看下去的必要了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 bool bvh_node::hit (const ray& r, double t_min, double t_max, hit_record& rec) const { if (!box.hit (r, t_min, t_max)) return false ; bool hit_left = left->hit (r, t_min, t_max, rec); bool hit_right = right->hit (r, t_min, hit_left ? rec.t : t_max, rec); return hit_left || hit_right; }
注意上面的代码中有一个剪枝操作,在第十行中,遍历右子树节点的时候先检查了左子树返回值是否为true,即光线有无和左子树碰撞,如果有,那么在判断右子树的时候就又有一个参照物了:“右子树上的物体是否是先被光线碰到的?或者它被左子树上的物体遮挡了?改变t的范围,只在右子树物体离光源更近的情况下,才认可这次碰撞,其他的直接剪掉。”
我们现在来理一下思路,我们写这样的hit函数会发生什么,假设一个bvh在场景中构造完毕(虽然我们暂时没有给出用来构造bvh的构造函数),即,现在main函数中那个world物体不再是一个物体列表,换做是一个已经构建好的bvh,现在有一根光线自相机或者某处发射过来:
既然有光线,我们就得调用场景中所有物体的hit,现在只有一个物体那就是bvh,我们只需要调用它的hit即可。如果这根光线没有碰到最外层的盒子,那上述代码中的第四行return false
就是整个碰撞检测的最后一句代码,在这之前我们只调用了一个aabb的hit函数,无论场景中有多少物体,也只需要调用这一个函数即可。再想想如果我们没有bvh,而是使用物体列表来管理场景中的物体,那我们会做哪些工作——如果t的范围不考虑进去的话,即便这根光线没有碰到任何一个物体,我们也需要调用每个物体的hit函数,其中就包括一些球,每个球意味着要求一次一元二次方程的求根公式。
要知道,场景是无限大的,物体只占了场景中微不足道的某个角落,我们创建的大部分光线实际上都是没有和物体发生碰撞的光线,光考虑这部分光线,bvh就比传统的物体列表要快上几个数量级了。
构造BVH 到此为止,核心问题还没有解决,如何构造一棵bvh树呢?
先来讲一讲上一章的遗留问题,对象划分的依据是什么?首先,必须要明确一个概念:我们现在不需要任何规律的把bvh里的所有物体粗暴的分成两堆,代码也能很好的工作,即便包围盒内部的包围盒比外面的盒子还要大,也不影响代码的运行,充其量只是慢了一点。我们要找的对象划分的依据,是能让程序运行更快,更发挥bvh优势的一种划分方式,它可以是这样一个划分:
先随便找一个轴,x、y或者z轴,把物体列表里的物体按照这个轴从小到大的顺序进行排序并划分成两堆,对于左右子树,用这两堆分别再次进行递归。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 #include <algorithm> ... bvh_node::bvh_node ( const std::vector<shared_ptr<hittable>>& src_objects, size_t start, size_t end, double time0, double time1 ) { auto objects = src_objects; int axis = random_int (0 ,2 ); auto comparator = (axis == 0 ) ? box_x_compare : (axis == 1 ) ? box_y_compare : box_z_compare; size_t object_span = end - start; if (object_span == 1 ) { left = right = objects[start]; } else if (object_span == 2 ) { if (comparator (objects[start], objects[start+1 ])) { left = objects[start]; right = objects[start+1 ]; } else { left = objects[start+1 ]; right = objects[start]; } } else { std::sort (objects.begin () + start, objects.begin () + end, comparator); auto mid = start + object_span/2 ; left = make_shared<bvh_node>(objects, start, mid, time0, time1); right = make_shared<bvh_node>(objects, mid, end, time0, time1); } aabb box_left, box_right; if ( !left->bounding_box (time0, time1, box_left) || !right->bounding_box (time0, time1, box_right) ) std::cerr << "No bounding box in bvh_node constructor.\n" ; box = surrounding_box (box_left, box_right); }
随机范围内的int值的函数如下所示,工具函数都写到rtweekend.h里:
1 2 3 4 inline int random_int (int min, int max) { return static_cast <int >(random_double (min, max+1 )); }
最后,我们给出比较器的函数代码,注意把它写在上述构造函数的前面,让编译器可以成功的找到他们:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 inline bool box_compare (const shared_ptr<hittable> a, const shared_ptr<hittable> b, int axis) { aabb box_a; aabb box_b; if (!a->bounding_box (0 ,0 , box_a) || !b->bounding_box (0 ,0 , box_b)) std::cerr << "No bounding box in bvh_node constructor.\n" ; return box_a.min ().e[axis] < box_b.min ().e[axis]; } bool box_x_compare (const shared_ptr<hittable> a, const shared_ptr<hittable> b) { return box_compare (a, b, 0 ); } bool box_y_compare (const shared_ptr<hittable> a, const shared_ptr<hittable> b) { return box_compare (a, b, 1 ); } bool box_z_compare (const shared_ptr<hittable> a, const shared_ptr<hittable> b) { return box_compare (a, b, 2 ); }
测试包围盒* 现在回到动态模糊那一章的最后一个场景,我们分别使用物体列表和BVH来装载场景物体,并运行光追器,来对比一下两者的耗时,首先,给main函数打个时间戳:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 #if !MULTITHREAD ... #include <windows.h> ... int main () { auto start = GetTickCount64 (); ... std::cerr << "\nDone.\n" ; int time = GetTickCount64 () - start; time = time / 1000 ; auto minute = time / 60 ; auto second = time % 60 ; std::cerr << "took " << minute << "m " << second <<"s to complete. \n" ; } }
紧接着,用bvh提到程序中的物体列表,在main函数中调用random_scene的地方,直接把物体列表传到bvh_node类的构造函数里,创建一个bvh即可:
1 2 3 auto world = bvh_node (random_scene (),0 ,1 );
传入构造函数的两个时刻值0和1是和相机的快门打开时间和关闭时间保持一致的。这决定了我们程序中所有的移动的球对象的包围盒都是通过这两个时间值来计算的。对于直线运动的球来说,在两个极端情况下的包围盒的包围盒,即是最终的包围盒。
运行程序,可见命令行中的运行时间(release-x86):
对比前者——物体列表作为容器下的运行时间,后者bvh容器下的运行时间快了好几倍。
课后实践
在脑海中构建一个少量物体的bvh,按顺序阅读bvh的构造函数,体会物体被二分和生成包围盒的过程。接下来思考hit函数中包围盒是如何和光线求交的,这有助于理解整体架构。
将bvh应用于多线程模式,并生成一个视频,比较使用包围盒前后的序列帧生成时间。
倘若使用八叉树包围盒,我们该如何设计?并了解另外两种包围盒KD-Tree和BSP-Tree。
将之前所有的关键类的关系做出一个类图,并梳理他们之间的调用过程,回想一下我们是怎么得到一张图片的。
参考文献 https://raytracing.github.io/books/RayTracingTheNextWeek.html
参考自《Ray Tracing: The Next Week》第3.8节到第3.10节。