传统阴影贴图的矛盾是:分辨率给低了阴影边缘全是锯齿(透视走样,近处一个 texel 对应屏幕十几个像素),给高了显存爆炸且大部分 texel 永远用不上。Virtual Shadow Map(VSM)的思路和虚拟纹理完全一致——把超大的虚拟纹理和有限的物理显存用页表解耦:虚拟分辨率可以做到 16K×16K 甚至更高(每级 cascade),物理上只分配实际被用到的页。代码在 Engine/Source/Runtime/Renderer/Private/VirtualShadowMaps/(UE5.3)。
虚拟化的动机:阴影的 mip 分布极不均匀
先量化"传统级联阴影(CSM)"的问题。CSM 把视锥按距离切成 N 级,每级一张等大的贴图(比如 4K)。近处 cascade 覆盖的地面范围小、texel 密度高,远处 cascade 覆盖几公里,同样的 4K 摊上去,一个 texel 对应屏幕几十像素——透视走样,阴影边缘的锯齿宽度随距离线性增长。
缓解办法只有两种:增加 cascade 数量,或者提高单张分辨率。前者让级联间过渡带变多、CPU 侧计算开销上升;后者直接撞显存墙——4 级 8K RGBA 深度(实际用 16-bit 打包)就是 $4 \times 8192^2 \times 2 \approx 512\ \text{MB}$,而这 512MB 里每帧真正被采样的 texel 可能不到 5%。
VSM 的观察是:阴影贴图的空间局部性极强——只有"相机能看到且光源照得到"的区域才有 shadow map texel 存活。虚拟化让分配粒度精确到页(64×64 texel 一块),用多少分多少。
页表结构
每级虚拟 shadow map 是一棵四叉树式的 mip 层级:PhysicalPagePool(物理页池,所有级联共享一个池子)+ 每级一张 PageTable(虚拟地址 → 物理地址的映射纹理,未映射项标记为未分配)。
虚拟地址编码借鉴了 GPU virtual memory 的做法:虚拟页 ID = mip 级别 + 二维页坐标。GPU 采样阴影时(VirtualShadowMapSampling.ush)做三件事:
- 把世界位置转到光源空间,算出虚拟 UV;
- 按需要的 mip 级别查 page table,拿到物理页地址(或 miss);
- miss 时走"未分配"分支——返回"需要渲染"标记,不写阴影深度。
第 3 步是 VSM 和 VT 采样最大的不同:阴影的页 miss 不是显示一块模糊占位,而是触发一个渲染请求。下一节的缓存机制就是围绕"哪些页值得真的去渲染"设计的。
缓存:整帧收集需求,一次渲染
页 miss 产生的渲染请求不是来一个画一个,那样 GPU 会陷入"画一页 → 采样 → 发现邻页也没画"的串行化。VSM 把流程拆成两帧内的三个阶段:
- 需求收集(mark)。所有 shadow 采样点(base pass 的逐像素采样、以及光源视角的可见性 pass)把 miss 的虚拟页 ID 通过原子操作写进一个全局需求缓冲;
- 页分配与去重。GPU 侧把需求缓冲去重(同页多像素只留一条),按优先级(mip 级别低的、被采样次数多的优先)从物理池分配页,更新 page table;
- 阴影渲染(render pages)。按分配结果做一次 instanced 渲染:每个物理页对应一个 instance,instance 的视口被设置成该页在物理池中的位置,从光源视角重投影场景几何,深度只写进自己那 64×64 的页区域。
VirtualShadowMapRaster*.usf里的机制,一次 draw call 画完全部待更新的页。
一次渲染画所有页的好处是场景几何只被 culling 和提交一遍,代价是渲染粒度从"整张 shadow map"细化到"页",单页渲染的固定开销(instance setup、视口切换)靠 instancing 摊薄。
代价分布:钱花在哪
VSM 的显存确实省了,但性能账本要重新算:
- 每帧的页更新成本。相机移动 → 可见页集合变 → 新页要渲染。静止镜头下缓存命中率接近 100%(
Stat VirtualShadowMap看命中率),阴影渲染成本趋近于零;快速移动时命中率掉到 90% 以下,大量页要重画,帧时间直接起飞。这是 VSM 最反直觉的特性:阴影成本和相机速度耦合,而不是和场景复杂度耦合; - Nanite 的协同。VSM 的深度渲染走 Nanite 的 cluster 剔除结果(
r.Shadow.Virtual.UseNanite),页渲染复用主视角已经做过的剔除,避免光源视角重复剔除的 CPU 成本。这是 UE5 各子系统互相咬合的典型例子——单独看 VSM 不合理的地方(页粒度太小、更新频繁),靠 Nanite 的 GPU-driven 管线把固定开销压下去了; - 页表采样的额外指令。每次阴影采样多了一次 page table 纹理读取(外加 miss 分支的判断),比传统 CSM 的直接 UV 采样贵约 20-30% 的 ALU。全屏几千个阴影采样点累计起来是可见的开销,
Stat GPU里对比ShadowProjection项能看出来。
调参与排查
- 命中率是第一指标:
Stat VirtualShadowMap的Page Update一节,静止场景应稳定在 99%+,走路模拟下掉到 95% 以内就要查; - 池子大小
r.Shadow.Virtual.MaxPhysicalPages:不够时表现是新页分配失败、阴影直接缺失(不是变糊),排查时先看这个; - 远处阴影闪烁:页在 mip 级别间来回跳(thrashing),把
r.Shadow.Virtual.CacheStaticSeparate打开让静态几何的页独立缓存,稳定性会好很多; - 半透明阴影:VSM 对 translucent 物体的支持是单独的 depth-only 路径,性能敏感项目里
r.Shadow.Virtual.Translucent可以考虑关掉换帧率。
源码入口
- 页表与池管理:
VirtualShadowMapCache.cpp的FVirtualShadowMapCache,AllocatePhysicalPages是核心; - 页渲染提交:
VirtualShadowMapProjection.cpp的RenderVirtualShadowMaps,看 instanced 页渲染的 RDG pass 链; - 采样侧:
VirtualShadowMapSampling.ush的SampleVirtualShadowMap,注意 miss 分支的早退逻辑。
VSM 的思路可以一句话总结:把"阴影分辨率"从显存问题变成缓存问题。缓存问题的解法(页表、LRU、命中率)都是成熟套路,难的是和渲染管线的每一环(提交、剔除、采样)咬合得足够紧——这也是它直到 Nanite 落地后才在 UE 里成为默认方案的原因。