满江红
[宋代][岳飞]
怒发冲冠,凭栏处潇潇雨歇。 抬望眼,仰天长啸,壮怀激烈。 三十功名尘与土, 八千里路云和月。 莫等闲白了少年头,空悲切。
靖康耻,犹未雪; 臣子恨,何时灭! 驾长车踏破贺兰山缺。 壮志饥餐胡虏肉, 笑谈渴饮匈奴血。 待从头收拾旧山河,朝天阙。
Bungie工作室的知名作品:《光晕》系列。本文主要介绍Bungie工作室在2009 Siggraph上的一些陈述内容。
目前游戏开发有以下几个趋势:
- 整个游戏制作的质量,就取决于图形实现的质量
- 美术风格越来越趋于真实世界
- 实时光照逐渐对全局光提出了要求
- GPGPU(General Purpose Computing on GPU, 说的就是Computing Shader吧)变得越来越普遍
在游戏研发过程中,主要有以下几点是工作的重心:
- 内容管线的制作
- 美术视角与风格实现与调优
- 终端用户感受调优
- 可扩展化的技术实现
要想在游戏中为玩家呈现真实的世界效果,就绕不开光照的计算实现,其中最重要也是最困难的就是全局光效果的实现,通常来说,真实光照效果的实现一般有两个思路:
- 通过预计算烘焙的方式,离线将数据计算好,运行时取用。这种方式在使用的时候计算消耗低,不过由于数据存储需要的空间以及运行时需要的带宽消耗会高一些。
- 在游戏运行时直接计算。这种方式不需要额外的存储空间与带宽消耗,但是在运行时计算的消耗相应会更多一些。
先来看一下实时光照的实现流程。
在户外场景中,阳光与天光是最主要的光源,高质量的大气效果可以给人眼提供许多关键信息(比如由于大气散射而导致的大气透视与衰减能够营造一种非常真实的距离感),对玩家的游戏真实感体验有着至关重要的作用。
关于大气与天光的模拟,之前使用的是Preetham & Hoffman在02年提出的大气散射模型,这个散射模型是在离线的时候烘焙出一张天空贴图,而散射效果则是实时计算的。这种模型只支持单次散射计算(暮光,地球自阴影以及light shafts等效果通常都需要计算多次散射才能实现),且只支持在地面上看到正确效果。
Bruneton & Neyret在2008给出了更好的大气散射模型,这种模型支持多次散射计算,支持对于任何方向的太阳光以及任何的观察位置与观察方向的散射效果,所有的数据都可以离线烘焙。
下面我们来简单过一遍大气散射中的一些相关理论。
瑞利散射散射理论是以英国物理学家Lord Rayleigh命名的散射模型,这个模型的大致结论是:光波在传输过程中如果遇见尺寸远小于波长的粒子时(至少要相差一个数量级),会发生弹性散射,此时各个方向的散射光强度是不一样的,该强度与入射光波长的四次方成反比。当光波在透明液固体或者气体中传播的时候,就会出现这种散射。
太阳光在洁净的(如下过雨后,大气中粗粒子较少)大气中发生的瑞利散射是导致天空呈现蓝色的主要原因:前面说到,散射的光强与波长的四次方成反比,因此波长较短的蓝紫光在瑞利散射中占据主导地位。
此外,随着海拔的增加,大气中的粒子密度也逐渐下降,导致天空的颜色也因之而变化(蔚蓝->青色->暗青色->暗紫色->黑紫色)。
而在日落或者日出之时,太阳处于观察者视线的正前方,使得太阳光在传输过程中波长较短的部分都因瑞利散射而消去,只剩下波长较长的红橙光这也是为什么日落跟日出的时候太阳呈现红色而天空依然蔚蓝的原因。
如果大气中的粒子半径为r,其周长为2pir,光波长为lamda,而尺度数x = 2pir/lamda,那么只有当x<<1的时候才会发生瑞利散射(当x!<<1,且x<50时使用米氏散射(Mie Scattering),当x>50时使用几何光学)。
这里给出了两个函数:Beta函数跟相位函数(phase function)。Beta函数表述的是?相位函数表述的是不同不同角度的散射光强度。
Beta函数公式中的h=r-Ra,Ra指的是海拔高度。n指的是光波在空气中的折射系数,N指的是海平面处的分子密度。
P函数的值取决于(1+cos(theta)^ 2),其中theta角指的是反射光方向与入射光方向的夹角(说明在与入射光垂直的方向的散射程度接近于0,且只要夹角互补,在入射方向与入射反方向上的散射程度是一样的)
当大气中的粒子尺寸与光波长尺寸一致时,此时发生的散射称为米氏散射。
烟尘,花粉,小水滴等粒子时导致米氏散射的主要原因,米氏散射的散射强度与波长的二次方成反比,且在光线前进方向的强度要高于光线前进的反方向。
米氏散射在低层大气空间中发生概率会更高一些,且在云雨天气更为明显,因为这个时间或者空间范围中的大尺度粒子会更丰富。
米氏散射是德国物理学家Gustav Mie在1908年发现的。
气溶胶等粒子散射的相位函数(aerosols phase function)是米氏理论给出的散射强度随散射光方向与入射光方向夹角而变化的公式,米氏散射相位函数可以通过Cornette-Shanks Phase函数进行拟合,在这个相位函数中的变量g是一个对称因子,这个数值控制着散射光的分布形状。
跟空气分子不同,气溶胶粒子会吸收部分入射光,这个现象可以通过一个吸收系数来表征:βeM=βsM + βam.
这里的内容陈述有点颠倒,且公式的写法也存在一点问题,后续使用的时候还需要进一步深入理解与核验。
实际渲染中某点的光照强度主要由三项内容叠加而成:
1.太阳直射的光强L0
2.x0点出的反射光强R[L]
3.向着观察者方向散射的光强S[L]
下面对这三项内容在实际渲染中的计算逐一进行拆解:
直接射入人眼的光强在抵达人眼之前其各个位置的强度可以通过光照传播函数来表述(随着距离的增加,光强逐渐衰减),此外,还需要考虑在传播过程中是否存在遮挡物。
L0指的是直接光源Lsun在抵达观察者人眼之前,经由光照传播函数T(x,x0)衰减后的结果。如果光照方向与观察方向不一致的话,此项数值为0,而即使这两个方向一致,在传播过程中如果存在遮挡物,这一项数值也是0.
X0位置的反射光在向着观察者位置传播的过程中同样会存在衰减,其最终的光强跟x0位置的光照辐射照度I有关。注意,在大气层的上边界处,I的数值为0,在进行大气与太空过渡区域的绘制时会利用到这个特性。
散射部分指的是在x0出发生的光照散射中,散射方向是朝向观察者的那一部分,这一项的数值依然跟传播函数以及x0处反射的辐射照度I以及x0处的散射光照度J有关
在实际渲染过程中,首先需要将一些复杂计算过程通过贴图烘焙的方式离线计算出来:主要包括传播函数T,散射光照度J,以及反射光照度I。
不同的情况下,起主导作用的部分也有所不同,因此会呈现不同的效果,如上图所示。
真实的天光与日光能够让人辨别出当前是一天中的哪个时段。为了能够达成这种效果,我们需要为为不同的观察角度,不同的光照方向,不同的时间段以及不同的大气属性生成不同的散射效果。本文给出的散射模型是能够支持多次散射的,因而能够提供一个较好的暮光效果的模拟,此外还支持从太空中观看的大气效果(如下图)
Bruneton&Neyret给出的散射模型中,对于天光的模拟使用的是一个单一的颜色,这种实现方式对于远景的渲染影响是足够了,不过对于一些近景的实现可能就还有所不足。更好的实现方法给出如下:
1.使用CIE天光辐射分布来模拟天光的颜色分布规律
2.对天光的辐射亮度进行离线烘焙,在运行时采样使用
3.对于地平线的每个经度方向,生成一个对应的Spherical Harmonics
4.将SH的系数填入到对应的多项式中?
5.使用PRT(基于物理的渲染?从后面的效果看,像是加了AO)渲染来获得全局光照效果。
下面给出一组实现效果图:
Shadow
使用Shadow Mapping来实现阴影渲染已经非常普遍了,不过要想实现高质量的阴影效果依然还存在着许多挑战,比如说处理由于贴图分辨率与贴图存储精度而导致的边缘锯齿问题等。
在开放大世界里,CSM已经成为一种标配,不过在实现的过程中依然要考虑如何对阴影贴图的分辨率进行有效利用的问题,且对于锯齿效果,CSM依然无能为力。
抛开贴图分辨率分配先不谈,在阴影贴图实现方法中首先需要解决的是由于投影导致的阴影边缘的锯齿问题。
PCF是目前比较常用的一种可以生成软影的阴影过滤方法,通常会搭配一个泊松圆盘分布的采样pattern来实现,不过这种方法的实施质量通常取决于采样点的数目,在采样点数目较多的情况下可以得到较好的质量,而采样点数目一多,由于此前的采样结果无法被当前的像素使用,因此在实时运行过程中的消耗就会比较高。虽然在使用PCF方法的时候,还是有着不少的优化技巧(比如为阴影的边缘部分生成一个mask,在实际运行的时候,只对这部分数据进行PCF处理),不过我们想的是,除了PCF之外,是否还存在其他更优的解决方案?
这是标准的Shadow Mapping(简称SSM)实现原理的示意图,可以看到,这是一个非常干脆的阶梯函数,这个阶梯函数的输出只取决于当前像素在光照空间的深度与对应的阴影贴图像素的深度值。
SSM的缺点是只能给出硬阴影,而无法给出现实世界中那样的具有柔软边缘的阴影效果。解决这种问题的一个方法是调整阴影计算的公式,使得给出的公式中能够包含投影物与承影物的深度值,从而最终的阴影计算可以使用线性过滤的方式来得到一种柔软的效果,在这个方向上,现在已经有很多方法走在了前面(VSM,Convolution SM以及ESM等)
SSM的阴影计算是一个二值输出的阶梯函数,首先的一个想法就是使用一个光滑的输出函数来代替这个阶梯输出。
实现的思路不是直接考虑投影物与承影物之间的深度关系,而是尝试使用概率论的方式或者近似的方式来给出方案。
通过这些方法,可通过硬件的mipmap特性以及贴图采样的模糊处理提前对投影物的深度数据进行一次过滤,使得阴影边缘变得柔软,从而避免再使用深度偏移的方法来减轻阴影毛刺问题。
概率的实现方法的核心在于一个公式:
f(dr) = Pr(do > dr)
计算出从阴影贴图采样的深度值大于当前像素的深度值(此时当前像素处于光照之中)的概率f(dr),1-f(dr)可以用作当前像素的阴影的Alpha值(Alpha为1,纯阴影,Alpha为0,完全暴露在光照之中)。
VSM通过切比雪夫不等式实现概率计算出当前像素被点亮的最大概率(实际上的概率可能会比这个小,这也是这种方法会存在漏光的原因)
VSM的优点在于只需要进行一次屏幕空间的处理就可以得到软影效果,性价比很高,可以消除深度slope过高的表面的毛刺问题。
VSM的缺点:
1.会需要额外的贴图用于存储VSM贴图
2.会因为方差过大而导致的漏光(VSM模糊半径越大,此现象越严重)
VSM漏光的主要原因是因为切比雪夫不等式计算的到的概率值永远无法等于0,因此导致无法得到纯正的阴影,且在当前像素到光源的路径上存在多个遮挡体,且这些遮挡体之间的距离与最近的遮挡体距离当前像素的距离的比值越大,导致得到的方差越大,此时切比雪夫不等式的到的概率值会更加接近于1,从而导致漏光。
减轻漏光问题的一些优化方法:
1.设定一个最小的概率阈值Pmin,当计算得到的概率值小于此值时,直接收缩到0,而大于此值的时候,根据范围进行一个缩放
2.最小的概率阈值Pmin是一个随着VSM模糊半径而递增的数值,当这个数值较大的时候,可能会导致一些错误的效果,比如说将一些原本应该点亮的区域涂成阴影。
这里给出的方法其实是治标不治本的,最好的方式还是换成其他方法,比如LVSM或者EVSM。
这里猜测会出现漏光问题的原因在于,只使用一次与二次数据所能提供的信息是非常有限的,不足以重构出概率计算的环境数据,因此导致在一些复杂的(多层遮挡)的投影物环境中会导致更严重的漏光。此外,需要注意的是,某个场景的概率分布函数需要通过所有阶数的数据才能表征,这个理论后面在讨论稳定可靠的SM方法的时候会用到。
不过即使这个猜测是成立的,这种方法的有效性也依然值得怀疑,毕竟当前计算一次与二次数据所需要的时间与空间消耗就已经不容忽视了。
假设当前像素的深度值总是大于等于SM中的深度值(也就是总是处于遮挡之中),那么如果采用指数的形式来重写阶梯函数,可以表示成上述公式(其计算的结果是阴影的浓度,也就是被遮挡程度),其中c是一个极大的整数,最终的计算结果会需要进行一次saturate处理。
使用这种指数形式的公式,可以将整个公式分解成occluder与receiver的项,从而使得指数阴影贴图可以通过多次采样进行blur处理(这个过程称为prefilter,区别于PCF对于测试结果进行模糊)。
ESM的实现步骤可以分成以下几步:
1.将阴影深度用指数形式存入到SM中
2.对SM进行模糊处理:对周边多个像素进行采样,加权平均(为ESM生成mipmap数据,或者分别进行水平垂直方向的高斯模糊)
3.在运行时对ESM贴图进行采样并与当前像素的指数深度进行相乘,从而进行阴影深度测试。
ESM有以下优点:
1.易于实现
2.能够消除阴影毛刺问题
3.能够实现软影效果
4.相对于VSM,只需要一个单通道贴图就能实现存储
5.不会出现VSM那种复杂场景中的漏光问题
从图中看出,常量c的数值越大,ESM越逼近阶梯函数,对于阴影的模拟效果就越好(越不容易出现漏光),不过如果c值过大,计算得到的数值可能会超出浮点数能够表示的最大范围(比如32位的浮点数,在使用c=88时,就可能会导致溢出),怎么办呢?
Bungie给出了一种解决方案:
1.渲染的时候,只存储线性深度数据,而非指数数据
2.进行模糊处理的时候,放在log space进行(也就是将多次指数叠加运算,变成一个抽取出一个共同的大乘数因子的叠加,从而使得叠加的结果不会超出浮点数能够表示的范围)
使用这种方法之后,可以在16位精度的浮点贴图上,也能够实现一个非常高的c ESM。不过到了这一步,ESM还依然存在其他的问题。
跟convolution shadow map一样,对ESM进行滤波处理的前提是,当前像素处于一个平面上——没看懂这句话,从前面的实现来看,滤波过程只是作用在Shadow Map上,跟屏幕像素完全没关系,为什么会出现这个假设呢?——这个假设是当初推导ESM的时候为了计算简化而给出的,最原始的模拟阶梯信号的函数采用的是
这个公式中r指的是当前像素转换到阴影空间的深度值,而oi则是shadow map上第i个采样点的深度值。为了能够将H(oi - r)简化成exp(k*(oi-r)),就需要保证r > oi,从而在k趋近无穷大的时候,分母中的1可以如愿被省掉,而只有当receiver处于一个平面之上(实际上,也可以处于一个朝向光源的凹平面,不过这种情况太特殊,可以直接忽略不考虑),才能保证当前像素对应的SM周边的采样点深度值oi不至于高过r过多(如果不是平面,那么可能就会出现周边区域的阴影深度值比当前像素的深度值还要大的情况),而如果高过r过多,原来的假设就不能成立,从而使得简化之前的公式计算的结果可能还是小于1的,但是简化之后由于指数的快速增长,就会导致一个超大的数值与一群较小的数值平均,其结果可能还是超大,再进行一次saturate的话,就会变成1,而1就对应着完全点亮,这就是漏光的表现了。
如上一页PPT所示,在dr逐渐增大到与超过do但是又与do相近的时候(比如当前像素处于投影物的背面的时候),此时正常的表现应该是完全处于阴影之中,但是按照ESM的计算,此时是会存在一点亮光的(漏光),这个问题被称为接触性漏光,实际开发中发现这种问题出现频率还挺高的。这个问题虽然可以通过增加c的数值来缓解这个问题,但是终归是无法消除,是否有更好的解决方法呢?
一个简单粗暴的解决方法,就是在最终计算的时候为输出结果乘以一个系数,进行加暗处理(darken处理)。
这种处理方法使用时存在限制的,那就是要求我们在生成ESM以及采样ESM的时候不能进行额外的计算调整(不能进行模糊处理),从而保证原本处于光照中的像素不会被错误的设置成阴影。当开启了ESM模糊处理之后,就不能使用这个trick,否则可能会导致更严重的问题,如果下图中的表现:
最开始的时候,我们以为CSM跟贴图模糊混合处理是解耦的,互不干扰的,但是在实际使用的过程中,为了使这二者能够正常工作,我们遇到了很多需要特意进行修复的问题(尤其是多个cascade之间的选择)。
CSM中cascade的选择通常是将当前像素在相机空间中的z值与对应的各个cascade的边界值进行比对来完成。
在为每个像素选择对应的VCSM(variance CSM)的时候可能会存在问题。因为我们的VSM是通过硬件来进行模糊处理的,不同的Cascade被渲染到一个texture array中,而在计算同一个quad中的相邻像素的cascade index的时候,可能会得到两个不同的结果,从而使得这两个相邻像素的深度值是从不同的cascade贴图中采样而来,其数值差异可能会有点大,且由于cascade index是决定光照空间中投影贴图转换矩阵的关键,因此也会导致最终采样的时候使用的贴图坐标的不同以及无效的微分数值以及错误的LOD层级(这些问题在PCF方法中是不存在的,因为不需要依赖于像素的微分与LOD层级来进行模糊滤波处理)。这些不一致可能会使得最终输出的阴影效果在两级过渡区域之间出现一条分界线,如下图所示:
这个问题主要源于同一个quad中的相邻像素选取了不同的cascade index,从而导致该像素的微分结果出现异常并引起mipmap过滤结果的异常,如果想要在VSM中使用mipmap过滤,就必须要修复这个问题。
假设对于同一个quad而言,其最多能够覆盖两个cascade(对于绝大多数情况下,这种假设是合理的,因为不太可能存在某个物体能够横跨两个cascade,太不合理)。在这个假设下,我们只需要保证当前quad的所有像素都对应于同一个cascade,就能够保证shadow map采样的微分数据是稳定的,mipmap的使用结果也是正常的了。
具体如何做呢,首先我们对1按照当前像素的cascadeIndex进行左移操作,其实就是相当于f(x) = 2^x,转换为2的指数形式。之后对这个数值进行三个方向的差分(水平,垂直,以及对角线?),按照前面的假设,如果当前quad中存在两个cascade,那么此时的nMaxDifference = 2^(x+1) - 2^x = 2^x.那么按照这个计算公式,那么有以下关系:
x=0, nMaxDifference = 1;
x=1, nMaxDifference = 2;
x=2, nMaxDifference = 4;
x=3, nMaxDifference = 8;
从而可以构建一个数组,将nMaxDifference-1作为索引进行访问,保证其结果与x相等就可以了:
array = [0, 1, 1, 2, 2, 2, 2, 3];
由于nMaxDifference是根据横跨整个quad的差分的最大值,因而保证了这个值对于当前quad的所有像素都是相同的,从而保证了所有的像素都对应于同一个quad。
此处还可以尝试将收缩各个cascade的覆盖范围,使之包裹住更高比例的有效像素,从而提升阴影的质量,降低前面提到的contact leaking问题。
在收缩cascade覆盖范围的时候,不可避免的会导致一些较远的物体处于覆盖范围之外,按照clamp的处理方式,这些物体就表现为一张贴在近平面或者远平面上的薄纸:不过由于这些物体时不可见的,因此不会因此而导致问题。
这里给出了ESM实现结果的一个对比,前者的阴影深度不如后者。这是因为由于进行了clamp裁剪操作,导致exp(k*(oi - r))变大,从而使得阴影深度变淡。
这组对比图就是违背了之前说到的receiver是planar的假设导致的异常,这种异常的表现是采样半径越大,问题越明显,且由于clamp裁剪的原因,使得{sum(exp(oi)) for i = 1, n}变大,从而使得问题更为加剧。
可以看出ESM的使用中也是故障频出,那么是否有更好的解决方案呢?
EVSM(Exponential Variance Shadow Map)是VSM跟ESM的结合,这种方法能够通过为每个SM像素额外增加四个浮点数据的存储量来达到有效降低VSM与ESM的漏光程度的目的,且EVSM不需要对深度范围进行裁剪(这是针对前面哪种方法的补救方案而言?)。
说是VSM与ESM的结合,但实际上从公式推导结果来看,其实EVSM更加偏近于VSM方案。而作为二者的结合,EVSM的表现效果上减少了ESM的接触性漏光现象,不过相对于ESM而言,又增加了Variance Light Bleeding(就是VSM的那种漏光现象),不过这种漏光相对于常规VSM的漏光而言其影响就微乎其微了。而且,由于EVSM不需要像ESM一样进行clamp裁剪,也就消除了前面说的那种问题的隐患,也不再会被planar receiver的假设所束缚。
不过,由于EVSM需要同时存储exp(ko)以及exp(ko) ^ 2,因而k的数值就需要进一步减小(32位的话,大概最多为40)以避免浮点数越界。此外,EVSM相对于VSM以及ESM的不足在于,可能需要更多的内存来存储数据。
这里给出了EVSM的shader实现算法,算法中,每个WarpDepth函数都会计算一组指数VSM数据(exp(kio) & exp(2ki*o)),在使用的时候,会计算一组正系数与一组负系数的数据,并在最终采样的时候使用数值较小的一个结果(降低漏光问题严重程度)。
这种算法实现的阴影质量很高,不过在一些复杂层次结构处依然存在漏光现象。不过,这些瑕疵可以通过对结果进行一个clamp来消除。
没有完美的方法,只有适合的方法,在具体使用过程中可以根据需要与实际情况,进行相应的修正与魔改。
GPU Pre-computed Lighting
采用光照预计算存在以下优势:
- 能够更好的利用GPU并行计算的优点(how?)
- 实现性能与表现能够跟随GPGPU(通用GPU,Compute Shader)技术的快速进步而提升
- 能够实现高质量的全局光
对于光照预计算,期望能够实现下述的一些目标:
- 能够应付大尺寸地图的需求表现(5~7百万面片)
- 能够支持多种光源类型
- 高性能的表现
- 实时预览
- 可以灵活控制的质量、时间的平衡
这里给出了光照计算管线的简要流程图,其中Direct Illumination以及Final Gather处理这两个阶段的耗时较长,因此尝试对这两个模块进行加速处理。
针对这两个模块,目前已经有人给出了对应的解决方案,具体可以参考给出的文献。
这里给出了实现算法的流程图,总的来说就是通过GPU的并行计算将工作量分摊到多个流程中完成。
这里给出了两种好评率比较高的K-D树构建方案。
直接实现全局光照主要需要完成以下两个功能点:
1.生成着色点(如果是用于预览的,直接使用ray trace实现;如果是用作生成light map,则使用图素存储)
2.投射光源对应的阴影射线
而通过间接实现全局光照则有以下优点:
- 间接实现的全局光的采样频率比较低
- 采样点会跟随几何体表面的法线变化率而增加(也就是说在变化较快的区域,采样点数目会多一些,符合需求),可以根据这个特性对采样点进行分类,分成一个个的cluster
- 只在cluster的中心进行采样
- 虽然采样点很粗糙,不过通过插值得到的效果其实已经可以满足需要。
这里简单介绍一下Photon Illumination Cuts(光照切片?)。这个切片跟light cuts比较类似:只需要对photon tree上的每个节点计算辐射亮度irradiance,之后通过K-D树计算“cut”数值,最后使用RBF基进行插值。
下面给出实现的效果图对比:
这里给出了某个场景中实现的各个部分的耗时统计。
直接实现间接光的目标依然无法实现,不过距离实现可交互的全局光的那一天已经越来越近了。