文章目录
前言
正确的阴影对于渲染质量的提升起着举足轻重的作用。
本文作为GAMES202作业1的笔记,记录学习内容和作业完成过程。
阴影映射(Shadow Mapping)
实时阴影有两大类别,一是硬阴影,采用经典Two Pass Shadow Map方法,二是软阴影,采用PCF(Percentage Closer Filter)和PCSS(Percentage Closer Soft Shadow)两种方法。
Two Pass Shadow Map

采用Two Pass Shadow Map方法实现硬阴影, 也就是两趟绘制阴影Shadow Map,第一遍将相机挪到光源的位置观测,
视线能看到的各最小深度就是光源能直接照射到的物体深度,如上图中浅黄色线所示;第二趟绘制时需要将相机挪到眼睛的位置再观测,得到视线与物体交点,再将该点的深度,如上图中深黄色所示,与第一趟绘制得到的该点的深度比较,如果此次得到的深度更深(或者说值更大),则判断为阴影,如果相比更浅(或者说值更小或相等),则判断为非阴影可直接着色。如下图所示,分别是两种视角下的深度图。

如下图所示,根据上述方法得到结果只有阴影和非阴影两种,实现的是轮廓分明的硬阴影。

阴影映射中存在的问题
这里的阴影映射主要存在两个问题
- 自遮挡
- 锯齿
自遮挡
如下图所示,在非阴影部分的平面存在一圈圈纹路,这就是由自遮挡引起。

自遮挡的原因我们可以用下面这幅图解释,我们在第一趟绘制光源视角下的深度图时,视线其实是从光源到一个个像素点的连线再延长到物体上,这就导致从不同像素射出的视线其实是离散的,投射到物体表面上并不连续,从物体表面上看表现出的就是一个个与视线垂直的区域内的深度都是一个固定值。
现在我们看下面的蓝线,我们在绘制Shadow Map时需要进行比较,先取第一趟绘制得到的深度,也就是左半边的蓝线,但由于第一次绘制的深度图离散的原因,实际我们取到的是黄色截线上面部分的那段,实际上少了黄色截线下的一小部分,这样我们在与第二趟绘制得到的着色点深度比较的时候就会发现,第二趟映射的深度是要比第一趟长的,所以我们应该将该点作为阴影处理。显然这是不对,下图中的这点是可以被光源直接照到的,并不是阴影。这就是自遮挡产生的原因。

自遮挡问题产生的直接原因还是两个深度作比较的时候,第一趟从光源绘制的深度图由于其离散性,实际上取到的值是要小于该着色点的深度。比较简单粗暴的,既然小了,那我们直接给这个值再加上一个偏移值bias就可以了。在比较的时候,将光源深度图+bais与着色点深度比较,这样就可以一定程度上解决自遮挡问题了。
关于这个bias值的选取,我们可以根据光源的倾斜角度选取。当光线垂直于物体表面时,前面说到的离散型就会消失,深度都是贴近于物体表明的没有误差,而当光线倾斜角越大时,这个离散性也就会越大,因此bias的取值可以与光线倾斜角度相关作为一个可变变量。
除此之外还有另外一种理解方式。如下图所示,黄色部分是离散后的场景,蓝色是不被误判的部分,红色是被误判为在阴影的部分,这样就会产生一道一道的黑色阴影。
定义bias后,相当于相机击中的点不再位于真实平面上,而是把着色点向着光源的方向平移了一段bias距离。
- 对于误判部分: 假如我们从右上角看向这个场景,位于红色误判部分的某个着色点p,因为在做判断时要向光源方向移动bias距离,因此相当于它到光源的距离变短了,短到比shadow map上记录的最小深度还要小,那么这个就不会再被阻挡
- 非误判部分: 上图蓝色部分,被判断不在阴影里,其中着色点往光源处移动一段距离,它的深度更小了 依旧小于shadow map上记录的最小深度,所以它依然不在阴影中
这样之后,入射角不大的情况下几乎不会产生自遮挡现象,但是掠射角度下依然会轻微的自遮挡

利用bias进行偏移,这样看似找到了一个好办法,但其实并没有从根源解决问题,一定程度上减弱自遮挡但也产生了其他问题。运用bias方法后得到的效果如上图所示,可以看到没有一圈圈自遮挡现象,但是角色脚部的阴影却缺失了,这就是bias导致的问题,因为这个值是简单地加在比较式上,在角色脚部部分,与阴影平面本身的距离比较小,所以有可能出现bias值太大的现象,导致阴影判断错误,出现上图这样的情况。
锯齿

上图中可以看到,这个忍者的阴影边缘由明显锯齿。锯齿产生的根本原因与自遮挡类似,由光源视线射出绘制的深度图有离散性,导致多个片段对应一个纹理像素,也就会得到同一个阴影产生锯齿。工业界的解决办法是,不同位置采用不同分辨率的shadow map,或者是动态分辨率之类的技术,也可以直接用PCF生成软阴影。
Percentage Closer Soft Shadow
PCSS可以根据阴影与遮挡物之间的距离大小,实现自适应的软阴影,如下图所示,笔尖部分的阴影硬,笔杆部分的阴影就比较软。

数学基础
在数学中有很多积分不等式,但在RTR中我们更关注下面这种近似的形式。
∫ Ω f ( x ) g ( x ) d x ≈ ∫ Ω f ( x ) d x ∫ Ω d x ⋅ ∫ Ω g ( x ) d x \int_{\Omega}{f(x)g(x)}dx \approx \frac{\int_{\Omega}{f(x)dx}}{\int_{\Omega}dx} \cdot \int_{\Omega}{g(x)}dx ∫Ωf(x)g(x)dx≈∫Ωdx∫Ωf(x)dx⋅∫Ωg(x)dx
上述积分近似在下面这些情况下比较准确
- 积分域 Ω \Omega Ω比较小
- 函数 g ( x ) g(x) g(x)比较光滑,或者说max和min差别不大
我们将上述近似方程运用到渲染方程中

可以得到下面这个近似结果

这个式子可以作为两部分理解,左边红色部分用于判断光线是否可见,右边部分就是不考虑是否可见的所有方向的光线积分。
同样,我们需要考虑在什么情况下该近似是比较准确的,对于渲染方程下的数学结果,我们考虑下面两种情况
- 点光源,或者是方向光源
- diffuse bsdf / constant radiance area lighting
PCF
PCF(Percentage Closer Filtering)本身是作为反走样的一种方法,将阴影结果进行过滤(Flitering),或者说卷积(Convolution)。注意这里并不是对shadow map卷积,因为如果对深度卷积后,首先得到的是模糊的shadow map,之后还要与着色点深度作比较,得到的还是一个二值化结果,只有阴影和非阴影两种情况,并不会产生软阴影效果。
PCF的过程,实际上就是卷积过程,运用在阴影映射中, 就是将某一片段的周围片段是否为阴影的二值化结果进行卷积。
- 对于某一个像素,在一个3*3范围里,运用Two Pass Shadow Map对每个像素是否为阴影进行判断,例如
[ 1 0 1 1 0 1 1 1 0 ] \left[ \begin{matrix} 1 & 0 & 1\\ 1 & 0 & 1\\ 1 & 1 & 0 \end{matrix} \right] ⎣ ⎡111001110⎦ ⎤
- 将上述结果取平均或者加权,作为该像素的阴影结果,这时得到的并不是非1即0的结果,而是介于两者之间的一个值,也就是软阴影。例如我们直接对上面这个结果取平均,得到的也就是0.667
PCSS
准备工作
可想而知,如果PCF卷积的范围越大,得到的阴影就会越软,范围越小,阴影越硬。如果我们想要实现前面钢笔那幅图中的效果,我们需要一个自适应的卷积范围来区分阴影软硬。

前面我们已经提到,笔尖处阴影比较硬,笔杆处阴影比较远,其中的几何关系我们可以用下图表示:

