首页 > 技术文章 > 实时阴影技术(Real-time Shadows)

KillerAery 2021-08-28 22:08 原文

Shadow Mapping 基本实现


Shadow Mapping 基本原理:

  1. 阴影生成 Pass:
    • 额外设置一个摄像机在光源位置(Light Camera,光源摄像机),并且朝光照方向看去。
    • 用一张 Texture(称为 阴影贴图 Shadow Map)来记录 Light Camera 所看到的像素深度(每个像素位置只记录所见最近深度,而不用做别的 shading 计算)来作为遮挡深度。
// shadowVertex.glsl
// ...
void main(void) {
  vNormal = aNormalPosition;
  vTextureCoord = aTextureCoord;
  gl_Position = uLightMVP * vec4(aVertexPosition, 1.0);
}
// shadowFragment.glsl
// ...
void main(){
  gl_FragColor = pack(gl_FragCoord.z);
}

如图,Shadow Map 记录了 Light Camera 所看到的最近深度图,颜色越深,离摄像机越近:

  1. 渲染 Pass:
    • 主摄像机需要渲染屏幕每个像素时,该像素对应的世界坐标进行 Light Camera 的MVP变换后能得到在 Light Camera 屏幕空间中的对应位置 \(shadowCoord = (x',y',z')\)
    • Shadow Map 里用\((x',y')\)采样得到的遮挡深度 \(depth\) 与深度值 \(z'\) 做比较: 若 \(depth < z'\)(意味着该像素的光被遮挡),这时就可以对该像素降低可见度(Visibility)。
// 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
	float visibility = 1.0;
	float depthInShadowmap = unpack(texture2D(shadowMap,shadowCoord.xy).rgba); //将rgba四通道(32位)的值unpack成float类型的数值
	if(depthInShadowmap < shadowCoord.z){
    	visibility = 0.0;
	}
    // blinnPhong光照着色
    vec3 color = blinnPhong();
    
    gl_FragColor = vec4(color * visibility,1.0);
}

如图为主摄像机每个像素经过变换后比较深度的结果,其中绿色点意味着深度 \(depth \approx z’\) (没有遮挡光照),非绿色点意味着 \(depth < z'\)(被遮挡了光照):

Shadow Bias


直接使用Shadow Map可能会在不应该出现阴影的位置出现一些黑白条纹相间的现象(称为 Shadow Acne):

其本质原因在于,Shadow Map 是一个二维数组,离散的存储方式很难完全表示实际的几何信息。尤其当光照方向不垂直于平面时,遮挡深度的采样会和实际深度产生偏差(如图一个不受遮挡的几何平面,但黑色加粗部分却被Shadow Mapping方法认为是被遮挡的):

解决方法:

  • 直接给采样阴影深度加一个 偏移量 Bias(相当于把阴影深度往远处加,从而更不容易产生遮挡)。

// phongFragment.glsl
//...
void main(){
    // 归一化坐标
    vec3 projCoords = vPositionFromLight.xyz / vPositionFromLight.w;
	vec3 shadowCoord = projCoords * 0.5 + 0.5;
    // Shadow Bias
	const float BIAS = 0.005;
    // Shadow
	float visibility = 1.0;
	float depthInShadowmap = unpack(texture2D(shadowMap,shadowCoord.xy).rgba); //将rgba四通道(32位)的值unpack成float类型的数值
	if(depthInShadowmap + BIAS < shadowCoord.z){
    	visibility = 0.0;
	}
    // blinnPhong光照着色
    vec3 color = blinnPhong();
    
    gl_FragColor = vec4(color * visibility,1.0);
}

Peter Panning 问题 & 简单 Trick

然而由于增加了Bias,可能会导致 Peter Panning 现象:往往在物体缝隙间发生漏光。

解决方法:

  • 避免使用单薄的几何体(例如薄墙、薄地面);只要几何体厚度大于Bias,影子边界便会产生在几何体内部,从而不易看见影子与几何体的分离现象。

有一种有别于Bias的方法(但实际上也是殊途同归):

  • 不使用Bias

  • 第一个Pass(Light Camera记录深度的那个)设置成仅渲染背面(正面剔除)

这样可以让一些具有厚度的几何体背面作为深度记录,从而部分避免了几何体正面的 Shadow Acne现象。实际上这个跟使用了Bias+加厚几何体思想是差不多的,区别只不过在于:前者是低门限加一个偏移,后者则是直接给出高门限

Slope Scale Based Depth Bias

通过上面知道,Bias 过小时可能不能解决 Shadow Acne 现象,Bias 过大时又可能导致严重的 Peter Panning问题。

Slope Scale Based Depth Bias :为了尽可能减少由于 Bias 过大过小引起的问题,采取了根据平面倾角的一种自适应 Bias(例如:当光线与平面垂直时,Bias应该为0;当光线与平面的夹角越小,则Bias应越大)。

float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);

Cascade Shadow Map(CSM)


当 Shadow Mapping 应用在大型场景中时,一张正常分辨率大小(如1024×1024)的贴图用来记录整个大型场景的阴影深度信息是非常不精确的,尤其是在靠近主摄像机的地方所看到的阴影将是严重失真的(一块块栅格)。

Cascade Shadow Map(CSM) 借鉴了 LOD 的思想,对主摄像机的视锥体范围进行了划分,然后对每个划分区域采用相同分辨率大小的 Shadow Map,这意味着:离主摄像机比较近的地方往往区域面积是比较小的,这样 Shadow Map 能够表示的深度信息就更精确些,看到的阴影效果也更真实;离主摄像机比较远的地方往往区域面积是比较大的,虽然使用相同分辨率的 Shadow Map 能够表示的深度信息比较不精确,但对于远处的物体来说,这种不准确是可以接受的。

CSM 的代价很明显,即划分了多少层区域,就要使用多少倍数量的 Shadow Map;但 CSM 已经算是比较廉价且视觉效果不错的大型场景 Shadow 解决方案,在工业界得到广泛应用。

CSM(一层Shadow Map)的效果:

CSM(三层Shadow Map)的效果:

视锥分割

首先需要对视锥体划分成若干层(每一层在主摄像机观察空间下的 z值范围 分别从哪到哪),有如下方法:

  • 通过自定义比例来分割

  • 通过对数比例分割:这是较理想的比例公式,能够减少 Shadow Perspective Alias 问题的出现(下面是公式推理的内容)。

Shadow Perspective Alias:透视投影会产生近大远小的效果,可能会让近处物体的多个像素对应着 Shadow Map 中的一个纹素,从而产生 Alias。

假设,\(dp\) 为主摄像机近平面上的一小段距离, \(ds\) 为 ShadowMap 平面上的一小段距离。

通过下图的分析,通过几何关系容易得:

\(\frac{dp}{ds} = \frac{n\tan(\theta)dz}{z}/ ds\)

\(\varphi\)\(\theta\) 为物体平面法线的角度参数,则 \(\tan(\theta)=\frac{\sin(\theta)}{\cos(\theta)}=\frac{\cos(\varphi)}{\cos(\theta)}\)

\(n\) 为主摄像机近平面距离,\(f\) 为主摄像机远平面距离。

我们期望各层 Shadow Map 的投影和主摄像机图像分辨率都呈现尽量相同的比例(即比例与 \(z\) 无关):

\(\frac{dp}{ds} = C_0\)

接着,我们假设所有物体表面法线都是一样的,也就是说 \(tan(\theta)\) 也将是一个常数。

这个假设是针对这类物体:一个横跨了多层 Shadow Map 具有相同朝向的物体(如一长块墙面),在各层 Shadow Map 交界处很容易被看出边界的artifact问题(上下两层 Shadow Map 精准度不同)。

最终整理得到这么一个式子:

\(\frac{1}{z}\frac{dz}{ds} = C\)

解一下微分方程得:

分别代入 \(z=n,s=0\)\(z=f,s=1\) 解得。

\(z=n \cdot \left(\frac{f}{n}\right)^{s}\)

最终,把若干层 Shadow Map 视为若干段 \(s\),代入得:

\(z_i=n \cdot (\frac{f}{n})^{\frac{i}{N}},i=1,...,N\)

但是对数分割是一种理论上的理想分割,在场景物体从近到远分布均匀的情况下才有比较好的表现,而在实际应用中是很少出现以上理想情况的。因为使用对数比例计算出来的一层 CSM 通常距离很小,在多数情况下都是比较空旷的,这就造成了大量的浪费。

因此通常会与线性分割进行一个混合,公式如下:

\(z_{i}=\operatorname{lerp}\left(n *(\frac{f}{n})^{\frac{i}{N}}, n+\frac{i*(f-n)}{N}, \lambda\right), i=1, \ldots, N \ , \lambda \in [0,1]\)

\(\lambda\) 是一个 \([0,1]\) 区间的控制参数,用以在对数和线性之间进行插值

计算包围盒

分割好各层视锥体后,我们需要选择对应的 Shadow Map 来恰好包围住视锥体(即各层的 Light Camera 的光锥体刚好包围住视锥体)。

有如下做法:

  • 使用最紧凑的方形包围盒:分割后的视锥体的8个顶点在世界空间上做的View变换后,取它们最大最小x值y值z值来作为 Light Camera 的远近平面参数设置。

然而这种做法很容易导致远近平面大小频繁变化,在视锥体发生变化的情况下容易出现阴影边缘闪烁的瑕疵(shimmering edge effect or shadow flickering)

shimmering shadow edges

  • 使用固定的最大方形包围:既然频繁变化容易导致阴影抖动,那么就干脆使用一个固定的最大方形包围盒来包围任意角度下的视锥体。代价则是会包含有更多视锥体以外的位置,降低了Shadow Map的利用率。
image-20211010222035636

此外,还有使用球形包围盒的方法来构造光源正交矩阵,优势是可以通过很低廉的算法来进行层级选择:即像素点坐标到球心的距离与半径比较。

层级选择

有了若干层 Shadow Map 后,渲染某个shading poing时该如何判断点在哪个层级:

  • 直接通过视锥分割的z值范围来判断所在是哪个层级

  • 通过各层 Light Camera 的 View 变换和 Projection 变换,得到点在该层 Shadow Map 的 UV 坐标,当 UV 坐标在 [0,1] 范围内时则说明在该层级内

前面我们在视锥分割已经确定了z值划分范围,直接简单根据shading point的z值来判断层级不是更好吗?这是因为每一层的 Shadow Map 其实多多少少包含了更远一层的部分阴影信息,但是它的精准度明显要比更远层的 Shadow Map 要好,因此通过uv坐标判断点所属层,就可以尽量命中较近层的 Shadow Map。

不过,如果仅选择单个层级,会容易出现各层级阴影交界处出现阴影效果剧变的问题,这时候也可以混合上下两层 Shadow Map 来让交界可以过渡变化。

Percentage Closer Filtering(PCF)


Shadow Mapping 还存在 阴影锯齿(Shadow Aliasing) 问题:

Percentage Closer Filtering(PCF)正是解决阴影锯齿的方案,它的核心想法是计算阴影时不是考虑单个采样点,而是在一定范围内进行多重采样,这样可以让阴影的边缘不那么锯齿,因为 Visibility 不再是非0即1,而是带有渐变的取值。

分布采样函数

vec2 disk[NUM_SAMPLES]; // 经过分布采样函数运算后得到NUM_SAMPLES个采样坐标

在对周围一定范围内若干个坐标进行采样的时候,可以通过分布采样函数来确定 NUM_SAMPLES 个采样位置,为了让阴影边缘更加柔和,我们可以用一些较好的分布采样函数。

均匀圆盘分布采样(Uniform-Disk Sample):圆范围内随机取一系列坐标作为采样点;看上去比较杂乱无章,采样效果的 noise 比较严重。

泊松圆盘分布采样(Poisson-Disk Sample):圆范围内随机取一系列坐标作为采样点,但是这些坐标还需要满足一定约束,即坐标与坐标之间至少有一定距离间隔。

// 均匀圆盘分布
void uniformDiskSamples( const in vec2 randomSeed ) {
  // 随机种子
  float randNum = rand_2to1(randomSeed);
  // 随机取一个角度
  float sampleX = rand_1to1( randNum ) ;
  float angle = sampleX * PI2;
  // 随机取一个半径
  float sampleY = rand_1to1( sampleX ) ;
  float radius = sqrt(sampleY);
  for( int i = 0; i < NUM_SAMPLES; i ++ ) {
    disk[i] = vec2(radius * cos(angle) , radius * sin(angle));
    // 继续随机取一个半径
    sampleX = rand_1to1( sampleY ) ;
    radius = sqrt(sampleY);
    // 继续随机取一个角度
    sampleY = rand_1to1( sampleX ) ;
    angle = sampleX * PI2;
  }
}
// 泊松圆盘分布
void poissonDiskSamples( const in vec2 randomSeed ) {
  // 初始弧度
  float angle = rand_2to1( randomSeed ) * PI2;
  // 初始半径
  float INV_NUM_SAMPLES = 1.0 / float( NUM_SAMPLES );
  float radius = INV_NUM_SAMPLES;
  // 一步的弧度
  float ANGLE_STEP = 3.883222077450933;// (sqrt(5)-1)/2 *2PI
  // 一步的半径
  float radiusStep = radius;

  for( int i = 0; i < NUM_SAMPLES; i ++ ) {
    disk[i] = vec2(cos(angle),sin(angle)) * pow( radius, 0.75 );
    radius += radiusStep;
    angle += ANGLE_STEP;
  }
}

PCF 算法过程

Percentage Closer Filtering(PCF) 的算法过程:

  1. 计算 Visibility 时,原本对 Shadow Map 的一次坐标采样换成对周围一定范围内若干个坐标进行采样。
  2. 各个采样结果同样用来与 \(z'\) 做比较,最后取比较结果的平均作为 Visibility。
float visibility_PCF(sampler2D shadowMap, vec4 coords) {
  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+disk[i]*0.001).rgba);
    sum += ((depthInShadowmap + bias)< coords.z?0.0:1.0);
  }
  // 返还平均采样结果
  return sum/float(NUM_SAMPLES);
}

