Games202 Hw1 Percentage Closer Soft Shadow
效果图
光源大小:10
光源大小:50
光源大小:100
作业总览
- 实现
Shadow Map
。 - 实现
Percentage Closer Filtering
。 - 实现
Percentage Closer Soft Shadow
。 - 实现多光源和动态物体。(Bonus)
源码
实现
Shadow Map
我们知道在进行物体的光照渲染时,Visibility
项一般与Shading
过程分开处理,其数学模型是基于一个约等式来完成,这是实时渲染中一个非常重要约等式:
\[
\begin{align}
\int_{\Omega}f(x)g(x)\approx\frac{\int_{\Omega}f(x)\text{d}x}{\int_{\Omega}\text{d}x}\cdot
\int_{\Omega}g(x)\text{d}x
\end{align} \tag{1}
\] 我们回顾一下有Visibility
项的渲染方程:
\[
\begin{align}
L_o(p,w_o)=\int_{\Omega+}L_i(p,w_i)f_r(p,w_i,w_o)\cos\theta_i\cdot
V(p,w_i)\text{d}w_i
\end{align} \tag{2}
\] 写成约等式的形式如下:
\[
\begin{align}
L_o(p,w_o)\approx\frac{\int_{\Omega+}V(p,w_i)\text{d}w_i}{\int_{\Omega+}\text{d}w_i}\cdot\int_{\Omega+}L_i(p,w_i)f_r(p,w_i,w_o)\cos\theta_i\text{d}w_i
\end{align} \tag{3}
\]
但当g(x)
积分上下限比较小的时候(或者实际积分范围很小,比如g(x)在球面上对二维高斯函数进行积分),会比较准确,特别是小到δ
时,只有一个方向上有贡献,就没积分什么事了。
还有一种情况也可以用这个约等式,g(x)
是Smooth
(g(x)低频,在积分范围内的最大值和最小值差别不大),对应光照项就是均匀的面光源,以及BRDF
项是Diffuse
的情况。这样当面光源高频,以及BRDF
是Glossy
的情况下就不适合用这个约等式。但是当真这两个条件都不成立,还是可以强行用,比如环境光遮蔽。
回到上面3
式,用方向光或者点光源对场景进行渲染时的渲染方程如下:
\[
\begin{align}
L_o(p,w_o)=V(p,w_i)\cdot L_i(p,w_i)f_r(p,w_i,w_o)\cos\theta_i
\end{align} \tag{4}
\]
这就是Shadow Mapping
做硬阴影的理论基础,其过程如下:
1.
深度贴图生成:从光源的视角渲染场景,记录每个表面片元到光源的距离,生成一个深度贴图。
2.
阴影测试:在Shading过程中,对于每个片元,将其转换到光源的坐标空间,并通过深度贴图进行比较,确定它们是否在阴影中。如果片元的深度值大于深度贴图中的对应深度值,则该片元在阴影中。PCSS算法就发生在这一阶段。
3.
阴影处理:根据片元是否在阴影中,可以对其进行阴影颜色处理(通常为暗化处理)或者其他适当的处理,以模拟真实的阴影效果。
为了PCSS能正常工作,我们需要线性深度信息,如果直接存储点光源空间的深度信息,我们每次采样深度信息还要用深度线性化函数对它处理,所以我们就不直接存屏幕空间的深度信息,而是length(世界空间下片段坐标 - 世界空间下光源坐标) / 平截头体的远平面
,以相同方式在Shading
过程中计算片元在光空间下的深度信息,就可以避免深度线性化的运算。代码实现如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20uniform vec3 uLightPos;
uniform float uLightFarPlane;
varying highp vec3 worldPos;
//深度值float -> 颜色值RGBA ,只能存储0-1之间的float值
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); //32位的深度值分成4个8位存储
rgbaDepth -= rgbaDepth.gbaa * bitMask; // 每个8位由于精度问题,无法表示后面的8位,所以减去后面的8位,否则会整体值会偏大。
return rgbaDepth;
}
void main(){
float lightDistance = length(worldPos - uLightPos);
float fragDepth = lightDistance / uLightFarPlane;
gl_FragColor = pack(fragDepth);
//正交投影屏幕空间深度信息是线性的,直接赋值。
// gl_FragColor = pack(gl_FragCoord.z);
}
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
34
35
36
37float useShadowMap(sampler2D shadowMap, vec4 shadowCoord, float biasC){
float depth = unpack(texture2D(shadowMap, shadowCoord.xy));
float cur_depth = shadowCoord.z;
//float bias = getShadowBias(biasC);
if(cur_depth > depth){
//不可见
return 0.0;
}
else{
//可见
return 1.0;
}
}
void main(void) {
...
//vPositionFromLight为光源空间下的裁剪空间坐标,除以w结果为NDC坐标
vec3 shadowCoord = vPositionFromLight.xyz / vPositionFromLight.w;
//把[-1,1]的NDC坐标转换为[0,1]的坐标
shadowCoord.xyz = (shadowCoord.xyz + 1.0) / 2.0;
//手动计算深度值
float fragDepth = length(vFragPos-uLightPos) / uLightFarPlane;
float visibility = 0.0;
// Shadow Bias
float biasC = 0.002;
//点光源没有使用cubemap,将就限制一下范围
if(shadowCoord.x < 0.0 || shadowCoord.x > 1.0 || shadowCoord.y < 0.0 || shadowCoord.y > 1.0 || shadowCoord.z < 0.0 || shadowCoord.z > 1.0){
//超出平截头体范围的值直接返回1.0
visibility = 1.0;
}
else{
// 硬阴影
visibility = useShadowMap(uShadowMap, vec4(shadowCoord.xy,fragDepth, 1.0), biasC);
...
}
vec3 phongColor = blinnPhong();
gl_FragColor = vec4(phongColor * visibility, 1.0);
}getShadowBias(biasC)
注释掉了,我们先看下这个的效果,后面再来研究这个bias
的用处以及怎么算得。
我把重点关注的地方都圈出来了,右边两个红圈中是Shadow Acne
,左边圈出来的是阴影正确的结果。这里地板上目前还不知道为什么是完全正确的,但是我在opengl
里面测试得到的结果是,不管物体还是地板都会有Shadow Acne
。
这个Shadow Acne
产生原因如下:
由于阴影贴图受分辨率限制,当多个片段距离光源相对较远时,它们可以从深度贴图中采样相同的值。上面图片中,每个黄色倾斜面表示深度图的单个纹素。多个片段会对同一深度样本进行采样。
当光源从一个角度看向表面时,这就有问题了,因为在这种情况下,深度图也是从这个角度渲染出来的。然后,几个片段访问相同的黄色倾斜面,而一些片段的深度值会小于黄色倾斜面(可见),还有一些片段大于黄色倾斜面(不可见)。这就导致画面中黑色条纹和可见条纹相互交织在一起,也就是Shadow Acne
。
Shadow Bias
的用处就在这里,通过图7
可以发现,我们将Depth Map
加上一小段距离就可以避免Shadow Acne
:
但是偏移值高度依赖于光源和表面之间的角度。如果表面与光源成陡峭的角度,加一个固定的Bias
仍然会看到Shadow Acne
。
一种更可靠的方法是根据表面法线和光的方向来改变偏移值: 1
2
3
4
5float getShadowBias(float biasC){
vec3 normal = normalize(vNormal);
vec3 lightDir = normalize(uLightPos - vFragPos);
return max(biasC * (1.0 - dot(normal, lightDir)), 1e-5);
}biasC
暴露给用户自己调整,我这里设置的0.002
,得到的效果如下:
可以看到Shadow Acne
几乎没有了,但是又有了新问题,漏光!(Peter
panning),原本正确的阴影也没了。这个是无解的问题,必须在两者之间权衡。
Percentage Closer Filtering
如果你放大看阴影,阴影映射对分辨率的依赖很快变得很明显。
因为深度贴图有一个固定的分辨率,多个片段对应于一个纹理像素。结果就是多个片段会从深度贴图的同一个深度值进行采样,这几个片段便得到的是同一个阴影,这就会产生锯齿边。
PCF
可以用来模糊阴影,但不是直接对已经生成好的阴影进行Filter
,也不是对Shadow Map
进行Filter
。而是在生成阴影的过程中,对于我们看到的一个像素x
,获取它在Shadow Map
上的点p
,并获取邻域p
附近的点q
,然后比较它们的深度差值,差值大于零则\(\chi^+\)函数为1
,小于零则为0
,最后对这个符号函数(念做kai)进行卷积得到我们想要的Visibility
值:
代码实现如下:
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
34
35
36
37
38
39
40float PCF(sampler2D shadowMap, vec4 coords, float biasC, float filterRadius) {
float visibility = 0.0;
for(int i = 0; i < NUM_SAMPLES; i++){
vec2 poissonDisk = staticPoissonDisk[i];
// 应用随机旋转
vec2 rotatedPoissonDisk = randomRotation(coords.xy, poissonDisk) * filterRadius * TEXEL_SIZE;
visibility += useShadowMap(shadowMap, coords + vec4(rotatedPoissonDisk, 0., 0.) , biasC);
}
return visibility / float(NUM_SAMPLES);
}
void main(void) {
initStaticPoissonDisk();
//vPositionFromLight为光源空间下的裁剪空间坐标,除以w结果为NDC坐标
vec3 shadowCoord = vPositionFromLight.xyz / vPositionFromLight.w;
//把[-1,1]的NDC坐标转换为[0,1]的坐标
shadowCoord.xyz = (shadowCoord.xyz + 1.0) / 2.0;
//手动计算深度值
float fragDepth = length(vFragPos-uLightPos) / uLightFarPlane;
float visibility = 0.0;
// Shadow Bias
float biasC = 0.002;
// Filter Size
float calibratedLightSize = 10.0;//clamp(uLightWorldSize, 10.0, 100.0)
//点光源没有使用cubemap,将就限制一下范围
if(shadowCoord.x < 0.0 || shadowCoord.x > 1.0 || shadowCoord.y < 0.0 || shadowCoord.y > 1.0 || shadowCoord.z < 0.0 || shadowCoord.z > 1.0){
//超出平截头体范围的值直接返回1.0
visibility = 1.0;
}
else{
...
// PCF
visibility = PCF(uShadowMap, vec4(shadowCoord.xy,fragDepth, 1.0), biasC, calibratedLightSize);
...
}
vec3 phongColor = blinnPhong();
gl_FragColor = vec4(phongColor * visibility, 1.0);
}staticPoissonDisk
是一个泊松圆盘分布,因为性能原因,我们不可能对PCF卷积范围内的每个像素都进行一次Filter
,应用这个分布来进行采样可以得到相对准确的结果,但是会新引入噪声。我这里初始化了64
个采样点,但是我实际只使用了32
个采样点。
1
2
3
4
5
6
7
8
9//已经有人过研究眼睛中视锥细胞的分布,与人眼相似,光感受器在猴子眼睛的中央窝外部的分布称之为泊松圆盘分布
//这是一种抗锯齿的采样方法。
void initStaticPoissonDisk() {
//将staticPoissonDisk进行初始化
staticPoissonDisk[0] = vec2(-0.5119625, -0.4827938);
staticPoissonDisk[1] = vec2(-0.2171264, -0.4768726);
...
staticPoissonDisk[63] = vec2(-0.1020106, 0.6724468);
}
Percentage Closer Soft Shadow
从图11
可以看到,我们利用PCF
可以使阴影变得不那么锐利,如果我们可以动态调整Filter
的范围,那就可以达到近处阴影锐利远处阴影模糊的效果:
我们可以用相似三角形来求出这个Filter
的范围,也就是半影范围:
\[
\begin{align}
\frac{W_{Penumbra}}{d_{Receiver}-d_{Blocker}}=\frac{W_{Light}}{d_{Blocker}}
\end{align} \tag{5}
\]
在此之前还有一件事情,就是这个Blocker
(平均遮挡物的深度)怎么求,既然是求平均深度,那我们也需要一个范围才能求平均,我用个图例解释一下这个范围怎么求得:
\[
\begin{align}
\frac{Receiver-BlockerDepth}{Receiver}=\frac{SearchRadius}{LightSize}
\end{align} \tag{6}
\]
但是这样算Search Radius
会有一个问题,看下面图片:
由于面光源是用点光源来生成Shadow Map
,对于我们看到的片段x,它靠近本影的区域可以通过公式6
算得,但是它靠近外面的区域,上面公式得出的Search Radius
为0
,表现出的样子就是阴影突然断层了没有过度的感觉,如果超过这个范围我直接将Search Radius
设置成了Light Size
。
下面是代码的实现:
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
34
35
36
37
38
39
40
41
42
43
44
45
46float findBlocker(sampler2D shadowMap, vec4 coords, float zReceiver, float calibratedLightSize) {
int blockerNum = 0;
float avgBlockDepth = 0.0;
float blockerDepth = unpack(texture2D(shadowMap, coords.xy));
float searchRadius = 0.0;
//blockerDepth和zReceiver大致相等时,固定search范围。
if(abs(blockerDepth - zReceiver) < 3.0 * TEXEL_SIZE){
searchRadius = calibratedLightSize;
// return searchRadius = 1.0;//debug
}else{
searchRadius = calibratedLightSize * (zReceiver - blockerDepth) / zReceiver;
// return searchRadius = 0.0;//debug
}
for(int i = 0; i < NUM_SAMPLES; i++){
vec2 poissonDisk = staticPoissonDisk[i];
vec2 rotatedPoissonDisk = randomRotation(coords.xy, poissonDisk); // 应用随机旋转
float shadowDepth = unpack(texture2D(shadowMap, coords.xy + rotatedPoissonDisk * searchRadius * TEXEL_SIZE));
if(zReceiver > shadowDepth){
blockerNum++;
avgBlockDepth += shadowDepth;
}
}
if(blockerNum == 0)
return -1.0;
else
//avgBlockerDepth,是有blocker的地方才算它的depth,所有这里不是除以NUM_SAMPLES
return avgBlockDepth / float(blockerNum);
}
float PCSS(sampler2D shadowMap, vec4 coords, float biasC,float calibratedLightSize){
float zReceiver = coords.z;
// STEP 1: avgblocker depth
float avgBlockerDepth = findBlocker(shadowMap, coords, zReceiver,calibratedLightSize);
if(avgBlockerDepth == -1.0)
return 1.0;
// STEP 2: penumbra size
float penumbra = (zReceiver - avgBlockerDepth) * calibratedLightSize / avgBlockerDepth;
float filterRadius = penumbra;
// STEP 3: filtering
return PCF(shadowMap, coords, biasC, filterRadius);
}
// Light Size
float calibratedLightSize = 40.0;
visibility = PCSS(uShadowMap, vec4(shadowCoord.xy,fragDepth, 1.0), biasC,calibratedLightSize);
...
//float debug1 = findBlocker(uShadowMap, vec4(shadowCoord.xy,fragDepth, 1.0), fragDepth, calibratedLightSize);
//gl_FragColor = vec4(vec3(debug1),1.0);blockerDepth
和zReceiver
判断相等时的阈值需要自己根据场景调整,或者跟Shadow Bias
一样动态调整,我这里就简单调试了一下:
1.0 * TEXEL_SIZE:
3.0 * TEXEL_SIZE:
其实为了方便也可以直接把Search Radius
固定死。
下面是Light Size
为40
的PCSS
的效果图:
经过上面的分析,其实可以发现PCSS
的缺点了,它处理不了大面积的光源,而且光源不能太靠近物体,因为它边缘的阴影过渡带是有限的。
多光源渲染
目前比较先进的多光源渲染技术我还未探索,这里就只讲一下作业中多光源怎么实现。
比较直接的方式就是每个光源都对场景进行一次渲染,然后将它们的结果混合起来。
先在engine.js
中添加四个光源:
1
2
3
4
5
6
7
8
9
10
11
12// Add lights
let focalPoint = [0, 0, 0];
let lightUp = [0, 1, 0]
//第一个光源的亮度
let lightPos1 = [0, 0, 40];
const pointLight = new PointLight(2, [1, 1, 1], lightPos1, focalPoint, lightUp, true, renderer.gl);
renderer.addLight(pointLight);
...
//添加第四个光源
let lightPos4 = [-40, 0, 0];
const pointLight4 = new PointLight(2, [1, 1, 1], lightPos4, focalPoint, lightUp, true, renderer.gl);
renderer.addLight(pointLight4);4
个光源上,以index
进行绑定:
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
26class Material {
...
// Uniforms is a map, attribs is a Array
constructor(uniforms, attribs, vsSrc, fsSrc, frameBuffer, lightIndex) {
...
this.lightIndex = lightIndex;
}
}
for(let i = 0; i < renderer.lights.length; i++){
let light = renderer.lights[i].entity;
switch (objMaterial) {
case 'PhongMaterial':
//添加光源索引参数 i
material = buildPhongMaterial(colorMap, mat.specular.toArray(), light, Translation, Rotation, Scale, i, "./src/shaders/phongShader/phongVertex.glsl", "./src/shaders/phongShader/phongFragment.glsl");
shadowMaterial = buildShadowMaterial(light, Translation, Rotation, Scale, i, "./src/shaders/shadowShader/shadowVertex.glsl", "./src/shaders/shadowShader/shadowFragment.glsl");
break;
}
material.then((data) => {
let meshRender = new MeshRender(renderer.gl, mesh, data);
renderer.addMeshRender(meshRender);
});
shadowMaterial.then((data) => {
let shadowMeshRender = new MeshRender(renderer.gl, mesh, data);
renderer.addShadowMeshRender(shadowMeshRender);
});
}index
与子模型绑定的光源index
是否相等,相等则渲染否则跳过,在此之前还需要开启混合并以ONE
,ONE
模式混合。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17for (let l = 0; l < this.lights.length; l++) {
//非第一个光源Pass时进行一些设置
if(l != 0)
{
// 开启混合,把后续渲染的结果混合到第一次渲染的结果上,否则会覆盖第一次的渲染结果
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
}
// Camera pass
for (let i = 0; i < this.meshes.length; i++) {
if(this.meshes[i].material.lightIndex != l)
continue;// 是当前光源的材质才绘制,否则跳过,详细参见LoadOBJ.js文件
...
}
// 还原第一次渲染的设置
gl.disable(gl.BLEND);
}Light Size
为100
的效果如下: