这里分享的是Bo Li在Siggraph 2019上关于阴影渲染优化的文章,这里是原文传送,这里是PPT链接
Bo Li在Siggraph 2019中分享的是一种实时支持大量灯光阴影效果的技术方案,在当前的技术背景下,因为性能等各方面的考虑,在主流的渲染引擎如UE以及众多3A游戏上,都没有办法支持大量光源的实时阴影。
这里先给出渲染管线的流程图,这里先根据这张图对流程进行简单的推测,为了降低计算复杂度,在每一帧开始的时候会对场景有影响的光源按照tile进行划分,之后逐tile进行Deferred-Shadow预Deferred-Shing处理。Static Shadow与Dynamic Shadow所使用的Shadow Map会通过一个预先分配好的Shadow Map Pool进行分配,这个Pool目测应该是常驻GPU的,在阴影贴图更新完成后,会对一个Bindless Texture Table(目测与前面CPU填充的Light Data Array相匹配)进行更新,指定各个光源对应的shadow map。经过Tiled-Deferred Shadow处理后,会输出各个光源在Tile上的Shadow Filtering结果,之后按照各个光源的参数计算出其阴影的权重(存储在Shadowed Masks中),并使用此权重进行阴影叠加输出。
想要在实时的条件下支持大量光源阴影的动态更新,在当前的硬件环境下是很困难的,因此这个技术的首要任务是将静态阴影(静态光源+静态物体)尽可能的缓存下来。
1. Shadow Maps
每盏需要投射阴影的光源需要应付的物件大致可以分为如下三类:
- 静态物体
- 带有静态boundingbox的动态物体(物体的变化主要通过顶点动画完成)
- 动态物体
物体的类型不同,阴影计算的方式(比如更新频率,裁剪算法,fading距离,shadow map分辨率等)也会有所区别,这里的做法是每盏光源需要维护三张贴图:
- static shadow buffer,记录静态物体的shadow map
- dynamic shadow buffer,记录动态物体的shadow map。
- Dirty-Mask texture of dynamic shadow buffer,此贴图中的有效数据表示的动态物体的覆盖区域,后面在对动态阴影贴图与静态阴影贴图进行filtering判断(判断当前点是否处于阴影中)的时候,用于降低filtering的消耗,只对有效区域进行叠加判断处理(相当于stencil),这张贴图通常会使用非常低的分辨率以减小损耗。
其实由于动态阴影贴图是每帧更新的,因此这里或许不用额外的Dirty-Mask texture,而是直接将static shadow map复制一份到dynamic shadow map,之后再在这张贴图的基础上完成动态物件的阴影生成,这样应该会具有更低的消耗吧?
PPT中有陈述,这种做法效率太低,因为当动态物件只占用很小的一部分像素时,进行一个大RT的拷贝消耗相对来说代价就太高了,因此这里直接使用一个mask来完成动态阴影的指示,虽然在使用的时候需要进行两次采样,且还需要一定的空间存储mask贴图,但是实际上执行效率却更高一点。
由于Mask贴图关系着最后Shadow Filtering是否需要考虑Dynamic Shadow Map的作用,且Dynamic Shadow对于质量的影响非常显著,因此Mask贴图需要做到保守覆盖,即在dynamic shadow 覆盖范围上进行一个外扩。在实现上,Mask贴图是直接绑定到一张UAV上的,这样可以在PS中将数据写入到周边像素上,在PS执行时,如果当前像素时被动态shadow覆盖的,那么就按照一定的kernel size将mask 贴图周边的像素也一并填充了。
后两张贴图是用于生成动态阴影的,这两张贴图会在同一个render pass中生成出来。部分光源如果只需要生成静态物体投影,不需要考虑动态交互,那么后面两张贴图就可以直接省掉。
对于前面说到的第二类物体(比如带顶点动画的树木),如果将之当成动态物体,会需要大量的Draw Call,而如果当成静态物件处理,就会导致阴影效果不匹配。这边的做法是,在较远的时候,将之当成静态物件处理,只有距离足够近的时候,才会将之当成动态物件。
2. Updating Strategy
在需要投影的光源较多的情况下,即使每帧只更新动态阴影贴图,其消耗依然无法承受,为了提升渲染性能,这里需要设计一个shadow map更新策略,这里的做法是根据如下因素来决定哪些光源的shadow map需要更新:
- 光源覆盖范围在屏幕上的占比
- 光源亮度
- 光源可见性?
- 用户自定义的分辨率参数?
最后,根据上述一些因素输出各个光源在当前帧中的贡献度,之后按照时间片算法进行分帧更新,这样就可以足够低的消耗得到不错的显示质量。
3. Shadow Map Allocation
每盏光源对应的shadow map都是提前在一个金字塔结构的贴图池中分配好的,所谓的金字塔结构指的是分辨率越高对应的槽位越少(比如一张2048,4张1024,16张512等),在为光源分配shadow map时(这里的分配包括静态shadow map与动态shadow map的分配,因为静态shadow map是使用压缩算法进行存储的,因此在使用的时候需要解码出来,塞入到一张shadow map中,因此这里也需要一个分配过程。),会考虑当前光源在屏幕中的覆盖范围,之后遵循一个事先给定的pixel-texel比例(比如1:1,即保证shadow map分辨率之和刚好等于屏幕分辨率)分配对应分辨率的shadow map,为了做到pixel-texel ratio恒定,就需要根据视角来调整动态shadow map与静态shadow map的分辨率。
下图给出了PS4/XBox One中的贴图分配情况,同时给出了需要分配的某个分辨率的贴图,但是槽位已经满了时,要如何解决冲突,这里的做法是,先计算出需要分配shadow map的光源的重要性,根据这个重要性确认其最佳匹配的Level,当这个Level槽位已经满了,就去下一层Level进行寻找,虽然这种做法会导致新加入的光源的阴影质量降低,但是可以避免此前已有光源由于shadow map分辨率骤变而导致的shadow popping问题。
按照这种分配策略,无论场景多复杂,所有光源所消耗的shadow map在内存中的尺寸基本上都是恒定的,可以用screen_pixels_num * pixel-texel_ratio * avg_shadow_overlappings来给出,大多数情况下不需要从池外分配空间给shadow map,实际上也应该极力避免这种做法(除了会导致内存占用增加,还有什么弊端?)
4. Shadow Map Packing
得到各个光源输出的shadow map之后,下一步就是将之传递到GPU供pixel shader取用,这里选用的是将所有的shadow map塞入到有flat texture descriptors数组组成的uniform buffers中(相当于用buffer数组对数据进行存储而非使用贴图,这是为了便于进行压缩与稀疏存储),这种做法相对于传统的shadow map atlas以及texture array而言,会减少很多如下限制:
- 贴图尺寸
- 存储位置
- 贴图格式
之后在GPU上,会按照tile或者cluster对光源进行剔除处理,以降低深度复杂场景中的计算消耗,剔除处理的结果为每个tile或者cluster一个shadow indices array,指示哪些光源的阴影会对当前tile/cluster产生影响。
这里有一个不得不说的问题是,在tile rendering中的一个处理逻辑是需要将场景划分成tile,之后计算影响每个tile的light list,之后shading的时候可以跳过那些无贡献的light处理。对于shadow来说,因为如果需要对每个light的shadow map进行filtering,其消耗是不可承受的,因此也有一个同样的处理过程,且这个进行light culling的时候,其逻辑跟light culling for shading还有所不同,总的来说,这里有两种剔除方案:
- 跟light shading类似,直接使用ligt投射阴影范围的boundingbox进行剔除
- 在tile上增加一些采样点,判断这些采样点是否处于光源的照射范围之内(boundingbox是六面闭合的,照射范围则是至少有一面是不闭合的)
为了减少误差,这里对shadow samples的排布进行了规划,如下图所示,保证tile划分成gird后至少每一行或者每一列都有一个sample:
虽然这种做法可能还会有细小的光源会被漏掉,但是总的来说质量还是令人满意的。
上面这张图给出了两种剔除算法的误差,中间boundingbox剔除明显存在false positive的问题。
在进行shadow filtering的时候,由于GPU的VGPR(向量通用寄存器)是有限的,为了避免浪费,在生成每个tile的Light List之后,首先通过bindless texture将所有的shadow map都绑定到GPU上,之后通过dispatch-indirect将点光与聚光灯是放在不同的pass中进行处理(两者使用的VGPR数目是不同的)。
dispatchindirect这是DirectX的一个接口,OpenGL中与之相对应的接口为glDispatchComputeIndirect ,这个接口会使用bufferresource的参数(避免数据在CPU/GPU中传递)来调用ComputeShader,在CS中生成执行逻辑所需要的数据之后,通过这个接口可以将不同的执行逻辑分配给不同的threadgroup进行执行
Shadow Filtering的结果会被塞入到一个紧凑的buffer中,这个buffer可以根据情况决定是否需要进行压缩,后面会被用在shading/lighting中。
由于这里光源的阴影都是通过shadow map产生,因此很多诸如局部体积雾等效果都是可以直接支持的(与lightmap shadow不同)。
5. Shadow Map Compression
为了降低存储消耗与带宽传输,这里会考虑对shadow map进行压缩,而由于动态物体通常距离相机较近,所以对精度的要求会较高,因此dynamic shadow map不参与压缩。static shadow map的压缩是在绘制完成后进行的,整个压缩过程发生在GPU上。(本段文字属于个人推测,有不同想法的同学欢迎辩驳)
为了适应不用的应用场景,这里给出了两套shadow map压缩算法:
- 可变比特率(variable bit-rate)压缩算法,这个算法是用在static shadow map上的,因为压缩率高,可以将static shadow map在离线的时候生成,之后直接塞入到包体里,从而避免运行时的static shadow map的生成。
- 基于GPU的TSVQ(Tree-Structured-Vector-Quantization,树状向量量化)算法,用于对deferred-shadow light mask进行压缩,用于减小overlapping lights在shadow map上的带宽与内存消耗。
5.1 可变比特率压缩算法
为了能够在有限的内存消耗下支持足够多的static shadow map,这里还给出了一种基于GPU的自适应四叉树压缩算法,在这个压缩算法作用下,可以达到平均1/30的压缩率,且质量上没有太多损失。
上面给出了整个压缩算法的实现框架,这里将整个shadow map(主要是static shadow map,分辨率为512x512)分割成32x32个像素(注意,上图中的quad指的是pixel,而非tile)的block/tile,总计16x16=256个block,主要可以分成如下几步:
- 每个像素在渲染的时候,会按照Depth Plane或者Packed float4(每个tile中各个像素的编码方式是统一的,不同tile之间编码方式不必相同。这里一个问题是,为什么选定tile作为统一编码的单位,不选用pixel或者quad或者全屏幕呢?首先,每个tile统一的编码方式可以最大程度的减少Depth Plane 还是Packed float4的标志位存储消耗;其次,32x32像素实际上是很小的一个区域,对于近景物件而言,这么大的一个triangle是很常见的,因此近景物件在depth plane上的占比会高一些,而远景物件如果比较小而密集的话,可以考虑使用Packed float4)的编码方法进行编码
- 之后会将2x2个像素看成一个单元(Packed Quads,每个单元对应一个32bits的Entry,所有的Entry组成CodeBook)对数据进行合并处理
- 对各个Packed Quads进行排序,并去重,保证最终输出的Entry都是不相同的
- 每个Packed Quads只需要存储这个Entry的Index,将Index数组与CodeBook输出
- 将结果以稀疏四叉树的形式保存,完成数据的压缩。
- 上面的CodeBook与稀疏四叉树的输出都是针对每个tile而言的,即每个Tile都有自己的CodeBook与稀疏四叉树。
正常来说,三维平面方程需要使用四个参数来表达:AX + BY + CZ + D = 0,但是,如果不考虑C=0的情况(在Depth Plane这种情况下,平面退化成一条线,在depth这种情况下是没有贡献的,可以直接忽略),因此可以将方程两边除以C,从而只使用三个变量来描述:A/C X + B/C Y + Z + D/C = 0,转换一下格式就是:dx * X + dy * Y + Z + ZPlane = 0,而这三个系数就是输入数据中前三个分量(每个分量用8位表示),最后一个分量Raw Depth可以看成是这三个系数的共同的偏移,通过这种方式来抵消8位系数精度不足的问题。
这里给出的两种编码算法并不是恒定的,而是根据当前tile中的depth分布进行调整的,简单来说,如果整个tile都基本分布在一个triangle上(即处于共面状态),那么最终就会使用depth plane进行编码;否则就使用Packed float4进行编码
Packed float4有很多实现方法,比如最简单的就是四个浮点数共享一个exponent,通过这种方式来降低浮点位数减少导致的精度损失
按照这种处理方式,即使某个物体表面上存在孔洞,那么大部分的像素依然能够使用Depth Plane进行压缩,只有孔洞附近突变较大的情况需要转换成Packed float4进行压缩,因此可以最大化压缩效益。压缩输出之后,在使用的时候,按照方向操作进行解码,就能得到还原度较高的shadow map了。
这个压缩算法的整个过程是在Compute Shader中完成的。
下面给出这个算法的实施效率与质量:
在PS4上面,压缩一张1024x1024分辨率的贴图,只需要消耗0.36ms,解码则只需要0.048ms,在大部分情况下贴图的压缩比未20:1(最恶劣的情况为所有tile都是用packed float4,此时压缩比为1.45:1,最好的情况为所有tile都是用depth plane,此时压缩比为512:1)。
动态shadow map的分辨率较高(这里使用的是2048x2048),且由于大部分空间都是空白的,为了降低内存消耗,这里也会对其进行同样的贴图压缩,但是在解压的时候,由于大部分区域都是不用的,因此没有必要全部解压,可以根据Dynamic Shadow Mask来确定哪些Tile需要解压,这里给出了全解压与部分解压的实施效率对比:
5.2 TSVQ压缩算法
高占用率是实现高GPU性能的关键,因此这里选用了Deferred-shadow算法来分离lighting与shadowing计算。
传统的Deferred-Shadow算法需要使用大量的临时mask贴图(shadow/light mask指的是根据屏幕空间深度贴图与对应light的shadow map计算出来的屏幕分辨率的shadowing factor贴图,每个像素对应的是当前光照作用下,此像素的阴影数值)来标记每个像素上的shadow value,按照这种做法,如果只考虑16盏光源的阴影输出,每个像素使用8bit计算shadow value,那么总共需要128M(业界的4K分辨率指的是3840x2196分辨率来计算):
针对这种情况,这里设计了一种TSVQ压缩算法,算法思路如下图所示,将4x4个像素看成一个block,在离线的时候训练出4096个block pattern,组成codebook,之后在运行时根据匹配程度为每个block指定一个pattern的index,只需要12个bits即可表达一个block
在这个算法的基础上还可以进行如下的优化:
- 全黑block或者全白block可以直接跳过,不用index指引,直接使用一个约定好的数据即可表达
- 将codebook数据结构构造成一个完全平衡四叉树,用于降低编码的比对消耗
- 通过指令(MSAD4+LaneSwizzle)级别的优化进一步提升实施效率,从而可以得到一个完全无分支的内部循环
MSAD4 用于进行block匹配
LaneSwizzle用于实现线程间的快速同步
实现源码:
uint CompressVQ(float Shadow, uint2 Gtid : SV_GroupThreadID, uint GroupIndex : SV_GroupIndex)
{
uint SrcPixel = uint(Shadow * 254.99f + 1.f) << ((GTid.x % 4) * 8);//0 is special number for msad
SrcPixel |= LaneSwizzle(SrcPixel, 0x1F, 0, 0x1);
SrcPixel |= LaneSwizzle(SrcPixel, 0x1F, 0, 0x2);//Collected 4 neighbor pixels
uint CurrIndex = -1;
[unroll]
for (int i = 0; i < 6; i++) //CodeBook size 4096=4^6
{
CurrIndex = CurrIndex * 4 + 4; //QuadTree next level
uint MatchErr = msad(SrcPixel, uint2(VQCodeBookBuffer[(CurrIndex + GTid.x % 4) * 4 + (GTid.y % 4)], 0), 0);
MatchErr += LaneSwizzle(MatchErr, 0x1F, 0, THREADGROUP_SIZEX); //Accum next line
MatchErr += LaneSwizzle(MatchErr, 0x1F, 0, THREADGROUP_SIZEX << 1);//Accum 2 lines away
uint MatchErr_Index = (MatchErr << 8) | (GTid.x % 4); //Pack index for deterministic order
MatchErr_Index = min(MatchErr_Index, LaneSwizzle(MatchErr_Index, 0x1F, 0, 0x1));
MatchErr_Index = min(MatchErr_Index, LaneSwizzle(MatchErr_Index, 0x1F, 0, 0x2));
CurrIndex += MatchErr_Index & 0xf; //Broadcasted best matching of the four children
}
return CurrIndex;
}
下面是显示质量对比:
从效果上来看,给出的TSVQ压缩算法可以得到0.75bit/pixel的内存占用,将内存占用从128MB降低到了12MB,大大优化了内存消耗,且显示质量上可以与4bits/pixel的压缩相媲美。
6. Shadow Bias算法
由于场景中的shadow map分辨率各式各样,如果完全使用同一套bias设置,效果会变得非常差,而如果为每种shadow map分辨率设定一套参数,可能需要大量的手动调整,且质量也不见得好,这里给出了一种自适应的bias计算方法,经验证可以得到比较鲁棒的shadow表现(给出了手动计算的逻辑SW与硬件自动处理的逻辑HW):
//HW:
RasterizerDesc.DepthBias = Epsilon; //(1 is a good epsilon choice)
RasterizerDesc.SlopeScaledDepthBias = Max_Filer_Kernel_Size; //(ex 3.0f)
//Note: HW implement max(ddx(z), ddy(x)), you might want to use lager value
//SW:
ShadowDepth += Epsilon / 65535.f; //For R16_Depth
ShadowDepth += (abs(ddx(ShadowDepth)) + abs(ddy(ShadowDepth))) * Max_Filer_Kernel_Size;
从逻辑上来看,并没有什么特别的处理,为啥能得到鲁棒的表现呢,难道是Max_Filer_Kernel_Size计算有什么讲究?(从对比图上来看,目的主要是为了消除大平面情况下的毛刺问题,并未完全消除任意几何平面上的瑕疵问题)
由于前面已经得到用平面方程表达的shadow depth,因此这里可以根据平面方程计算出Max_Filer_Kernel_Size,用于消除自阴影瑕疵。
效果对比:
7. 性能表现
在同等的环境背景下,当前算法的执行效率是UE4的十倍,且能够同时支持超过一千盏实时投射阴影的光源,下面给出相机在场景中匀速移动时,UE渲染消耗(绿色)与当前算法渲染消耗(黄色)的对比图,可以看到,当前的算法表现十分平稳,而UE当前的阴影算法则存在较大的波动:
在一个使用了2507盏需要投射阴影的光源的场景中,在PS4上的时间表现,如下图所示:
8.总结
Bo Li的这篇技术分享通过静态阴影+动态阴影结合的方式来解决大量灯光阴影投射的效率问题,为了降低包体尺寸,还给出了一种针对阴影贴图的压缩方案,这个压缩方案既可以用于静态阴影存储,也可以用于动态阴影贴图存储以降低内存占用(在使用的时候可以根据需要进行部分区域的解码使用),另外,针对场景中多盏需要投射阴影的光源而言,最后输出light/shadow mask贴图时,可能也会存在内存消耗过高的问题,针对这个问题,还给出了一个TSVQ压缩算法在保证显示质量的前提下得到10倍贴图压缩率。