🚩 0x00 To begin with
这篇文章将会包含以下内容:
- GAMES202 作业 1
For reference👇:
- 📺 B 站视频: GAMES202-高质量实时渲染
- 📦 代码仓库: congyuxiaoyoudao/GAMES202_Homework at working
master 分支上是 202 全部作业汇总,working 分支用于提交代码。需要原始作业可下载 master 分支的包
0x01 Shadow Map
完成两个任务点:
- 第一个 Pass 以光源作为相机渲染一张 ShadowMap,需要为 Shader 传递正确的
uLightMVP矩阵; - 第二个 Pass 获取光源传递的统一变量 FBO (ShadowMap),需要比较当前 ShadingPoint 的深度值与 ShadowMap 上记录的深度值,得出可见性项与 Shading 结果相乘。
DirectionalLight.js 中,完善 CalcLightMVP 函数:

1 // Model transform
2 mat4.translate(modelMatrix, modelMatrix, translate);
3 mat4.scale(modelMatrix, modelMatrix, scale);
4
5 // View transform
6 mat4.lookAt(viewMatrix, this.lightPos, this.focalPoint, this.lightUp);
7
8 // Projection transform
9 var r = 100;
10 var l = -r;
11 var t = 100;
12 var b = -t;
13 var n = 0.01;
14 // caution! Depth of far plane should be a bit more larger
15 var f = 400;
16
17 mat4.ortho(projectionMatrix, l, r, b, t, n, f);
远平面需设置得稍微大一点,避免光源的视锥范围无法覆盖全部场景
phongFragment.glsl 中,由于 ShadowMap 记录的深度值范围是 0-1,所以在比较之前需要把当前 ShadingPoint 到光源的距离转换到该区间,可以透视除法后转换至 NDC 后再映射至该范围:
1 vec3 shadowCoord = vPositionFromLight.xyz / vPositionFromLight.w; // NDC
2 shadowCoord.xyz = (shadowCoord.xyz + 1.0) / 2.0; // -1~1 -> 0-1
然后使用这个坐标传入 useShadowMap 函数进行比较:

1float useShadowMap(sampler2D shadowMap, vec4 shadowCoord){
2 float shadowMapDepth = unpack(texture2D(shadowMap, shadowCoord.xy));
3 float visibility = shadowCoord.z > (shadowMapDepth + EPS) ? 0.0 : 1.0;
4 return visibility;
5}
这里的 EPS(Epsilon)是框架定义的 Bias,作为阈值判断是否显著得大于记录的深度,用于修正自遮挡问题
然后在 main 函数中调用 useShadowMap 和 phongColor 相乘即可。

0x02 PCF
简单说下 PCF 的原理:针对当前 ShadingPoint,不仅考察其对应像素在 ShadowMap 上记录的深度,还要考察该像素周围(邻居像素)的深度,并逐一比较得出当前 ShadingPoint 在这些像素上的遮挡关系,然后将这些值求平均,得出一个平均的遮挡关系(介于 0-1 之间)。要点:
- 并非对 ShadowMap 进行滤波
- 更像是对深度比较的结果进行滤波
框架推荐在圆盘状滤波核中随机采样,可以生成更加自然的模糊阴影,要完成的任务如下:
- 调用任一随机采样函数,填充采样偏移数组(
poissonDisk[NUM_SAMPLES]); - 考察每个偏移后像素记录的深度并与当前 ShadingsPoint 的深度进行比较,累加可见性项;
- 返回平均的可见性项。
这里使用 poissonDiskSamples 生成随机偏移:

这段代码将会填充采样偏移数组,值形如:
在填充数组后,循环采样每一个偏移后的像素,比较深度,累加可见性项,最后求其均值:

1// filter size is filter window size in pixels defined by FILTER_RADIUS / resolution
2float PCF(sampler2D shadowMap, vec4 coords, float filterSize) {
3
4 // STEP 1: uniform disk sampling generate a group of random sample points
5 poissonDiskSamples(coords.xy);
6 // Step 2: sample each point and accumulate each visibility component
7 float sum = 0.0;
8 for (int i = 0; i < PCF_NUM_SAMPLES; i++) {
9 vec2 sampleOffset = poissonDisk[i] * filterSize;
10 vec2 uv = coords.xy + sampleOffset;
11 float shadowMapDepth = unpack(texture2D(shadowMap, uv));
12 float visibility = coords.z > (shadowMapDepth + EPS) ? 0.0 : 1.0;
13 sum += visibility;
14 }
15 // Step 3: return averaged visibility
16 return sum / float(PCF_NUM_SAMPLES);
17}
由于采样的偏移在 UV 空间中进行,所以需要除以贴图分辨率,框架给的是 2048,此外,乘一个
FILTER_RADIUS调整偏移范围大小,该值越大,阴影越软
同样在 main 函数中调用 PCF 和 phongColor 相乘即可。

