更快更好的PCSS

PCSS令人诟病的一点是:需要大量的PCF采样,才能得到一个较好的软影效果。

SATVSM/VSSM使用Sum-Area-Table表代替PCF采样,虽然稍微(Cascade Shadow Map的SAT表构建和采样也会花费不少时间)减缓了性能问题,但它的效果非常的差。

Image

究其原因,SAT只能实现Box Filter,而且由于它的特性,仅能搭配VSM的切比雪夫不等式使用。最终得到的软影,既有VSM的漏光问题,也显露出一种廉价感。

PCSS使用的PCF采样更加符合物理规则。阳光透过树叶的间隙,甚至可以投影圆形的焦散,非常的好看:

Image

没有人能在看到这种画面后再次拒绝PCSS。我开始测试一些加速PCSS的算法。

本文依次按照如下顺序展开:

  1. 软影遮罩。
  2. 光照结果指导PCSS提前退出。
  3. 同心圆式采样缓存复用。
  4. 可变采样率。
  5. 其它细节。
  6. 最终性能。

软影遮罩

原神使用一张近似的软影Mask来标记PCSS区域,跳过非软影区域的PCSS计算。

Image

在屏幕软影占比大的时候,这种做法非常容易出Bug,Mask贴图并不能全部覆盖到当前软影。

我的做法是:

在当前帧的PCSS计算完成后,统计8x8 Tile内的阴影讯息,可以得到一张免费的软影Mask,在下一帧的PCSS计算前,(双线性)采样当前帧得到的Mask,就可以得到更加准确的软影Mask了。

Image

Image

如图所示,仅泛红区域需要软影计算。

这张Mask可以在PCSS计算完成后立即计算出来(同一个Pass内),基本是零性能消耗:

Image

因为是上一帧的阴影Mask,当前帧的PCSS计算采样前,还需要重投影到上一帧:

Image

历史帧的软影Mask可能会因为各种问题失效,如:Camera Cut、Depth Dissolve、灯光旋转等。

所以还做了一个Wave内的保底软影Mask:

Image

与历史帧的Mask相比,这个Wave内的软影Mask相对保守,但能缓解历史帧软影Mask失效的画面错误问题:

Image

通常情况下软影Mask能把软影计算量减少50%以上,一些较好的情景下甚至可以达到90%+的性能优化。

光照结果指导PCSS提前退出

常规引擎中,阴影在光照前计算完成。

考虑光照方程的特性:把Visiblity和Radiance分开来,先计算完Radiance部分存到RT中,根据着色模型的特性,若与Visiblity的合成方式为乘法,即可跳过Radiance为零的像素区域:

Image

如下图所示,仅白色区域需要阴影评估:

Image

该做法需要分离Lighting Pass,在一些引擎中可能做起来有点麻烦,但效果也是肉眼可见的,能减少不少像素的阴影计算。

同心圆式采样缓存复用

该想法来自Crytek的一次分享,他们的PCSS的Blocker Search和PCF Filter使用相同的泊松圆盘(按半径排序)。Blocker Search加载全部的采样,得到软影半径后,可以得知PCF Filter的泊松圆盘半径。

他们直接在PCF采样阶段复用Blocker Search缓存下来的阴影采样结果,仅需软影半径内的采样。

Image

红框处的字体非常容易搞混,一开我以为他们缓存了全部采样深度到Shared Memory中,但仔细想想根本没有这么多LDS内存可用。

因此,在Blocker Search阶段时,直接计算该采样点的阴影结果,每一个采样点仅需1 Bit就能表示,这样,32个采样点的阴影结果仅需一个uint就能缓存下来,PCF阶段则直接使用前面缓存下来的阴影结果。

对于采样模式,我更加倾向于使用Vogel圆盘,同时也可以简单确定在某个软影半径下,PCF应该复用那些阴影缓存结果:

Image

这种做法性能良好,但软影效果差:大量采样点分配到软影外侧,供Blocker Search使用,最终PCF分配到的采样点很少。

可变采样率

Variable Rate Shading(VRS)的思想现在根深于我的脑中,每次性能优化我都优先考虑该做法,PCSS也不例外。

一种直观的想法是:PCF阶段的采样数根据软影的半径动态改变,半影多的地方采样多,半影少的地方采样少。

在此之前还需要一种可以动态改变采样数的圆盘分布,此处Vogel圆盘依然胜任。

Image

这种可变采样率做法可以大幅减少接触硬阴影处的PCF采样数:

Image

如图所示,颜色越深的地方,采样数越多。(非半影区域的采样基本很少)

其它

PCSS结合CSM有一些小问题需要处理。

首先是Cascade过渡,业界常用做法是计算两次阴影,然后是用Lerp插值过渡。由于PCSS消耗太高,这种做法显然不符合要求。

我使用Dither As Lerp的思想,在Cascade过渡处抖动Cascade级别:

Image

这种做法可以将生硬的Cascade级别过渡转换成高频噪声过渡,搭配TAA的话基本媲美Lerp效果,同时还能保持较高的效率。

Image

其次,由于不同级别的Cascade的覆盖范围不同,对应的ShadowDepth纹素覆盖范围也不一样,所以要用Cascade覆盖区域的半径去缩放PCSS Search Blocker的半径、PCSS PCF Filter的半径,Bias值,使得Cascade级别过渡自然:

Image

另外,由于PCF的特性,对于大范围的PCF filter,若使用相同的Bias可能会在边缘出现条带状的Bug(在许多PCSS实现中都有出现)。我们应该根据PCF的半径,再额外动态调整它的Bias:

Image

值得注意的是,此处PCF的Bias不关心Cascade的半径缩放:

Image

最后,Blocker Search的采样也稍有讲究,正常情况下,Blocker Search的采样半径要达到最大半影区域。这导致我们要分配大量的采样预算给它才能得到一个相对无噪的结果。

如下图所示:过大的采样半径导致BlockerSearch结果非常噪:

Image

一种简单的想法是:调低Light Size,同时对计算出来的半影半径做调整(Power操作啥的),使得我们在较小的Blocker Search范围内能得到更多的半影。

Image

这样做,虽然可以稍微缓解Blocker Search的噪声,但不利于半影和硬阴影的叠加区域过渡:

Image

产生这种问题的原因是PCF采样超出了Blocker Search的范围,导致接触地方的阴影过软。

另外一种想法是:缩放Blocker Search的区域,同时限制PCF的最大采样范围:

Image

Image

这样做,既能保证PCF的采样范围均为Blocker Search计算出来的结果之内,同时也能减少Blocker Search的采样半径减少噪声。

Image

最终性能

在RTX3070Ti下渲染4k。PCF最多采样32次(Gather Load), Blocker Search 最多采样16次。

最差情况(满屏半影):(1.45ms)

Image

正常视角:(0.81ms)

Image

影区视角:(0.65ms)

Image

© - 2024 · 月光下的旅行。 禁止转载