【图形实战】 DX12的VSSM阴影实现

用自己的渲染玩具做软阴影

Posted Kongouuu's Blog on February 22, 2022

前言

做到这里的时候自己的引擎也新增了很多包装以及功能的迭代。可以在运行时去切换不同的阴影效果或者关闭阴影,来做更实时的效果对比。

VSSM (Variance Soft Shadow Mapping) 是在 Games202 里看到的一种用近似去加速生成软阴影的手段,主要是用于解决我们进行常规软阴影渲染时一个过多采样的问题。 因为课的作业也是最多写到 PCSS 所以这里也是实际碰一碰 VSSM 看看效果到底怎么样。实际上整体思路在课程已经推导完了,细节一点操作(例如解决Light Bleeding) 都已经在 GPU Gems 3 里面的 SAVSM 章节有所处理。

大致的流程如下:

  1. 把深度以及深度的平方渲染到同一个纹理上
  2. 为这个纹理进行模糊操作(这样阴影会更平滑,如果不模糊的话会不太好看)
  3. 为纹理生成MipMap (实际上SAT的效果应该更好,不过我这里还是用的MipMap)
  4. 在正式的Pass通过读取不同的 Mip Level 来进行不同范围的数值查找,套用公式算出 Shadow Factor

(一)渲染深度图

在一个正常的阴影贴图的渲染中,我们一般会使用一个格式为 D24_UNORM_S8_UINT 的深度模板缓冲格式的资源,作为深度模板缓冲去放入我们的Shadow Pass 中。但我们现在需要一个额外的信息,那就是深度的平方,因此我们不能直接这样做,而是要把深度以及深度平方的信息渲染到一个渲染目标中。

可以的话尽量去使用 R32G32_FLOAT 去储存我们的深度,效果比较好,但我这里的小测试使用的是 R16G16_UNORM 图个方便。渲染到深度图其实流程也是很简单的:

  1. 建立格式为目标格式的资源
  2. 为资源绑定一个用于当作渲染目标的 RTV 描述符, 和一个用于当作纹理输入的 SRV 描述符。 要记得资源的flag 必须要有 D3D12_RESOURCE_FLAG_ALLOW_RENDER_TARGET
  3. 写好我们需要用到的 Shader, 输出为 float2 (对应我们的资源格式)
  4. 建立Shader 使用的 PSO, 这里要设置一下渲染目标格式。
  5. 照正常的阴影贴图渲染流程来就可以了,只需要把Render Target绑定成我们资源就可以了。

(二) 为纹理进行模糊以及MipMap

2.1 资源

提前说一下就是上一部分生成的资源是没有办法直接拿去进行 模糊/Mipmap 操作的。

模糊方面因为我们没有办法在读取一个资源的时候直接写入同一个资源,很好理解。

MipMap 一般是读取上层的纹理,去写入下层的Mip Slice, 虽然理论上并不会有读写的冲突。不过 DX12 本身有个限制就是如果我们的资源是可以当作渲染目标的(我们的深度图的资源就有这个flag), 那么这个资源就不能被当作UAV。由于我们计算着色器写入的目标是通过UAV句柄去设置的, 如果我们的资源没办法被当作UAV, 那么我们就没有办法在计算着色器中写入他。

意思就是说我们要在之前生成好的阴影贴图之上去建立额外的允许UAV的资源来做这个操作。

2.2 模糊

模糊方面直接使用 DX12 龙书的代码就可以做到一个简单的 2 Pass 模糊。不过由于是2 Pass, 我们的第一个 Pass 得是模糊到一个中间的临时资源上面,然后在第二个模糊流程再把结果储存到我们要进行 Mipmap 的贴图。这里不考虑多次模糊的情况,因为这个阴影贴图是要每一帧的去更新的,如果每一帧都要为阴影进行多次的模糊,那效率会过低。

2.3 MipMap

MipMapDirectX 是没办法直接让API 帮我生成的。如果是加载的贴图可能可以透过什么WIC/DDS Texture Loader 帮我进行对应的操作,不过如果是我们自己在渲染流程中建立的资源,只能自己写计算着色器去进行一个MipMap的生成,我这里整体还是参考的 Learning DirectX 12 – Lesson 4 – Textures的思路。

2.3.1 准备工作-资源

我们最后需要去进行 MipMap 的资源首先需要保证的是格式跟我们之前使用的是尽量一样的。这里资源最主要需要做的准备就是为所有的 Mip Slice 申请一个 UAV Desc。原因是我们计算着色器之后会从我们第零级的Mip 开始读取,并且一个一个层级的往后写入。 写入的时候要求我们需要有目标 Mip SliceUAV 句柄。

我这里的做法是新建了一个ComputeBuffer 类, 记录我们原始的资源,并且根据原始资源的描述符去进行资源的初始化。在初始化的过程中绑定好 SRVUAV 的描述符。

