基础Shadow Map
渲染流程
1. 从光源位置看向场景,渲染一张深度纹理(Shadow Map),记录每个像素的最小深度值(即光源到最近物体的距离)。
- 设置光源的视角和投影矩阵。对于方向光,通常使用正交投影;对于点光源/聚光灯,使用透视投影。

- 对方向光,最简单的shadowmap直接覆盖场景范围(和摄像机视锥无关了),生成一张 shadowmap
- 对点光源,需要创建一个光周围的深度值的立方体贴图,我们必须渲染场景6次:每次一个面。显然渲染场景6次需要6个不同的视图矩阵,每次把一个不同的立方体贴图面附加到帧缓冲对象上。
- 因此点光源性能开销较大
2. 在主摄像机渲染场景时,将每个像素的世界坐标变换到光源空间,比较其深度与阴影图中存储的深度:若当前深度大于阴影图中的深度,说明该点被遮挡,处于阴影中;否则被照亮。
普通阴影渲染的问题
阴影失真(Shadow Acne)

受阴影贴图的分辨率影响,当多个片段距离光源比较远的时候,它们可能从深度贴图中采样相同的深度值。图片中,每个斜坡代表深度贴图一个单独的纹理像素。你可以看到,多个片段会采样相同的深度值。 比如最左侧一黑一黄的片段都采样到一个shadowmap的纹素,但是把他们的世界坐标变换到光源空间比较深度时,就会导致黑色深度更小没被遮挡,而黄色深度更大被遮挡。
此时可以加一个阴影偏移(shadow bias),我们简单的对表面的深度(或深度贴图)应用一个偏移量,这样片段就不会被错误地认为在表面之下了:

但 bias 过大了可能会导致阴影悬浮(Peter Panning)

这个 bias 可以为常量 constant bias;不过我们发现,在斜面角度较大时,一个固定的偏移值就不再适用了,因此一个常见的改进,就是根据斜面角度来改变偏移值(偏移仍然是沿着光照的方向的),叫做 slope scaled depth bias / slop bias 。
Unreal 中,使用的 constant bias + slope scaled depth bias
另外一种常用的替代 slope scaled depth bias的方案是 normal offset bias。和 slop bias 不同, normal offset bias 将阴影的计算位置沿着物体表面的法线偏移。
Unity中,使用的是 constant bias + normal offset bias
走样(Aliasing)

因为深度贴图的分辨率固定,一个纹理像素可能覆盖了多个片段,结果就是多个片段会从深度贴图中采样相同的深度值,并得到相同的阴影判定结果,这也就导致了图中的锯齿边缘。
这块可以看: https://learnopengl-cn.github.io/05%20Advanced%20Lighting/03%20Shadows/01%20Shadow%20Mapping/#_7
Light Space Frustrum的计算
Shadowmap的效果,一般会非常依赖于 shadowmap 分辨率的大小和zbuffer的精度。因此我们要尽量提高 shadowmap的精度。
如果直接使用整个场景的AABB,转化到 Light Space,肯定是不行的,这样会造成很多不需要的阴影投射计算。
通常我们会将世界空间视锥的八个顶点,变换到光照空间,算出在光照空间下,最远和最近的z值,并计算出AABB边界。不过,这样也可能会造成另外一个问题,就是当摄影机的View Frustrum 很小时,造成计算出来的 light space frustrum 非常小,无法正确地投射所有需要投射阴影的物体:

因此我们还会根据整个场景的AABB空间,对得到的light space frustrum 进行扩展,使其能否覆盖到可能产生阴影的物体。当然,为了防止 light space frustrum 的 near plane 和 far plane的值相差过大,我们还会在光照中设置一个最大阴影距离,当阴影投射物体,超出这个最大距离后,就不再投射阴影,来提高阴影的精度。