Percentage Closer Soft Shadows(PCSS)


Shadow Mapping 还存在硬阴影(Hard Shadow)的问题,因为现实世界的影子往往是软阴影(Soft Shadow)

一个现实观察是,当投影物与阴影之间的距离越远,则阴影越软(如下图:笔尖阴影由于与笔尖的距离较近,因此阴影边缘较为锐利;而远处笔身阴影则因与笔身距离较远,阴影边缘较为发散且模糊)。

这是因为较大的光源面会有一些区域被遮蔽一部分光又接受一部分光,从而产生半影(Penumbra),直观看就是没那么暗的边缘处阴影。

Penumbra Size

用二维平面的图去描述,实际上就是光源段 \(w_{Light}\) 两端与遮挡物连直线后打在被投影物上的即是 半影段 \(w_{Penumbra}\) ,也就是说这段半影需要有渐变的阴影效果。假如我们用 PCF 算法中的圆盘半径大小等同于这个半影段的尺寸 \(w_{Penumbra}\),就能实现这段的渐变阴影效果(可以想想为什么)。

现在,由下图的几何关系容易推出:

\(w_{\text {Penumbra }}=\left(d_{\text {Receiver }}-d_{\text {Blocker }}\right) \cdot w_{\text {Light }} / d_{\text {Blocker }}\)