要特别注意的地方是资源本身建立时的Flag,因为我们的源资源很有可能是含有Allow Render Target 的设置的,所以我们需要把他去掉,并且加上允许无序访问。上图中的 TmpBuffer 目前是用于进行帮助模糊操作的。

我们建立好准备Mip 的资源后,如果不打算模糊到这个资源,也可以直接用 CopyResource 把原本的资源复制过来进行操作。

2.3.2 准备工作-计算着色器

实际上我们是可以在一个计算着色器的 Pass 中去进行大于一层的Mip 计算的,不过这取决于我们本身一个 Group 有多少线程在跑。这里设置的是一个 8*8Group, 可以做到一次写入四个不同的层级。具体方法使用的是Group Shared Memory。就是说我们计算着色器的每一组线程是有一定程度上的共享储存的。所以我们可以把每个组的第一次采样结果储存起来进行后面的均值计算。

大概的流程如下,我这里使用的是 4*4Group 大小,演示的是写入三个不同的 Mip 层级的过程。图中的黄色为这次计算要写入的格子,绿色为有效的格子,红色为已经被均值过了不需要考虑的格子。这里除了要存储到Group Shared Memory 以外,肯定还是要优先把每一层的计算值先放到我们输出的 Mip Slice 上。细节就不放了。

2.3.3 计算着色器的坑

因为之前看龙书的时候跳过了计算着色器章节,所以踩了一些小坑:

  1. 如果资源格式想绑定 RTV/DSV, 就不可以绑定UAV。 要用做计算着色器输出的资源,必须含有允许无序访问的 Flag, 而不能有另外两个的Flag
  2. 之前包装好根签名生成里面的 Descriptor Table 参数默认包含了 D3D12_SHADER_VISIBILITY_PIXEL, 导致我的 Compute Shader 读不了我的输入…

(三) Shader运算

3.0 VSSM

PCSS 一样,这个算法会考虑到遮挡物和被遮挡物之间的距离来决定阴影到底有多软,具体流程是:

  1. Blocker Search: 使用手动设置的范围来进行平均遮挡物深度查找。手动设置的范围可以通过log2 转换为Mip的层级。我们如果使用线性插值采样的话,可以在不同Mip 层级的中间来取插值查找,所以范围可以不限定在二的次幂上。
  2. Penumbra Size: 计算实际软阴影的采样范围跟PCSS是一样的
  3. VSM: 在我们选定的范围采样纹理,并且使用切比雪夫去计算最后的可见度。不过这里比起Blocker Search 还需要做到一些处理漏光的调整。

3.1 切比雪夫不等式

我们会在计算中两次运用到这个不等式,一次是用于查找平均遮挡物深度,第二次是可见度。它的本质作用就是找到在一定范围内,有多少比例的物体比我们的目标像素还深(有多少比例物体没挡住我们的目标像素)。

\[P(x > t) \le \frac{\sigma^2}{\sigma^2+(t-\mu)^2} \\t: 采样点距离光源的深度 \\ \mu: 采样纹理得到的平均深度 \\ \sigma^2 = E(X^2)-E(X)^2 \\E(X^2):直接采样纹理的平方通道 \\E(X)^2:实际上就是\ \mu^2\]

遮挡物的平均查找是一个比较近似的手法去进行的。我们目前存在阴影贴图里面的是一个区域范围内所有物体的深度平均值,而不是遮挡物的深度平均值,所以不能够直接拿来用。不过我们可以通过近似的手法去推出遮挡物的平均深度:

\[z_{avg}= 没遮挡物体比例*z_{unocc}+遮挡物比例*z_{occ}\]

我们已经有了等式左侧的Zavg,并且需要找到的是最右边的Zocc

\[z_{occ}=\frac{z_{avg}-没遮挡比例*z_{unocc}}{遮挡比例}\\ =\frac{z_{avg}-P(x>t)*z_{unocc}}{1-P(x>t)}\\ 假设没遮挡的物体深度都是跟像素深度一样,是t\\ z_{occ}=\frac{z_{avg}-P(x>t)*t}{1-P(x>t)}\]

通过使用切比雪夫和一定程度的假设,就可以得到一个遮挡物平均深度值。

特殊情况

如果我们的P(x>t)*tZavg大,或是相等,会得到零或是更小的数字,这并不是很合理,所以要限制一下结果的范围。

代码

image-20220222155251320

3.3 Penumbra Size

由于一般使用的光源并不会有光源宽度这么一个概念,所以也是使用一个比较基础的可控的方式:

\[Penumbra\ Size = Scale*\frac{t-z_{occ}}{z_{occ}}\]

这里的Scale还是看我们自己想要怎么去设置整个阴影的范围。

代码

这里的 max 纯属是一个效果的设置,因为我之后会根据 log2(penumbraSize) 去决定使用什么Mip 层级,所以这个大小本身就不能低于一。 那我希望最低也从第一个层级(2*2) 开始查找,所以我设置了一个最小值。

