2018年2月25日
光线追踪是一种强大、简单、费资源的渲染技术,在这个教程中,我将通过创建一个WebGL为基础实时光追渲染咖啡因分子结构的demo,带你走进光追的世界
理论方法
让我们先从光线追踪的算法开始,第一步我们先要创建两个三分量向量:一个蒙版, m r g b m_{rgb} mrgb,一个累加器, a r g b a_{rgb} argb。蒙版层用于保存当前所看到的材质颜色,我们将它初始化为[1,1,1]。累加器则是我们所看到的每个像素的光所叠加的结果,我们将它初始化为[0,0,0]。
现在我们已经初始化了蒙版层以及累加器,我们将会从每个像素点出发,向场景中射出一条射线,每根射线都与场景中的东西进行相交测试,并获得第一个相交的点。当我们找到一个相交的最近的东西时,我们将mrgb乘以这个最近的东西的颜色(同样也是一个三分量向量)。
下一步就需要计算在这个相交点上的光源信息,包括光源的亮度以及颜色。这个过程实际上叫做‘下一事件评估’(next event estimation),从该点开始反向寻找在场景中的光源。经典的光线追踪是完全使用了蒙特卡罗方法(Monte Carlo method),其成功与否取决于能否随机地与光源产生碰撞。当所渲染的场景中的光源比较小时,等待这样极其微小可能性的事件(指与光源发生碰撞)发生是非常昂贵的。在这里,我们通过另外的方法绕过这个问题,我们可以通过从相交点开始发射一根到光源位置的射线,然后检测一下是否碰到了其他东西,如果有碰到了其他东西而非光源,则说明这个相交点的位置处于阴影之中,反之则是在光照的到的地方。当我们计算完这个点的光照强度以及颜色之后,我们就把 m r g b m_{rgb} mrgb、光照强度、光照颜色的乘积累加到 a r g b a_{rgb} argb上。
再下一步,我们从第一个相交的位置再射出一条射线,并且重复以上的步骤。这个过程被叫做光线反弹。我们要让光线多反弹几次的原因是这样更能够细致地捕捉光线在传播过程中的行为,例如环境光吸收以及颜色扩散,这是单渲染一次阴影所做不到的。下面一张图片是一张光线没有反弹的渲染结果图(只计算了第一次碰撞),看起来是不是非常没意思?
我们继续我们的反弹过程直到以下三种情况发生:1、反弹次数超过了最大值;2、啥都没碰到;3、碰到了光源。当我们决定停下来的时候,我们将会把这个特定的像素涂上
a
r
g
b
a_{rgb}
argb颜色(使用某种颜色校正的方法)。我们让屏幕上的每个像素点都经历这样的过程,最后我们会看到一个看到如下有很多噪点的结果:
我知道你想说啥。哇,这太tm丑了,撤回键在哪?!
很不幸的是,这正是光追的缺点,如果你想要在你的场景中有效地采样出一帧的效果,你就需要将很多这样的帧重叠平均在一起,这样才能渲染出平滑无噪点的效果。
我需要指出一点,关于降噪这个问题业界也做出了很多的尝试,比如英伟达的一项技术(Nvidia’s Optix AI Denoiser)就使用通过神经网络的训练,以一帧为基础来进行预测,以达到降噪的效果。最后的结果非常振奋人心。
我们所使用的方法相比之下很简单,采样N帧的噪点信息(但是同时又需要保证稳定的帧率),将他们平均一下,然后将结果呈现给用户。如果在这一帧与下一帧的场景中的内容没有变动,就继续添加新的帧,直到获得一个没什么噪点的结果为止。
下面我们快速深入地看一下接下来所要做的步骤,这就是我们目前为止讨论出来的伪代码:
mask = [1, 1, 1]
accumulator = [0, 0, 0]
For each pixel on the screen:
For N bounces:
Cast a ray into the scene and check for collision
If collision:
mask *= object_color
light_intensity = 0
light_color = [0, 0, 0]
Cast ray at the light source and check for collision
If no collision:
light_intensity, light_color = calculate_lighting()
accumulator += mask * light_color * light_intensity
Else:
accumulator += mask * ambient_light_color * ambient_light_intensity
Break
pixel_color = color_correct(accumulator)
average_frame()
蒙版 = [1,1,1]
累加器 = [0,0,0]
对屏幕中的每个像素点所进行的计算:
for N次反弹:
向场景射出一条射线并且检查碰撞
if 碰到了:
蒙版 *= 碰到的东西的颜色
光强度 = 0
光颜色 = [0,0,0]
向光源射出一条射线并且检测碰撞
如果没碰到(被遮住了):
光强度,光颜色 = 通过某种方式计算的结果()
累加器 += 遮罩 * 光颜色 * 光强度
else:
累加器 += 遮罩层 * 环境光颜色 * 环境光亮度
break 跳出循环
该像素的颜色 = 颜色校正(累加器)
平均目前所得到的所有帧()
发射射线
我们将会用WebGL来渲染场景,我们把所有的处理都交给GPU来做,而不是使用CPU一个像素一个像素地渲染。经典的GPU渲染做法是将一堆三角形组成一个场景中的模型,但在当前情况下,我们只需要两个三角形,每个三角形占了屏幕的一半,就像这样:
这里的坐标由[-1,-1]开始到[1,1]结束,是规范化的设备坐标系,代表着从窗口的左下角开始到窗口的右上角结束。我们将这些顶点以逆时针的方式排列,生成两个三角形,并将其作为坐标系传入到WebGL当中:
[ [-1, -1], // 左下
[ 1, -1], // 右下
[ 1, 1], // 右上
[-1, -1], // 左下
[ 1, 1], // 右上
[-1, 1], ] // 左上
当这两个三角形被渲染的时候,片元着色器将会让其与屏幕上的像素一一对应。片元着色器是并行处理每个像素的程序。
现在我们已经准备好渲染每个像素点了,下面我们来想想办法怎么样让每个像素都变成一条条的射线。我们需要知道以下的信息: 相机(或者说是眼睛)的位置,用
c
c
c来表示;在空间中注视点的位置(目标);可视范围;屏幕纵横比;上方向的单位向量以及近截面和远截面。由这些信息可以构成投影矩阵和视图矩阵,将两者相乘得到一个单独的投影视图矩阵:
P
V
=
P
∗
V
\begin{aligned} P&V=P*V \end{aligned}
PV=P∗V
注意:在我梳理理论的过程中,会有些数学运算。但是不用怕,我都会用代码的方式一一展现
投影视图矩阵能够将三维场景中的点转换为一个规范化的设备坐标系下的二维坐标位置,如果我们取得该投影视图矩阵的倒数,其相当于做一次反操作:将一个二维的点(在规范化设备坐标系下)重新映射到三维坐标上。如果我们用
p
2
d
p_{2d}
p2d表示二维坐标点,用
p
3
d
p_{3d}
p3d来表示得出的三维的坐标位置,那么他们的关系如下:
p
3
d
=
P
V
−
1
p
2
d
\begin{aligned} p_{3d} = PV^{-1}p_{2d} \end{aligned}
p3d=PV−1p2d
点击这篇文章处查看与屏幕坐标映射相关的更深入的信息
在默认情况下,只要我们是从同一个点发出的射线,WebGL都会得到正好是这个像素最中心的点。然而,这样的话我们就会遇到锯齿的问题,因为我们没有处理在像素内的过渡:
为了能够让射线在一个像素内进行不同的采样,我们就要让三维的点位置进行微弱偏移,方向随机,距离随机但是偏移后的位置不超过这个像素点,并且需要和视线垂直:
p
a
a
=
p
3
d
+
R
0
\begin{aligned} p_{aa} = p_{3d} + R_0 \end{aligned}
paa=p3d+R0
p
a
a
p_{aa}
paa是亚像素点,
R
0
R_0
R0是在
p
3
d
p_{3d}
p3d 1个像素范围内的均匀分布的随机三维向量,这样就可以在
p
3
d
p_{3d}
p3d周围获得很多亚像素点。
每一帧中我们都进行这样的操作,将这些帧合并之后,就能得到一个抗锯齿的结果。
在射出初始射线之前,还有一个效果是我们需要考虑的:景深。看一下下面的效果:近处的东西是清晰的,远处的东西是模糊的,这就是景深效果。这看起来很棒对不对,如果我们使用光追的方法进行渲染,这其实是很简单的,只要把相机稍微做一点点摇摆就可以了。
首先,选择一个焦平面,所谓焦平面就是在场景中处于聚焦点的那个平面,也就是最清晰的部分。在这个场景中,咖啡因分子模型处于原点也就是(0,0,0)的位置,所以这是一个座位初始位置比较不错的选择:我们把焦平面设置为
z
z
z轴等于0的平面。
下一步我们就需要知道目前的这个已经经过抗锯齿处理的射线是如何穿过焦平面的,需要把相交的点记录下来:
r
=
p
a
a
−
c
\begin{aligned} r = p_{aa} -c \end{aligned}
r=paa−c
r ^ = r ∣ r ∣ \begin{aligned} \hat{r} = \frac{r}{\mid{r}\mid} \end{aligned} r^=∣r∣r
t f p = z f p − c z r ^ z \begin{aligned} t_{fp} = \frac{z_{fp}-c_z}{{\hat{r}_{z}}} \end{aligned} tfp=r^zzfp−cz
p f p = c + t f p r ^ \begin{aligned} p_{fp} = c+t_{fp}\hat{r} \end{aligned} pfp=c+tfpr^
在上面的公式中,
r
r
r是以相机位置作为起点,以经过抗锯齿处理的点作为终点的射线向量,
r
^
\hat r
r^是
r
r
r的单位向量,
t
f
p
t_{fp}
tfp是射线与焦平面相交的事件,
z
f
p
z_{fp}
zfp是焦平面与
z
z
z轴相交的
z
z
z值,
p
f
p
p_{fp}
pfp是射线与焦平面的相交点。
最后一步,我们需要在原有相机位置基础上,平移平行于焦平面的随机向量,产生一个新的相机位置并重新计算射线。
c
′
=
c
+
R
1
\begin{aligned} c' = c +R_{1} \end{aligned}
c′=c+R1
r ′ = p f p − c ′ \begin{aligned} r'=p_{fp}-c' \end{aligned} r′=pfp−c′
r
^
′
=
r
′
∣
r
′
∣
\begin{aligned} \hat{r}' = \frac{r'}{\mid{r'}\mid} \end{aligned}
r^′=∣r′∣r′
c
′
c'
c′经过新的相机位置,
R
1
R_{1}
R1是随机的平移向量,
r
′
r'
r′是从新的相机位置到之前与焦平面相交的点的新射线,
r
^
′
\hat{r}'
r^′是
r
′
r'
r′的单位向量。
我们将会在每一帧中做这样的操作并且将每次的随机平移平均起来,最终得到的结果就是在焦平面附近的东西将会清晰,距离焦平面远的东西则会模糊。
与焦平面上固定的点相交的射线来模拟
相交检测
现在我们已经有射线了,我们要看看射线是否会与场景中的东西碰到。这个示例中的场景是由咖啡因分子和一滩咖啡组成的。咖啡因分子由24个不同颜色不同大小的球组成,而咖啡池是在x方向以及z方向上无限扩展的棕色平面,场景中还存在一个简单的球形光源。
接下来就要进行检测了,相交检测包括射线与球的检测以及射线与平面的检测。射线与球形物体的相交检测是一件很恶心的事情,这里先跳过。我们先看射线与平面的检测。在这个例子中,射线与平面的检测已经被简化了,因为平面是在xz轴上无限扩展的平面,这里我们简要概括一下平面的相交检测做了些什么。
射线要么与这个咖啡平面相交,要么不相交(要么指向平面要么没有指向平面),只能是两种状态中的一种。用起始点到平面的y轴距离除以射线单位向量的y轴分向量,就可以知道这个射线与咖啡的平面相交的时间。假如咖啡平面与y轴相交于 y c y_c yc,那么射线于咖啡平面相交的时间就是:
t c = y c − c y ′ r ^ y ′ \begin{aligned} {t_c} = \frac{y_c-c'_y}{\hat{r}'_y} \end{aligned} tc=r^y′yc−cy′
t c t_c tc可能是正数,负数,0,或者无限。如果是正数,那么这个值是可以被正常使用的,如果是负数,则说明这个射线没有朝向咖啡平面所以是不会相交的。如果是0,则说明 c ′ c' c′正好在平面上,这样的情况不会发生,因为我们不会把相机放在平面上。而最后的情况有些棘手,如果 t c t_c tc是无穷的话,则说明这条射线与平面是平行的,所以不可能相交。这可能会导致一些奇怪的数学错误并且可能会让像素的渲染错误。幸运的是我们的射线是随机抖动的(用很多种方法),这种情况的发生几率非常之低以至于可以完全忽略。
现在我们知道怎么样计算碰撞的时间了,我们只要遍历场景中的所有东西,并记录下最短的碰撞时间,最短的碰撞时间一位这是射线第一次碰到的东西,这个东西就用来计算像素的颜色。我们需要记录一些碰撞的信息以及碰撞到的物体的信息,包括物体的颜色、粗糙度、物体是否是光源,同时我们还需要计算相交平面的法向量以及相交点。
如果
t
i
t_i
ti是地产一次碰撞的时间,那么我们就可以计算3d的碰撞点
p
i
p_i
pi:
p
i
=
c
′
+
t
i
r
^
′
\begin{aligned} p_i=c'+t_i\hat{r}' \end{aligned}
pi=c′+tir^′
如果碰撞到的东西是球体,并且球的中心点在 s s s,那么在碰撞点的法向量可以这样求得:
n ^ i = p i − s ∣ p i − s ∣ \begin{aligned} \hat{n}_i=\frac{p_i-s}{\mid{p_i-s}\mid} \end{aligned} n^i=∣pi−s∣pi−s
如果碰撞点在咖啡平面上,那么法向量就是简单的(0,1,0),即y轴正方向
最后,我们终于可以继续并将蒙版颜色 m r g b m_{rgb} mrgb与对象的颜色 o r g b o_{rgb} orgb相乘得到最新的颜色结果:
m r g b = m r g b ⋅ o r g b \begin{aligned} m_{rgb} = m_{rgb} \cdot o_{rgb} \end{aligned} mrgb=mrgb⋅orgb
计算光照强度
现在我们有第一个相交点
p
i
p_i
pi,现在我们需要计算打在这个点上的光源。计算光源的函数由很多个变量组成,比如光源的大小,入射角,以及光源范围等
真实的光源是有大小的,他们并不是一个无限小的点,所以真实的光源照出影子是平滑的。请看下面的图片:
从A点的视角看,整个光源都能够被看到。而如果是B,只有一部分光源可以被看到。而在C点则看不到任何的光源,因为光都被遮挡物遮住了。想象一下, 当从A点移动到C点这个过程中,首先你能看到光源的全部,然后少了一部分,并且随着移动慢慢减少,直到所有的光都被挡住,这个时候你就处在完全的阴影当中,这个渐变的过程就产生了平滑的阴影。
另外要说的是, 光源的大小影响着平滑阴影的范围。光源越大,阴影越平滑。如果你想过之前从A点到C点的移动过程,那么就很容易理解,更大的光源,这个从完全看到光源到完全看不到光源的过程就更长。
当然,光源在表面大小也是很重要的,比如太阳,是一个巨大的光源,直径有139万千米,而月亮则只有3476千米,但是这两个天体都有一样的表面大小——即他们在我们的视线中所占的空间几乎是一样的。这也是接下来的事件判断中必须要考虑的一点,因为光源的表面大小越大,光线撞击点的亮度就越高。
换一种更为简单的说法,想象一个画面,一个快照,时间可能是一毫秒,在这一毫秒时间段内光从太阳发出的过程,在最开始大概就是太阳的大小,我们把它想象成一个球型的光壳,然后慢慢向外面扩展变大。当这个光壳渐渐扩大到与地球碰撞的时候,只有一小部分的光壳碰到了地球。现在假如地球距离太阳更近,比如在水星的轨道上,此时光壳碰到地球的表面要远远大于在远的时候,所以光也更亮。因此为了能把光强考虑在内,我们使用更改光源的表面积的方法来控制光照强度。
在这我需要强调一点,上面提到的这些是我们在下面的事件判断中必须要考虑的。如果我们用的是经典的光追,我们不需要考虑光源的大小,因为没有没有偏差的射线射到光源的概率是被光源的大小影响的。但是在我们使用的方式中,阴影的射线是有偏差的,我们需要矫正偏差,就需要把射线射到光源的概率计算进去,而射线射到光源的概率可以通过将光源大小除以整个天空的“大小”来替代。
最后一个细节就是入射角,当光撞到平面上并反射到我们的眼睛里时,进入我们眼睛的光子数量(或者说光强)是取决于光照射到平面的夹角的。这个角度越大,光就越弱。这是因为当角度越大的时候,光照到的表面积越大。
举个例子,想象一下你现在在一个全黑的暗室里,只有一个手电筒是光源。面对着墙靠近,并且把手电筒直接对准照射,记录下此时手电筒在墙上照射到的地方的光强。之后不要进行移动,将手电筒转一个角度,以一个斜角照到墙上。原本一个正圆的照亮的地方就会变成一个不规则的椭圆。就这个手电筒来说,光的数量还是一样的,但是它们分散到了更大的面积上,所以在照亮地方的每一个点的光强都会变小。
等量的光被分布在更大块的面积上,
会导致在照亮的每一个点的光强都会减少
好了,现在我们看看该如何实现这些效果。首先,通过解析方法正确地计算多少光被挡住了是很困难的。所以我们可以使用迭代的方法进行模拟,就像我们在上面一直在用的方法的一样,一点都不用惊讶。我们可以这样做: 当我们要射出阴影的射线时,我们将射线射到光源球面上一个随机的点,当经过多帧的平均之后,我们就可以看出在第一个相交点的位置大致有多少光能被照到。
首先计算一下从阴影处发出的射线的目标点:
p s = l c + l r ⋅ R 2 \begin{aligned} p_s=l_c+l_r \cdot R_2 \end{aligned} ps=lc+lr⋅R2
当 p s p_s ps为光源球上的射线目标点, l c l_c lc是光源的中心, l r l_r lr是光源的半径, R 2 R_2 R2在一个球型面上随机取得的模为1的向量(就是随机单位向量)。
但是这里我需要提醒一下此处并不是精确的做法,最精确的做法应该是从一个2d的圆片上取得随机点,从一个球面上获得随机点会造成一定的误差,但是从结果来看已经可以了,所以目前就先跳过更为复杂的数学计算。
然后我们算一下阴影射线的方向:
r
^
s
=
p
s
−
p
i
∣
p
s
−
p
i
∣
\begin{aligned} \hat{r}_s=\frac{p_s-p_i}{\mid{p_s-p_i}\mid} \end{aligned}
r^s=∣ps−pi∣ps−pi
r ^ s \hat{r}_s r^s为从碰撞点开始到光源球上随机点的方向的单位向量。
有了 r ^ s \hat{r}_s r^s之后,我们是不是就可以从 p i p_i pi点射出阴影射线了呢?原则上来说,是的。但是实际操作起来会有一些问题,因为 p i p_i pi是精确地着陆在物体表面上的点,所以在做相交计算的时候可以确切知道相交的时间肯定是0,但是这不是我们想要的。
解决这一问题的典型方法就是将射线原点稍微偏离物体表面一丢丢的位置。这里我们用
r
^
s
\hat{r}_s
r^s来计算一个新的点
p
i
′
p'_i
pi′,这个新的点离
p
i
p_i
pi有一个非常微小的偏移
ϵ
ϵ
ϵ。
p
s
′
=
p
i
+
ϵ
⋅
r
^
s
\begin{aligned} p'_s=p_i+ϵ⋅\hat{r}_s \end{aligned}
ps′=pi+ϵ⋅r^s
解决了!
好了,现在我们可以由 p i ′ p'_i pi′开始朝着 r ^ s \hat{r}_s r^s方向射出射线,如果第一次相交的东西不是光源,那么就把光照强度 l l l设置为0。
如果第一个碰到的东西是光源,那么还需要更多的东西要做,我们还得知道光源入射角以及光源的大小。
通常用的计算光照强度的办法是使用光线入射角的余弦值。用单位向量进行点乘计算可以快速得得到这个结果,我们可以用下面的的方法快速计算比例因数:
q a i = clamp ( r ^ s ⋅ n ^ i , 0 , 1 ) \begin{aligned} q_{ai}=\text{clamp}(\hat{r}_s·\hat{n}_i,0,1) \end{aligned} qai=clamp(r^s⋅n^i,0,1)
q a i q_{ai} qai是入射角的比例因数,clamp(a,b,c)是一个将a值限定在b到c范围内的函数。我们需要将 q q q限定在0到1之间,因为如果表面的法向量与射线在两个不同的方向上,点积会变成负的,而光的值是不可能为负的!即便有负的光,我也不想处理它。
最后一步就需要计算角直径了,用更为通俗的说法就是光源的大小。我们要计算光源的表观半径(天文学专业词汇,是用来描述在一个给定的观察点,一个球或者圆有多大的度量单位。例如太阳与月亮这两个天体在地球人看来有接近的表观半径),并将其平方所得到的值来表示光源范围大小:
q a s = arcsin ( l r l d ) 2 \begin{aligned} q_{as}=\text{arcsin}(\frac{l_{r}}{l_{d}})^2 \end{aligned} qas=arcsin(ldlr)2
q
a
s
q_{as}
qas代表的是表观大小的比例因数,
l
d
l_{d}
ld是从碰撞点到光源球的中心点的距离。在这还缺了一个用来除以整个天空大小的被除数,在此处可以用用户提供的一个固定光强值来代替(即光源的亮度)。
现在我们可以计算光源强度 l l l 了:
l = q a i ⋅ q a s \begin{aligned} l=q_{ai}·q_{as} \end{aligned} l=qai⋅qas
终于,我们可以在累加器上加上东西了:
a
r
g
b
=
a
r
g
b
+
m
r
g
b
⋅
l
⋅
l
r
g
b
\begin{aligned} a_{rgb}=a_{rgb}+m_{rgb}·l·l_{rgb} \end{aligned}
argb=argb+mrgb⋅l⋅lrgb
a r g b a_{rgb} argb表示累加器, m r g b m_{rgb} mrgb是遮罩层, l r g b l_{rgb} lrgb为光源颜色。
反射光
现在我们快要接近理论部分的末尾了。到目前我们已经射出了一条光线,找到了它的相交点,从相交点射出了一条阴影射线,计算了光源亮度,并且将其加到累加器上。最后一部分拼图就是让第一条射线在场景中不停地反射,并在此过程中持续不断地更新光线累加器。我们会循环经历此过程,直到到达反射的次数上限,或者射向了边界,或者射到了光源(在此处的处理有很多种选择,你可以直接收集光源继续反射,同样也可以反射离开光源或者直接穿过光源)。
在这个咖啡因例子中,当光线在场景中不停地反射时,需要将物体表面的粗糙度计算在内,粗糙度的取值范围从0.0 到1.0,0.0 意味着表面为完全光滑,1.0则表示表面完全散射,粗糙的表面会将光散射,或者说会将光分散到随机的方向上。举个直观的例子:粗糙的表面其实是由非常小的不同方向的微型平面组成的,它们会把光反射到无法预测的方向上,例如粉笔。而完全光滑的平面则会将光反射到完全可以预测的方向上,例如镜子。
现在我们要做的事情变成了如何在粗糙的平面上反射时计算新的射线方向。如果此话题深入将会涉及到非常复杂的学术问题,我们暂时跳过。现在先用我已经成熟的怪方法把这件事简单又漂亮地完成!
首先我们计算一下在完全平滑表面的射线反射:
r ^ s m o o t h = reflect ( r ^ ′ , n ^ i ) \begin{aligned} \hat{r}_{smooth} = \text{reflect}(\hat{r}',\hat{n}_i) \end{aligned} r^smooth=reflect(r^′,n^i)
r
^
s
m
o
o
t
h
\hat{r}_{smooth}
r^smooth 是反射出来的射线,reflect(a,b) 为计算
a
a
a射线在
b
b
b平面的反射线。
挺简单, 下面来处理一下在完全漫反射表面的射线。在这我们可以在平面上放一个一个单位半径的球与平面相切于
p
i
p_i
pi点,选择在这个球面上的一个随机的点,然后朝着这个点发射,首先我们要选择这个随机的点:
p
r
o
u
g
h
=
n
^
i
+
R
3
p_{rough}=\hat{n}_i+R_3
prough=n^i+R3
然后将射线穿过这个点:
r
r
o
u
g
h
=
p
r
o
u
g
h
−
p
i
∣
p
r
o
u
g
h
−
p
i
∣
r_{rough}=\frac{p_{rough}-p_i}{\mid{p_{rough}-p_i}\mid}
rrough=∣prough−pi∣prough−pi
看起来如下:
目前我们已经完成了在
G
=
0.0
G=0.0
G=0.0的全光滑平面下和
G
=
1.0
G=1.0
G=1.0完全粗糙的平面下的情况,对于任何在这两者之间的情况,我们可以直接做一个两者的线性差值:
r
b
o
u
n
c
e
=
mix
(
r
^
s
m
o
o
t
h
,
r
^
r
o
u
g
h
,
G
)
r_{bounce}=\text{mix}(\hat{r}_{smooth},\hat{r}_{rough},G)
rbounce=mix(r^smooth,r^rough,G)
r
^
b
o
u
n
c
e
=
r
^
b
o
u
n
c
e
b
\hat{r}_{bounce}=\frac{\hat{r}_{bounce}}{b}
r^bounce=br^bounce
r
^
b
o
u
n
c
e
\hat{r}_{bounce}
r^bounce为归一化的反射方向,函数 mix(a,b,c) 返回基于 c 值的在 a 与 b 之间的差值。
快完成了,生下来要做的就是计算反射射线的原点,和之前一样,我们可以将在物体表面沿着射线方向移动一小段
ϵ
ϵ
ϵ的距离:
p
i
′
=
p
i
+
ϵ
⋅
r
^
b
o
u
n
c
e
{p'_i}=p_i+ϵ\cdot\hat{r}_{bounce}
pi′=pi+ϵ⋅r^bounce
完成!在接下来的算法迭代中,我们将使用新的射线方向以及射线原点收集更多的光线。
执行
这个咖啡因分子模型例子使用了regl(发音为 regal),是一个非常不错的WebGL库,它并不是以WebGL为基础抽象出来的更少漏洞的库,而是将其变为用起来非常舒服的东西,而且并没有放弃WebGL的原生功能,非常非常好,本人墙裂推荐。
在数学库方面我们在javascript中将会使用gl-matrix,一个为WebGL准备的功能丰富运行良好的线性代数库。
伪随机数
你可能注意到了我们在理论部分中为蒙特卡洛采样所使用的随机数生成。在WebGL1.0中有很多生成伪随机数的常见方法:非常有名的单行代码,或者将一个随机噪声纹理上传到GPU并从中采样。
对于第一种方案我并不完全满意。不管是两种方案的哪一种都差强人意。但是就WebGL1.0伪随机数生成而言这是我见过最好的方案了。WebGL2.0中有进一步改进的更为复杂的方案。
鉴于目前我们使用的是WebGL1.0,我们只要向GPU上传一些噪声纹理用于采样即可。我们需要三张这样的纹理:一张用于生成均匀分布的2D点位向量,一张用于在单位圆上均匀采样出2D向量,一张用于在单位球上采样出3D向量。
乒乓
在实际写代码前还要考虑一个细节:乒乓,回想前面考虑到的理论细节,我们还需要将多帧不同的光线追踪样本进行平均来生成一个有较少噪点的图片,对于WebGL来说最简单的渲染路径是从顶点到光栅化然后显示。这对于我们来说并不是一个好的渲染方案,因为我们需要追踪上一帧渲染的结果,将上一帧的渲染结果叠加到当前帧之后,再除以总共的采样次数来获得最终的结果。
我们在这种情况下要完成的这件事就叫做乒乓。我们会创建两个不同的帧缓冲(可以理解为可以动态绘制的纹理),并且每一帧交替着读取与写入,而不是直接将他绘制并显示出来。每次渲染到写帧缓冲时,会将其同时绘制到屏幕上,然后将存储的数值除以总共的样本数量。
如果还是有些混乱的请看下面的伪代码:
fbos = [new framebuffer(), new framebuffer()];
sampleCount = 0;
fboIndex = 0;
while sceneIsStatic:
fbos[fboIndex] = render() + fbos[1 - fboIndex];
sampleCount++;
display(fbos[fboIndex] / sampleCount);
fboIndex = 1 - fboIndex;
帧缓冲集合 = [new 帧缓冲(), new 帧缓冲()];
采样次数 = 0;
帧缓冲索引 = 0;
while 场景不变:
集合[索引] = 渲染() + 帧缓冲集合[1 - 帧缓冲索引];
采样次数++;
显示(帧缓冲集合[帧缓冲索引] / 采样次数);
帧缓冲索引 = 1 - 帧缓冲索引;
注意在这里只有在 sceneIsStatic
为 ture时才能通过计算结果的平均值作为最终结果(场景中的内容没有发生变动的时候)。当场景发生变动的时候,需要重新开始渲染以及计算,意味着采样次数需要设置为0,并且清理掉所有的帧缓冲。
代码走查
从随机数生成的纹理开始,要生成均匀分布的2D向量,先创建一个Float32Array
并将其填满由Math.random()
的方法生成的随机数。纹理的长宽边均由固定变量randsize
决定。
const dRand2Uniform = new Float32Array(randsize*randsize*2);
for (let i = 0; i < randsize * randsize; i++) {
const r = [Math.random(), Math.random()];
dRand2Uniform[i * 2 + 0] = r[0];
dRand2Uniform[i * 2 + 1] = r[1];
}
然后将其变为一个长宽均为randsize
的WebGL纹理,使用luminance alpha
的纹理格式,这个纹理格式意味着每个每个像素有两个通道,将其定义为float
类型意味着其有完整浮点精度。然后将这个纹理在x轴y轴上重复:
const tRand2Uniform = regl.texture({
width: randsize,
height: randsize,
data: dRand2Uniform,
type: 'float',
format: 'luminance alpha',
wrap: 'repeat',
});
然后再使用类似的方式生成单位圆上的2D向量,不过不同的是,因为其分布是在单位圆上均匀分布,将xy轴的每个维度均匀分布并标准化是不够的。在这我们使用gl-matrix的库中的vec2.random()
方法来创建在单位圆上均匀分布的2D向量
const dRand2Normal = new Float32Array(randsize*randsize*2);
for (let i = 0; i < randsize * randsize; i++) {
const r = vec2.random([]);
dRand2Normal[i * 2 + 0] = r[0];
dRand2Normal[i * 2 + 1] = r[1];
}
转换当前信息为纹理的方法如上:
const tRand2Normal = regl.texture({
width: randsize,
height: randsize,
data: dRand2Normal,
type: 'float',
format: 'luminance alpha',
wrap: 'repeat',
});
最后生成单位球上的3D向量, 同样使用gl-matrix中的方法:
const dRand3Normal = new Float32Array(randsize*randsize*3);
for (let i = 0; i < randsize * randsize; i++) {
const r = vec3.random([]);
dRand3Normal[i * 3 + 0] = r[0];
dRand3Normal[i * 3 + 1] = r[1];
dRand3Normal[i * 3 + 2] = r[2];
}
纹理生成非常类似,不过在此处使用的是rgb
格式,每个像素有三个通道的数据
const tRand3Normal = regl.texture({
width: randsize,
height: randsize,
data: dRand3Normal,
type: 'float',
format: 'rgb',
wrap: 'repeat',
});
接下来创建乒乓帧缓冲
const pingpong = [
regl.framebuffer({
width: canvas.width,
height: canvas.height,
colorType: 'float'
}),
regl.framebuffer({
width: canvas.width,
height: canvas.height,
colorType: 'float'
}),
];
并初始化一下乒乓帧缓冲的索引值以及采样数量的值:
let ping = 0;
let count = 0;
接下来我们创建regl指令来渲染一帧光追结果到帧缓冲。在这里做一下简单的解释,regl指令使用数据信息以及着色器代码来渲染东西到帧缓冲的。我们在此只是创建了指令,调用指令则会放到后面的采样函数中。
const cSample = regl({
我们将顶点以及片元着色器用glslify传进去:
vert: glsl('./glsl/sample.vert'),
frag: glsl('./glsl/sample.frag'),
这是存储在位置属性中的屏幕四边形的坐标:
attributes: {
position: [-1,-1, 1,-1, 1,1, -1,-1, 1,1, -1,1],
},
先让我们把一系列的uniforms信息传入到命令中。当你看到下面内容中出现的regl.prop,只要将其视为稍后调用命令时传入的参数即可。
uniforms: {
投影视图矩阵的逆矩阵:
invpv: regl.prop('invpv'),
相机位置,或者说是“眼睛”:
eye: regl.prop('eye'),
作为采样来源以及放置结果的乒乓帧缓冲:
source: regl.prop('source'),
随机纹理以及其偏移值:
tRand2Uniform: tRand2Uniform,
tRand2Normal: tRand2Normal,
tRand3Normal: tRand3Normal,
randsize: randsize,
rand: regl.prop('rand'),
表示咖啡因分子旋转方向的矩阵:
model: regl.prop('model'),
输出界面的分辨率:
resolution: regl.prop('resolution'),
原子以及咖啡平面的粗糙度:
atom_roughness: regl.prop('atom_roughness'),
coffee_roughness: regl.prop('coffee_roughness'),
光源的半径,强度以及角度:
light_radius: regl.prop('light_radius'),
light_intensity: regl.prop('light_intensity'),
light_angle: regl.prop('light_angle'),
在一次采样中光线反弹次数:
bounces: regl.prop('bounces'),
焦平面位置和焦距:
focal_plane: regl.prop('focal_plane'),
focal_length: regl.prop('focal_length'),
是否使用抗锯齿:
antialias: regl.prop('antialias'),
},
传入结果乒乓帧缓冲:
framebuffer: regl.prop('destination'),
设置视口,关闭深度,以及设置充满屏幕的六个顶点的mesh:
viewport: regl.prop('viewport'),
depth: { enable: false },
count: 6,
});
接下来看下采样方法:
function sample(opts) {
使用gl-matrix中的lookAt
方法以及相机(眼睛)位置和目标位置创建视图矩阵:
const view = mat4.lookAt([], opts.eye, opts.target, [0, 1, 0]);
然后开始构造一个透视矩阵,同样使用gl-matrix,视野角度设置为 p i 3 \frac{pi}{3} 3pi。纵横比一渲染面(canvas)决定。近平面设置为0.1个单位,远平面设置为1000个单位(老实说和这个例子关系不大):
const projection = mat4.perspective(
[],
Math.PI/3,
canvas.width/canvas.height,
0.1,
1000
);
通过将视图矩阵乘以投影矩阵来生成投影视图矩阵:
const pv = mat4.multiply([], projection, view);
将其反转以获得逆投影视图矩阵:
const invpv = mat4.invert([], pv);
目前所有东西都是设置完毕,我们需要调用cCample regl命令。 注意我们要将结果渲染到pinpong[1-ping]
的帧缓冲中,将pingpong[ping]
作为读取的数据源。
cSample({
invpv: invpv,
eye: opts.eye,
resolution: [canvas.width, canvas.height],
rand: [Math.random(), Math.random()],
model: opts.model,
destination: pingpong[1 - ping],
source: pingpong[ping],
atom_roughness: opts.atom_roughness,
coffee_roughness: opts.coffee_roughness,
light_radius: opts.light_radius,
light_intensity: opts.light_intensity,
light_angle: opts.light_angle,
bounces: opts.bounces,
focal_plane: opts.focal_plane,
focal_length: opts.focal_length,
antialias: opts.antialias,
viewport: {x: 0, y: 0, width: canvas.width, height: canvas.height},
});
让count
值递增来记录采样数量:
count++;
并切换乒乓索引值:
ping = 1 - ping;
}
好的,接下来看下cSample regl命令中绑定的glsl着色器程序。首先看下简单的要命的顶点着色器。
第一步需要将所有的浮点数设置为高精度。你可以试试看调整他,但是我发现这是一个安全的默认值。
precision highp float;
接下来我们将引入我们的位置attribute。这些表示全屏四边形的顶点。
attribute vec2 position;
最后写main函数,只做一件事,就是将位置属性填充到gl_Position中。
void main() {
gl_Position = vec4(position, 0, 1);
}
现在是更复杂的片元着色器。首先,再次强调精度:
precision highp float;
然后我们将收集所有uniforms。上面我们已经介绍过这些,所以我在这里不再重复:
uniform sampler2D source, tRand2Uniform, tRand2Normal, tRand3Normal;
uniform mat4 model, invpv;
uniform vec3 eye;
uniform vec2 resolution, rand;
uniform float atom_roughness, coffee_roughness;
uniform float light_radius, light_intensity, light_angle;
uniform float focal_plane, focal_length;
uniform float randsize;
uniform bool antialias;
uniform int bounces;
接下来是我们之前略过的事情。我们如何定义折现需要进行相交检测的球体?有几种方法。您可以将它们作为统一数组或纹理传递,或者您可以将数据直接硬编码到着色器中。因为我们的模型非常简单(只有 24 个原子,4 种不同类型的元素),所以我们将在这里对所有内容进行硬编码。
首先定义一下一些结构体。我们需要一个element
的结构来标记不同的元素类型:
struct element {
float radius;
vec3 color;
};
然后继续用上面的结构体来定义四种在场景中需要用到的元素类型:
const element H = element(0.310, vec3(1.000, 1.000, 1.000));
const element N = element(0.710, vec3(0.188, 0.314, 0.973));
const element C = element(0.730, vec3(0.565, 0.565, 0.565));
const element O = element(0.660, vec3(1.000, 0.051, 0.051));
接下来我们需要一个原子的结构体来保存原子的位置以及原子元素类型信息:
struct atom {
vec3 position;
element element;
};
接下来是所有的原子素组,你可能会奇怪为什么不简单地通过构造函数来填写所需要的数组数据,实际上比较烦人的是在WebGL1.0并不支持这样做。所以需要使用一个函数来填上数据。就是下面的buildMolecule
函数。直接在main函数的开头调用就行了。
atom atoms[24];
void buildMolecule() {
atoms[ 0] = atom(vec3(-3.3804130, -1.1272367, 0.5733036), H);
atoms[ 1] = atom(vec3( 0.9668296, -1.0737425, -0.8198227), N);
atoms[ 2] = atom(vec3( 0.0567293, 0.8527195, 0.3923156), C);
atoms[ 3] = atom(vec3(-1.3751742, -1.0212243, -0.0570552), N);
atoms[ 4] = atom(vec3(-1.2615018, 0.2590713, 0.5234135), C);
atoms[ 5] = atom(vec3(-0.3068337, -1.6836331, -0.7169344), C);
atoms[ 6] = atom(vec3( 1.1394235, 0.1874122, -0.2700900), C);
atoms[ 7] = atom(vec3( 0.5602627, 2.0839095, 0.8251589), N);
atoms[ 8] = atom(vec3(-0.4926797, -2.8180554, -1.2094732), O);
atoms[ 9] = atom(vec3(-2.6328073, -1.7303959, -0.0060953), C);
atoms[10] = atom(vec3(-2.2301338, 0.7988624, 1.0899730), O);
atoms[11] = atom(vec3( 2.5496990, 2.9734977, 0.6229590), H);
atoms[12] = atom(vec3( 2.0527432, -1.7360887, -1.4931279), C);
atoms[13] = atom(vec3(-2.4807715, -2.7269528, 0.4882631), H);
atoms[14] = atom(vec3(-3.0089039, -1.9025254, -1.0498023), H);
atoms[15] = atom(vec3( 2.9176101, -1.8481516, -0.7857866), H);
atoms[16] = atom(vec3( 2.3787863, -1.1211917, -2.3743655), H);
atoms[17] = atom(vec3( 1.7189877, -2.7489920, -1.8439205), H);
atoms[18] = atom(vec3(-0.1518450, 3.0970046, 1.5348347), C);
atoms[19] = atom(vec3( 1.8934096, 2.1181245, 0.4193193), C);
atoms[20] = atom(vec3( 2.2861252, 0.9968439, -0.2440298), N);
atoms[21] = atom(vec3(-0.1687028, 4.0436553, 0.9301094), H);
atoms[22] = atom(vec3( 0.3535322, 3.2979060, 2.5177747), H);
atoms[23] = atom(vec3(-1.2074498, 2.7537592, 1.7203047), H);
}
接下来看看怎么样使用随机噪声纹理。在一帧的渲染中我需要从噪音纹理中采样多次,所以需要一个地方来保存信息,以避免采样到重复的点:
vec2 randState = vec2(0);
WebGL 中的纹理使用 [0,1] 范围内的浮点坐标进行索引.如果需要采样范围之外的点则需要对纹理进行一些配置。这里我将采样设置为重复,如果这样做的话采样(1.1,1.1)的点实际上的值使用的是(0.1,0.1)的位置。
我们将在gl_FragCoord.xy/randsize
的位置上采样纹理。一个在场景上的点对应一个在噪音纹理上的点。任意一个维度上,当gl_FragCoord
超过randsize
的时候则进行重复。要达到这个目的的话就需要递增rand
向量来避免在同一帧里采样到同一个点。然后将randState
加上一个在噪音纹理中采样到的2D向量。这样在下一次调用函数的时候,就可以得到一个不一样的值。最后返回这个随机的2D向量。 这个函数看起来是这样的:
vec2 rand2Uniform() {
vec2 r2 = texture2D(
tRand2Uniform,
gl_FragCoord.xy/randsize + rand.xy + randState
).ba;
randState += r2;
return r2;
}
注意此处提取的二维向量值分别从蓝色和alpha通道提取,所以在里面使用的是.ba
。
接下来,我们将对从单位圆采样的2D向量执行几乎相同的操作:
vec2 rand2Normal() {
vec2 r2 = texture2D(
tRand2Normal,
gl_FragCoord.xy/randsize + rand.xy + randState
).ba;
randState += r2;
return r2;
}
对于3D向量来说也差不多。不过需要将randState
三个值中的两个进行递增。并且在导出的时候需要红绿蓝三个通道(.rgb):
vec3 rand3Normal() {
vec3 r3 = texture2D(
tRand3Normal,
gl_FragCoord.xy/randsize + rand.xy + randState
).rgb;
randState == r3.xy;
return r3;
}
接下来初始化一下光源。设置光源位置,在这里使用的是之前传入的light_angle
的uniform,颜色的话就使用一个固定的黄颜色好了。 然后将环境光设置为舒服一点的偏暗一些的颜色。
vec3 lightPos = vec3(cos(light_angle) * 8.0, 8, sin(light_angle) * 8.0);
const vec3 lightCol = vec3(1,1,0.5) * 6.0;
const vec3 ambient = vec3(0.01);
接下来终于轮到片元着色器中的main函数了。首先调用的就是创建原子数组的buildMolecule
函数
void main() {
buildMolecule();
接下来获取上一帧中的乒乓帧缓冲的源信息。在第一帧的时候是全黑的,因为还没有渲染过任何信息。
vec3 src = texture2D(source, gl_FragCoord.xy/resolution).rgb;
然后需要创建用来做抗锯齿的2D向量。如果抗锯齿标记设置为false的话。所有的值都是0。不然的话使用在[(-0.5,-0.5),(0.5,0.5)]范围内的随机值。
vec2 jitter = vec2(0);
if (antialias) {
jitter = rand2Uniform() - 0.5;
}
接下来对2D向量gl_FragCoord.xy
进行转换。这个值表示的是当前屏幕标准坐标系下所渲染的像素的位置。范围在(-1,-1)到(1,1)之间。在转换像素点之前,我们将使用抗锯齿抖动来偏移像素点。
vec2 px = 2.0 * (gl_FragCoord.xy + jitter)/resolution - 1.0;
然后用逆投影视图矩阵将2D坐标位置转换为3D射线方向。
vec3 ray = vec3(invpv * vec4(px, 1, 1));
ray = normalize(ray);
然后就可以开始创建景深效果了。首先找到眼的射线到焦平面的时间。
float t_fp = (focal_plane - eye.z)/ray.z;
使用这个时间来计算出在空间中相交点的位置:
vec3 p_fp = eye + t_fp * ray;
然后将眼睛的位置在x和y轴上添加随机偏移量。rand2Normal
函数返回的是一个单位向量。长度是一样的,但是为了这里为了返回的偏移量不是正好在一个圆的边上,而是一个圆内部的任意一点的随机值,所以在这里还要把他乘以rand2Uniform
。虽然平均下来看起来有点奇怪。
vec3 pos = eye + focal_length *
vec3(rand2Normal() * rand2Uniform().x, 0);
最后计算新的射线方向:
ray = normalize(p_fp - pos);
接下来初始化累加器和蒙版数值:
vec3 accum = vec3(0);
vec3 mask = vec3(1);
现在就开始做光线反弹的循环了。目前反弹次数是由uniform传入的。WebGL1.0只允许循环一个完全固定的静态量。我们需要以异常方式终止循环 - 我们将迭代到某个相当大的值(在本例中为 20),并在索引达到反弹次数时跳出循环。
for (int i = 0; i < 20; i++) {
if (i > bounces) break;
创建变量存储相交函数的返回值
vec3 norm, color;
float roughness;
bool light;
然后调用相交函数。相交函数在有相交点时返回true
否则返回false
。同时相交函数会获取返回一些out
参数。就是上面定义那些。这里包括了pos,相交的3D位置。norm,相交位置的法线。color,相交位置的颜色。roughness,相交位置的粗糙度。以及light,记录相交的东西是否为光源的布尔值。最开始的两个参数pos和ray分别表示射线的起始点以及方向。
如果不和任何的位置相交的话。将累加器设置为环境光乘以遮罩。并跳出弹跳循环。
if (!intersect(pos, ray, pos, norm, color, roughness, light)) {
accum += ambient * mask;
break;
}
到这的时候我们已经知道撞到了东西。如果是光源的话。就直接增加累加并跳出循环。正如我之前提到的,这是我的选择。你也可以用一些其他逻辑。
if (light) {
accum += lightCol * mask;
break;
}
确认射线撞到了东西,但是又不是光源,那意味着撞到了物体。就可以进行反弹。第一步就是先将遮罩层加上物体表面的颜色。
mask *= color;
接线来就需要创建一个阴影射线来决定这个点有获得了多少的光源。首先创建一些一次性变量,
以便我们可以有针对性地忽略它们。
vec3 _v3;
float _f;
然后需要知道这个涉嫌需要射到哪里去。上面理论部分提到过,我们要向光源球上随机位置发射一根射线。
vec3 lp = lightPos + rand3Normal() * light_radius;
计算从相交位置到光源随机点位置的射线单位向量。
vec3 lray = normalize(lp - pos);
接下来需要进行相交计算。这里我需要提下几个要注意的点。首先是在前面理论部分提到 过的,需要将阴影射线的起始位置要在表面上沿着射线方向偏移一小段距离。然后是我们将 _v3 和 _f 作为一次性变量传递。不会使用其中返回的内容。最后是检测顺序 先把光源填充上,然后再检测是不是光源。 将 &&light
的判定放置在相交检测之后。
if (intersect(pos + lray * 0.0001, lray, _v3, _v3, _v3, _f, light)
&& light) {
如果确实碰到了光源。还需要做另外的事情。首先,我们将通过表面法线和阴影光线之间的点积来计算入射角对光强度的影响。
float d = clamp(dot(norm, lray), 0.0, 1.0);
然后是计算光源球大小对光强度的影响,在这里实际计算的时候需要计算光源占整个天空的比例常数。这里的话就简化一下使用light_intensity
uniform 来替代
d *= pow(asin(light_radius/distance(pos, lightPos)), 2.0);
最后,我们需要往累加器上加上光源颜色,计算出来的亮度,设置的光源强度以及遮罩层。
accum += d * light_intensity * lightCol * mask;
}
此次反弹的最后一步是选择一个新的射线方向。这可以通过混合一个完全反射的射线方向和完全粗糙的随机的射线方向来做到。混合的程度则由表面粗糙度决定。当然别忘了射线的初始位置要向射线方向偏移一点点距离。然后就可以做下一次的循环了!
vec3 mx = mix(reflect(ray, norm);
ray = normalize(mx, norm + rand3Normal(), roughness));
pos = pos + 0.0001 * ray;
}
当跳出反弹循环时。我们给这个像素填上当前累加器里所显示的颜色以及上一帧渲染的颜色,完成!
gl_FragColor = vec4(accum + src, 1.0);
}
额,还没有。还需要完成相交函数。上面以及提到过相交函数的输入参数,就跳过好了
bool intersect(vec3 r0,
vec3 rd,
out vec3 pos,
out vec3 norm,
out vec3 color,
out float roughness,
out bool light) {
这个函数将会便利场景中所有的东西并返回射线第一次相交的东西。这意味着需要记录最少的相交时间,相交时间最小的就是最近的第一次相交的东西。所以在这里需要创建一个tmin
变量记录最小相交时间并将其设置为一个比较大的数值。同时需要一个时间的变量t
。同时还需要记录下是否相交了。这里创建一个hit
变量并初始化为false
来记录是否相交。
float tmin = 1e38, t;
bool hit = false;
接下来需要遍历场景中所有的原子。上面提到过,咖啡因的分子结构包含了24个原子。
for (int i = 0; i < 24; i++) {
现在我们将原子的位置乘以模型的矩阵得到原子的最终位置。模型的矩阵中记录的是用户鼠标操作之后的分子模型旋转信息。
vec3 s = vec3(model * vec4(atoms[i].position, 1));
然后我们就可以开始编辑射线与球体相交的函数。这个函数需要几个输入的变量。r0
是射线的起始位置,rd
是射线方向单位向量,s
球的中心点,球的半径,以及需要返回的时间t
。````raySphereIntersect```函数返回一个布尔值表示是否碰撞到了东西。提一嘴:这里将球体的半径乘以了1.2,会看起来漂亮点。
if (raySphereIntersect(r0, rd, s, atoms[i].element.radius * 1.2, t)) {
如果碰到了东西,需要检查一下相交的时间是否比当前最小的相交时间小。如果更小意味着更近。则需要更新最小时间值并更新相交位置的信息。
if (t < tmin) {
tmin = t;
pos = r0 + rd * t;
norm = normalize(pos - s);
roughness = atom_roughness;
color = atoms[i].element.color;
light = false;
hit = true;
}
}
}
然后是射线与平面的相交。在这里只需要做XZ轴平面上的相交。将相交平面的高度设置在-2的位置。如果碰到了这个平面,就做和球体相交一样的逻辑
t = (-2.0 - r0.y) / rd.y;
if (t < tmin && t > 0.0) {
tmin = t;
pos = r0 + rd * t;
norm = vec3(0,1,0);
color = vec3(1, 0.40, 0.06125) * 0.25;
roughness = coffee_roughness;
light = false;
hit = true;
}
最后一次相交检测是光源的相交检测。光源是一个球体。与上面的情况不一样的只有在最后的时候将out的变量中的light
值设置为true
,意为相交的球体是一个光源。
if (raySphereIntersect(r0, rd, lightPos, light_radius, t)) {
if (t < tmin) {
tmin = t;
pos = r0 + rd * t;
norm = normalize(pos - lightPos);
roughness = 0.0;
color = lightCol;
light = true;
hit = true;
}
}
最后,返回hit
的布尔值。
return hit;
}
另外需要补充一下在此片元着色器中的raySphereIntersect
函数,这个函数用来通用地做射线到球体的相交检测。我把它放在这里,但是来源不提,可以自己找
bool raySphereIntersect(vec3 r0, vec3 rd, vec3 s0, float sr, out float t) {
vec3 s0_r0 = r0 - s0;
float b = 2.0 * dot(rd, s0_r0);
float c = dot(s0_r0, s0_r0) - (sr * sr);
float d = b * b - 4.0 * c;
if (d < 0.0) return false;
t = (-b - sqrt(d))*0.5;
return t >= 0.0;
}
好的,这样的话我们就能够渲染出很多光追的养病并将其平均掉,并放入乒乓帧缓冲中了。当然我们还得把他展现给用户看。这里需要用到regl中的另外的指令,接下来我们看一下是如何做的吧。
这里用到的指令cDisplay
是很简单的。但是要注意的是source
和count
的uniform。source
uniform是我们需要展现的帧缓冲。count
uniform是需要进行光追样本平均的总数。而framebuffer
帧缓冲区传入null
,其作为用户自己的输出窗口。
const cDisplay = regl({
vert: glsl('./glsl/display.vert'),
frag: glsl('./glsl/display.frag'),
attributes: {
position: [-1,-1, 1,-1, 1,1, -1,-1, 1,1, -1,1],
},
uniforms: {
source: regl.prop('source'),
count: regl.prop('count'),
},
framebuffer: regl.prop('destination'),
viewport: regl.prop('viewport'),
depth: { enable: false },
count: 6,
});
接下来是display
函数,只是用来调用cDisplay
指令的。在这注意下source
的uniform,传入的是pingpong[ping]
,如果你还记得前面sample
函数中ping
是怎么更新的话,你就知道是干嘛的了。
function display() {
cDisplay({
destination: null,
source: pingpong[ping],
count: count,
viewport: {x: 0, y: 0, width: canvas.width, height: canvas.height},
});
}
cDisplay
中的顶点着色器很简单。就只是单纯地传入了平面坐标系并与uv
匹配对应。uv坐标的范围是从(0,0)到(1,1)
precision highp float;
attribute vec2 position;
varying vec2 uv;
void main() {
gl_Position = vec4(position, 0, 1);
uv = 0.5 * position + 0.5;
}
cDisplay
中用到的片元着色器同样简单。其作用是从提供的纹理中读取信息,并且将值除以count
,应用伽马矫正,并渲染上去。这我推荐看一下我的精神导师Íñigo Quílez的伽马矫正讲座。
precision highp float;
uniform sampler2D source;
uniform float count;
varying vec2 uv;
void main() {
vec3 src = texture2D(source, uv).rgb/count;
src = pow(src, vec3(1.0/2.2));
gl_FragColor = vec4(src, 1.0);
}
另外还有几个前面基本没提到的函数。reset
函数和resize
函数。场景发生变化的时候需要调用reset
函数,例如像是用户旋转了分子模型,移动了光源,改了抗锯齿等。它会清除掉乒乓帧缓冲并且把采样数量重设为0。
function reset() {
regl.clear({ color: [0,0,0,1], depth: 1, framebuffer: pingpong[0] });
regl.clear({ color: [0,0,0,1], depth: 1, framebuffer: pingpong[1] });
count = 0;
}
resize
函数则会在渲染比(canvas)发生变化的时候调用。其作用是重新配置乒乓缓冲区并调用reset
函数
function resize(resolution) {
canvas.height = canvas.width = resolution;
pingpong[0].resize(canvas.width, canvas.height);
pingpong[1].resize(canvas.width, canvas.height);
reset();
}
本教程差不多已经进入尾声。最后一点是根据用户输入调用采样和显示函数,将所有这些结合在一起。为了简洁已将其简化,具体的可以查看demo仓库里的main.js文件。在我看来除了收敛那部分有点难其他的还算简单。如果用户关闭了converge选项。渲染的每一帧都会调用reset
函数(而非原来的取多个采样的平均值)。这个可以做到让场景不会收敛。我加上了这个设置项是因为说不定你会想看在即时渲染情况下的效果。
function loop() {
if (needResize) {
resize();
}
for (let i = 0; i < samples; i++) {
sample(opts);
}
renderer.display();
if (!converge) renderer.reset();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
按你胃,完成了!我希望能看懂这篇有些长的教程!如果有问题的话可以直接在推特上发消息给我,我的推特是@wwwtyro