https://zhuanlan.zhihu.com/p/45805097
https://xiaoiver.github.io/coding/2018/09/27/%E5%AE%9E%E6%97%B6%E9%98%B4%E5%BD%B1%E6%8A%80%E6%9C%AF%E6%80%BB%E7%BB%93.html
给场景中光照表的物体增加阴影能够显著增强“真实感”,即使阴影并不十分完美。在光线追踪算法中,实现阴影(shadow ray)更加符合直觉。而在光栅化算法中,基于shadow map的实现是最常见的。
https://github.com/xiaoiver/ray-tracer
阴影的“软硬”
在下面的图中,a点与光源之间没有任何物体遮挡,因此是照亮(lit)的。而地面也就是接受者(receiver)上的 c 点被遮挡者(occluder)立方体遮挡,处于本影区(umbra)。b 点处于被部分遮挡形成的半影区(penumbra)。
阴影基础-《real-time shadows》
理想中的点光源会造成只有本影区的硬阴影(hard shadows),但是现实中的光源毕竟本身有体积,会形成拥有半影区的软阴影(soft shadows)。两者的关系不是简单地将阴影的边缘模糊化处理就能得到软阴影,根据我们日常生活中的经验,光源和接受者的距离越近,软阴影的边缘就越清晰(软度降低)。
shadow map思路
来自《real-time shadows》中的一张图总结的十分清晰,由于光栅化的渲染管线相比基于光线追踪的实现方式缺少全局性信息,每个fragment并不清楚全局的光照情况,无法直接判断自己是否处于阴影中,因此需要额外的预渲染阶段。第一次渲染中以光源位置作为视点,基于z-buffering算法,将每个像素点的深度值(z-depth)也就是距离光源最近的对象距离记录在z-buffer中,输出到fbo(framebuffer object)关联的texture中,生成shadow map。第二次场景渲染时,以正常摄像机作为视点,将每个fragment到光源的距离和shadow map中保存的深度值进行比较,如果大于后者则说明被其他物体遮挡处于阴影之中。
整体思路就是这样,针对不同的光源类型有一些额外的注意点,比如:
1、平行光(directional light)并不存在确定的光源位置,在投影矩阵的选择上应该采用平行投影而非透视投影。
2、平行光和聚光灯(spot light)都有固定的方向,而泛光灯(omnidirectional shadow maps)向四面八方发光。其实思路都是一致的,只是具体使用cubemap保存shadowmap,可以参考https://learnopengl.com/Advanced-Lighting/Shadows/Point-Shadows
基础版本
有了以上的思路,我们很容易使用webgl实现,涉及到具体api的使用例如fbo等等可以参考之前的一篇(https://xiaoiver.github.io/coding/2018/06/15/%E5%88%9B%E5%BB%BA%E9%98%B4%E5%BD%B1.html)
其中第一对shadow shader负责计算光源到物体的距离,而第二对display shader负责真正的场景绘制。
在shadow fragment shader中,将深度值存在R分量里,其实RGBA任何一个分量都可以。
precision mediump float;
void main() {
gl_FragColor = vec4(gl_FragCoord.z, 0.0, 0.0, 0.0);
}
在 Display Fragment Shader 中,首先需要转换到 NDC(通过除以 w 分量得到)。 其次 texture 坐标取值范围是 [0,1],从 NDC [-1,1] 转换而来时需要除以 2 再加 0.5。 最后比较当前 fragment 到光源的距离和 shadow map 中对应的深度值,判断是否处于阴影中。
float calcShadow(sampler2D depths, vec4 positionFromLight, vec3 lightDir, vec3 normal) {
// Clipped Coord -> NDC -> texture Coord
vec3 shadowCoord = (positionFromLight.xyz / positionFromLight.w) * 0.5 + 0.5;
// 获取 shadow map 中保存的深度值
vec4 rgbaDepth = texture2D(depths, shadowCoord.xy);
// 比较当前距离和深度值
return step(shadowCoord.z, rgbaDepth.r);
}
这个最基础的版本存在一些明显的问题,让我们来看一下。
精度问题
运行 Demo 会发现根本没有阴影生成,这是为啥呢?
显而易见的,当物体到光源的距离过远时,使用 RGBA 中任何一个分量存储深度值都会存在精度丢失问题,毕竟只有 1 byte。 合适的做法是在 Shadow Fragment Shader 中充分利用四个分量也就是 4 bytes 存储。
precision mediump float;
void main() {
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);
vec4 rgbaDepth = fract(gl_FragCoord.z * bitShift);
rgbaDepth -= rgbaDepth.gbaa * bitMask;
gl_FragColor = rgbaDepth;
}
而在 Display Fragment Shader 中获取原始深度值只需要通过如下方式:
以上运算可以通过dot() GLSL内置函数完成:
float unpackDepth(const in 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));
float depth = dot(rgbaDepth, bitShift);
return depth;
}
最后我们定义我们的阴影效果比较函数,后续这个方法都不会再发生改变:
float texture2DCompare(sampler2D depths, vec2 uv, float compare) {
float depth = unpackDepth(texture2D(depths, uv));
return step(compare, depth);
}
至少现在阴影能显示出来了,让我们来看下一个问题。
深度偏移问题
再次运行demo发现物体的表面居然也出现了阴影,如同一颗复活节彩蛋,这又是咋回事呢?
surface ance
这种现象也被称作 Surface acne 或者 self-shadowing,原因是 Shadow Map 的分辨率是离散的,多个 fragment 会对应到同一个纹素,增加 Shadow Map 的分辨率(我们的 Demo 采用的是 2048 * 2048)只能减少可能性,并不能完全避免这个问题。 比如下图中一个倾斜的表面,蓝色的点和红色的点都对应 Shadow Map 中的同一个纹素,但是蓝色点距离光源的距离更远,因此在比较时被错误地认为处于阴影中。 常用的解决办法是给 Shadow map 中保存的深度值增加一个偏移值(Depth bias):
但是,这个偏移值过大又会出现“Peter Pan”现象(物体似乎飘在了空中),也叫漏光(light leaking),如下图右边所示,本该处于阴影的区域反而被照亮了。
surface acne & light leaking -《Real-Time Shadows》
所以这个偏移值的选择十分重要,如果采用一个固定值例如 0.005,当表面法线与光源方向夹角很大(上图中表面更加倾斜)时还是会出现,更好的做法是根据法线方向和光线方向计算:
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
float texture2DCompare(sampler2D depths, vec2 uv, float compare, float bias){
float depth = unpackDepth(texture2D(depths, uv));
return step(compare - bias, depth);
}
勾选 Demo 中“shadow-high-precision”可以查看目前的效果,很容易发现块状痕迹让阴影显得很不自然,而且完全没有半影区。
走样问题
锯齿状的痕迹也被称作走样(Aliasing),原因其实和之前 Surface acne 的问题一样。在下图中橙色区域内的所有 fragment 都对应到了 Shadow Map 中的同一个纹素。实时渲染中的软阴影技术也有相应的解释。
https://zhuanlan.zhihu.com/p/33444125
反走样或者更熟悉的名字–“抗锯齿”就是为了解决这个问题。 要注意,下面介绍的方法都不能生成真正物理意义上的软阴影,它们针对的其实都是走样问题,目的是平滑/模糊阴影的边界。