🚩 0x00 To begin with

这篇文章将会包含以下内容:

  • GAMES202 作业 1

For reference👇:

master 分支上是 202 全部作业汇总,working 分支用于提交代码。需要原始作业可下载 master 分支的包


0x01 Shadow Map

完成两个任务点:

  1. 第一个 Pass 以光源作为相机渲染一张 ShadowMap,需要为 Shader 传递正确的 uLightMVP 矩阵;
  2. 第二个 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 相乘即可。

ShadowMap 硬阴影


0x02 PCF

简单说下 PCF 的原理:针对当前 ShadingPoint,不仅考察其对应像素在 ShadowMap 上记录的深度,还要考察该像素周围(邻居像素)的深度,并逐一比较得出当前 ShadingPoint 在这些像素上的遮挡关系,然后将这些值求平均,得出一个平均的遮挡关系(介于 0-1 之间)。要点:

  • 并非对 ShadowMap 进行滤波
  • 更像是对深度比较的结果进行滤波

框架推荐在圆盘状滤波核中随机采样,可以生成更加自然的模糊阴影,要完成的任务如下:

  1. 调用任一随机采样函数,填充采样偏移数组(poissonDisk[NUM_SAMPLES]);
  2. 考察每个偏移后像素记录的深度并与当前 ShadingsPoint 的深度进行比较,累加可见性项;
  3. 返回平均的可见性项。

这里使用 poissonDiskSamples 生成随机偏移:

这段代码将会填充采样偏移数组,值形如:

$$ \begin{aligned} possionDisk_{i}=&(\cos\theta,\sin \theta)\cdot R \\ a_{0}\leq \theta\leq a_{0}&+2\pi \cdot rings \\ \frac{1}{samples}^{3/4}&\leq R\leq 1 \end{aligned} $$

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

 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 的基础上需要根据遮挡物的距离改变滤波核的大小,以调节阴影软硬,要完成的任务如下:

  1. 获得遮挡物的平均深度,完善 findBlocker 函数;
  2. 根据相似三角形原理计算半影大小(penumbra size);
  3. 根据半影大小调整滤波核大小,调用 PCF。

课程视频给出了一种确定可能遮挡当前 ShadingPoint 的范围,即连接 ShadingPoint 到 Light,在近平面处所截的区域。

也是相似三角形,给定光源宽度即可计算:

$$ blockerSearchRadius=W_{light} \cdot \frac{d_{PosToLight}-d_{near}}{d_{PosToLight}} $$

然后将这个范围乘上偏移,在最终的偏移范围内随机采样,仅记录并累加为遮挡物的像素深度,最后求得平均遮挡物深度。

 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    }

完成之后就可以转圈圈~

转<del>圈</del>圈~