如上图所示,这个半影直径就可以作为卷积范围变化的自适应变量,半影直径越大,阴影越软,反之阴影越硬。遮挡物越远,半影直径越大,光源越大,半影直接也越大。由相似三角形, 我们可以得到 w P e n u m b r a w_{Penumbra} wPenumbra半影直径有如下关系:
w P e n u m b r a = ( d R e c e i v e r − d B l o c k e r ) ⋅ w L i g h t / d B l o c k e r w_{Penumbra}=(d_{Receiver} - d_{Blocker}) \cdot w_{Light}/d_{Blocker} wPenumbra=(dReceiver−dBlocker)⋅wLight/dBlocker
为了更准确的结果,上图中遮挡物的距离 d b l o c k e r d_{blocker} dblocker需要对周围区域内的遮挡物进行卷积计算平均深度,而不是直接用该点遮挡物的距离。
这个周围区域又该如何选择呢?当然我们可以直接用一个固定区域比如16*16,这很简单但并不一定准确合适,为了更好地确定该区域范围,我们可以考虑下面这种情况:

虽然我们考虑的是点光源,但我们可以将光源适当扩展一下作为虚拟面光源,再将片段与面光源边缘相连得到一个锥体,可以观察到只有锥体内的物体有可能遮挡光源。这个锥体把之前我们将相机移动到光源得到的Shadow Map截断,得到的区域(也就是锥体的近平面)就是我们需要计算的周围区域范围。
完整的PCSS
自此,我们准备工作终于全部完成,完整的算法步骤如下:
- Blocker Search
- Penumbra estimation
- Percentage Closer Filtering
对于每一步的详细解析如下:
Blocker Search
在某一范围内获得平均遮挡物深度。着色点与光源相连,在锥体截断的Shadow Map中,将该区域内的每个片段与原本的深度作比较,如果更近则同样作为遮挡物相加,最后取平均(如下图所示,假如着色点的深度为7,这里只将深度更小的蓝色片段相加,计算的只是同为遮挡物的平均深度,其他红色点则忽略)Penumbra estimation
根据相似关系对半影进行估计,可以直接套用前面相似得到的数学关系 w P e n u m b r a = ( d R e c e i v e r − d B l o c k e r ) ⋅ w L i g h t / d B l o c k e r w_{Penumbra}=(d_{Receiver} - d_{Blocker}) \cdot w_{Light}/d_{Blocker} wPenumbra=(dReceiver−dBlocker)⋅wLight/dBlockerPercentage Closer Filtering
用半影直径作为控制PCF卷积范围的变量,卷积范围越大阴影越软。注意PCF是将该卷积范围内各片段是否为阴影的二值化结果进行卷积,得到一个介于[0,1]的结果
作业1:实时阴影
Shadow Map
uLightMVP矩阵的实现
在 ShadowMaterial.js 中需要向 Shader 传递正确的 uLightMVP 矩阵,该矩阵参与了第一步从光源处渲染场景从而构造 ShadowMap 的过程。
这里相当于我们在光源处建立一个虚拟摄像机,需要先用viewMatrix矩阵将相机移动到光源,再调用lookAt函数得到viewMatrix矩阵,最后使用projectionMatrix正交投影矩阵得到Shadow Map。
CalcLightMVP(translate, scale) {
let lightMVP = mat4.create();
let modelMatrix = mat4.create();
let viewMatrix = mat4.create();
let projectionMatrix = mat4.create();
// Model transform
// 模型矩阵,对相机先平移再缩放
mat4.translate(modelMatrix, modelMatrix, translate);
mat4.scale(modelMatrix, modelMatrix, scale);
// View transform
// 视图矩阵,将位于光源的相机移到坐标原点
mat4.lookAt(viewMatrix, this.lightPos, this.focalPoint, this.lightUp);
// Projection transform
// 正交投影,获取其他物体的世界坐标,并使深度信息保持线性
// 这里的各项数值相当于获得的SM图的分辨率
mat4.ortho(projectionMatrix, -100, 100, -100, 100, 1e-2, 400);
// lightMVP = projectionMatrix * viewMatrix * modelMatrix
// 注意顺序
mat4.multiply(lightMVP, projectionMatrix, viewMatrix);
mat4.multiply(lightMVP, lightMVP, modelMatrix);
return lightMVP;
}
对于lookAt函数,我们可以这样理解,我们想要得到的Shadow Map需要将相机移动到光源,而相机始终应该放在世界坐标系的原点并看向-z方向,所以当我们把相机从原点移动到光源后并旋转后,同样需要对其他世界坐标进行一个变换保持相对位置不变。将平移过后位于光源的摄像机作为新原点,这个变换矩阵 M v i e w = R v i e w T v i e w M_{view} = R_{view}T_{view} Mview=RviewTview就是lookAt函数,先平移再旋转。


