type
status
date
slug
summary
tags
category
password
icon
为了遥远的目标疾驰
作业总体实现流程
- 实现shadow map
- 实现PCF
- 实现PCSS
- 实现多光源,旋转物体(Bonus部分)
关于作业框架加载不出模型

作业框架神鬼二象性,时不时就会加载不出202娘,但是有字.解决方法如下,将下面这行代码加入到``让202娘的贴图预加载一下即可:
关于作业框架汇总的shadowMap流程
先简单回顾一下shadow map的流程吧


两道pass,第一道光源视角的light pass,从光源视角先渲染出一张深度图(shadow map),第二个pass是普通的相机pass,也就是正常的从相机视角渲染场景:对于每个渲染点(shading point),需要有一次深度对比:
- 将shading point变换到光源空间中,取其深度与uv(需要转化),和shadow map中同uv记录的深度值对比,若大于shadow map的深度,则该点在阴影中
以下是框架中对该部分是实现:
两个Pass的绘制流程体现在
WebGLRenderer.js
以上代码中。其中shadowMeshes和meshes最终其实指向的是同一堆mesh数据,只是其材质不一样,这一点可以在脚本loadOBJ.js
中看到.在shadow pass中,会以光源位置朝向光源方向的视角,把场景渲染一遍,其中顶点着色器使用
shadowVertex.glsl
,片元着色器使用shadowFragment.glsl
.注意shadow pass并不会直接把结果渲染到屏幕的缓存中,而是渲染到属于该光源的FrameBuffer中,这一点在DirectionalLight.js
可以看到有代码this.fbo = new FBO(gl)
;这段代码会创建属于这个光源实例的FrameBuffer,最终会储存在Material中,当对应的MeshRender被调用Draw方法时,通过gl.bindFramebuffer(gl.FRAMEBUFFER, this.material.frameBuffer);来绑定到材质持有的FrameBuffer,所以shadow pass就会把结果渲染到自己的FrameBuffer上.在camera pass中,会以真实摄像机视角,把场景渲染一遍,其中顶点着色器使用
phongVertex.glsl
,片元着色器使用phongFragment.glsl
.另外提一下,用于在camera pass渲染的PhongMaterial,是没有frameBuffer参数的,也就是其Material中frameBuffer字段为空,但当他的MeshRender的Draw被调用时候,仍然会执行gl.bindFramebuffer(gl.FRAMEBUFFER, this.material.frameBuffer)
;但绑定到空的FrameBuffer上相当于绑定到默认FrameBuffer(屏幕)上,所以最终绘制结果是在屏幕,shadow pass的渲染结果会传递到phongFragment的uniform数据中,以贴图形式供着色器采样以渲染阴影.
在
shadowFragment.glsl
中有两个值得注意的点,一个是gl_FragCoord,另一个是pack函数.gl_FragCoord是glsl提供的一个内置vec4类型变量,其中xyz代表窗口空间坐标(window-space coordinate),也就是经过viewport transformation后得到的坐标,我们因此可以直接用该变量获得当前片元的深度关系.
至于此深度值是否线性,取决于投影矩阵,因为平行光使用的是正交矩阵,所以gl_FragCoord.z取得的深度值是线性的.而gl_FragCoord.w则是裁剪空间坐标中w的倒数.
pack函数的作用是把一个[0,1)的float值储存到RGBA四个通道中,pack的实现最早似乎可以追溯到Encoding floats to RGBA - the final?这篇文章.相对应的在
phongFragment.glsl
中还有一个unpack函数,因为shadowmap中储存的值是pack后的值,我们在采样后需要unpack后才能使用.为啥呢?大概是便于计算吧,因为浮点型运算一直是计算机的一个硬伤,对于渲染(尤其是高精度大场景)这种对精度要求较高的运算,用这样的一拆四策略能有效保证精度.
实现流程
1.SM
1.编写light pass所需的MVP矩阵
这里需要计算的是光源生成ShadowMap所用的MVP矩阵,原理在GAMES101已经整过,这里不再赘述.这里不需要我们自行计算矩阵变换,我们只需要调用现有接口指定对应参数即可.
这里用到的库是一个叫glMatrix的Javascript矩阵和矢量库,具体可参考官方文档.
注意因为我们实现的是平行光阴影,投影矩阵可用正交矩阵,变换后仍然保持线性深度,另外注意正交矩阵的参数定义,这里应该用尽可能小的参数,以使得ShadowMap的精度尽可能大.
2.useShadowMap函数实现
在实现这个函数之前,需要先看一看这个函数最后怎么调用.
可以看出,由于在SM上取样用的是uv坐标,因此在调用之前需要将坐标转化为uv范围.而又因为ShadowMap储存的值unpack之后是在[0,1)的范围,我们需要在这个范围区间做深度比较,所以z分量同样也在这个区间.
为了求出这3个值,我们可以利用vPositionFromLight这个数据,它是由顶点着色器中把顶点坐标乘以uLightMVP(相当于以灯源作为摄像机的MVP矩阵)得到的,也就是光源空间下投影变换后的裁剪坐标,我们第一步把他除以自己的w值,即可得到NDC坐标,此时坐标范围在[-1,1]区间,第二步把他转换到[0,1]区间中即可.
然后我们把参数传入useShadowMap函数,函数应该返回两种值:该点处于阴影中时返回0,处于光照范围时返回1,然后我们把返回值visibility乘以着色结果phongColor并赋值给gl_FragColor输出最终结果即可.
useShadowMap的实现很简单,用texture2D传入shadowMap贴图和对应uv坐标即可,然后把结果unpack就是在光源视角的当前位置点遮挡物深度,若当前位置点的深度大于遮挡物深度,则表示处于阴影中.
完成后我们即可得到下面结果:

3.shadow bias
还记得SM的不足之处吗?对了哥,有明显的自遮挡问题(Shadow Acne)

这事不难解决,只需设置一个bias,在对比深度的时候加上这个偏移量即可,这样便可以获得比上图好很多的效果

但是难以避免的,加上这个bias后,遮挡体与shading point离得比较近的位置(比如图中脚的位置)就会因为bias导致错误计算为不在阴影中,也就会产生漏光现象,SM里也叫阴影悬浮.
为了优化这个问题,需要让bias可以调整,最好能自适应不同场景情况.根据这篇文章给出的公式:
其中参数c是我们可以调节的一个最终系数,而参数filterRadiusUV是当使用PCF时,自适应还得考虑PCF的采样范围,但我们实现目前暂时用不到.
在片元着色器文件中追加一个自适应bias的函数:
由于自适应算法与视锥体大小和ShadowMap大小有关,我们直接用宏定义这两个数据:
并修改useShadowMap函数.
注意这里给useShadowMap函数添加了两个参数,最后我们还需要调整main函数中对useShadowMap函数的调用,其中bias可以执行根据效果设定,而由于我们第一步是做硬阴影,所以filterRadiusUV参数传0.
得到以下效果:

2.PCF

PCF实现首先需要两个参数,一是采样范围,二是采样数量,然后我们需要描述如何在指定范围内采样到指定数量的样本.
作业框架提供了uniformDiskSamples和poissonDiskSamples两个随机采样函数,我们调用即可,采样函数要求我们传入一个vec2变量作为随机种子,我们直接使用片元坐标即可,注意不要使用固定值,否则每次采样结果都是一样的.我们也可以调整NUM_SAMPLES来修改采样数,采样数越高,噪点越少,效果越好.
调用采样函数后,会把采样结果储存到数组poissonDisk中,我们把结果乘以我们指定的一个范围值(这里作为参数传入),然后把结果作为原始采样坐标的offset传入useShadowMap即可(一次查询操作).然后把useShadowMap得到的结果做深度比较,计算出被遮掩的样本数量占总样本数量占比,就是我们需要的模糊结果,注意由于返回的是visibility项,我们需要用1减去模糊结果,把意义取反.
然后修改main函数,以PCF返回值为visibility项,
采样范围直接宏定义,这里定义的单位是在ShadowMap大小的单位,因为采样时用的是uv单位,我们需要把采样范围除以ShadowMap大小作为参数.
使用poissonDiskSamples采样函数,并把NUM_SAMPLES调整为50,效果如下:

3.PCSS
理论回顾
回顾一下PCSS,其主要的不同在于动态变化的filter size:距离光源越远那么影子越模糊,也就是filter size更大

对于一个面积光,上图很清楚的描述了半影面积(Penumbra)如何求,有下面的公式:
其实就是相似三角形,公式中是已知的,light大小也是预设已知的,注意是一个平均深度,为了准确的描述一个范围内的情况.那么这个平均深度的平均范围是多少呢?

这个图很形象的解答了这个问题,又是一个相似三角形(四棱锥)问题,从shading point连接到光源,其经过Shadow Map时所截取的面积,就是用来求平均深度的采样面积,也就是离光源越近,采样范围就越大.
那么Light到ShadowMap的距离是一个怎样的概念?这里我们认为ShadowMap在近平面的位置,那距离就是近平面的深度了.
以下是总结的3 pass过程:
- 计算出范围内的平均深度(包括了计算范围大小)
- 计算半影面积作为虚实程度的参数
- 进行PCF

具体实现
我们先定义一下需要的数据,其中:
- NEAR_PLANE为光源所用透视矩阵的近平面数据
- LIGHT_WORLD_SIZE是我们自行设定,可根据效果调节的光源在世界空间的大小
- LIGHT_SIZE_UV则是光源在ShadowMap上的UV单位大小,可通过光源在世界空间的大小除以FRUSTUM_SIZE获得,FRUSTUM_SIZE我们在前面已经定义过.(注意这里设定ShadowMap和FRUSTUM都是正方形)
先实现一个查找平均深度的findBlocker函数,根据上文所说的确定范围的规则,用相似三角形求出在ShadowMap上的查找范围(这里posZFromLight和NEAR_PLANE是光源空间下的单位,而LIGHT_SIZE_UV和searchRadius是ShadowMap的UV单位),然后用这个范围进行一个类似于PCF的计算,只不过PCF是计算出与深度比较结果的平均值,而这里则是计算遮挡物的深度平均值.
这里有一个对于没有遮挡的特殊情况处理,直接返回-1(或者其他遮挡存在的情况下不会出现的数字都行).
然后按照上文的3 pass操作将PCSS函数补充完整:
然后别忘了改main函数
最终结果如下:

4.Bonus部分
1.动态物体(模型旋转)
先从简单的开始,框架中其实并没有提供模型旋转的部分,需要自己改函数加参数,因此与位置构建(xyz信息输入输出)有关的部分都要有所改动.以下是所有需要修改的部分:
- engine.js
- DirectionLight.js
- PhongMaterial.js
- ShadowMaterial.js
- Mesh.js
- MeshRender.js
- loadOBJ.js
以上就是支持旋转的部分需要修改的部分,接下来在engine.js里面加入旋转参数看看效果如何.
但是在看效果之前有一些关于时间方面的参数需要增删改,因为旋转是一个有时间概念的操作,因此需要在mainLoop和render函数加入时间流逝的概念.说的高大上,其实就是加入一个deltatime
接下来让render函数接收时间参数:
上述代码中有一个奇妙的小trick,在旋转前有个判断,必须要求mesh的顶点数量大于10才能转.这是因为不去筛选mesh,这个旋转会同时应用到地面和202娘上,地面只有六个顶点,这样筛选就保证了地面不会跟着一起转.
当然,加入一些tag等参数用哈希判断模型也可以,但是这样就更麻烦了,对于这种实验简单代码结构不必如此复杂.
按照上述步骤编写完毕后会发现,模型是在转了,地面也没转,但是地面上的影子怎么也不转呢??这个很简单,gl是一个状态机,本质上对于计算机硬件,每一帧都需要向下(向硬件)提交每一帧的状态,这个状态包括模型现在的各种属性,所用的shader等等.这样说就清晰很多了,影子不转是因为用于体现物体变动的LightMVP矩阵没有更新,因此还保留着最初的状态
像这样每一次渲染都重新计算提交一次即可实现正常效果.
另外我们还包含了这三行图形API的调用.
- 第一行是表示绑定当前灯光的framebuffer,后续操作都对绑定的framebuffer生效.
- 第二行是为了解决下图出现的,在地面边缘有黑影的问题,因为原本ShadowMap默认为黑色,地板外的地方因为没采样到,所以值为默认值0,而这在我们的采样中会被认为visibility为0,所以会产生阴影,这里我们对ShadowMap进行设置,当执行clear操作时,默认以白色(值为1)填充即可解决.

- 第三行很重要,首先第三行才是真正执行清除的操作,让第二行代码生效,第二行只是一个设置,另外这也是清除掉上一帧ShadowMap数据的操作,不然阴影动起来时会发现阴影会叠加.
2.动态多光源
框架中依旧没有给出多光源的支持,因此还需要修改框架,加上关于光源index的代码.
由于在WebGLRenderer中只用了两个数组区分了Shadow Pass的MeshRender和Camera Pass的MeshRender,在多光源情况下,我们把不同光源对应的MeshRender也会添加到其中,我们需要做一个区分,避免在每一轮的光源渲染中都把所有光源包含的MeshRender都Draw了一次,但框架现有代码似乎没有太好的方式可以区分,我决定给Material类添加一个lightIndex字段表示对应光源,修改如下:
你记一下,我作如下部署调整(bushi
- Material.js
- ShadowMaterial.js
- PhongMaterial.js
- loadOBJ.js
以上就是多光源的支持了,接下来添加光源.添加第二个光源,位置可以自由调整,但注意位置太远会脱离我们定义的渲染ShadowMap的视锥体,那样就无法绘制阴影了,为了防止画面太亮,我们也调整一下两个光源的亮度.
然后我们注释掉WebGLRenderer中一行光源数量检测(不知道作业框架加这个检测做什么).
最后添加动态多光源的核心实现
总结一下上面代码改动:
- 实现光源围绕原点进行Y轴旋转,注意这里的旋转并不是模型变换里的旋转,这里旋转的结果是位置发生变化,最后真正产生了“旋转”的,是光照方向的朝向,其对应光源的观察变换.
- 在Shadow Pass和Camera Pass中都判断一下当前MeshRender的材质的lightIndex与当前渲染中的光源的Index是否一致,不一致的跳过,不然会把不属于当前光源的MeshRender全部渲染一遍.
- 由于是多光源绘制,我们需要把第二个及以后的光源的Camera Pass渲染结果都叠加到第一个光源的Camera Pass渲染结果上,需要开启混合,并以one-one模式叠加,否则只能看到最后一个光源的渲染结果.
最终效果如下:
- Author:Kyrie
- URL:virtual-kyrie.cc/article/Games-202-homework1
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!