image-20220222160127242

3.4 VSM(Variance Shadow Mapping)

VSM 这里主要就是通过我们的Penumbra Size 去决定采样的范围,然后返回切比雪夫的值。

代码

漏光

这个整个算法本身最大的问题就是漏光。漏光的本质要回到整个方差阴影的概念。

首先要想的是我们的VSSM是怎么去制造边缘软阴影的?这里要想的是一个边缘检测的问题,我们的边缘检测是基于我们计算中的方差,也就是说方差的值代表着我们采样范围内的深度之间有多大的差距。所以在边缘的地方会有很大的方差,平坦的遮挡物会有近乎于0的方差。

从切比雪夫来看,如果方差很大,那么得到的结果就会越来越趋近于1, 也就是说方差越大,结果越亮。假设我们的遮挡物之间有很大的方差,切比雪夫有可能会以为这里是遇到了边缘,所以把物体正中间去进行了一个软化。

漏光并没有办法从根本上解决,我们只能去截断一定范围内的可见度。就是比如说原本我们的非完全阴影范围是[0,1], 经过截断后,假设我们从可见度0.6开始截断,那么所有低于0.6可见度的计算结果都会被视为0.0

也就是说阴影因子的可视区间从 [0,1] 变成了 [0.6,1], 不过当我们截断之后也同样需要把 [0.6,1] 的值线性的拉伸到 [0,1]的范围

3.5 效果的小问题

跟着GPU Gems 3的方案就是差不多上面所有的内容,但自己试了一下会出现一个很猎奇的效果:

阴影的边缘有了描边,这实在是非常的不好看。当然我也不了解其中过程哪里出错了,反正人家的示意图没有这种效果。当然试了一下发现精度对结果的影响还是很大的,以及我们光源摄像头的位置,也就是说通过调整光源位置,以及光源视锥体的大小可以在特定的角度避开这个问题。不过当然这么做然后截截图就说做完了确实不太好,所以稍微研究了一下下。

先说结果,我估计会有描边问题是Blocker Search在最边缘应该亮着的地方方差过大,然后计算出来遮挡物深度大于t 的全都截断了。然后由于方差大导致计算的值不稳定凑巧跑出一些非常接近像素深度 t 的遮挡物深度,让边缘的地方也采样了特别小的范围。

为了解决这个问题,我决定在Blocker Search 后面放一个:

1
2
if (t < zocc || p >= 0.90)
	return 1.0f;

这样第一次算出来方差过大的地方就直接当作阴影外去处理,直接接受全额光照了,结果也还可以:

(四) 效果对比

我这里使用的是 2048*2048 的阴影图

4.1默认阴影贴图

4.2 PCF (18个采样)

pcf

4.3 PCSS(18个采样)

4.4 VSSM

vssm

(五) 效率和效果

5.1 效率

直接的去对比不同手法的效率其实没啥用,在不同场景和不同分辨率的阴影贴图下都可以有不同的表现,何况还有其他手法采样次数的差异的关系。

我这里使用的是 2048*2048 的阴影贴图,效率测试使用 PIX 去截帧 Sponza 场景。很遗憾,效率并没有得到提升。本身 FPS 的差距并不是很大,从截帧的数据来看,我是用 1ms 的模糊和 1msMipmap 生成, 换取了跟PCSS相比快了 0.18msShader 计算速度。

当然我的计算着色器的操作在不同场景下都是稳定的,而Shader的计算还是得看我的屏幕分辨率以及场景复杂程度去考虑 (如果是延迟渲染倒是没事) 。 同时采样次数不同也有不同的效果。

如果真的要加速可以不模糊,也可以生成少一点 Mipmap ,不过没啥必要去这样做对比。总的来说就是 Sponza 场景没有加载的更快,这非常的可惜。

5.2 效果

比起 PCSS 来说,可以看到差不多的配置下并没有那么多的噪音,不过同时阴影虽然有很多部分很 , 但是边缘并没有感觉的那么 。 主要还是看需要渲染什么,如果是要渲染树枝这种比较细的东西,那么我相信 VSSM 这种让阴影变淡同时保持一定的边缘锐度的做法是很合适的。效果上其实还是看个人审美了。

不过必须要说的是 VSSM 这整个思路比其他的做法都更吃精度,个人感觉是。 我的做法是挺缺失精度的,因为采用的是精度较低的 R16G16_UNORM,也没有做额外的调整。还有就是我采取的是 Mipmap (因为一开始以为API可以帮我生成…),都有一定的精度问题。不过最大的问题在于阴影贴图本身的分辨率低的情况下渲染出来的东西是 完全不能看的。

就是说如果我的目标物体在阴影贴图中占比较小的成比,那么我使用 PCF/PCSS 都还是能得到还过得去的画面,但是 VSSM 不可以,我一定要让我的物体吃满整个阴影贴图才能渲染出好看的阴影…