GAMES202 高质量实时渲染 作业 1 解答 (Assignment1)

GAMES202 课程的作业难度相较于 GAMES101 有了显著提高,几乎花了我一整天的时间才将作业1完成。之后我阅读了几篇相关的解答文章,发现大家在算法的具体实现上存在差异。有些解答修改了函数接收的参数数量,我感觉这可能会与出题者的初衷不符。因此,我打算分享我自己的解题过程,力求尽可能贴合作业提供的函数构造方法,希望能给后续学习这门课程的同学们提供一个参考。

本篇文章将主要涵盖以下内容:

  1. ShadowMap 算法的实现
  2. PCF 算法的实现
  3. PCSS 算法的实现
  4. TODO:多光源和动态物体
  5. TODO:自适应 Bias 算法的实现
  6. 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 的实现有两个任务点需要完成:

  1. DirectionalLight.js 中的 CalcLightMVP 函数。
  2. phongFragment.glsl 中的 useShadowMap 函数。

下面将依次进行分析。

DirectionalLight.js 中的 CalcLightMVP 函数

这个就是 GAMES101 中学的 MVP 变换,不过这次我们在框架下可以直接使用内置的函数,不用手动构建变换矩阵了。所以实际的实现也变得很简单,如下所示。需要注意的是我们一般是先做 Translation,再 Rotation,再 Scale,用矩阵来表示就是 \(SRTx\) (从右向左依次施加到 \(x\) 上) 。

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
// DirectionalLight.js

CalcLightMVP(translate, scale) {
let lightMVP = mat4.create();
let modelMatrix = mat4.create();
let viewMatrix = mat4.create();
let projectionMatrix = mat4.create();

// Solution starts here

// 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
var r = 100;
var l = -r;
var t = 100;
var b = -t;
var n = 0;
var f = 200;
mat4.ortho(projectionMatrix, l, r, b, t, n, f);

// Solution ends here

mat4.multiply(lightMVP, projectionMatrix, viewMatrix);
mat4.multiply(lightMVP, lightMVP, modelMatrix);

return lightMVP;
}

这里正交投影矩阵参数是可以自己选定的,基本准测是确保裁剪平面足够大以包含所有重要的场景元素,同时又不要太大以避免不必要的性能损失。

在 engine.js 中可以看到光源和物体在场景中的坐标

1
2
3
4
5
6
7
8
// engine.js

let lightPos = [0, 80, 80];
let focalPoint = [0, 0, 0];
let lightUp = [0, 1, 0]
let floorTransform = setTransform(0, 0, -30, 4, 4, 4);
let obj1Transform = setTransform(0, 0, 0, 20, 20, 20);
let obj2Transform = setTransform(40, 0, -40, 10, 10, 10);

以此为根据可以选出一组比较合理的参数,同时我们尽量保证 l = -rb = -t 方便后面我们对坐标进行变换,也就是上面代码中的 Projection transform 部分。

phongFragment.glsl 中的 useShadowMap 函数

useShadowMap 函数负责在阴影映射 (ShadowMap) 中查询当前片段 (Fragment) 的深度值,并将其与转换到光源空间 (Light Space) 的深度值进行比较,以返回可见性 (Visibility) 项。在 OpenGL 中,通常通过对深度纹理进行采样并将采样值与片段的深度值进行比较来实现这一点。

在这个函数中,我们需要考虑以下几个步骤:

  1. 坐标转换:深度纹理的坐标是在 [0, 1] 范围内,所以我们将阴影坐标 (shadowCoord) 从裁剪空间转换到 [0, 1] 的规范化设备坐标 (Normalized Device Coordinates, NDC) 。由于后面 PCF 和 PCSS 的实现都需要做这一步,所以我们可以先在 main 函数中进行转换,然后传给函数转换后的坐标,方便直接使用。
  2. 深度比较:使用转换后的坐标从阴影纹理中采样深度值,然后将其与片段的深度值 (即 shadowCoord.z) 进行比较。
  3. 可见性计算:如果片段深度大于从阴影映射中采样的深度 (意味着片段在光源视角下被其他物体遮挡) ,则该片段位于阴影中,可见性为 0。否则,它是可见的,可见性为 1。