- R - 右向量,也就是相机坐标系x在世界坐标系中的表示
- U - 上向量,也就是相机坐标系y在世界坐标系中的表示
- D - 方向向量,也就是相机坐标系z在世界坐标系中的表示
- P - 相机在世界坐标系中位置
useShadowMap函数的实现
该函数负责查询当前着色点在ShadowMap上记录的深度值,并与转换到light space的深度值比较后返回visibility项。
// 对每个点直接与sm判断得到硬阴影,有锯齿和自遮挡现象
float useShadowMap(sampler2D shadowMap, vec4 shadowCoord){
float lightDepth = unpack(texture2D(shadowMap,shadowCoord.xy)); // 将RGBA值转换成[0,1]的float
float shadingDepth = shadowCoord.z; // 当前着色点的深度
// return lightDepth + EPS < shadingDepth ? 0.0 : 1.0; // EPS考虑精度问题
return lightDepth + EPS < shadingDepth ? 0.0 : 1.0; // 无bias
}
实现效果
- 无bias
在完善上面两个函数后,我们就完成了最简单的Two Pass Shadow Map算法,实现的硬阴影效果如下图所示,可以看到有很多自遮挡现象,放大后可以看到硬阴影的边缘也有很明显的锯齿。



为了解决这个问题,我们可以采用前面提到的bias方法,将该着色点对应SM图的深度延长一段距离,或者说将着色点的深度人为减少一段距离。如果我们想要一个可变bias实现较好的效果,其值需要与光线倾斜角度有关,这里可以采用光线与法线的关系 1 − d o t ( l i g h t D i r , n o r m a l ) 1-dot(lightDir,normal) 1−dot(lightDir,normal)来计算。

// 使用bias偏移值优化自遮挡
float getBias(float ctrl) {
vec3 lightDir = normalize(uLightPos);
vec3 normal = normalize(vNormal);
float m = 200.0 / 2048.0 / 2.0; // 正交矩阵宽高/shadowmap分辨率/2
float bias = max(m, m * (1.0 - dot(normal, lightDir))) * ctrl;
return bias;
}
// 对每个点直接与sm判断得到硬阴影,有锯齿和自遮挡现象
float useShadowMap(sampler2D shadowMap, vec4 shadowCoord){
float lightDepth = unpack(texture2D(shadowMap,shadowCoord.xy)); // 将RGBA值转换成[0,1]的float
float shadingDepth = shadowCoord.z; // 当前着色点的深度
// return lightDepth + EPS < shadingDepth ? 0.0 : 1.0; // EPS考虑精度问题
float bias = getBias(1.4);
return lightDepth + EPS < shadingDepth - bias ? 0.0 : 1.0; // EPS考虑精度问题并且使用bias
}
- 有bias
如下图所示,自遮挡现象得到了很好的解决。

但是模型身上的阴影(如头发下的披肩和裙子下的腿部)和脚部阴影有明显缺失。

阴影边缘仍然存在比较明显的锯齿。

