我们开开心心的讲完了中篇的前两章,渲染出了炫酷的动态模糊效果,它简单而且漂亮。但是,该来的总归还是要来,下面的几章会给大伙儿整些头疼的:包围盒。
这些内容虽然较为枯燥,但是确是异常重要的图形知识点。能把这样一个经典的技术在我们的光追项目里复刻出来,我感到无比的荣幸。
准备好了吗?几何数学课开始了!

光线碰撞中的重复冗余

我带你回顾一下我们发射光线到回收颜色的全过程,你就会明白,我们的代码有哪些不合理的地方:
相机朝虚拟视口中的像素格子发出光线,这跟光线会尝试调用场景中所有物体(本项目中一个名为world的物体列表)的hit函数——会按物体插入物体列表时候的顺序对物体进行逐一调用它们自己的hit函数。这些子物体要不还是物体列表,或者是球、移动的球之类的实体物体。
我们再来看看球和移动的球是如何处理光线碰撞的,我们通过解一个二元一次方程来判断球是否和光线碰撞,返回碰撞时刻以及碰撞点信息等等(具体见第五章《球》)。这意味着对于每一次射出光线(无论是相机射出的,还是材质表面反射得来的)都要和场景中所有的球进行一次判断。如果场景中有100个物体,意味着,每一次hit之后反射的光线,就要解100个二元一次方程。假设平均每根光线会在场景中弹射5次,对于每一根光线我们要解500个方程,采样数会把这个数字拉到50000(假设每个像素采样100次),而像素数量会把这个数字拉到亿级别(假设我们的场景是400300),你现在看看我们球类代码中的hit函数,那么一大坨,你能想象每次在命令行中点击回车,你的电脑运行了几亿次这部分的代码吗?
不合理的地方在哪里呢?假设我们有一个光线朝西边径直而去,在光线射过去的方向的相反方向有99颗球,这九十九颗球也都逃不过碰撞检测函数。这分莫名其妙的开销应该是可以通过某种方式避免的。
总结一下刚刚所说的:*光线-物体交集是光线追踪器中的主要时间瓶颈,时间与物体数量成线性关系。这是对同一模型的重复冗余搜索,有没有什么办法可以使之复杂度降低呢?比如通过某种二分法让其的复杂度降低到对数等级?

空间划分与八叉树

我提到了二分法,你应该知道,二分法必须针对已排序的数据才有作用,也就是说,我们必须让场景中的物体依照某种规则变得有序,才可以使用二分法的思想降低复杂度。
假设我们的物体都集中在以原点为中心的单位立方体里面,很容易可以想到一种划分方式:先通过三个坐标轴把空间划分为八个小立方体。
如果把任意一个数带入t,光线的公式十九:包围盒子(上) - 图1,最终得到的坐标点的x,y,z都不会大于0,那我们就没有必要检查位于第Ⅰ象限的物体了。如果x,y,z有全大于0的情况,说明光线与第Ⅰ象限有交点,我们就对第一象限中的小立方体继续划分——划分成八个更小的立方体,再检查光线是否与它们有交点,位于和光线无交点的小立方体内的物体,我们就无需检查了。这种思想叫空间划分
整个搜索链条就如同树一样,先看有没有和大立方体有交集,如果有交集就再看其中的小立方体,然后一直往下看,直到我们人为规定的某个大小的立方体内不再有更小的立方体,就直接查看该立方体内的物体即可。
image.png
八叉树——这种结构有一个浅显易懂的名字。很容易可以写出关于八叉树的伪代码(这个八叉树只有两层,第三层就是物体层):

  1. if (光线碰到了某个立方体)
  2. if(光线碰到了子立方体1号){
  3. if(光线碰到了子立方体1号内的某个物体){
  4. 处理碰撞信息。
  5. return true;
  6. }
  7. }
  8. else if(光线碰到了子立方体2号){
  9. ...
  10. }
  11. ...
  12. else if(光线碰到了子立方体7号){
  13. ...
  14. }
  15. else{
  16. ...
  17. }
  18. else
  19. return false

我们没有讨论如何判断光线和立方体求交,也没有讨论物体在两个立方体的边界上我们要如何处理,篇幅有限。我们不会在项目中使用八叉树,但是它确实是一个可行的方案,并且很容易被图形工程师们提起。
我们并不准备使用空间划分的八叉树来管理我们场景中的物体,因为还有另一个备选方案可以使用——对象划分。这种空间划分比空间划分使用更为广泛。

对象划分简述

现在假设空间中有15个对象,我们按照某种规律把它们分成俩个部分,如下图所示:
image.png
至于划分对象的算法我们之后再说。
这是三维空间的某种对象划分在二维上的投影示意图。可见对象划分之后的各个区域是很可能会重叠的,但是也就不再存在一个物体处于区域边界的情况。
当然这里也依然存在一个树状结构。光线先和最外层和紫色部分算交点,如果存在交点才会查看红蓝两个区域的交点情况。