Percentage Closer Filtering (PCF)
主要是想解决走样而非实现软阴影,但后来人们也想应用到软阴影上。
核心思想是多次采样深度贴图,每一次采样的纹理坐标都稍有不同,独立判断每个采样点的阴影状态后,将子结果混合取平均,最终获得相对柔和的阴影。
最简单的采样深度贴图周边纹理像素,并取平均值:
| |
因为这只是最终采样做的手段,所以也可以和 CSM 之类的阴影技术结合。
从信号角度来看,PCF 就是对是否是阴影的这么一个可见性函数进行低通滤波,在边缘处信号量变化剧烈,所以需要先低通滤波再去采样。
bias

bias cone,根据当前采样点到采样中心的位置,来缩放bias的大小
Receiver Plane Depth Bias,这种方式需要假定接受阴影的是一个平面,然后会根据每个阴影采样点到中心的位置,来计算偏移。
PCSS(Percentage closer soft shadows)
面光源->半影(Penumbra):


软阴影,思考之前那个半影的图,其实和阴影接受物的某个位置到阴影投射物的对应位置是近还是远有关。因此我们就想用根据这个距离值(我们叫做 blocker distance)来定义不同的 Filter size。这里的 Filter size 其实就是和 Wpenumbra 正相关的,例如 shading point 离 blocker 越近(例如上图的笔尖)阴影理应越硬,那么就不应该用很大的 Filter size(即 pcf 的搜索区域范围),而 Wpenumbra 越大则表示 shading point 离 blocker 越远,因此应该用很大的 Filter size

(注:面光源肯定不能搞一个shadow map出来,所以大家常先用点光源生成一个shadow map,再根据面光源参数来确定阴影的软硬)
PCSS流程
- 对shading point,找到对应的shadow map的那个像素,然后对这个像素周围的一块区域进行比较(比如 5 * 5,或者更好的用面光源的参数来算,如下图):若在阴影中则标记为blocker,然后对这些 blocker 取平均(如果被遮挡的话,shadow map记录的深度也就是 blocker 的深度了)

- 有了 blocker 的深度,我们就可以计算 filter 有多大,然后就回到了 PCF 的流程了。
CSM (Cascaded Shadow Maps)
针对方向光(如太阳)的大范围场景,CSM 是最常用的阴影技术。它将摄像机的视锥体按深度分割成多个级联(cascade),每个级联对应一张阴影图,覆盖近到远不同距离的区域。
渲染流程
1. 分割视锥体:根据深度将视锥体划分为若干区间(如4个)。分割方式可以是均匀分割、对数分割或混合分割(如practical split scheme,结合均匀和对数)。
2. 计算每个级联的光源正交投影矩阵
- 对每个级联,计算其包围盒在世界空间中的范围。
- 调整包围盒以匹配光源方向(通常让包围盒与光源方向对齐,但保持正交投影的轴对齐性质,或使用“Stabilize Cascades”技术避免阴影抖动如用包围球避免旋转的抖动)。
3. 渲染阴影图:为每个级联渲染一张深度图。
4. 主渲染
- 在片段着色器中,根据当前像素的深度确定它属于哪个级联。
| |
- 使用对应级联的阴影图进行阴影测试。
- 在级联边界处可能需要进行混合,避免突然的过渡。
阴影抖动
原因
1. 当摄像机移动或旋转时,视锥子区域发生变化,导致每个级联的投影矩阵随之改变。
- 平移变化:摄像机移动时,包围盒的位置平移。
- 缩放变化:视锥子区域的远近、宽高变化可能导致包围盒大小改变。
- 旋转影响:摄像机旋转时,视锥子区域的方向变化,其轴对齐包围盒会膨胀或收缩,导致投影矩阵的缩放因子突变。
- 这些变化使得同一个世界空间点在不同帧中映射到阴影贴图的不同纹素位置。即使场景完全静止,阴影比较结果也可能因为纹素偏移而改变,从而产生抖动。
2. 当物体从一个级联区域移动到另一个时,如果两个级联的阴影贴图分辨率或投影参数不同,阴影可能突然变化,造成跳变。
解决手段
Fit to scene vs. fit to cascade

