GAMES202 REALTIME RAYTRACING

本文最后更新于:6 个月前

GAMES202-REALTIME-RAYTRACING

PTPT基本原理

image-20230525214822829

上图是最简单的RTRT实现全局光照的过程,先解释几个名词:

SPP:Sample Per Pixel,即每个像素采样一个光路的样本

滤波核:待处理像素和周围像素的加权平均等操作的一个范围,也就是一个处理单元

Primary Ray:从camera向屏幕每个pixel投射的一条Ray

Second Ray:从primary hit point经过一次bounce的Ray

流程很简单,首先camera向每个像素投射一条primary ray,hit到某一点之后该点向光源计算shadow ray,这样就完成了直接光照。然后该点向另一方向投射一条second ray,如法炮制,模拟简单的一次bounce间接光照。可以看到1SPP(一个光路样本)每个pixel至少要四根光线计算。

这里有一个技巧。我们在计算primary ray,本质上是查找每个pixel样本对应的worldposition的信息,这种做法和光栅化一样。那么我们就将这个步骤直接用一次光栅化替换掉,1 rasterization + 3 rays。减少了一根光线。

image-20230525220330495

但是很明显,1SPP根本无法满足需求,会带来大量的噪点。因此RT的基本原理很容易掌握,但是RTRT的大量工作都是降噪——Denoising。

Denoiser降噪

时域滤波Temporal

image-20230525224734323

在时域上,我们可以复用上一帧的数据,这种技术叫做temporal。这里引入一个概念:motion vector。由于上一帧和当前帧内的物体位置可能是变化的,因此为了准确查找当前像素在上一帧的位置,我们引入一个位置向量叫做motion vector。这种temporal是基于递归的思想,无形中增加了SPP

image-20230525235608842

image-20230525235632670

如图蓝色的点在当前帧的位置已知,我们如何求得上一帧的位置呢?换句话说我们如何求得蓝色的点对应的世界空间内物体上的点的上一帧的屏幕像素位置呢?按照如下几个步骤进行:

  1. 我们将屏幕空间内像素的世界空间位置缓存下来,可以直接记录在GBUFFER中。OpenGL是这样做的,但是考虑到带宽问题,UE并没有直接在GBUFFER中存储世界空间坐标。因此我们可以结合屏幕坐标和深度信息,使用MVP逆变换计算世界空间坐标。

  2. 由于我们知道camera的变化,也知道物体的变化参数,因此我们很容易将当前世界空间坐标乘以一个转换矩阵计算回上一帧的世界空间坐标。

  3. 将上一帧的世界空间坐标通过MVP变换为平面空间坐标,对应的就是像素的平面空间位置。

image-20230526001358917

对于整体的Denoising而言,我们有当前帧的filter结果和上一帧保留下来的结果,可以对二者进行一个blending,在工业界,复用上一帧的权重相当大,占据了最终结果的80%~90%。

image-20230526005648997

image-20230526005958464

上图是降噪前后的效果,很容易看出降噪后明显亮了很多,这里有一个误区:Denoising绝对没有将场景亮度调高的操作。1spp的效果很暗是因为有很多的噪点亮度实际上是大于255(或1)的,但是非HDR显示器会直接将超出的部分砍掉,破坏了能量守恒,以至于整个场景很暗。

temporal的原理并不复杂,但是在以下场景中会出问题:

  1. 类似于电影的不断切换画面镜头

  2. 第一帧如何渲染

  3. 场景中有个不断变换颜色的闪耀的光球

  4. 镜头倒退往后走,四周不断加入新信息,这些地方的temporal全部失效

  5. 通过motion vector计算出的上一帧的位置被遮挡,会造成残影

解决上述问题可以有一种傻瓜式方法,将上一帧的颜色clamp到本帧该点像素+-周围混合值的一定范围内,再通过上述公式进行blending,可以稍微缓解。

还有一种工业界常用的方法是比较当前帧和上一帧对应位置的物体ID是否相同,这需要引入额外空间存储物体ID。如果不相同就把a调的很低甚至为0。但是这么做本质上是稀释了Denoising的效果。由于每帧都会产生新的噪声,会导致更多的噪点。

image-20230526001955027

同时temporal还会严重影响阴影。在上图物体移动,相机不动的情况下,桌面上的motion vector一直为0,会一直复用上一帧的数据,这会导致动态阴影的投射失效。