以下是 useShadowMap 函数的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// phongFragment.glsl

float useShadowMap(sampler2D shadowMap, vec4 shadowCoord) {
// Sample the depth value from the shadow map.
float closestDepth = texture2D(shadowMap, shadowCoord.xy).r;

// Check if the current fragment is in the shadow.
float shadow = 1.0;
if (shadowCoord.z > closestDepth + EPS) {
shadow = 0.0;
}

return shadow;
}

在上述代码中,projCoords.z 是片段的深度值,而 closestDepth 是阴影图中采样得到的深度值。两者比较的结果决定了片段是否处于阴影中,从而影响最终的可见性。这里的 EPS 是用来减少阴影粉刺 (Shadow Acne) 的一个小偏差值,默认是 1e-3,我调整成了 2e-2 感觉效果比较好。

EPS 定义在文件最上面,别忘了改,默认值 1e-3 会有很严重的阴影粉刺现象 (你可以试试看) 。

main 函数中我们需要实现坐标转换和启用 useShadowMap (用哪种方法启用哪个,其它方法注释掉即可,后面就不赘述了) ,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// phongFragment.glsl

void main(void) {
// Transform to NDC [0, 1].
vec3 shadowCoord = vPositionFromLight.xyz / vPositionFromLight.w;
shadowCoord = shadowCoord * 0.5 + 0.5;

float visibility;
// Enable useShadowMap(), comment unused methods.
visibility = useShadowMap(uShadowMap, vec4(shadowCoord, 1.0));
//visibility = PCF(uShadowMap, vec4(shadowCoord, 1.0));
//visibility = PCSS(uShadowMap, vec4(shadowCoord, 1.0));

vec3 phongColor = blinnPhong();

// Don't foget this line!
gl_FragColor = vec4(phongColor * visibility, 1.0);
//gl_FragColor = vec4(phongColor, 1.0);
}

到此我们就完成了 ShadowMap 算法的实现,效果如下

ShadowMap 实现效果

PCF 算法

实现 PCF 函数的步骤大概可以归纳为下面几点:

  1. 采样点生成:使用泊松圆盘采样或均匀圆盘进行采样。然后用 filterSize 乘上这些采样,得到一系列坐标偏移值,将原始 shadowCoord 加上这些坐标偏移值,就能得到采样点的坐标。
  2. 深度比较和累积:对于每个采样点,计算它在阴影图上的深度值,并与当前片段的深度值进行比较。根据比较结果累积被遮挡的采样点数量。
  3. 平均化处理:将被遮挡的采样点数量除以采样点总数,得到最终的阴影强度。

下面是代码实现:

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
// phongFragment.glsl

float PCF(sampler2D shadowMap, vec4 shadowCoord, float filterSize) {
// Perform Sampling
poissonDiskSamples(shadowCoord.xy);
//uniformDiskSamples(shadowCoord.xy);

// Shadow Accumulation
float shadow = 0.0;
for (int i = 0; i < NUM_SAMPLES; ++i) {
// Calculate the coordinates of the current sample point
vec2 samplePos = shadowCoord.xy + poissonDisk[i] * filterSize;

// Sample the depth value from the shadow map
float sampleDepth = unpack( texture2D( shadowMap, samplePos ) );

// Depth comparison
if (shadowCoord.z > sampleDepth + EPS) {
shadow += 1.0;
}
}

// Calculate the average shadow intensity
shadow /= float(NUM_SAMPLES);

return 1.0 - shadow; // 1 means full illumination, 0 means complete shadow
}

在这个实现中,filterSize 是滤波核的大小,用于控制阴影的软化程度,这个参数在原本的 main 里没有写,但是作业要求上是有这个参数的,所以我们手动在 main 函数里加一下这个参数,我设置为了 0.01

1
2
3
4
5
6
7
// phongFragment.glsl

