GAMES202作业1
作业要求
正确的阴影对于渲染质量的提升起到着举足轻重的作用。本次作业的内容为实时阴影,对于硬阴影要求实现经典的 Two Pass Shadow Map 方法,对于软阴影则要求实现 PCF(Percentage Closer Filter) 和 PCSS(Percentage Closer Soft Shadow)本轮工作的主要内容将集中在 phongFragment.glsl 内函数的完善上。
- 对于场景中的每个物体都会默认附带一个 ShadowMaterial 材质,并会在调用 loadOBJ 时添加到 WebGLRenderer 的 shadowMeshes[] 中。当光源参数hasShadowMap 为 true 时,作业框架将开启 Shadow Map 功能,在正式渲染场景之前会以 ShadowMaterial 材质先渲染一遍 shadowMeshes[] 中的物体,从而生成我们需要的 ShadowMap。
- ShadowMaterial 将使用 Shader/shadowShader 文件夹下的顶点和片元着色器。实现该材质的重点是要向 ShadowVertex.glsl 传递正确的 uLightMVP变量。
- 在正确实现 Shadow Map 之后,实现 PCF、PCSS 的主要工作将集中在 glsl的编写上,详见开发说明中的相应小节。
实现了 Shadow Map后的效果
实现了PCF后的效果
实现了PCSS后的效果
ShadowMap
原理
用Shadow生成阴影的方法比较简单,我们可以通过把摄像机视角下的所有像素点通过MVP矩阵转换到光源视角下,转换后的每一个像素点都会有一个深度,通过比较通过比较同一个像素的光源视角的深度与转换后的摄像机视角的深度可知,当光源视角的深度小于转换后的摄像机视角深度,则说明未转换前的摄像机视角中这个点处于阴影中。
由上图可见摄像机视角下的蓝点,转换到光源视角下后,明显深度要深于光源下该像素中能看到的第一个点(红点)的深度,所以蓝点应该在阴影中。
- ShadowMap是光源视角下各个可视像素点的深度图,因此要计算阴影,就必须在正常渲染前先以光源为视角把ShadoMap渲染出来,再在正常渲染中渲染,上述过程分为两个Pass。
第一个Pass是以光源为视角,渲染一遍场景,将所有像素的深度值存储在一张深度图中
第二个Pass以摄像机为视角,将摄像机看到的每一个像素点先用MVP矩阵转换到光源视角下,再对比该点的深度和ShadowMap中对应点的深度,判断该点是否在阴影下
代码
在作业框架中,这两个Pass如下
//WebGLRenderer.js
// Shadow pass
if (this.lights[l].entity.hasShadowMap == true) {
for (let i = 0; i < this.shadowMeshes.length; i++) {
//渲染每个mesh
this.shadowMeshes[i].draw(this.camera);
}
}
// Camera pass
for (let i = 0; i < this.meshes.length; i++) {
this.gl.useProgram(this.meshes[i].shader.program.glShaderProgram);
this.gl.uniform3fv(this.meshes[i].shader.program.uniforms.uLightPos, this.lights[l].entity.lightPos);
//渲染每个mesh
this.meshes[i].draw(this.camera);
}
在Shadow pass中,shadowVertex和shadowFragment会把屏幕深度渲染到光源的Framebuff中。
//DirectionalLight.js
this.fbo = new FBO(gl);
DirectionalLight.js里面的fbo是光源的Framebuff
//ShadowMaterial.js
class ShadowMaterial extends Material {
constructor(light, translate, rotate, scale, vertexShader, fragmentShader) {
let lightMVP = light.CalcLightMVP(translate, rotate, scale);
super({
'uLightMVP': { type: 'matrix4fv', value: lightMVP }
}, [], vertexShader, fragmentShader, light.fbo);
}
}
在ShadowMaterial中可以看出此处的Material绑定了light.fbo
//MeshRender.js
draw(camera) {
//..
gl.bindFramebuffer(gl.FRAMEBUFFER, this.material.frameBuffer);
//..
}
在MeshRender中的draw里面可以看到,渲染光源方向的时候是绑定光源对应的ShadowMaterial材质中持有的Framebuff,所以此处ShadowMap是存在light.fbo中的。
而在PhongMaterial中是没有Framebuff参数的,所以正常的PhongMaterial是渲染在默认Framebuff也就是屏幕上的。
- 光源生成ShadowMap的MVP矩阵
CalcLightMVP(translate, rotate, scale) {
let lightMVP = mat4.create();
let modelMatrix = mat4.create();
let viewMatrix = mat4.create();
let projectionMatrix = mat4.create();
// Model transform
//利用mat4接口实现的model转换
mat4.translate(modelMatrix,modelMatrix,translate);
mat4.scale(modelMatrix,modelMatrix,scale);
// View transform
//利用mat4接口实现的view转换
mat4.lookAt(viewMatrix,this.lightPos,this.focalPoint,this.lightUp);
// Projection transform
//利用mat4接口实现的projection转换
//注意这里用的正交矩阵
mat4.ortho(projectionMatrix,-150,150,-150,150,1e-2,400);
mat4.multiply(lightMVP, projectionMatrix, viewMatrix);
mat4.multiply(lightMVP, lightMVP, modelMatrix);
return lightMVP;
}
需要注意的是这里用的是正交矩阵,所以后续变换后,深度仍然是线性的。
- 实现uesShadowMap
首先需要修改的是main里的函数
//phongFragment.glsl
void main(void) {
//因为shadowCoord是在光源视角下,而光源视角观察的结果是基于投影成像的,所以需要除w分量
vec3 shadowCoord = vPositionFromLight.xyz / vPositionFromLight.w;
//把[-1,1]的NDC坐标转换为[0,1]的屏幕空间坐标
shadowCoord.xyz = (shadowCoord.xyz + 1.0) / 2.0;
float visibility;
visibility = useShadowMap(uShadowMap, vec4(shadowCoord, 1.0));
//visibility = PCF(uShadowMap, vec4(shadowCoord, 1.0));
//visibility = PCSS(uShadowMap, vec4(shadowCoord, 1.0));
vec3 phongColor = blinnPhong();
gl_FragColor = vec4(phongColor * visibility, 1.0);
}
我们需要用ShadowMap来计算可见度visibility,所以我们调用useShadowMap函数。此时的vPositionFromLight是把摄像机观察点坐标转换到的光源观察方向后的坐标,所以shadowCoord已经是该像素点在ShadowMap上的对应位置了。又因为ShadowMap坐标范围是[0,1],所以需要转换坐标系。在该函数中,处于阴影中返回0,否则返回1.下面开始补全useShadowMap函数。
//phongFragment.glsl
float useShadowMap(sampler2D shadowMap, vec4 shadowCoord){
float current_depth=shadowCoord.z;
float shadow_depth=unpack(texture2D(shadowMap,shadowCoord.xy));
if(current_depth>=shadow_depth+EPS)
{
return 0.;
}
else return 1.0;
}
在该函数中,current_depth变量代表的是摄像机视角转换过后的像素点的深度,而shadow_depth则是ShadowMap上的对应像素点深度,哦首先先用unpack把shadow_depth还原后才能进行比较。
首先我们来看一下pack函数和unpack函数的关系
//shadowFragment.
vec4 pack (float depth) {
// 使用rgba 4字节共32位来存储z值,1个字节精度为1/256
const vec4 bitShift = vec4(1.0, 256.0, 256.0 * 256.0, 256.0 * 256.0 * 256.0);
const vec4 bitMask = vec4(1.0/256.0, 1.0/256.0, 1.0/256.0, 0.0);
// gl_FragCoord:片元的坐标,fract():返回数值的小数部分
vec4 rgbaDepth = fract(depth * bitShift); //计算每个点的z值
rgbaDepth -= rgbaDepth.gbaa * bitMask; // Cut off the value which do not fit in 8 bits
return rgbaDepth;
}
//phongFragment.glsl
1float unpack(vec4 rgbaDepth) {
const vec4 bitShift = vec4(1.0, 1.0/256.0, 1.0/(256.0*256.0), 1.0/(256.0*256.0*256.0));
return dot(rgbaDepth, bitShift);
}
对比上面两段代码我们可以知道,其实pack函数就是用来保证depth的精度问题的,通过把depth用rgba的四维存储,可以最大程度保存精度,因为在depthbuff中,深度范围是[0,1],所以哪怕一点点细微的差异都会导致深度问题的出现,所以这时候深度的精度就非常重要了,而pack正是解决这个问题的。
完成ShadowMap后的结果
Shadow Bias
原理
从此图可以其实可以看出,该方法其实是存在很严重的自遮挡问题的。而造成该问题的原因可以这么理解
以太阳为观察点,Shadow Mapping记录的是各个像素对应的世界位置的深度,但是这种像素是垂直于观察点到目的地的连线的,所以每一片像素的深度并非连续的,如上图可见,左右相接的像素点却有不同的深度,他们之间的深度值并非线性。
这就导致了在以眼睛为观察点的时候,各个像素几乎平行于世界中的物体平面,这就不会出现上面的斜着观察导致各像素记录深度存在间断的问题,但如果将上述的深度图用于眼睛观察点的Shadow Mapping中,就会出现,Shadow Mapping记录的对应像素的深度是在黄色点,但是实际观察点在蓝色点,这样得出结论是观察点深度>Shadow Mapping深度,就会产生阴影,但这是有问题的。
该问题产生的原因其实在于像素深度的不连续性造成的误差导致的,也可以归类为数据精度导致的问题。
- 一种解决思路
设定合理的范围,如果观察深度和Shadow Mapping记录深度差值小于该范围,认为该点不产生阴影。
但该方法仍然存在严重的问题
从上图可见,阴影于物体相接的地方出现断层。
对于该现象的解释
上面为了解决精度问题设置的范围会造成这种问题,因为如果物体很薄,在物体与阴影相连接的地方,会出现观察点深度基本等于Shadow Mapping深度的问题,这样就会导致他们之间的差值极小然后被上面的范围覆盖认为是没以后阴影。
代码
为了解决上面的现象,我们引入自适应bias,具体的可以参考下面的文章
https://zhuanlan.zhihu.com/p/370951892
参考该文章给出的公式
我们自定义一个在phongFragment中自适应获取bias的函数getShadowBias
//phongFragment.glsl
float getShadowBias(float c, float filterRadiusUV){
vec3 normal = normalize(vNormal);
vec3 lightDir = normalize(uLightPos - vFragPos);
float A = (1. + ceil(filterRadiusUV)) * (400.0 / 2048.0 / 2.0);
return max(A, A * (1.0 - dot(normal, lightDir))) * c;
}
frustumSIze可以理解为视锥体大小,我们定义为400,shadowMapSize是ShadowMap大小,我们定义为2048
其中c是一个系数,可以手动调整bias的大小,而filterRadiusUV则可用于后续的PCF和PCSS中来决定采样的范围
至此我们可以修改上面的uesShadowMap函数了
//phongFragment.glsl
float useShadowMap(sampler2D shadowMap, vec4 shadowCoord, float biasC, float filterRadiusUV){
float current_depth=shadowCoord.z;
float shadow_depth=unpack(texture2D(shadowMap,shadowCoord.xy));
float bias = getShadowBias(biasC, filterRadiusUV);
if(current_depth-bias>=shadow_depth+EPS)
{
return 0.0;
}
else return 1.0;
}
我们要用到上面的函数,也得修改main里面的visibility函数了
float bias = 0.4;
//由于硬阴影不需要filter,所以最后一项为0
visibility = useShadowMap(uShadowMap, vec4(shadowCoord, 1.0), bias, 0.);
由此可见效果好了很多。
PCF
原理
上面成功的用ShadoMap还原了人物阴影,但发现效果其实还是很违和,那是因为上面的阴影太硬了,边界感太明显了,这是因为对ShadowMap采样以后对比结果非0即1。所以我们要优化阴影,需要对阴影进行一个“滤波”。
PCF(Percentage Closer Filtering)相比于传统的ShadoMap,它从原本直接采样某个像素点的ShadowMap深度进行比较变成了在该像素点附近多采用一圈的像素点,并把这一圈的采样都与原像素的深度对比,最后把深度比较的结果相加后进行平均,这样便可以得到一个模糊的结果,最后的visibility项不再是非0即1,而是一个范围是[0,1]的浮点数。
如上图,在计算某点的V值(渲染方程中的Visibility)过程中,圈定一个范围(a×a)的是否可见(有无阴影)进行求平均,相当于对阴影可见数值进行差值,不是单纯的黑色和白色的差别,中间存在过渡。filter的范围越大,得到的阴影越软。
代码
//phongFragment.glsl
float PCF(sampler2D shadowMap, vec4 coords, float biasC, float filterRadiusUV) {
poissonDiskSamples(coords.xy); //这里用的是随机生成序列,通过coords.xy随机生成数
float visibility = 0.0;
for(int i = 0; i < NUM_SAMPLES; i++){
vec2 offset = poissonDisk[i] * filterRadiusUV;
float noshadow = useShadowMap(shadowMap, coords + vec4(offset, 0., 0.), biasC, filterRadiusUV);
if(noshadow!=0.0){
visibility++;
}
}
return visibility / float(NUM_SAMPLES);
}
poissonDiskSamples是用来定义一些列随机数的,随机数会存在poissonDisk这个数组里,我们基于采样点NUM_SAMPLES的个数,对附近的ShadoMap进行采样并比较,若该点不在阴影中,则noshadow=1,此时visibility++,最后再除以采样数量,得到一个filter的结果。
下面的main函数也需要略做修改
//phongFragment.glsl
void main(void) {
float visibility;
float bias = 0.2;
float filterRadiusUV = 10.0 / 2048.0;
vec3 shadowCoord=vPositionFromLight.xyz/vPositionFromLight.w;
shadowCoord.xyz=(shadowCoord.xyz+1.0)/2.0;
//visibility = useShadowMap(uShadowMap, vec4(shadowCoord, 1.0), bias, 0.0);
visibility = PCF(uShadowMap, vec4(shadowCoord, 1.0), bias, filterRadiusUV);
//visibility = PCSS(uShadowMap, vec4(shadowCoord, 1.0),bias);
vec3 phongColor = blinnPhong();
gl_FragColor = vec4(phongColor * visibility, 1.0);
}
上面我们定义了filterRadiusUV采样半径为10.0/2048.0,越大的采样半径得到的结果越模糊。
PCSS
原理
PCSS可以理解为适应性软阴影,会根据遮挡物距离来调整PCF的范围大小,做到近处锐利,远处模糊
已知WLight是光源半径,Blocker是遮挡物,Receiver是产生阴影的地方
根据上面的相似三角形,我们可以得出结论,当遮挡物里产生阴影的地方越接近,其WPenumbra越小。这个性质刚好可以反映遮挡点离阴影越近时越锐利这种现象。由此我们可以以Wpenumbra来度量PCF的采样半径。
上述公式中,我们已知receiver的深度可以用屏幕空间的深度z表示,而light大小是我们自己设定的,剩下是求dBlocker,为了结果的准确,我们需要进行一定范围的采样,而范围的定义需要多大才合适呢?
上图给出了范围的定义,我们可以将ShadoMap的采样范围定义在从观察点到光源上,ShadoMap所截取的面积,而ShadowMap到光源的距离可以理解为近平面的距离。
PCSS的实际流程可以分为两步
先计算范围内遮挡物的平均深度,通过结果求得PCF范围
根据上述的范围进行PCF计算
在第一步中,计算遮挡物平均深度可以理解为在原理之进行比较深度值并返回1和0的基础上,统计采样点的总计遮挡次数,再累加每次遮挡的深度,最后把二者相除得到最终平均遮挡深度。
代码
我们将PCSS按上面的步骤分两步执行,先定义一个函数findBlocker,返回值是平均遮挡深度,再定义PCSS求得对应采样范围的PCF的结果。
float findBlocker( sampler2D shadowMap, vec2 uv, float zReceiver ) {
int blocknum=0;
float blockdepth=0.0;
float lightdepth_z=vPositionFromLight.z;
//以光源为摄像机,通过将ShadowMap放到近平面上来计算每次采样的范围
//光源采样方式为投影,得在世界空间里计算
float lightSize=5.0;
float near_plane=0.01;
float filterRadius=(lightSize/400.0)*(lightdepth_z-near_plane)/lightdepth_z;
poissonDiskSamples(uv);
for(int i=0;i<NUM_SAMPLES;i++)
{
float dBlocker=unpack(texture2D(shadowMap,uv+poissonDisk[i]*filterRadius));
//计算深度在坐标范围在(0,1)的处理后的NDC空间里操作
if(dBlocker<zReceiver)
{
blocknum++;
blockdepth+=dBlocker;
}
}
return blockdepth/float(blocknum);
}
lightSize/400定义的是光源在ShadowMap上的UV单位大小,这里的400表示视锥体的大小
vPositionFromLight是把摄像机观察点坐标转换到的光源观察方向后的坐标,该点可以表示观察点到光源的距离。
上面公式里的filterRadius求得是PCSS中对ShadoMap的采样范围,详细的计算可以通过下面的图来理解
float PCSS(sampler2D shadowMap, vec4 coords,float biasC){
float zReceiver=coords.z;
float avgDepth=findBlocker(shadowMap,coords.xy,zReceiver);
float penumbra=(zReceiver-avgDepth)*(5.0/400.0)/avgDepth;
float filterRadiusUV=penumbra;
return PCF(shadowMap,coords,biasC,filterRadiusUV);
}
在PCSS中运用findBlicker获取平均采样深度,平均采样深度再代入公式算出penumbra
修改main
void main(void) {
float visibility;
float bias = 0.2;
float filterRadiusUV = 10.0 / 2048.0;
vec3 shadowCoord=vPositionFromLight.xyz/vPositionFromLight.w;
shadowCoord.xyz=(shadowCoord.xyz+1.0)/2.0;
//visibility = useShadowMap(uShadowMap, vec4(shadowCoord, 1.0), bias, 0.0);
//visibility = PCF(uShadowMap, vec4(shadowCoord, 1.0), bias, filterRadiusUV);
visibility = PCSS(uShadowMap, vec4(shadowCoord, 1.0),bias);
vec3 phongColor = blinnPhong();
gl_FragColor = vec4(phongColor * visibility, 1.0);
}