目录
- Shadow Map
- CalcLightMVP函数
- useShadowMap函数
- Bias函数
- 最终效果
- PCF
- 两个采样函数
- PCF函数
- 最终效果
- PCSS
- findBlocker函数
- PCSS函数
- 最终效果
- 参考
先放上公式:
后面的积分项是我们在作业0中就做好的blinnphong项,我们要求的就是积分项前,等号后的可见项。
最终体现在代码中便是
- gl_FragColor = vec4(visibility * phongColor, 1.0);
这部分代码在homework1\src\shaders\phongShader\phongFragment.glsl中
Shadow Map
第一部分是使用Shadow Map的方法来渲染阴影,也就是渲染硬阴影,经典的Two Pass Shadow Map方法。
简单来说,shadow map主要分为两步操作:
- 第一部分,我们需要将摄像机移动到光源的位置,从光源视角出发看向场景。视线能看到的各最小深度就是光源能直接照射到的物体深度,获得这个视角下的场景深度图。(记录场景中距离光源最近的每一点,形成一张图)
- 第二部分,绘制时需要将相机挪到眼睛的位置再观测,得到视线与物体交点,再将该点的深度,如下图中深黄色所示,与第一趟绘制得到的该点的深度比较,如果此次得到的深度更深(或者说值更大),则判断为阴影,如果相比更浅(或者说值更小或相等),则判断为非阴影可直接着色。
先看src\renderers\WebGLRenderer.js,两次Pass计算阴影的主要代码:
CalcLightMVP函数
首先是src/lights/DirectionalLight.js的矩阵变换部分,该矩阵参与了第一步从光源处渲染场景从而构造 ShadowMap 的过程。
CalcLightMVP(translate, scale) {
let lightMVP = mat4.create();
let modelMatrix = mat4.create();
let viewMatrix = mat4.create();
let projectionMatrix = mat4.create();
// Model transform 模型矩阵,对相机先平移,再缩放
mat4.translate(modelMatrix,modelMatrix,translate);
mat4.scale(modelMatrix,modelMatrix,scale);
// View transform 视图矩阵
mat4.lookAt(viewMatrix,this.lightPos,this.focalPoint,this.lightUp);
// Projection transform 投影矩阵
mat4.ortho(projectionMatrix,-100,100,-100,100,1e-2,400);
mat4.multiply(lightMVP, projectionMatrix, viewMatrix);
mat4.multiply(lightMVP, lightMVP, modelMatrix);
return lightMVP;
}
- 因为我们需要获取从光源处看世界的坐标。所以在直射光中需要补充一个获得转换到光源处空间的矩阵。我们可以调用API来快速生成translate和scale矩阵。
- 再调用lookAt函数得到viewMatrix矩阵。关于lookAt()函数可以参考:learnopengl教程
- 最后使用projectionMatrix正交投影矩阵得到Shadow Map。关于正交矩阵和透视矩阵的选取:投影矩阵,由于透视投影会产生深度精度问题,因此作业中选择正交投影。
完成该矩阵的输出后,我们就可以在片元着色器中获取到Shadow Map:
useShadowMap函数
src\shaders\phongShader\phongFragment.glsl
- shadowCoord —— 纹理图片上像素对应的坐标
- 在着色时我们使用了可见项对blinnPhong得到的颜色进行一个可见性衰减。
- 我们在useShadowMap函数中可以看到我们需要使用到当前着色片元在光源坐标系下的坐标shadowCoord,这个坐标是在片元着色器前插值生成的。为了在贴图采样中使用该坐标,我们需要将向量的各个分量从( -1 , 1 ),强制转化到( 0 , 1 )(uv坐标的范围都是0-1)。
在代码里发现是调用了useShadowMap方法,
float useShadowMap(sampler2D shadowMap, vec4 shadowCoord){
float mapDepth = unpack(texture2D(shadowMap,shadowCoord.xy));//shadow map中各点的最小深度,unpack将RGBA值转换成[0,1]的float
float shadingDepth = shadowCoord.z; //当前着色点的深度
float visibility1 = ((mapDepth + EPS) < shadingDepth) ? 0.0 : 1.0;
return visibility1;
}
- 先获取第一步已经获取到的shadow map中的深度。unpack()用来将RGBA值转换成在范围[0,1]的float值。
- 再获取第二次从相机出发观察到的点离光源的深度。
- 然后进行比较,EPS考虑精度问题。
- 返回visibility。
效果:
Bias函数
放大之后会发现会有很多锯齿,因为发生了自遮挡现象:
-
其一是处理器的数值精度的限制
-
还有一个原因是因为shadow map本身保存的值是离散值,也就是说shadow map上每个采样点都代表着一块范围内图元的深度值,因此在第二个pass比较深度的时候,shadow map中的深度可能会略低于物体表面的深度,部分片元就会被误计算为阴影,导致自遮挡。该现象在光源与平面趋于平行时(掠射)尤为严重。
-
为了解决这个问题,课上也说了,使用bias(偏移值)方法。
-
在shadow map中引入一个偏移值(bias),使得每次在比较深度大小的时候,都将一定区间内的shadow map深度认作与屏幕空间深度相等,强行减弱阴影判定。
-
但这样做又会引入一个新的问题——detached shadow,或者说,peter panning(阴影悬浮)——即丢失部分原本可能发生遮挡的阴影
那来看一下怎么做这个bias,还是在src\shaders\phongShader\phongFragment.glsl中直接添加一个Bias方法:
放两个,其实没多大区别,差别在于这个阴影的出现程度,第二个的阴影区域会比第一个小:
float Bias(float CDepth){
vec3 lightDir1 = normalize(uLightPos);
vec3 normal1 = normalize(vNormal);
float m = 200.0 / 2048.0 / 2.0; // 正交矩阵宽高/shadowmap分辨率/2
float bias1 = max(m * (1.0-dot(normal1,lightDir1)),m) * CDepth;
return bias1;
}
float Bias1(){
vec3 lightDir1 = normalize(uLightPos);
vec3 normal1 = normalize(vNormal);
float bias1 = max(0.08 * (1.0-dot(normal1,lightDir1)),0.08);
return bias1;
}
然后修改useShadowMap方法:
float useShadowMap(sampler2D shadowMap, vec4 shadowCoord){
float mapDepth = unpack(texture2D(shadowMap,shadowCoord.xy));//shadow map中各点的最小深度,unpack将RGBA值转换成[0,1]的float
float shadingDepth = shadowCoord.z; //当前着色点的深度
//float visibility1 = ((mapDepth + EPS) < shadingDepth) ? 0.0 : 1.0;
float bias = Bias(1.4);
float visibility1 = ((mapDepth + EPS) <= (shadingDepth - bias)) ? 0.2 : 0.9;
return visibility1;
}
最终效果
采用Bias方法后的效果:
放大之后,虽然不会产生严重的锯齿现象,但还是有锯齿:
但很明显,腿部的阴影没有了,因为发生了我们上面说的阴影悬浮现象,这就可以通过修改bias方法来改善,但不可避免。
完成了硬阴影的two pass shadow map方法后,在实际生活中我们更希望我们得到的是软阴影,接下来就是写软阴影部分的代码,软阴影又分为PCF和PCSS两种。
PCF
我们通过SM生成了一个硬阴影,但是在实际生活中我们希望我们得到的是软阴影,而在我们刚才的硬阴影的计算中我们得到的visibility项非0即1.如果我们着色点周围的一圈像素进行一个加权平均,我们就可以得到一个相对来说较软的阴影——visibility项不再是非0即1。
请注意,这个过程发生在采样过程中:
-
- 滤波对象既不是Shadow Map自身(对shadow map滤完波再做深度测试,结果仍是二值化数据,相当于什么都没做);
-
- 也不是深度测试结束后得到的阴影图(非但不会消除锯齿,还会让阴影变糊,在101中有提到过);
-
- 而是找我们选定的点对应于shadow map中周围一圈邻域的点,将领域中的每个点与我们选定的点进行比较,进行一个二值化处理。比较完成之后,对这一圈邻域内的二值化数据进行求和平均(也就是filter,其实叫filter不够准确,就是做一个求和平均罢了)。
两个采样函数
作业中建议用泊松圆盘采样和均匀圆盘采样,代码里都给出来了:
-
泊松圆盘
-
均匀圆盘
实话说没看懂哈,会用就行了,要是有小伙伴感兴趣的可以看下面的链接:
- 三维点云泊松圆盘采样(Poisson-Disk Sampling)
- 泊松盘采样(Poisson Disk Sampling)生成均匀随机点
- 需要注意的是,两个采样方法都把数据存储到了下面这个数组中:
所以不管我们用哪个方法,都可以直接使用这个数组来取数据。
PCF函数
课上老师也提到过,PCF其实是基于shadow map做AA(Anti-Aliasing,即反走样)。PCF就是在做卷积,把卷积核也叫做过滤器,也就是filter。
卷积原理看这个:卷积 (Convolution) 填充 (Padding) 步长 (Stride)
在进行shading point的深度与shadowmap比较时,不只比较一个方向的值,而是与周围像素做卷积,在周围采样多个点的深度值,逐一比较之后求平均值,就能得到一个[0,1]的连续分布,可以表示不同明暗程度的阴影,不再是硬阴影那样非0即1对比强烈的感觉,阴影就变得柔和起来,也就实现了人工软阴影化。
- filter size 卷积核大小
- 作业1中filter的大小由采样数量决定,也就是一开始给定的NUM_SAMPLES,初始值设置成了20。
- filter的大小、个数一般都是先设定一个初始值,再根据实验效果进行调整。
- 在进行卷积时,在输出要求相同的情况下,filter越大参与计算的参数越多,那么对于作业1来说达到的阴影柔和的效果越明显。
float PCF(sampler2D shadowMap, vec4 coords) {
float stride = 2.0; //定义步长
float shadowMapSize = 2048.0; //shadowmap分辨率
float visibility1 = 0.0; //初始可见项
float cur_depth = coords.z; //卷积范围内当前点的深度
float filterRange = stride / shadowMapSize; //滤波窗口的范围
//泊松圆盘采样得到采样点
poissonDiskSamples(coords.xy);
//均匀圆盘采样得到采样点
//uniformDiskSamples(coords.xy);
//对每个点进行比较深度值并累加
for(int i = 0; i < NUM_SAMPLES; i++){
float shadow_depth = unpack(texture2D(shadowMap,coords.xy + poissonDisk[i] * filterRange));
float res = (cur_depth < shadow_depth + EPS) ? 1.0 : 0.0;
visibility1 += res;
}
//返回均值
float avgVisibility = visibility1 / float(NUM_SAMPLES);
return avgVisibility;
}
- 采样偏移值 -> 与步长Sride关系
- 在卷积过程中,将每次卷积核滑动的行数/列数称为Stride(步长)。有时需要在卷积时通过设置的Stride来压缩一部分信息,成倍缩小尺寸。
- 对于作业1而言,由于PCF输入的坐标coords归一到了[0,1]的范围,那么给定采样点的偏移值poissonDiskSamples[i]也需要缩小一定范围以迎合coords坐标的尺寸,因此需要给定Stride以缩小尺寸。缩小比例当然是stride/shaodowMapSize,框架中shadowMapSize=2048,Stride可以给定一个初始值1,根据效果进行调整。
额,最后别忘了在main函数里改一下:
最终效果
用泊松圆盘采样的结果:
NUM_SAMPLES=80,stride=10:
要是更改一下参数:NUM_SAMPLES=80,stride=2:
用均匀圆盘采样的结果:
NUM_SAMPLES=80,stride=2:
NUM_SAMPLES=80,stride=2:
- 可以发现NUM_SAMPLES越小或者stride越大 阴影边缘噪点越多,有兴趣可以自己试试调整这两个参数值。
PCSS
然后就是最后的PCSS方法了。为了达到前实后虚的软阴影效果,就可以采用PCSS(Percentage Closer Soft Shadow),通过计算投影平面与遮挡物之间的距离,来确定滤波范围的大小(自适应的filter size)。
算法的整体思路是:
- 首先将shading point点x投应到shadow map上,找到其对应的像素点p。
- 在点p附近取一个范围(这个范围是自己定义或动态计算的),将范围内各像素的最小深度与x的实际深度比较,从而判断哪些像素是遮挡物,把所有遮挡物的深度记下来取个平均值作为blocker distance。(Blocker search)
- 第二步:用取得的遮挡物深度距离来算在PCF中filtering的范围。
- 第三步:进行pcf操作。
这里有个问题,filter size可以按上述方法确定了,那么计算filter size时需要用到的d(blocker)同样需要在一定范围内做平均,这个范围又怎么确定呢?我们可以认为规定一个固定的大小,如4 * 4,16 * 16等,但这么做绝对不是最优解,更好的方法是在光源处设置一个视锥,将shadow map置于近平面上,接着连接着色点和光源,以其在shadow map上所截得的范围作为样本,来计算平均深度。
这么做有一个非常大的好处,就是计算d(blocker)也采用了自适应的方法,离光源越远,遮挡物越多,计算blocker所用的样本范围就越小;而离光源越近,遮挡物越少,计算blocker所用的样本空间就越大,非常合理。
- W(Light)是光源的大小。
- W(Penumbra)是filter的大小,某种程度算是阴影软硬程度的表现,该值越大,阴影就越软。
- d(Receiver)是阴影接受物的深度。
- d(Blocker)是遮挡物,也就是阴影投射物的深度。
- 遮挡物Blocker越接近接受物Receiver,W(Penumbra)越小,阴影越硬;
- 遮挡物Blocker越接近光源Light,W(Penumbra)越大,阴影越软;
由相似三角形就能得到:
所以PCSS的具体步骤为:
- 首先依据着色点选择一块范围,对Shadow Map做一次局部深度测试,找到范围内的blocker并计算其平均深度(计算平均深度的目的是减小遮挡物自身的几何影响,避免漏光)。
- 得到d(blocker)后代入公式计算滤波范围, d(blocker)越小, [d(receiver)-d(blocker)] 越大,卷积核越大,得到的阴影就越软。
- 重新进行深度测试,继续完成PCF的过程。
那作业需要我们完成findBlocker(sampler2D shadowMap,vec2 uv, float zReceiver) 和 PCSS(sampler2D shadowMap, vec4 shadowCoord)函数。
findBlocker函数
看一下findBlocker函数,需要完成对遮挡物平均深度的计算,也就是上面的d(Blocker)。
- 首先给定基数BlockerNum和总的Block_depth。
- 从当前的shading point连向方向光源light,方向上击中位于shadow map上的一点P,取点P周围的一个区域(利用到了泊松圆盘采样),判断区域里的点是否在阴影里,如果在则BlockerNum加一、Block_depth加上cur_depth;如果不在,则不纳入计算。
以发现这个判断过程跟前面的shadowmap和PCF都是一样的,但是目的不同,这里是在求blocker的深度!
float findBlocker( sampler2D shadowMap, vec2 uv, float zReceiver ) {
int blockerNum = 0; //着色点对应到shadow map中后,周围一圈邻域内为blocker的点的数量
float blocker_depth = 0.0; //blocker的深度
float shadowMapSize = 2048.0; //shadow map的分辨率
float stride = 50.0; //采样的步长
float filterRange = stride / shadowMapSize; //滤波窗口的范围
//泊松圆盘采样得到采样点
poissonDiskSamples(uv);
//均匀圆盘采样得到采样点
//uniformDiskSamples(uv);
//判断着色点对应到shadow map中后,邻域中的点是否为blocker,如果是就累加
for(int i = 0; i < NUM_SAMPLES; i++){
float shadow_depth = unpack(texture2D(shadowMap,uv+ poissonDisk[i] * filterRange));
if(zReceiver > shadow_depth + 0.01){
blockerNum++;
blocker_depth += shadow_depth;
}
}
if(blockerNum == 0){
return 1.0;
}
blocker_depth = blocker_depth / float(blockerNum);
return blocker_depth;
}
PCSS函数
float PCSS(sampler2D shadowMap, vec4 coords){
// STEP 1: avgblocker depth
float avgBlocker_depth = findBlocker(shadowMap,coords.xy,coords.z); //在这步里我们已经做好了采样,后面就能直接调用数据
float wLight = 1.0; //光源大小
float dReceiver = coords.z;
// STEP 2: penumbra size
float wPenumbra = wLight * (dReceiver - avgBlocker_depth) / avgBlocker_depth;
// STEP 3: filtering 就是做PCF,不过加入了wPenumra的影响
//首先定义变量
float stride = 10.0;
float shadowMapSize = 2048.0;
float visibility1 = 0.0;
float cur_depth = coords.z;
float filterRange = stride / shadowMapSize;
//做采样,前面已经做好了
//poissonDiskSamples(coords.xy);
//然后循环比较
for(int i = 0; i < NUM_SAMPLES; i++){
float shadow_depth = unpack(texture2D(shadowMap,coords.xy + poissonDisk[i] * filterRange * wPenumbra));
float res = cur_depth < shadow_depth+0.01 ? 1.0 : 0.0;
visibility1 += res;
}
//求平均
visibility1 /= float(NUM_SAMPLES);
return visibility1;
}
最终效果
最后main函数改一下记得,结果图:
下面这个是NUM_SAMPLES=80
这个是让cur_depth < shadow_depth+EPS,NUM_SAMPLES=20的结果:
也可以加bias,但是得调…
参考
- GAMES202作业1-实现过程详细步骤
- GAMES202 作业1解答
- GAMES202作业1-万字分析代码框架
- GAMES202高质量实时渲染-个人笔记:实时阴影
- GAMES202 Real-Time High Quality Rendrting 高质量实时渲染课程笔记Lecture 4: Shadow 02
- GAMES202-高质量实时渲染