上图左边为 Fit to Scene,每个CSM的包围盒都包围前一个包围盒,即CSM2包围CSM1,CSM3包围CSM2和CSM1,以此类推。可以解决镜头平移、缩放甚至旋转带来的采样点在Shadowmap的Texel中发生的平移和缩放。缺点是包围盒不够紧凑。
右边为 Fit to CSM
细节
不能用于点光源,且通常只有主光源(如太阳)用 CSM
理论上可以为场景中的每一个方向光都分配一组CSM,但这意味着渲染开销会成倍增长。因为每一组CSM都需要每帧为每个级联渲染一次场景。考虑到性能预算,这种做法非常奢侈。对于次要的方向光(如补光),通常使用简单的单张阴影贴图甚至不开启实时阴影,以平衡画面效果和运行效率。只有在包含多个主光源的复杂场景中,才有可能需要为每个主光源配置CSM。
CSM Caching
在使用CSM时,我们常常会遇到 CSM开销较大的问题,比如现在使用四级CSM级联,就意味着在生成shadowmap时,很多物体需要重复绘制四次。因此有的时候我们会对 CSM 进行一些优化。
一种方式是降低远处 CSM 的更新频率。比如在原神的PC版中,共有八级的CSM,前四级是每帧都更新的,后四级是逐帧依次更新的,这样相当于每帧需要更新五级的CSM。
另外一种方式是将 CSM 中算出的阴影动态缓存,对于静态物体的 shadowmap,是可以实现前后两帧之间的复用的。上一帧中静态物体的shadowmap,经过一些小小的处理,在当前帧仍然是可用的,对于一些没有覆盖的区域,可以动态来检测,重新绘制生成:

VSM (Virtual Shadow Maps)
这里的 V 即 Virtual,这个概念取自于Virtual Memory,类似的还有 VT (Virtual Texture)
虚拟超大纹理 + 分页(Page)管理 + 按需渲染 + 帧间缓存 + Clipmap / 级联适配。
只渲染屏幕可见区域对应的阴影页,用物理页池承载虚拟超大纹理,缓存复用,实现超高分辨率阴影。
细节
Shadow Map 划分
类似 VT 的方式,这里一般平行光用 Clipmap 形式,点光和聚光可以用 Mipmap 的形式。点光源每个方向一张VT,共6张,平行光和聚光灯每盏一个VT。
这里主要分析平行光的 Clipmap 形式,如下,中心为相机位置,白色为视锥范围:

UE 的 VSM 的 VT 大小默认16k * 16k,其中每个Page分辨率为128 * 128,共 128 * 128 个Page
Page 收集
每帧在主视图的Nanite流程走完后,可以得到主视图的深度图,将每个像素根据深度计算世界空间坐标再转换到光源空间得到像素在光源空间的位置, 再根据像素与相机之间的距离来计算合适的Clipmap层级,这样就可以知道这个像素需要哪级Clipmap上的哪个Page的Shadow信息:

这部分可以用 Compute Shader 批量统计:哪些虚拟页被至少一个像素引用 → 可见页集合。
渲染流程
Step 1:重置与标记脏页
- 清空上一帧可见标记,保留物理页映射与缓存。
- 遍历动态物体 / 光源,标记受影响的虚拟页为脏(Dirty),需重渲染。
Step 2:可见性收集(屏幕空间→光源空间)
- 对主相机深度缓冲做光源空间投影,为每个屏幕像素计算其在虚拟 SM 上的 UV 与页坐标。
- 用 Compute Shader 批量统计:哪些虚拟页被至少一个像素引用 → 可见页集合。
Step 3:物理页分配与映射
对每个可见页:
- 已分配且不脏 → 直接复用物理页(缓存命中)。
- 未分配 / 脏 → 从物理页池分配空闲页,更新页状态表的物理索引。
- 页池满 → 采用 LRU(最近最少使用) 替换策略,淘汰最久未使用的页。
Step 4:批量渲染阴影页(光源视角)
对所有可见且脏的虚拟页,执行光源视角光栅化:
- 每个页对应一个视口(Viewport),只渲染该页覆盖的场景区域。
- 深度写入物理页对应的 Atlas 位置,生成页级深度缓冲。
- 支持多线程 / 多队列并行渲染,大幅提升效率。
Step 5:阴影采样(主渲染阶段)
主相机渲染时,像素着色器:
- 将像素位置投影到光源空间,计算虚拟 SM UV。
- 从页状态表查询对应虚拟页的物理页索引与 UV 偏移。
- 采样物理页池 Atlas,获取深度值。
- 比较当前深度与采样深度,输出阴影 / 非阴影结果。
帧间缓存(阴影缓存)
物理页与映射关系跨帧保留,仅脏页重渲染。
支持缓存是VSM变得高效的重要因素。在光源不动的情况下,对于每个ShadowPage,其覆盖的世界空间范围相对较小,在这一帧ShadowPage绘制完之后下一帧如果检测当前Page范围内没有物体发生变化则直接复用上帧数据即可。
每帧在收集到屏幕上像素所需的所有Page之后,这些Page大致分以下几种情况:
- 新出现的Page,需要为其分配物理空间并绘制
- 已经缓存了的Page,但其上物体发生变化,需要重新绘制
- 已经缓存了的Page且其上物体没有变化,不需要绘制
HiZ遮挡剔除
VSM 通常要配合 HiZ 进行深度遮挡剔除。
HiZ = Hierarchical Z(层次化深度缓冲区),是游戏渲染里GPU 级遮挡剔除的核心技术,用来快速扔掉被挡住的物体 / 片元,大幅减少 GPU 无用计算。
原理
把深度图(Z‑Buffer)做成多级 Mipmap,每一层存对应区域的最大深度;用物体包围盒快速查高层(粗粒度)HiZ,只要物体最近深度 > 该区域最大深度,就直接判定被遮挡、不渲染。
渲染流程
- 生成基础深度图(Z‑Buffer)
- 先渲染场景主要遮挡物(墙、地面、大模型),只写深度、不写颜色,得到全分辨率深度图。
- 构建 HiZ 层级(Mipmap 金字塔)
- 从全分辨率开始,每级降采样 2×2 像素,取最大深度值作为当前像素值。
- 层级越高,尺寸越小、精度越粗,但覆盖区域越大。
- 例:1024×1024 → 512×512 → 256×256 → … → 1×1。
- 物体可见性判断(GPU 快速剔除)
对每个物体,计算屏幕空间包围盒(AABB / 球),得到它在屏幕上的大小。
按包围盒大小选对应 HiZ 层级(大物体查高层,小物体查低层)。
取物体最近深度(minZ),与 HiZ 该区域的 最大深度(maxZ) 比较:
minZ > maxZ → 物体完全被遮挡 → 直接剔除,不进光栅化 / 片元着色。
否则 → 可见,继续渲染。
- 渲染可见物体
- 只渲染通过 HiZ 测试的物体,跳过大量被遮挡的小物体 / 远处物体。
GPU Driven
HiZ 通常结合 GPU Driven 来做,如生成 HiZ 金字塔和可见性剔除都可以用 Compute Shader 来做。
参考
https://zhuanlan.zhihu.com/p/1973068100296527933
Contact Shadow
在屏幕空间进行逐像素的 RayMaraching,来得到高质量的近距离阴影。因为RayMarching的开销较大,Contact Shadow RayMarching的距离一般都很短,大约在0.1m~0.5m左右。
基本原理
从 Shading Point 位置往光源方向做 RayMarching,每一步 March 后根据此位置对 SceneDepth (Depth Buffer)进行一个采样(不是ShadowMap!),然后根据该位置本身的 深度(即射线上的深度 z 分量,由 Shading Point 深度与光源方向计算得出)与从Depth Buffer 采样的值进行一个对比,如果小于某个阈值则说明 Shading Point 被遮挡了。
优化
个人优化
加了一个bias减弱闪烁;光源方向(即Marching方向)和物体表面相切时会闪烁剧烈,我 通过深度反推出物体该位置的法线(Forward 下后处理不好拿法线),然后增加了法线和光 源方向的一个判断。
其他优化
使用 Compute Shader

