图形学基础—ShadowMapping

在光栅化渲染中,我们考虑的着色通常是一种局部计算的结果。

也就是对于着色点的颜色,只考虑光源、相机以及着色点自己。

对于其他的物体则完全不考虑,甚至当前物体的其他部分对着色点的影响也不考虑。

因此,显而易见,这样的着色结果是不对的。

因为如果有其他物体挡在光源和着色点之间,那么这个着色点就应该是黑的或者说暗的。

这就是为什么会存在阴影。

ShadowMapping 是光栅化渲染中最常用的一种生成阴影的方式。

ShadowMapping

ShadowMapping 本质上是一种图像空间上的做法。

也就是在生成阴影的这一步,是不需要知道场景的空间信息的。

阴影

ShadowMapping 的主要思想

ShadowMapping 的主要思想其实很直观:

如果一个点不在阴影里,而我们又能看到这个点,那就说明这个点能被相机看到,并且也能被光源看到。

而如果一个点在阴影里,而我们又能看到这个点,那就说明这个点能被相机看到,并且不能被光源看到。

ShadowMapping的思路

ShadowMapping 的实现思路

既然前面说我们判断一个点在不在阴影里,是需要判断光源能不能看见,那么我们就需要想办法看一眼。

因此,我们可以先在光源处放置一个虚拟的相机看向场景,然后做一遍深度测试,得到一张深度图,通过深度图就能知道光源看到了什么。

然后再从相机位置看向场景,并将看到的每一个像素点,都投影回光源相机看到的画面中,计算这个像素在光源相机下的深度值。

用这个深度值和光源相机渲染得到的深度图上对应的深度进行比较。如果大于深度图上记录的深度,那么说明这个像素点会被遮挡。

实现步骤

ShadowMapping 的具体实现

使用了 ShadowMapping 和未使用 ShadowMapping 的对比如下

ShadowMapping效果

第一趟 pass,生成阴影贴图

将相机放在光源位置,用 z-buffer 的方式存一张深度图,称之为阴影贴图 (Shadow Map),并记录此时的投影变换矩阵 M,点光源对应透视投影,定向光对应正交投影

步骤一

第二趟 pass,正式渲染场景

将相机放到眼睛的位置,考察每个片元处是否处于阴影。方法为:

用第一趟 pass 里面的矩阵 M 将三维点 (Px,Py,Pz)(P_x, P_y, P_z) 变换为二维坐标 (px,py)(p_x, p_y) 和深度 pzp_z

pzp_z 与第一次 pass 存下来的阴影贴图对应点的深度 c(px,py)c(p_x, p_y) 进行对比,若 pz>c(px,py)p_z > c(p_x, p_y) ,则认为此片元处于阴影中

步骤二

最后将遮挡情况表现到着色点的颜色中即可。

ShadowMapping 存在的一些问题

自遮挡

shadowMapping 判断一个点是不是可见,是通过判断投影回去的深度值是否与深度图上记录的深度值一致来确定的。

然而,在计算机中,判断两个浮点数相等是很困难的一件事情。

如果只是简单的判断投影回去的深度大于深度图上的深度,那么就会产生一个很脏的阴影,这种现象称之为阴影的自遮挡。

仔细看模型身上会有很多黑色的条纹

为了解决阴影自遮挡的问题需要引入一个 bias 的数值(Depth Bias)。

使得我们投影回去的深度,不仅要大于深度图上记录的深度,还要大于 bias 加深度图上的深度。这样就会减少一些由于数值精度产生的问题。

经过bias调整后的阴影

不接触阴影

但是如果 bias 调整的过大,又会造成阴影与模型不衔接,这种现象称之为不接触阴影。

过大的bias使得阴影不完整

锯齿

另一个很常见的问题是,由于阴影的生成需要依赖深度图,因此深度图的尺寸会影响阴影的质量。

过小的深度图尺寸会产生一个锯齿严重的阴影,增大深度图的尺寸阴影质量会变高,但是又会使得渲染效率降低。

深度图尺寸过小的阴影

ShadowMapping 的一些优化方法

优化阴影贴图的使用率

ShadowMapping 计算阴影,需要从阴影贴图中进行采样,一般而言,出现采样不合理的情况,都是因为创建的贴图与实际使用的部分两者不匹配。

例如:在场景中,有一个模型和一个灯光,灯光的相机视野很大,能够覆盖场景中很大得一块范围。但是我们的相机视野很小,只看到场景中的那个模型。这样也能得到看起来正确的阴影,但是相机视锥外的的阴影贴图就全部被浪费了。

下面介绍两个能够使得创建的阴影贴图与相机视锥更匹配的方法,使得阴影贴图能够得到充分的利用。

Fitting

如下图所示,假设创建了一个场景,设置了灯光相机的视锥体,那么在进行一趟 ShadowMapping 之后,生成的阴影贴图就记录了黄色区域的深度信息。

但是很明显,光源相机的范围(黄色区域)过大了,它不仅超出了 Scene 的范围,而且很多区域没有在主相机的视锥中,这些区域记录的信息都会白白被浪费掉。

在正式开始介绍 Fitting 之前,先介绍两个概念:

  1. 潜在阴影接收者(Potential Shadow Receiver),也就是有可能会被阴影覆盖的片元,这些片元必须是同时在 Camera、Scene 和 Light 范围内的。

PSR=CSLPSR = C \bigcap S \bigcap L

  1. 潜在阴影遮挡者(Potential Shadow Caster),也就是可能遮住光线的片元,这一类的片元必须在 Scene 和 Light 的范围内,但是不一定会在 Camera 的范围内(比如看到的阴影可能是被相机视锥外的物体遮挡产生的)。这部分区域可以表示成 PSR 区域加上 PSR 区域到 Light 近平面的区域,再减去 Scene 之外的范围。

PSC=(PSR+psrToLightNear)SPSC = (PSR + psrToLightNear) \bigcap S

PSR 和 PSC 示意图如下(橙色区域):

可以看到,如果能让 Light 视锥体缩小到刚好能包住 PSC 就算得上不错的 Fitting,包括从 xy 方向和从 z 方向两个途径

Brabec[1] 最早提出将 Light 的视锥体集中到 PSR 区域(由于 PSC 与 PSR 在 XY 方向上范围一致,所以其实还是集中到了 PSC 范围上),也就是把 Light 的视锥体在 XY 方向上通过平移和缩放,使得 Light 的视锥更接近 PSR 区域,提高阴影贴图的使用率

Light视锥沿xy方向平移和缩放

F=[Sx00ox0Sy0oy00100001]F = \begin{bmatrix}S_x & 0 & 0 & o_x \\ 0 & S_y & 0 & o_y \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}

Sx=2xmaxxminS_x = \frac{2}{x_{max} - x_{min}}

Sy=2ymaxyminS_y = \frac{2}{y_{max} - y_{min}}

ox=Sx(xmax+xmin)2o_x = -\frac{S_x(x_{max} + x_{min})}{2}

oy=Sy(ymax+ymin)2o_y = -\frac{S_y(y_{max} + y_{min})}{2}

其中 x,y 均为 PSR 区域在 Light 视锥体的 NDC 区域

Wimmer[2] 则进一步提出,从 z 方向缩小 Light 视锥体至 PSC 的范围,把 near 和 far 刚好设置到 PSC 的范围,降低 z 方向也就是阴影贴图深度的浪费。

调整Light视锥的near和far

Partition

在实际应用中我们可以发现,在计算一个场景的阴影时,距离相机近的地方往往需要更高分辨率的阴影贴图,而在远处的地方,则不需要那么精细。

Partition 实现的就是就是这个效果。

一种最流行的 Partition 方法是z-partition:沿着 z 轴将相机的视锥体划分为多个子视锥体,然后对每个子视锥体单独计算阴影贴图。