void main(void) {
// ...
visibility = PCF(uShadowMap, vec4(shadowCoord, 1.0), 0.01);
// ...
}
PCF 算法实现效果 (NUM_SAMPLES = 20)

可以看到阴影的边缘变得模糊了,但是噪声比较大,可以将采样数调高获得更好的阴影质量,下面是将 NUM_SAMPLES (定义在文件最开头) 从默认的 20 调成 50 后的效果,可以看到噪声明显变小了。

PCF 算法实现效果 (NUM_SAMPLES = 50)

至此我们实现了 PCF 算法。

PCSS 算法

要完善 findBlockerPCSS (Percentage Closer Soft Shadows) 函数,我们需要计算遮挡物的平均深度,并使用这个信息来确定伴影 (Penumbra) 的直径,进而调整 PCF 函数的滤波核大小。findBlocker 函数负责寻找可能遮挡光线的物体并计算它们的平均深度,而 PCSS 函数则使用这个平均深度来计算伴影直径,并将其传递给 PCF 函数。

作业文档中说了实现这个函数的时候我们可以自己定义诸如光源宽度、采样数之类的参数,我的选择如下:

1
2
3
4
// phongFragment.glsl

#define BLOCKER_SEARCH_RADIUS 0.1;
#define LIGHT_SIZE 0.05;

findBlocker 的实现和 PCH 很类似,都是对一定范围内的采样点查询是否被遮挡,只不过 PCH 返回的是被遮挡的采样点比例,findBlocker 返回的是平均被遮挡的深度,其代码如下:

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
// phongFragment.glsl

float findBlocker(sampler2D shadowMap, vec2 uv, float zReceiver) {
// Initialize average depth and number of blockers
float averageDepth = 0.0;
int numBlockers = 0;

// Generate Poisson disk samples based on uv
poissonDiskSamples(uv);
// Or generate uniform disk samples based on uv

// Loop over all samples
for (int i = 0; i < BLOCKER_SEARCH_NUM_SAMPLES; ++i) {
// Calculate offset for current sample
vec2 offset = poissonDisk[i] * BLOCKER_SEARCH_RADIUS;
// Sample depth from shadow map at the offset position
float sampleDepth = texture2D(shadowMap, uv + offset).r;

// If the sampled depth is less than the receiver's depth, it's a blocker
if (sampleDepth < zReceiver) {
// Accumulate the depth of blockers
averageDepth += sampleDepth;
// Increase the number of blockers
numBlockers++;
}
}

// If no blockers found, return -1.0
if (numBlockers == 0) return -1.0;

// Return the average depth of blockers
return averageDepth / float(numBlockers);
}

得到平均遮挡深度后,我们就可以根据课上的这个图来计算伴影的直径了。

伴影计算示意图
伴影计算公式

上面公式中,\(d_{Receiver}\) 是当前片段 (Fragment) 的深度,\(d_{Blocker}\) 是我们刚刚求得的平均遮挡深度, \(w_{light}\) 是光源宽度,刚刚由我们定义了,由此我们可以得到 PCSS 的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// phongFragment.glsl

float PCSS(sampler2D shadowMap, vec4 shadowCoord) {
// Find the depth of the blocker
float blockerDepth = findBlocker(shadowMap, shadowCoord.xy, shadowCoord.z);

// If no blocker is found, return full illumination
if (blockerDepth == -1.0) {
return 1.0;
}

// Calculate the ratio of the penumbra (the part of a shadow where the light source is only partially blocked)
float penumbraRatio = (shadowCoord.z - blockerDepth) / blockerDepth;
// Calculate the size of the filter based on the penumbra ratio and the size of the light source in UV coordinates
float filterSize = penumbraRatio * LIGHT_SIZE;

// Return the result of the Percentage-Closer Filtering (PCF) function
return PCF(shadowMap, shadowCoord, filterSize);
}
PCSS 算法实现效果

可以看到由近到远阴影产生了由硬到软的转变,至此 PCSS 算法实现完成。