0x03 PCSS
观察现实的阴影,会发现在靠近投射阴影的障碍物时,阴影会更加硬,反之更软。PCSS 在 PCF 的基础上需要根据遮挡物的距离改变滤波核的大小,以调节阴影软硬,要完成的任务如下:
- 获得遮挡物的平均深度,完善
findBlocker函数; - 根据相似三角形原理计算半影大小(penumbra size);
- 根据半影大小调整滤波核大小,调用 PCF。
课程视频给出了一种确定可能遮挡当前 ShadingPoint 的范围,即连接 ShadingPoint 到 Light,在近平面处所截的区域。

也是相似三角形,给定光源宽度即可计算:
然后将这个范围乘上偏移,在最终的偏移范围内随机采样,仅记录并累加为遮挡物的像素深度,最后求得平均遮挡物深度。

1float findBlocker( sampler2D shadowMap, vec2 uv, float zReceiver ) {
2 float avgBlockerDepth = 0.0;
3 float blockerSum = 0.0;
4 poissonDiskSamples(uv);
5
6 // use proj on near plane to define search radius
7 float blockerSearchRadius = LIGHT_WIDTH_UV * (vPositionFromLight.z - NEAR_PLANE) / vPositionFromLight.z ;
8
9 for (int i = 0; i < BLOCKER_SEARCH_NUM_SAMPLES; i++) {
10 vec2 sampleOffset = poissonDisk[i] * blockerSearchRadius;
11 vec2 uvSample = uv + sampleOffset;
12 float shadowMapDepth = unpack(texture2D(shadowMap, uvSample));
13 if (shadowMapDepth < zReceiver) {
14 avgBlockerDepth += shadowMapDepth;
15 blockerSum ++;
16 }
17 }
18
19 if(blockerSum == 0.0) return 1.0; // no blocker give a max depth value
20 else return avgBlockerDepth / blockerSum;
21}
之后在 PCSS 中调用之,再一次使用相似三角形获得半影大小,将其作为滤波器大小传入 PCF。

1float PCSS(sampler2D shadowMap, vec4 coords){
2
3 // STEP 1: avgblocker depth
4 float avgBlockerDepth = findBlocker(shadowMap, coords.xy, coords.z);
5 // STEP 2: penumbra size
6 // LIGHT_WIDTH_UV is defined by (LIGHT_WIDTH / resolution)
7 float penumbraSize = (coords.z - avgBlockerDepth) * LIGHT_WIDTH_UV / avgBlockerDepth;
8 // STEP 3: use penumbra size to define filter size
9 float visibility = PCF(shadowMap, coords, penumbraSize);
10 return visibility;
11
12}
这里相似三角形求解时直接使用屏幕空间灯宽度,可以直接将得到的半影直径作为滤波器大小传入
PCF,注意统一的量纲
同样在 main 函数中调用 PCSS 和 phongColor 相乘即可。

0x04 Bonus
多光源
要实现多光源,就需要为每个光源绘制一张 ShadowMap,常规的思路是为每张 ShadowMap 绑定一个 uniform,然后 Shader 里一一比较,合成最终的阴影。但是这样还需要向 Shader 传不同的光源的 ulightMVP 矩阵,随着光源数量增加,变量会异常地多,不是很优雅。
这种思路要干的事:
- 为每个光源渲染其独立的 ShadowMap
- 将每个光源的 ShadowMap 绑到 Material 的
uniform中 - 将每个光源的
uLightPos和ulightMVP传递给 Shader - 在 Shader 中遍历每张 ShadowMap,累加可见性项
本来是想用这个思路的,但是框架要动的东西太多了,遂放弃
GPUGems 中使用一张 ShadowMap 纹理:
Chapter 10. Parallel-Split Shadow Maps on Programmable GPUs
…we reuse a single shadow-map texture in each rendering pass
生成 ShadowMap 后马上合成阴影,之后的光源就可以复用这个 Texture,然后只需将每个光源的贡献累加即可。
采用花桑氏的做法,每个光源都走一次 Camera Pass,然后混合。
因为一个 material 只能存一张 ShadowMap,所以一个材质要为每个光源都 build 一遍,也是花桑氏的做法,用一个索引区分 material 对应的光源,先从父类 Material 改起,Material.js 中:

然后是 PhongMaterial.js 中,在构造函数和 build 函数中增加灯光索引:

ShadowMaterial.js 中,同样:

loadOBJ.js 中,为每个光源 build 一个材质:

engine.js 中,为了方便一点封装一个加方向光的函数:
1function AddDirectionalLight(renderer, intensity, color, position, focalPoint, up, hasShadowMap) {
2 const directionLight = new DirectionalLight(intensity, color, position, focalPoint, up, hasShadowMap, renderer.gl);
3 renderer.addLight(directionLight);
4}
然后改一下位置和颜色,再次增加两盏方向光:
1AddDirectionalLight(renderer, 5000, [1, 0, 1], lightPos, focalPoint, lightUp, true);
2lightPos = [0, 80, -80];
3AddDirectionalLight(renderer, 500, [0, 1, 1], lightPos, focalPoint, lightUp, true);
4lightPos = [80, 80, 0];
5AddDirectionalLight(renderer, 500, [1, 1, 0], lightPos, focalPoint, lightUp, true);
最后 WebGLRenderer中.js 中,只为对应光源索引的 material 进行绘制,然后把其余光源的绘制结果叠到第一个光源的绘制结果(FrameBuffer)上:

这里按我原来写的把 mesh 的旋转放 Camera Pass 里了,所以加了多光源会出现很严重的抖动,拿到外面即可
最后再让光源转起来:

1let lightPos = this.lights[l].entity.lightPos;
2lightPos = vec3.rotateY(lightPos, lightPos, this.lights[l].entity.focalPoint, degreeToRadian(10) * deltaTime);
3this.lights[l].entity.lightPos = lightPos;
大功告成,看一下三体运动:

动态物体
目前可以看到物体和光源是不支持旋转的,这里加上。不动脑子一点的办法是全局搜索 Transform,然后一个一个加上旋转就行,这里笔者尽量给出有逻辑的修改方式。
框架中的渲染循环位于 engine.js,最后有个 setTransform 函数,在这里加上旋转变换:

1function setTransform(t_x, t_y, t_z, r_x, r_y, r_z, s_x, s_y, s_z) {
2 return {
3 modelTransX: t_x,
4 modelTransY: t_y,
5 modelTransZ: t_z,
6 // Begin TOP changes
7 modelRotateX: r_x,
8 modelRotateY: r_y,
9 modelRotateZ: r_z,
10 // End TOP changes
11 modelScaleX: s_x,
12 modelScaleY: s_y,
13 modelScaleZ: s_z,
14 };
15}
然后在上面部分代码设置模型变换中加上旋转的参数,目前先给 0:

1let floorTransform = setTransform(0, 0, -30, 0, 0, 0, 4, 4, 4);
2let obj1Transform = setTransform(0, 0, 0, 0, 0, 0, 20, 20, 20);
3let obj2Transform = setTransform(40, 0, -40, 0, 0, 0, 10, 10, 10);
这个变换作为 loadOBJ 函数的参数用于设置模型变换,先看看这个函数干了什么(loadOBJ.js):

涉及到 transform 的地方就这些,一个是 Mesh 的 Ctor,另一个是 material 的 build 函数。
在 Mesh.js 中,更改其构造函数,增加对旋转的支持:

1// TRSTransform Ctor
2 constructor(translate = [0, 0, 0], rotate = [0, 0, 0], scale = [1, 1, 1]) {
3 this.translate = translate;
4 this.rotate = rotate;
5 this.scale = scale;
6 }
7
8// ...
9
10// Mesh Ctor
11 const modelTranslation = [transform.modelTransX, transform.modelTransY, transform.modelTransZ];
12 const modelRotation = [transform.modelRotateX, transform.modelRotateY, transform.modelRotateZ];
13 const modelScale = [transform.modelScaleX, transform.modelScaleY, transform.modelScaleZ];
14 let meshTrans = new TRSTransform(modelTranslation, modelRotation, modelScale);
回到 loadOBJ.js,从传入的 transform 中解析模型旋转,然后传入 material 的 build 函数:

1// object.traverse
2let Translation = [transform.modelTransX, transform.modelTransY, transform.modelTransZ];
3let Rotation = [transform.modelRotateX, transform.modelRotateY, transform.modelRotateZ];
4let Scale = [transform.modelScaleX, transform.modelScaleY, transform.modelScaleZ];
5
6material = buildPhongMaterial(colorMap, mat.specular.toArray(), light, Translation, Rotation, Scale, "./src/shaders/phongShader/phongVertex.glsl", "./src/shaders/phongShader/phongFragment.glsl");
7shadowMaterial = buildShadowMaterial(light, Translation, Rotation, Scale, "./src/shaders/shadowShader/shadowVertex.glsl", "./src/shaders/shadowShader/shadowFragment.glsl");
然后自然需要对 material 的变换进行支持,依次在 PhongMaterial.js 和 ShadowMaterial.js 中为上述创建材质的函数添加对旋转的支持:
PhongMaterial.js 中:

1// PhongMaterial Ctor
2 constructor(color, specular, light, translate, rotate, scale, vertexShader, fragmentShader) {
3 let lightMVP = light.CalcLightMVP(translate, rotate, scale);
4
5// material builder function
6async function buildPhongMaterial(color, specular, light, translate, rotate, scale, vertexPath, fragmentPath) {
7
8 let vertexShader = await getShaderString(vertexPath);
9 let fragmentShader = await getShaderString(fragmentPath);
10 return new PhongMaterial(color, specular, light, translate, rotate, scale, vertexShader, fragmentShader);
11}
同样,ShadowMaterial.js 中:

1class ShadowMaterial extends Material {
2 constructor(light, translate, rotate, scale, vertexShader, fragmentShader) {
3 let lightMVP = light.CalcLightMVP(translate, rotate, scale);
4 super({
5 'uLightMVP': { type: 'matrix4fv', value: lightMVP }
6 }, [], vertexShader, fragmentShader, light.fbo);
7 }
8}
9
10async function buildShadowMaterial(light, translate, rotate, scale, vertexPath, fragmentPath) {
11 let vertexShader = await getShaderString(vertexPath);
12 let fragmentShader = await getShaderString(fragmentPath);
13 return new ShadowMaterial(light, translate,rotate, scale, vertexShader, fragmentShader);
14}
注意到,上面传递的 lightMVP 矩阵是通过 CalcLightMVP 计算的,所以下一步为其增加对旋转的支持,DirectionalLight.js 中:

1 CalcLightMVP(translate, rotate, scale) {
2 let lightMVP = mat4.create();
3 let modelMatrix = mat4.create();
4 let viewMatrix = mat4.create();
5 let projectionMatrix = mat4.create();
6
7 // Model transform
8 mat4.translate(modelMatrix, modelMatrix, translate);
9 mat4.rotateX(modelMatrix, modelMatrix, rotate[0]);
10 mat4.rotateY(modelMatrix, modelMatrix, rotate[1]);
11 mat4.rotateZ(modelMatrix, modelMatrix, rotate[2]);
12 mat4.scale(modelMatrix, modelMatrix, scale);
记得把上面灯的 Mesh(cube)模型变换的旋转参数加上
1// DirectionalLight Ctor
2this.mesh = Mesh.cube(setTransform(0, 0, 0, 0, 0, 0, 0.2, 0.2, 0.2, 0));
最后,渲染循环每一帧绘制时会调用 WebGLRenderer 的 render 函数,后者又会为每一个实体调用其 meshRender 的 draw 函数,该函数需要调用 bindCameraParameters,这里向 Shader 传递了作为 uniform 的模型变换矩阵,在这里加上旋转(MeshRender.js 中):

1mat4.rotateX(modelMatrix, modelMatrix, this.mesh.transform.rotate[0]);
2mat4.rotateY(modelMatrix, modelMatrix, this.mesh.transform.rotate[1]);
3mat4.rotateZ(modelMatrix, modelMatrix, this.mesh.transform.rotate[2]);
对旋转的支持到这里就差不多了,现在我们需要让模型随着时间按 Y 轴旋转,我们可以从循环里拿到当前时间,然后与保存的时间相减得到差值,向 render 函数传入这个差值然后每帧累加一个旋转角度即可。

最后,在 WebGLRenderer.js,render 函数中每帧需要更新光源对应的 FBO(帧缓冲区),否则 ShadowMap 的内容会每帧叠加,以及更新传入 Shader 的 uniform 变量。

这里 updateMVP 是我自定义的一个函数:
1 updateMVP(mesh, lightEntity){
2 let translate = mesh.transform.translate;
3 let rotate = mesh.transform.rotate;
4 let scale = mesh.transform.scale;
5 let MVP = lightEntity.CalcLightMVP(translate, rotate, scale);
6 return MVP;
7 }
完成之后就可以转圈圈~
