6. 光线追踪
6. 光线追踪
鲸拓工作室本系列的主要目的是复盘并总结计算机图形学这个学科常用算法与原理,会从基本的数学理论讲起,同时会附带部分算法的代码实现,由浅入深,只需初中的数学基础就行。主要参考了GAMES101-现代计算机图形学入门这门课(具体引用见文章末尾),同时附加上自己的理解。有兴趣的也可直接看视频。
光线追踪
Shadow Mapping
在正式进入光线追踪之前,我们先了解一下Shadow Mapping。
这是一种生成阴影的方法,曾在游戏和电影中广泛使用。
它的本质是通过光栅化的思想,对比点与光源、点与相机的距离,从而判断是否有阴影。具体过程如下:
来看一个案例
上图的场景没有任何阴影,给人一种悬浮在空中的不真实感。针对上图的场景,从光源处拍摄一张深度图
然后在人眼处计算物体与光源之间的距离,再进行深度比对,可以看到绿色的地方就是光线能够照到的部分,非绿色的地方应该存在阴影
这里球面上的绿色看起来不均匀,是因为在计算机中浮点数之间很难判断相等,容易有误差,所以才会造成这种看起来有点脏的现象。
这种生成阴影的方法很经典,但是也存在一些问题
- 只能做硬阴影(边缘很锐利)
- 存在数值精度问题
- Shadow map的分辨率低会导致走样
先来说说第一个问题,硬阴影看起来十分锐利,软阴影则柔和很多。而硬阴影其实是本影,软阴影是半影,我们以日食的过程来阐述这都是什么东西,见下图
数值精度就是计算机内浮点数运算的问题;而Shadow map的分辨率本质上也是深度图的分辨率,也对应里我们在游戏里设置的阴影质量(低、中、高、极高),分辨率越高,阴影质量越好,但消耗的性能也越多;反之亦然。
Whitted-Style光追
其实目前在游戏开发中,有不少还是继续采用的Shadow map,因为它对性能的要求相对较低,而且速度很快。但是之所以还是需要光追,是因为Shadow map在软阴影和间接光照等方面处理的仍然不够好,在某些场景下,仍有一些不真实感。可以看看下面的对比图
可以看出Ray Tracing有明显的优势,但之所以没有全面采用,是因为它慢。虽然效果很好,但是一帧画面可能要花几十分钟到一个小时。当然,目前工业界也已经有了很大优化,并且硬件性能也逐步提升,还是很有必要搞清楚它的理论知识的。
这里我们在讲光追之前,需要先明确几个前提:
- 光是沿直线传播的(不考虑光的波动)
- 光与光之间不会发生碰撞(虽然这在物理上也不正确)
- 光路是可逆的(光能从光源到达人眼,自然也能从人眼沿原路径到达光源)
在Whitted-Style光追中,第三条前提尤为重要,因为它都是以人眼为起点放出射线,然后再校验射线碰到的物体与光源之间的可见性。具体步骤如下:
1、从眼睛出发,沿着每个像素发出一根光线。找到光线与物体相交最近的点
2、把这个最近的点与光源连接,判断时候能形成一条光路。如果可以,那就计算着色,并将着色效果写到像素上
3、考虑反射与折射现象,找到每次反射与折射过程中的弹射点。判断每个弹射点与光源之间是否能形成光路,如果可以,那就都加到像素上
好了,这就是Whitted-Style光追的核心算法原理了。
害,早着呢,这核心原理说的含糊,实际落地还有很多问题没有解决。比如光线与物体怎么判断相交、怎么找交点、怎么加速这个模型、怎么计算能量损失等等。不慌,我们一个个看。
光线的定义
在此之前,我们首先要定义一根光线,用数学的形式将之表达出来。定义方法如下:
光线与圆的求交
我们知道,圆上每一个点到圆心的距离等于半径。所以对圆上的任意一点
好了,这里面只有一个未知量
光线与隐式表面求交
了解了光与圆的求交方法之后,就可以很容易知道光与隐式表现的求交了,道理都是一样的
光线的表达式
光线与三角形求交
我们都知道,模型是由很多个三角形组成的
那么想知道光线与物体相交的话,就直接判断光线是否与这个物体模型里的三角形求交就可以了。而判断光线是否与三角形求交也很简单,核心思想就两个
- 找到光线和平面的交点
- 看这个交点是否在三角形内
我们先来定义一个平面:平面就是一个法线
现在我们知道平面公式了,也知道光线表达式了,接下来要做的就是把光线代入平面,就可以得到交点
找到这个点后,我们用向量里的叉乘就可以很容易判断出这个点是否在三角形内了
这个方法很直观,但是人都是懒的,有没有办法直接判定光线是否与三角形有交点呢?答案是有的
还记得前面提到的重心坐标吗,我们直接把光线代入三角形的坐标公式就可以了
我们不详解线性方程组的求解和克莱姆法则,这是线性代数中的知识,说多了容易迷糊,对推导有兴趣同学去查一下相关资料就可以了
加速
到目前为止,我们已经知道怎么找光线与物体的交点了。
但是,一个场景里那么多模型,一个模型有那么多三角形,我们要找每个像素发出的光线与这么多三角形的交点,并且有可能的话还要算光线的多次弹射。那这不得慢死。
有没有办法可以加速一下呢?
很明显是有的
包围盒
我们知道,一个模型由很多个三角形组成,如果光线击中了模型中的三角形,那么就说明击中了这个物体。
那么我们为什么不直接算光线是否击中模型呢?因为这不好算,模型的组成太复杂了。
那有没有办法让模型变简单点,然后再算光线是否击中这个简单模型呢?
有的,这就是包围盒。很容易理解,看下图
就是用一个盒子或者一个球,来包裹住我们的复杂模型。如果光线连包围盒都没击中,那更不可能击中里面的物体了
好了,现在我们的问题就是要算光线是否击中包围盒
光线与圆的交点我们已经说了,与球的交点自然也是类似,就不再展开。这里主要说一下怎么与盒子求交。
目前用到一个比较经典的办法是Axis-Aligned Bounding Box (AABB)(轴对齐包围盒)
这个方法里,盒子的几个平面都是与坐标轴平行的,我们需要把盒子,看成是三个对面的交集
在看三维之前,我们先看看二维矩形和光线的交点
再回到三维,要判断光线与盒子的交点,需要了解这么两个核心思想
- 只有光线进入了所有的对面,就算进入了盒子(这里的平面无限大,且不考虑光线与之平行的情况)
- 光线离开了任意一对对面,就离开了盒子
对每一对对面,我们都需要计算光线进入时间
对于3D的盒子,光线进入的时间
如果最后的
好了,到这里基本就算完了,但是还需要补充几个点
- 光线是个射线,不是个直线,是有起点的,起点就是光源
- 如果
,就说明光源在盒子的背后,必然没有交点 - 如果
并且 ,就说明光源在盒子里 - 只有
的时候,光线才与盒子有交点
最后补充一句,我们之所以要用3个对面来算,而不是直接算光线和平面的交点,是因为这有点复杂。如果用轴平面的话,只用看对应方向的分量就行了,这多简单,本质还是为了加速
到这里还没结束,还能更快。
上面是把物体当做包围盒来计算光线是否与之相交,还有另一种方法也用于加速,叫空间划分
说白了就是把空间划分了一个个格子,判断光线经过的格子里面是否有物体,没有物体就不管,有的话就再判断光线是否与物体相交
这个格子的密度也有讲究,不能太稀也不能太密,目前普遍使用的格子数大概是空间中的物体数*27
但是这个办法更适用于物体密集的场景,而非较为空旷的场景
确实,这个方法有一定的局限性,不过网格划分只是空间划分中的一种,我们也可以采用别的划分算法,比如以下三种
- 八叉树:顾名思义,沿着水平或竖直的方向,将空间划分成八份
- KD树:在合适的地方,沿着水平或竖直的方向砍一刀
- BSP树:找个方向砍一刀
这里我们主要介绍KD树,看起来十分容易理解,这也是相对较为常用的模型
如图,KD树是将空间不断划分,变成了一颗树,它只沿X轴、Y轴或Z轴划分,并且所有的物体都只存放在叶子节点上(就是图中带颜色的节点)
划分好之后,光线会以此经过这些节点,我们只需要顺着这棵树的结构,遍历下去就可以找到与之相交的物体了,过程见下图
这个看起来很美好,那么按照惯例,我们又要开始挑毛病了
首先,这个实现起来很难,因为一个物体可能存在于多个不同的格子里;并且还需要考虑三角形与盒子的求交
那么解决方案是什么呢?既然我们不好算物体具体归于哪个盒子,那就干脆通过物体划分好了
这种方法叫Bounding Volume Hierarchy(BVH) 层次包围盒
就是下图这种划分方法
我们不再执着于盒子与盒子是否相交,而且只关注物体,划分物体的方法也很多,比如这两种
- 总是选择最长的轴来划分
- 取中间的物体进行划分
大致计算逻辑如下
- 如果光线没击中盒子,就不管
- 如果光线击中的是叶子节点(带颜色的节点),就给盒子内所有物体求交,返回最近的那个
- 如果击中的不是叶子节点,就对左右两个叶子节点都递归一次这个算法
辐射度量
上面讲完之后,我们就已经大致能模拟光照了。但是总感觉不太对,似乎过于随意了些,咱们对光照连个单位都没有,也没有谈到任何物理模型,最终的渲染效果怎么可能会准呢?
所以,再进一步研究之前,我们需要稍微了解一下辐射度量学(不怕,这个不难),用它来准确的定义和模拟光线的传播过程
这一章我们不用了解的很深,只需要记住并了解几个物理量,知道它们是干啥的就可以了(接下来会比较绕,注意理解概念,不必深究公式)。
在热学里,用于表示能量的是
单位时间内的能量就叫做功率,单位是瓦特。这个应该就比较熟了,家用电器的瓦数就代表了它在一定时间内消耗的能量值。而在光照这方面,虽然灯泡也是用功率表示,但灯的亮度用的是另一个物理量,叫流明(lumen)
流明,你也可以理解为,一定时间内,通过某一平面的光线数
然后我们还需要定义这么几个物理量,先看一眼,留个印象
Radiant Intensity
翻译为辐射强度,它的解释是:单位立体角上的能量
在球里,我们可以通过
圆的弧度
类比公式,我们可以知道单位立体角
对于球面上的一个单位平面
这里不用什么数学的解释,你只需要理解为什么用
辐射强度就是这某一个小方向上的能量除以这个小方向的立体角,得出以下公式
我们还可以做一个简单的验证(觉得不简单的可以直接跳过)
Irradiance
叫辐照度,翻译为单位面积上的能量
辐射强度是指单位立体角上的能量,这里是单位面积上的能量,注意区分
二者看起来很相似,因此公式也差不多
把公式翻译一下,就是单位能量除以单位面积,需要注意的是,这里的单位能量是这个面积上接收到的能量,而不是几束光本身的能量之和。因为光照的方向和平面的角度,会影响到平面能接收的能量
这里可以简单的做个小总结,intensity(辐射强度) 和 Irradiance(辐照度) 在在光线传播过程中,有如下区别
Irradiance 会随着光线的不断传播变得越来越小,而intensity不变
这是因为传播过程中,随着半径的不断变化,球的面积也在不断变化,唯一不变的只有角度
Radiance
Radiance 跟 Irradiance 有点像,Radiance 是描述光线在环境中的传播的一个物理量,这个叫辐亮度
你可以把它理解为是 Irradiance 和 intensity 的交集,Radiance 是指单位立体角上单位投影面积的能量
也就是说,它与单位面积和单位立体角都有关,有这么两种理解方式
- 光从某一个方向打出去,到达一个单位面积的能量
- 光从某个单位面积打出去,沿某个方向上的能量
所以它既可以表示光的入射,也可以表示光的发出,并且这两个的公式表达还不太一样,所以它具有方向性
从一个方向入射
这是每单位立体角的 Irradiance
从一个面发出
这是每单位投影面积的 intensity
Irradiance vs. Radiance
- Radiance具有方向性,Irradiance 不具有方向性
- Radiance在所有方向上的求和(积分),就是 Irradiance
到这里为止,我们已经了解了光线和能量的传播,那么当光线碰到物体的时候,会发生什么奇妙的作用呢?
(上面几个物理量有点绕,但是非常重要,要好好理解清楚再继续看下面哈)
BRDF
BRDF,全称叫Bidirectional Reflectance Distribution Function,即双向分布函数
(这句鬼话我也不想看,认识一下就行,接下来说人话)
这个函数,简单来说就是描述反射到底是什么,描述光线和物体是如何作用的,它定义了物体的材质
光从
可以看出来,这里最核心的就是一个比例,光照入射和出射的比例,这里用
不同的比例,就代表了不同的材质
反射方程
到目前为止,我们已经能够写出一个完成的式子,来描述光线的反射过程了
将所有入射方向的光线对观测点的反射方向的贡献相加,就能得到最终的结果
渲染方程
渲染方程与反射方程已经很接近了,只不过是在反射方程上加上了自己发的光
目前的这个渲染方程,只适用于一个点光源照射一个物体的情况。如果有多个点光源,只需要加几个反射过程就可以了。如果是面光源的话,可以看出是许多点光源的集合,物体的反射光也依旧可以当做点光源来计算
路径追踪
上面的Whitted-style光线追踪的效果其实还不错了,但是在某些方面表现的还是不够好,比如说这个茶壶
根据Whitted-style光追的原理,你可以想的到,他在镜面反射上做得很好,但是似乎没有办法做到一些模糊的效果。就比如说右边这个磨砂茶壶,就没办法很好的表示出来,因为光打到这种材质上的时候,并不一定会沿着特定的方向反射。
那么此时,路径追踪就该出场了。
蒙特卡洛积分
我们还是基于上面的渲染方程,因为它是基于物理推导出来的,理论上是完全正确的。
那么问题来了,怎么求解呢?
对于这种复杂的积分式,我们很难用数学推导算出来,但可以用蒙特卡洛模拟出来
在使用蒙特卡洛积分之前,我们先来了解一下概率密度函数
概率密度函数
从图中可以看出,用这个概率密度函数来采样的话,那
还有个性质,这个函数曲线围成的面积为1,也就代表了所有点的概率之和为1,用公式表达如下
对于随机采样,也就是取任意一个
如果我们要求解一个复杂函数的积分,比如求解下面这段蓝色区域的面积
那么可以用蒙特卡洛的思想,即不断在a和b之间选取
针对这个例子,有如下结果
好了,到此为止,我们已经知道了随机采样的蒙特卡洛积分的解法,那如果采样的不随机呢?
对于非随机的采样,每个点
权重=概率×范围,这个概率就是指概率密度函数
对于随机采样,每个点的权重就是
对于非随机采样,就是
N越大,代表采样的次数越多,最后的结果也会越准
渲染方程的求解
我们将这个方法应用到渲染方程上,先忽略物体自身发出的光。渲染方程的本质,就是在计算四面八方的光,经过BRDF后,反射到
这里应用了光源的可逆性,从着色点出发从里往外发出射线,模拟光源从外往里照射,本质上是一样的,只是方便计算。
这一根又一根的射线,不就相当于在这个半球上不断的采样么?
好了,蒙特卡洛上场,我们直接代入进去就可以了
此时的
代入进去,就变成了这样
到这一步,我们就算是解完了
但再仔细考虑一下,如果采样打到了光源,那还好说,可以直接算出光强;但如果打到的是另一个物体咋办呢?那不就没光了么
其实这就是光的反射了,采样的光线打到了另一个物体,就意味着是另一个物体反射出来的光,照在了这个着色点上。我们可以模仿这个采样的过程,如果打到了物体上,就反射一次再继续走
说详细点,比如着色点p,以
但,我们不能无限弹射下去啊,可是没有打到光源就停止弹射的话,不就又损失了能量么?
欸,我们可以用俄罗斯轮盘赌的玩法来解决这个问题
啥是俄罗斯轮盘赌?就是一把手枪能装6发子弹,玩家往里面装一发,互相对自己开枪,谁能活下去谁就赢了
这本质也是一个概率问题,如果枪里有两发子弹,那开枪的时候,你活下去的机会就是
我们同样可以给光线的弹射来手动设置一个概率,如果光线满足了这个概率,他就继续弹射,然后将结果除以这个概率。如果不满足,那就直接终止。
这样做的聪明之处在哪里呢?它能使最后的期望趋近于真实
不错,我们似乎解决了所有问题
但是还有一个,凭啥每次反射都要以
确实,我们不应该只限定一个方向,每次反射我们需要往附近的所有方向做一次反射
理论很美好,现实很骨感,你做这么多次反射,机器跑不动啊。一条光线经过一次反射就变成了100条,再经过二次反射就变成了10000条,这个计算量我们肯定接受不了
那100条接受不了,反射出去几条才能接受呢?
一条,因为一条光线,无论反射多少次,都是一条
但是只反射一条,那不又绕回去了么?
虽然反射的光线只有一条,但是别忘了,我们的单位是像素,穿过像素的路径可是有很多的
我们每一条路径都只沿着一个方向不断反射,然后放出很多条路径,最后的结果不也是一样的么?
正是因为这个原因,所以这个算法才叫路径追踪嘛
很好,目前为止,我们似乎真的解决了所有问题
兴致冲冲的跑出来一看,不太对劲
似乎在路径数量比较少的情况下,图像的燥点就十分明显
这是因为大部分的路径最终都没有打到光源,如果光源比较大的话就还好,如果光源很小,就很难命中,大量的光线都将浪费掉
那有没有什么办法能既减少浪费光线的数量,又能保证基本的渲染质量呢?
有的,既然我们希望光线都能命中光源,那何不直接对光源进行采样呢?这样就能保证每一根光线都能打到光源,属于是在光源上采样,在光源上积分
此时的采样范围就成了光源,概率密度函数就变成了
相应的,随机方向
这样我们就又可以用蒙特卡洛方法来解这个积分,此时着色点的光分为两部分,一是直接光源的贡献,二是其他的贡献。对于直接光的部分我们就可以直接使用采样光源的方法来处理,这样不会导致路径的浪费。而间接光的部分依旧使用原来的逻辑,即从P点已某个概率随机往任意方向发射一条光线,若打到非光源的物体,则进入递归。如下图,P随机打到O点,然后已O点进入递归,对于O的直接光部分依旧使用采样光源的方法。
当然采样光源的方法还会有个问题,若Q和P直接有障碍物(如下图),那么P点肯定就无法受到来着Q点的直接光照,因此我们还需要从P点再射一条光线来判断中间是否有障碍物。
好,恭喜你,到此为止,路径追踪的核心原理就算是学明白了