shadow

阴影系统是一个渲染器的重要部分,对渲染品质有至关重要的作用。本文将介绍Ilum渲染器的阴影系统,针对渲染器目前支持的三种光源:聚光灯、平行光和点光源,我们分别有三种不同的阴影渲染策略:Shadow Map、Cascade Shadow Map和Omnidirectional Shadow Map,并通过实现PCF和PCSS两种软阴影滤波方式,提高阴影渲染的品质,后续有时间也可能将继续探索VSM、SDF Shadow等阴影渲染方法,目前我们只实现一个最基本的阴影系统。

1. 实时阴影渲染概述

实时渲染阴影主要是解决一个可见性的问题,将渲染方程引入可见性可以建模为:
$$
L_o(p,\omega_o)=\int_{\Omega^+}L_i(p,\omega_i)f_r(p,\omega_i,\omega_o)\cos\theta_iV(p,\omega_i)\mathrm d\omega_i
$$
利用实时渲染中一个常用的近似:
$$
\int_\Omega f(x)g(x)\mathrm dx\approx \frac{\int_\Omega f(x)\mathrm dx}{\int_\Omega \mathrm dx}\cdot\int_\Omega g(x)\mathrm dx
$$
可以得到:
$$
L_o(p,\omega_o)\approx \frac{\int_{\Omega^+}V(p,\omega_i)\mathrm d\omega_i}{\int_{\Omega^+}\mathrm d\omega_i}\cdot\int_{\Omega^+}L_i(p,\omega_i)f_r(p,\omega_i,\omega_o)\cos\theta_i\mathrm d\omega_i
$$
这样一来我们便将着色项和可见性项分开,对于上述近似,在特定情况下为完全精确:

  • $\Omega^+$范围很小,如点光源、方向光
  • 被积函数光滑、低频,如漫反射BSDF、均匀辐射率的面光源

所以,阴影渲染将解决的问题便是求解可见性项$\frac{\int_{\Omega^+}V(p,\omega_i)\mathrm d\omega_i}{\int_{\Omega^+}\mathrm d\omega_i}$,常用的方法包括:

  • 两Pass的阴影映射
  • 屏幕空间阴影映射
  • 光线追踪阴影
  • 有向距离场阴影
  • ……

这里我们只实现了基于两Pass的阴影映射方法,在这种方法下,对于一个带有光源的场景

image-20200821184459801

第一个Pass需要从光源出发,架设一台摄像机,生成从光源角度看到的场景深度图

image-20220316213721776

第二个Pass需要从主摄像机出发,通过采样光源深度图进行比较,计算各个着色点的可见性

image-20220316213839050

得到最终结果

2. 聚光灯阴影——Shadow Mapping

聚光灯阴影相对其他两个光源比较简单,因为聚光灯的位置与朝向是已知的,聚光灯的照明范围为一个圆锥体:

image-20220316213256815

因此我们需要的光源相机便很明确了,只需一个视锥体能够覆盖聚光灯锥体的透视相机,朝向与聚光灯方向相等,FOV与聚光灯的裁剪角相等即可。每一盏聚光灯只需一张深度图,实现过程中,我们希望在一个Pass内生成所有聚光灯的Shadow Map,这里我们将Render Target的层数设为当前支持的最大聚光灯数,利用Vulkan扩展shaderOutputLayershaderOutputViewportIndex(在Vulkan1.2中已升至Core特性),我们可以直接在vertex shader中直接对gl_Layer进行赋值,将渲染结果输出到目标的图层上(若未启用上述两个扩展/功能,则只能在geometry shader中写入gl_Layer),渲染完深度图后,我们将聚光灯Shadow Map作为TextureArray绑定到光照阶段的着色器上,进行可见性的计算和阴影的渲染:

  1. 先将世界空间内的着色点frag_pos变换到聚光灯相机的裁剪空间内clip_pos(乘VP矩阵)
  2. 对裁剪空间的着色点进行透视除法得到ndc_pos,同时将xy分量归一化到$[0,1]$,得到阴影采样坐标shadow_coord
  3. 利用shadow_coord采样Shadow Map,比较采样值与ndc_pos.z,若采样值小,说明光线在达到着色点前先打到了其他的物体上,因此着色点被遮挡,产生阴影

