代出自蓟北门行
[南北朝][鲍照]
羽檄起边亭,烽火入咸阳。
征师屯广武,分兵救朔方。
严秋筋竿劲,虏阵精且强。
天子按剑怒,使者遥相望。
雁行缘石径,鱼贯度飞梁。
箫鼓流汉思,旌甲被胡霜。
疾风冲塞起,沙砾自飘扬。
马毛缩如蝟,角弓不可张。
时危见臣节,世乱识忠良。
投躯报明主,身死为国殇。
今天给各位分享的是寒霜引擎在Siggraph 2015上的Stochastic Screen-Space Reflections算法实现,这里是原文链接
本文主要内容框架给出如上,先来看下Motivation
镜之边缘概念原画中存在大量反射性的对象,且不同的对象其反射特性还不一样,有的光滑,有的模糊。
这里是寒霜会议室的一张真实照片,这张图片中基本上很少看到非常锐利的反射效果,反而户外光照的阴影效果倒是占据着较大的比例,这个结果阐释了SSR一个此前少有人关注的特性,即过滤局部以及全局反射的specular信息。此外,在这个场景中存在很多非光滑平面的反射,尤其是在反射系数变化程度较高的地表上。另外,注意看椅子的脚与地面接触的区域,还可以发现,越是接近反射平面与被反射物体的接触点,反射效果越是清晰锐利。
赛车游戏中可能会经常见到类似上述图片的场景效果,在这个效果中可以看到反射效果在垂直方向上存在着拉伸,而且在反射平面上也存在较高频率的粗糙度与法线的变化
因此,寒霜引擎这边针对SSR提出了以下一些效果预期:
1.能够支持清晰与模糊反射效果
2.在接触区域应该有比较清晰的反射表现
3.为了提升真实感,应该同样需要纵向的拉伸效果,当然,如果使用的是microfacet BRDF的话,光照结果其实就已经将这个包含在内了
4.希望能够支持逐像素的粗糙度与法线反射调整
先看看前人工作,这里列举的都是寒霜认为可能会对他们实现前面预期有帮助的一些工作
先看一下标准的SSR实现方法,这种方法只能应用在镜面反射中:
1.先计算出反射平面上某个反射点对应的反射向量
2.根据上面的反射向量对depth buffer进行ray marching
3.Ray marching中遇到的第一个交点就是被反射点,读取此点的颜色数据(这里读取的数据有很多来源,比较常见的是读取上一帧的color buffer数据)
杀戮地带(Killzone Shadow Fall)用了一种不一样的实现光滑SSR的方法,跟Image Space Gathering类似,他们会先生成一张与屏幕尺寸一致的清晰的反射结果贴图(使得每个相机视线方向对应的反射点计算得到的uv坐标都能在这个贴图中找到对应的color),之后会为这张清晰的反射贴图生成一个blur pyramid,之后根据像素的粗糙度选择合适的mip level,通过这种方式可以保证采样结果不会出现明显的不连贯,不过这里想要建立起粗糙度跟模糊程度之间的关联会比较困难。
此外,这种方法在处理normal map的时候也会存在问题,比如上图所示,由于模糊的时候只注重粗糙度没有考虑不同点的法线方向不同,使得采样的结果只覆盖了其中某一点的法线而非整个区域多个法线方向采样结果的平均值,使得采样结果存在偏差。
另一个缺陷在于交界处的反射也没有做到很清晰
前人工作中能够满足寒霜前面剔除的所有需求的一个方法是Importance sampling,这里对于反射平面粗糙度的表示不是通过模糊来完成的,而是通过对反射方向进行随机采样来直接生成模糊结果。如图所示,对于一个粗糙的表面,其反射的方向将会更加的分散,而对于一个光滑的表面,其反射的方向就会聚集的多。如果觉得结果依然不够平滑的话,还可以预先对color buffer进行一次blur操作。
这个方法的问题,在于想要得到较好的效果,就需要进行较多次数的采样,从而对性能存在很大的考验。
寒霜给出的方法结合了filtered importance sampling跟杀戮地带的reflection方法,既使用随机采样方向,又使用了屏幕空间的模糊处理。
其中在Importance sampling处理的时候,发射的射线数目非常少,甚至低到每个像素只射出一条线
不过跟importance sampling算法不太一样的地方在于,这个地方并不直接返回被反射点的颜色数据,而是将被反射点直接存储下来,这样可以方便后面进行resolve pass的时候根据被反射点的数据对相邻像素进行采样(即所谓的ray reuse,实际上就是将相邻像素沿着其对应的法线方向ray marching得到的hit点作为当前点的另一条射线ray marching的结果,通过这样的数据重用降低ray marching消耗,实际上是借用了bilinear filtering的概念)。
同样,在resolve pass中还会根据粗糙度来设定出射线为中心的椎体的张角,之后考虑被反射点到反射点的距离,计算出被反射点在椎体对应位置的半径,并根据这个数值来计算mip level做模糊处理,通过这种方式可以有效避免前面所说的blur pyramid对于法线数据的污染,同时还能够在交界处得到较为清晰的反射结果。
下面看下效果展示
下面介绍一下详细的实现细节,主要分成如下几个步骤:
1.Tile classification,考虑解决怎么识别屏幕中需要SSR的区域,并将之划分成不同的tile
2.Ray allocation,评估每个tile需要的采样精度,并根据所需精度自适应的分配各个反射像素所需要的出射光线数目,
3.ray tracing,这里有两种方案,对应于不同的计算精度
4.Color resolve,通过前面所说的存储被反射点数据的ray reuse方案来求得反射贴图
5.Temporal filter,利用前一帧的数据来提升反射输出结果的质量
下面对上述事实步骤进行详细解析。
寒霜将整个屏幕空间分成多个方形区域,每个区域称之为一个tile。划分完成之后,就会对每个tile进行评估,计算出这个tile反射所需要的rays count。
这个过程具体是怎么实现的呢:
首先,在项目中,会让用户设定每个像素需要出射射线数目的min/max值,之后计算的结果会按照min/max进行缩放。
其次,在评估中,会对每个tile先试探性的进行一些ray tracing计算,之后根据计算的结构来评估当前数目的射线所得到的反射点周边的颜色复杂程度(可以通过图像的对比度来表示)。
经过上述计算后,可以考虑为那些噪声多(颜色复杂度高)的tile分配更多的tracing ray,此外,在这个过程中还可以通过进一步的检测所射出的射线是否与场景中的物体相碰撞来决定是否需要基本放弃对tile中像素使用ray marching。
当知道哪些tile需要进行SSR计算之后,就可以通过一个间接的计算来确定哪些情况需要精确ray tracing,哪些情况需要模糊ray tracing。对于镜面等较为光滑的反射平面而言,通常需要比较清晰的反射结果,此时会选择使用精确的HiZ tracing方法,这种方法可以给出反射射线的精确交点;对于那些粗糙度比较高的反射平面而言,通常会用性能比较好的但是反射结果比较粗糙的线性ray tracing方法,这种方式得到的反射结果通常会需要经过模糊处理来掩盖数据的瑕疵。
这里来简单回顾一下HiZ的基本框架,其基本思路是非常简单的,就是根据场景的depth buffer,按照min-Z的方式构造一个四叉树,每个父节点的深度值都是其所有子节点中深度值最小的,其结果可以看成是一个pyramid。
先从pyramid中最下面的一层,也就是四叉树中的叶子结点层开始marching,实际上不论当前是在对哪一层进行marching,其处理方式都是相同的,即沿着射线的方向朝着当前节点的边缘走,判定射线与当前节点是否相交,如果没有相交,就进入更上一层(相当于加大ray marching step length)进行继续比较处理
通过这种方式,可以快速跳过marching过程中的空白区域
如果在当前节点的marching过程中检测到相交,那么就进入到此节点的四个子节点的marching流程(相当于缩小ray marching step length),如此循环往复
直到当前碰撞的level已经不可继续细分未知,此时碰撞的节点就是最接近于镜面的最精细的碰撞点了。
蒙特卡罗算法通过离散的期望求取方式来计算函数的积分,比如需要对f(x)进行积分,其实就相当于对g(x) = f(x)/p(x)求取期望(也是一个积分,积分项为g(x)*p(x)),期望值可以通过离散的累加来模拟,从而将积分转换为累加。要想求得较为精确的积分结果,就需要大量的采样数据。
Importance sampling其实是蒙特卡罗积分算法的一种,相对于蒙特卡罗算法,Importance sampling使用的样本数要少得多,其方差相对也要小得多。
这里针对BRDF使用Importance sampling,来生成反射采样方向,理论上来说,任何的BRDF都是可以的,寒霜使用的是GGX。
因为Ray-tracing的成本是非常高的,因此想要做到实时就不能生成过多的反射射线。寒霜这里通过采用Halton sampler方法可以做到在尽可能少的射线数目的前提下获得最高的采样精度,在这个方法中,部分参数数值可以提前在CPU中计算好。
经典的BRDF Importance sampling方法是通过生成多个半角向量(half-angle vectors,即视角方向ViewDir与光照方向LightDir的中间向量),之后按照反射点的microfacet normal计算对应的反射向量。这里有一个问题,那就是生成的半角向量中,有可能会存在射向反射平面之下的向量,而这些向量显然是不符合反射计算需求的,因此在这里会进行一次过滤,滤除那些异常向量,经过实际验证,这个滤除的操作对性能的影响基本可以忽略。
此外,还可以如Eric Heitz & Eugene D’Eon指出的那样,对可见法线的分布情况进行importance sampling,使用这种方法得到的结果具有更少的噪声(目前寒霜也还没有尝试,或者尝试过暂时没成功)
生成了多条用于ray tracing的射线之后,就进入color计算阶段,最简单的方法就是根据射线与depth buffer的交点直接读取color buffer中的结果,不过这种方式得到的color会有比较高的噪音,想要消除噪音就需要更多的ray tracing,性价比过低。不过,实际上大部分时候,反射表面在各个位置的属性是比较接近的,且周边位置的点反射后的可见性也基本上是一样的,因此可以假装相邻像素的射线也是从当前像素发出,且交点跟从相邻像素发出的交点一致(跟直接取用color有什么区别,没看出存储交点跟存储color的区别)。
对于相邻射线与Depth buffer上的交点,并不能直接拿来重用,为了保证得到正确的结果,所有射线对于反射color的贡献值都需要经由当前像素的BRDF加权处理,并需要除以对应的PDF。
结果发现,对这个算法的结果预期过于乐观,事实证明,相邻射线的PDF可能跟当前像素的BRDF数值存在较大差异,从而导致某些地方的f/p变得很尖锐。
这里是一个示例场景,场景中反射平面上各个位置的粗糙度跟法线数据是不一样的。
这是只使用一条射线,读取交点对应位置的color的作为反射颜色的结果,其中反射color buffer的分辨率为屏幕分辨率的1/2.
这里给出的是在resolve pass使用四条射线(一条精确结果,其余三条是通过ray reuse的方式得到的)获取color的结果,可以看到,部分位置的反射结果确实变得光滑了,但是地表上的大部分区域的出现了更为明显的噪音,总体来说,其结果还不如一条射线计算的结果。
先来回顾一下发生了什么,首先我们需要计算的是这么一个积分,这是一个常见的散射光强积分公式,其意义为反射输出数值等于入射辐射照度经过BRDF加权后的半球积分。
将之转换成蒙特卡罗公式形式,其中BRDF,cos项跟分母汇总的PDF项是整个计算的变量Variance。对所有的样本进行累加之后,由于前面提到的相邻像素采样时Variance分子分母之间的巨大差异,导致此时计算得到的Variance要么过大要么过小,从而使得最终的结果过亮或者过暗。
怎么消除这个问题呢,寒霜给出的解决方案是在分母跟分子乘上同一个值,这个值就是BRDF*cos之后的积分,到这一步为之,整个变换过程还依然是等价的。
之后,将分子所乘的积分项用一个离线计算的常量数值来代替,这种替代方式是IBL积分公式中常用的方法,因此这个常量值在各位的引擎中可能已经存在了(详情参见Brian Karis在2013 PBR course上的演讲内容[Karis13])
之后,继续使用蒙特卡罗算法对剩下的公式进行计算。
这里需要注意,BRDF数值在最后需要进行归一化处理。
这里给出伪代码,实现过程非常简单。
这是没有进行特殊处理之前的单射线结果
没有进行特殊处理前的多射线结果
这是进行了特殊处理后的结果,看起来好多了。
作为对比这是直接使用4条射线进行精确反射后的结果,虽然由于更均匀的噪声分布使得此结果看起来更好一点,但是方差表现是差不多的。
将单射线无特殊处理与单射线+特殊处理的对比,结果确实变得好多了。
这里给出四条射线,每条射线对应四个resolve sample,因此总共就是16个sample的求和积分了,其结果看起来已经不错了,不过这个代价依然有点高昂了。
这里依然使用1条射线四个resolve sample,不过加上了temporal filtering,其结果已经可以跟前面给出的四条射线四个resolve sample的结果相媲美了。
对BRDF权重进行重新归一化还有一个作用,在进行ray tracing的时候,有时候会碰到相邻像素的ray追踪不到任何有效物体(即反射方向最终的归宿是天空),这种情况使得稀疏ray tracing称为可能。稀疏ray tracing表示,不必为所有的像素都生成其对应的反射射线,而只需要在resolve pass的时候解析出全分辨率的输出结果即可。(有点绕口,看看后面能不能解惑)
这里给出的前面的输出结果,是按照half-res trace + half-res resolve的方式实现的。
这里给出的是half-res trace + full-res resolve的输出结果,因为应用了full-res的temporal filtering,使得噪声变得更轻微。
先来解释一下temporal reprojection的含义:给定某个像素的3D世界坐标,通过一定的手段(比如通过velocity buffer)可以得到此像素在上一帧中的世界坐标,并通过计算可以转换为上一帧中屏幕空间的uv坐标。(拿到有什么用呢,这一帧能够拿到的颜色数据跟上一帧拿到的颜色数据应该是一样的,对于输出的结果貌似并没有什么帮助?)
不过,如果上一帧跟当前帧的viewport发生了变化的时候,就会有点问题,这种时候反射的结果会存在视差(为什么?)。这是因为被反射的物体移动的时候是沿着相机空间对应的深度方向进行的,而非沿着反射平面的深度进行的(每太看懂,意思是深度变化方向),在这种时候,使用最近depth来计算反射的话,得到的结果会对最终color产生污染,寒霜这边先计算被反射物体的平均深度,之后通过这个深度来进行reproject,这样得到的结果就好多了。
寒霜使用的Importance sampling跟标准的方法有所不同,想要消除所有的噪点,只能通过大量的采样,因为其中很大一部分噪点来源于BRDF的尾部数据,尤其是GGX,因此这里会将生成的采样点朝着镜面的方向做一个偏移处理(即将尾部截掉,之后进行归一化,将能量分配到头部区域,相当于将权重更多赋予镜面反射方向),不过由于在计算中依然希望使用精确的PDF,所以偏移方式比较讲究(具体怎么做的?)
这里使用了伪随机数作为偏移来生成远离镜面方向的偏移量,这种方式可以将截断的区域数据按比例转换到非截断区域,经过这种处理后的PDF跟之前的PDF是一致的,区别在于乘上了一个常量系数,不过这个常量不需要另行计算,因为前面说过会对BRDF做一次归一化,这个归一化操作会将这个常量偏移的影响剔除掉。
另一个减轻方差的方法是对importance sampling进行filtering,前面说过,filtering就是在importance sampling的基础上,调整最终获取color时候的算法,根据表面粗糙度确定reflection cone的张角,之后根据采样点的距离确定cone截面的半径,根据这个半径可以计算采样的mipmap的mip level。
Filtering也可以根据需要添加bias,从而得到比实际需要的模糊程度更高的模糊结果,这个偏移参数可以有效的减轻前面说过的importance sampling中的偏移带来的反射过于尖锐清晰的问题。寒霜的实现中将这两个bias做了一个绑定,做成了一个bias参数,用于控制实现的过程从完全无偏移的蒙特卡罗算法(得到镜面反射效果)过渡到提取所有反射方向的输入得到的超模糊结果。
加大bias将从以下两个方向提升表现:
1.ray-tracing变得更为合乎逻辑(coherent)
2.Mipmap采样的mip level会更小(表现好在哪里?)
不过过大的偏移也会带来问题,比如会导致反射颜色不连贯以及漏光等(毕竟屏幕空间位置的临近并不等同于反射视角上位置也是临近的),此外还可能不利于实现前面所说的拉长反射效果。
下面看下不同偏移值的效果。
由于temporal filtering的存在,可能不是很能看得出来效果的提升,但是如果将temporal filtering关掉之后,噪点的减少跟性能的提升就很明显了。
前面说过resolve pass使用的是full-res的RT,在这个过程中会一次性处理四个像素的数据,并将这个处理结果共享给这四个像素使用,虽然会导致VGPR(每太明白是什么意思,GPU?)负担加重,但是由于对于这四个像素来说,只需要执行一次data fetch操作,从而节省了带宽,还是很划算的。此外,由于前面假设这四个点的反射射线的交点都是相同的,因此在屏幕空间的depth buffer的相交检测也只需要执行一次。
不过这里需要注意,对于这四个像素而言,在从mipmap贴图中读取对应的color数据的时候,其使用的mip level却不一定是相同的,因为四个像素的粗糙度可能都是不一样的,这里寒霜使用了一种跟DXT贴图压缩实现算法相类似的trick来解决这里的不一致问题,先计算出四个像素的min/max mip level,之后在使用的时候,只需要fetch min & max mip level的color数据,并根据roughness进行插值即可,这样就将贴图采样数目从四个降低到了两个。
这里给出了这种算法的实现效果比对,相对于单个mip fetch,这种算法与4 mip fetch的区别已经非常小了。
给出了PS4上面的性能消耗。