实时阴影技术
实时阴影
硬阴影
对于点光源来说,它只会产生硬阴影。

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,而是带有渐变的取值。

上面这得出的最终
采样窗口越大,抗锯齿效果就约好,但是当采样范围变大时,采样的次数成平方次膨胀,开销就会很大,达不到实时渲染的要求,因此我们可以在采样范围内,随机抽取一定个数(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 是利用
函数特效实现的伪随机函数
// 伪随机函数
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 的一次坐标采样,换成对周围一定范围内若干个坐标进行采样
- 各个采样后的结果跟之前实际深度
做比较,最后去平均值作为 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(半影的大小)
根据半影的产生原因我们可以得出下面这个图
半影的大小取决于光源的大小(

: 半影的长度
: 阴影接收区域到光源距离
: 遮挡物到到光源距离
: 光源的大小
半影的长度可以看成是软阴影的范围
因此 PCSS 算法分为下面几个过程:
- Blocker Search:计算得出
- 计算得出半影大小
- 根据半影大小做 PCF
- 一般来说,Blocker Search 的时候不会只找单个像素点来获取
,会在像素周围一定范围内找遮挡,然后将所有是遮挡区域的深度求和再取平均值 - 对应大面积的灯光,理论上是不会产生硬阴影,因此一般会使用灯光的中心点作为点光源,生成 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:计算得出
- 根据半影大小做 PCF
VSSM 方差软阴影映射算法 (Variance soft shadow mapping)
加速 PCF 计算方案
从上面的介绍可以得知,PCSS 有两个耗时的地方,我们首先来看 PCF 这个耗时点。PCF 的本质是在特定区域内,对每一个像素都采样深度贴图,将采样的到的值跟当前实际深度对比,小于则返回 0 大于返回 1(例如 10 * 10 的区域内,有 40 个小于的,则最终返回 40 / 100),这个等价于找到当前区域内,给定一个深度

上图是把当前区域所有的像素值在 Shadow Map 上的深度做的直方图,横坐标表示当前深度值,绿色的区域表示当前深度的像素个数,个数越多,柱状图越高,深色区域表示深度大于等于 12 的像素个数
VSSM 的思想是,将某个区域的深度值近似的看成是正态分布,那对于一个正态分布我们只需要知道两个变量均值(期望)
: 深度图某个区域内像素的平均值 :另外一张深度图,记录的是深度值的平方,求给定区域像素的平均值 - 怎么快速计算指定区域内像素的均值,后面会讲
当我们有了期望跟方差后,就能得出一个正态分布的函数图,那我们之前要求的百分比

VSSM 为了求解上面的百分比,使用切比雪夫不等式来求解,切比雪夫不需要知道具体的分布函数的,不一定需要是正态分布。

:分布函数里的变量
:某个指定的值
: 标准差
: 均值
限制:
必须在均值右边,不然不准(实时渲染里,还是这么使用)
加速 Block Search 计算方案
现在回到第一步 Blocker Search 的计算上,我们有如下区域深度信息,当前像素光照位置的的真实深度是 7 则所有深度小于 7 的像素就是要找的 Blocker,即下图中的蓝色区域。

对于这个区域会有下面这个等式成立:
t : 当前深度
:所有深度小于 的深度平均值
: 所有深度大于等于 的深度的平均值
: 当前区域像素点个数
:深度大于等于 的像素个数
:深度小于 的像素个数
:当前区域所有像素的深度的平均值
沿用之前 PCF 中的假设
剩下
区域均值
综上所述,不管 PCF,还是 Blocker Search 加速方法,都需要计算某个区域内的像素的均值,均值的求解方法:
Mipmap
最简单的方法就是使用 Mipmap 来快速获取贴图上某个区域的均值,但是 Mipmap 获取的值也是通过插值获取的近似值(三线性插值)

SAT(Summed-Area Table)二维面积前缀和
先介绍一维的 SAT,如下图 SAT 存储的是当前位置之前所有的数的总和

当我们要求括号区域的值的平均值时,只需要找到区间前一个位置的和跟区间最后一个位置的和,做减法即可快速得到当前区间内的像素的和。
扩展到二维:
- 首先跟一维 SAT 一样,逐行计算每行的 SAT
- 然后再逐列遍历,计算每行的 SAT
然后我们要计算某个区域的平均值时,如下图蓝色框框是我们要计算的区域

VSSM 效果

VSSM 缺点
- 假设区域内像素分布为正态分布,如果采样区域不符合正态分布,阴影效果就不正确

右图的阴影分布呈现比较规则的分布,深度值会几种在三个面片附近,如下图所示,会有三个波峰

当估计值偏高时,如下图,则计算得出的 Visibility 的值偏大(1 为可见,0 为全黑),就会导致漏光,车底盘出现了漏光现象(Light Leaking)。

当估计值偏小时,得出的阴影会更黑,人眼不容易观察出来。
- 之前计算 Blocker Search 的时候, VSSM 大胆假设了投影接收物体是一个平面
,但实际上有些情况,投影接收物体不一定是个平面

左图中的几个面片是倾斜的,是的阴影接收面是一个斜面。
- 切比雪夫不等式应用上问题:大于均值区域不等式才成立
MSM(Moment Shadow Mapping)
MSM 主要是解决在 PCF 阶段,描述分布不准导致漏光的问题,VSSM用深度的均值
和方差

可以类比成泰勒展开,保留的次方越高,拟合效果越接近。
下面是 VSM(PCF 的优化版本, VSSM 是 PCSS 的优化版本)对比效果

Distance Field Soft Shadows(距离场软阴影)
之前在介绍文本的时候,介绍过 SDF 的文本,距离场也可以用在实时阴影中,假设我们已经知道场景中任何一个点到最近物体的距离场后,可以利用距离场近似计算软阴影,软阴影的产生其实是光源一部分光线被遮挡了,如下图,则半影的大小跟图中的

SDF 将阴影求解转换求解安全角度
当我们有了整个场景的 SDF 数据后,我们首先从渲染点

然后将所有圆跟起点

SDF 只能告诉我们当前点最近的遮挡物距离,但是不知道遮挡物的方向,因此这里对圆做切线,将切线处当成遮挡物位置(切线处角度最大)
因此
但是在实际中,会用下面这个式子来做近似
// 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)原理与实现
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本