一、从一次周一早晨的崩溃说起
去年十月份,我们项目的美术负责人老周在周一晨会上摔了鼠标。
场景是一个开放世界的峡谷关卡,阳光从山体缝隙照进来,地面铺满了风滚草和碎石。前一周 shadow 表现还算稳定,到了周一,帧时间从 14ms 暴涨到 28ms。问题在于:没人动过程序,也没人改过光照。美术只换了一片风滚草的材质。
我打开 stat VirtualShadowMapCache 看了一眼,Invalidated Pages 那一行在屏幕上疯狂跳动,峰值冲到总页数的 60%。再打开 r.Shadow.Virtual.Cache.DrawInvalidatingBounds 1,整片草原都被绿色包围了。
那片风滚草材质用了 World Position Offset 做随风摆动。对 VSM 来说,这不是普通的材质改动,而是缓存失效风暴的起点。
这个故事会贯穿整篇文章。理解了它,你就理解了 Virtual Shadow Maps 90% 的实战问题。
二、Virtual Shadow Maps 到底是什么
UE5.8 默认启用 Virtual Shadow Maps,新项目不需要手动开。它替代了 UE4 时代遗留的 Cascaded Shadow Maps、per-object shadow、pre-shadow、stationary 预计算阴影等一堆东西,统一成一条动态阴影管线。
VSM 的核心思路很直接:把阴影空间看成一张巨大的虚拟纹理,典型虚拟分辨率是 16K x 16K。它不会真的在显存里铺开这么大的图,而是把空间切成 128x128 的 Page,只渲染当前屏幕上被采样到的那些 Page。
方向光还会叠加 Clipmap,近处 Pages 分辨率更高,远处逐步降级。局部光则使用更简单的虚拟金字塔。所有 Page 都管理在一个统一的 Page Pool 里。
architecture-beta
group shadow_space(cloud)["Virtual Shadow Space 16K²"]
service page_1(service)["Page 128x128"] in shadow_space
service page_2(service)["Page 128x128"] in shadow_space
service page_3(service)["Page 128x128"] in shadow_space
service empty_1(internet)["未分配 Page"] in shadow_space
service empty_2(internet)["未分配 Page"] in shadow_space
end
group page_pool(database)["物理 Page Pool"]
service phys_1(service)["物理 Page 1"]
service phys_2(service)["物理 Page 2"]
service phys_3(service)["物理 Page 3"]
service phys_free(service)["空闲 Page"]
end
page_1:B --> T:phys_1
page_2:B --> T:phys_2
page_3:B --> T:phys_3这张图看起来简单,但它把 shadow 从「固定分辨率的大图」拉进了「按需分配」的时代。画面外、阴影后、屏幕外的空间,不再占用 Page。省下来的显存和带宽,换来了近处每像素阴影的精度。
三、传统 CSM 的痛点,VSM 怎么解
用 CSM 的项目都会遇到几件事:
- 镜头转动时,远处阴影闪烁或「游动」。
- 第一级 cascade 不够用,角色脚下很清晰,一转身远处的建筑边缘全是锯齿。
- 级数开多了,shadow map 分辨率被摊薄;开少了,远处直接没有动态阴影。
- 每个方向光要维护几张甚至十几张 shadow map,内存按兆字节往上走。
CSM 把视锥沿深度切成几段,每段给一张固定分辨率的 shadow map。第一级覆盖 0-10 米,第二级 10-50 米,依此类推。这个分法很粗暴:很多像素其实根本用不到,但资源已经分配了。
VSM 把这个问题反过来看。它不关心你分了几个 cascade,它只看屏幕上的像素需要采样 shadow space 的哪些区域,然后只给这些区域分配 Page。
用公式表达,Page 的需求量大致和屏幕上可见阴影像素的数量成正比:
其中 是第 个阴影投射体在屏幕上的投影面积, 是 Clipmap 层级带来的冗余系数。这个公式不是引擎里的真实代码,但能帮你建立直觉:Page 开销绑定的是屏幕上可见像素,不是场景里有多少几何体。
四、Page Pool 是怎么管理的
UE 里管理 Page Pool 的核心数据结构可以理解成三层:
- 虚拟地址层:每个光源在 shadow space 中占一段虚拟 Page 网格。
- 分配表:记录哪些虚拟 Page 被映射到物理 Pool 里的哪一页。
- 物理 Page Pool:真正存放深度图块、法线或矩信息的显存块。
方向光的 Clipmap 有近 20 层。每一层覆盖不同距离,近层单个 Page 对应世界空间很小的区域,远层一个 Page 可能覆盖几十米。所有层都竞争同一个物理 Page Pool。
flowchart TD
A[GPU 分析 Depth Buffer] --> B{需要哪些 Page}
B -->|未缓存或已失效| C[从 Page Pool 分配物理页]
B -->|命中缓存| D[复用上一帧 Page]
C --> E[Nanite/非 Nanite 几何光栅化到 Page]
D --> F[采样 Page 计算阴影]
E --> G[标记 Page 为已渲染]
F --> H[下一帧继续参与缓存]
G --> HPage Pool 的大小由 r.Shadow.Virtual.PagePoolCapacity 控制。默认值在 PC 上通常是 4096,也就是 4096 个 128x128 的物理页。按每个页存 depth 16 bit 来算,Pool 本身大约 128MB 上下。这个数不是越大越好,Pool 膨胀会挤占纹理流送、Lumen Surface Cache 和 GBuffer 的预算。
Pool 满了以后会发生什么?引擎会按 LRU 或最近未使用策略踢掉旧 Page。如果你的场景有大量植被,而植被因为 WPO 一直在失效,那么可用 Page 会被频繁换出,新 Page 又得重新渲染。这就是老周周一早晨看到的现象。
五、缓存:VSM 的命根子
VSM 能跑起来的前提是缓存。每一帧只渲染那些新出现的、失效的或被遮挡后重新暴露的 Page,其余全部复用上一帧结果。没有缓存,VSM 就是一张 16K 的阴影图,谁都跑不动。
缓存分两块:
- 动态缓存:给可动物体用的 Page,每帧都可能失效。
- 静态缓存:给静态几何用的 Page,只要灯光和几何不动,理论上可以一直保留。
UE5.4 之后引入了 Separate Static Caching,默认开启。它的意义是把动态物体和静态物体的 Page 分开管。风滚草摆动只会失效风滚草自己的 Page,不会把山体和建筑的静态 Page 一起拖下水。
每个 Static Mesh Actor 上有一个 Shadow Cache Invalidation Behavior 属性,常见选项:
- Static:如果几何和材质不动,Page 不失效。
- Dynamic:每帧都当作动态处理,开销最大但最保险。
- Rigid:允许 Transform 变化,但不响应顶点动画。
老周的风滚草应该设为 Dynamic,而地面、岩石、建筑设为 Static。风滚草摆归摆,别拖整片草原陪葬。
sequenceDiagram
participant Cam as 相机
participant CPU as 渲染线程
participant GPU as GPU
participant Cache as VSM Cache
participant Pool as Page Pool
Cam->>CPU: 提交 Depth Buffer 分析
CPU->>GPU: 计算所需 Page 列表
GPU->>Cache: 查询 Page 是否有效
Cache-->>GPU: 返回命中/失效
GPU->>Pool: 未命中则分配物理页
Pool-->>GPU: 返回物理页索引
GPU->>Cache: 写入新渲染 Page
Cache-->>CPU: 提交下一帧依赖六、缓存失效的几大元凶
我在几个项目里排查 VSM 性能问题,失效原因基本逃不出这几类:
1. World Position Offset 动画
任何材质里有 WPO,哪怕只是微风摆动,VSM 都会默认把对应 Actor 当成动态。植被、旗帜、水面最容易中招。
2. Transform 每帧变化
蓝图驱动的浮空岛、旋转广告牌、程序化生成的障碍物,Transform 一变,Page 就失效。
3. 灯光属性变化
方向光的 Directional Light Intensity、Color、Rotation 任何一项每帧改,整个 Clipmap 链都会重绘。
4. Nanite Skeletal Mesh
UE5.6 之后 Nanite Skeletal Mesh 即使设置了 Rigid 也会失效 VSM,这是引擎已知问题。遇到角色用 Nanite Mesh 投阴影时,要格外留意。
5. 边界框过大的物体
一个巨大合并 Mesh 的 Bounds 覆盖了半个地图,只要它上面有任何 WPO,会导致大量 Pages 被标为失效。
调试时我最常用的命令:
; 查看每帧失效页数
stat VirtualShadowMapCache
; 用绿色画出所有触发失效的物体边界
r.Shadow.Virtual.Cache.DrawInvalidatingBounds 1
; Page 可视化:红色=新分配,绿色=缓存命中
r.Shadow.Virtual.Visualize 1
r.Shadow.Virtual.Visualize 2
; 显示 Page 在虚拟空间中的分布
r.Shadow.Virtual.Visualize 3七、SMRT 软阴影与 Page 开销的权衡
VSM 除了分辨率高,另一个卖点是 Shadow Map Ray Tracing,简称 SMRT。它用光线在 shadow map depth 上行走,生成带半影的软阴影。光线步数由 r.Shadow.Virtual.SMRT.SamplesPerRay 和 r.Shadow.Virtual.SMRT.RaysPerPixel 控制。
这里有个我经常跟 TA 解释的观点:SMRT 的软不是后期模糊出来的,而是真正根据遮挡物距离采样出来的。遮挡物离投影面越近,半影越硬;越远,半影越软。这个物理上更合理,但也更吃性能。
r.Shadow.Virtual.SMRT.RaysPerPixel 默认值是 8,室内或近景特写可以提到 16 甚至 32,阴影边缘会非常干净。开放世界大方向光我建议降到 4 或 6,把预算让给 Page Pool。r.Shadow.Virtual.SMRT.SamplesPerRay 默认 16,低平台可以降到 8。
SMRT 和 Page Pool 是竞争关系。软阴影采样越多,每个 Page 被采样的次数越多,缓存命中率的重要性就越高。如果 Page 本身一直在失效,SMRT 只是雪上加霜。
还有一个容易忽略的点:SMRT 对 Page 边缘的精度要求更高。如果 Page 因为 Pool 不足被压缩或降级,软阴影会出现颗粒感。这时很多人会误以为是 SMRT 采样不够,继续加 RaysPerPixel,结果性能崩了,问题却没解决。正确做法是先确保 Pool 和缓存稳定,再调 SMRT。
八、局部光的 Page 预算
方向光因为有 Clipmap,大家讨论得最多。局部光其实也在用 VSM,而且更容易失控。
点光源和聚光灯的 VSM 是一个虚拟立方体或金字塔,每个光源有自己的 Page 预算。r.Shadow.Virtual.PointLight.PageBudget 和 r.Shadow.Virtual.SpotLight.PageBudget 默认值通常是 128 或 256。一个场景里如果放了三十个点光源,即使每个只分 128 页,总量也不小。
局部光的陷阱在于重叠。两个聚光灯照在同一片区域,它们各自的 Page 会分别申请,Pool 压力翻倍。更麻烦的是,局部光的 Page 优先级通常不如方向光,Pool 紧张时会被先踢掉,导致局部阴影每帧都在重绘。
我的习惯是:
- 主方向光保证预算。
- 次要局部光能关 shadow 就关,尤其是只影响很小区域 decorative light。
- 必须开 shadow 的局部光,控制
Light Function和 attenuation radius,别让 Page 覆盖到不需要的地方。 - 室内场景局部光密集时,适当提高 SpotLight PageBudget,同时压低 PointLight 预算。
九、Page Pool 到底该设多大
这是被问得最多的问题。答案不是越大越好,而是要看你的场景和平台。
Pool 里的每个 Page 是 128x128。如果存 16 bit depth,一页大约 32KB。4096 页就是 128MB。UE 实际还会存法线、矩信息或其他辅助数据,真实占用会更高。
判断 Pool 够不够,不要只看显存占用,要看 stat VirtualShadowMapCache 里的 Evicted Pages 和 Reallocated Pages。如果这两个数字长期不为零,说明 Pool 在频繁换页。此时有两个选择:
一是扩大 Pool,把 r.Shadow.Virtual.PagePoolCapacity 往上调 20%-50%。二是减少 Page 需求,例如降低 Clipmap 层数、缩短 shadow distance、减少局部光数量。
我个人倾向先优化需求再扩容。显存是全局资源,Pool 吃多了,Lumen Surface Cache 和纹理流送就会挨饿。有一次我把 Pool 从 4096 提到 8192,VSM 稳了,但纹理流送开始爆池,整体画面反而更差。
九、远景阴影退化:别跟距离较劲
VSM 不是无限精度。方向光的 Clipmap 层级越远,单个 Page 覆盖的世界空间越大。远处物体在 shadow space 里只占几个像素,阴影自然会变软、变粗。
这不是 bug,是 VSM 的设计。远处阴影退化有下面几种处理方式。
1. 合理设置 Dynamic Shadow Distance
方向光的 Dynamic Shadow Distance Movable Light 决定 VSM 最远覆盖多远。默认值在 Epic 品质下是 20000 cm,也就是 200 米。开放世界可以拉远,但 Page 需求会随之增长。
我建议按平台分档:
- 高端 PC / PS5 / XSX:300-500 米
- 中端 PC / XSS:150-250 米
- 低端或植被密集场景:100-150 米
2. 与 Distance Field Shadows 混用
Distance Field Shadows 不会被 VSM 替代。对于非 Nanite 的远景物体,比如传统 LOD 树木、地形远景,可以让 DFS 接棒。VSM 负责近处和 Nanite 几何,DFS 负责远处大轮廓。
开启方式是在 Project Settings > Rendering > Generate Mesh Distance Fields 勾选,并在方向光上设置合适的 DFS 距离。
3. 控制 Clipmap 层数
r.Shadow.Virtual.Directional.LightDirectionalClipmapSize 和 r.Shadow.Virtual.Directional.ClipmapLevelsMax 控制 Clipmap 的分辨率和层数上限。层数越多,远处覆盖越平滑,但 Pool 压力越大。
4. 远裁剪与雾的配合
远处的阴影退化可以被大气雾、高度雾、体积雾自然掩盖。只要退化不发生在清晰可视的范围内,玩家通常察觉不到。
graph LR
A[相机附近 0-50m] -->|VSM Clipmap 0-3| B[高精度每像素阴影]
B -->|VSM Clipmap 4-8| C[中距离软化阴影]
C -->|VSM Clipmap 9+| D[远景粗粒度阴影]
D -->|Distance Field Shadows| E[超远景轮廓阴影]
E -->|Fog / Haze| F[完全遮蔽]十、Nanite 与 VSM 的耦合
VSM 是为 Nanite 设计的,Nanite 也是为 VSM 优化的。两者共享一套「只渲染可见像素」的哲学。
Nanite 在渲染阴影时不会走传统 draw call 路径。它通过 cluster culling 直接把可见三角形推到 VSM Page 里。这意味着即使场景里有几千万个三角形的建筑,只要它在屏幕上只占一小块,GPU 实际处理的几何量就很低。
反过来看,如果你项目不用 Nanite,VSM 基本不建议开。没有 Nanite 的几何剔除,传统网格进 VSM 要跑完整 shadow pass,Page 需求和 draw call 都会爆炸。
这里有个细节:Nanite 几何总是渲染到 VSM,不受 Dynamic Shadow Distance 影响。想让非 Nanite 几何也这样,需要关闭 r.Shadow.Virtual.UseFarShadowCulling。但除非你很清楚代价,否则别开。
architecture-beta
group nanite(cloud)["Nanite 管线"]
service cluster(service)["Cluster Culling"]
service raster(service)["Software/HW Raster"]
end
group vsm(server)["Virtual Shadow Maps"]
service page_alloc(service)["Page Request"]
service phys_pool(database)["Physical Page Pool"]
service shadow_filter(service)["Shadow Filtering"]
end
cluster:R --> L:page_alloc
raster:R --> L:phys_pool
page_alloc:B --> T:phys_pool
phys_pool:B --> T:shadow_filter十一、控制台变量速查表
下面是我平时调试和分平台调优时会改的一组变量,适合放进 DefaultScalability.ini 或平台专属配置。这些数值不是圣经,要按实际场景压测后再锁死。每次调完我都会截一张 stat VirtualShadowMapCache 的图存到版本控制里,方便后面复盘。
[/Script/Engine.RendererSettings]
r.Shadow.Virtual.Enable=1
r.Shadow.Virtual.PagePoolCapacity=4096
; 远景裁剪,越低性能越好,阴影距离越短
r.Shadow.Virtual.Directional.ClipmapLevelsMax=12
; 局部光 Page 预算
r.Shadow.Virtual.PointLight.PageBudget=256
r.Shadow.Virtual.SpotLight.PageBudget=256
; 非 Nanite 远景物体用回传统裁剪
r.Shadow.Virtual.UseFarShadowCulling=1
; 调试开关,发布时务必关闭
r.Shadow.Virtual.Cache.DrawInvalidatingBounds=0
r.Shadow.Virtual.Visualize=0r.Shadow.Virtual.SMRT 控制软阴影质量。数值越高,Sample 数越多,阴影边缘越柔和也越贵。室内小光源可以拉高,户外大方向光可以适当降低。
十二、实战:老周的峡谷最后怎么救的
回到开头那个周一。我们按下面步骤把帧时间压了回去。
第一步,用 stat VirtualShadowMapCache 确认是失效风暴,Invalidated Pages 占比高到异常。
第二步,开 r.Shadow.Virtual.Cache.DrawInvalidatingBounds 1。绿色边界集中在风滚草区域,山体和岩石是干净的。
第三步,批量选中风滚草和随风摆动的灌木,把 Shadow Cache Invalidation Behavior 从默认的 Static 改成 Dynamic。同时把 Static 留给了真正不动的石头和建筑。
第四步,在风滚草材质里加 Shadow Pass Switch 节点,让阴影 pass 里的 WPO 强度减半。这样视觉上摆动还在,但每帧覆盖的 Page 变化小了很多。
第五步,把 r.Shadow.Virtual.PagePoolCapacity 从 4096 提到 6144,避免 Pool 过小导致 LRU 抖动。
第六步,远景方向光的 Dynamic Shadow Distance Movable Light 从 300 米降到 200 米,超出部分交给 Distance Field Shadows。
调整之后,Invalidated Pages 稳定在 5% 以下,帧时间回到 15ms 左右。老周表示可以接受,但再三强调以后换植被材质之前要先打招呼。
十三、避坑清单
- 大片水面不要开 Cast Shadow,水波纹的 WPO 会把 VSM 缓存冲烂。
- 粒子系统里的 skeletal mesh 粒子如果投阴影,要确认
Shadow Cache Invalidation Behavior是否合适。 - 用蓝图驱动 Transform 的物体,尽量把更新频率降低,别每 Tick 都改。
- Landscape 默认可能是 Static 缓存行为,但如果地形材质用了 WPO 做风吹草动,考虑单独处理或限制 WPO Disable Distance。
- 合并大 Mesh 时要考虑 Bounds,一个覆盖半个地图的静态体如果失效,代价是灾难性的。
- 不要在同一场景里混用大量非 Nanite 植被和 VSM,这种情况 Distance Field Shadows 往往更稳。
- 升级 UE 小版本后,务必重新跑一遍
stat VirtualShadowMapCache,缓存行为有时会在补丁里变化。
十四、写在最后
Virtual Shadow Maps 是 UE5 渲染管线里最聪明也最娇气的部分之一。聪明在于它终于把 shadow 开销和屏幕像素挂钩;娇气在于它对缓存失效极其敏感,一个材质、一个 Blueprint、一个 WPO 都能让性能翻船。
理解了 Page Pool、缓存失效、Nanite 耦合和远景退化这几条主线,再遇到 VSM 问题就不会像老周那样摔鼠标。打开 stat VirtualShadowMapCache,先看失效页数,再画边界,八成问题都能定位。剩下的两成,通常藏在某个 Blueprint 的 Tick 或某片材质的 WPO 里。
UE5.8 在 VSM 上的改动不算革命,但细节打磨了很多。对于正在做开放世界或高密度 Nanite 场景的团队,这套系统值得花时间吃透。毕竟,阴影 cheap 了,光照才敢放开手脚。
最后补充一个个人观点:不要指望把 CSM 那套经验直接搬到 VSM 上。CSM 是「我有多少分辨率,就切多少 cascade」;VSM 是「屏幕需要多少,我才分配多少」。前者靠手调距离和分辨率,后者靠控制失效和 Pool。 mindset 不一样,调试工具也不一样。转过来的前三个月,我几乎每周都在和风滚草、水面、 Blueprint 驱动的门较劲。熬过那段,再看 VSM 的 shadow,会觉得很值。这套系统不适合想一键搞定的团队,但适合愿意花时间打磨光影的人。