可以看到ue也是用 cs 来进行的,代码位于 ScreenSpaceShadows.usf 中
Compute Shader 的优势(来源于deepseek):
更精细的线程调度与分组
在 Compute Shader 中,开发者可以精确控制线程组(thread group)的大小和共享内存(shared memory)的使用。
利用共享内存(Shared Memory):这是最关键的一点。Compute Shader 允许一个线程组内的所有线程访问一块极快的、由开发者管理的共享内存。在计算 Contact Shadow 时,可以把线程组对应的屏幕区块(tile)所需的深度数据,预先从全局显存(VRAM)加载到共享内存中。这样,线程在步进射线时,可以直接访问这块高速缓存,而不是每次都去慢得多的全局显存里读取。这极大地减少了带宽压力,是性能提升的核心。
更灵活的计算与访客模式
Compute Shader 可以打破 Pixel Shader 中“一个像素对应一个线程”的严格限制。
动态分支与负载均衡:虽然 GPU 的分支仍然有成本,但 Compute Shader 允许更灵活的逻辑控制。可以设计算法,让一部分线程处理简单的像素,另一部分处理复杂的像素,尽量减少线程束内的分歧。甚至可以重新组织任务,让计算负载更均衡。
非矩形调度:可以只对需要计算阴影的像素启动线程,而不是像 Pixel Shader 那样必须处理整个屏幕矩形范围内的所有像素。不过 Contact Shadow 通常还是需要全屏处理,所以这一点优势在这个场景下不是最主要的。
解耦与并行化
Compute Shader 作为一个独立的计算 Pass,可以与图形渲染管线并行执行(在支持异步计算的 GPU 上)。例如,可以在 GPU 渲染主场景的同时,让空闲的计算单元去计算 Contact Shadow。这种并行能力是传统、固定的 Pixel Shader 难以做到的。
提高资源利用率
通过精心设计 Compute Shader 的调度策略(例如让每个线程处理多个像素),可以更好地隐藏显存访问的延迟,让 GPU 的计算单元始终处于忙碌状态,从而提高整体吞吐量。
GBuffer存flag,按需进行

可以看到ue会判断当前像素是否要用 Contact Shadow(位于 DeferredShadingCommon.ush),所以 ue 会对一些物体有 Cast Contact Shadow 的开关。
例如 ULandscapeGrassType,就可以对每个 Grass Varieties 选择 Cast Dynamic Shadow 或者 Cast Contact Shadow,Contact Shadow 本身就是 UE 为解决 “细碎物体(草、小石子)无阴影导致的浮空感” 设计的轻量化方案,对 这些细碎的草如果是动态的不能烘培阴影,开启 Dynamic Shadow 开销会比较高,所以我们可以关闭 Dynamic Shadow 只开启 Contact Shadow 就行。
Variance soft shadow mapping
PCSS因为要多次采样,太慢了,于是出来了 Variance soft shadow mapping(VSMM),其实际上可以看成 PCSS 的快速版本,不过现在降噪手段越来越多,大家还是PCSS用的多一点?
VSMM的思想是,我不需要那么精准的数据,我只需要大概知道我的 shading point 对应的 shadow map 那一段采样区域内的平均值大概排什么水平(之前PCF的时候说过shadow map一块区域都会和shading point的深度值进行非零即一的比较),我们直接假设这一段分布是正态分布的,要获得正态分布的曲线我们只需要求均值和方差即可。
对shadow map的一个矩形区域快速求均值可以用 mipmap 或者 summed area tables(SAT)
那么求方差只需要按方差公式即可:

因此我们还需要额外的一张shadow map(其实可以直接记录在一张 RT 的不同的通道里,在生成shadow map的时候顺手往其他通道写入深度平方的数据)。
而有了均值方差之后,我们接着就是根据pcf的那个平均值,求出对应的 CDF 值(如下图),也就知道了有百分之多少的地方比我的深度小。

而这个积分求解可以直接通过读表来做,但其实VSMM用的是切比雪夫不等式(也就没有用正态分布公式了实际上):

重新回到PCSS流程,我们还需要知道 Blocker 的深度距离,我们应该用那一块区域内的遮挡深度求平均。
VSSM流程
例子:shading point 深度为7,PCSS 第一步的范围为 5 * 5,这个shadow map在该范围内的深度值如下图:

(这里假设可以通过 mipmap 或者 summed area tables(SAT)得到一个矩形区域内的深度平均值或者深度的方差)
- 第一个 pass,我们要生成 shadow map,生成的同时把深度的平方写入第二个通道
- 这一步实际上是优化PCSS的求bloker的步骤:获取shading point的深度值(假设为7)。我们有了这个 55 矩形的深度平均值,我们假设大于shading point的那些深度值都为7(即上图红色的所有值我们都当作7来算,再根据这个55内的深度平均值算出遮挡物的深度:
其实就是,PCSS第一步希望求 Zocc (即蓝色区域平均值),我们先假设 Zunocc 直接为 shading point 的深度值,然后通过该 5*5 矩阵的平均深度来计算出 Zocc,从而求出 PCSS 第一步的所需的那个 blocker distance (这样就避免了采样) - PCSS第二步,根据 blocker distance 计算出需要采样区域的大小(PCF 的 filter 的大小)。
- 对于第三步得出的区域(假设 7 * 7),得出这个区域的深度均值与方差,由切比雪夫不等式,可以知道这个区域大于shading point的深度的概率,也即 PCF 要求的那个值了。
快速求均值
之前说了可以用 mipmap 或者 summed area tables(SAT),用 mipmap 如果矩阵不是2的幂之类的就势必要双线性或是三线性插值,不太方便;我们可以用 summed area tables(SAT),就是一个经典的二维前缀和罢了?不过构建的时候原本是 O(n)的复杂度,但是可以写成并行去构建。
Moment shadow mapping
之前的 VSSM 存在的问题是,如果真正的分布并不那么正态,如下图,那么还用正态分布去描述的话就会出问题(阴影黑一点倒是没啥,人们不希望的是阴影突然变白了):

因此人们想要VSSM对遮挡分布的描述更准,于是引入更高阶的moments:

其实就是某个展开而已。
SDF 阴影
SDF 可以很好的做软阴影。软阴影由来就是一个面光源有一部分被挡住了,那么用SDF就可以近似得到大概有多少范围被挡住的一个信息,虽然不准,但是比较符合人们的观察。

参考 https://zhuanlan.zhihu.com/p/398656596
参考
https://learnopengl-cn.github.io/05%20Advanced%20Lighting/03%20Shadows/01%20Shadow%20Mapping/#_2
https://learnopengl-cn.github.io/05%20Advanced%20Lighting/03%20Shadows/02%20Point%20Shadows/
https://learnopengl.com/Guest-Articles/2021/CSM
https://zhuanlan.zhihu.com/p/53689987
https://www.zhihu.com/search?type=content&q=%E7%A8%B3%E5%AE%9ACSM
https://learn.microsoft.com/en-us/windows/win32/dxtecharts/cascaded-shadow-maps
https://zhuanlan.zhihu.com/p/104687855
https://zhuanlan.zhihu.com/p/1973068100296527933
GAMES202