代表方法有三个,Engel 的 CSM,Lloyd 的 z-partition 和 Zhang 的 PSSM,其思路差不多,最出名的是 CSM(cascaded shadow map)[3]

下图显示了平行分割 CSM,其中分割是平行于近平面和远平面的平面,每个切片本身就是一个截锥。 太阳是定向光,所以相关的光锥是 Boxes(红色和蓝色)。

CSM示意图

CSM 的实现方式:

1. 对相机视锥体进行切分

最简单的切分方法就是沿 z 轴均匀切分:

CSM均匀切分

然而最合理的切分方式是越靠近 near 平面,切分区域应该更小才合理。所以另一种是按对数切分:

CSM对数切分

但是对数切分会在相机视锥的 near 面附近分配很多的“分辨率”,但是实际中,相机的 near 平面附近往往没有物体,所以可将以上两种切分方式合并起来:

zi=a(zn(zfzn)iN)+(1a)(zn+iN(zfzn))z_i = a({z_n(\frac{z_f}{z_n})}^{\frac{i}{N}}) + (1-a)(z_n + \frac{i}{N(z_f - z_n)})

其中 a 用于控制权重。

2. 得到每个切片区域的阴影贴图

得到切分区域的 z 值之后,就可以结合视场角计算出每个区域的角点,然后需要计算出PSC(潜在阴影遮挡者)的最小包围盒,包围盒的边平行于光线的投影视锥体(由于是正交投影,所以是一个长方体),可以得到包围盒的最大值(Mx,My,Mz)(M_x, M_y, M_z)和最小值(mx,my,mz)(m_x, m_y, m_z)

然后设模型空间到光线空间的矩阵为MMzz方向的正交投影矩阵为PzP_z(其中远近裁切面分别为MzM_zmzm_z),对每个切分区域调整的剪切矩阵为CC

其中剪切矩阵CCfitting中的矩阵一致

C=[Sx00ox0Sy0oy00100001]C = \begin{bmatrix}S_x & 0 & 0 & o_x \\ 0 & S_y & 0 & o_y \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}

Sx=2MxmxS_x = \frac{2}{M_{x} - m_{x}}

Sy=2MymyS_y = \frac{2}{M_{y} - m_{y}}

ox=Sx(Mx+mx)2o_x = -\frac{S_x(M_{x} + m_{x})}{2}

oy=Sy(My+my)2o_y = -\frac{S_y(M_{y} + m_{y})}{2}

就有从每个裁切区域的模型空间转换到光线空间的规范视域体坐标为:ph=CPzMp_h = C P_z M

由此可以得到每个切面区域的阴影贴图

3. 最终场景的渲染

在进行最终的场景渲染时,需要先将当前片元的深度值与前面计算的 N 个区域的 z 值范围进行比较,确定当前片元落在哪一个范围内。

此时片元在相机的规范视域体空间,记从物体模型空间转到相机视域体空间的变换矩阵为McM_c,那么就需要通过McM_c的逆矩阵先转回模型空间。

然后再应用第二步中计算得到的php_h矩阵转到光线空间的规范视域体中,得到(xh,yh,zh)(x_h, y_h, z_h),其中zhz_h为当前片元在光线空间规范视域体中的深度。

通过(xh,yh)(x_h, y_h)查询对应阴影贴图的值与zhz_h进行比较,即可判断片元是否在阴影中。

CSM 适用与定向光源的大范围场景,Cesium 中的阴影就是使用 CSM 方式实现的。

软阴影

传统的 ShadowMapping 只能判断点在阴影里或不在阴影里,这是一个非零即一的结果。

这样非零即一的结果会产生一个边界锐利的阴影,我们通常称之为硬阴影,如下面的左图所示。

而日常生活中我们看到的阴影并不是一个边界锐利的阴影,如下面的右图所示。

硬阴影
软阴影

在理想情况下,一个点光源产生的阴影就是硬阴影,但是实际生活中的光源都有尺寸,所以阴影的边缘会有一个强度渐变的现象,称之为软阴影。真实的阴影包含本影和半影:

