今天分享的是Ubisoft(育碧) 2014年在Siggraph上关于大气散射技术实现的分享,在这个分享中,育碧给出了他们用与相机frustum平齐的3D贴图来存储各个位置的粒子密度数据以及各个光源作用下的in-scattering的具体细节,以及后续3D贴图使用的逻辑框架,使用这个方法,在XBox上只需要1.1ms就能够得到非常不错的体积雾效果。
这里是原文链接。
大气散射名字听起来很抽象,但这个术语对应着许多重要的表现效果,比如天光,雾效,云效,丁达尔效应,体积阴影等。
总的来说,大气散射对于一款游戏有着如下的一些重要作用:
- 能够提供更为真实的效果表现(上面列出的各种大气散射效果,以及给玩家提供距离深度信息等)
- 能够掩盖由于LOD切换以及资源streaming导致的瑕疵等
光线在非真空的介质中传播,必定会与介质中的粒子发生碰撞交互,之后光线会分流到如下的几个方向:
- out-scattering,光线方向发生变化,散射到其他地方
- absorption,与粒子交互,被粒子吸收(金属物体常见)
- transmission,经过交互后继续前进的部分
假如物件处于真空中,那么视线所感知到的光照数据就仅仅只剩下直接光照打在物件上,经过BRDF等作用,笔直射向相机的部分,大部分的游戏目前采用的就是这种光照模式,在这种模式下,没有因为多次散射导致的全局光照,仅仅保留了直接反射的光线数据,因此结果显得很单调。
而如果考虑介质对光线的作用,那么总的来说就需要新增对如下两项光线的考虑:
- In-scattering,这部分光照表示的是光线在与粒子的作用后,'误入'视线方向的部分,对应上图中的灰色箭头
- out-scattering,这部分光照表示的是光线在传播过程中与粒子碰撞后,偏离原有光路的部分,对应上图中的绿色箭头部分。
而由于光线与粒子的交互作用并没有一套确切的公式,因此显得十分随机,且由于粒子数目众多且散射是多次的(通常在实时渲染方案中,出于效率的考虑会直接忽略多次散射的处理),因此很难对这些数据给出一套准确的拟合方案。
用于描述光线在经过与介质的交互效果的最出名的公式应该就要算是比尔定律了,其公式给出如下:
这个公式描述的是光线在经过介质交互后剩余的光强比例,其中A,B分别指光线与介质作用的起点与终点,T(transmission)表示的是剩余光强比例,是衰减系数,通常表示的散射系数+吸收系数,e(x)表示的是点x处的粒子浓度,整个积分部分描述的是光线传播的光学深度(optical depth),而这个公式表示的是光线在传播过程中的损耗与传播距离以及粒子浓度成一个指数的增长。
根据介质的不同,散射也有不同的类型:
- 瑞利散射 - Rayleigh,描述的是小尺寸粒子(比如空气粒子)中的光照散射分布
- 米氏散射 - Mie,描述的是稍微大一点的粒子(比如水雾,灰尘等)中的光照散射分布。
为了描述光线与粒子发生碰撞后,沿着各个方向散射能量分布,人们提出了相函数的概念,出于对能量守恒的考虑,沿着各个方向散射的能量之和应该正好等于输入的光能。
描述米氏散射的相函数中应用最为广泛的就要数HG相函数了,如上图所示,这个函数有着如下的一些优点:
- 公式简单,可以在运行时完成计算
- 支持各项异性设置参数g
- 通过一些手段,可以用多个HG函数来逼近球谐效果(比如通过对不同阶数的g的HG进行累加),实现更为逼真的散射模拟。
下面来介绍下传统游戏中是如何实现大气散射效果模拟的, 如下图所示,总的来说,传统的模拟方法可以分成如下几种:
解析解,比如上世纪九十年代使用线性雾来对大气散射进行模拟,而当前世代主机通常也是采用的解析解,支持指数雾,常用的是Wenzel在"Real-time atmospheric effects in games revisited"中的方案,不过这个方案无法解决空气中粒子浓度变化的问题
模拟解,通过美术同学手动放置的特效粒子来实现对大气散射的模拟,其缺点在于效果固定,很难跟环境发生交互,且美术同学工作量较大。
后处理模拟,UE3跟CryEngine使用径向模糊来实现light-shaft效果,从而使得这种方法流行起来,不过这种方法只能处理光源在屏幕中可见的情况,否则效果会存在问题。
Raymarching方案,这种方案能够提供比较真实的效果,但是缺点在于计算消耗较高。
此外,屏幕空间的raymarching方案存在如下的一些不足之处:
- 只能模拟一些短距的体积效果,无法实现长距离体积效果的模拟
- raymarching需要通过循环来实现,这对于现代GPU来说不太友好(尤其是AMD GCN架构这种能同时发起数千个线程wave的的GPU来说,会存在极大的浪费,因为raymarching会需要从shadow map中进行贴图读取,因此众多的线程wave是可以用于隐藏贴图读取的延迟的,但是循环过程把这个逻辑打破了?)
- 对于线性raymarching方案的不足,有人给出了一种使用极坐标的替代方案,虽然能够解决线性方案中的一些问题,但是限制条件却比较苛刻(介质密度不能发生变化,不能处理多盏光源等)
- raymarching过程是放在后处理中完成的,因此很难兼顾前向渲染中的透明物体以及粒子等的效果
- 为了兼顾效率,raymarching使用的分辨率通常较低,因此输出的小姑频率较低,对于一些平整表面来说,效果可能看不出异常,但是在其他位置会导致一些锯齿采样不足等问题。
育碧在尝试了多种大气散射方案原型之后,最后选定了一种用于计算GI的“Light Propagation Volumes”技术方案,仿照原方案中将光照注入并在介质中进行扩散来模拟GI,这里可以用于计算与模拟介质中的光照传播。
使用这种方案,只需要进行一次raymarching,且计算过程不受光源数目的限制,介质中的所有的光照传播过程都可以被归一化(比较费解),因此,只需要在这个过程中添加阴影项,就能够用计算阴影的消耗得到light-shaft效果,这种方案被育碧称之为体积雾。
实验证明这种方案能够在多光源下得到非常不错的表现,下面一起来过一下具体的技术细节。
为了能够将渲染过程拆分成多个pass,并且保证各个pass之间的互不干扰,这里选择了使用volumetric texture来进行中间数据存储,另外,所有的计算都是放到CS中完成,数据的读写是通过UAV来实现的。
这个算法的核心在于将散射计算拆解成多个可以并行完成的步骤,各个步骤之间的运行是相互独立的,通过这种做法不但可以得到并行计算的高效性,同时还可以根据需要对单个的步骤进行调整与替换。
上图给出了算法拆分的具体做法:
- 首先要做的,也是最重要的,就是要完成每个voxel的光照与阴影的计算
- 同时(可以并行,也可以放在同一个shader中串行完成),会需要对当前介质的密度数据进行计算
- 之后利用上面两步计算得到的数据进行raymarching,并将结果存储在volume slices中。
- 最后通过PS将volume texture中的结果应用到对应的物体光照上面
这里介绍了3D贴图的使用方式,最开始考虑的是使用与世界坐标系平齐的voxel排布方式,这种做法可以跟TAA相结合来给出更好的效果,但是后面发现使用这种方式进行raymarching需要进行更多的采样计算,效率低且容易导致锯齿。
后面考虑的是使用与相机frustum平齐的3D贴图布局,直接使用NDC坐标作为XY轴,而slice深度使用的则是指数深度,根据平台的不同,这里有两种分辨率,虽然分辨率有所不同,但是计算的时间消耗是完全相同的,与分辨率无关。
在160x90x64分辨率下,计算时所使用的分辨率与720p分辨率相同(1280x720),不过对于每个voxel,这里只进行一次lighting计算。
体积雾的覆盖范围与美术同学的设置有关,不过其深度覆盖范围为50~128m,目的是保证能提供一种远距雾效(当然,距离还可以根据需要进行扩充)
虽然3D贴图分辨率比较低,不过因为如下的几个原因,并不会显得质量低下:
- 每个slice上存储的是低频数据
- 数据读取的时候会通过四边线性过滤来对3D贴图数据进行混合,因此很难看到单个voxel的边界锯齿
- 因为透视矫正的存在,信息在每个slice上的分布是比较均匀的
- 并不会因为深度的不连续而导致的边界锯齿
虽然高频细节丢失了,但是现实中由于多次反射的存在,高频信息本身就会被模糊,因此反而更加符合实际表现。
另外,由于整个计算不需要依赖于场景深度,因此只要shadow map就位了,就可以将这个计算跟正常的场景渲染并行进行。
在进行低分辨率渲染时,主要的问题是对于高分辨率情况下多个像素可能具有不同的深度值,但是在低分辨率渲染模式下只能存储一个,因此在后面取用深度的时候,只能通过对多个像素进行滤波的方式来构造深度值,因此可能会导致输出数据的异常。
但是,在3D贴图中通过3D采样是可以避免这种深度不连续的问题的,虽然由于低分辨率的原因,输出的结果比较模糊,看起来有锯齿,但是并不会出现深度断裂的问题。
如上图所示,使用低通滤波器可以有效避免因为时间变化导致的信号锯齿。
计算的第一步是需要准备好fog渲染所需要的阴影贴图,而场景渲染的阴影贴图由于分辨率过高导致无法得到平滑的结果而不符合需求,为了避免高频阴影导致的锯齿和闪烁,就需要对阴影贴图进行下采样。
进行的第一次尝试是使用大尺寸的PCF来进行平滑,但是这种方案不但消耗高,同时也没有消除锯齿问题。
最终选择的是ESM,使用ESM可以很方便的实现对阴影与否概率的评估,shadow test的实现也非常的高效,还可以非常方便的完成下采样,相关代码片段如下图所示;
对于ESM的使用,主要有以下几步:
首先对CSM进行下采样,这里下采样进行了4次,最终的shadow map分辨率为1024*256。在下采样的过程中还会根据阴影计算指数shadow(对shadow值进行exp计算)
在下采样的过程中还会通过box filter进行混合,从而使得生成阴影更为柔软
虽然上述做法可能会导致光照泄漏,但是对于体积雾的介质而言,这种漏光问题完全可以忽略。
在实现过程中,对于聚光灯而言,还可以根据聚光灯的公式对锯齿进行优化。
上图展示了ESM从CSM读取到最终体积雾实现的全过程:
- 对阴影贴图进行下采样与box过滤
- 阴影贴图就位之后,就可以开始光照计算了,在这个过程中会计算各个位置的介质密度,之后会根据密度计算in-scattering数据
育碧将密度计算与光照计算结合在一起计算,虽然会有一点点的带宽消耗,但是基本可以忽视,当然这两个过程是可以分开完成的。
密度的计算比较简单,直接对某个octave的Perlin噪声进行计算,Perlin噪声会通过风力进行调节,后面也曾尝试过使用多阶的Perlin噪声,但是结果并没有太大的区别,徒增消耗。
此外,由于比较重的粒子通常会更加接近地表,因此这里还对垂直方向上的粒子密度进行了一点衰减计算,最终的散射系数是存储在3D贴图的alpha通道中。
这里插一句,育碧方案中美术同学是可以编辑粒子浓度的,可以为单独的关卡设置单独的密度贴图,从而实现特定物件(充满灰尘的工厂等)下的定制的散射效果,此外,还可以将粒子系统的数据动态注入到散射系统中来实现交互效果。
光照计算过程由以下几步组成:
- 对主光源(阳光或月光)进行累加
- 添加常量环境光
- 对美术同学标注为影响大气散射的点光进行计算,通过判断是否与相机frustum进行相交来决定是否需要考虑其影响
- 使用之前计算得到的ESM来输出主光源的阴影
- 将经过密度调制后的光照数据塞入到3D贴图的RGB通道中
在刺客信条4中,大气散射没有使用基于物理的相函数,光照颜色都是通过美术同学手调的,主要包含两个相函数颜色,一个对应于光照方向,另一个对应于光照反方向,只考虑了各项同性(即在与光照方向垂直平面上各个方向的散射光照颜色是完全相同的)的情况。如果添加相函数,也可以按照这种框架实现以得到更为真实的效果。
- 通过沿着射线进行marching以求取一个数值微分解(numerical calculus solution)
具体而言,要如何做呢?
- 对密度进行累加得到密度的积分,根据比尔定律,可以求得out-scattering的作用效果
- 而对于in-scattering,则是对整条线路上的in-scattering(考虑out-scattering后)进行累加即可
由于之前已经将in-scattering数据并累加到了3D贴图中,因此对于每条射线而言,只需要计算出累计的密度,据此计算出out-scattering衰减因子之后与in-scattering相乘即可。
通过Compute Shader,在每一次march step中都对in-scattering以及密度进行累加,最终就得到了每个voxel位置处到相机有多少的in-scattering光照以及累计的粒子浓度,之后在正式渲染中只需要直接对这个数据进行取用即可。
这张图给出了CS线程组是怎样对3D volume进行march的。
前面给出的过程是串行完成的,实际上这边也考虑过通过并行的方式进一步提升渲染效率,但是其结果并直接暴力破解还要慢20~30%,推测可能是由于如下原因导致:
- LDS(Local Data Storage)带宽冲突
- shader分支导致运行降速
- Cache命中率降低
下面给出了解方程的部分代码片段:
如上图所示,由于我们存储了整个场景的射线信息,因此可以将体积雾效果用在不透明的物体(比如下方的方块)以及连续多个透明物体上,都能够得到正确的表现。
这里给出了最终3D贴图是怎样用到场景渲染中的,比较简单,就不做赘述了。
在xbox one上这个方案总消耗只有1.1ms,即使将分辨率翻倍也只有1.6ms,其中消耗最高的就是密度与光照计算部分,此外应用阶段实际上是可以与光照计算结合到一起,还可以省掉0.247ms消耗。
本着精益求精的态度,育碧还对性能做了进一步的优化:
- 体积雾对于wave占用率较高而VGPR数目较低的硬件最为友好,将这个功能插入到其他的渲染pass中可以有助于降低寄存器的使用率,提升协同效率。
- 体积雾的一个消耗在于阴影贴图的重新计算,但是如果将这个贴图用在其他的效果上(比如粒子阴影,透明物体)还可以得到一些bonus。
- 此外,对于多盏光源的贴图以及光照计算结果也可以复用
- 并不需要对所有的voxel进行全光照计算,当距离较远的时候,只需要计算主光源的影响也并不会有多大的质量损失。
- 剔除一部分处于物件后方的雾效计算(HiZ)
- 通过类似Forward+/Clustered Shading,还可以减少光源计算的消耗
- 使用次时代主机的异步compute特性来降低计算消耗,因为不需要做几何体剔除,只要阴影贴图准备就绪,就可以进行后续计算
- 因为体积雾对于带宽与ALU有较高的消耗,因此可以跟一些顶点处理高消耗(比如填充G-Buffer)的逻辑结合起来使用
此外,使用相同的光照注入方法,还可以替代前向渲染中的光照计算部分,通过indirect dispatch(DX11/次时代主机功能)可以很方便就能完成这部分计算,这种做法对于一些非常复杂的光照场景来说是非常有效的。
下面主要介绍一下刺客信条4之后的体积雾优化:
- 使得效果更为物理
- 通过TAA增强效果的稳定性
如果不添加天光,这就会导致在添加了体积雾之后,由于out-scattering的原因,物体表面会显得比正常的情况下要暗(因为缺失了天光输入的in-scattering数据),因此这里需要考虑添加相应的环境光/全局光。
前面说过,天光(环境光)采用的是常量值,而添加了天光后的效果如上图所示。
GI颜色对于雾的颜色会产生叠加,因此添加一些随着空间而变化的颜色会使得效果更真实。
球谐SH是常用的存储全局光数据的方法,这种方法有很多非常不错的数学特性(正交性,旋转不变性等),其中一个是Zonal SH使用的方便性。
Zonal SH可以很方便的完成旋转操作(尤其对于低阶SH而言),这个特性在体积雾中非常有用。
HG相函数可以很方便被扩展成Zonal SH,对于2阶SH而言,旋转计算十分简单(如上图中的代码所示)。
如果要计算GI对体积雾的影响,只需要计算旋转过的相函数SH与view vector以及之前存储的天光/GI的SH这三者的乘积积分即可。
虽然通过低分辨率的shadow map已经消除了大量体积雾中的锯齿,但是依然还存在一些顽固分子,对于这些锯齿,育碧尝试通过TAA来进行消除。
在2D情况下(屏幕空间)应用TAA的一个问题是reprojection可能会因为数据不连续的问题而找不到原始的数据,因此经常是需要在颜色抖动与重影等瑕疵中找一个平衡。
在3D(贴图)中进行reprojection计算就相对容易的多,虽然因为物件移动,其之前所在的位置数据(上图红色区域)依然是空的,但是因为3D贴图还存储了物件后面的数据(如上图绿色标注区域),因此还是能找到匹配的数据(只有那些超出了3D贴图表示范围的数据可能会存在问题)
另一个消除锯齿的方法是对采样grid进行抖动,即通过引入高频噪声来掩盖锯齿,而引入的高频噪声则可以通过低通滤波方法滤除,此外,这种做法是可以同时用在时域与空域上的。
上图给出了单个采样点TAA下的效果对比,可以看到所有边缘上的锯齿都被消除了。