本文是对Crytek引擎在Siggraph 2013年上关于阴影实施技术陈述的学习文稿,前面是一系列的效果图,这里就不展示了,请有需要的同学自行查看原始PDF。
这里是本文的主题框架,包含一系列阴影实现方案。
这里是实时渲染中阴影部分的预算范围,包括时间消耗与内存占用等。
第一种要介绍的是延迟阴影(Deferred Shadows)方案。这种方案的具体实施与光源类型有关:
- 对于方向光如太阳光而言,通常是采用阴影mask方式来实现的(上图中左边的小图)。
- 所谓的阴影mask实际上是一种对阴影occlusion数据进行累加计算的特殊RT
- 在实际的着色计算之前,阴影mask会将多种阴影计算结果(如VSM,per-object shadows以及clouds shadows等)按照从上到下的方式组织起来
- 对于点光而言,其阴影将不需要进行这么复杂的中间过程,而是直接渲染到light buffer中(上图中右边的小图)。
CSM是最常见的阴影实现方式了,将view frustum按照距离远近分割成多个有轻微重叠的shadow frustum,之后为每个shadow frustum分配一张阴影贴图,从而得到由多个阴影cascade组成的阴影贴图组。
这里介绍了cascade的一些属性:
- 各级阴影贴图像素密度基本上呈对数分布(如果各级阴影贴图分辨率相同的话,那么其覆盖的范围将呈对数分布);
- 相邻两级阴影frustum需要有轻微重叠,避免区域遗漏
- 阴影frustum的朝向是固定的,因为光源方向是不变的
不论是点光阴影,还是CSM,都是以一种延迟渲染的方式得到。
以CSM为例,通过对frustum volume的绘制,会在各级shadow map对应的stencil上添加标记,标记出相机可见的像素,也就是阴影接收像素的范围,这样做的好处是:
- 可以使得cascade划分更为合理
- 可以在屏幕空间像素被多个cascade覆盖时,挑选出最高分辨率shadow map对应的cascade
- 可以提高阴影贴图空间的利用率
当光照方向恒定时,其实不需要每帧都进行全量阴影绘制,只需要对超出相机边界的区域以及动态物件进行阴影贴图更新即可,而一些远距离cascade则可以考虑分帧更新;对于最后一级阴影贴图而言,其对应的都是一些远距离物体,可以考虑使用VSM算法(由于距离较远,因而不需要考虑VSM导致的漏光问题)与shadow mask相结合的方法来得到一个大尺寸的软影效果。
点光阴影通常是采用将点光所覆盖的球体拆分成类似cubemap的六个2D区域,每个cube face对应于一张shadow map的方式计算得到。
每个cube face对应的阴影贴图的scale数值是不一样的:
- scale是基于阴影覆盖范围计算得到的
- 实际上,最终的scale数值实际上是阴影贴图密度分布函数的对数表示,这个函数会使用阴影覆盖范围作为参数
对于一些覆盖范围实在太大的cube face,还可以考虑CSM。
在shadow map scaling完成后,可以考虑将多张shadow map组合到一张texture atlas上面,避免进行多次shadow map绑定,也有利于降低阴影计算的DP数。
同样,对于每个阴影贴图而言,依然需要如前面CSM所说的,先用view frustum绘制对stencil进行标志以缩小有效阴影贴图的区域,降低阴影绘制的消耗。
CSM以及点光阴影对于一些大尺寸的几何数据的阴影有很好的展示效果,但是对于一些小尺寸的几何细节,其计算得到的阴影效果可能比较差。因此,对于一些需要展示其精细阴影细节的模型比如FPS中的枪械以及一些展馆场景中的物件而言,其常用的实现方式为per-object shadow。
per-object shadow是指为特定物件单独制作一张高分辨率的阴影贴图,最终会通过max()算子对全局阴影贴图与per-object阴影贴图进行混合,并将结果输出到shadow mask中。
而为了消除低分辨率全局阴影贴图(CSM,点光阴影)导致的自阴影噪声,可以考虑为存在per-object shadow的物体使用一个大数值的bias(用在绘制全局阴影贴图时)。
- 虽然低分辨率全局阴影贴图依然会考虑物件投射的阴影,但是大的bias数值,会过滤掉其self shadow
- self shadow主要来自于高分辨率的per-object shadow
下面介绍下软阴影的实现方式。
crytek engine的软影是通过PCF + 样本阴影空间随机旋转的方式实现的。
在运行时调整PCF的采样半径可以为不同区域制定不同程度的软影效果,这种方式可以得到很好的软影效果。
crytek引擎软影的基本思想:根据当前采样点到阴影caster之间的距离比例来调整当前位置阴影的软化效果(与原始的PCF想法类似)。
软影算法实现:
- 基于泊松分布的采样点会提前根据到中心点的距离进行排序
- 初始的filter半径是根据最大软影范围计算出来的
- 根据初始的filter半径,计算出平均距离比例(average distance ratio)
- 最终所使用的的采样点数目与平均距离比例是成正比的
- 由于采样点已经提前计算并排序完成了,那么减少采样点数目,就会同步缩小filter半径
- 根据前面计算得到的采样点数目计算最终的阴影值
CSM需要为每一级阴影提供单独的filter调整机制,一方面可以兼顾不同cascade的阴影质量,另一方面也需要保证两级阴影之间能够平滑过渡
PCF可以交给computer shader来实现,在这个过程中,可以将所有的预结算的采样点放到CS的共享存储空间,不论是filter距离计算还是后面的阴影数值计算,都可以重用。
crytek engine的面光源阴影是通过voxel-based的方法实现的。
整个场景会被转换成具有不同分辨率的uniform voxel数据:
- 能够实现大尺寸volume高效遮挡计算
- 能够为ray traversal进行分辨率自适应调整
- 可以考虑根据距离将场景分割成多个cascade来进行处理
需要实现对动态场景的体素化(voxelization)与下采样(这里说的下采样是对于directional occlusion数值的下采样)
下采样算法自适应:
- 避免每帧对场景中的静态数据进行更新处理
- 通过将bit-mask与前一帧bit-mask数据进行XOR(异或),根据bit位变动来决定是否需要进行下采样
场景体素化结果
directional occlusion下采样计算方法
体素数据下采样结果
面光源阴影可以通过cone tracing计算得到,从当前点朝着面光源所覆盖区域发射若干射线,通过统计这些射线中无阻挡比例即可得到当前点被光源点亮的程度。
通过cone tracing来计算遮挡射线数目的消耗通常比较高,这里给出一种近似的算法,那就是沿着从当前采样点P到光源位置的方向,分层次采集当前层次的遮挡面积占比,最终取遮挡面积比例最高的一层作为最终的阴影输出数值。
不过这个算法可能会存在误差,如上面两张图中的两种情况,其实都会导致计算结果不精确。
这里给出面光源阴影计算的效果与时间消耗,从性能消耗上来看还是挺让人震撼的。
此外,通过对8个均匀分布的圆锥进行如前面所说的voxel-based cone tracing采样,还可以得到世界空间中的Ambient Occlusion效果(要比SSAO效果更为逼真)
对于阴影与透明物体的作用,可以分成如下两种情况进行考虑:
- 对于半透的阴影接收者而言:可以在前向渲染pass中通过shadow map计算出对应像素的阴影数值
- 对于半透阴影投影者而言(比如烟,雾等):会需要沿着光照方向累计对应的shadow map像素上的alpha数值,并将之存储在一个与shadow map同分辨率的8bit贴图(称之为半透贴图)中
这里给出了透明投影物的半透贴图生成算法:
- 半透贴图像素的写入需要先对不透明物体生成阴影贴图所对应的depth进行深度测试,不通过的可以跳过后续步骤
- 对于那些不处于半透阴影之中的阴影贴图像素进行alpha累计(这个应该在上一步中就被深度测试所拒绝了)
- 需要添加一个alpha blend pass来对此前累计的alpha数值(需要保证顺序是从后往前)进行累加
- 对于CSM,应该要为每级阴影增加一张半透贴图
- 最终输出的阴影数值需要同时考虑到阴影贴图的遮挡关系与半透贴图的alpha数值,最终输出结果以二者阴影中较大的为准
对于所有的光源+环境光(ambient),都会通过SSDO方法(可以看成是SSAO的加强版)计算出contact shadows。
SSDO的核心思想:
- 通过计算出来的屏幕空间occlusion对光照结果进行调制
- 可以实现软影效果
- 同时可以移除由于shadow map bias过大导致的peterpan现象(阴影细节通过SSDO得到)
- 其实施效果要优于SSAO(相对于SSAO,考虑了输入光源的光照方向)
directional occlusion(DO)信息可以通过一种延迟的方式获取(屏幕空间):
- 跟当前的光照管线能够很好的匹配起来
- 由于实现高效,能够适应多光源的应用场景
遮挡信息计算:
- 在SSAO计算的过程中,同步存储bent normal ,这个法线指的是每个像素的平均未遮挡方向
- 这里需要剔除self-occlusion(为什么要剔除这一项?)之后的干净SSAO计算过程,且需要使用一个相对较大的处理半径(半径过小,遮挡效果看不到)
对于每个光源:
- 先计算
- 再计算
- 中心位置的采样点depth是全分辨率的,而其他采样点则是FP16的半分辨率depth(啥意思)
- 将occlusion比例(这个应该是SSAO的输出结果)乘以(这一项大概是用来表示当前点所在的平面受周围像素遮挡影响的幅度。也就是Directional Occlusion中的Direction部分)得到的结果用于对光照数据进行attenuate处理。
屏幕空间自阴影实现方法使用了一个小小的trick来实现近似模拟:
- 沿着屏幕空间中光照向量的方向进行ray casting计算
- 指定受影响的depth buffer的范围
- 射线长度追踪还可以用于计算输出一定的软影效果
体积雾效果实现算法是以Real-time Volumetric Lighting in Participating Media(TOTH09)为基础实现的,不同的是,这里不再是对in-scattering light进行累加,而是沿着视线射线对阴影贡献值(shadow contribution,大概就是射线上每一点是否处于光照中的意思吧)进行累加。
这里给出实施细节:
- 与原算法一样,也是在光源空间也就是shadow space中进行
- 依然采用interleaved sampling算法来降低渲染消耗
- 每个pixel block包含8x8个相邻屏幕空间像素
- pixel block上的所有采样点在射线上的位置是均匀分布的
- 为了进一步降低渲染消耗,将累加过程输出RT分辨率降为屏幕Native Resolution的一半(1/2 * 1/2)
- 最终输出的shadow数值还需要通过一个gather pass对pixel block中的shadow contribution进行累加得到
- 通过双边线性滤波算法消除物件或者相机移动带来的重影,以及采样过程中的孔洞
- shadow contribution存储在一张8bit的depth贴图中
- 最终输出到屏幕空间上的结果,会采集周边8个相邻像素shadow depth数据与当前像素depth比对的结果通过加权平均作为输出(类似PCF)
- 支持对最大采样距离进行配置(150~200m)
- 最终输出的结果还会将Cloud产生的阴影考虑进去
- 最终输出的颜色结果需要考虑到fog height与径向颜色数值。
这里给出了两种上采样算法的实施效果对比,可以看到双边滤波上采样算法给出的结果更优,Naive Upscale算法会导致处于fog中的物体被模糊的瑕疵。
在CSM模式下体积雾算法的适配细节:
- 射线从相机出发,终点为屏幕空间像素对应的surface position,这个射线的投射过程全程放在shadow space中完成
- 对于射线上靠近相机近平面的位置,会给出更多的采样点
- 多个shadow frustum以级联的方式存在
- 相邻shadow frustum应该要有一定的重叠区域,避免出现数据缺漏
- 在shadow frustum重叠区域,总是选择分辨率较高的frustum数据进行计算
- 使用全局参数坐标来存储当前射线与当前shadow frustum的交点位置(没太明白这样做的目的,可以在上一级frustum shadow终止之后判定是否需要进行下一级shadow frustum的ray marching)
- 不需要在多级shadow map中进行re-project
- 给出了一个优化过的射线clip函数,可以直接对全局的参数坐标进行修正,可以实现随时ray marching终止。
下面来看下CSM算法中各级shadow frustum的在不同划分模式下的表现:
这里给出了随CSM覆盖范围变化而变化的划分结果,主要是两组数据对比,第一组固定远裁剪平面,第二组固定近裁剪平面。
这里给出的是第一组对比数据的图形化展示,共6个划分结果,横坐标是划分级别,纵坐标为划分距离。
这里给出了CSM划分的一些考虑要点:
- 需要考虑划分后的cascade之间的重叠问题
- 对于靠近近平面的cascade,如果直接使用精确的对数划分算法,会存在一定的困难(意思是重叠问题会比较明显吧?)
- 因此,对于靠近近平面的cascade划分,可以考虑人为调整
- 相机近平面距离以及FOV对于划分的cascade表现有很重要的影响
- FOV越大,划分后的shadow frustum的重叠区域就越大
- FOV越大,场景中不可见部分在阴影贴图中的占比就越大,浪费就越严重(是这样吗?shadow frustum中超出view frustum的区域占比越高)
- 近平面距离相机越近,shadow frustum的重叠区域就越大(从前面的数据与图表中很难直观得出此结论,可能需要添加一个辅助公式进行说明)
- 对于有限的相机深度范围,最终划分得到的CSM box应该要尽可能的紧凑(啥意思?意思是尽可能的缩小shadow frustum的有效包裹范围,比如剔除shadow frustum一前一后的空白区域,所谓的空白区域指的是在相机空间中没有接受阴影物件的区域)
这里给出了缩小shadow frustum包裹范围的效果对比,阴影质量有明显的提高。
shadow frustum对齐方向有两种策略,分别是与光照方向一致(light space)以及与相机视线方向一致(view space),下面看一下两者的区别。
-
相机方向对齐策略(接受阴影的物件的区域无重叠,为了保证阴影结果的正确性,投影区域还是有重叠的):
- 阴影空间利用率更高(因为重叠率低了,所以利用率高了?)
- shadow frustum重叠率更低
- 阴影贴图采样密度更高(采样密度如何计算?阴影贴图有效分辨率与对应的屏幕空间对应的阴影覆盖范围的分辨率的比值?按照这个定义来说,是符合此说法的,毕竟重叠率低了,阴影贴图上的所有像素基本上都是有效的)
- 在阴影贴图under-sampling(阴影贴图分辨率过低,导致屏幕空间采样时而采到上一个像素,时而采到下一个像素)的时候,会使得阴影质量不稳定(当相机旋转移动的时候,会导致阴影锯齿或者抖动现象)(这个问题light space alignment策略也会存在,可以通过修正shadow map投影矩阵,使之按照整像素进行更新来修复,不过如果这里是相机旋转的话,那么这种修复方法可能不一定能生效)
- 由于投影区域的重叠,所以同一个物件可能会出现在多个shadow frustum中,因此绘制shadow map所需的消耗会更高
-
光照方向对齐策略(考虑到阴影是通过正交投影方式产生的,因此shadow frustum的形状多为立方体,因此投影区域跟承影区域都是有重叠的。):
- 由于shadow frustum重叠范围的增加,阴影空间利用率降低
- 可以更好的兼容CSM Caching策略
- 支持按照shadow map整像素更新投影矩阵,修正相机移动旋转导致的闪烁
CSM中导致阴影锯齿的原因分析:
- 阴影贴图采样密度过低(可以直观理解为阴影贴图有效分辨率过低)
- 阴影贴图格式精度较低(depth bits过少,导致浮点数精度不足)
- 光照方向与视线方向接近平行(导致投影到shadow map上的物体在shadow space中的depth range过大,从而使得同样的浮点数精度,每一位对应的分辨率下降,即实际表达的浮点数精度不足)
应对阴影锯齿问题的多种策略:
- 对于太阳光阴影而言:物体的投影采用front face渲染,渲染过程中开启slope bias
- 对于点光阴影而言:采用back face渲染(针对室内场景,比如墙壁之类的,对于室内的其他物体,应该还是要使用front face渲染的)
- 对于远距离物体,可以考虑使用VSM来模拟软影实现(相对于PCF而言,消耗更低,缺陷在于会存在light bleeding,不过如果物体距离较远,这个现象就不太明显了),为了进一步降低light bleeding,建议开启双面渲染
在延迟阴影pass中,通过constant bias来规避或者减弱阴影贴图精度不足导致的锯齿问题
CSM算法在业界使用中的一些现状特点:
- 游戏中所使用的阴影贴图大多采样密度较低
- CSM划分策略不够合理
- 为了保证阴影质量,通常会使用一些渲染小trick,比如说整像素shadow map更新以及单物件阴影等
- 没有一套普适性的自动调整策略,大多需要为每个场景进行单独的shadow map配置
这里给出本文尝试解决的一些问题或者说希望能够达成的目标:
- 消除或者降低shadow frustum之间的重叠程度
- 移除或者减少shadow map中的无效像素
- 缩小阴影贴图中哪些在相机视角中不可见的部分
- 希望能够得到较高的阴影贴图采样密度,从而避免阴影贴图整像素更新的trick的使用
- 希望达成场景中的所有区域的阴影贴图采样密度基本一致的目标,这样有助于减弱阴影锯齿的影响
倾斜投影(oblique projection)的定义:
- 这是平行投影的一种(正交投影也是平行投影,不过投影方向与投影平面垂直)
- 从三维物体上引出平行线,将平行线与投影平面相交,得到的结果就是倾斜投影
- 与正交投影不同的是,倾斜投影的平行线与投影平面不垂直
倾斜投影可以通过两个角度参数来定义投影行为,这两个角度参数与含义给出如下:
,指的是被投影点(x, y, z)与投影点()在xy平面上的投影点的连线()与投影平面之间的夹角
,指的是被投影点(x, y, z)与投影点()在xy平面上的投影点的连线()与x轴的夹角。
如果用L表示()的长度,L1 = L / z的话,那么倾斜投影的投影矩阵可以给出如上图所示。
关于投影公式,这里有两个疑点:
这个矩阵揭露的规律显示,投影只跟有关而跟无关,这是第一个疑点 —— 实际上L的计算与有关,令,那么有如下的计算公式:。
根据投影矩阵可以得到:,而实际上根据两个角度的定义,正确的公式应该是:,这是第二个疑点(猜测应该是书写错误)
从这个定义来理解倾斜投影,可以将之理解成,xy存在一定的偏移,偏移量与投影方向的倾斜程度有关,实际上正交投影可以看成是一种特殊的倾斜投影,即投影方向与平面的法线方向重合,xy无偏移下的倾斜投影
在CSM中应用倾斜投影的具体做法:
- 使用前面定义的倾斜投影计算矩阵
- 将view frustum的裁剪平面(这里的裁剪平面并不仅仅是远近裁剪平面,实际上应该包括上下左右前后共六个平面的裁剪平面)看成是倾斜投影的投影平面
- 从5个view frustum平面中选出当前cascade的投影平面(不考虑远裁剪平面)
- 根据光照方向选择阴影投影的倾斜投影平面
- 平面法线跟光照方向的点乘结果的符号相同的多个平面用作倾斜投影的平面(为了能够完整覆盖view frustum,需要选取多个view frustum平面作为倾斜投影平面,具体实例见下图)
对于这样的view frustum跟light direction而言,可以选取左裁剪平面,下裁剪平面以及近裁剪平面等三个裁剪平面用作shadow frustum的倾斜投影平面。
倾斜投影实现细节:
- 根据对数分布,将投影平面分割成不同的plane segment(细分平面)
- 每个plane segment对应于一个shadow map cascade
- 各个plane segment对应的shadow map分辨率相同,且距离(相机原点)越远的segment覆盖的范围越大
- 在CPU上使用一系列的倾斜frustum完成对投影物件的culling处理
按照这三个倾斜投影平面,可以将整个view frustum分割成L4+B4+N1等9个plane segments,也就对应于9个shadow cascade,其中8个(L4,B4)shadow frustum倾斜投影平面是梯形的,1个(N1)shadow frustum倾斜投影平面是长方形的。
下面看下换个光照方向下的plane segments划分:
在这种情况下,view frustum的裁剪平面并不能当成是shadow frustum的远裁剪平面,因此还需沿着光照方向继续向前延伸到更远的位置(比如延伸到view frustum的远裁剪平面等),以保证view frustum中的相关物件的投影表现是正常的。
前面说过,部分plane segments的投影平面形状是梯形的,部分是长方形的,这里就有两种shadow map规划策略:
将投影平面形状以长边为准,扩展成长方形(如下图所示),有如下的一些特点
- 需要较多的plane segments才能得到对数划分CSM效果的近似模拟(啥意思?)
- 因为增加了不可见区域在shadow map上的占比,会导致shadow map的浪费
使用view camera透视变换方式将梯形适配到矩形shadow map中
- 通过对相机的近裁剪平面进行shift & expansion(相当于将近平面形状拉伸成与远平面形状一致,使得原有的透视投影转变成正交投影?),实现适配
- 不会导致shadow map的浪费
- 在shadow map采样密度足够高的情况下,也不会因为相机的移动与旋转而导致阴影的抖动
plane segments的划分按照对数规律实现。
下面看下倾斜投影实施过程的示意图:
这里将一个茶壶通过倾斜投影的方式投影到了多个plane segments上
之后在渲染场景物件的时候,根据当前物件所对应的plane segments,取用不同的shadow map来进行阴影的计算与绘制。
移除掉不相关的plane segments之后,展示效果如上图所示。
这里给出的是选择的另外一种plane segments时的倾斜投影效果展示。
倾斜投影shadow map实施的一些特点:
- 所有的shadow map能够完整的覆盖整个view frustum空间
- 投影到shadow map上的相机不可见区域较小,因此shadow map的浪费也相应得到降低
- shadow cascade之间的重叠率大大降低,进一步缩小了shadow map的浪费
- 由于每个投影物体都被投影到了最为合适的shadow map上,因此对应shadow map上的采样密度将得到保证,足以消除此前因为采样密度不足导致的阴影锯齿问题
- 不论光照方向如何,都能保证接近于常数的shadow map的采样密度(此前在光照方向与视线方向接近一致的时候,会因为shadow volume尺寸交到而使得采样密度不足,从而引发锯齿问题,而在这种情况下,通过倾斜投影,shadow volume的尺寸并不会存在太大的差异,使得采样密度能够得到很好的保证),从而降低因此导致的锯齿问题。
下面看下倾斜投影方案的一些实施细节,分为阴影贴图绘制阶段与阴影贴图采样渲染阶段两块:
阴影贴图绘制:
- 每个plane segment都是单独处理,与其他plane segments不构成耦合关系
- 在必要的时候,会通过GS对那些同时处于多个plane segments覆盖范围的面片进行复制处理(从而实现,一遍绘制,多个shadow map输出,避免DP增加?)
- 根据view space中的Z坐标在view frustum plane中选择对应的plane segment
阴影贴图采样使用,根据渲染方式的不同,有不同的用法。
在延迟阴影绘制中:
- 根据当前像素所处的位置,采取对应的倾斜投影shadow map
- 由于shadow map之间不存在重叠,因此不再需要通过stencil来对屏幕像素进行标记
- 使用texture array来实现多张shadow map之间的组织
在前向阴影绘制中,直接根据当前被绘制物体表面上的像素所对应的shadow segment区域采取对应的shadow map进行绘制即可。
在Clustered前向/延迟着色管线中:
- shadow frustum依然没有重叠
- 依据cluster与shadow map之间的一一对应关系进行采样处理即可
倾斜投影相对于传统CSM的一些优势:
- 贴图利用率更高
- 阴影锯齿问题得到更好的抑制
- 阴影贴图采样密度得到很好的保障
- 如果阴影贴图分辨率足够高的话,可以做到完全消除阴影锯齿问题
下面给出一些实施效果图展示:
这里给出本文的内容回顾。