软阴影的产生

在光栅化渲染中,我们通常使用滤波(Filtering)去模拟这一现象。

PCF 软阴影

PCF 是最常用的一种生成软阴影的方式。

它的想法十分简单,硬阴影的产生是因为我们每次只对 Shadow Map 采样一次,通过深度值比较判断像素点在不在阴影内,这样的结果是非零即一的。有点类似锯齿的产生。

所以我们也可以类似抗锯齿一样对采样结果进行 Filter,使得最后在阴影的边界处产生一个从零到一的结果,获得一个软阴影

具体做法是:假设我们要做一次 3*3 的 PCF,那么可以在每次利用着色点的深度判断是否在阴影内时。在阴影贴图中以着色点为中心取一个 3*3 的区域。

假设当前着色点在光源相机中的深度为 0.5, 阴影贴图中 3*3 采样范围中的深度值如下图所示。

这时所有的在阴影贴图中获取到的深度值要与 P 点得到的实际到光源深度,即 0.5 进行比较,所有大于 0.5 的像素我们输出 0,反之则输出 1。可以得到下图这样的结果:

接下来,将得到的值求平均,得到 4/9=0.44,作为阴影输出,这样我们就得到了一个看上去不错的软阴影。

可以发现同一个 PCF 生成阴影的“软度”都是一样软的,事实上整个阴影都一样软是不太符合现实情况的。但是由于 PCF 实现起来很简单,所以 PCF 仍然是最常用的生成软阴影的方式。

并且通过测试可以发现,PCF 可以通过控制 Filter 范围的大小控制阴影的“软度”,也就是说 16*16 的 PCF 一定会比 8*8 的 PCF 更软。然而更大的 Filter 范围也会带来更大的性能消耗(PCF 基于多重采样,而采样会影响性能)

如果想让阴影有一个很大的半影区域(也就是很软),但是又不想因为过大的 Filter 范围使得程序运行效率变的很慢,有没有什么好的优化方法呢?

事实上PCF 可用多种滤波核实现不同的效果,因此也可以通过调整滤波核实现性能提升:

1. Box 滤波:Box 滤波是最常见的滤波核(前面讲解 PCF 原理时使用的就是 Box 滤波核),Box 滤波核是规则的 n*n 的方形,核函数则选取的是均值函数。Box 滤波实现起来很简单,但是很明显随着滤波范围的增加,采样数也会指数型增加,导致性能下降。

2. 泊松圆盘滤波 (Poisson Disk):泊松圆盘滤波的做法是,从一个圆盘范围内取少量服从泊松分布的点作为采样点进行采样。泊松分布的采样点分布均匀,因此可以利用该算法生成的采样点近似的代替整个范围。并且由于限定了采样点的个数,因此无论最后需要 Filter 多大的范围,都能使程序保持一个较为良好的性能。

下面是一个简单的泊松圆盘测试页面,可以通过调整 NUM_SAMPLES 和 NUM_RINGS 观察采样点的分布变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const c = document.getElementById("myCanvas");
const ctx = c.getContext("2d");

// 泊松圆盘
const poissonDisk = [];
const NUM_SAMPLES = 30;
const NUM_RINGS = 7;

const ANGLE_STEP = (Math.PI * 2 * NUM_RINGS) / NUM_SAMPLES;
const INV_NUM_SAMPLES = 1.0 / NUM_SAMPLES;

let angle = 0.1 * Math.PI * 2;
let radius = INV_NUM_SAMPLES;
const radiusStep = radius;

for (let i = 0; i < NUM_SAMPLES; i++) {
poissonDisk[i] = [
Math.cos(angle) * Math.pow(radius, 0.75),
Math.sin(angle) * Math.pow(radius, 0.75),
];
radius += radiusStep;
angle += ANGLE_STEP;

ctx.beginPath();
ctx.arc(
(poissonDisk[i][0] + 1) * 100,
(poissonDisk[i][1] + 1) * 100,
2,
0,
2 * Math.PI
);
ctx.fill();
}

