Games202课程的作业一,内容是基于ShadowMap实现硬阴影和PCF、PCSS的软阴影。
一、作业框架
作业框架与作业0基本相同,可以参考GAMES202 作业1框架代码结构分析 - 知乎 (zhihu.com)这篇文章,个人觉得非常详细,当然,框架其实并不复杂,花点时间就能搞懂。下面主要说一下需要注意的几点:
- 模型加载不出来,与作业0的问题相同,解决方法可参考我的上一篇博客Games202作业0及优化部分-CSDN博客
- WebGL中的矩阵所采用的是列优先存储,这是其与大多数图形API不同的地方,在做矩阵操作需要注意,否则会得到意想不到的结果
- ShadowMap的存储采用的是RGBA四个通道,虽然每个通道都是一个float,但只有一个字节有效,也就是每个通道相当于只有一个字节,每个通道分别存储了深度值(float)的一个字节的精度,所以精度仍然不变,为32bit。将深度值拆解为颜色向量的pack函数位于shadowFragment.glsl中,pack函数采用了一系列位运算操作提取深度值的第 1 2 3 4 个字节并以vec4的形式返回,实现如下:
// pack函数用于将深度值的浮点数按照字节分别存储在RGBA中 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); rgbaDepth -= rgbaDepth.gbaa * bitMask; // 分别提取第1234个字节的内容, 并存储在RGBA的第一个字节中 return rgbaDepth; }
相对应的unpack函数位于phongFragment.glsl中,实现如下:
float 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); }
其原理也就不难理解了。
二、ShadowMapping实现
ShadowMap采用经典的two-pass方法实现,即先从光源出发对场景做一次模拟渲染,将得到的深度缓冲保存在阴影贴图中,然后再从相机出发进行渲染,在渲染时比较片元在light space下的深度与阴影贴图中的深度,从而判断该片元是否应该渲染为阴影。对于shadow acne现象,通过添加一个偏置进行缓解。
1.实现光源的MVP矩阵
在DirectionalLight.js中完善CalcLightMVP函数:
CalcLightMVP(translate, scale) {
let lightMVP = mat4.create();
let modelMatrix = mat4.create();
let viewMatrix = mat4.create();
let projectionMatrix = mat4.create();
// Model transform
// webgl中的矩阵是列优先存储,所以矩阵相乘的顺序应该反过来
// T * R * S * v = S.t * R.t * T.t * v
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, -150, 150, -150, 150, 0.01, 500);
mat4.multiply(lightMVP, projectionMatrix, viewMatrix);
mat4.multiply(lightMVP, lightMVP, modelMatrix);
return lightMVP;
}
对于投影矩阵使用正交投影,投影边界的参数设置需要通过多次调参得到一个合适的范围。对于MVP变换,一般的顺序是缩放→旋转→平移,但由于WebGL是列优先存储,所以要反过来,原理在注释中很详细了。
2.使用阴影贴图
首先是执行透视除法,并将裁剪空间(-1~1)变换到NDC空间(0~1):
vec3 shadowCoord = vPositionFromLight.xyz / vPositionFromLight.w;
shadowCoord = (shadowCoord + 1.0) / 2.0;
对阴影贴图进行采样,比较深度决定是否可见:
float useShadowMap(sampler2D shadowMap, vec4 shadowCoord){
float shadowz = unpack(texture2D(shadowMap, shadowCoord.xy));
if(shadowz + EPS > shadowCoord.z)
return 1.0;
else
return 0.0;
}
可以达到的效果如下,在Edge中测试可以达到90FPS:
可以看到硬阴影效果,但由于ShadowMap分辨率有限,所以出现了很多的锯齿。
这里有些博主会采用自适应的bias来进行优化,但我发现实际效果并不好,比较常见的是这样:
(图片来自GAMES202 作业1解答_games202作业1_川明_Fragtex的博客-CSDN博客)可以发现很多应该出现的阴影消失了,包括模型的辫子、手臂、脚以及模型背后应该出现的阴影全都消失不见,原因就是得到的bias太大(注意模型身上的一些浅阴影其实是贴图中本来就有的)。
而对于直接渲染出来结果,其实可以观察到已经没有shadow acne出现,至于一些“不太自然”的地方,其实是因为阴影贴图的分辨率不够再加上硬阴影所带来的锯齿现象。
三、PCF软阴影
PCF即Percentage Closer Filter,原本是指一种滤波,后来被应用于软阴影技术中。
PCF的主要原理是在对阴影贴图进行采样时,不只采样一个点,而是对UV坐标的周围进行多次采样,将这些采样的平均结果作为一种阴影权重用于混合计算出来的颜色和阴影(也就是黑色),该方法放弃了非0即1的硬阴影做法,可以以较小的代价得到软阴影的效果,但其没有考虑软阴影的程度变化,其使用的PCF核大小是arbitrary的,故而还不能准确模拟现实世界中的阴影效果。
PCF的做法可以分为如下两步:
1.在UV周围得到多个采样点
框架为我们提供了两个二维的随机采样函数:均匀圆盘采样和泊松圆盘采样,二者的区别是均匀圆盘采样在采样方向、采样距离都做了随机操作,而泊松圆盘采样只在一开始选择采样方向时做了一次随机。所以导致的结果就是,均匀圆盘采样可能会出现一些聚集现象,样本之间的分布并不“均匀”,而泊松圆盘采样会呈现十分均匀有规律的采样结果,类似于一个蚊香,逐渐向外伸展开,看上去极度舒适。通过二者的可视化可以很清楚地看到这一点:
![](https://img-blog.csdnimg.cn/cd30f02d81d247809e87b055b3f6a07a.png)
![](https://img-blog.csdnimg.cn/52cd318fead84d619743f0ee6f0928d5.png)
2.使用得到的采样点对ShadowMap进行采样,将采样的结果分别与片元的深度值进行比较和平均
float PCF(sampler2D shadowMap, vec4 coords, float filterSize) {
poissonDiskSamples(coords.xy);
float res = 0.0;
float filterRange = filterSize / 2048.0;
for (int i = 0; i < NUM_SAMPLES; i++) {
vec2 sampleuv = coords.xy + poissonDisk[i] * filterRange;
float shadowz = unpack(texture2D(shadowMap, sampleuv));
if(shadowz + 5.0 * EPS > coords.z)
res += 1.0;
}
return res / float(NUM_SAMPLES);
}
最终可以得到的结果如下:
采样点为150,滤波大小为7时,测得FPS为30
四、PCSS
PCSS(Percentage Closer Soft Shadow)是对PCF的优化,加入了对滤波核大小的考虑。
在现实世界中,很容易观察到当遮挡物距离阴影越远时,阴影越“软”,原理如下图(来自Games202):
根据相似三角形原理,很容易得到如下等式:
也就阴影从1到0的过渡范围,换言之,在使用PCF时,当
越大,使用的核应该越大。
但在实际中,要想得到公式中的、
等并不容易,可能涉及到射线与物体的求交等十分消耗性能的操作。所以,仍然采用近似的方法,在实现上,对
采用常数,同时,在UV周围进行采样,对采样点从shadowmap中获取深度,从而得到
。
首先是查找遮挡物,并求得平均深度,实现如下:
float findBlocker( sampler2D shadowMap, vec2 uv, float zReceiver ) {
poissonDiskSamples(uv);
float res = 0.0;
float searchSize = 20.0;
float filterRange = searchSize / 2048.0;
float blockerDepthSum = 0.0;
float blockerCount = 0.0;
for (int i = 0; i < BLOCKER_SEARCH_NUM_SAMPLES; i++) {
vec2 sampleuv = uv + poissonDisk[i] * filterRange;
float shadowz = unpack(texture2D(shadowMap, sampleuv));
if(shadowz + EPS <= zReceiver) {
blockerDepthSum += shadowz;
blockerCount++;
}
}
if(blockerCount == 0.0) { // 当没有遮挡时,不需要PCF,核大小为0
return zReceiver;
}
return blockerDepthSum / blockerCount;
}
其次是PCSS,计算核大小,然后进行PCF:
#define LIGHTWIDTH 10.0
float PCSS(sampler2D shadowMap, vec4 coords) {
// STEP 1: avgblocker depth
float avgblockerDepth = clamp(findBlocker(shadowMap, coords.xy, coords.z), 1e-7, coords.z);
// STEP 2: penumbra size
float penumbraWidth = LIGHTWIDTH * (coords.z - avgblockerDepth) / avgblockerDepth;
// STEP 3: filtering
float filterSize = 2.0 * penumbraWidth;
return PCF(shadowMap, coords, filterSize);
}
最终可以达到的效果如下:
使用采样点为150,在edge中测得FPS=5.3(disaster!)
五、遗留问题
- 对于PCSS,FPS只有5.3,如何能做到实时(至少30FPS)?,或者在实现上面还有可以优化的方法?
- 即便是5.3FPS的PCSS其实也只是做了近似,如
、
,如果要更加精确地进行模拟,是否要涉及到射线和物体求交?性能开销难以想象