基本代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
vec3 spot_light_shadow(SpotLight light, vec3 frag_color, vec3 frag_pos, float layer)
{
vec4 shadow_clip = light.view_projection * vec4(frag_pos, 1.0);
vec4 shadow_coord = vec4(shadow_clip.xyz / shadow_clip.w, shadow_clip.w);
shadow_coord.st = shadow_coord.st * 0.5 + 0.5; // [-1, 1] -> [0, 1]
shadow_coord.t = 1.0 - shadow_coord.t; // Flip Y

return frag_color * sample_shadowmap(CascadeShadowMaps, shadow_coord, layer, vec2(0.0));
}

float sample_shadowmap(sampler2DArray shadowmap, vec4 shadow_coord, float layer, vec2 offset)
{
float shadow = 1.0;

if (shadow_coord.z > -1.0 && shadow_coord.z < 1.0)
{
float dist = texture(shadowmap, vec3(shadow_coord.st + offset, layer)).r;
if (shadow_coord.w > 0.0 && dist < shadow_coord.z)
{
shadow = 0.0;
}
}
return shadow;
}

实现效果:

无阴影:

no_shadow1

聚光灯Shadow Map:

shadowmap

聚光灯阴影:

shadow1

3. 平行光阴影——Cascade Shadow Map

平行光相对于聚光灯要复杂不少,平行光没有具体的光源位置,只有一个方向,且覆盖范围为无穷远,在场景搭建中常常用来表示日光等主光源。由于没有具体的位置与范围,我们很难进行投影摄像机的设置,好在已经有成熟的方案来帮助我们实现平行光的阴影,那就是级联阴影映射,即Cascade Shadow Map,aka CSM。

CSM的核心思想是根据当前主相机的视锥体进行架设投影相机,因为在主相机可见范围之外的深度判断没有意义,因此我们希望把深度信息用在刀刃上,只考虑主相机看得到的部分。一个最直接的想法是选择一个能够覆盖整个主相机视锥体的投影相机,但由于主相机可见范围可能很远,视锥体范围很大,相应的投影相机也会覆盖较大的范围,势必将会降低生成的Shadow Map的精度,而事实上用户往往只关心里自己近的渲染细节品质,大范围渲染一视同仁,将会带来不够让人满意的结果。而CSM则是对主相机视锥进行分割,对每一块都架设投影相机,这样一来既能保证覆盖主相机所有可见范围,又能保证近处的深度图精度。为了方便对齐,Ilum渲染器将Cascade级数设置为4,$n$个平行光需要$4n$张贴图,将其打包成TextureArray,每四张归于一个平行光。总结起来,CSM的基本步骤如下:

  1. 将视锥体$V$通过分割平面$\{C_i\}$分割为$m$份$\{V_i\}$
  2. 对每一份视锥体$V_i$计算平行光相机的View Projection矩阵
  3. 对每一份视锥体$V_i$生成一张Shadow Map $\{T_i\}$
  4. 在场景中渲染阴影结果

10fig02.jpg

3.1. 视锥分割

10fig03

假设单位像素在阴影贴图中的边长为$\mathrm ds$,由上图所示,模型表面的混淆误差定义为:
$$
\frac{\mathrm dp}{\mathrm ds}=n\frac{\mathrm dz}{z\mathrm ds}\frac{\cos\phi}{\cos\theta}
$$
其中$n$即相机到近平面的距离。