PCF
利用PCF实现阴影,对目标像素的周围像素进行卷积。具体操作为,先对范围内所有像素进行是否为阴影的二值化判断,再将所有结果进行卷积,得到一个位于[0,1]之间的结果,这样实现的阴影在边缘处会变得模糊,实现软阴影的效果。
值得注意的是,PCF并不是对下面两种进行卷积
- Shadow Map深度图
- 阴影全部绘制后的图
FIltering
对该范围的选取,我们采用在圆盘滤波核中进行随机采样,这种采样可以简化后续PCSS中平均深度计算范围的选取,并且使阴影模糊的部分更加圆润自然。计算出来的半影直径可与单位圆盘上的采样点相乘得到卷积范围内的采样点偏移。
coords.xy + poissonDisk[i] * filterRange * w_Penumbra
本次作业提供了两种随机采样函数
- 泊松圆盘采样
- 均匀圆盘采样
经过后面的实验我们发现泊松圆盘采样得到的结果更加自然,性能也更强,因此随机采样函数的质量也与最终渲染效果的好坏息息相关。
采样偏移与步长
在卷积过程中,将每次卷积核滑动的行数/列数称为Stride(步长)。有时需要在卷积时通过设置的Stride来压缩一部分信息,成倍缩小尺寸。
对于作业1而言,由于PCF输入的坐标coords归一到了[0,1]的范围,那么给定采样点的偏移值poissonDiskSamples[i]也需要缩小一定范围以迎合coords坐标的尺寸,因此需要给定Stride以缩小尺寸。缩小比例当然是Stride/ShaodowMapSize,框架中ShadowMapSize=2048,Stride可以给定一个初始值1,根据效果进行调整。
我们在src\engine.js
文件中可以找到SM纹理的分辨率:

// 对周围像素是否遮挡的结果进行卷积
float PCF(sampler2D shadowMap, vec4 coords) {
float shadingDepth = coords.z;
float visibility = 0.0;
float stride = 10.0; // 步长,压缩尺寸
float shadowmapSize = 2048.0; // SM分辨率
float filterRange = stride / shadowmapSize; // 将泊松圆盘采样结果缩小一定比例到[0,1]
poissonDiskSamples(coords.xy);
// uniformDiskSamples(coords.xy); // 均匀圆盘采样效率更低,噪点更多
for (int i = 0; i < NUM_SAMPLES; i++) {
float smDepth = unpack(texture2D(shadowMap, coords.xy + poissonDisk[i] * filterRange));
float res = shadingDepth > smDepth + 0.01 ? 0.0 : 1.0; // 这里的0.01是EPS,影响模型上的阴影和噪点
visibility += res;
}
return visibility / float(NUM_SAMPLES);
}
上述代码实现的效果如下图示,可以看到阴影边缘变得模糊并且没有锯齿,自遮挡现象也有所减少,成功实现软阴影,但是模型身上出现了噪点,看起来画面不干净。

实现效果
- EPS的影响
与bias不同,EPS是考虑浮点数进行比较时添加的一个精度值。调整这里的EPS发现对阴影质量影响不大,但是对模型身上噪点的影响很大。
- 这里先取EPS=1e-3,也就是原框架给的数值,我们可以看到模型身上有比较明显的噪点,画面不干净

- 再取EPS=1e-2,可以看到噪点减少,画面明亮干净许多

- 步长Stride的影响
步长控制卷积核每次滑动的行数或者列数,步长越大,阴影边缘越模糊(或者说越软)。
- Stride = 10

- Stride = 100

- 采样数NUM_SAMPLES的影响
采样数越大,阴影边缘越平滑自然,模型身上的噪点也越少,得到的整体渲染效果更好。
- NUM_SAMPLES = 100

- NUM_SAMPLES = 10

PCSS
PCSS是基于PCF方法的进一步映射阴影的方法,可以实现阴影由软到硬的一个渐进过程。
根据前面所总结的算法步骤,实现PCSS大致有三步
- Blocker Search
- Penumbra estimation
- Percentage Closer Filtering
findBlocker函数的实现
findBlocker()用于实现PCSS的第一步,计算遮挡物平均深度,以此获得半影直径用于调整滤波核大小。
不同于前面讲到的,在计算遮挡物平均距离 d B l o c k e r d_{Blocker} dBlocker时,通过着色点与光源相连得到的一个锥体来截断SM得到计算遮挡物平均深度的范围大小,这里采用的方法是使用PCS中提到过的随机采样函数——泊松圆盘滤波,这个方法实现起来更加简单并且效果不错。
// 得到着色点像素周围遮挡物的平均深度,阴影点离遮挡物越远,阴影越软
float findBlocker( sampler2D shadowMap, vec2 uv, float zReceiver ) {
int blockerNum = 0;
float totalDepth = 0.0;
float stride = 100.0; // 步长,压缩尺寸
float shadowmapSize = 2048.0; // SM分辨率
float filterRange = stride / shadowmapSize; // 将泊松圆盘采样结果缩小一定比例到[0,1]
poissonDiskSamples(uv);
for (int i = 0; i < NUM_SAMPLES; i++) {
float smDepth = unpack(texture2D(shadowMap, uv + poissonDisk[i] * filterRange));
if (smDepth + 0.001 < zReceiver) { // 深度更小的才是遮挡物
blockerNum++;
totalDepth += smDepth;
}
}
if (blockerNum == 0) // 如果没有遮挡物,也就是“最先”被光线直接照到,返回1
return 1.0;
else
return totalDepth / float(blockerNum);
}
这里值得注意的是,这里的返回条件还需要考虑没有其他遮挡物的情况,直接返回1表示能直接被照射到,否则会出现下面这种情况,模型身上有部分位置错误地被识别为阴影。

PCSS函数的实现
在完成findBlocker()后,PCSS的第一步也就完成了,我们直接调用即可。第二步计算半影直径,需要定义光源大小再根据前面得到的相似关系计算。第三步过程与PCF类似,不同之处在于选取卷积采样点时需要额外用到第二步得到的半影直径,调整滤波核的大小。
float getBias(float ctrl);
float PCSS(sampler2D shadowMap, vec4 coords){
float shadingDepth = coords.z;
// STEP 1: avgblocker depth
float d_Blocker = findBlocker(shadowMap, coords.xy, coords.z);
// STEP 2: penumbra size 半影尺寸,或者说卷积范围
float d_Receiver = coords.z;
float w_Light = 1.0; // 值越大,光源面积越大,阴影越软
float w_Penumbra = (d_Receiver - d_Blocker) * w_Light / d_Blocker;
// STEP 3: filtering
float stride = 10.0; // 步长,压缩尺寸
float shadowmapSize = 2048.0; // SM分辨率
float filterRange = stride / shadowmapSize; // 将泊松圆盘采样结果缩小一定比例到[0,1]
float visibility = 0.0;
// poissonDiskSamples(coords.xy); findBlocker()已经进行过泊松采样
for (int i = 0; i < NUM_SAMPLES; i++) {
float smDepth = unpack(texture2D(shadowMap, coords.xy + poissonDisk[i] * filterRange * w_Penumbra));
float bias = getBias(1.4);
// float res = coords.z - bias > smDepth + 0.0001 ? 0.0 : 1.0; // 同样可以采用bias减少自遮挡问题,但也同样会有脚部阴影缺失
float res = shadingDepth > smDepth + 0.01 ? 0.0 : 1.0;
visibility += res;
}
return visibility / float(NUM_SAMPLES);
}
实现效果
NUM_SAMPLES = 100 Stride = 20.0
同样采样数越多实现的阴影效果越好

可以看到运用PCFF得到的阴影边缘自然平滑没有锯齿,脚部偏硬,头部偏软,成功实现了硬阴影到软阴影的渐进过程。

虽然阴影的效果不错,但模型身上仍然会出现上图这样的自遮挡现象。为了解决这个问题我们同样可以采用之前的老办法,使用bias偏移减小着色点深度。

如上图所示,添加bias后模型身上的自遮挡现象消除很多,得到了很不错的效果。但还是存在前面提到的问题,脚部和模型披肩部分等较近的阴影会有所缺失。

总结
自此,关于GAMES202-Lecture3 Real-time Shadows2部分的核心知识梳理完毕,并且记录了作业1完成过程以及遇到的问题和需要注意的地方。
参考
LookAt、Viewport、Perspective矩阵 - 知乎
【GAME202实时渲染】1、软阴影01(Shadow Mapping、Peter Panning、PCSS原理超详细)_宗浩多捞的博客-CSDN博客_实时阴影原理
拓展阅读
主流实时阴影技术
实时阴影(一) ShadowMap, PCF与Face Culling - 知乎