实时阴影技术
硬阴影
对于点光源来说,它只会产生硬阴影。
shadow maping 基本原理
点光源的 Shadow Map 算法,分为两个 PASS
- PASS 0:从光源位置看向场景,并且计算出光源到能看到的最近的物体的深度图,并将深度存起来作为 Shadow Map
// shadowVertex.glsl
// ...
void main(void) {
vNormal = aNormalPosition;
vTextureCoord = aTextureCoord;
gl_Position = uLightMVP * vec4(aVertexPosition, 1.0);
}
// shadowFragment.glsl
// ...
void main(){
// 将 z 拆分到 4 个通道存储
gl_FragColor = pack(gl_FragCoord.z);
}
如下图, Shadow Map 记录了 Light Camera 所看到的最近的深度图,颜色越深,离相机越近
- PASS 1:然后第二个 PASS 从相机出发,使用第一个 PASS 得到的 Shadow map,去判断渲染的像素点,是否在阴影中(计算当前点到光源距离,跟 Shadow map 中采样的深度作比较),最终计算得出 Visibility(0 or 1)
太阳 表示灯光位置,绿色线 表示场景中的物体
右图中,第一个点计算得出的数值跟阴影图中数据一致
右图中,第二个点计算出来的距离比阴影贴图中的大,因此改点在阴影里
// phongVertex.glsl
// ...
void main(void) {
vNormal = (uModelMatrix * vec4(aNormalPosition, 0.0)).xyz;
vTextureCoord = aTextureCoord;
vFragPos = (uModelMatrix * vec4(aVertexPosition, 1.0)).xyz;
vPositionFromLight = uLightMVP * vec4(aVertexPosition, 1.0);
gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix
* vec4(aVertexPosition, 1.0);
}
// phongFragment.glsl
// ...
void main(){
// 归一化坐标
vec3 projCoords = vPositionFromLight.xyz / vPositionFromLight.w;
vec3 shadowCoord = projCoords * 0.5 + 0.5;
// Shadow 1.0 表示没有阴影 0 表示阴影
float visibility = 1.0;
//将rgba四通道(32位)的值unpack成float类型的数值
float depthInShadowmap = unpack(texture2D(shadowMap,shadowCoord.xy).rgba);
if(depthInShadowmap < shadowCoord.z){
visibility = 0.0;
}
// blinnPhong光照着色
vec3 color = blinnPhong();
gl_FragColor = vec4(color * visibility, 1.0);
}
缺点
传统的 Shadow Map 技术存在一些缺陷:
自遮挡(Shadow Ance)
传统的 Shadow Map 存在由数值精度造成的自遮挡问题,即在下图中看到的地面上的不正确的纹路:
这是因为 shadow map 的分辨率太低。因为 shadow map 贴图的分辨率过低,阴影贴图的一个纹素会对应多个像素,而这些像素在shadow map空间中的深度是不同的,因此就会出现自己遮挡自己的情况,当光照与投影面垂直时,几乎不存在自遮挡现象,当光照向与平面接近平行时,平面会产生严重的自遮挡现象。
每个蓝色线隔开的地方,计算出来的深度一样,用黄色线条表示,但实际上,靠右侧的实际深度要大一些,因此,计算是否可见时,就容易出现自遮挡。(橙色箭头所示,实际深度还要加上灰色线段部分,如上图)
增加一个偏移值
最简单的方法就是,直接给采样阴影深度增加一个偏移值:相当于是把阴影深度往远处加,从而不容易产生自遮挡)。
// phongFragment.glsl
//...
void main(){
// ...
// Shadow
float visibility = 1.0;
// BIAS 调很大,为了显示漏光 bug
const float BIAS = 0.01;
//// 增加了 BIAS
if(depthInShadowmap + BIAS < shadowCoord.z) {
visibility = 0.0;
}
// ...
}
可以看到,自遮挡问题解决了,但是因为增加了 Bias,可能导致影子悬空。(Peter Panning 现象在物体缝隙间漏光,特别是遮挡物厚度小于 Bias 时)
Peter Pan 是童话故事里的彼得潘,他是个会飞的男孩,而 panning 有平移、悬浮之意
有另外一种跟 Bias 不太一样的方法(实际上原理相同)
- 不使用 Bias
- 第一个 Pass 设置成仅渲染背面(正面剔除),对于有厚度的物体,相当于是增加了遮挡物渲染后的深度大小
本来深度值应该是正方块上表面的距离,使用正面剔除后,深度值是正方块的下表面的距离了
- 解决办法:避免使用单薄的几何体
动态 Bias
通过之前的介绍,使用过大的 Bias 增加深度的方法会导致影悬空问题,而过小的 Bias 又解决不了自遮挡问题,自遮挡问题产生又跟光线的角度有关系,因此可以采取根据平面倾角来自适应 Bias
float MIN_BIAS = 0.005;
float BIAS = 0.05;
float bias = max(BIAS * (1.0 - dot(normal, lightDir)), MIN_BIAS);
第二深度法
第二种解决办法是在计算光照方向的深度时,同时计算得到最小深度以及第二小的深度,然后用这两个的中间值作为最终深度存放到 Shadow Map 中
如上图所示,需要两个 PASS 来生成阴影贴图,第一个 PASS 设置背面剔除,第二个 PASS 设置为前向面剔除,这样就能分别获得两个深度,最后得出平均深度
但是这种方法要求遮挡物(投射阴影的物体)必须是闭合曲面(watertight),必须有正反面,然后会多增加一个 PASS 带来更大的开销,因此没有得到广泛应用。
锯齿
第二个缺点就是生成的 Shadow Map 分辨率是有限的,如果阴影面积过大,就会产生锯齿(Aliasing)
级联阴影贴图 CSM(Cascade Shadow Map)
当 Shadow Mapping 应用在大型场景中时,一张正常分辨率大小(如1024×1024)的贴图用来记录整个大型场景的阴影深度信息是非常不精确的,尤其是在靠近主摄像机的地方所看到的阴影将是严重失真的(一块块栅格)。
CSM 的思想是使用多层 Shadow Map 将视锥按照距离划分成多个阴影区,相机附近提供更高分辨率的深度纹理,而在远处提供更低分辨率的纹理,来帮助解决走样问题。
PCF (Percentage closer filtering)
之前使用的 Shadow Maping 技术中,进行深度比较时,只对阴影贴图采样一次作比较,PCF 算法为了解决锯齿问题,采样时会在 对应点周围一定范围的像素 (例如下图 5*5) 进行多重采样,然后把采样区域内所有像素深度比较后的结果求平均得出最后的值,因此得出的 Visibility 不在是非 0 即 1,而是带有渐变的取值。
上面这得出的最终 $Visibility = 13 / 25 = 0.52$
采样窗口越大,抗锯齿效果就约好,但是当采样范围变大时,采样的次数成平方次膨胀,开销就会很大,达不到实时渲染的要求,因此我们可以在采样范围内,随机抽取一定个数(NUM_SAMPLES)的采样点进行采样,下面是一些常用的分布采样函数。
均匀圆盘分布采样(Uniform-Disk Sample):圆范围内随机取一系列坐标作为采样点;看上去比较杂乱无章,采样效果的 noise 比较严重。
泊松圆盘分布采样(Poisson-Disk Sample):圆范围内随机取一系列坐标作为采样点,但是这些坐标还需要满足一定约束,即坐标与坐标之间至少有一定距离间隔。
#define NUM_SAMPLES 20
vec2 poissonDisk[NUM_SAMPLES];
// 泊松圆盘分布
void poissonDiskSamples( const in vec2 randomSeed ) {
float ANGLE_STEP = PI2 * float( NUM_RINGS ) / float( NUM_SAMPLES );
float INV_NUM_SAMPLES = 1.0 / float( NUM_SAMPLES );
float angle = rand_2to1( randomSeed ) * PI2;
float radius = INV_NUM_SAMPLES;
float radiusStep = radius;
for( int i = 0; i < NUM_SAMPLES; i ++ ) {
poissonDisk[i] = vec2( cos( angle ), sin( angle ) ) * pow( radius, 0.75 );
radius += radiusStep;
angle += ANGLE_STEP;
}
}
// 均匀圆盘分布
void uniformDiskSamples( const in vec2 randomSeed ) {
float randNum = rand_2to1(randomSeed);
float sampleX = rand_1to1( randNum ) ;
float sampleY = rand_1to1( sampleX ) ;
float angle = sampleX * PI2;
float radius = sqrt(sampleY);
for( int i = 0; i < NUM_SAMPLES; i ++ ) {
poissonDisk[i] = vec2( radius * cos(angle) , radius * sin(angle) );
sampleX = rand_1to1( sampleY ) ;
sampleY = rand_1to1( sampleX ) ;
angle = sampleX * PI2;
radius = sqrt(sampleY);
}
}
其中, rand_2to1, rand_1to1 1 是利用 $sin$ 函数特效实现的伪随机函数
// 伪随机函数
highp float rand_1to1(highp float x ) {
// -1 -1
return fract(sin(x)*10000.0);
}
highp float rand_2to1(vec2 uv ) {
// 0 - 1
const highp float a = 12.9898, b = 78.233, c = 43758.5453;
highp float dt = dot( uv.xy, vec2( a,b ) ), sn = mod( dt, PI );
return fract(sin(sn) * c);
}
PCF 算法过程:
- 计算 Visibility 时,原本对 Shadow Map 的一次坐标采样,换成对周围一定范围内若干个坐标进行采样
- 各个采样后的结果跟之前实际深度 $z^{'}$ 做比较,最后去平均值作为 Visibility
float PCF(sampler2D shadowMap, vec4 coords, float filterSize) {
const float bias = 0.005;
float sum = 0.0;
// 初始化泊松分布
poissonDiskSamples(coords.xy);
// 采样
for(int i = 0;i< NUM_SAMPLES;++i){
float depthInShadowmap = unpack(texture2D(shadowMap,
coords.xy + poissonDisk[i] * filterSize).rgba);
sum += ((depthInShadowmap + bias) < coords.z ? 0.0 : 1.0);
}
// 返还平均采样结果
return sum/float(NUM_SAMPLES);
}
void main(void) {
float visibility = 1.0;
vec3 shadowCoordNDC =
(vPositionFromLight.xyz / vPositionFromLight.w + 1.0) / 2.0;
// fiterSize 参数会影响实际采样区域的范围
visibility = PCF(uShadowMap, vec4(shadowCoordNDC, 1.0), 0.001);
vec3 phongColor = blinnPhong();
gl_FragColor = vec4(phongColor * visibility, 1.0);
}
效果如下图所示,当 fiterSize 很小时,抗锯齿效果不明显(fiterSize = 0.0001),而当 fiterSize 逐渐增大时,阴影的边缘渐变效果越来越明显( Shadow Map 的大小为 2048 * 2048)
当 Shadow Map 尺寸为 256 * 256 时,效果如下图:
PCF 通过增加采样区域范围,来做抗锯齿,当过滤范围变大时,逐渐有了软阴影的效果,因此,我们可以使用 PCF 的原理,来实现软阴影。
软阴影
较大的光源面在被物体遮挡时,阴影接收物上会有一部分区域被遮蔽了一部分光线,从而产生半影(Penumbra)。
PCSS (Percentage closer soft shadows)
之前介绍的 PCF 里,我们通过改变采样窗口,可以调整阴影整体的软硬程度,因此可以用这个方法来实现软阴影,不过我们注意到,投影面到遮挡物距离,会影响阴影的软化程度
钢笔尖的阴影距离钢笔近,产生的阴影很硬,轮廓很分明,笔身距离钢笔远,产生的阴影就很软,阴影边缘不清晰
因此,当我们对 PCF 进行一些改进,自动根据边缘半影的大小来计算过滤半径,就能很好的实现软阴影的效果,这就是 PCSS 的核心原理。
Penumbra Size(半影的大小)
根据半影的产生原因我们可以得出下面这个图
半影的大小取决于光源的大小($W_{Light}$)以及距离遮挡物(Blocker)的距离($d_{Receiver}$)
$$
w_{Penumbra} = \frac{(d_{Receiver} - d_{Blocker})* w_{light}}{d_{Blocker}}
$$
$w_{Penumbra}$ : 半影的长度
$d_{Receiver}$ : 阴影接收区域到光源距离
$d_{Blocker}$ : 遮挡物到到光源距离
$w_{light}$ : 光源的大小
$w_{Penumbra}$ 半影的长度可以看成是软阴影的范围
因此 PCSS 算法分为下面几个过程:
- Blocker Search:计算得出 $d_{Blocker}$
- 计算得出半影大小
- 根据半影大小做 PCF
- 一般来说,Blocker Search 的时候不会只找单个像素点来获取 $d_{Blocker}$,会在像素周围一定范围内找遮挡,然后将所有是遮挡区域的深度求和再取平均值
- 对应大面积的灯光,理论上是不会产生硬阴影,因此一般会使用灯光的中心点作为点光源,生成 Shadow Map
#define NUM_SAMPLES 20
#define BLOCKER_SEARCH_NUM_SAMPLES NUM_SAMPLES
// 在附近 40 * 40 的范围内抽取 100 个像素点计算遮挡物平均深度
float findBlocker( sampler2D shadowMap, vec2 uv, float zReceiver ) {
// 初始化泊松分布
poissonDiskSamples(coords.xy);
const int radius = 40;
const vec2 texelSize = vec2(1.0/2048.0, 1.0/2048.0);
float cnt = 0.0, depthSum = 0.0;
float is_block = 0;
float EPS = 0.002;
for(int ns = 0; ns < BLOCKER_SEARCH_NUM_SAMPLES; ++ns)
{
vec2 sampleCoord = (vec2(radius) * poissonDisk[ns]) * texelSize + uv;
float depthOnShadowMap = unpack(texture2D(shadowMap, sampleCoord));
is_block = step(depthOnShadowMap, zReceiver - EPS)
cnt += is_block;
depthSum += is_block * depthOnShadowMap;
}
if(cnt < 0.1)
{
return zReceiver;
}
return depthSum / cnt;
}
float PCF(sampler2D shadowMap, vec4 shadowCoord, float radius) {
const vec2 texelSize = vec2(1.0/2048.0, 1.0/2048.0);
float visibility = 0.0, cnt = 0.0;
for(int ns = 0;ns < PCF_NUM_SAMPLES;++ns)
{
vec2 sampleCoord = (vec2(radius) * poissonDisk[ns]) * texelSize
+ shadowCoord.xy;
float cloestDepth = unpack(texture2D(shadowMap, sampleCoord));
visibility += ((shadowCoord.z - 0.001) > cloestDepth ? 0.0 : 1.0);
cnt += 1.0;
}
return visibility/cnt;
}
float PCSS(sampler2D shadowMap, vec4 shadowCoord){
// STEP 1: avgblocker depth
float avgBlockerDepth = findBlocker(shadowMap, shadowCoord.xy, shadowCoord.z);
// STEP 2: penumbra size
const float lightWidth = 50.0;
float penumbraSize = max(shadowCoord.z - avgBlockerDepth, 0.0) /
avgBlockerDepth * lightWidth;
// STEP 3: filtering
return PCF(shadowMap, shadowCoord, penumbraSize);
//return 1.0;
}
结果如下图所示:
缺点
从实现步骤上,PCSS 有两个地方非常耗时,需要多次采样图片。
- Blocker Search:计算得出 $d_{Blocker}$
- 根据半影大小做 PCF
VSSM 方差软阴影映射算法 (Variance soft shadow mapping)
加速 PCF 计算方案
从上面的介绍可以得知,PCSS 有两个耗时的地方,我们首先来看 PCF 这个耗时点。PCF 的本质是在特定区域内,对每一个像素都采样深度贴图,将采样的到的值跟当前实际深度对比,小于则返回 0 大于返回 1(例如 10 * 10 的区域内,有 40 个小于的,则最终返回 40 / 100),这个等价于找到当前区域内,给定一个深度 $D_{scene}$,当前点在 Shadow Map 上的深度为 $D_{SM}$,求 $D_{SM} > D_{scene}$ 的概率 $P(D_{SM} > D_{scene})$
上图是把当前区域所有的像素值在 Shadow Map 上的深度做的直方图,横坐标表示当前深度值,绿色的区域表示当前深度的像素个数,个数越多,柱状图越高,深色区域表示深度大于等于 12 的像素个数
VSSM 的思想是,将某个区域的深度值近似的看成是正态分布,那对于一个正态分布我们只需要知道两个变量均值(期望)$E$,方差 $Var$,平均值很好求,方差可以用下面的公式求得:
$$
Var(X) = E(X2)-E2(X)
$$
- $E(X)$: 深度图某个区域内像素的平均值
- $E^2(x)$:另外一张深度图,记录的是深度值的平方,求给定区域像素的平均值
- 怎么快速计算指定区域内像素的均值,后面会讲
当我们有了期望跟方差后,就能得出一个正态分布的函数图,那我们之前要求的百分比 $P(D_{SM} > D_{scene})$,就是下图中 1 - 阴影的面积:
VSSM 为了求解上面的百分比,使用切比雪夫不等式来求解,切比雪夫不需要知道具体的分布函数的,不一定需要是正态分布。
$$
\begin{align*}
P(x > t) &\leq \frac{\sigma ^2}{\sigma ^2 + (t - \mu)^2} \
P(x > t) &\approx \frac{\sigma ^2}{\sigma ^2 + (t - \mu)^2}
\end{align*}
$$
$x$ :分布函数里的变量 $x$
$t$ :某个指定的值
$\sigma$ : 标准差
$\mu$ : 均值
限制:
- $t$ 必须在均值右边,不然不准(实时渲染里,还是这么使用)
加速 Block Search 计算方案
现在回到第一步 Blocker Search 的计算上,我们有如下区域深度信息,当前像素光照位置的的真实深度是 7 则所有深度小于 7 的像素就是要找的 Blocker,即下图中的蓝色区域。
对于这个区域会有下面这个等式成立:$Z_{occ}$ 就是我们要求的 Blocker 的平均深度
$$
\frac{N_{1}}{N}Z_{unocc} + \frac{N_{2}}{N}Z_{occ} = Z_{avg}
$$
t : 当前深度
$Z_{occ}$:所有深度小于 $t$ 的深度平均值
$Z_{unocc}$: 所有深度大于等于 $t$ 的深度的平均值
$N$ : 当前区域像素点个数
$N_1$:深度大于等于 $t$ 的像素个数
$N_2$:深度小于 $t$ 的像素个数
$Z_{avg}$:当前区域所有像素的深度的平均值
沿用之前 PCF 中的假设
$$\begin{align*}
\frac{N_{1}}{N} &= P(x>t) \
\frac{N_{2}}{N} &= 1 - P(x>t)
\end{align*}
$$
剩下 $Z_{unocc}$ 不知道值,这个时候,VSSM 做了个大胆的假设,假设投影接收区域是个平面,直接使用估计值,令 $Z_{unocc} = t$,就能立刻计算出 $Z_{occ}$
区域均值
综上所述,不管 PCF,还是 Blocker Search 加速方法,都需要计算某个区域内的像素的均值,均值的求解方法:
Mipmap
最简单的方法就是使用 Mipmap 来快速获取贴图上某个区域的均值,但是 Mipmap 获取的值也是通过插值获取的近似值(三线性插值)
SAT(Summed-Area Table)二维面积前缀和
先介绍一维的 SAT,如下图 SAT 存储的是当前位置之前所有的数的总和
当我们要求括号区域的值的平均值时,只需要找到区间前一个位置的和跟区间最后一个位置的和,做减法即可快速得到当前区间内的像素的和。
扩展到二维:
- 首先跟一维 SAT 一样,逐行计算每行的 SAT
- 然后再逐列遍历,计算每行的 SAT
然后我们要计算某个区域的平均值时,如下图蓝色框框是我们要计算的区域
$$
S = S1 - S2 - S3 + S4
$$
VSSM 效果
VSSM 缺点
- 假设区域内像素分布为正态分布,如果采样区域不符合正态分布,阴影效果就不正确
右图的阴影分布呈现比较规则的分布,深度值会几种在三个面片附近,如下图所示,会有三个波峰
当估计值偏高时,如下图,则计算得出的 Visibility 的值偏大(1 为可见,0 为全黑),就会导致漏光,车底盘出现了漏光现象(Light Leaking)。
当估计值偏小时,得出的阴影会更黑,人眼不容易观察出来。
- 之前计算 Blocker Search 的时候, VSSM 大胆假设了投影接收物体是一个平面 $Z_{unocc} = t$,但实际上有些情况,投影接收物体不一定是个平面
左图中的几个面片是倾斜的,是的阴影接收面是一个斜面。
- 切比雪夫不等式应用上问题:大于均值区域不等式才成立
MSM(Moment Shadow Mapping)
MSM 主要是解决在 PCF 阶段,描述分布不准导致漏光的问题,VSSM用深度的均值 $\mu$
和方差 $\sigma$ 来逼近可见性的累积分布函数,本质上是利用深度值分布的一阶原始矩和二阶中心距,MSM 采用更高阶的矩来进行估计(前四阶矩),不考虑精度的情况下,分别用 Shadow Map 的四个通道存储 $z$,$z2$,$z3$,$z^4$
可以类比成泰勒展开,保留的次方越高,拟合效果越接近。
下面是 VSM(PCF 的优化版本, VSSM 是 PCSS 的优化版本)对比效果
Distance Field Soft Shadows(距离场软阴影)
之前在介绍文本的时候,介绍过 SDF 的文本,距离场也可以用在实时阴影中,假设我们已经知道场景中任何一个点到最近物体的距离场后,可以利用距离场近似计算软阴影,软阴影的产生其实是光源一部分光线被遮挡了,如下图,则半影的大小跟图中的 $\theta$ 角度(当前渲染点到光源中心方向上与最近的遮挡物所形成的最小安全角)成正比。
SDF 将阴影求解转换求解安全角度
当我们有了整个场景的 SDF 数据后,我们首先从渲染点 $P0$ 出发,找到该点最近遮挡物的距离(红色圆圈),然后继续沿着该方向找到下一个点 $P1$,继续找到 $P1$ 点到最近遮挡物的距离,以此类推,从而找出该方向上最小的距离。
然后将所有圆跟起点 $P0$ 做切线得出下面这个图:
SDF 只能告诉我们当前点最近的遮挡物距离,但是不知道遮挡物的方向,因此这里对圆做切线,将切线处当成遮挡物位置(切线处角度最大)
因此 $\theta$ 求解公式如下:
$$
\theta = arcsin(\frac{SDF(p)}{|p-o|})
$$
但是在实际中,会用下面这个式子来做近似
$$
min \left { \frac{k \cdot SDF(p)}{|p-o|}, 1.0
\right }
$$
// ro: o 点
// rd: o 点到光源中心点方向向量
float softshadow( in vec3 ro, in vec3 rd, float mint, float maxt, float k )
{
float res = 1.0;
for( float t = mint; t < maxt; )
{
float h = map(ro + rd * t);
if( h < 0.001 )
return 0.0;
res = min( res, k * h / t );
t += h;
}
return res;
}
优点
- 计算速度快(假设 SDF 已经离线生成的情况下,比传统的 Shadow Mapping 技术快很多
- 阴影质量很高,完美解决 Shadow Ance(自遮挡),Peter Panning(阴影浮空)等问题
缺点
- SDF 需要预计算,因此场景中的物体需要是静态的
- SDF 需要大量的存储空间(一般采用三维数组来存储空间中各个网格的 SDF 值)
参考
5.GAMES202-高质量实时渲染 —— Lecture3 Real-time Shadows
6.联级阴影贴图CSM(Cascaded shadow map)原理与实现