AABB

我们已经决定使用对象划分来加速光线碰撞,这里有很多问题待解决:具体如何划分物体?树状结构如何搭建?新的场景物体又该如何管理(物体列表显然已经不满足要求了)?
这些问题先靠一边,我们先来把目光瞄准到所谓“区域”,也就是包围盒。
包围盒应该是什么样的?我们又如何计算光线与盒子求交呢?
如果不考虑性能,包围盒可以是任何形状,它可以是一个大球,包裹住内部的小球,这样我们只需要算一次一元二次方程就可以得到光线和大球碰撞的结果,如果有碰撞再计算光线和内部小球的碰撞情况。而且我们计算光线和大球求交的时候不需要计算具体反射光线,完全不需要调用材质代码,只需要返回是否碰撞到信息,以便我们去判断需不需要和内部小球进一步计算碰撞即可。
但是光线和球的碰撞计算复杂度还是高了一点,有一种结构最擅长处理和光线求交,这就是轴向立方体。使用轴向立方体作为包围盒的形状,这就是轴向包围盒(Axis-Aligned Bounding Boxes ),又叫AABB
轴向立方体就是所有的边都和坐标轴平行的立方体,它和光线求交的计算复杂度低,且易于理解。
先不看Z轴,在平面上的轴向包围盒如下图所示,空间中的包围盒和其原理相同,可以通过推广得到:
image.png
平面上的AABB(2D-AABB)即是四条直线十九:包围盒子(上) - 图5围成的区域。对于这对平行线十九:包围盒子(上) - 图6,光线会和它们产生两个交点,当然,另外一对平行线同理:
image.png
看看光线传播到十九:包围盒子(上) - 图8分量为十九:包围盒子(上) - 图9十九:包围盒子(上) - 图10时,时间十九:包围盒子(上) - 图11十九:包围盒子(上) - 图12是多少,只要把光线原点位置坐标向量A的x分量和方向向量b的x分量带入公式十九:包围盒子(上) - 图13即可:

十九:包围盒子(上) - 图14十九:包围盒子(上) - 图15
同理,在y轴上你同样可以得到两个时间:
十九:包围盒子(上) - 图16十九:包围盒子(上) - 图17
好,有了这四个时间值,我们如何判断这根光线有没有和2D-AABB相交呢?看下图:
image.png
对于上图中上方的光线,其(t0,t1)和(t2,t3)两个时间区域并无交集,在图中的表现即是蓝色和绿色的线段并无重合处,那就可以断言,这根光线和盒子并无交集。而下面的光线可见明显蓝绿重合,它一定是一根穿过盒子的光线。
很容易就能把这个原理推广到三维的情形下:
3D-AABB由三对面组成,假设光线传播到与XoY面平行的那一对面上的时间是t0,t1,传播到YoZ面上的时间t2,t3,ZoX则是t4,t5。则一定有:光线和盒子相交 <==> (t0,t1)、(t2,t3)和(t4,t5)两两有交集。
伪码如下:

  1. compute (tx0, tx1)
  2. compute (ty0, ty1)
  3. compute (tz0, tz1)
  4. //返回这三个时间段有无重叠
  5. return overlap?( (tx0, tx1), (ty0, ty1), (tz0, tz1))

对于坐标轴上的两个区间(a,b)和(c,d),判断区间有无重叠,只需要取a和c较大的那一个和b与d中较小的那一个比较,如果前者小于后者,说明有重叠,这部分的伪码如下:

  1. bool overlap(a, b, c, d)
  2. f = max(a, c)
  3. F = min(b, d)
  4. return (f < F)

铺垫完成,接下来我们来从代码层面构造AABB。

编写AABB类

