Skip to content

PCFSampleOptimize

zilch edited this page May 7, 2021 · 4 revisions

阴影的PCF采样优化算法

今天在手写PCF软阴影,涉及到采样算法的优化。翻开Unity的源码想参考一下,然而发现其内部实现十分复杂,完全看不懂。拿关键字搜了一下,也没有看到相关的Paper说明。只看到一篇CSDN上的文章,里面的内容好像摘自某本书(话说这是哪本书呢?)。但依旧没有看明白里面的什么三角形面积是什么意思。于是只好自己推导一下采样算法,总结得此文,自我感觉比Unity那套写法要简单清晰(虽然推到最后,我突然明白三角形面积是什么意思了orz)。

PCF阴影简介

首先,使用shadowmap来生成实时阴影的话,阴影边缘会存在锯齿问题。因为拿场景中像素的深度值去shadowmap中对比时,结果要么在阴影中,要么不在阴影中,这是一个非1即0的二值函数。因此假如我们什么都不做的话,shadowmap产生的阴影边缘如下图所示:

为了解决这个问题,于是人们提出了percent closer filter。这本质上是一个滤波器的思想,即增加采样点,与depth做比较,得到若干的1或0,然后进行加权平均。这样的话,最终的结果值将是一个0~1范围的值,于是便形成了阴影边缘的半透明过渡。

PCF1x1

这是最基础的一个PCF核,根据给定的uv,采样其四周的4个像素,按照双线性插值(Bilinear Interpolation)的方式进行混合。在早期时代,我们需要自己手动进行4次Pointer Filter采样,然后插值混合,后来硬件直接提供了支持,只需要进行一次特殊采样即可。例如HLSL中提供的如下函数:

SampleCmpLevelZero(SamplerComparisonState,uv,depth)

可以通过一次操作完成以上的PCF1x1整个过程。效果如下:

可以看出来,阴影边缘出现了灰度渐变。但是1x1的PCF实在有点弱,所以锯齿感还是比较明显的。

因此人们自然考虑扩大PCF Kernel的半径,来达成更"模糊"的边缘渐变效果。那么一个NxN的PCF,需要进行几次采样呢?不妨先以3x3来分析

PCF 3x3

首先最直接的方式,就是将uv往4个对角方向各自偏移0.5个坐标,来使用SampleCmpLevelZero进行4次采样,然后将结果求平均。

如图所示,蓝点是给定的uv,红点是进行偏移后的4个采样坐标。每个采样坐标能以Bilinear Interpolation的方式覆盖4个像素。易知,4次采样共覆盖了9个像素,但是每个像素的占比权重不一样。权重比例大致如下:

1|2|1
2|4|1
1|2|1

该算法的代码如下:

float SampleShadowPCF3x3_4Tap_Fast(float3 uvd){
    float offsetX = _ShadowMapSize.x * 0.5;
    float offsetY = _ShadowMapSize.y * 0.5;
    float4 result;
    result.x = SampleShadowPCF(float3(uvd.x - offsetX, uvd.y - offsetY, uvd.z));
    result.y = SampleShadowPCF(float3(uvd.x + offsetX, uvd.y - offsetY, uvd.z));
    result.z = SampleShadowPCF(float3(uvd.x - offsetX, uvd.y + offsetY, uvd.z));
    result.w = SampleShadowPCF(float3(uvd.x + offsetX, uvd.y + offsetY, uvd.z));
    return dot(result,0.25);
}

使用该算法产生的阴影结果如下图:

可以看出,虽然渐变范围大了,但是渐变之间似乎存在一些明显的边界,像是一块块似的。为什么会这样呢?

本质原因是,线性插值是仅仅一阶连续的,其导数是不连续的。用一纬的示意图来表示就是

ABC的点本来是离散的取值,在进行线性插值后会变成如下图:

可知,导数在A点是不连续的,带来的效果就是A点很尖锐,于是形成了鲜明的分界线。

更进一步的分析,Bilinear Interpolation本质上是一个离散的Tent Filter。 之所以会造成这种尖锐,正是因为Filter是离散的,它将像素看成了点,只计算了那个点的权重,而不是计算像素整个面积所占据的权重。

很多网上的文章都使用了这种采样方式去扩大PCF的核,但其实效果很不好,而且N * N的核需要进行(N-1) * (N-1)次采样,是Performance Killer。

注:

TentFilter的权重函数是一个倒三角的形式,如下图。

连续的TentFilter卷积

为了让A点不那么尖锐,我们需要用连续的TentFilter卷积来代替离散的加法 。

这里我们首先给出一个一纬的3x3的TentFilter的公式形式:

$$

f_w(x)= \begin{cases}

0& x\in(-\infin,-1.5) \ x + 1.5 & x \in [-1.5,0]\ -x + 1.5& x \in [0,1.5] \ 0& x \in (1.5,+\infin)

\end{cases}

$$

其图像如下图所示:

我们将每个坐标区间看成一个个像素(即像素不再是一个点)。

用微积分的思想不难理解,一个像素在TentFilter下所占据的权重,应当是f(x)在像素区间上的积分。例如[0,1]区间所代表的像素,其权重应当是图中画出的阴影部分的面积。

