这里分享的是Unreal在Siggraph 2015上关于SDF的相关技术,原始PPT在参考文献[1]中给出。
分享的作者是UE的图形程序Daniel Wright,到2015年为止,他在Epic待了9年,专注于lighting & rendering相关的技术,此前参与过Gears of War系列。
先来提出问题,在一个可以动态变化的场景而言,在如下的一些要求下,目前并没有一套比较好的Occlusion(遮挡光效,比如AO/Shadow)方案:
- 精准、柔软
- 支持area shadows,sky occlusion, reflection shadowing
一个有效的解决策略是使用Ray Marching Signed Distance Fields。
受Inigo Quilez使用SDF+Raymarching生成漂亮效果(如下图)以及Alex Evans 2006的Fast Approximations for Global Illumination on Dynamic Scenes文章激发,这里尝试使用SDF来解决上述的问题。
在开始计数方案介绍之前,我们先来看下此前SDF的应用方式。
如上图所示,UE3中层尝试将SDF用在反射阴影(地面反射效果上的阴影)的计算上面,这里没有介绍其中的实现细节,后面有机会再补上。
这个方案对场景是有要求的:
- 场景需要是static的,需要单个volume texture(整个场景一张还是每个物件一张?)来存储distance field数据
- 性能消耗较高,只有高端硬件才支持。
SDF的另一项应用是用于计算天光遮罩(Occlusion)效果,同样没有给出实现细节,后续有机会再补充。
大概的实现方式是,为屏幕空间的每个像素朝着某个方向(比如bent normal之类)以某个cone angle(bent normal计算时附带输出的未围挡区域的范围计算得来)进行cone march,以cone march的结果作为天光的illumination效果,这种方案的弊端在于:
- 需要27个cones(每个像素?)
- 即使在非常小的场景中依然只能得到2fps的帧率……
GDC 2015 Kite Tech Demo给出了SDF的另一项应用,计算植被的Occlusion效果,同样只支持高端硬件。
相对于上述的一些应用场景的高昂的性能消耗,Epic目前在Fortnite上使用的方案则更为亲民:
- 支持动态TOD
- 支持高动态场景光照效果
- 在PS4级别的硬件上都能有流畅的体验
接下来进入正文,整个技术介绍分成五个部分,下面一一介绍。
1. SDF简介
SDF的中文翻译叫做有向距离场,这里的场描述的是3D空间中的数值分布,数值是距离,是到物件/场景的最小距离,而有向则指的是距离是带符号的,当某个点处于物件内部,这时的距离是小于0的,否则是大于等于0的。
SDF的数据有很多存储方式,一个常用的方式是使用volume texture,即3D贴图,贴图中的每个像素对应于所覆盖范围中的一个点的SDF数值,其他未落在像素中心的点则可以通过对其他像素数据进行插值得到。
SDF的一个常见应用是阴影计算,某个点是否被阴影覆盖,只需要从这个点向着光源方向发射射线,判断射线是否会跟场景相交即可,而如果没有SDF,在沿着射线进行ray marching的时候,就需要以一个较小的step逐点步进判定,这个消耗是非常高的。
有了SDF之后,由于我们可以知道任意点的SDF,那么相当于以这个数值作为marching step长度是完全可以保证不会穿到物体内部的,也不会出现step过大某个遮挡体被跳过的可能(当然,这要求场景的SDF表达的精度足够高),因此可以对这个计算过程进行加速,这种算法有个术语叫做sphere tracing(如上图中右边的小图所示)。
通过前面的sphere tracing,我们可以很容易的直到某点是否处于阴影中,这个过程得到的是硬阴影,要想得到软影效果,还需要一些额外的处理:
- 根据当前像素到遮挡体的距离来对阴影进行软化(除以某个与距离(或距离的平方)成正比的数值)
- 在sphere tracing的过程中,当前射线与场景相交的最小cone,如上图中右边小图所示,以这个cone的角度与一个预设的全亮时的cone的角度(这个角度跟光源形状有关,可以用作面光源软影的求取方式)作比,结果用作阴影的软化程度。
跟直接用voxel来进行场景或者物件表达相比,SDF有如下的一些优势:
- 可以表示表面的朝向
- 可以根据数据进行插值求得更为精确的表达结果
- 可以用于对ray marching等算法进行加速
- 可以无需进行prefiltering处理以实现cone intersection求取。
当然,相对而言,SDF也有其不足之处,比如只支持position/normal等数据的存储(其他数据就不支持了),又比如,要想得到可见性就只能通过trace的方式来计算。
2. 场景表达
下面来介绍一下如何使用SDF完成对场景的表达。
每个物件的SDF是在离线的时候通过暴力遍历所有面片的方式求得的,SDF使用一张3D的volume贴图表达,贴图数据格式为float point 16,平均而言,每个物件的3D贴图分辨率只需要50x50x50即可。
除了CPU算法之外,还可以借助GPU的并行加速功能来完成这个计算。
由于3D贴图分辨率有限,为了保证精度,就需要将其覆盖范围收缩得足够小,而非直接覆盖整个场景。
如上图所示,在这种时候要计算某个点到场景的最小距离,则需要分成两步:
- 第一步,是求取当前点到覆盖范围bounds的交点,计算当前点到交点的距离
- 第二步,从上个交点处获取到物件的最小距离
将上述两步的距离相加用作当前点到物件的最小距离的近似。
将SDF用trace steps表示出来,大概如上图所示。
这里的一个问题是,对于开口的物件而言,其SDF是如何表示的内,被半包围的空间处的数值是正还是负?
如上图所示,判断某个点的SDF数值是正还是负,主要看其与物件的碰撞面是frontface还是backface的,前者是正,后者是负。
树木等物件的SDF是将树叶当成双面物体来计算的,不过这种物件在ray marching的时候消耗会十分的高,因为面片过于复杂,光线在其中穿梭的时候,SDF比较小,因此step也就跟着变小,导致采样数目剧增。
SDF是支持同一物件的不同实例的,使用同一套SDF数据,只是变换不一样。
因为分辨率较低的原因,一些物件的边缘区域可能就被四舍五入掉了,而一些薄片可能就仅仅只保留了内部的一个负值像素(为了避免射线marching过程中的遗漏,这个保留是必要的。)
在实际使用中,通常不会对单个物体进行ray marching,而是会将场景的所有物件的SDF组合成一个全局的SDF贴图,对于一个长宽高都为512m的场景而言,这个贴图的内存消耗大约在300M左右。
这个全局SDF贴图的更新是在GPU上完成的,CPU将需要更新的数据(比如要移动某个物件,只需要将物件的变换矩阵上传即可)上传到GPU,GPU根据数据对各个物件的SDF进行读取,并将之写入到全局SDF中,这个更新是增量完成的。
将SDF数据放到GPU,可以加速剔除,这里没有给出剔除的实现细节,推测一下,大概是从屏幕空间每个像素发射一条射线,计算交点即可判断哪些物件是可见的了,而各个射线的求交是并行的。
这一页从PPT给的关键字没推测出描述的是什么,大概是使用SDF用作地形的heightfield?后面有机会再补上相关描述。
image.png当前使用volume texture的表达方式可以表达绝大部分场景的数据,只是在如下的一些数据表达上还存在问题:
- 非均匀拉伸的物件
- 蒙皮等动态deform的数据
- 大型有机物或者volumetric terrain数据。
除了这种方式之外,当然也还有其他的一些表达方法: - 分析式的Distance Function
- sparse volumetric SDF等
至于SDF的应用,则凡是会有用到cone tracing的相关算法,应该都能实现一定的加速。
3. 直接阴影
SDF的一个作用是可以很方便的计算直接光照的阴影,且得到的阴影分辨率会十分之高。
这张PPT给出的信息并不是十分直观,在没有对应的演讲视频的情况下,只能根据个人理解推测其表达意思。
具有球状外形的径向光源(方向光、点光、聚光灯应该都能应用这种处理方式吧?)的处理逻辑:
- 根据光源的覆盖范围计算出受影响的物体
- 将屏幕分成tile,每个tile记录一个光源列表
这个列表是通过对光源的cone与物件bounds相交来计算得到的?(没有具体细节)
这里给出了阴影计算的算法步骤:
对于每个处于光源cone覆盖范围之内的物体而言:
绘制在屏幕空间中的每个像素发射一条朝向光源的射线
对于射线上的每个采样点:
采样SDF,得到当前点到场景平面的最小距离。
计算DistanceToSurface/ConeRadius,这个数值实际上是cone的半角的tangent,判断与此前纪录的最小的比值的大小
每个采样点前进步长为abs(DistanceToSurface)
当射线与场景相交或者达到了最大采样数,则终止raymarching
最终的阴影值就为1减去记录下的最小比值
这里给出了一个解释,使用DistanceToSurface/ConeRadius得到的结果与(DistanceToOccluder/SphereRadius)^X的结果是一致的。
DistanceToOccluder指的是像素发出的射线到相交点处的距离,而SphereRadius则是遮挡体的半径, 倒过来可以看成是遮挡体占据的cone的比例,也就是阴影的浓度,那么倒过来之前就是光透过遮挡体进入当前像素的比例。
DistanceToSurface/ConeRadius中DistanceToSurface表示射线上当前采样点到最近的遮挡体的距离,ConeRadius则是当前采样点对应的cone的半径,同样这个公式可以用来表达光源经过遮挡体之后进入当前像素的光照比例,因此这两个式子在直观上应该是一致的。
平行光的处理逻辑跟前面的径向光源相似,不同的地方在于:
- 不再需要为光源查找对应的屏幕空间tiles,而是全屏幕都参与
- 每个像素沿着光源方向的射线长度不再是光源的覆盖球的半径,而是max view distance。
由于SDF无法处理形变物体,因此带有顶点动画的植被等物件就不太适合使用这种方式来求取阴影,不过这也分情况,当距离比较远的时候,完全可以停止顶点动画,此时还是可以使用SDF求取阴影,否则只能考虑使用CSM来计算阴影了,上图给出的效果可以看到,CSM的阴影精度相对于SDF的阴影精度有比较大的差距。
此外,远景植被如果退化成公告板,那么也是不能使用SDF来计算阴影的,这种时候可考虑使用conservative depth write(搜了一圈,没找到相关技术文档,不过这么远的距离,可以考虑直接将阴影烘焙到公告板上吧?)来解决这个问题。
相对于传统Shadow Map方案,使用SDF计算阴影有如下的一些好处:
- 可以实现带有比较明显轮廓的面阴影(如上图中的下方小图所示,效果更为真实)
- 不会出现此前shadow map精度不足导致的毛刺或者peterpan问题
- 阴影消耗与场景复杂度无关,只取决于raymarching过程中的采样点数目(与场景密度有关)与屏幕分辨率,当然可以使用半分辨率进行计算来降低消耗,且支持一个较大距离的投影,还没有CPU的消耗。
- 相对于传统的阴影方案如cubemap/CSM而言,会快30%~50%。
4. Sky Occlusion 问题与解决方案
SSAO只能给出小尺寸的遮挡效果,而我们经常需要一些较大范围(10m)的遮挡效果。
在SDF表达的场景中,使用单个cone进行trace可以得到较为柔软的阴影效果,但是对于来自四面八方的天光而言,使用单个cone就不合适了。
这里考虑使用分布在以法线为中心方向的半球面上的多个cone trace来计算天光遮蔽,将多个cone trace的结果平均一下作为最终的天光遮挡效果。
每个cone的trace范围限定在10m左右。
如果觉得cone的数目太多会导致消耗过高,可以考虑只使用一个cone,选择bent normal作为cone的trace方向,trace的结果会被用在天光的SH diffuse lighting部分。
此外也可以使用视线的反射方向作为cone trace的方向来获取specular。
这里给出天光遮蔽计算的一些优化策略(信息有限,加入个人推测):
- 将屏幕空间划分成大小均匀的tile
1.1 每个tile存储两个bounds,每个bounds的边长存储在一张depth贴图中,分别表示起始trace距离与终止trace距离?这个bounds怎么计算?下面有说
1.2 每个tile还包含两个culled list,什么作用? - 还可以借助SDF进一步降低计算消耗,how?
这里说到,在AMD的GCN架构下,使用光栅化算法比直接使用CS效率要高。其实现算法给出如下:
- 从frustum culling处理后的结果中构建Draw Buffer
- 每个物件的覆盖范围bounds作为渲染的数据,使用DrawIndexedInstancedIndirect一次性完成所有bounds的绘制
- 在PS中完成各个像素的min/max bounds的输出。
为了提升计算效率,这里cone trace是在1/8分辨率(单纬度)下计算的,但是这种情况下计算的结果会有很强的锯齿感,需要添加一个1/2分辨率的bilateral filtering,这个filtering是geometry aware的。
还可以进一步通过借助TAA的velocity数据来降噪。
另一个优化策略是将物件的Distance Field合并成一个场景Distance Field,因为如果是对物件的SDF进行采样的话,由于物件数目众多,在采样的过程中需要频繁切换采样贴图,可能还有一些其他消耗,而合并成一张贴图之后流程就更为清晰简洁了。
场景SDF是存储在以相机为中心的四级Clipmap(每级clipmap都以相机为中心,不过覆盖范围逐级递增(比如翻倍))中的,每级Clipmap的分辨率都是128^3。
clipmap的覆盖范围会随着相机的移动而进行逐行或者逐列更新。
覆盖范围大的clipmap的更新频率会低一些。
场景SDF的问题在于靠近物件时的精度会有所不足,这里可以考虑使用两级策略,在cone开始的时候使用物件SDF,其他地方使用场景SDF。
这种处理策略可以极大的降低SDF的堆叠复杂度,提升运行效率。
这里给出了天光遮蔽算法各个步骤的执行消耗。
这个算法目前还存在一些问题,上面的描述比较抽象,似乎是说室内遮挡效果会存在问题,具体后面有更多信息再来补充。
SDF除了前面的应用之外,还可以用在gameplay中,比如用于实现粒子与场景的交互效果。
还可以用于实现软体模拟、gameplay玩法以及渐进式flow map生成等。
网友评论