基于WebGL实现的光线追踪渲染
〇、前言
本文是课程 计算机图形学 的课程报告
本文共同作者信息见 https://github.com/Aeroraven/Ray-tracing
一、 理论内容
1. 光线追踪
我们选用的常规渲染方法是光线追踪,与传统的光栅化渲染不同,光线追踪是把对一个场景的渲染任务分解成摄像机发出的若干光线对场景的影响。每条光线会和场景并行地求交,根据交点位置获取表面的材质、纹理等信息,并结合光源信息计算光强。
1.1 渲染方程
根据从视点处发出的光线,计算光线和物体的交点,我们可以列出下列渲染方程得到光线颜色其中$L_e$是物体自身的出射辐射(自发光),$w_i$为物体散射函数(取决于物体表面材质)$L_i$是入射辐射度(入射光),$cos\theta$是入射光线和法线之间的夹角余弦。
$$
L_o(p,\omega_0)=L_e(p,\omega_0)+\int_{\xi^2}f_r(p,w_i\to w_0)L_i(p,\omega_i)cos\theta d\omega_i
$$
1.2 蒙特卡洛积分
对于镜面反射,反射方向是可以直接确定的。对于漫反射,反射光线遍布所有方向,由于积分难以直接计算,我们使用蒙特卡洛积分法对该积分近似求解,每次随机选取一个反射反射方向计算光线的辐射信息,这样一次渲染的过程就可以近似为
$$
L_o(p,\omega_0)=L_e(p,\omega_0)+f_r(p,w_i\to w_0)L_i(p,\omega_i)cos\theta
$$
1.3 路径追踪算法
从视点发射光线计算颜色的过程是上述方程进行逆向计算的过程。对于光线所碰到的第p个物体,其对视点感知到的辐射度的贡献值可以标识为:
$$
L_{o,p}=\left(\prod_{k=0}^{p-1}f_{r,k}L_{i,k}\right)L_{e,p};L_o=\sum_{k=0}^NL_{o,k}
$$
之后迭代计算光照的辐射信息,并记录采样数,对N次采样结果取平均值即得到光想追踪的渲染结果。
1.4 Ping Pong 缓冲
为了能够实时地观察到渲染的结果,我们利用WebGL的FrameBuffer实现了Ping Pong缓冲机制。一次计算的时候将上一次光线追踪计算出的结果(Texture A)
渲染出来,而当前光线追踪则将计算的结果写入到另一个地方(Texture B)
中。当一次计算完成后,将Texture A
和Texture B
进行交换。为了将Ping Pong和蒙特卡洛积分进行结合,程序向片段着色器传入一个值uSample
表示已有的采样数量,并将已经渲染出的结果(Texture A)
同样传入片段着色器中。最终的FragmentColor由Texture A
(权重为uSample
)和本次的光线追踪计算结果(权重为1)加权平均产生。
1.5 Gamma校正
因为人眼对光线的感受不是线性的,举个例子,在黑暗时增加一盏灯,那时人眼的感受最明显,往后随着灯数量的增加,人眼对光亮的感觉不会这么明显,事实上显示器输出<人眼接收
,因此我们在图像处理的过程中尽可能得保留暗部细节,符合人眼的暗部敏感需求,我们的RGB值与功率的关系如下公式所示
$$
V_o=V_i^{1/\gamma}
$$
2. 光子映射
我们小组基于光线追踪的基础上,希望能够研究除了软光栅和光追踪之外其他相关的渲染方法,并且通过具体实现加以分析。我们小组选择的拓展方向是光子映射。根据相关的资料说明,我们了解到光子映射相较于光线追踪可以更好的解决 Caustics 等现象。但是,光子映射一般对内存空间的开销是比较大的。
我们也从光子映射的原理出发开始学习。基本的光子映射可以分为两个阶段:构建光子贴图和基本全局光子映射。我们构建光子图,主要是存储从光源出发的所有光子通量信息;后续的基本全局光子映射和传统的路径追踪比较相似,但是在追踪到特定表面,比如漫反射表面的时候,我们根据光子图以及实时发射的光子信息计算最终的辐射率。
2.1 光子贴图
2.1.1 光子贴图的基本定义
我们可以用网上资料[1]如上图对于光子贴图进行解释说明。光源是会不断发射光子的,他们场景中不断弹射,每次击中漫反射表面,就可以在对应位置记录下相应的光子信息,这个过程会在两种情况下结束:要么光子能量低于一定的阈值,我们就看作光子被这个漫反射表面吸收了;要么使用俄罗斯轮盘赌,一旦随机数低于设定好的反射率,也强行停止。
例如上图中,从顶上方的面光源发射光子分别弹射到墙壁、镜面球、玻璃球等不同材质,进行反射和折射直到被某处,上图为顶板和底板吸收。实际项目中,有例如数组、链表、kd树等多种方式存储光子贴图。
2.1.2 光源和光子初始情况
首先我们假定光源本身的辐射值是$$Le$$,那么当前光子的初始发射功率就可以表示为:
$$
\Phi=\frac{L_e|cos\theta|}{pdf_A(x)pdf_w(\omega)}
$$
和路径追踪类似地,我们在光子映射中也考虑点光源、面积光和平行光,可以分别计算得到光源法向量、面积和采样值。
2.1.3 光子反射
当光子发射的射线和场景某一表面相交的时候,我们可以通过随机数或者BSDF的算法原理计算反射分布,也可以得到反射方向和反射类型。当反射类型是漫反射的时候,光子映射就需要将其记录到光子贴图。我们可以为光子构建结构体,往光子贴图中记录光子位置、路径和功率。
2.1.4 光子吸收
光子撞击漫反射表面时,在以下两种情况下会判定为被吸收:
- 光子能量小于一定的阈值
- 被俄罗斯轮盘赌筛掉
如果一个光子没有被吸收,我们可以更新下一次发射迭代时光子的功率。事实上,这里就可以体现出光子映射和路径追踪的理论区别,因为光子映射追踪的是功率,而路径追踪计算的是吞吐量。从原理上来说,我们可以先计算当前反射撞击到的材质的反射率:
$$
R=\frac{f(x_{i-1}\to x_i\to x_{i+1})|cos\theta_i|}{pdf_\omega(x_i\to x_{i+1})}
$$
随后再迭代计算下一次功率的具体值:
$$
\Phi_{i+1}=\Phi_i\times\frac{R}{matProb},or,\Phi=\Phi_i\times\frac{R}{N_{BxDF}}
$$
两个等式使用不同方法对功率进行模拟,前者是SmallVCM,后者是PBRT。在实际项目中我们也可以通过其他方式对功率进行模拟。
2.2 基本全局光子映射
经过上述阶段的光子贴图构建,我们可以实时获得光子的位置、方向和功率,这些属性我们都可以运用到后续的渲染过程中。我们可以从基本的基本渲染公式出发:
$$
L_r(x,\omega)=\int_{\Omega}f(x,\omega’,\omega)L_i(x,\omega’)|cos\theta ‘|d\omega’
$$
根据辐射率的定义,我们可以将以上公式转换为:
$$
L_r=\int_\Omega f(x,\omega’,\omega)\frac{d^2\phi_i(x,\omega’)}{dA|cos\theta’|dw’}|cos\theta’|dw’=\int_\Omega f(x,\omega’,\omega)\frac{d^2\Phi_i(x,\omega’)}{dA}
$$
二、项目实现
1. WebGL渲染过程
1.1 基本的WebGL渲染过程
本次项目实现的光线追踪算法主要由两次渲染过程组成。第一次渲染将光线追踪的结果输出至纹理上,第二次渲染将纹理绑定至一个矩形区域并输出到HTML的Canvas画布上。为了更加便捷地使用WebGL,我们使用OOP的思想重新封装了WebGL的API,在此基础上实现路径追踪和光子映射。
Three.js和Babylon.js等Web3D库都使用面向对象的思想对一些渲染过程中常见的概念进行封装。受到这些项目的启发,本项目对光栅渲染中的一些常见的概念通过WebGL底层的API进行了重新的封装。例如项目中场景就封装于类WGLScene中、纹理处理封装于类WGLTexture中、帧缓冲处理封装于WGLFrameBuffer中、基本的三角形图元和矩形封装于类Triangle和Rect当中、相机封装于Camera类中。
Camera 对象 Camera对象位于项目的core/Camera.js
文件当中,主要目标是提供模型视图变换(Model View)和投影变换(Projection View)。其中模型视图矩阵通过LookAtMatrix进行生成(即指定观察者位置、观察中心和上方向,生成一个模型视图矩阵)。投影变换支持透视投影(通过setPerspective
方法)和正交投影(通过setOrtho
方法)实现。Camera对象生成的两个矩阵在第二次渲染的过程中被送入光栅渲染部分的顶点着色器中
WGLTexture 对象 WGLTexture对象位于项目的render/WGLTexture.js
文件当中,用于简化WebGL的纹理相关的操作,例如通过texImage2D
创建纹理或bindTexture
进行纹理的绑定以及对纹理Mipmap的相关操作、图片加载(loadImageAsync
方法)、纹理更新(updateTexture
方法)等操作。
WGLFrameBuffer对象 WGLFrameBuffer对象安慰与项目的render/WGLFrameBuffer.js
文件当中,用于帧缓冲相关的处理。该部分内容将在下一小节进行阐述。
WGLScene 对象 WGLScene对象位于项目的render/WGLScene.js
文件的当中,主要目标是将场景中的图形对象转换为顶点信息,并且执行渲染。在获得所有的图形顶点信息后,使用WebGL提供的bindBuffer
和bufferData
将顶点信息(包括顶点位置、顶点颜色和纹理坐标)传入光栅渲染部分的顶点着色器中,并执行渲染。WGLScene提供两种渲染方式,一种为直接渲染至Canvas画布,另一种为渲染至Texture(通过FrameBuffer和Texture共同实现)。二者共有的部分使用renderInternal
方法进行封装。
1 |
|
对于渲染至纹理的部分,使用renderToTexture
封装。其基本流程是通过FrameBuffer和Texture进行绑定,将渲染的结果重定向到FrameBuffer绑定的Texture对象上。
1 |
|
着色器基类和光栅着色器 着色器基类位于项目的shader/ShaderBase.js
文件中,用于提供一个包含顶点着色器(Vertex Shader)和片段着色器(Fragment Shader)的接口。光线追踪、光子映射和光栅渲染三个部分的着色器均继承自着色器基类。光栅着色器用于将光线追踪渲染到纹理上的结果在一个面片上进行渲染,位于项目的shader/TrivialShader.js
文件当中。其顶点着色器接收模型变换矩阵(uModelViewMatrix
)、投影矩阵(uProjectionMatrix
)和环境光照(uAmbientLight
),并且向片段着色器输出颜色(vColor
)、位置(vPosition
)、环境光照信息(vAmbientLight
)和顶点信息(vTextureCoord
)。顶点着色器主要是对顶点位置根据矩阵进行变换。
1 |
|
片段着色器则用于颜色确定
1 |
|
1.2 Ping Pong 缓冲的实现
为了能够实时地观察到渲染的结果,此处利用WebGL的FrameBuffer实现了Ping Pong缓冲机制。一次计算的时候将上一次光想追踪计算出的结果(Texture A)渲染出来,而当前光线追踪则将计算的结果写入到另一个地方(Texture B)中。当一次计算完成后,将Texture A和Texture B 进行交换。
为了将Ping Pong和蒙特卡洛积分进行结合,程序向片段着色器传入一个值uSample表示已有的采样数量,并将已经渲染出的结果(Texture A)同样传入片段着色器中。最终的FragmentColor由Texture A(权重为uSample)和本次的光线追踪计算结果(权重为1)加权平均产生。这部分通过WGLFrameBuffer进行实现。
1 |
|
而在光线追踪场景渲染时,只需要调用bindTexturePingPong
就可以很容易地实现Texture的切换。代码中的省略号为省略与该部分无关的代码,不代表源代码此处是省略号。
1 |
|
2. 光线追踪(路径追踪算法)
2.1 主体框架
2.1.1 整体框架
WebGL常用于光栅渲染,要利用WebGL进行光线追踪渲染无法通过常规的途径实现。在Web端通常有两个选择途径,一个是使用JavaScript脚本进行渲染。该方法在JavaScript脚本中计算光线追踪的渲染结果,然后将光线追踪的结果通过uniform传入片段着色器进行着色,该方法调试简单,但计算过程依赖于CPU,且Web的客户端脚本不适合执行高性能的运算操作,因此我们并没有采用此方法。另一种是将光线追踪的内容写入着色器中,虽然该方法调试开销极大,但该方法能够最大限度地利用GPU的运算性能,因此我们选择了此方法作为此项目的渲染方法。
通常着色器的代码是不变的,在使用着色器时,只需要将值通过attribute(高版本的GLSL为in关键字)或者uniform传入即可。但也可以使用动态着色器生成的思路,后者在调试上相对更加容易,且能够减小代码冗余。Niklas F和Daniel W也就实时渲染中的动态代码生成进行研究[2]。在本项目中我们综合了两种方案,对于常量信息使用传统的uniform传值方案,对于复杂的,且容易产生冗余和难以调试的内容,我们使用后一种方案。
同样,为了使得代码具有可扩展性和可维护性,修改场景只需要增加或修改一两行的JavaScript代码,而不是对GLSL代码进行大幅度修改,我们同样使用了面向对象的思想将光线追踪中的一些概念进行封装,封装后的对象位于文件path-tracing/raytracing
文件夹中。主要的文件结构如下所述:
- RTObserver:该类封装光线追踪中的相机对象,用于处理光线追踪当中的投影变换和模型视图变换。具体将在
- RTScene:该类用于存放场景中的几何体,并根据其中的相机对象和着色器对象,将光追的结果渲染到一个矩形区域上(渲染区域和Texture绑定)
- RTShader:该类继承自着色器基类ShaderBase,主要处理两个部分,顶点着色器负责处理矩形区域的顶点,以及根据RTObserver中传入的变换矩阵,将四个顶点上的光线方向向量进行确定,光线方向将传入片段着色器中;片段着色器对传入的四个方向向量进行插值,得到每一个像素点的光线方向,之后进行光线碰撞和追踪。
- RTShaderUtil:该类用于动态生成和组合着色器的代码。
- RTShaderVariableMap:该类用于建立uniform变量和其值之间的映射,主要目的是进行封装来化简操作。避免每一次都调用
getUniformLocation
和uniform*v
等函数,便于进行代码调试和维护 - Preset 文件夹:该文件夹存放预设的场景
- RTGlassTest: 为一个玻璃球和一个金属球的室内场景,玻璃折射率为1.5
- RTLiquidTest:为一个带水面的两个粗糙小球的半封闭(顶部开放)的场景,水为蓝色
- RTSceneWithGeometry:该场景为一个室内具有很多个不同材质小球的场景
- RTSceneWithGeometryOutdoors:该场景为室外具有很多不同材质小球的场景
- Component 文件夹:该文件夹存放与光追相关的组件内容
- RTAmbientLight:环境光照,为在每一次光线反射时附加计算的光照信息。
- RTMaterial:材质信息,可以附加到几何体上,包括材质颜色、发光颜色、折射率、材质类型等。一共提供七种材质类型,为该类下的静态常量(
ABSORBED
为完全吸收、DIFFUSE
为理想漫反射、SPECULAR
为理想镜面反射、REFRACTION
为理想折射、METAL
为金属材质、MOSSY
为粗糙玻璃表面、WATER
为水面) - RTPointLight:点光源,该类为学习光线追踪算法时,实现Whitted-Style光线追踪的残留类,在路径追踪算法中,该光源不使用。
- RTSkyLight:天空颜色,当一个光线无法触碰到任何的物体时,其返回天空颜色
- RTPlane:几何体,三角形平面。
- RTSphere:几何体,球。
- RTTetrahedron:组合的几何体,四面体。由四个RTPlane组成。
- RTWaterSurface:几何体,无限水面。
2.1.2 渲染至纹理对象和Ping Pong的实现
渲染过程由光追的场景对象RTScene
完成,其主要过程是先调用之前第一节中封装过的WGLFrameBuffer
和WGLTexture
中的函数,完成Ping Pong的切换以及帧缓冲和纹理对象的绑定,该步骤完成后,一切渲染操作将输出到和当前绑定的帧缓冲所绑定的纹理上。之后和普通的渲染过程没什么差别,即设定视窗(glViewPort
)、清屏(glClearColor
)、设定Uniform(2.1.1中RTShaderVariableMap
封装的方法)、绑定顶点(即把矩形的四个顶点进行绑定,并启用其传入着色器的选项)。最后就是很简单的画图(glDrawArrays
)。绘制完成后,解除帧缓冲和纹理的绑定,然后Ping Pong 翻转纹理对象,使得当前完成渲染的纹理能够贝使用。该过程位于RTScene
的render
函数下。
1 |
|
2.2 坐标系的建立和光线的发射
对于场景中的物体坐标$V$,设投影矩阵为$P$,模型矩阵为$M$。经过变换后的坐标应该为$VPM$,可以对场景中的所有物体的坐标都应用上述的变换,得到最终的坐标。但上述开销相对较大。我们可以对光线和视点采取$P$和$M$的逆变换$M^{-1}P^{-1}$。这个即为光追场景中坐标系建立的初步思路。
在光追相机对象RTObserver
中,我们重新封装了第一章中提及的Camera
对象,对于getRay
为获取视点处到(x,y,z)的视线方向,用于发射光线,在此处我们使用齐次坐标,应用上述的逆变换调整光线方向。
1 |
|
我们将四个方向的光线向量传入到RTShaderVariableMap对象中,便于uniform的实时更新
1 |
|
对于顶点着色器而言,由于渲染的是一张矩形的图片,因此不需要进行任何的坐标变换。只需要对上面传入的向矩形四个定点发射的光线方向向量进行插值,并且将其传入到片段着色器当中即可。因此顶点着色器不需要非常复杂,只需要一个mix插值函数即可。对于光追的着色器,我们使用了GLSL 300 ES版本的着色器语言。
1 |
|
2.3 光线和物体的碰撞判定与片段着色器的动态生成
在2.2完成后,片段着色器拿到的ray的方向就可以直接使用了。在此之前,还需要对光线和物体的碰撞判断进行处理。此节中将阐述球体和三角平面的碰撞判断。对于水面的碰撞判断,将在2.7节中阐述。
2.3.1 光线和球体的相交判定
在项目中光线使用向量$\vec{L}=\vec{O}+t\vec{D}$进行表示,其中$\vec{D}$是单位向量。对于一个三维球体,其可以使用方程$|\vec{X}-\vec{C}|=R$表示,其中$\vec{X}$是球上任意一点,$\vec{C}$是圆心。光线和球的相交判定,即判断$|\vec{O}+t\vec{D}-\vec{C}|=R$是否有解。因此,需要解一元二次方程组。
设$\vec{P}=\vec{O}-\vec{C}$,则上述方程可转换为$(t^2\vec{D}^2-2t\vec{D}\vec{P}+\vec{P}^2)=R^2$是否有解。此时可以确定一元二次方程的系数$a=\vec{D}^2,b=-2\vec{D}\vec{P},c=\vec{P}^2$,据此得到判别式$\Delta=b^2-4ac$。若判别式值小于等于0,直接舍弃(和球面相切的可以不必判断)。否则得到方程的两个根$x_1,x_2=(-b+\sqrt{\Delta})/(2a)$,此时选取两根中为正值的最小者(如果都为负值,直接返回其中一个,负值在后续会被舍弃,此处不需要特判),作为相交结果。该部分的代码如下:
1 |
|
若要得到交点坐标,直接将该函数的返回值按照$\vec{L}=\vec{O}+t\vec{D}$进行计算即可。另外上述使用$eps=1e-10$来防止浮点误差,该方法在后续也会使用。
2.3.2 光线和平面的相交判定
对于一个平面,可以使用方程$A(x-x_0)+B(y-y_0)+C(z-z_0)=0$表示,即$\vec{N}\cdot (\vec{X}-\vec{X_0})=0$,因此只需要解方程$\vec{N}(\vec{O}+t\vec{D})=\vec{N}\vec{X_0}$即可。该方程移项可得$t\vec{N}\vec{D}=\vec{N}\vec{X_0}-\vec{N}\vec{O}$,因此有:
$$
t=\frac{\vec{N}\vec{X_0}-\vec{N}\vec{O}}{\vec{N}\vec{D}}
$$
对于$X_0$,可以从三角形的顶点中随机选取一个。因此可以很容易地得到平面和光线相交的判定结果。
1 |
|
之后需要判断该交点是否在三角形区域内。此处利用三角形的面积进行判断,对于三角形内部一点$P$,其在三角形内部需要满足的条件为:
$$
S_{\Delta ABP}+S_{\Delta BCP}+S_{\Delta CAP}=S_{\Delta ABC}
$$
根据向量叉乘计算面积,上式可以重新写为:
$$
|\vec{PA}\times\vec{PB}|+|\vec{PC}\times\vec{PB}|+|\vec{PA}\times\vec{PC}|=|\vec{AB}\times\vec{AC}|
$$
然后就可以得到点在三角形内的判别式。
1 |
|
2.3.3 记录最近的碰撞点及动态着色器代码生成
光线追踪要求记录光线最近的碰撞信息,因此我们实现了一个函数fRayCollision
来进行光线和所有物体的碰撞判断。在该函数中,维护一个最近的碰撞距离$t$,只有物体和光线碰撞距离更小时,该值才会更新。同时碰撞的法线(colnorm
)、辐射颜色(emicolor
)、材质颜色(matcolor
)、是否碰撞(collided
)、折射率(refra
)、碰撞点(colvex
,colp
)等信息也将同步维护,最近的碰撞结果将放入结构体sRayCollisionResult
返回调用者。其中该结构体的定义如下:
1 |
|
对于如何遍历所有物体,此处采用动态代码生成的形式。每一个几何体生成一段自身的碰撞代码,动态地放入该部分。所有几何体的JavaScript类都需要实现genShaderIntersection
方法,在其中放入该几何体的碰撞判断逻辑。在几何体被放入场景对象RTScene 后,genIntersectionJudge
方法负责将所有几何体的碰撞判断逻辑整合为一个顺序执行的代码,在生成片段着色器时(RTScene的genFragmentShader
),会调用该代码,并将其插入到着色器函数的fRayCollision
函数中,下方代码的object处即为代码插入处。
1 |
|
对于三角平面,生成的碰撞检测代码为:(其中uEM
,uCL
,uRF
为材质的uniform,this,material.tp
为材质类型,VA~VC为顶点)
1 |
|
对于球体部分,代码为:(其中VC为圆心,RA为半径,其他同上)
1 |
|
两个代码中的if(true)
用于隔离局部变量,使得一个局部变量能够被多次声明,且互不影响。
2.4 镜面反射、漫反射以及金属材质的实现
对于镜面反射,设入射向量为$\vec{I}$,入射点的单位法向量为$\vec{N}$(保持和入射方向的夹角为钝角)。则入射向量在法向量方向上的投影长度为$\vec{I}\vec{N}$,因此可以设修正后的入射向量$\vec{I’}=\vec{I}/\vec{I}\vec{N}$,而出射向量满足$\vec{O}=\vec{I’}+2\vec{N}$,之后对该向量进行单位化就可得到反射向量。因此镜面反射十分简单。
1 |
|
对于漫反射,在反射后采用在和法向量相同半球方向上(即和法向量内积大于0)随机选取一个反向作为反射方向。漫反射中极其依赖随机数的生成,对于随机数的生成将在2.8节中讨论。
1 |
|
对于金属材质,是理想镜面反射和理想漫反射的一个综合。因此对于镜面反射得到的向量$\vec{O}$,在$\vec{P}+\vec{O}/|\vec{O}|$ 处按照漫反射的方法进行随机的方向选取,设此时得到的随机方向为$\vec{D}$,则最终反射方向为$\vec{F}=\vec{O}+\vec{D}$。
1 |
|
2.5 蒙特卡洛积分和迭代采样
2.5.1 蒙特卡洛积分
本项目中蒙特卡洛积分依赖于Ping Pong缓冲。程序向片段着色器传入一个值uSample表示已有的采样数量,并将已经渲染出的结果(Texture A)同样传入片段着色器中。最终的FragmentColor由Texture A(权重为uSample)和本次的光线追踪计算结果(权重为1)加权平均产生。这部分通过WGLFrameBuffer进行实现。在着色器中,只要对光追得到的颜色和上一次渲染的结果进行插值,将结果赋值给fragmentColor即可
1 |
|
2.5.2 光线颜色
迭代采样通过一个for循环实现。该循环每次调用fRayCollision
判断该光线是否和物体产生碰撞,如果产生碰撞,则将碰撞的光线、颜色等信息返回计算累积光照和累积材质,并根据返回的材质类型判断进行反射的方式,在调用相关的反射函数后,生成下一个光线的起点和方向,进入下一次迭代。
在此处我们简化了光照模型。设物体发光辐射为$E_i$,物体材质统称为$M_i$,初始光线颜色为$R$。考虑一个光线正向传播的过程,当其碰到第一个物体时,反射光线的颜色为$RM_1+E_1$,当该反射光线继续触碰第二个物体时,反射光线的颜色为$(RM_1+E_1)M_2+E_2=RM_1M_2+M_2E_1+E_2$,利用数学归纳法,可得当反射光线触碰第$N$个物体时,光线的颜色为
$$
L_{i,N}=\left(\sum_{i=1}^NM_i\right)R+\sum_{k=1}^N\left(\sum_{j=k+1}^NM_j\right)E_k
$$
容易发现,当$N$为中物体数量时,第$k$个物体发光的颜色需乘上$k+1$至$N$ 编号的物体的材质信息。如果将光线反过来计算,按照路径追踪的想法,这些累积的材质信息可以用一个变量进行维护。
下面对所有的物体进行倒序编号,即可得到路径追踪累积颜色的计算方法。
设第$m$个物体对最终光线颜色的贡献值为$L_{o,m}$,则
$$
L_{o,m}=E_m\sum_{i=1}^{m-1}M_i
$$
引入一个累积材质$\alpha_i$,其中$\alpha_i=M_i\alpha_{i-1}$,且$\alpha_0=1$,此时上式可以写为
$$
L_{o,m}=E_m\alpha_{m-1}
$$
设第$k$次迭代后光线的颜色为$L_{c,k}$,则
$$
L_{c,k}=\sum_{i=1}^kL_{o,k}
$$
同样地,可以用一个累积颜色$\beta_i$进行维护,其中$\beta_i=\beta_{i-1}+L_{o,i}$,$\beta_i=0$
由于$\alpha_i,\beta_i$的信息只用一次,因此算法实际上不需要用数组维护。因此第$k$次迭代所需要做的就是。
$$
\beta\gets\beta+\alpha E_i
$$
$$
\alpha \gets M_i\alpha
$$
最后,当一束光线没有碰到任何物体时,此时的$E$和上面公式的$R$一样。项目中,我们用天空颜色来标识$R$的值。
2.5.3 分类讨论各种反射
对于漫反射,我们使用Lambert光照模型(这个实际上对应于渲染方程中的$cos\theta$项目,经过Lambert修正后的漫反射的发光颜色根据如下公式进行修正
$$
E_i’=E_i\frac{\vec{N}\vec{D}}{|\vec{N}||\vec{D}|}
$$
漫反射的处理代码如下
1 |
|
镜面反射处理相对容易
1 |
|
其他反射将在后续小节中讨论
2.5.4 最核心的For循环
路径追踪所用到的最核心的For循环位于函数fRayTracing
中,为了提高效率,一个光线在两种情况下会停止迭代计算。第一种情况是迭代次数超过20次,第二种情况是颜色值小于0.01且迭代次数大于3.
另外为了防止浮点误差,反射光线的起始点会在反射方向上进行一小段的位移。
1 |
|
2.6 透明和折射
2.6.1 折射
在折射部分,我们主要使用了斯奈尔定律和经典的折射公式来完成。我们假定一条光线最多只能发生折射和反射中的一种情况,因此我们需要先判断一条光线是否发生了折射,判定代码如下:
1 |
|
我们是根据上述Discriminant这个参数进行判定的。如果斯奈尔定律是成立的,也就是说折射已经发生了,Discriminant在物理意义上表示$$cos(θ_2)^2$$,这个值是一定在0~1之间的。如果这个Discriminant<0,那么一定表示斯奈尔定律不成立,也就是折射没有发生。
当我们确定折射发生之后,我们就可以计算折射后光线的方向,重要代码如下:
1 |
|
我们先要判断折射情况,一共可以分为两种类型,第一种是第一介质折射到第二介质;第二种是第二介质折射到第一介质。区分这两种类型的参数就是Dt。Dt是入射点法线向量和入射点向量的点击,如果Dt<0,就代表入射点法向量和入射点向量之间的夹角是钝角,也就是第一介质折射到第二介质。反之如果是Dt>0,就代表是第二介质折射到第一介质。
再之后,我们可以分别计算$$cos(θ1)$$,$$sin(θ1)$$,$$cos(θ2)$$, $$sin(θ2)$$。我们可以利用矢量和叠加的形式,将X方向的矢量和Y方向的矢量进行叠加就可以得到折射光线的方向。
2.6.2 毛玻璃材质
对于毛玻璃,我们收到了蒙特卡洛积分的启发。当光线碰到毛玻璃材质时以$p$的概率发生漫反射,以$1-p$的概率发生折射。即可实现相应的效果。
2.7 水面渲染
该小节删减
2.8 其他细节优化
2.8.1 Gamma 校正
因为人眼对光线的感受不是线性的,举个例子,在黑暗时增加一盏灯,那时人眼的感受最明显,往后随着灯数量的增加,人眼对光亮的感觉不会这么明显,事实上显示器输出<人眼接收
,因此我们在图像处理的过程中尽可能得保留暗部细节,符合人眼的暗部敏感需求。Gamma校正使用的函数依赖于GLSL的内置pow函数。
1 |
|
在最后输出fragmentColor前,进行Gamma校正。
1 |
|
2.8.2 ForLoop和RequestAnimationFrame的权衡
在前端页面使用RequestAnimationFrame实现连续渲染的效果。但在没有优化的情况下,光追渲染进行一次蒙特卡洛采样,然后将结果绘制到Texture上,之后Ping Pong然后就等待下一次的RequestAnimationFrame。该方案渲染对机器的性能要求最低,不会产生机器的卡顿问题,但一个最严重的缺陷是需要等待很久才能够等到光线追踪的结果收敛。一个原因是只进行一次蒙特卡洛采样无法充分利用GPU,频繁地RequestAnimationFrame加大了切换和调用的开销。为了解决该问题,我们才main函数中增加了一个新的For循环,等待多次采样后,才返回fragColor。该优化明显地加速了光追结果的收敛速度。
1 |
|
2.8.3 随机数生成
GLSL和C不同,没有内置的随机数函数。因此要产生随机噪声,必须自行构造伪随机函数。在项目实现过程中,自定义的随机数出现了效果差,随机性不好的问题。产生的随机数结果不均匀对最后的渲染质量造成了非常严重的影响。因此我们从网络引用了一个常见的随机数生成函数。
1 |
|
另外为了确保更好的伪随机性能,我们在uniform中增加了一个uTime的值,为当前的时间戳与页面启动的时间戳的差值,uTime将在初始化时被赋值给随机数种子seed。
基于上述的随机数,就可以实现随即单位向量(函数uniformlyRandomDirectionNew
)等多个功能。
2.8.4 降噪
如果不使用降噪,则光追渲染的结果噪点非常严重。因此我们考虑对初始的光线增加一个微小的扰动,通过引入扰动适当地模糊,来避免噪点严重对渲染质量的影响。
1 |
|
3. 光子映射
光子映射主要分为两个部分:生成光子贴图和采样生成颜色,我们将生成光子贴图的部分写在了一个函数里面,采样生成颜色部分则会读取生成的光子贴图来进行计算。
生成光子贴图部分,我们规定了一次反射的最大记录次数为60,每次反射的衰减度为0.5,当颜色向量的长度小于0.2时视为被吸收,同时使用了一个随机数来进行随机中止,代码如下所示:
1 |
|
采样生成颜色部分,当视线第一次碰撞到漫反射表面的时候就会计算其颜色,计算的公式在之前已经进行过解释,这里便不再赘述,所示代码中取的计算用光子数N=50:
1 |
|
参考文献
本文档中引用的文献有:
[1] AainSvck. 光子映射总结(1/4):基本全局光子映射(Basic Photon Mapping)[EB/OL]. (2020-09-07) [2022-01-14] https://zhuanlan.zhihu.com/p/208356944
[2] Folkegard, Niklas. Dynamic Code Generation for Realtime Shaders [EB/OL]. (2004) [2022-01-14] https://ep.liu.se/ecp/013/005/ecp01305.pdf
在项目的实现过程中,也参考了很多教程和开源项目的相关技术原理。通过了解这些教程和相关的开源项目的相关后,我们在相关技术实现上的障碍也能够解决,下列参考排序不分先后和重要性。下列项目在项目开发过程中仅做技术性参考,主体开发工作仍由小组成员独立实现。
[a] MDN. WebGL教程 [EB/OL]. (2022-01-14) [2022-01-14] https://developer.mozilla.org/zh-CN/docs/Web/API/WebGL_API/Tutorial (了解基础的WebGL相关框架、语法和流程)
[b] Evan Wallace. WebGL Path Tracing [EB/OL]. (2010) [2022-01-14] https://madebyevan.com/webgl-path-tracing/ (受到该项目和其它类似的参考文献的思路的影响,我们采用了动态代码生成的思路编写片段着色器代码)
[c] Erich Loftis. Threejs Path Tracing Renderer [EB/OL]. [2022-01-14] https://github.com/erichlof/THREE.js-PathTracing-Renderer(提供随机数,水面生成噪声等相关的帮助)
[d] GFX Fundamentals. WebGL Fundamentals [EB/OL]. [2022-01-14] https://webglfundamentals.org/(WebGL的一些相关的教程)
[e] Peter Shirley. Ray Tracing in One Weekend [M/OL]. [2022-01-14] https://raytracing.github.io/ (光线追踪的基本原理的了解)
[f] Peter Shirley. Ray Tracing: the Rest of Your Life [M/OL]. [2022-01-14] https://raytracing.github.io/ (光线追踪的基本原理的了解)
[g] Khronos. OpenGL4 Reference Pages [EB/OL]. [2022-01-14] https://www.khronos.org/registry/OpenGL-Refpages/gl4/index.php(GLSL的相关函数的使用)
[h] FaithBook. 一周实现光线追踪(十)玻璃材质 [EB/OL]. [2022-01-14] https://www.bilibili.com/read/cv11990396(参考折射的原理,但该资料中的一些推导和实现有错误,小组在实现过程中进行了重新验证推导以及修正)