games202:二,实时阴影Shadow Mapping、软阴影PCF、PCSS、VSSM、MSM、SDF + 作业1
一,Shadow Mapping
Shadow Mapping是很常用的阴影生成方法,塞尔达、玩具总动员都用它。games101里已经讲过原理,分为2pass:
- 先从光源视角得到各方向最浅深度shadow map。
- 再从相机视角根据shadow map渲染
1.1Shadow Mapping的问题
Shadow Mapping速度快但有锯齿化走样和自遮挡现象。
1.1.1自遮挡
下图是由数值精度造成的地板纹路,原因是光源视角的shadow map由于像素限制所记录的深度不连续,如右图一段段红色,在光线视角连续,在蓝色相机视角就不连续,形成自遮挡。在此前提下可以想象,光线视角与地面越垂直,纹路越少,与地面越平行,纹路越多。
1.1.2自遮挡解决办法
在shadow map两像素之间加一个bias(误差区间),如下图黄色段,在此区间内的最小深度不计算;并且bias根据光源角度是变化的。
- 但此方法也会引入其他问题:阴影和物体断裂
工业界有许多解决办法,但都是找到一个更合适的bias,目前没有真正解决问题的方法。学术界有一种方法Second-depth shadow mapping:在生成 Shadow Map 时,同时保存最小深度和次小深度,比较的时候取两者平均值。但此方法工业界没什么人用,因为需要封闭模型而不能只是一个平面,并且计算量大一些。
1.1.3锯齿化走样
shadow map分辨率不够会出现锯齿,可以以效率为代价使用超采样或提高分辨率来改善,或者动态分辨率等方法。
「老师忠告:实时渲染不相信复杂度!常数一样很重要!----再好的方法超过1ms就基本不考虑了」
二,Shadow Mapping Math
微积分中的两个常用不等式:
实时渲染关注近似相等—把不等式当做约等式用:
-
认为不等式近似相等的条件(满足其一即可):
- g(x)的积分域(support)很小
- g(x)的值足够光滑(变化不大)
-
有了上述近似约等式后,可以把渲染方程简化,把 Visibility 项提取出来,剩下的g(x)就是shading的结果:
「Shadow Map的原理就是这样,先计算shading,再根据 Visibility 乘上系数」 -
该不等式成立条件在渲染中的意义:
- g(x)的积分域很小----只有一个Visibility,即只有一个点光源或者一个方向光源
- g(x)的值足够光滑----radiance各处不变的面光源并且shading point是漫反射时(diffuse brdf)「如果brdf是glossy就是不光滑」
三,软阴影
Shadow Map 方法做出来的硬阴影在现实中不常见,因为现实中多是面光源对应的软阴影。下图是软硬阴影区别:
3.1 PCF(Percentage Closer Filtering)
「该技术起初是用于抗锯齿\反走样,后来发现软阴影也可以用。」
「为什么不对shadow map做滤波?因为把shadow map模糊后,在深度判定完还是硬阴影(相当于二值化)。」
- 原理是在阴影判定时做一个filtering----不仅计算当前着色点对应shadow map上的深度,还计算该点在shadow map上周围一圈(比如7x7)的深度判定结果(非0即1)并取平均,将平均值作为Visibility项。「如果滤波核比较大,可以在范围内随机采样固定个数」
3.2 PCSS(Percentage closer soft shadows)
- 软阴影与物体远近关系:
- 投影平面上的阴影到物体的距离越远,阴影越软、滤波核越大。
- 投影平面离物体越近,阴影越硬、滤波核越小。「即下图W越大,阴影越软」
这里决定软阴影大小的w通过下式计算(相似三角形),可以看到阴影越远、光源越大、物体离光源越近时阴影越软越模糊。由于实际物体是有形状的,深度不一样,我们这里的
d
b
l
o
c
k
e
r
d_{blocker}
dblocker需要是一个average blocker depth(如果直接用shadow map的值会在陡峭处出现错误)。
在计算平均遮挡深度(average blocker depth)时有两种方式,一种固定范围计算,比如都用5X5计算;一种动态范围计算。在计算shadow map的时候把光源当做过相机,此时我们就把shadow map认为是近平面,然后将光源和要渲染的点相连,如下图,在shadow map覆盖的区域就是要计算平均遮挡距离的区域。
-
根据PCF的原理,可以得到PCSS算法步骤:
- Blocker search,计算物体平均遮挡深度(在固定或动态范围内去遮挡区域计算)。
- Penumbra estimation,用平均遮挡深度确认该点滤波核大小。
- 应用PCF算法。
-
步骤中有两次范围计算消耗性能,工业上一般用随机采样+图像降噪代替。
四,VSSM(Variance Soft Shadow Mapping)
PCSS中的1、3步计算范围内像素数值效率太低,于是有了VSSM。
4.1 加速PCSS-step3
-
前提:pcss步骤三其实就是计算shadow map的一个区域内能被相机看到的像素的百分比(涉及到比例,我们就可以拟合它),也可以比作 已知自己的成绩求自己排第几 的问题。
-
中心思想:为了拟合上述比例,我们假设着色点附近的深度是符合概率分布的(正态分布),因此快速计算出均值( μ μ μ)和方差( σ 2 σ^2 σ2)后,就可以求出该着色点的深度在附近排前百分之多少。
-
步骤
- 计算均值:基于硬件的MipMap(时间几乎可以忽略但不准----但没关系,实时渲染就喜欢快)或 Summed Area Tables(SAT区域求和表) 方法获得期望,O(1)。
- 计算方差:生成储存深度平方的square-depth map(texture多一个通道即可满足)得到平方值期望,再根据概率论公式 V a r ( X ) = E ( X 2 ) − E 2 ( X ) Var(X) = E(X^2) - E^2(X) Var(X)=E(X2)−E2(X)计算得到方差,O(1) 。
- 计算排名:计算CDF(积分分布函数) of PDF(概率密度函数),如下图,实际vssm使用切比雪夫不等式,O(1)。
-
对于高斯分布的CDF没有解析解,但可以把数值解的值打成一张表(表就叫Error Function,在c++里有对应函数erf),但这样比较麻烦。因此VSSM这里使用了切比雪夫不等式:
P ( x > t ) ≤ σ 2 σ 2 + ( t − μ ) 2 = > P ( x ≤ t ) ≈ 1 − σ 2 σ 2 + ( t − μ ) 2 P(x>t) ≤ \frac{σ^2}{σ^2 + (t-μ)^2} => P(x≤t) ≈ 1- \frac{σ^2}{σ^2 + (t-μ)^2} P(x>t)≤σ2+(t−μ)2σ2=>P(x≤t)≈1−σ2+(t−μ)2σ2
「此处切比雪夫不等式成立条件为 t大于均值(不管分布啥样),但实际实时渲染中问题不大」
4.1.1 MIPMAP
mipmap做的是快速近似方形查询,其中用到了差值,有误差,个别有长方形用各向异性过滤,具体内容101讲过了,不懂回去看下。
4.1.2 SAT-Summed Area Tables
mipmap局限于正方形或特殊情况长方形,想随意取矩形计算就不划算。Summed Area Table每个像素的值为原纹理从(0,0)到该像素组成的矩形中所有像素值的和,是100%准确的,如下图:
对任意矩形内的平均值可通过下式四次加减即可求得。
s
=
t
[
x
m
a
x
,
y
m
a
x
]
−
t
[
x
m
a
x
,
y
m
i
n
]
−
t
[
x
m
i
n
,
y
m
a
x
]
+
t
[
x
m
i
n
,
y
m
i
n
]
s = t[x_max,y_max] - t[x_max,y_min] - t[x_min,y_max] + t[x_min,y_min]
s=t[xmax,ymax]−t[xmax,ymin]−t[xmin,ymax]+t[xmin,ymin]
查询很快但构建需要点时间,实时渲染不喜欢。(如何构建应用SAT可看参考链接3。加速构建可以行列分开并行计算)
4.2 加速PCSS-step1
第一步中要获得对应遮挡区域的平均深度,如上图图蓝色区域平均深度,记为
Z
o
c
c
Z_{occ}
Zocc,而我们通过mipmap已知全部区域的平均深度
Z
a
v
g
Z_{avg}
Zavg,红色非遮挡区域的平均深度记为
Z
u
n
o
c
c
Z_{unocc}
Zunocc,数量为N。有下式成立:
Z
a
v
g
=
N
u
n
o
c
c
N
Z
u
n
o
c
c
+
N
o
c
c
N
Z
o
c
c
Z_{avg} = \frac{N_{unocc}}{N} Z_{unocc} + \frac{N_{occ}}{N} Z_{occ}
Zavg=NNunoccZunocc+NNoccZocc
N
u
n
o
c
c
N
+
N
o
c
c
N
=
1
\frac{N_{unocc}}{N} + \frac{N_{occ}}{N} = 1
NNunocc+NNocc=1
N
o
c
c
N
=
P
(
x
>
t
)
\frac{N_{occ}}{N} = P(x>t)
NNocc=P(x>t)
到这里只有
Z
u
n
o
c
c
Z_{unocc}
Zunocc是未知量,因此VSSM大胆假设
Z
u
n
o
c
c
=
t
Z_{unocc} = t
Zunocc=t,即假设没有遮挡的着色点平均深度就是该着色点的深度,即上图红色值全部都是7。
- 这么假设的前提是大部分阴影接受面是平面,但接受面如果是曲面或光源不平行时就会有问题。
- tip:但现在主流方法还是PCSS,因为随着降噪技术发展,有噪声越来越不是问题了。但VSSM的大胆假设思想值得学习。
五,MSM(Moment矩 shadow mapping)
VSSM为了解决PCSS的问题,但它自己也有问题----假设约等太多了。
- 深度分布复杂时假设为正态分布问题不大,反而场景简单时就可能不成立了,实际可能是多峰分布,会造成light leaking漏光现象如下图。
- 非平面接收面或光线不平行可能导致artifact。
- 切比雪夫不等式 t大于均值的条件有可能不满足。
- 以上问题的根本原因都是 深度估计不准,因此引入“使用更高阶的矩来估计分布”—MSM。(这里的矩指 随机变量的指数 x 2 、 x 3 、 x 4 x^2、x^3、x^4 x2、x3、x4,VSMM相当于使用了2阶矩来估计)「本质相当于某种展开,矩越多越准」
「随着temporal filter的发展,PCSS又流行起来,MSM因为实现麻烦现在用得少了。」
- MSM思路:用前m阶矩来拟合CDF(会有 m 2 \frac{m}{2} 2m个阶梯,如图),对于深度估计取m为4就可以。(在blocker search和PCF步两步中计算CDF)
六,SDF (Signed Distance Field) soft shadows
- 有向距离场SDF记录了空间中任何一点到定义该场的物体之间的最小距离,并用正负号表示在物体内外(0表示在物体上)。
- 不同物体的距离场还可以用插值将不同形状混合。
- SDF相关知识:最优传输Optimal Transport(顾险峰)
6.1 SDF应用
6.1.1 Ray Marching
解决光线和SDF的求交问题,将SDF距离作为安全距离,判断光线是否与物体相交(sphere tarcing)(第一次取的安全距离作为步长,在光线方向取第二次,以此类推,直到安全距离小于一定值或者在该光线上走了很远,然后取最小值)。「运动刚体可以用此方法,但形变物体不行」
6.1.2 生成距离场软阴影
估计大概的percentage of occlusion(实际上不准但符合人们观察)。下图左模拟人眼看向面光源的阴影距离场计算,对光线方向计算不会被物体遮挡的最小安全角度(光线上每个step都要计算取最小,如下右图),安全角度越小,阴影越硬。
- 计算角度做切线用arcsin就能算出,但是在实时渲染中不喜欢开销大的反三角函数,而我们这里只需要获得角度相对大小和Visibility的关系,因此可以用以下近似替代(k的大小是控制阴影的软硬程度,k越大阴影越硬):
a r c s i n S D F ( p ) p − o = m i n { k ⋅ S D F ( p ) p − o , 1.0 } arcsin \frac{SDF(p)}{p-o} = min\{ \frac{k \cdot SDF(p)}{p-o} , 1.0 \} arcsinp−oSDF(p)=min{p−ok⋅SDF(p),1.0}
- SDF优缺点:
- SDF可以快速生成高质量软阴影(在shader里就可以做ray marching),SDF预计算生成后调用shadow map又快又好。
- 存储上消耗比较大,需要预计算,生成SDF的时间也长,如果是动态物体消耗就高了。还有artifact,表面参数不好获得,不容易uv贴图。
七,作业1
7.1 硬阴影
- lights\DirectionalLight.js中完成MVP矩阵的变换得到lightMVP(这部分完全忘了,抄完别人作业以后想起来一点,希望以后别是个坑),上代码:
// 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,-150,150,-150,150,1e-2,400);
- 获得了正确的lightMVP后,shaders\phongShader\phongFragment.glsl中的vPositionFromLight就是正确的了,用它采样shadowmap后与自身深度对比获得可见性(注意vPositionFromLight需要先转换到NDC 标准空间 [0,1])
- 做完法线头发身上好多锯齿,出现了1.1中的自遮挡现象,于是在深度判断时加了一个bias,但头发那块bias值很大了还有遮挡但脚部的阴影却没了,出现1.2的典型情况。怎么解决老师也没讲,可能要动态bias。
vec3 shadowCoord = vPositionFromLight.rgb*0.5+0.5;
float useShadowMap(sampler2D shadowMap, vec4 shadowCoord){
vec4 rgbaDepth = texture2D(shadowMap, shadowCoord.xy);
float depthlight = unpack(rgbaDepth);
float ret = shadowCoord.z < depthlight + 0.01 ? 1.0:0.0;//+ 0.01为了避免自遮挡
return ret;
}
7.2 PCF
这里注意看作业文档中的建议,使用一个圆盘滤波进行随机采样,老师给了两个圆盘滤波,怕我看不懂居然还给了可视化网站(呜呜呜谢谢)!
然后就是采样NUM_SAMPLES个点后取可见性的平均值,注意采样完后还需要设置一个软边缘半径,根据engine.js中可知图像大小为2048,半径自己调就行了。
float PCF(sampler2D shadowMap, vec4 coords) {
poissonDiskSamples(coords.xy);
// uniformDiskSamples(coords.xy);
float visibility = 0.0;
for( int i = 0; i < NUM_SAMPLES; i ++ ) {
vec4 rgbaDepth = texture2D(shadowMap, coords.xy + poissonDisk[i]*10.0/2048.0);
float depthlight = unpack(rgbaDepth);
float ret = coords.z < depthlight + 0.01 ? 1.0:0.0;
visibility += ret;
}
return visibility/float(NUM_SAMPLES);
}
- 这里泊松圆盘采样和均匀圆盘采样的结果是不一样的,我设置NUM_SAMPLES为100,在泊松采样下能获得比较自然的软阴影(下图左),而均匀采样就噪点很多还有明显分层(下图右):
7.3 PCSS
float findBlocker( sampler2D shadowMap, vec2 uv, float zReceiver ) {
float averagedepth = 0.0;
int count = 0;
for( int i = 0; i < BLOCKER_SEARCH_NUM_SAMPLES; i ++ ) {
vec4 rgbaDepth = texture2D(shadowMap, uv + poissonDisk[i]*10.0/150.0);
float depthlight = unpack(rgbaDepth);
if(depthlight - 0.01 < zReceiver)
{
averagedepth += depthlight;
count += 1;
}
}
return averagedepth/float(count);
}
float PCSS(sampler2D shadowMap, vec4 coords){
poissonDiskSamples(coords.xy);
// STEP 1: avgblocker depth
float averagedepth = findBlocker(shadowMap,coords.xy,coords.z);
// STEP 2: penumbra size
float penumbrasize = (coords.z - averagedepth) * 20.0/averagedepth;
// STEP 3: filtering
float visibility = 0.0;
for( int i = 0; i < NUM_SAMPLES; i ++ ) {
vec4 rgbaDepth = texture2D(shadowMap, coords.xy + poissonDisk[i]*penumbrasize/2048.0);
float depthlight = unpack(rgbaDepth);
float ret = coords.z < depthlight + 0.01 ? 1.0:0.0;
visibility += ret;
}
return visibility/float(NUM_SAMPLES);
}
7.4 多光源
搞了半天也没找到WebGL的变暗blend怎么设置,最后勉强搞了个各自0.5透明度叠加的,还有js和Three.js都不会,学习太难了!
找了两个资源mark下吧:
WebGL中文学习网
Three.js学习网
- 先是loadOBJ.js里添加多光源的meshRender和shadowMeshRender:
let light = renderer.lights[0].entity;
let light2 = renderer.lights[1].entity;
switch (objMaterial) {
case 'PhongMaterial':
material = buildPhongMaterial(colorMap, mat.specular.toArray(), light, Translation, Scale, "./src/shaders/phongShader/phongVertex.glsl", "./src/shaders/phongShader/phongFragment.glsl");
material2 = buildPhongMaterial(colorMap, mat.specular.toArray(), light2, Translation, Scale, "./src/shaders/phongShader/phongVertex.glsl", "./src/shaders/phongShader/phongFragment.glsl");
shadowMaterial = buildShadowMaterial(light, Translation, Scale, "./src/shaders/shadowShader/shadowVertex.glsl", "./src/shaders/shadowShader/shadowFragment.glsl");
shadowMaterial2 = buildShadowMaterial(light2, Translation, Scale, "./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);
});
material2.then((data) => {
let meshRender = new MeshRender(renderer.gl, mesh, data);
renderer.addMeshRender(meshRender);
});
shadowMaterial2.then((data) => {
let shadowMeshRender = new MeshRender(renderer.gl, mesh, data);
renderer.addShadowMeshRender(shadowMeshRender);
});
- 再在WebGLRenderer.js里添加以下代码使两个光源的渲染结果blend:
gl.enable(gl.BLEND);
gl.blendFunc(768, 769);
结果:
参考链接
- https://www.jianshu.com/p/55b66f65ba41
- https://developer.nvidia.com/gpugems/gpugems3/part-ii-light-and-shadows/chapter-8-summed-area-variance-shadow-maps
- http://www.shaderwrangler.com/publications/sat/SAT_EG2005.pdf
- https://zhuanlan.zhihu.com/p/396924653
- https://blog.csdn.net/qq_58622402/article/details/124276196