image-20230526002052156

对于glossy的平面也会有这种问题。

即:motion vector只能捕捉到几何的变化,对于shading的结果变化感知不到,会带来很大的问题。

空域滤波(Spatial Filter)

高斯滤波

image-20230526135648360

对于低频空域滤波,最经典的方式就是高斯。高斯函数实际上就是一种正态分布,我们可以将待滤波的像素点作为高斯零点,计算周围像素权重时根据距离去从高斯函数上获取权重值,将每个像素加权后的color和各自的权重累加下来,最后做除法就可以得到output了。这本质上是一种归一化方法,使得我们引入的高斯函数可以随意调整,无需保证积分或累计权重为1。

image-20230526134631037

有了以上归一化的计算方法,我们可以引入各种各样的指数、cos函数等来替换高斯函数,合理即可。

双边滤波(Bilateral filtering)

image-20230526140601219

上图就是高斯滤波的结果,可以看到只能保存一些低频信息,对于任务边缘等高频信息几乎完全舍弃掉了,这时候我们需要引入一种新的滤波方式:双边滤波(Bilateral filtering)

\[ w(i, j, k, l)=\exp \left(-\frac{(i-k)^2+(j-l)^2}{2 \sigma_d^2}-\frac{\|I(i, j)-I(k, l)\|^2}{2 \sigma_r^2}\right) \] 我们可以通过color的变化去判断物体边缘,识别高频信息。双边滤波本质上就是在高斯滤波的基础上引入了颜色变化来判断边界。上述公式是双边滤波的一种2D滤波核拆分形式,(i,j)和(k,l)是两个像素点坐标,exp内第一项就是根据距离去做的高斯滤波,第二项是两点的color差值。对于多参数滤波而言,基本算法和这个公式是一样的,都是exp指数函数相乘的形式,幂的负的,分子是考虑的参数,分母是可以认为设定的权重。

联合双边滤波(Cross/Joint Bilateral filtering)

image-20230526143114289

双边滤波的结果,可以看到效果已经很好了,边界依旧锐利。但是对于满是噪点的原图来说,噪点的color变化也很大,普通的双边滤波按照color判断的方法会受到噪点的影响。高斯滤波只考虑了位置因素,双边滤波在高斯的基础上额外考虑了color,那么能否引入更多的因素呢?联合双边滤波(Cross/Joint Bilateral filtering)就这这么做的。

image-20230526140241962

denoising是在屏幕范围内的,我们想到basepass里输出的gbuffer也是屏幕空间的信息,且是高精度无噪点的,因此可以利用gbuffer内的信息。

img

上图中我们考虑gbuffer信息。对于点A和B来讲,二者的depth相差很大;对于点B和C来讲,二者法线差异很大;对于点D和E来讲,二者color差异很大。引入gbuffer这些因素的本质实际上就是考虑了世界空间范围内的几何信息,脱离了纯屏幕空间的范畴,结果自然是更加准确的。

但是Filter中的许多步骤都依赖于一个没有噪点的GBuffer。这种情况对于想要通过光线追踪模拟景深或者动态模糊等Stochastic效果的渲染来说,就无法满足了。

优化

image-20230526141659896

对于降噪的卷积核大小选择会很大程度上影响效率。因此有了一些方法来提高降噪的效率。对于高斯滤波,我们注意到对于平面像素的处理是二维的,而二维两个方向上是无关的,是可分离卷积核。那么对于原本N*N大小的核,我们可以先在X方向上做一次1*N的计算,在Y方向做一次N*1的计算,直接把时间复杂度从N*N降低到了2N。

image-20230526145244699

为什么高斯滤波就可以这样做呢?因为数学上2D高斯的定义本质上是可分离的核,就是两个1D的乘积,在二维积分中可以独立拆分为X、Y项,先X后Y就可以了。对于双边滤波和联合双边滤波等,是很多个高斯相乘的形式,理论上是不可以分离的,但是对于很小的核,工业界强行进行分离得到的效果也不错。

image-20230526154249007

Progressively growing sizes:对于很大的卷积核,我们可以采用多pass拟合的方式进行计算(空洞卷积?)。如上图,我们定义最小的卷积核是5*5的大小。第一个pass我们正常做filter,第二个pass我们还是找5个pixel,但是这五个pixel不是相邻的,之间的距离(interval)是1(21-1),第三个pass我们找间距为3(22-1)的pixel……以此类推,每个pass找到的pixel间距为(2n-1),实际上是复用了上一个pass的结果进行叠加。这样对于原本64*64的很大的核而言,我们拆分成了5*5的小核做五次,复杂度从64*64减少到了5^2*5。证明没看懂……就不贴了

image-20230526150651040

对于一些场景来说,由于光源很亮,或者tracing样本不足,会有一些特别亮的点(Outlier)。例如地面上的亮点,可能是由于从该点进行tracing的光线正好打到了光源上,导致拿到光源的亮度很强。普通的滤波只会把这个点稍微扩散模糊一下,处理的不好,那么如何找到上图这些很亮的点并降噪呢?

image-20230526151546870

对于每个降噪核(通常是5*5或者7*7),我们计算这些pixel的均值(mean)和方差(variance),然后限定一个上图的范围,如果某个pixel的颜色超过了这个范围就认为它是outlier的,强行clamp到这个范围内。

image-20230526155649862

这种做法同样可以用于时域滤波中,将上一帧的colorclamp到本帧计算出来的范围内,道理一样。

扩展: SVGF(Spatiotemporal Variance-Guided Filter)

SVGF处理极低样本数量的核心思想就是结合时间和空间上的信息一起做Filter。因为每一帧每个像素都只有一个着色样本,我们只能把每个像素的样本在时间上均匀分布开来,并且尝试在将过去帧的信息用在当前帧的Filter中,类似现在流行的Temporal Anti-Aliasing。同时,过去数帧的信息还可以提供一个当前像素区域样本分布的Variance(方差)大小的估计,这个Variance的信息则会影响空间上Filter。

算法流程介绍

image-20230526161338379

算法首先将像素分解为直接光照(direct lighting)和间接光照(indirect lighting)两部分,除以abedo之后得到irradiance,拿这两个irradiance值去分别做filter,结果加起来再乘回abedo。这样做的好处是将abedo和filter解耦,无论你filter的再厉害也不会影响到abedo的细节。最后又加了一步Temporal AA。

Reconstruction Filter

Temporal

对于temporal filter来说,前后两帧的混合方式还是按照经典的Exponential Moving Average计算: \[ C_i=\alpha C_i+(1-\alpha) C_{i-1} \] 为了尽可能多的囊括历史帧的所有有用信息,并没有设置color clamp将最终的filter限制到一个范围内来防止鬼影,这要求我们保证用来filter的color尽可能的准确,因此引入位置、法线、物体ID、深度等信息来混合计算权重。

Variance Estimation(方差估计)

方差可以从时间和空间两个方向进行计算。

在样本数量足够时,时间上累积方差是可行的。直接通过下面的公式做当前帧和上一帧的均值差值即可: \[ \sigma_i^{\prime 2}=\mu_{2 i}^{\prime}-\mu_{1 i}^{\prime 2} \] 但是并不是每个像素都有足够的时间样本。例如被运动的物体遮挡,突然出现或者消失等情况。对于历史帧样本数量小于4的情况,采用Cross Bilateral filtering去在一个7*7的核内在空间上去计算方差。我的理解是利用核均值平方减去当前像素的均值平方来计算方差?

空间滤波(Spatial Filter)

对于Spatial Filter我们选择了使用Edge-Avoiding À-Trous Wavelet Transform for fast Global Illumination Filtering这篇文章中的方法,基本上可以把他当成是一个对Cross Bilateral Filter的在小波分解频域上的空间快速近似。具体的理论和实现的伪代码可以直接戳上面论文的链接。

类似Cross Bilateral Filter, A-Trous Wavelet也需要提供一个Edge Stopping函数去避免把边缘给模糊掉。我们采用的Edge Stopping函数除了常用的屏幕空间深度以及像素法线意外,还采用了之前估计的像素的方差大小。

直观的理解就是,对于方差,也就是噪点很小的区域,例如完全在阴影里的区域,我们则在减小在空间上Filter的力度。具体的Edge Stopping权重的计算可以参考paper的4.4章节。

总结来说,整个filter的流程如下图:

image-20230526155017263

基本流程就是,现在时间上用Exponential Moving Average积累像素的Irrdiance颜色信息用来增加每像素在1SPP情况下的有效样本数量,同时在时间上积累Luminance的First & Second Moment去估计每像素的方差大小。方差大小又作为后续Spatial Filter中Edge Stopping函数的输入去更好的指引Spatial Filter在不同噪点区域的力度。

SVGF实际上就是GAMES202提到的滤波技术的综合体,创新点是将irradiance和abedo分离,并且更多的考虑variance所带来的权重影响,例如边缘检测等。

扩展: A-SVGF

传统的SVGF会带来两个artifact:

ghosting:目前的Temporal方式还是无法完全避免鬼影问题,会导致上一帧的边缘数据出现在当前帧

image-20230527124357780

lag:当场景中的光源关闭时,SVGF无法快速收敛,甚至会有之前的光源信息一直残留,这是由于无法准确调整指数混合系数α导致的,说白了还是上文提到过的传统Denoising能从gbuffer中捕捉到几何信息就很不错了,对于shading的变化很不敏感

A-SVGF 滤波器能够可靠地检测采样信号的突然变化,自适应地计算时序积累因子 α,从而删除过时的历史信息。

A-SVGF原理

时间梯度计算

时间梯度本质上是当前帧像素和上一帧该像素对应位置的差。类似于TAA,我们很自然的就想到了时域重投影。重投影分为两种:正向投影和反向投影。我们定义第 i 帧的第 j 个像素的表面采样表示为 Gi,j。

前向投影就是把先前帧的样本投影到当前帧: \[ \overrightarrow{G}_{i-1, j} \] 反向投影就是把当前帧的样本投影到先前帧的位置: \[ \overleftarrow{G}_{i, j} \] 定义f为像素着色函数,则前向投影和反向投影计算时间梯度可以分别表示为: \[ f_i\left(\vec{G}_{i-1, j}\right)-f_{i-1}\left(G_{i-1, j}\right) \text { or } f_i\left(G_{i, j}\right)-f_{i-1}\left(\overleftarrow{G}_{i, j}\right) \] 在之前的文章我们论述过,由于反向投影需要计算在t-1上的投影范围,需要上一帧保存更多的数据,正向投影只保存上一帧color就完事了,因此我们采用正向投影的方式。最终计算时间梯度的公式如下: \[ \delta_{i, \vec{j}}=f_i\left(\vec{G}_{i-1, j}\right)-f_{i-1}\left(G_{i-1, j}\right) \] 直接用上一帧的像素投影样本计算梯度是最简单的方式,但是由于投影的方式是以像素为坐标的,会引起子像素的偏移。换成人话说就是像素颜色实际上是中心点的位置颜色,本帧的像素位置根据两次MVP变换转换成上一帧的位置可能不在对应像素的中心点,但是复用上一帧color读取的是上一帧像素中心点的颜色,这样会导致一些梯度值很高,可以通过做一次双线性差值减缓。

随机采样

每个像素都计算一个梯度太费了,因此我们每3*3个像素为一个cell,一个cell计算一个梯度。为了获得更准确的随机梯度,减小方差,我们引入了随机数采样。为了更少的噪声(使用大的协方差来抵消方差),我们每帧复用随机数种子。

我们假设着色函数不止依赖于当前表面采样,还依赖于一个随机数 ξi,j: \[ \delta_{i, \vec{j}}=f_i\left(\vec{G}_{i-1, j}, \xi_{i, \vec{j}}\right)-f_{i-1}\left(G_{i-1, j}, \xi_{i-1, j}\right) \] 因此我们可以得到时序梯度的方差: \[ \begin{array}{r} \operatorname{Var}\left(\delta_{i, \vec{j}}\right)=\operatorname{Var}\left(f_i\left(\vec{G}_{i-1, j}, \xi_{i, \vec{j}}\right)\right)+\operatorname{Var}\left(f_{i-1}\left(G_{i-1, j}, \xi_{i-1, j}\right)\right) \\ -2 \cdot \operatorname{Cov}\left(f_i\left(\vec{G}_{i-1, j}, \xi_{i, \vec{j}}\right), f_{i-1}\left(G_{i-1, j}, \xi_{i-1, j}\right)\right) \end{array} \] 每帧复用随机数种子,我们保证: \[ \xi_{i, \vec{j}}:=\xi_{i-1, j} \]

构建梯度样本

image-20230527160434063

分开的表面和着色采样子集被重投影(a,b,e)。然后重投影的表面样本合并到新的可见性缓冲区(c,d)中。将重新投影的着色样本与新着色样本相结合产生梯度样本(f,g)。每 3x3 层最多有一个梯度样品。重建步骤将这些散射和噪声样本转换为稠密去噪梯度图像(h)。

