望蓟门
[唐代][祖咏]
燕台一望客心惊,箫鼓喧喧汉将营。
万里寒光生积雪,三边曙色动危旌。
沙场烽火连胡月,海畔云山拥蓟城。
少小虽非投笔吏,论功还欲请长缨。
今天给大家分享的是来自微软的Adam Cichocki在Siggraph 2017上发表的屏幕空间反射算法Pixel-projected Reflections的实现,点击链接查看原文PPT
另外值得一提的是,Ghost Recon Wildlands(原文链接)在未曾通气的情况下自行实现的SSR技术与本文给出的算法近乎一致,实在是非常的凑巧。
先来介绍一下SSR的一些情况,SSR是当下用于实现反射的一个非常通用的算法,整个算法的思路是通过ray marching从normal render pass的color buffer与depth buffer中获取数据,从而避免reflection的render cost。SSR的缺点在于因为数据来源于normal render pass,而reflection视角跟正常的相机视角所见的数据是不一致的,导致反射结果会存在缺漏,常见的瑕疵包括部分物体在反射界面上不可见,屏幕边缘的反射存在缺块以及反射输出结果存在孔洞等,此外,SSR在实现时对于内存带宽有着较高的要求,因此通常会在1/2或者1/4分辨率上实施。
这里将SSR跟当前需要介绍的Pixel-projected Reflection(下面简称PPR)进行一下简单的比对,PPR实际上是一个受限版本的SSR,其应用范围有一定的要求,且需要进行额外的内容编辑,不过其性能与效果表现相对于SSR会更好一点。PPR的实现方式不是通过ray marching完成的,而是借助compute shader的indirect dispatching,因此性能上会有较大提升。
PPR算法的实现是通过data scattering而非data gathering完成的,与SSR一样,计算过程需要用到Normal render pass的color buffer与depth buffer数据,此外还需要用到反射(镜面)区域的一些解析数据信息。
正如各位推断的那样,PPR的反射实现路径与SSR的实现路径正好是相反的,在SSR中,某点的反射结果颜色数据是从此点引出一条视角方向的反射方向的射线,通过ray marching追索到color buffer中对应的数据,而在PPR中则是反其道而行之,将normal render pass中的color buffer中的数据按照depth buffer中的信息逐像素的投影到反射平面上,相对于SSR而言,PPR对于depth buffer的读取次数从多次降低到一次。
为了能够完成上述计算过程,就需要将反射surface表示成平整的解析几何形状,并通过shader constants的形式将之传递到shader中,实际应用中最常见的形状是矩形,因此后面的阐述都以此为例,当然,除此之外的其他形状也是支持的。
PPR相对于SSR有着更为严格的使用限制:
- PPR只支持磨砂(non-glossy)形式的反射,且反射surface只支持平面
- PPR虽然不支持根据surface的normal map调整反射效果,但是可以通过其他方式得到对应的结果,比如可以通过后面bonus slides中的一个方案解决
- PPR的实施需要用一个简单的反射形状(用于告诉shader反射面的形状与pos数据)来覆盖反射区域(被覆盖的区域才会进行反射计算),反射形状的法线朝向应该要与反射区域的法线朝向相一致,通常这个形状的放置需要手动完成,不过也可以通过程序进行自动放置。
这里先用2D模型来简单解释PPR的工作原理
这里是一个简单的2D场景,里面橙色线条表示的是水池,灰色方块表示待反射的物体
与SSR一样,PPR通常需要两个pass来完成,第一个pass称之为projection pass,在这个pass中,会通过一个后处理对屏幕上的像素逐一进行处理,计算其在反射平面上的位置数据
对于物体上的绿色小点而言,先将之沿着镜面做一个倒影,这个倒影就是相机视角中原点在镜面反射后的效果
之后将倒影与相机连成一条线,并检查此线条是与反射平面的相交点是否落在反射镜面的范围之内:如果超出边界,那么此点倒影在相机中是不可见的,否则进入下一个判定阶段(是否被遮挡)
对于示意图中的这种情况,反射倒影由于被其他物体遮挡,此时后续的一切计算都是徒劳且浪费的,因此在计算中,会先判定当前的反射点在depth buffer中对应的数据是否是最近的,如果不是,就跳过后面的所有处理。
对于场景中存在多个反射平面的情况,projection pass可以通过在CPU先计算出可能会导致遮挡的物体列表,并将这些物体转换成shape list传递到shader中来进行加速(听起来有点复杂)
得到反射点之后,下面一步就是对反射点进行透视变换,将之转换到屏幕空间坐标系中,之后将原像素位置的color数据添加到反射点对应的屏幕像素位置
经过projection pass之后,normal render pass中的color数据就被scatter到屏幕上对应的反射位置上了,接下来就进入第二个pass:reflection pass。
在前面projection pass中已经将反射点对应的color数据写入到一个screen space buffer中,在reflection pass只需要将对应反射点转换到屏幕空间,之后按照uv读取color数据即可
因此,反射color的获取不需要进行ray marching搜索。
刚才说到reflection color是存在一个screen space buffer中的,这个buffer这里称之为intermediate buffer,这个buffer的size跟screen一致,其中只存储反射数据(且可以应对多个反射平面的数据,对于这种情况就需要进行针对性的筛选,只存储距离相机最近的反射平面的反射数据了)
这里给出整个算法的概览
这里是PPR实现的效果图,放大图给出花盆反射效果破裂的瑕疵,这些瑕疵看上去像是该反射花盆的地方,反射了花盆背后的柱子(多个屏幕像素对应于一个反射点导致,没有做写入前的比对)
Intermediate buffer中存储的数据从理论上来说应该是反射的颜色,不过直接存储颜色的话会有问题,问题就在于对于同一个反射点来说,depth buffer中会存在多个被反射的点与之对应,此时应该存储的是最近的一个被反射点的颜色,不过直接存储颜色的话,显然是无法比对位置信息的。
这里给出了一个示意图说明问题产生的原因
为了解决这个问题,这里不再存储color,而是存储屏幕坐标的offset,通过这种方式在写入的时候调用InterlockedMin接口保证每次写入的数据都是距离反射平面最近的数据
想要达到期望,还需要解决下面几个问题
这里对2D坐标进行分析,绿色点投影到镜面上的浅绿色点上,两者的偏差在于Y轴
在这个基础上再增加一个更远一点的红色点,其投影的位置也是浅绿色点,可以看到,红色点在Y轴上距离浅绿色点更远,而X轴上距离无差别
根据这个观察,可以将intermediate buffer中存储的UV Offset编码成Y轴处于most significant bit的整数数据
这里给出了详细的编码实现
增加了这个处理之后,虽然增加了一个texture read,但是瑕疵确确实实是不见了
此外,在intermediate buffer中可以同时存储多个反射plane的反射数据
这里介绍一下reflection pass的实现细节(其中涉及到很多异常情况的处理)
Reflection pass遭遇的第一个问题就是反射buffer中的孔洞问题
孔洞有两种情况,一种是根本没有任何相邻的有效数据(屏幕边缘),另一种则是周边存在有效的反射数据
对于周边存在有效反射数据的情况,可以通过最近邻offset搜索从周边的有效反射数据中fake一个反射结果
通过这种修复方式得到的效果如图所示,可以看到孔洞是被填补了,不过反射效果存在轻微的扭曲,根据相机的角度不同,normal render pass中的数据映射到reflection中时,总是会出现拉伸或者压缩,压缩会导致反射效果的不连贯(因为normal render pass中的部分像素数据将不会存储到intermediate buffer中),而拉伸就会导致孔洞(前面的算法修复的就是这个问题)
为了修正这些distortion瑕疵,这里需要在采样的时候进行一次过滤,前面说过,intermediate buffer的索引数据中是包含了小数部分的,就是用来进行过滤的,这里给出了过滤后的表现效果。
不过,过滤也会引入漏光问题,如图中黑色区域边缘的白色部分,这是由于反射过程中的视差效应导致,要想消除这个瑕疵,就需要借助下面的两个特性:
1.漏光问题多发生在对比强烈的区域,且漏光问题如果不是特别严重,其实也是可以忽略
2.在使用point sampling的时候,不会产生任何的漏光问题
因此,这里考虑将filter结果跟非filter结果混合(采样成本double了)来解决这个问题,不过在结合的时候需要进行一下约束,对于混合后的输出结果与point sampling结果相差过大的,直接取用point sampling的结果,否则取用混合后的结果。
在具体实施层面,不论是filter sampling还是point sampling,都会将结果转换成hue & luminance分量,之后根据数值差异度,分别对两个分量进行混合,其实整个操作跟TAA算法的近邻采样clamping操作比较类似,不同的是,这里为了将所有的计算都放在reflection shader pass中完成,仅仅根据单个point sampling数据来对filtered的颜色数据进行修正,而非如TAA一样,将周边的所有像素数据都纳入考量。
到此为止,所有的计算与处理过程就讲完了,下面一步一步看下reflection pass中的每一个步骤的输出结果对比,这是直接根据intermediate buffer中的索引读取color作为反射结果的效果,其中因为反射的拉伸而导致了大量的数据缺失,从而出现如图所示的黑色条纹
在这个基础上从相邻像素中进行了一次修补处理后,孔洞数据被填补了
修补后的结果依然还存在着数据distortion的瑕疵,因此对其进行了一遍filter处理
Filter之后的结果会存在漏光问题,在这个基础上结合filter sampling跟point sampling,分别对hue & luminance进行一次constrained混合,就得到了最终的输出结果。不过因为这个混合操作,是的之前已经修复的distortion问题再次变得严重起来,虽然可以通过其他的方式解决漏光问题,但是想要真正得到较好的效果,就需要一次额外的render pass,这个成本就有点高了。
这是放大版的输出结果对比
下面来看下性能表现,这里给出测试的硬件配置
分别对比了这几种方法的消耗
暴力SSR消耗20.6ms,因为使用了 ray marching算法,这个表现也算预期之中
低端版本的SSR虽然将消耗降低到了0.95ms,但是其质量表现实在是堪忧:depth不连贯导致边缘的锯齿过于严重
另外,值得一提的是,虽然可以通过dithering+temporal filtering的方式降低depth不连贯的瑕疵,但是同时会导致原本应该具有锐利反射结果的地方变得模糊,得失如何就要看项目自己评判了,而且由于比较高的cache miss,dithering也会导致内存access效率降低,另外将这个算法改造成Deinterleaving的版本将有助于提升dithered tracing的效率。
HiZ tracing算法的实施质量还是不错的,相对于暴力SSR而言,消耗也不算高,这3.2ms是包含了HiZ Depth Buffer创建的0.35ms的GPU消耗,如果还有其他需要用到HiZ Buffer的算法的话,那么这个时间消耗就是一个bounus了。
龙形雕塑后面的黑色方块(shadow)的出现是因为measured算法(这难道是一个专有名词?)没有对处于其后的模型进行tracing,目的是提升算法的实施效率。(意思是这只是写demo的时候图省事,没有去掉?)
PPR的实施算法的消耗也是0.95ms
这里给出了PPR用在透明材质上的反射效果展示,如果使用SSR的ray marching来实现类似效果的话,其消耗可能会比不透明材质的SSR更高。