6. 光线追踪

本系列的主要目的是复盘并总结计算机图形学这个学科常用算法与原理,会从基本的数学理论讲起,同时会附带部分算法的代码实现,由浅入深,只需初中的数学基础就行。主要参考了GAMES101-现代计算机图形学入门这门课(具体引用见文章末尾),同时附加上自己的理解。有兴趣的也可直接看视频。

光线追踪

Shadow Mapping

在正式进入光线追踪之前,我们先了解一下Shadow Mapping。

这是一种生成阴影的方法,曾在游戏和电影中广泛使用。

image-20220922080443920

它的本质是通过光栅化的思想,对比点与光源、点与相机的距离,从而判断是否有阴影。具体过程如下:

image-20220922081757395 image-20220922082057532 image-20220922082137025

来看一个案例

image-20220922082334210

上图的场景没有任何阴影,给人一种悬浮在空中的不真实感。针对上图的场景,从光源处拍摄一张深度图

image-20220922082324914

然后在人眼处计算物体与光源之间的距离,再进行深度比对,可以看到绿色的地方就是光线能够照到的部分,非绿色的地方应该存在阴影

image-20220922082525202

这里球面上的绿色看起来不均匀,是因为在计算机中浮点数之间很难判断相等,容易有误差,所以才会造成这种看起来有点脏的现象。

这种生成阴影的方法很经典,但是也存在一些问题

  • 只能做硬阴影(边缘很锐利)
  • 存在数值精度问题
  • Shadow map的分辨率低会导致走样

先来说说第一个问题,硬阴影看起来十分锐利,软阴影则柔和很多。而硬阴影其实是本影,软阴影是半影,我们以日食的过程来阐述这都是什么东西,见下图

image-20220922083125995

数值精度就是计算机内浮点数运算的问题;而Shadow map的分辨率本质上也是深度图的分辨率,也对应里我们在游戏里设置的阴影质量(低、中、高、极高),分辨率越高,阴影质量越好,但消耗的性能也越多;反之亦然。

Whitted-Style光追

其实目前在游戏开发中,有不少还是继续采用的Shadow map,因为它对性能的要求相对较低,而且速度很快。但是之所以还是需要光追,是因为Shadow map在软阴影和间接光照等方面处理的仍然不够好,在某些场景下,仍有一些不真实感。可以看看下面的对比图

image-20220923083052560

可以看出Ray Tracing有明显的优势,但之所以没有全面采用,是因为它慢。虽然效果很好,但是一帧画面可能要花几十分钟到一个小时。当然,目前工业界也已经有了很大优化,并且硬件性能也逐步提升,还是很有必要搞清楚它的理论知识的。

这里我们在讲光追之前,需要先明确几个前提:

  1. 光是沿直线传播的(不考虑光的波动)
  2. 光与光之间不会发生碰撞(虽然这在物理上也不正确)
  3. 光路是可逆的(光能从光源到达人眼,自然也能从人眼沿原路径到达光源)

在Whitted-Style光追中,第三条前提尤为重要,因为它都是以人眼为起点放出射线,然后再校验射线碰到的物体与光源之间的可见性。具体步骤如下:

1、从眼睛出发,沿着每个像素发出一根光线。找到光线与物体相交最近的点

image-20220923084317966

2、把这个最近的点与光源连接,判断时候能形成一条光路。如果可以,那就计算着色,并将着色效果写到像素上

image-20220923084621493

3、考虑反射与折射现象,找到每次反射与折射过程中的弹射点。判断每个弹射点与光源之间是否能形成光路,如果可以,那就都加到像素上

image-20220923085254398

好了,这就是Whitted-Style光追的核心算法原理了。

害,早着呢,这核心原理说的含糊,实际落地还有很多问题没有解决。比如光线与物体怎么判断相交、怎么找交点、怎么加速这个模型、怎么计算能量损失等等。不慌,我们一个个看。

光线的定义

在此之前,我们首先要定义一根光线,用数学的形式将之表达出来。定义方法如下:

image-20220923090302480

光线与圆的求交

我们知道,圆上每一个点到圆心的距离等于半径。所以对圆上的任意一点 ,与圆心 ,半径 之间存在这么个关系 而光线上任意一点 ,也能用如下式子表示 所以如果光线与这个圆相交了,也就是说,在某个时刻,有 ,满足下面的关系式(就是把换成了 image-20220923091047982

好了,这里面只有一个未知量 ,我们可以用公式来解出这个一元二次方程,结果如下 我们得到最终 的公式之后,只需要判断 是否有意义就可以了,不能是负数或者虚数,只要 就代表有交点

光线与隐式表面求交

了解了光与圆的求交方法之后,就可以很容易知道光与隐式表现的求交了,道理都是一样的

光线的表达式 隐式表面的表达式 将光线代入隐式表面即可 只要算出的 是有意义的就说明有解

光线与三角形求交

我们都知道,模型是由很多个三角形组成的

image-20220924065230389

那么想知道光线与物体相交的话,就直接判断光线是否与这个物体模型里的三角形求交就可以了。而判断光线是否与三角形求交也很简单,核心思想就两个

  • 找到光线和平面的交点
  • 看这个交点是否在三角形内

我们先来定义一个平面:平面就是一个法线 和一个点 ,平面上的任意一点 的连线都与 垂直,数学表达如下 看起来有点复杂,但是还是蛮好理解的,法线用来定义方向,点 用来确定平面高度,示意图如下

image-20220924065717229

现在我们知道平面公式了,也知道光线表达式了,接下来要做的就是把光线代入平面,就可以得到交点 解出 确认,即可

找到这个点后,我们用向量里的叉乘就可以很容易判断出这个点是否在三角形内了

这个方法很直观,但是人都是懒的,有没有办法直接判定光线是否与三角形有交点呢?答案是有的

还记得前面提到的重心坐标吗,我们直接把光线代入三角形的坐标公式就可以了

image-20220924070744727

我们不详解线性方程组的求解和克莱姆法则,这是线性代数中的知识,说多了容易迷糊,对推导有兴趣同学去查一下相关资料就可以了

加速

到目前为止,我们已经知道怎么找光线与物体的交点了。

但是,一个场景里那么多模型,一个模型有那么多三角形,我们要找每个像素发出的光线与这么多三角形的交点,并且有可能的话还要算光线的多次弹射。那这不得慢死。 线image-20220924071212225

image-20220924071242473

有没有办法可以加速一下呢?

很明显是有的

包围盒

我们知道,一个模型由很多个三角形组成,如果光线击中了模型中的三角形,那么就说明击中了这个物体。

那么我们为什么不直接算光线是否击中模型呢?因为这不好算,模型的组成太复杂了。

那有没有办法让模型变简单点,然后再算光线是否击中这个简单模型呢?

有的,这就是包围盒。很容易理解,看下图

image-20220924071850632

就是用一个盒子或者一个球,来包裹住我们的复杂模型。如果光线连包围盒都没击中,那更不可能击中里面的物体了

好了,现在我们的问题就是要算光线是否击中包围盒

光线与圆的交点我们已经说了,与球的交点自然也是类似,就不再展开。这里主要说一下怎么与盒子求交。

目前用到一个比较经典的办法是Axis-Aligned Bounding Box (AABB)(轴对齐包围盒)

这个方法里,盒子的几个平面都是与坐标轴平行的,我们需要把盒子,看成是三个对面的交集

image-20220924072427279

在看三维之前,我们先看看二维矩形和光线的交点

image-20220924073010161

再回到三维,要判断光线与盒子的交点,需要了解这么两个核心思想

  • 只有光线进入了所有的对面,就算进入了盒子(这里的平面无限大,且不考虑光线与之平行的情况)
  • 光线离开了任意一对对面,就离开了盒子

对每一对对面,我们都需要计算光线进入时间 和光线离开的时间

对于3D的盒子,光线进入的时间,光线离开的时间

如果最后的 ,就说明光线在这个盒子里待了一段时间,就必然与这个盒子有交点

好了,到这里基本就算完了,但是还需要补充几个点

  • 光线是个射线,不是个直线,是有起点的,起点就是光源
  • 如果,就说明光源在盒子的背后,必然没有交点
  • 如果并且 ,就说明光源在盒子里
  • 只有Misplaced &t_{enter}<t_{exit} &&t_{exit}\ge0的时候,光线才与盒子有交点

最后补充一句,我们之所以要用3个对面来算,而不是直接算光线和平面的交点,是因为这有点复杂。如果用轴平面的话,只用看对应方向的分量就行了,这多简单,本质还是为了加速

image-20220924074845954

到这里还没结束,还能更快。

上面是把物体当做包围盒来计算光线是否与之相交,还有另一种方法也用于加速,叫空间划分

说白了就是把空间划分了一个个格子,判断光线经过的格子里面是否有物体,没有物体就不管,有的话就再判断光线是否与物体相交

image-20220926065958311

这个格子的密度也有讲究,不能太稀也不能太密,目前普遍使用的格子数大概是空间中的物体数*27

image-20220926070154857

但是这个办法更适用于物体密集的场景,而非较为空旷的场景

image-20220926070257663 image-20220926070321529

确实,这个方法有一定的局限性,不过网格划分只是空间划分中的一种,我们也可以采用别的划分算法,比如以下三种

image-20220926070532208
  • 八叉树:顾名思义,沿着水平或竖直的方向,将空间划分成八份
  • KD树:在合适的地方,沿着水平或竖直的方向砍一刀
  • BSP树:找个方向砍一刀

这里我们主要介绍KD树,看起来十分容易理解,这也是相对较为常用的模型

image-20220926070805965

如图,KD树是将空间不断划分,变成了一颗树,它只沿X轴、Y轴或Z轴划分,并且所有的物体都只存放在叶子节点上(就是图中带颜色的节点)

划分好之后,光线会以此经过这些节点,我们只需要顺着这棵树的结构,遍历下去就可以找到与之相交的物体了,过程见下图

image-20220926071828689

这个看起来很美好,那么按照惯例,我们又要开始挑毛病了

首先,这个实现起来很难,因为一个物体可能存在于多个不同的格子里;并且还需要考虑三角形与盒子的求交

那么解决方案是什么呢?既然我们不好算物体具体归于哪个盒子,那就干脆通过物体划分好了

这种方法叫Bounding Volume Hierarchy(BVH) 层次包围盒

就是下图这种划分方法

image-20220926072305740

我们不再执着于盒子与盒子是否相交,而且只关注物体,划分物体的方法也很多,比如这两种

  • 总是选择最长的轴来划分
  • 取中间的物体进行划分

大致计算逻辑如下

  1. 如果光线没击中盒子,就不管
  2. 如果光线击中的是叶子节点(带颜色的节点),就给盒子内所有物体求交,返回最近的那个
  3. 如果击中的不是叶子节点,就对左右两个叶子节点都递归一次这个算法

辐射度量

上面讲完之后,我们就已经大致能模拟光照了。但是总感觉不太对,似乎过于随意了些,咱们对光照连个单位都没有,也没有谈到任何物理模型,最终的渲染效果怎么可能会准呢?

所以,再进一步研究之前,我们需要稍微了解一下辐射度量学(不怕,这个不难),用它来准确的定义和模拟光线的传播过程

这一章我们不用了解的很深,只需要记住并了解几个物理量,知道它们是干啥的就可以了(接下来会比较绕,注意理解概念,不必深究公式)。

在热学里,用于表示能量的是 ,它的单位是焦耳 ,焦耳值越大,能量就越大。

单位时间内的能量就叫做功率,单位是瓦特。这个应该就比较熟了,家用电器的瓦数就代表了它在一定时间内消耗的能量值。而在光照这方面,虽然灯泡也是用功率表示,但灯的亮度用的是另一个物理量,叫流明(lumen)

image-20220926080034132

流明,你也可以理解为,一定时间内,通过某一平面的光线数

image-20220926080131332

然后我们还需要定义这么几个物理量,先看一眼,留个印象

image-20220927083227586

Radiant Intensity

翻译为辐射强度,它的解释是:单位立体角上的能量

image-20220927065912060

在球里,我们可以通过 来定义空间中的方向。比如 轴的偏离方向,是绕 轴旋转的角度

image-20220927070500452

圆的弧度,与之对比,球的弧度

类比公式,我们可以知道单位立体角

对于球面上的一个单位平面 ,我们可以把他当做一个小小的矩形,计算公式如下 于是代入单位立体角里,就可以得到如下公式 好了,立体角我们知道了,那就来说说能量

这里不用什么数学的解释,你只需要理解为什么用 来表示就好

就是功率,也就是在某一时刻放出的能量,就是做了一个微分,也就是在某一个小方向上的能量

辐射强度就是这某一个小方向上的能量除以这个小方向的立体角,得出以下公式 image-20220927072136988

我们还可以做一个简单的验证(觉得不简单的可以直接跳过)

image-20220927083124910

Irradiance

辐照度,翻译为单位面积上的能量

辐射强度是指单位立体角上的能量,这里是单位面积上的能量,注意区分

二者看起来很相似,因此公式也差不多

image-20220927073425670

把公式翻译一下,就是单位能量除以单位面积,需要注意的是,这里的单位能量是这个面积上接收到的能量,而不是几束光本身的能量之和。因为光照的方向和平面的角度,会影响到平面能接收的能量

image-20220927073752854

这里可以简单的做个小总结,intensity(辐射强度) 和 Irradiance(辐照度) 在在光线传播过程中,有如下区别

image-20220927074150513

Irradiance 会随着光线的不断传播变得越来越小,而intensity不变

这是因为传播过程中,随着半径的不断变化,球的面积也在不断变化,唯一不变的只有角度

Radiance

Radiance 跟 Irradiance 有点像,Radiance 是描述光线在环境中的传播的一个物理量,这个叫辐亮度

image-20220927075041254

你可以把它理解为是 Irradiance 和 intensity 的交集,Radiance 是指单位立体角上单位投影面积的能量

也就是说,它与单位面积和单位立体角都有关,有这么两种理解方式

  • 光从某一个方向打出去,到达一个单位面积的能量
  • 光从某个单位面积打出去,沿某个方向上的能量

所以它既可以表示光的入射,也可以表示光的发出,并且这两个的公式表达还不太一样,所以它具有方向性

从一个方向入射

image-20220927080445105

这是每单位立体角的 Irradiance

从一个面发出

image-20220927080511775

这是每单位投影面积的 intensity

Irradiance vs. Radiance

  • Radiance具有方向性,Irradiance 不具有方向性
  • Radiance在所有方向上的求和(积分),就是 Irradiance
image-20220927080806842

到这里为止,我们已经了解了光线和能量的传播,那么当光线碰到物体的时候,会发生什么奇妙的作用呢?

(上面几个物理量有点绕,但是非常重要,要好好理解清楚再继续看下面哈)

BRDF

BRDF,全称叫Bidirectional Reflectance Distribution Function,即双向分布函数

(这句鬼话我也不想看,认识一下就行,接下来说人话)

这个函数,简单来说就是描述反射到底是什么,描述光线和物体是如何作用的,它定义了物体的材质

image-20220927082051431

光从 方向射入,到单位面积 ,被吸收后得到了 ,BRDF 要做的就是算出它吸收了能量之后,将会沿着 方向反射出多少能量 。反之亦然,所以是“双向”

可以看出来,这里最核心的就是一个比例光照入射和出射的比例,这里用 来表示

image-20220927082556817

不同的比例,就代表了不同的材质

反射方程

到目前为止,我们已经能够写出一个完成的式子,来描述光线的反射过程了

image-20220927085536530

将所有入射方向的光线对观测点的反射方向的贡献相加,就能得到最终的结果

渲染方程

渲染方程与反射方程已经很接近了,只不过是在反射方程上加上了自己发的光

image-20220927090111998

目前的这个渲染方程,只适用于一个点光源照射一个物体的情况。如果有多个点光源,只需要加几个反射过程就可以了。如果是面光源的话,可以看出是许多点光源的集合,物体的反射光也依旧可以当做点光源来计算

路径追踪

上面的Whitted-style光线追踪的效果其实还不错了,但是在某些方面表现的还是不够好,比如说这个茶壶

image-20221012192314705

根据Whitted-style光追的原理,你可以想的到,他在镜面反射上做得很好,但是似乎没有办法做到一些模糊的效果。就比如说右边这个磨砂茶壶,就没办法很好的表示出来,因为光打到这种材质上的时候,并不一定会沿着特定的方向反射。

那么此时,路径追踪就该出场了。

蒙特卡洛积分

我们还是基于上面的渲染方程,因为它是基于物理推导出来的,理论上是完全正确的。

那么问题来了,怎么求解呢?

对于这种复杂的积分式,我们很难用数学推导算出来,但可以用蒙特卡洛模拟出来

在使用蒙特卡洛积分之前,我们先来了解一下概率密度函数

概率密度函数 描述了点 被采样到的概率

image-20221011085303523

从图中可以看出,用这个概率密度函数来采样的话,那 大概率会取在-1到1之间

还有个性质,这个函数曲线围成的面积为1,也就代表了所有点的概率之和为1,用公式表达如下

image-20221011085528921

对于随机采样,也就是取任意一个 的概率是一样的,它的概率密度函数就应该是一条直线

image-20221011090024618

如果我们要求解一个复杂函数的积分,比如求解下面这段蓝色区域的面积

image-20221011090104506

那么可以用蒙特卡洛的思想,即不断在a和b之间选取 ,最后把所有的 做平均。就可以把这个复杂的图形,转换成一个矩形来求解

image-20221011090425003

针对这个例子,有如下结果

image-20221011090756905

就是这个矩形的宽度,剩下的不断取 然后累加求平均,就是矩形的高度,乘起来就是面积

好了,到此为止,我们已经知道了随机采样的蒙特卡洛积分的解法,那如果采样的不随机呢?

对于非随机的采样,每个点 被采样的概率都不一样,如果采样到 的概率比 的概率大,那么最后的结果自然会更偏向 所在的高度。所以为了保证最后的结果正确,我们需要除上 对应的权重,概率越大,权重自然也就越大,两者相除,不就平衡了么。

权重=概率×范围,这个概率就是指概率密度函数

对于随机采样,每个点的权重就是 ,即每个点的权重都一样

对于非随机采样,就是 ,所以最后的积分表达式如下

N越大,代表采样的次数越多,最后的结果也会越准

渲染方程的求解

我们将这个方法应用到渲染方程上,先忽略物体自身发出的光。渲染方程的本质,就是在计算四面八方的光,经过BRDF后,反射到 方向上的光强

image-20221012193738501

这里应用了光源的可逆性,从着色点出发从里往外发出射线,模拟光源从外往里照射,本质上是一样的,只是方便计算。

这一根又一根的射线,不就相当于在这个半球上不断的采样么?

好了,蒙特卡洛上场,我们直接代入进去就可以了

此时的 变成了下面的式子 因为我们是在半球上进行平均采样,所以此时的概率密度函数就是 (注:就是这个半球的立体角)

代入进去,就变成了这样

image-20221012195426939

到这一步,我们就算是解完了

但再仔细考虑一下,如果采样打到了光源,那还好说,可以直接算出光强;但如果打到的是另一个物体咋办呢?那不就没光了么

其实这就是光的反射了,采样的光线打到了另一个物体,就意味着是另一个物体反射出来的光,照在了这个着色点上。我们可以模仿这个采样的过程,如果打到了物体上,就反射一次再继续走

image-20221012200554559

说详细点,比如着色点p,以 的角度进行一次采样,打到了Q点,那就在Q点,以 的方向继续采样。这其实也代表了一次反射,如果这样弹射两次,就是二次反射了

但,我们不能无限弹射下去啊,可是没有打到光源就停止弹射的话,不就又损失了能量么?

欸,我们可以用俄罗斯轮盘赌的玩法来解决这个问题

image-20221012200950532

啥是俄罗斯轮盘赌?就是一把手枪能装6发子弹,玩家往里面装一发,互相对自己开枪,谁能活下去谁就赢了

这本质也是一个概率问题,如果枪里有两发子弹,那开枪的时候,你活下去的机会就是

我们同样可以给光线的弹射来手动设置一个概率,如果光线满足了这个概率,他就继续弹射,然后将结果除以这个概率。如果不满足,那就直接终止。

这样做的聪明之处在哪里呢?它能使最后的期望趋近于真实

image-20221012201746526

不错,我们似乎解决了所有问题

但是还有一个,凭啥每次反射都要以 的方向继续采样啊,说好的磨砂,说好的漫反射呢?

确实,我们不应该只限定一个方向,每次反射我们需要往附近的所有方向做一次反射

image-20221012202126784

理论很美好,现实很骨感,你做这么多次反射,机器跑不动啊。一条光线经过一次反射就变成了100条,再经过二次反射就变成了10000条,这个计算量我们肯定接受不了

那100条接受不了,反射出去几条才能接受呢?

一条,因为一条光线,无论反射多少次,都是一条

但是只反射一条,那不又绕回去了么?

虽然反射的光线只有一条,但是别忘了,我们的单位是像素,穿过像素的路径可是有很多的

image-20221012202525304

我们每一条路径都只沿着一个方向不断反射,然后放出很多条路径,最后的结果不也是一样的么?

正是因为这个原因,所以这个算法才叫路径追踪嘛

很好,目前为止,我们似乎真的解决了所有问题

兴致冲冲的跑出来一看,不太对劲

image-20221012202949010

似乎在路径数量比较少的情况下,图像的燥点就十分明显

这是因为大部分的路径最终都没有打到光源,如果光源比较大的话就还好,如果光源很小,就很难命中,大量的光线都将浪费掉

image-20221012203248351

那有没有什么办法能既减少浪费光线的数量,又能保证基本的渲染质量呢?

有的,既然我们希望光线都能命中光源,那何不直接对光源进行采样呢?这样就能保证每一根光线都能打到光源,属于是在光源上采样,在光源上积分

image-20221012204717752

此时的采样范围就成了光源,概率密度函数就变成了 (代表光源的面积)

相应的,随机方向 也需要用光源上的 来表示。在学习立体角的时候,说立体角 ω 的值就是球面上对应的一个面积除以半径平方。半径很好求,即为 ,那么怎么对应到以P为圆心半径为的球面上呢?因为球面上的面积其法线肯定指向P,但是我们光源不一定,因此我们只需要做一个余弦变化即可。设的法线为 n’ 那么夹角的余弦值即为 。因此可得到: 这个公式就把 联系起来了,我们的渲染方程就可以通过写成如下形式

image-20221013070830130

这样我们就又可以用蒙特卡洛方法来解这个积分,此时着色点的光分为两部分,一是直接光源的贡献,二是其他的贡献。对于直接光的部分我们就可以直接使用采样光源的方法来处理,这样不会导致路径的浪费。而间接光的部分依旧使用原来的逻辑,即从P点已某个概率随机往任意方向发射一条光线,若打到非光源的物体,则进入递归。如下图,P随机打到O点,然后已O点进入递归,对于O的直接光部分依旧使用采样光源的方法。

image-20221013071006223

当然采样光源的方法还会有个问题,若Q和P直接有障碍物(如下图),那么P点肯定就无法受到来着Q点的直接光照,因此我们还需要从P点再射一条光线来判断中间是否有障碍物。

image-20221013071208749

好,恭喜你,到此为止,路径追踪的核心原理就算是学明白了