在每一帧中,首先渲染一个新的可见性 buffer,产生新的随机数 seed(上图 c)。我们只重新利用我们的着色预算的一部分以稀疏地评估梯度样本,而不是提供每像素的时间梯度样本。我们每层定义3x3像素,从先前帧中随机选择一个像素j,根据分层抽样,我们用aliasing来权衡时序不相干噪声(上图a)。

然后我们使用前向投影,计算它们在当前帧的屏幕位置(上图b),和TAA一样,我们根据深度buffer来丢弃那些在当前帧被遮住的重投影样本。其他的表面样本的seed倍融合到新的可见性buffer的合适像素位置里(上图d)。每层里(3x3像素),我们只允许不超过1个梯度样本,然而,重投影可能会映射多个样本到一个层里,因此我们只融合那个首先被计算重投影到该层的样本到可见性buffer里。我们从先前帧重投影着色样本,与前面方法相同,不使用插值(上图e)。当前帧的着色函数计算重投影以后的样本的着色值(上图f)。然后通过减法操作就能得到梯度样本(上图g)。

表面样本重投影然后得到的着色样本对于新的一帧来说都是可用的着色样本。它们都是在一个像素内采样得到的可见性表面,只不过采样位置不在像素中心。因此,我们的策略不会在帧缓冲区中引入需要填充的间隙。然而,由新的表面样本和随机数产生的着色样本是优选的,如果重复使用随机数,则时间滤波器获得的新信息较少。此外,如果随机数中的某些是前一帧的残余,则它们的低差异性会减弱。层的尺寸为2x2时这些问题相当明显,但在3x3时,重投影样品的比例足够小。虽然着色采样是密集的,但是梯度采样是稀疏的。通过构造,我们每个层最多有一个样本,但由于重投影和深度遮挡关系,可能样本之间有间隙。

重建时序梯度

由于梯度分布很稀疏,并且有很多cell没有填充,因此它是不连续有噪声的。我们首先将那些没有梯度的cell的梯度值设置为0,然后采用和SVGF一样的迭代重建。svgf中对luminance的重建是这样的:

每个层的照明估计被初始化为该层全部样本的 luminance 的平均值,记为\[\hat{l}^{(0)}\],初始方差估计\[\operatorname{Var}\left(\hat{l}^{(0)}\right)\] 是一个层内的方差。我们令迭代次数 k ∈ 0, 1, 2, 3, 4,h为像素值,w为权重,重建后的亮度即为: \[ \hat{l}^{(k+1)}(p)=\frac{\sum_{q \in \Omega} h^{(k)}(p, q) w^{(k)}(p, q) \hat{l}^{(k)}(q)}{\sum_{q \in \Omega} h^{(k)}(p, q) w^{(k)}(p, q)} \] 类推到梯度重建,我们有: \[ \hat{\delta}^{(k+1)}(p)=\frac{\sum_{q \in \Omega} h^{(k)}(p, q) w^{(k)}(p, q) \hat{\delta}^{(k)}(q)}{\sum_{q \in \Omega} h^{(k)}(p, q) w^{(k)}(p, q)} \]

用梯度来控制时序累积因子α

我们已经有了重建好的时序梯度,现在我们要控制时序滤波的因子了,首先加入标准化因子,意义就是找到当前帧和历史帧的最大值作为标准: \[ \Delta_{i, \vec{j}}=\max \left(f_i\left(\vec{G}_{i-1, j}, \xi_{i-1, j}\right), f_{i-1}\left(G_{i-1, j}, \xi_{i-1, j}\right)\right) \] 因为我们已经使用了联合双边滤波构建出了每个点的梯度,并计算出了每个点的 \[\hat{\Delta}_i(p)\],我们定义密度和标准化历史权重: \[ \lambda(p):=\min \left(1, \frac{\left|\hat{\delta}_i(p)\right|}{\hat{\Delta}_i(p)}\right) \] 上式的意义在于让 λ 小于等于 1。之后我们定义自适应时序积累因子为: \[ \alpha_i(p):=(1-\lambda(p)) \cdot \alpha+\lambda(p) \]

参考文章:

Spatiotemporal Variance-Guided Filter, 向实时光线追踪迈进

A-SVGF-自适应时空方差去噪滤波器