下面将证明以上的算法可以获得一个导数连续的渐变阴影。

我们不妨假设未经软化的阴影强度公式如下:

$$ f_{shadow}(x) = \begin{cases} 1,x>=0 \\ 0,x<0 \end{cases} $$

x=0即为阴影的强边界线。

我们使用$f_w$对$f_{shadow}$进行卷积运算并将取值范围归一化:

$$ \begin{aligned} W &= \int_{-\infin}^{+\infin} f_w(x)dx \ G_{shadow}(x) &= \int \frac{f_w(x)f_{shadow}(x)}{W} dx \end{aligned}

$$

不难得到以下结果(这里不给出具体的积分过程了):

$$

G_{shadow}(x) \begin{cases} 1&x>1.5 \ -\frac{2x^2}{9} + \frac{2x}{3} + \frac{1}{2}& x > 0 \ \frac{2x^2}{9} + \frac{2x}{3} + \frac{1}{2}& x < 0 \ 0&x<-1.5 \end{cases} $$

将以上函数绘制出来如下:

可见,阴影边缘将形成非常柔和的过渡。

PCF采样优化

基于这个思路,我们首先可以将3x3的PCF采样范围扩大到至多16个像素。宇宙人都知道一次SampleCmpLevelZero调用,可以得到4个有效像素。因此理论上来说,4次采样,最多可以覆盖4x4=16个像素。我们可以将4个采样点位置按如下进行分配:

蓝点是我们预期计算阴影强度的uv,也是kernel的中心位置,红点是4个采样点。每个红点至多可以覆盖2x2个像素,我们将此称为一个Group。注意,这只是示意图,并不代表红点的准确位置,蓝点也不一定位于整数位置。

我们以距离蓝点最近的一个整数坐标作为原点建立坐标系,在此坐标系下,假设蓝点坐标为P,4个红点坐标依次为P1~P4。那么我们有公式:

$ShadowStrength(P) = w_1Sample(P_1) + w_2Sample(P_2) + w_3Sample(P_3) + w_4Sample(P_4)$

  • 注: Sample是双线性插值采样

为了使这个公式可以计算,我们有两件事要做:

  • 根据P的坐标,求出P1~P4的坐标
  • 求出4个采样点Group各自的权重w1~w4

众所周知,我们已经有了一个TentFilter Kernel,根据Kernel的位置,可以计算出其范围内16个像素各自的权重。然后我们就可以:

  • 在每个采样Group内部,根据像素权重,计算红点uv
  • 将每个Group内部像素权重相加,即可以得到每个Group各自的权重w1~w4

那么怎么计算每个像素的权重呢?

我们可以将其拆分为求像素在横向上的4个权重$w_{x1}$ ~ $w_{x4}$和纵向的4个权重$w_{y1}$ ~ $w_{y4}$。将它们相乘,就得到了每个像素的权重。

那么求像素在单个轴向的权重,就归结为了求如下图每个区间的面积:

这个Tent Filter是一个底为3,高为1.5的等腰直角三角形。 由于我们是按照对Kernel的中心位置取Round操作建立的坐标系,因此A.x的范围为-0.5~0.5

利用初中的几何知识,我们不难得出区间[-2,-1],[-1,0],[0,1],[1,2](对应4个像素)的权重计算函数如下:

static float4 GetTent3Weights(float kernelOffset){
    float a = 0.5 - kernelOffset;
    float b = 0.5 + kernelOffset;
    float c = max(0,-kernelOffset);
    float d = max(0,kernelOffset);
    float w1 = a * a * 0.5;
    float w2 = (1 + a) * (1 + a) * 0.5 -  1 * w1 - c * c;
    float w4 = b * b * 0.5;
    float w3 = (1 + b) * (1 + b) * 0.5 -  1 * w4 - d * d;
    return float4(w1,w2,w3,w4);
}

其中kernelOffset为Kernel相对与坐标原点的偏移量。

再看每个Group内部的2x2个像素,既然我们已经有了这4个像素的横向权重和纵向权重,那么根据双线性插值公式,不难计算出其Group内部的采样坐标为:

static float2 GetGroupTapUV(float2 weightsX,float2 weightsY){
    float offsetX = weightsX.y / (weightsX.x + weightsX.y);
    float offsetY = weightsY.y / (weightsY.x + weightsY.y);
    return float2(offsetX,offsetY);
}

同时我们将每个Group内部的像素权重相加,不难计算出每个Group的权重。由此就可以计算出目标位置的阴影强度。

最终效果如下:

看得出来同样是4次采样,呈现的渐变效果自然了很多,没有明显的突变感了。

PCF5x5

基于同样的算法,我们可以给出PCF5x5的实现。一个5x5的TentFilter,可以仅用9次采样,覆盖到36个像素。具体的代码实现,这里就不展开了,单放一张效果图:

再看一下球体的软阴影效果:

不难得出,使用此算法,一个(2N - 1) x (2N - 1)的Tent PCF滤波器,只需要进行N x N 次采样即可以覆盖到2N x 2N个像素。

后记

写到这里,我突然明白Unity的那套PCF代码是什么意思了,orz

以上算法在SRP中的实现请参考这里 : Shadow PCF的SRP实现

Clone this wiki locally