创建aabb.h文件,敲入如下代码:

  1. #ifndef AABB_H
  2. #define AABB_H
  3. #include "rtweekend.h"
  4. class aabb {
  5. public:
  6. aabb() {}
  7. // 如何唯一确定一个3D-AABB?只需要两个点即可!
  8. aabb(const point3& a, const point3& b) { minimum = a; maximum = b;}
  9. //返回三个分量都较小的那一个点。
  10. point3 min() const {return minimum; }
  11. //返回minimum点在盒子上的对角点,即三个分量都较大的那个点。
  12. point3 max() const {return maximum; }
  13. //光线和AABB求交代码,注意这个hit和物体类的hit没有一毛钱关系。
  14. bool hit(const ray& r, double t_min, double t_max) const {
  15. //for循环判断三个轴向的三对面和光线碰撞的时间。
  16. for (int a = 0; a < 3; a++) {
  17. //见解析。
  18. auto t0 = fmin((minimum[a] - r.origin()[a]) / r.direction()[a],
  19. (maximum[a] - r.origin()[a]) / r.direction()[a]);
  20. auto t1 = fmax((minimum[a] - r.origin()[a]) / r.direction()[a],
  21. (maximum[a] - r.origin()[a]) / r.direction()[a]);
  22. t_min = fmax(t0, t_min);
  23. t_max = fmin(t1, t_max);
  24. if (t_max <= t_min)
  25. return false;
  26. }
  27. return true;
  28. }
  29. point3 minimum;
  30. point3 maximum;
  31. };
  32. #endif
  1. 我们使用两个point3来唯一确定一个3D-AABB,就如同我们可以在平面直角坐标系上用左下角和右上角两个点确定一个2D-AABB一样。<br />我们来看看hit函数里的for循环中做了什么?<br />首先,我们把minmummaximum点的![](https://cdn.nlark.com/yuque/__latex/712ecf7894348e92d8779c3ee87eeeb0.svg#card=math&code=x&id=mWzx5)分量都带入了公式![](https://cdn.nlark.com/yuque/__latex/8f8c605714efea092050d60d61b9ce49.svg#card=math&code=P%28t%29%20%3D%20A%20%2B%20tb&id=sJsUi)来计算光线穿过这一对面的时间值![](https://cdn.nlark.com/yuque/__latex/120ecc98af8b35809bffc3741ac52f86.svg#card=math&code=t_0&id=LlFaS)和![](https://cdn.nlark.com/yuque/__latex/a67f549adc92ae7ed58082ebbbc38d50.svg#card=math&code=t_1&id=Gpmwz),注意,这里的fmin和fmax函数是为了确保![](https://cdn.nlark.com/yuque/__latex/120ecc98af8b35809bffc3741ac52f86.svg#card=math&code=t_0&id=h5SGn)小于![](https://cdn.nlark.com/yuque/__latex/a67f549adc92ae7ed58082ebbbc38d50.svg#card=math&code=t_1&id=ZySGb)。因为minmum[0]一定是小于maximum[0]的,但是无法确定![](https://cdn.nlark.com/yuque/__latex/029bb2c1832b843ce66867ac49f1db71.svg#card=math&code=%5Cfrac%7Bminimum%5B0%5D%E2%88%92A_x%7D%7Bb_x%7D&id=Wk0UK)就一定比![](https://cdn.nlark.com/yuque/__latex/c8811b213e2cf2356b0395ce5e85f807.svg#card=math&code=%5Cfrac%7Bmaximum%5B0%5D%E2%88%92A_x%7D%7Bb_x%7D&id=lg6V4)小,想想光线从x轴无穷大处朝x变小的方向射过来,这时候![](https://cdn.nlark.com/yuque/__latex/359c47e9adb148f44b2583aed8513818.svg#card=math&code=%5Cfrac%7Bminimum%5B0%5D%E2%88%92A_x%7D%7Bb_x%7D%20%3E%5Cfrac%7Bmaximum%5B0%5D%E2%88%92A_x%7D%7Bb_x%7D&id=cifXq),我们就需要调换存储![](https://cdn.nlark.com/yuque/__latex/120ecc98af8b35809bffc3741ac52f86.svg#card=math&code=t_0&id=X9t48)和![](https://cdn.nlark.com/yuque/__latex/a67f549adc92ae7ed58082ebbbc38d50.svg#card=math&code=t_1&id=qFHHx)。<br />紧接着,我们开始对区间求交集。hit函数传进来的t_min和t_max代表我们认可的t的范围,这个与物体类的hit函数的参数中的t_min和t_max是一个道理,用来给我们切去不必要的碰撞(如负值t)的。现在把(![](https://cdn.nlark.com/yuque/__latex/120ecc98af8b35809bffc3741ac52f86.svg#card=math&code=t_0&id=js5YY),![](https://cdn.nlark.com/yuque/__latex/a67f549adc92ae7ed58082ebbbc38d50.svg#card=math&code=t_1&id=phcvt))和(t_min,t_max)求交,得到的值如果是一个不存在的区间,则表示“在您认可的t的区间内,不存在一个t让光线传播到AABB的x轴同向对面中”。<br />紧接着对y和z轴上的对面做同样的操作,期间一旦发现求交后区间不存在,便可断定“光线在给定的时间区间内不曾跨过指定对面”,对三个对面进行这样的操作之后,求交的结果存在与否则代表了“光线是否在某一时刻同时在三个对面之间,即是否曾射入AABB之中”。

优化hit函数

我们换一种方式描述hit代码中的逻辑,下面的代码比起原先的hit函数冗长的逻辑显得更为清晰。

  1. inline bool aabb::hit(const ray& r, double t_min, double t_max) const {
  2. for (int a = 0; a < 3; a++) {
  3. auto invD = 1.0f / r.direction()[a];
  4. auto t0 = (min()[a] - r.origin()[a]) * invD;
  5. auto t1 = (max()[a] - r.origin()[a]) * invD;
  6. //如果光线的方向向量某个分量为负,则光线一定会逆向穿过本轴上的对面。
  7. if (invD < 0.0f)
  8. std::swap(t0, t1);
  9. t_min = t0 > t_min ? t0 : t_min;
  10. t_max = t1 < t_max ? t1 : t_max;
  11. if (t_max <= t_min)
  12. return false;
  13. }
  14. return true;
  15. }

给物体绑定包围盒

AABB类已经被构造出来了,我们现在得把它和物体类联系起来了,因为包围盒子终归是要包裹住物体的。
先来修改底层,给物体类添加一个纯虚函数。

  1. #include "aabb.h"
  2. ...
  3. class hittable {
  4. public:
  5. ...
  6. virtual bool hit(const ray& r, double t_min, double t_max, hit_record& rec) const = 0;
  7. //制造包围盒子的函数。
  8. virtual bool bounding_box(double time0, double time1, aabb& output_box) const = 0;
  9. ...
  10. };

来看看这个新的函数,首先,它的返回值是bool类型,这是因为我们得给无法被包围盒包裹的物体一个解释,比如一个无限大的平面(以后可能会有),如果返回true,则表示包围盒生成失败。
生成的包围盒通过参数中的引用传递出去 :aabb& output_box
至于另外两个参数time0和time1,它对于移动的球类以及后续所有动态的物体都是必要的,因为如果不给定一个时间区间,物体运动的轨迹将会无限长,无法被包围盒包裹。
很容易可以写出球类的包围盒生成函数:

  1. class sphere : public hittable {
  2. public:
  3. ...
  4. virtual bool hit(
  5. const ray& r, double t_min, double t_max, hit_record& rec) const override;
  6. //override包围盒生成函数。
  7. virtual bool bounding_box(double time0, double time1, aabb& output_box) const override;
  8. ...
  9. };
  10. ...
  11. bool sphere::bounding_box(double time0, double time1, aabb& output_box) const {
  12. //这是一个球的外切立方体。它是能包裹住球且体积最小的AABB。
  13. output_box = aabb(
  14. center - vec3(radius, radius, radius),
  15. center + vec3(radius, radius, radius));
  16. return true;
  17. }
  1. 下面是移动的球类的包围盒生成函数:
  1. ...
  2. #include "aabb.h"
  3. ...
  4. class moving_sphere : public hittable {
  5. public:
  6. ...
  7. virtual bool hit(
  8. const ray& r, double t_min, double t_max, hit_record& rec) const override;
  9. //override包围盒生成函数。
  10. virtual bool bounding_box(
  11. double _time0, double _time1, aabb& output_box) const override;
  12. ...
  13. };
  14. ...
  15. bool moving_sphere::bounding_box(double _time0, double _time1, aabb& output_box) const {
  16. //我们先生成移动的球在两个极端时间下的包围盒。
  17. aabb box0(
  18. center(_time0) - vec3(radius, radius, radius),
  19. center(_time0) + vec3(radius, radius, radius));
  20. aabb box1(
  21. center(_time1) - vec3(radius, radius, radius),
  22. center(_time1) + vec3(radius, radius, radius));
  23. //然后再求这两个包围盒的包围盒。这个surrounding_box函数会在之后给出。
  24. output_box = surrounding_box(box0, box1);
  25. return true;
  26. }
  1. 下面添加两个包围盒的包围盒函数`surrounding_box(box0, box1)`aabb.h中,它的本质是通过比较去筛选包围盒中的两个point3的分量,使得minimum的各个分量取两个盒子的minimum分量的较小值,maximum的所有分量都取两个盒子的maximum分量的较大值,如下所示:
  1. aabb surrounding_box(aabb box0, aabb box1) {
  2. point3 small(fmin(box0.min().x(), box1.min().x()),
  3. fmin(box0.min().y(), box1.min().y()),
  4. fmin(box0.min().z(), box1.min().z()));
  5. point3 big(fmax(box0.max().x(), box1.max().x()),
  6. fmax(box0.max().y(), box1.max().y()),
  7. fmax(box0.max().z(), box1.max().z()));
  8. return aabb(small,big);
  9. }

aabb对于本章开头提到的树状结构来说相当于叶子节点,本章基本完成了叶子节点的构造,但对于整个树状结构的搭建来说,才是刚刚开始。

课后实践

尝试编写物体列表类的包围盒生成函数(这个函数会在下一章给出)。

参考文献

https://raytracing.github.io/books/RayTracingTheNextWeek.html
参考自《Ray Tracing: The Next Week》第3.1节到第3.7节。