其中,\(w_{Light}\) 是光源面积尺寸,\(d_{Blocker}\) 是遮挡物的深度,\(d_{Receiver}\) 是被投影物(实际上就是shading point)的深度。

但是 PCF 算法的圆盘半径大小是固定的,因此处处的边缘看起来都带有相同的渐变范围,这和我们看到的笔尖阴影现象不符合(近处边缘渐变应该更少些,远处边缘渐变应该多些),所以我们可以只要根据不同位置动态地修改圆盘半径大小(实际上就是动态地计算 \(w_{Penumbra}\) ),这个也就是PCSS的核心部分。

我们不能简单把一个投影点变换成Shadow Map的坐标后,直接拿单个坐标采样 ShadowMap 的深度来作为 \(d_{Blocker}\) 。这是因为投影点的单次采样实际上就是单一直线连向了光源面的中心,而这条直线要是没有碰到遮挡物(即 \(d_{Blocker}=d_{Receiver}\) ),从而得出该投影点为全亮的结论。

但实际很多场景中(如下图),投影点和光源面处处连线后会发现有相当一部分光线会碰到遮挡物,因此该投影点应该属于半影范围内。

为此,我们可以对 ShadowMap 的一定范围内进行多重采样,每次采样得到的深度若小于 \(d_{Receiver}\) 则认为遇到遮挡物并算入平均遮挡深度的贡献,这样多重采样之后得到的平均遮挡深度就作为 \(d_{Blocker}\)