PCSS 软阴影

前面有提到,同一个 PCF 生成阴影的“软度”都是一样软的,但事实上现实生活中,我们看到的阴影类似于下图。

现实生活中的阴影

可以发现,现实生活中的阴影并不都是一样软的。而是阴影的接收物到阴影的投射物越远,阴影就越“软”,反之则越“硬”

前面提到 PCF 只能生成“软度”一样的阴影,但是可以通过调整 Filter 来调整 PCF 的“软度”。因此如果我们可以计算出每个像素点的阴影应该 Filter 多大的范围,那么我们就可以做出类似显示生活中的阴影。

PCSS[4]就是这样一种通过动态计算 Filter 范围,得到一个“软度”不一样的阴影的方式

PCSS的计算方式可以用下面这张图来说明,Wlight为我们模拟出的光源的“大小”(我们使用的点光源其实并不存在大小一说,这里是将其模拟为面光源来处理),Blocker为遮挡物,Receiver为我们接收光源的面,下面的W就是软阴影,也就是filter的范围。

我们很容易就可以发现一对相似三角形,根据这个相似三角形,可以得到如下图的等式

相似三角形在图中标出

通过相似三角形可以写出半影区域的计算公式:

WPenumbra=(dReceiverdBlocker)WLightdBlockerW_{Penumbra} = \frac{(d_{Receiver} - d_{Blocker}) * W_{Light}}{d_{Blocker}}

在等式右侧这些值中,除了Blocker到光源的竖直距离( dBlockerd_{Blocker} ),其他我们都可以轻松得到。

呢么 dBlockerd_{Blocker} 应该如何界定呢?首先不能直接使用shadow map中对应单个点的深度,因为这样如果该点的深度与周围点的深度差距较大(遮挡物的表面陡峭或者对应点正好有一个孔洞),将会产生一个错误的效果。

因此,我们选择使用平均遮挡距离来代替,具体方法是在shadow map上采样该点周围取许多点来计算各自的遮挡距离后求平均。

这样就又涉及到了一个问题,采样的范围该如何界定?

一种方法是采用固定的范围,例如4*4、16*16。另一种更好的方法是动态计算遮挡范围。

如图所示,首先把shadow map放在光源相机的近平面,然后将光源的边界点和要渲染的点相连,在shadow map上截出来的面就是要查询计算平均遮挡距离的部分。这部分的深度求一个均值,就是Blocker到光源的平均遮挡距离( dBlockerd_{Blocker} )。

获得( dBlockerd_{Blocker} )后即可带入上面的公式计算出半影区域 WPenumbraW_{Penumbra} 从而得到filter的范围。

接下来再根据filter的范围做一遍PCF即可。PCSS本质上就是求出了阴影中需要做PCF的半影部分后再进行PCF的计算,这样动态调节了半影范围,也就是动态设置了PCF的搜索范围,这样就能实现靠近遮挡物阴影更硬,反之更软的效果,动态的实现了不错的软阴影效果。

PCSS效果

Cesium中原本的软阴影是采用PCF的方式生成的,本人尝试加入PCSS软阴影后对比如下:

Cesium中的PCF软阴影

Cesium中实现PCSS软阴影

参考

  1. Brabec, S., Annen, T., and Seidel, H.-P. 2002. Practical shadow mapping. Journal of Graphics Tools, 7, 4, 9–18.
  2. Wimmer, M. and Scherzer, D. 2006. Robust shadow mapping with lightspace perspective shadow maps. In ShaderX4: Advanced Rendering Techniques (edited by W. Engel), pp. 313–330. Charles River Media, Hingham, MA. ISBN 1-58450-425-0.
  3. cascaded shadow map
  4. Integrating Realistic Soft Shadows into Your Game Engine

图形学基础—ShadowMapping
https://www.liaomz.top/2022/04/15/tu-xing-xue-ji-chu-shadowmapping/
作者
发布于
2022年4月15日
许可协议