为了使得整个画面的阴影看起来质量一致,不会因为和相机的距离而出现明显的质量变化,我们应尽量使$\frac{\mathrm dp}{\mathrm ds}$项为一个常数,则余弦项系数$\frac{\mathrm dz}{z\mathrm ds}$也应保持常数,则有
$$
\frac{\mathrm dz}{z\mathrm ds}=\rho\Rightarrow s(z)=\int_0^s\mathrm ds=\frac{1}{\rho}\int_n^z\frac{1}{z}\mathrm dz=\frac{1}{\rho}\ln\frac{z}{n}
$$
又有$s(f)=1$,因此
$$
\rho=\ln\frac{f}{n}
$$
因此对于共计$m$次分割的第$i$个视锥体分割,有:
$$
s(z_i)=\frac{1}{\ln(f/n)}\ln\frac{z_i}{n}=\frac{i}{m}\Rightarrow z_i=n\left(\frac{f}{n}\right)^{i/m}
$$
image-20220317191201141

从上图中可以看到光锥体包含了很大一部分不可见的区域,造成阴影贴图的浪费,因此我们可以添加一个线性项来改善这个问题:
$$
z_i=\lambda n\left(\frac{f}{n}\right)^{i/m}+(1-\lambda)(n+(f-n)\frac{i}{m})
$$
这样一来,我们便得到一个采样相对均匀的视锥体分割方法

10fig04.jpg

实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float cascade_splits[4] = {0.f};

float near_clip = camera->near_plane;
float far_clip = camera->far_plane;
float clip_range = far_clip - near_clip;
float ratio = far_clip / near_clip;

for (uint32_t i = 0; i < 4; i++)
{
float p = (static_cast<float>(i) + 1.f) / 4.f;
float log = near_clip * std::pow(ratio, p);
float uniform = near_clip + clip_range * p;
float d = 0.95f * (log - uniform) + uniform;
cascade_splits[i] = (d - near_clip) / clip_range;
}

3.2. 计算光源的相机参数

image-20220318094640099

每一级Cascade的阴影相机应覆盖相应的视锥体,首先计算分割后的视锥体各个顶点坐标,在屏幕空间中,视锥体应为屏幕边界,由于Vulkan中的深度范围为$[0,1]$,因此八个顶点坐标分别为:

1
2
3
4
5
6
7
8
9
glm::vec3 frustum_corners[8] = {
glm::vec3(-1.0f, 1.0f, 0.0f),
glm::vec3(1.0f, 1.0f, 0.0f),
glm::vec3(1.0f, -1.0f, 0.0f),
glm::vec3(-1.0f, -1.0f, 0.0f),
glm::vec3(-1.0f, 1.0f, 1.0f),
glm::vec3(1.0f, 1.0f, 1.0f),
glm::vec3(1.0f, -1.0f, 1.0f),
glm::vec3(-1.0f, -1.0f, 1.0f)};

通过主相机的View Projection矩阵反变换到世界坐标中:

1
2
3
4
5
6
glm::mat4 inv_cam = glm::inverse(camera->view_projection);
for (uint32_t j = 0; j < 8; j++)
{
glm::vec4 inv_corner = inv_cam * glm::vec4(frustum_corners[j], 1.f);
frustum_corners[j] = glm::vec3(inv_corner / inv_corner.w);
}

再通过分割比例对视锥体进行裁剪:

1
2
3
4
5
6
for (uint32_t j = 0; j < 4; j++)
{
glm::vec3 corner_ray = frustum_corners[j + 4] - frustum_corners[j];
frustum_corners[j + 4] = frustum_corners[j] + corner_ray * split_dist;
frustum_corners[j] = frustum_corners[j] + corner_ray * last_split_dist;
}

得到当前级的视锥体后,可以计算其包围球:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
glm::vec3 frustum_center = glm::vec3(0.0f);
for (uint32_t j = 0; j < 8; j++)
{
frustum_center += frustum_corners[j];
}
frustum_center /= 8.0f;

float radius = 0.0f;
for (uint32_t j = 0; j < 8; j++)
{
float distance = glm::length(frustum_corners[j] - frustum_center);
radius = glm::max(radius, distance);
}
radius = std::ceil(radius * 16.0f) / 16.0f;

