GAMES202 高质量实时渲染 作业 1 解答 (Assignment1)
GAMES202 课程的作业难度相较于 GAMES101 有了显著提高,几乎花了我一整天的时间才将作业1完成。之后我阅读了几篇相关的解答文章,发现大家在算法的具体实现上存在差异。有些解答修改了函数接收的参数数量,我感觉这可能会与出题者的初衷不符。因此,我打算分享我自己的解题过程,力求尽可能贴合作业提供的函数构造方法,希望能给后续学习这门课程的同学们提供一个参考。
本篇文章将主要涵盖以下内容:
- ShadowMap 算法的实现
- PCF 算法的实现
- PCSS 算法的实现
- TODO:多光源和动态物体
- TODO:自适应 Bias 算法的实现
- TODO:VSSM 算法的实现
在阅读过程中,如果存在任何疑问或建议,欢迎在评论区交流讨论。
注意事项
作业框架分析请见:[TODO]
作业代码及我的解答:[GitHub Link]
默认作业框架可能会出现加载不出模型的情况,解决方法为在 index.html 第 4 行的
<head>
前加上以下内容对模型进行预加载,相关讨论请见:作业0 结果不稳定,有时模型显示不全 – 计算机图形学与混合现实在线平台 (games-cn.org)1
2
3
4
5
6
7
8
9// index.html
<link
rel="preload"
href="assets/mary/MC003_Kozakura_Mari.png"
as="image"
type="image/png"
crossorigin
/>未正确加载的模型
正确加载的模型
算法实现
ShadowMap 算法
根据作业要求,对于 ShadowMap 的实现有两个任务点需要完成:
- DirectionalLight.js 中的
CalcLightMVP
函数。 - phongFragment.glsl 中的
useShadowMap
函数。
下面将依次进行分析。
DirectionalLight.js 中的 CalcLightMVP 函数
这个就是 GAMES101 中学的 MVP 变换,不过这次我们在框架下可以直接使用内置的函数,不用手动构建变换矩阵了。所以实际的实现也变得很简单,如下所示。需要注意的是我们一般是先做 Translation,再 Rotation,再 Scale,用矩阵来表示就是 \(SRTx\) (从右向左依次施加到 \(x\) 上) 。
1 | // DirectionalLight.js |
这里正交投影矩阵参数是可以自己选定的,基本准测是确保裁剪平面足够大以包含所有重要的场景元素,同时又不要太大以避免不必要的性能损失。
在 engine.js 中可以看到光源和物体在场景中的坐标
1 | // engine.js |
以此为根据可以选出一组比较合理的参数,同时我们尽量保证
l = -r
和 b = -t
方便后面我们对坐标进行变换,也就是上面代码中的 Projection transform
部分。
phongFragment.glsl 中的 useShadowMap 函数
useShadowMap
函数负责在阴影映射 (ShadowMap)
中查询当前片段 (Fragment) 的深度值,并将其与转换到光源空间 (Light Space)
的深度值进行比较,以返回可见性 (Visibility) 项。在 OpenGL
中,通常通过对深度纹理进行采样并将采样值与片段的深度值进行比较来实现这一点。
在这个函数中,我们需要考虑以下几个步骤:
- 坐标转换:深度纹理的坐标是在 [0, 1]
范围内,所以我们将阴影坐标 (
shadowCoord
) 从裁剪空间转换到 [0, 1] 的规范化设备坐标 (Normalized Device Coordinates, NDC) 。由于后面 PCF 和 PCSS 的实现都需要做这一步,所以我们可以先在main
函数中进行转换,然后传给函数转换后的坐标,方便直接使用。 - 深度比较:使用转换后的坐标从阴影纹理中采样深度值,然后将其与片段的深度值
(即
shadowCoord.z
) 进行比较。 - 可见性计算:如果片段深度大于从阴影映射中采样的深度 (意味着片段在光源视角下被其他物体遮挡) ,则该片段位于阴影中,可见性为 0。否则,它是可见的,可见性为 1。
以下是 useShadowMap
函数的实现代码:
1 | // phongFragment.glsl |
在上述代码中,projCoords.z
是片段的深度值,而
closestDepth
是阴影图中采样得到的深度值。两者比较的结果决定了片段是否处于阴影中,从而影响最终的可见性。这里的
EPS
是用来减少阴影粉刺 (Shadow Acne) 的一个小偏差值,默认是
1e-3
,我调整成了 2e-2
感觉效果比较好。
EPS
定义在文件最上面,别忘了改,默认值 1e-3
会有很严重的阴影粉刺现象 (你可以试试看) 。
main
函数中我们需要实现坐标转换和启用
useShadowMap
(用哪种方法启用哪个,其它方法注释掉即可,后面就不赘述了)
,如下所示:
1 | // phongFragment.glsl |
到此我们就完成了 ShadowMap 算法的实现,效果如下
PCF 算法
实现 PCF
函数的步骤大概可以归纳为下面几点:
- 采样点生成:使用泊松圆盘采样或均匀圆盘进行采样。然后用
filterSize
乘上这些采样,得到一系列坐标偏移值,将原始shadowCoord
加上这些坐标偏移值,就能得到采样点的坐标。 - 深度比较和累积:对于每个采样点,计算它在阴影图上的深度值,并与当前片段的深度值进行比较。根据比较结果累积被遮挡的采样点数量。
- 平均化处理:将被遮挡的采样点数量除以采样点总数,得到最终的阴影强度。
下面是代码实现:
1 | // phongFragment.glsl |
在这个实现中,filterSize
是滤波核的大小,用于控制阴影的软化程度,这个参数在原本的
main
里没有写,但是作业要求上是有这个参数的,所以我们手动在
main
函数里加一下这个参数,我设置为了
0.01
。
1 | // phongFragment.glsl |
可以看到阴影的边缘变得模糊了,但是噪声比较大,可以将采样数调高获得更好的阴影质量,下面是将
NUM_SAMPLES
(定义在文件最开头) 从默认的 20
调成 50
后的效果,可以看到噪声明显变小了。
至此我们实现了 PCF 算法。
PCSS 算法
要完善 findBlocker
和 PCSS
(Percentage
Closer Soft Shadows)
函数,我们需要计算遮挡物的平均深度,并使用这个信息来确定伴影 (Penumbra)
的直径,进而调整 PCF
函数的滤波核大小。findBlocker
函数负责寻找可能遮挡光线的物体并计算它们的平均深度,而 PCSS
函数则使用这个平均深度来计算伴影直径,并将其传递给 PCF
函数。
作业文档中说了实现这个函数的时候我们可以自己定义诸如光源宽度、采样数之类的参数,我的选择如下:
1 | // phongFragment.glsl |
findBlocker
的实现和 PCH
很类似,都是对一定范围内的采样点查询是否被遮挡,只不过 PCH
返回的是被遮挡的采样点比例,findBlocker
返回的是平均被遮挡的深度,其代码如下:
1 | // phongFragment.glsl |
得到平均遮挡深度后,我们就可以根据课上的这个图来计算伴影的直径了。
上面公式中,\(d_{Receiver}\)
是当前片段 (Fragment) 的深度,\(d_{Blocker}\)
是我们刚刚求得的平均遮挡深度, \(w_{light}\)
是光源宽度,刚刚由我们定义了,由此我们可以得到 PCSS
的代码
1 | // phongFragment.glsl |
可以看到由近到远阴影产生了由硬到软的转变,至此 PCSS 算法实现完成。