技术博客

Virtual Shadow Map:虚拟页表的阴影贴图

第 38 / 100 篇 · 2025-10-24 · ue5 · VSM阴影渲染源码分析

传统阴影贴图的矛盾是:分辨率给低了阴影边缘全是锯齿(透视走样,近处一个 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)做三件事:

  1. 把世界位置转到光源空间,算出虚拟 UV;
  2. 按需要的 mip 级别查 page table,拿到物理页地址(或 miss);
  3. miss 时走"未分配"分支——返回"需要渲染"标记,不写阴影深度。

第 3 步是 VSM 和 VT 采样最大的不同:阴影的页 miss 不是显示一块模糊占位,而是触发一个渲染请求。下一节的缓存机制就是围绕"哪些页值得真的去渲染"设计的。

VSM 的虚拟-物理映射:按需分配,共享池 虚拟地址空间(16K 级联) 实线=已映射 虚线=未分配 物理页池(共享,远小于虚拟) 命中:直接采样深度 Miss:发渲染请求 Page Table 纹理 虚拟页 ID → 物理地址 未映射 = 请求标记 Mipmap 层级独立映射 虚拟 16K² × 4 级联 ≈ 2 GB 等效分辨率,物理池常配 32-128 MB

缓存:整帧收集需求,一次渲染

页 miss 产生的渲染请求不是来一个画一个,那样 GPU 会陷入"画一页 → 采样 → 发现邻页也没画"的串行化。VSM 把流程拆成两帧内的三个阶段:

  1. 需求收集(mark)。所有 shadow 采样点(base pass 的逐像素采样、以及光源视角的可见性 pass)把 miss 的虚拟页 ID 通过原子操作写进一个全局需求缓冲;
  2. 页分配与去重。GPU 侧把需求缓冲去重(同页多像素只留一条),按优先级(mip 级别低的、被采样次数多的优先)从物理池分配页,更新 page table;
  3. 阴影渲染(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 里成为默认方案的原因。