有了包围球,便能够很容易地求出相机参数了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
glm::vec3 max_extents = glm::vec3(radius);
glm::vec3 min_extents = -max_extents;

glm::vec3 light_dir = glm::normalize(light.direction);

glm::mat4 light_view_matrix = glm::lookAt(frustum_center - light_dir * max_extents.z,
frustum_center,
glm::vec3(0.0f, 1.0f, 0.0f));

glm::mat4 light_ortho_matrix = glm::ortho(min_extents.x,
max_extents.x,
min_extents.y,
max_extents.y,
-2.f * (max_extents.z - min_extents.z),
max_extents.z - min_extents.z);

light.split_depth[i] = -(near_clip + split_dist * clip_range);
light.view_projection[i] = light_ortho_matrix * light_view_matrix;

3.3. 渲染深度图

知道了各级相机参数,我们便可以进行深度图的渲染了,一个简单的想法是对所以四个Cascade级渲染四遍场景,事实上为了减少Drawcall,我们可以利用几何着色器在一个Pass中绘制四遍场景。

渲染方法延续此前的GPU Driven Rendering Pipeline中的Indirect Draw,不过在渲染深度图时没有做剔除操作,一方面是生成深度图开销较小,另外一方面是框架本身的局限性导致难以添加阴影剔除功能。

顶点着色器中,我们只需要简单地将实例的索引传给下一阶段的几何着色器即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#version 450

#extension GL_KHR_vulkan_glsl : enable
#extension GL_ARB_shader_viewport_layer_array : enable

#include "../../GlobalBuffer.glsl"

layout(location = 0) in vec3 inPos;
layout(location = 1) in vec2 inUV;
layout(location = 2) in vec3 inNormal;
layout(location = 3) in vec3 inTangent;
layout(location = 4) in vec3 inBiTangent;

layout(location = 0) out int outInstanceIndex;

void main()
{
outInstanceIndex = gl_InstanceIndex;
gl_Position = vec4(inPos, 1.0);
}

在几何着色器中,渲染四遍场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#version 450

#extension GL_KHR_vulkan_glsl : enable
#extension GL_ARB_shader_viewport_layer_array : enable

#include "../../GlobalBuffer.glsl"

layout (triangles, invocations = 4) in;
layout (triangle_strip, max_vertices = 3) out;

layout (location = 0) in int inInstanceIndex[];

layout(set = 0, binding = 0) buffer PerInstanceBuffer
{
PerInstanceData instance_data[ ];
};

layout(set = 0, binding = 1) buffer DirectionalLights{
DirectionalLight directional_lights[ ];
};

layout(push_constant) uniform PushBlock{
mat4 transform;
uint dynamic;
uint light_id;
}push_data;

void main()
{
mat4 trans = push_data.dynamic == 1? push_data.transform : instance_data[inInstanceIndex[0]].transform;

for (int i = 0; i < gl_in.length(); i++)
{
gl_Position = directional_lights[push_data.light_id].view_projection[gl_InvocationID] * trans * gl_in[i].gl_Position;
gl_Layer = int(push_data.light_id * 4 + gl_InvocationID);
EmitVertex();
}
EndPrimitive();
}

3.4. 渲染CSM

在渲染CSM时,先计算当前像素位于哪一级Cascade的范围,索引相应的深度图,再按照之前计算Spot Light的Shadow Map的方法进行计算可见性,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
vec3 directional_light_shadow(DirectionalLight light, vec3 frag_color, vec3 frag_pos, vec3 view_pos, float layer)
{
uint cascade_index = 0;

for(uint i = 0; i < 3; ++i)
{
if(light.split_depth[i] > view_pos.z)
{
cascade_index = i + 1;
}
}

layer = layer * 4 + cascade_index;

// Same as Spot Light ...
}

3.5. 实现结果

无阴影:

no_shadow2

Cascade Shadow Map:

Cascade #0 Cascade #1 Cascade #2 Cascade #3
csm0 csm1 csm2 csm3

渲染结果:

cascade

4. 点光源阴影——Omnidirectional Shadow Map

点光源阴影的核心思想是渲染一张以点光源为中心的阴影立方体贴图,因此需要渲染六遍场景,开销也比较大。

渲染相机方面,则是对点光源六个方向分别设置FOV为90°的透视相机,且远平面不宜过大,否则效果不佳,各个方向的相机参数通过如下计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
glm::mat4 get_point_light_shadow_matrix(const glm::vec3 &position, uint32_t face)
{
glm::mat4 projection_matrix = glm::perspective(glm::radians(90.0f), 1.0f, 0.01f, 100.f);

switch (face)
{
case 0: // POSITIVE_X
return projection_matrix * glm::lookAt(position, position + glm::vec3(1.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
case 1: // NEGATIVE_X
return projection_matrix * glm::lookAt(position, position + glm::vec3(-1.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
case 2: // POSITIVE_Y
return projection_matrix * glm::lookAt(position, position + glm::vec3(0.0f, 1.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f));
case 3: // NEGATIVE_Y
return projection_matrix * glm::lookAt(position, position + glm::vec3(0.0f, -1.0f, 0.0f), glm::vec3(0.0f, 0.0f, -1.0f));
case 4: // POSITIVE_Z
return projection_matrix * glm::lookAt(position, position + glm::vec3(0.0f, 0.0f, -1.0f), glm::vec3(0.0f, 1.0f, 0.0f));
case 5: // NEGATIVE_Z
return projection_matrix * glm::lookAt(position, position + glm::vec3(0.0f, 0.0f, 1.0f), glm::vec3(0.0f, 1.0f, 0.0f));
}

return glm::mat4(1.f);
}

在后续计算中,由于我们需要通过深度图还原出实际的世界深度,再与点光源和着色点之间连线的距离大小做判断,在实际开发中发现精确还原世界深度有一定难度,因此我们另辟蹊径,在片元着色器中计算点光源和阴影相机下着色点之间连线的距离,通过除以远平面进行归一化,赋值到gl_FragDepth,手动写入的深度信息不会进行深度校正,在后续计算中可以简单地进行采样并乘上远平面得到实际的世界深度。

光照阶段的着色器代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
float sample_shadowmap_cube(samplerCubeArray shadowmap, vec3 L, float layer, vec3 offset)
{
float shadow = 1.0;
float light_depth = length(L);
L.z = -L.z;
float dist = texture(shadowmap, vec4(L + offset, layer)).r;
dist *= 100.0;

if (light_depth > dist)
{
shadow = 0.0;
}

return shadow;
}

vec3 point_light_shadow(PointLight light, vec3 frag_color, vec3 frag_pos, float layer)
{
return frag_color * sample_shadowmap_cube(OmniShadowMaps, L, layer, vec3(0.0));
}

实现效果:

无阴影:

no_shadow3

立方体阴影贴图:

X+ X- Y+ Y- Z+ Z-
onmi_X+ onmi_X- onmi_Y+ onmi_Y- onmi_Z+ onmi_Z-

渲染结果:

omni

5. 实时阴影的效果提升

5.1. 自遮挡问题

image-20220318114924723

直接使用Shadow Map容易出现上图的自遮挡现象,自遮挡现象出现的原因是数值精度问题,如下图所示,

image-20220318115653757

由于阴影贴图分辨率有限,当距离光源比较远时,多个片元可能从深度图的同一个值中去采样,此时当光源以一个角度朝向表面时,不同的片元对深度图中同一个值可能出现差异,有些偏大,有些偏小,此时采样渲染结果将导致交错的黑白条纹,出现失真现象。而解决这个问题的思路也很简单,只需简单地给深度图增加一个偏置Bias即可,但当光线接近于平行入射时,依然会失败,一个更高效的方法是使用Slope Scale Bias,使得偏置正比于入射角。深度偏置可以在着色器中手动添加,也可以利用图形API驱动,调用Vulkan函数vkCmdSetDepthBias进行设置。深度偏置过小会导致自遮挡现象不能很好的消除,偏置过大则会出现漏光现象,事实上,Shadow Map的误差问题是可以进行定量计算的,即可以计算出自适应最优的Depth Bias,但在大多数时候使用固定的Bias即可解决问题,知乎上也有人提供了关于自适应Depth Bias的计算方法(链接),可以作为参考

5.2. Percentage-Closer Filtering(PCF)

直接通过采样阴影相机深度图得到的阴影结果通常为硬阴影,即边缘会有走样问题,如下图所示:

no_pcf

而且,实际光源常常具有一定的体积,产生阴影时往往会得到边缘较为柔和的软阴影,如下图所示:

image-20220318132949840

对于理想点光源,图中$P$点作为一个分界点,$P$点右侧无法被光源看到,表现为阴影;$P$点左侧能够被光源看到,表现为光照。而对于带有一定面积的光源,$P$点左右会被光源部分照射到,往左光照逐渐增加,往右逐渐减少,表现出来便是从阴影处到照明处有一段渐变,即软阴影。

而Shadow Map方法,我们难以捕捉到面积光这种特性,因此需要采用一些滤波方法来近似达到软阴影和反走样的效果,其中一个最简单的便是Percentage-Closer Filtering(PCF)。

PCF的思路是对阴影进行模糊,和普通的图像模糊一样,设计一个滤波核,采样邻域像素进行卷积即可,卷积核的大小和权重将影响PCF滤波结果。Ilum渲染器中利用均值滤波器,支持均匀随机采样与泊松盘随机采样,进行PCF滤波。

实现效果:

硬阴影 均匀采样PCF 泊松采样PCF
no_pcf uniform_pcf poisson_pcf

5.3. Percentage-Closer Soft Shadows(PCSS)

PCSS算法是PCF算法的一个改进,考虑了光源面积的大小,如下图所示:

image-20220318134625721

$w_{\mathrm{Light}}$为光源大小,$d_{\mathrm{block}}$为光源到遮挡物的垂直距离,$d_{\mathrm{receiver}}$为光源到着色点的垂直距离,根据简单的相似三角形原理,很容易得到关系:
$$
\frac{w_{\mathrm{Light}}}{w_{\mathrm{Penumbra}}}=\frac{d_{\mathrm{Block}}}{d_{\mathrm{Receiver}}-d_{\mathrm{Block}}}\Rightarrow w_{\mathrm{Penumbra}}=\frac{d_{\mathrm{Receiver}}-d_{\mathrm{Block}}}{d_{\mathrm{Block}}}\cdot w_{\mathrm{Light}}
$$
其中$w_{\mathrm{Penumbra}}$为PCF算法中的采样范围,PCSS考虑了光源面积,能够得到更加自然的软阴影,但同时计算开销也将增加,因为需要多次采样深度图

实现效果:

硬阴影 均匀采样PCSS 泊松采样PCSS
no_pcf uniform_pcss poisson_pcss

6. 效果展示

我们已经基本实现了各种光源的阴影渲染以及相应的软阴影优化,后续也将探索VSSM、SDF Shadow以及Moment Shadow Maps等更先进的软阴影技术,Ilum渲染器阴影渲染效果展示如下:

7. 参考资料

[1] Akenine-Moller, Tomas, Eric Haines, and Naty Hoffman. Real-time rendering. AK Peters/crc Press, 2019.

[2] GAMES202:高质量实时渲染

[3] GPU Gems3 Chapter 10

[4] https://johanmedestrom.wordpress.com/2016/03/18/opengl-cascaded-shadow-maps/

[5] Lloyd, Brandon, et al. “Warping and Partitioning for Low Error Shadow Maps.” Rendering Techniques. 2006.

[6] https://learnopengl.com/