如何确定采样的范围半径呢?两个参数决定:\(w_{Light}\) 的尺寸、投影点与光源的距离(可以结合上图推理一下为什么)

\(SampleSize=w_{Light}\cdot z_{Receiver} \cdot c\)

这样,计算 Blocker 平均遮挡深度的整个过程为:

float findBlocker( sampler2D shadowMap, vec2 uv, float zReceiver ) {  float dBlocker = zReceiver * 0.01;  const float wLight = 0.006;  const float c = 100.0;	  float sampleSize = wLight * zReceiver * c;  float sum = 0.01;	// 取0.01一是为了避免出现0除问题,二是当多重采样没有贡献时的dBlocker/sum将等于zReceiver  for(int i = 0;i<BLOCKER_SEARCH_NUM_SAMPLES;++i){    float depthInShadowmap = unpack(texture2D(shadowMap,uv+disk[i]*sampleSize).rgba);    if(depthInShadowmap < zReceiver){      dBlocker += depthInShadowmap;      sum += 1.0;    }  }  return dBlocker/float(sum);}

PCSS 算法过程

Percentage Closer Soft Shadows(PCSS) 的算法过程:

  1. Blocker Search:通过多重采样,计算出平均遮挡深度 \(d_{Blocker}\)

  2. Penumbra Size:计算圆盘半径大小 \(w_{\text {Penumbra }}=\left(d_{\text {Receiver }}-d_{\text {Blocker }}\right) \cdot w_{\text {Light }} / d_{\text {Blocker }}\)

  3. Filtering:通过多重采样,计算出平均 Visibility(实际上就是调用PCF算法)

float visibility_PCSS(sampler2D shadowMap, vec4 coords){
  poissonDiskSamples(coords.xy);
  // STEP 1: avgblocker depth
  float dBlocker = findBlocker(shadowMap,coords.xy,coords.z);
  // STEP 2: penumbra size
  const float wLight = 0.006;
  float penumbra = (coords.z-dBlocker)/dBlocker * wLight;
  // STEP 3: filtering
  const float bias = 0.005;
  float sum = 0.0;
  for(int i = 0;i<PCF_NUM_SAMPLES;++i){
    float depthInShadowmap = unpack(texture2D(shadowMap,coords.xy+disk[i]*penumbra).rgba);
    sum += ((depthInShadowmap + bias)< coords.z?0.0:1.0);
  }
  return sum/float(PCF_NUM_SAMPLES);
}

PCF算法效果图:

PCSS算法效果图:

Variance Soft Shadow Mapping(VSSM)


PCSS、PCF 的算法都需要多重采样,尤其 PCSS 需要两个多重采样(第一步的Blocker Search和第三步的PCF),这使得算法速度较慢。

为了避免多重采样的计算,Variance Soft Shadow Mapping(VSSM) 假定一定范围内的深度的分布符合 正态分布(Normal Distribution) ,那么只要知道该段范围的 均值(实际上就是期望值)E方差 Var,就能先得到该范围的正态分布模型(即知道对应的 概率密度函数 PDF)。

\(PDF(x) = \frac{1}{\sqrt{2 \pi} \sigma} \exp \left(-\frac{(x-\mu)^{2}}{2 \sigma^{2}}\right)\)

其中,\(\mu = E\)\(\sigma^2 = Var\)

接着可以通过该正态分布模型的 累计分布函数(即 CDF),就能快速推算出该范围内有多少比例的 x 大于(或小于)给定的某个值。

\(CDF(x) = \int^x_{-\infin} PDF(t) \mathrm{d}t\)

Variance Soft Shadow Mapping(VSSM) :简单来说,VSSM 算法就是依据 ShadowMap 的深度符合正态分布的假设来快速完成 PCSS 中的第一步(Blocker Search)和第三步(PCF算法)的一种阴影算法。

VSSM效果图:

计算平均值 & 方差

为了快速查询得到某段范围的均值、方差,我们可以先选以下一种数据结构来快速查询 Shadow Map 某段范围的均值(期望值)\(E(X)\)

  • 硬件 Mipmap:当 Shadow Map 更新时,需要重新生成 Mipmap,不过GPU硬件实现的 Mipmap 算法非常快的开销非常小;查询某段方形范围时,需要根据方形中心所在的位置(相对于周围四个纹素的坐标)、上下层级做三线性插值(Trillinear interpolation),得到的结果即是近似的均值(期望值)。

  • 前缀和数组(Summed Area Tables/SAT):当 Shadow Map 更新时,需要重新进行二维前缀和计算;需要编写 Compute Shader 实现该算法,比Mipmap方法更慢一些,但百分百精准;查询某段方形范围时,就可以通过如下图方法快速查询得到某段范围的总和,除掉范围面积就能得到均值(期望值)。

我们需要存储 \(E(X)\)\(E(X^2)\)​ ,这样就能计算某段范围的平均值、方差:

  1. 平均值 \(E(X)\)

  2. 方差 \(Var(X)=E(X^2)-E^2(X)\)

\(E(X^2)\) 即 ShadowMap 每个纹素再求个平方后作为额外的ShadowMap,然后再生成 Mipmap 或 SAT。

计算累计分布函数(CDF)

有了上面的期望值与方差,我们就能确定一个正态分布。但是它对应的 CDF 函数是没有解析解的,而有数值解(称为 Error Function),但是计算比较繁琐。

切克比夫不等式(Chebyshev’s Inequality)\(P(x>t) \leq \frac{\sigma^{2}}{\sigma^{2}+(t-\mu)^{2}}\)

实际上这个切克比夫不等式不仅可用在正态分布,其它的很多分布也是可以套用这个不等式的。

将这个不等式改造一下,就成了一个大胆的近似公式:

\(P(x>t) \approx \frac{\sigma^{2}}{\sigma^{2}+(t-\mu)^{2}}\)

注意:这里求的是 \(x>t\) 的部分,即 \(P(t)=1-CDF(t)\)

当然这个近似公式肯定不是精确的,但是计算开销非常小,也就被用在 VSSM 算法中。

加速 Blocker Search 算法

PCSS 算法中的 Blocker Search 步骤:在一定范围内多重采样,每次采样得到的深度若小于 \(d_{Receiver}\) 则认为遇到遮挡物并算入平均遮挡深度的贡献,这样多重采样之后得到的平均遮挡深度 \(z_{occ}\) 就作为 \(d_{Blocker}\)

如下图5X5的采样结果若设 \(d_{Receiver}\) 为7,那么平均遮挡深度 \(z_{occ}\) 则为红色部分的平均值。

设该采样范围的面积为 \(N\),无遮挡的面积占有 \(N_1\),有遮挡的面积则占有 \(N_2\) ,则有:

\(\frac{N_{1}}{N} z_{\text {unocc }}+\frac{N_{2}}{N} z_{o c c}=z_{A v g}\)

我们做出两个假设:

  • \(\frac{N_1}{N} = P(x>d_{Receiver})\)\(\frac{N_2}{N} = 1-P(x>d_{Receiver})\) ;这个假设基于认为深度分布为正态分布,通过切克比夫不等式获得近似解(即上面两节的内容)。

  • \(z_{unocc} = d_{Receiver}\) ;这个假设基于认为绝大部分没被遮挡的情况都属于同一个深度(相当于在同一个垂直于光方向的平面),即可认为均为深度 \(d_{Receiver}\)

那么 VSSM 加速该算法的公式表示为:

\(d_{Blocker} = z_{occ} = \frac{N\cdot z_{Avg} - N_1 \cdot z_{unocc}}{N_2} = \frac{E(x)-P(x>d_{Receiver})\cdot d_{Receiver}}{1-P(x>d_{Receiver})}\)

加速 PCF 算法

PCF 算法中的多重采样:每次采样得到的遮挡物深度用来与 \(z'\) 做大小比较(小于 \(z'\) 则视为被遮挡,大于 \(z'\) 则视为全亮),最后取比较结果的平均作为 Visibility。

我们做出一个假设:

  • \(不被遮挡的概率 = P(x > z')\) ;这个假设基于认为深度分布为正态分布,通过切克比夫不等式获得近似解。

那么 VSSM 加速该算法的公式表示为:

\(Visibility = P(x>z') \cdot 1 + (1-P(x>z')) \cdot 0 = P(x>z')\)

VSSM 的缺陷

VSSM 的主要缺陷表现:

  • 并不是任何深度的分布都是符合正态分布模型的,例如对于图右的简单几何体反而用正态分布表示会很不适合。
  • 漏光(Light Leaking)现象,在一些应当被阴影完全遮蔽的内部有可能仍产生亮度。

  • 在加速 Blocker Search 算法中的假设 \(z_{unocc} = d_{Receiver}\) 基于认为绝大部分没被遮挡的情况都属于同一个深度,但实际上有些不被遮挡的地方深度并不等于 \(d_{Reveiver}\)

Moment Shadow Mapping


Moment Shadow Mapping 正是为了解决 VSSM 缺陷的一种算法,它主要想法是:使用高阶的矩去描述一个分布的 CDF。这样就能通过记录 m 阶的矩,就能复原成足够接近实际 CDF 函数的效果,从而能适应不同的深度分布模型(有些地方可能接近正态分布,有些地方可能奇奇怪怪的分布)。

Moment Shadow Mapping将使用最简单的形式来标识矩:\(z,z^2,z^3,z^4,...\)

实际上,VSSM 本质便是记录 2 阶的矩来复原 CDF 函数,而 Moment Shadow Mapping 一般使用4阶的矩就已经足够接近实际 CDF 了。

虽然 Moment Shadow Mapping 效果相当不错,很好的解决了 VSSM 绝大部分缺陷,但是它仍需要相当的额外空间开销和重建矩的额外性能开销。

Distance Field Soft Shadows


Distance Field Soft Shadows 是与 Shadow Mapping 系列技术(PCF、PCSS、VSSM、Moment Shadow Mapping)截然不同的阴影技术路线,它主要想法是:

将点 \(o\)(Shading Point)与光源面中心点 \(p_{light}\) 相连形成一条方向为 \(l\) 的中心线段,而这条中心线上各个点 \(p_i\) 都可以通过 SDF 查得与其最近几何物体的距离并且推算出安全角度(点\(o\) 能打到光源面的直线与中心线的最大夹角)为 \(\theta_i = arcsin \frac{\operatorname{SDF}(p_i)}{p_i-o}\)

SDF 相关可以看几何(Geometry)部分,这里假定已经对场景生成了 SDF 信息。

那么所有这些点中对应的安全角度之中取最小的安全角度 \(\theta = min\{\theta_i\}\) ,这个安全角度与最大角度的比例决定了光源面的光照覆盖率,也就决定了点 \(o\) 的Visibility。

使用 Distance Field Soft Shadows 的好处很多:

  • 计算阴影很快(假设已经生成了SDF的情况下,比传统Shadow Mapping类技术是要快的多)
  • 阴影质量很高,而且完美解决 Shadow Ance / Peter Panning / 采样噪声等传统Shadow Mapping会出现的问题

然而代价是:

  • SDF 需要预计算,这就意味着场景物体需要是静态的,当然也可以使用一些算法使能和动态物体相结合,尽量减少重新生成SDF的成本。
  • SDF 需要较大的存储空间(一般采用三维数组表示空间各个网格的SDF值,但是可以使用八叉树等空间数据结构或者其它方法做进一步优化)。

计算安全角度

计算某个点 \(p_i\) 的安全角度时,直观的几何关系便是:

\(\theta_i = \arcsin \frac{\operatorname{SDF}(p_i)}{p_i-o}\)

而在实践中,往往会使用:

\(\theta_i = \min \left\{\frac{k \cdot \operatorname{SDF}(p_i)}{p_i-o}, 1.0\right\}\)

这样的近似公式实际效果相当接近原几何关系,而且也能减少复杂的 arcsin 运算开销,最后它还能通过 \(k\) 这个参数来调整阴影的硬软程度。

如下图分别为 \(k=32\)\(k=8\)\(k=2\) 的效果:

Distance Field Soft Shadows 算法过程

具体算法过程:

  1. \(o\) 点(shading point)设为第一个步进点,即 \(p_0 = o\)

  2. 每次算出下一个步进点 \(p_{i+1} = p_{i} + l \cdot SDF(p_{i})\) 并记录安全角度 \(\theta_i = \min \left\{\frac{k \cdot \operatorname{SDF}(p_i)}{p_i-o}, 1.0\right\}\)

  3. 重复 "步骤2",直到满足 \(l \cdot (p_{i+1}-p_{light}) < 0\) (即意味着已经步进到光源点背面了)

  4. 取所有次步进的最小安全角度 \(\theta = min\{\theta_i\}\) ,则可见度则为 \(Visibility = \frac{\theta}{c}\) (其中 \(c\) 为点 \(o\) 与光源面连接的最大角度)

参考


推荐阅读