首页 > 行情 >

天天即时看!光线追踪原理与实现2

发表于: 2023-01-27 15:08:54 来源:哔哩哔哩

本文基于 《Ray Tracing The Next Week》(https://raytracing.github.io/books/RayTracingTheNextWeek.html#)该文,对于上一章节中我们实现的光线追踪渲染器进行升级,并渲染得到上面这张图像。主要内容为 引入光追中的时间概念来实现运动模糊、通过BVH空间划分加速光追过程、实现体积物体的散射渲染并对之前的大气散射做修改、在光追中引入物体的移动/旋转方式。

另外需要说明的是,本文中的一些渲染图片是直接使用的 RayTracing The Next Week 中的图像,而读者的代码运行结果可能会与之有些差异,这是因为在上一章中我们的多线程和大气散射部分存在些问题,这些我打算放在下一章再去修复。

运动模糊

在现实中,相机的运动模糊是由于相机拍照时,其快门的关闭是一个过程,在这段时间中底片会持续接收到光线,那么这一过程中运动的物体反射来的光线就会都呈现在底片上,进而形成了运动模糊现象。


(资料图片仅供参考)

那么对于光线追踪,我们也可以让我们的相机持续一段时间不停的发射射线,最后对这些射线结果取平均,来实现运动模糊效果。

但更好的一种方法是,我们可以参考之前实现景深的方法。在之前实现景深的方法中,我们也可也添加一个遍历结构,来从光圈中发射射线,然后对这些射线结果取平均,但实际上这个遍历问题可以转换为一个概率问题。遍历叠加后取平均这个过程实际上就是在计算一个概率,对于景深问题这个就是在计算相机发射的光线有多少概率是发生偏移的,因此我们最终没有增加循环结构,而是直接让本来发射出的射线根据一个随光圈半径变化的概率而产生偏移。在运动模糊中我们同样可以这么做,即让原本的光线更加一个均匀的概率,让这些光线拥有一个自己的随机时间,每个射线在自己对应的时刻对场景进行碰撞检测,记录自己的渲染结果,这样最后所有光线叠加的结果就是最终的效果。

这样做的好处在于,我们在调试渲染时,不需要每次都去调整一大堆循环结构的循环次数等参数,而是可以直接通过增加每像素发射的射线数,即可增加相机每个时刻发射的射线数量,应为该数量本来等于一个均匀概率 * 总射线数。

接下来我们开始修改射线类和相机类,让射线存储其自身的时刻,让相机能够设置其拍摄的开始时刻和结束时刻,并且在生成射线时给射线随机分配一个这之间的时刻。

[ray.h] 记录时刻

[camera.h] 记录时刻

现在我们的相机和射线可以记录时间了,但是物体还不能够随着时间产生变化。接下来我们来创建一个球心可以随时间做线性变化的球体类 moving_sphere,虽然其球心随时间一直变化,但对于一个时刻而言,其球心是确定的,那么在射线击中它时,它也有一个确定的球心,我们可以通过 射线本身记录的时刻来确定该球心。因此其 hit 函数就可以通过传入的射线上的时刻来确定球心,然后做正常的球-射线碰撞计算。

[moving_sphere.h] 移动球体

我们的射线除了由相机发出,在击中物体后还会根据物体的材质进行散射,因此在散射时我们需要进行修改来传递射线上的时间信息。

[material.h] 散射射线记录时间

接下来我们对上一篇文章的结尾场景添加运动模糊效果,我们的相机快门在时刻0开启,在时刻1关闭,漫反射球体在这段时间进行(0, r/2, 0) 的偏移

[main.cpp] 移动球体

[main.cpp] 设置相机快门

得到图像:

树结构包围盒(Bounding Volume Hierarchies: BVH)

我们现在渲染时对每次对场景中物体做射线检测都需要遍历一边场景中的所有物体,该时间成本与场景中物体数量是呈线性的。因此我们可以通过将空间划分为二叉树结构来加速这一遍历过程。

其划分思路为,先把整个场景内的物体划分到一个包围盒中,然后把物体按照某种方式进行划分和排序,左半边划分到左节点下的包围盒内,右半边划分到右节点下的包围盒内。之后左右节点也分别按照这样的方式向下继续创建各自的左右节点,以此递归,直到节点下只剩一个碰撞体。其结构如下图所示。

那么我们会发现,对于上图结构,射线有可能从红色和蓝色包围盒之间贯穿过去,即射线既能够击中红色包围盒也能够击中蓝色包围盒,此时我们选择距离射线最近的包围盒作为击中结果。

那么我们会发现,对于上图这种划分方式,我们最终选择的击中结果可能不是最正确的,因为两个包围盒有相交的范围。因此一个合理的空间划分方式就至关重要。对于空间划分 和 射线检测这样的场景下通常的方法是使用轴对齐包围盒(Axis-Aligned Bounding Boxes: AABB)

轴对齐包围盒即一个包围盒其六个面的法线都是与世界坐标系的坐标轴平行

基于这样的特点,我们只需要知道该包围盒一条对角线上的两个点的坐标就可以表示该包围盒(其它的点坐标都可以通过这两个点计算出来),通常我们会记录上图中标出的 左下角 和 右上角这两个点,p_min 点坐标的xyz分量都是包围盒所有点中最小的,p_max 点坐标的xyz分量都是包围盒所有点中最大的。

接下来我们来探讨该包围盒如何与射线进行碰撞检测。我们先看 2D 情形下,包围盒被压扁为一个平面区域,其可以由 x0, x1, y0, y1 四个值来表示,即 (x0, y0) (x1, y1) 两个点来表示

那么对于 x0 和 x1 平面我们会有这样的相交情况:

根据射线表达式 P(t)=A+tb

我们可以在 x 分量上得到:

可得:

同理可得:

那么对于 y0 和 y1 平面,我们同样可以得到:

如图,射线对 x0、x1 面的相交区域为绿色区域,对 y0,y1 面的相交区域为 蓝色区域,那么只有绿色和蓝色的相交区域才是真正的射线与包围盒的相交区域。

翻译成伪代码即:

而对于三维情况该规律同样适用,我们只需要增加z这个维度即可:

为了判断这几个轴的维度间是否相交,我们需要分别对 (tx0,tx1) (ty0, ty1) (tz0, tz1) 做排序。以上图为例,我们可以发现对于相交区域,我们做排序让 tx0 < tx1,ty0 < ty1,那么只有当 max(tx0, ty0) < min(tx1, ty1) 时,这两段区域才会相交。

其伪代码为:

因此我们有:

这里会出现一个问题是,如果 bx 为 0,那么就会出现结果为无穷的情况,但好在这种情况下 t0 和 t1 会要么都是正无穷,要么都是负无穷,这样的结果并不会影响我们后面计算的正确性。

但还有一个问题是,如果 bx 为 0,且 (x0 - Ax) 或者 (x1 - Ax) 也为 0,那么就会出现结果为 NaN 的情况,这种情况我们会在后面进行讨论。

基于这些我们就可以设计出我们的 aabb 包围盒了:

[aabb.h] AABB包围盒类

在代码实现当中,如果出现了 NaN 情况,那么比较就会返回 false,这样也会得到正确的结果,应该出现 NaN则说明射线与包围盒相切,我们将相切情况排除到相交范围外。

对于 AABB-射线 相交的代码实现,Andrew Kensler 在 Pixar 中实现的版本在各个编译器上都能以很好的效率运行,因此我们将其作为首选:

[aabb.h] AABB-射线相交实现

实现了 AABB包围盒后,我们就可以给所有碰撞体添加其对应的包围盒了。我们现在 hittable 类中抽象出包围盒函数,如果该碰撞体能够创建出包围盒就返回true,并输出其包围盒,否则返回false。因为像对于无限大的平面之类的物体我们就无法通过包围盒来对其进行包围:

[hittable.h] 包围盒函数

对于球体,其包围盒创建十分简单:

[sphere.h] 球体包围盒

对于移动球体 moving_sphere,我们可以计算出其在 t0 时刻的包围盒 box0 和 在 t1 时刻的包围盒 box1,然后再计算出一个能将 box0,box1 都包裹进去的包围盒作为最终的包围盒。

基于 AABB包围盒,p_min 中所有分量是所有点中最小的,p_max 中所有分量是所有点中最大的,我们可以得到计算包裹两个AABB包围盒的方法:

[aabb.h] 计算包裹两个包围盒的大包围盒

那么就可以得到 moving_sphere 的包围盒:

[moving_sphere.h] 创建包围盒

对于碰撞体列表 hittable_list,我们遍历其中物体,对于遍历的第一个物体,先对其创建包围盒,如果遍历中遇到不能创建包围盒的物体,那么我们就认为这个列表不能够创建包围盒,否则就对物体创建包围盒并不断的用更大的包围盒对其进行包裹,最后这个大包围盒就是列表的包围盒:

[hittable_list.h] 创建包围盒

接下来我们开始设计 BVH 树,与传统的二叉树表达方式相同,我们用节点的方式来设计BVH树,用树的根节点来表示整棵树。

每个节点有左、右两个节点,且每个节点都是一个带有 AABB包围盒的碰撞体:

[bvh.h] bvh树节点

注意,我们这里BVH节点的左右节点指向的是 hittable,而不是 bvh_node,因为我们 BVH树的根节点其包裹的通常不再是 bvh 节点,而是 sphere、hittable_list 这样具体的碰撞体。

其 hit 函数很简单,分别对左右两个节点做hit,如果有一个节点成功则返回true,并返回距离最近的碰撞信息:

[bvh.h] hit函数

这里返回最近的碰撞信息,是通过修改传入子节点的 t_max 来实现的。如果 hit_right 能在 rec_left.t 之前就与射线产生碰撞,那么就说明 rec_right 是与射线最近的碰撞信息,否则就是 rec_left 是最近的信息。

接下来就是对场景进行划分,创建我们的 BVH 树了。我们遵循下面的步骤进行划分:

随机选择一个轴

对物体在这个轴上进行排序

排序后的物体数组,一半划分给左节点,一半划分给右节点

子节点继续递归执行上面的步骤,直到最终指向一个物体

由于节点的包围盒必须包裹其两个子节点的包围盒,因此也可也通过递归得到

由于 bvh 的 hit 函数之后返回一个碰撞信息,即最终之后选择一个节点。那么如果一个节点,其右子节点为空,那么我们可以让该节点的右节点指向左节点,这样就可以构成一个完整二叉树,我们也就不需要去处理节点为空的情况。那么在创建叶子节点其父节点时,此时可能只剩下一个碰撞体无法再继续二分,因此其应该只有一个节点去指向最终这个物体,此时我们就可以让其两个节点都指向该物体,以创建完整二叉树。还有一种情况是刚好剩下两个物体,此时我们只需要对这两个物体进行比较然后放入对应的两个节点即可。

[bvh.h] 创建bvh树

这里用到的 random_int 函数在 constant.h 中对其进行实现:

[constant.h] 整型随机数

同样还有AABB包围盒在各个轴上的比较器:

[bvh.h] 包围盒在轴上的比较器

之后在 main.cpp 中我们创建 bvh 树来划分我们的场景,会发现渲染时间加快了许多

[main.cpp]

纹理

我们将物体表面颜色抽象出一个纹理类 texture,它可以是单一的颜色,也可以是程序生成的颜色,或者是从已有的贴图中采样。

[texture.h] 纹理类

通过纹理获取颜色我们就需要物体表面的纹理坐标,因此在我们的射线检测信息结构体中存储击中点的纹理坐标

[hittable.h] 存储击中点纹理坐标

对于球体,我们可以通过极坐标的方式定义其纹理坐标

[sphere.h] 球体纹理坐标

在球体的hit函数中,设置击中点的纹理坐标

[sphere.h] 设置纹理坐标

在球体的hit函数中,设置击中点的纹理坐标

[sphere.h] 设置纹理坐标

有了纹理类之后,我们就可以用纹理类来替换之前材质中用单一颜色表示的反照率albedo

[matrial.h] 更新albedo

我们可以根据正弦函数和余弦函数它们正负号交替的特点,来制作一个程序生成的棋盘格纹理:

[texture.h] 棋盘格纹理

我们可以把场景中的地面给换上棋盘格纹理

[main.cpp] 地面换上棋盘格纹理

得到图像:

之后我们会创建多种场景,因此我们可以在 main.cpp 中通过 switch case 的方式来在各个场景中进行切换。现在我们就再创建一个棋盘格纹理检查场景

[min.cpp] 棋盘格检查场景

得到图像:

还有一种在图形学中广为使用的程序生成纹理,或者说是程序生成噪声。那就是柏林噪声 Perlin Noise。这是一种晶胞噪声,本文不会给出柏林噪声具体的推导过程和原理解释,只是给出其一种代码实现,感兴趣的话大家可以自行去查找相关知识。

[perlin.h] 柏林噪声类

其中 noise函数返回基本的柏林噪声,turb返回一个由多个柏林噪声叠加形成的噪声,通常称为 turb(湍流)噪声,其可以被用来模拟大理石瓷砖上的纹路形状。

对 turb噪声再做一些处理,我们就可以得到更清晰的大理石纹路效果,我们将其作为一种噪声纹理

[texture.h] 噪声纹理

我们使用这个噪声纹理来渲染场景,会得到这样的效果:

接下来我们通过图像映射的方式直接采样图片来做纹理。既然要使用图像,我们首先就要把图像载入到程序中,这里我们使用stb_image库(https://github.com/nothings/stb)来加载图片。我们得到的图片数据是其像素数据,因此我们需要把纹理坐标映射为图片的像素坐标,我们只需要直到图片的宽高即 image_width 和 image_height,那么与我们的纹理坐标uv相乘即可得到对应的像素坐标,即 i = u * image_width,j = v * image_height,而 stb_image 加载图像返回的数据是一个一维数组,因此还需要将二维坐标转换为一维数组的下标,即 index = j * image_width + i

因此我们就可以实现 image_texture 类了:

[texture.h] image_texture类

这里包含的 m_stb_image头文件是按照 stbi_image 的规定对其做一些初始化定义:

[m_stb_image.h]

接下来我们使用下面这张图作为采样的贴图:

搭建场景

[main.cpp] 搭建场景

[main.cpp] 搭建场景

得到图像:

矩形

上篇文章我们已经实现了光源材质,接下来我们来创建矩形物体,并创建出矩形光源

我们先来创建一个在 xy 平面上的矩形,那么其法线就是在 z 轴方向上(其也是一种轴对齐矩形,Axis-Align Rect: aarect)。那么我们就可以用 x=x0, x=x1, y=y0, y=y1 这四xy平面上的线来确定矩形在xy平面上的范围,然后用 z=k 来确定该 xy平面在z轴上的位置,从而最终确定矩形的位置和范围,来表达具体的矩形。

由于该矩形中任何一点,其 z 分量一定为 k,因此我们可以通过

来得到射线与其所在的 xy平面相交的t值:

再将t代入回去,得到交点的x,y分量:

然后只需要判断 x,y 分量是否在 x0~x1 和 y0~y1 范围,即可确定交点是否真的在矩形内,即射线是否真的与矩形相交。

对于矩形的纹理坐标uv,实际就是其坐标分量在其平面范围内的比值,即 u = (x - x0) / (x1 - x0)

对于矩形的包围盒,我们选择通过给矩形增加一个微小的厚度,来创建其包围盒。

那么我们就可以来创建xy平面矩形类了:

[aarect.h] xy平面矩形

[aarect.h] xy平面矩形hit函数

接下来创建矩形光源测试场景

[main.cpp] 测试场景

[main.cpp] 测试场景

这里我们让渲染的背景颜色能够通过 background 值进行自定义:

[main.cpp] 设置background color

[main.cpp] 设置background color

[main.cpp] 设置background color

得到图像:

同样我们还可以像上篇文章一样,创建一个球体,然后给其一个光源材质,得到图像:

同理我们还可以得到 xz平面、yz平面的矩形:

[aarect.h] xz、yz平面矩形

[aarect.h] xz、yz平面矩形hit函数

接下来我们就可以创建一个空的 Cornell Box 场景了,康奈尔盒子是由康奈尔大学的计算机图形学组的Cindy M. Goral,Kenneth E. Torrance,Donald P. Greenberg和Bennett Battaile 在 1984 年发表的论文中创建出的一个渲染场景,而在康奈尔大学中他们也实际建造了与之一样的现实场景,用来验证渲染的正确性。

[main.cpp] cornell box场景

[main.cpp] cornell box场景

得到图像:

立方体

Corrnel Box 中还有两个旋转的立方体,对于轴对齐的盒子我们可以用与AABB包围盒同样的方式来表示,并且其还是由6个轴对齐矩形构成的,因此我们可以轻易的组建出来

[box.h] 立方体类

在我们的 Corrnel Box 中添加这两个盒子:

[main.cpp] 添加盒子

得到图像:

移动与旋转

在光线追踪渲染中,对物体进行变换实际上可以转化为对射线进行变换。比如下面这个图展示的对物体进行移动:

我们要把物体移动到粉色的这个位置,那么我们实际上可以不去移动物体。而是移动射线,将粉色的射线移动到黑色的位置,也就是对射线做反向位移,那么射线就会击中到该物体上的同一个位置,然后再对击中点做正向位移,此时我们算出的击中点坐标就是位移后的坐标。

而对于AABB包围盒,我们只需要直接移动包围盒的两个点p_min 和 p_max 即可直接移动整个包围盒

[hittable.h] 位移类

nslate 中的 ptr 就是我们要移动的物体,而其本身就表示移动后的问题,因此其继承自 hittable 类

对于旋转,同样的我们可以先反向旋转射线,射线击中后在对击中点做正向旋转,需要注意的是旋转变换除了改变了击中点的位置坐标,还改变了击中点的法线坐标,因此法线也需要做正向旋转。

这里我们实现一个绕轴的旋转,例如 对于绕 z 轴旋转,点的 z 分量不会变化,只有 x,y 分量会变化,其公式为:

同样

绕y轴旋转:

绕x轴旋转:

对于 Corrnel Box 场景,里面的两个盒子是绕 y 轴旋转一定角度,因此我们来实现绕y轴的旋转:

[hittable.h] 绕y轴旋转类

其构造函数实现:

[hittable.h] rotate_y 构造函数

hit函数实现:

[hittable.h] rotate_y hit函数实现

接下来我们把旋转和位移应用到 Corrnel Box 场景中的两个盒子上,构成我们最终的 Corrnel Box 场景:

[main.cpp] 应用旋转和位移

得到图像:

体积

还记得上篇文章中提到的大气散射吗?其原理为大气中散布着大量的粒子,光线在打中这些粒子时能量会被吸收掉一部分,然后剩余的能量发生散射。大气中的粒子浓度在高度上大致是呈指数分布的,即我们经常使用的 exp(-h/H) 这个近似因子。同时其还受到压强、温度、湿度等等因素的影响,在浓度低时,我们看到的天空就比较晴朗,能见度会比较高,在浓度高时就会形成雾或者霾,此时能见度就比较低了。除了整个大气层有这样的特点,像 天上的云、烟囱冒出的烟、燃烧或者爆炸产生的烟雾、地面扬尘、对着面粉吹口气等等都会形成类似的物体,对于这类物体在渲染中我们通常将其抽象为 Volume 体积。

接下来我们会对体积进行更加基于光追原理的实现,其不再只是对光能量进行吸收,还会对光线产生散射影响,因此我们先把上一篇中的大气散射实现给删掉。

[constant.h] 删除旧的大气散射

[material.h] 删除旧大气散射

如上图表达了射线在体积内的散射情况。我们可以给体积表面看作一个碰撞体,那么射线就会在其表面有一个交点,接下来如果我们要知道射线发生散射的点,那么就需要知道射线在与表面相交后又经过了多少的距离产生了散射,在光学上我们有公式:

其中 p 与光学密度(optical density)成正比,光学密度表示 光进入介质前的光强(l0)和透过介质后的光强(lt)比的对数即 od = log(l0/l1)

而 C 就是我们所说的散射概率,那么 p/C 即为我们的散射距离,即

l0/l1 是一个大于 1 的值,我们可以对其取倒数,将其限定在一个 0~1 的范围,于是得到:

根据体积中只有一种粒子,那么其光学密度应该恒定,而如果有多种粒子那么光学密度就会有变换

这样我们就可以得到射线实际与体积的相交点了,也就是我们的散射点。

对于体积,其散射方向通常与法线无关,因此我们并不关心散射点的法线方向。

体积的材质描述了光学散射的方向和能量的变化,在光学上其被称为 相位函数 phase_function

因此我们可以创建一个拥有固定散射概率 和 随机光学密度的体积:

[constant_medium.h] 体积类

其 hit 函数为:

[constant_medium.h] hit函数

我们在hit函数中做了许多判断,原因在于对于体积 我们的射线是可以在里面进行穿梭移动的。不像其它的硬表面物体光学在击中表面后就立马发生散射离开表面,并且光学无法穿入其内部。因此我们要在光学在体积内部的情况做出一些额外判断。

接下来我们实现一个各向同性的散射函数,即光线会均匀随机的选择一个方向作为散射方向

[material.h] 各向同性散射函数

最终场景

最后我们创建一个场景,来把本篇中的所有内容都给展现出来。

我们用一堆有随机高度的盒子作为地面

放置一个矩形光源

放置一个移动球体

放置一个玻璃球和金属球

再放置一个玻璃球并以其为表面创建一个蓝色体积雾

再创建一个表面为半径很大的球体的体积雾,作为我们的"大气层"

放置一个采样地球贴图纹理的球

放置一个纹理为柏林噪声的球

在创建一堆球并将其旋转和移动到一个立方体区域内

[main.cpp] 最终场景

得到图像:

Copyright ©  2015-2022 纵横公司网版权所有  备案号:浙ICP备2022016517号-12   联系邮箱:51 46 76 11 3 @qq.com