从”普通程序员能听懂”到”能改 UE 渲染源码”——基于源码逐行考证的 VSM(虚拟阴影图)深度讲解,
附开放大世界优化 / 昼夜循环 / 动态太阳光三大专题

  • 文档版本:1.0(2026-08-22)
  • 源码快照:本地仓库 d:\Project\GameDevelop\UnrealEngine,UE 5.9 移动优化 fork,HEAD 6cea9bd20f8f
  • 写作原则:文中所有”源码考古”块引用的文件路径、行号、函数名均在本仓库中实际验证过;行号只用于定位,请以 路径:函数名 为准
  • 姊妹文档:本文是《Nanite 虚拟几何体完全教程》(D:\Project\GameDevelop\NaniteGuide\)的姊妹篇——VSM 大量复用 Nanite 的 GPU 驱动光栅化管线,建议先读 Nanite 文档第 2、9~11 章建立”页流送 + GPU 驱动”的直觉
  • 符号说明=Page,页表=Page Table,物理页池=Physical Page Pool,=Clipmap Level;Clipmap/SMRT/uncached/RasterBin 等不翻译

第 0 章 导读:这份文档怎么读

TL;DR|这份文档把 VSM(虚幻引擎 5 的虚拟阴影图系统)从”它是什么”讲到”源码长什么样”,
并用三章专题回答三个实战问题:开放大世界怎么优化、昼夜循环怎么做、动态太阳光怎么优化
心智模型与 Nanite 一脉相承:**阴影也是”虚拟内存”**——按需调页、LRU 淘汰、缓存复用。
有三个深度档位:30 秒看懂、3 小时学会、1 天读源码。

0.1 目标读者与前置知识

目标读者是普通程序员:会写 C++、用过 UE 或主流引擎、知道”深度缓冲””阴影贴图”是什么但不必是图形专家。文档沿用 Nanite 文档的惯例——每个 GPU 术语第一次出现都配一句直觉解释。

前置知识三个(与 Nanite 文档相同):

  1. 显存有限——数据放显存才能被 GPU 读,显存要省着用;
  2. 深度缓冲(Z-Buffer)——每个像素记一个”离相机多远”,阴影的本质就是”从光源视角记录谁挡了谁”;
  3. 每帧重算——游戏每秒 60 帧,阴影等一切渲染结果每帧都要重新决定。

与 Nanite 文档的关系:VSM 是 Nanite 的”影子兄弟”——Nanite 光栅化几何体时,VSM 告诉它”把阴影画进哪个虚拟页”。如果你没读过 Nanite 文档,先读它第 2 章(五大核心概念)和第 9~11 章(GPU 剔除与光栅化),再回来本文会非常顺。

0.2 三条阅读路径

通道 读什么 耗时 读完能做什么
🚀 30 秒通道 每章开头的 TL;DR + 类比 5 分钟 跟人解释 VSM 是什么、为什么比 CSM 适合大世界
📚 标准通道 TL;DR + 正文 + 图 + 三个专题章 4~5 小时 理解完整数据流,会调 r.Shadow.Virtual.* 优化大世界阴影、会搭昼夜循环
🔬 源码考古通道 全部,尤其”源码考古”块 1~2 天 敢打开 UE 渲染源码对照着读,能定位 VSM 任意子系统

0.3 块标记约定

与 Nanite 文档完全一致:

  • **TL;DR**(每章开头):30 秒读完本章,3~5 句、必须含类比名、不含源码路径;
  • **类比实验室**(正文小节):用普通程序员已懂的东西讲陌生概念;
  • **源码考古**(每章末尾,折叠块):贴真实源码(≤25 行/段),逐行翻译。引用格式:路径:行号(函数名)
  • **避坑**(正文穿插):版本差异、公开资料与当前代码不一致、易错点。

0.4 一张图看懂全局

图 F1 VSM 全链路数据流总览(六阶段 + 缓存回环;此图贯穿全文)
① 阴影设置(CPU) AddDirectionalLightShadow 创建 FVirtualShadowMapClipmap AllocateVirtualShadowMapIds 分配 VSM ID 槽位 缓存键比较(第 8 章) LightDirection 变了→失效 ② 页标记(GPU) BeginMarkPages 深度缓冲→像素→页请求 MarkCoarsePages 粗页预分配 页表标记"本帧要哪些页" GeneratePageFlagsFromPixels ③ 页分配(GPU) BuildPageAllocations KEEP_PAGE 复用缓存页 PageAddressOffset 页表平移 LRU 淘汰 + 新页分配 物理页池(2048 页) ④ 光栅化(GPU 双路径) Nanite 路径(VIRTUAL_TEXTURE_TARGET) 页翻译:虚拟页→物理页→写页池 非 Nanite 路径(逐页 draw command) 16384×16384 虚拟视口 PostRender 合并/清理/HZB ⑤ 投影(GPU) RenderVirtualShadowMapProjection 全屏 CS:页表查找+深度比较 SMRT 软阴影 + 屏幕空间 ray march 写 ScreenShadowMask / MaskBits 光照 pass 采样遮罩

⑥ 帧末缓存管理(CacheManager)

dirty flags 合并 / ExtractFrameData 持久化 / 失效传播(ProcessInvalidations)→ 影响 ①②③ 的下一帧

第 5~6 章
第 6 章
第 7 章
第 9 章
第 10 章

读图方法:从左到右五步是”一帧的路”:CPU 定好灯与缓存状态 → GPU 说”这帧需要哪些页” → GPU 从页池里把页分配好(复用旧的、新画缺的)→ 两条光栅路径把阴影深度写进页池 → 屏幕上的像素查页表取阴影。然后第 ⑥ 步的回环决定了明天的性能:哪些页要保留(缓存)、哪些页要作废(失效)——这正是三个专题章的核心战场。

0.5 章节地图

章节 一句话
开篇 第 0~1 章 VSM 是什么,为什么需要它
概念篇 第 2~3 章 六大核心概念 + 设计哲学(数字从哪来)
机制篇 第 4~10 章 页表 → clipmap → 标记 → 分配 → 缓存 → 光栅 → 投影
专题篇 第 11~13 章 开放大世界优化 / 昼夜循环 / 动态太阳优化(重点)
调优篇 第 14~15 章 平台与 CVar 速查、局限与展望
附录 A~D 源码地图、术语表、参考资料、自测题

第 1 章 VSM 是什么:阴影的”虚拟化”革命

TL;DR|VSM 是 UE5 的虚拟阴影图:把”每张灯一张固定分辨率阴影贴图”变成”一池可复用的
128×128 阴影像素页”,GPU 每帧只画”画面真正需要、且缓存里没有”的页——阴影成本从
“世界有多大”变成”画面变化有多少”。方向光用 6~22 级 clipmap(洋葱皮)覆盖从脚下
1 米到 80 公里的范围。心智模型:
阴影版的虚拟内存/虚拟纹理

1.1 传统阴影的痛点:CSM 的三座大山

先看 VSM 出现之前,大世界阴影的标准方案 CSM(级联阴影图,Cascaded Shadow Maps) 有多痛。

CSM 的思路:把相机前方的空间切成 3~4 个”级联”(近的精细、远的粗糙),每级渲染一张阴影贴图。听起来合理,但有三座大山:

第一座山:分辨率撕裂(Resolution Tearing)。 固定分辨率贴图覆盖固定距离——近处 2048² 贴 50 米和 100 米,每 texel 覆盖的世界面积差 4 倍,相机一移动,阴影边缘就会”抖”(texel 在世界空间移动)。级联切换处还有明显的分辨率跳变。

第二座山:每帧全量重光栅化。 CSM 每帧把所有阴影投射体(shadow caster)重新画一遍(RenderShadowDepthMaps 在每帧主流程里无条件执行),成本恒定——不管画面有没有变化。大世界测试数据(社区实测,非本仓库):阴影距离从 8000 拉到 15000,Shadow Depths 阶段从 2.1ms 涨到 4.8ms(ixueyouxi《UE5.8 大世界光照与阴影策略》)。在开放世界里,远处永远有东西——于是永远在画。

第三座山:局部光阴影数量受限。 每盏局部光一张阴影贴图 = 每盏光一次全屏重绘 + 一个纹理槽位。一个室内场景 20 盏灯 → 20 张图,显存和带宽双双爆炸。所以传统做法是”只有重要的灯开阴影”。

一句话总结 CSM 的困境:它在”内容端”做分辨率预算——把世界硬塞进固定数量的级联里,无论实际需要与否。

1.2 虚拟化的心智模型:从虚拟内存到虚拟阴影

Nanite 文档里讲过:Epic 的虚拟纹理把”纹理按页按需加载”,Nanite 把”几何体按页按需加载”。VSM 是同一思想的第三次应用:把”阴影贴图”按页按需渲染

虚拟内存 虚拟纹理 VSM 虚拟阴影图
被虚拟化的东西 内存字节 纹理像素 阴影像素
页粒度 4KB 128×128 像素 128×128 像素
页表 页表 页表纹理 页表纹理(128×128 页)
缺页事件 Page Fault 像素访问未加载页 画面像素需要的阴影页未渲染
调页 内核读盘 后台加载 GPU 计算着色器分配+光栅化
淘汰 LRU LRU LRU(MaxPageAgeSinceLastRequest=1000 帧)
缓存 页缓存 纹理页缓存 静态页缓存(100 帧规则)+ 级缓存平移

类比实验室:虚拟内存
老规矩,先想操作系统:程序假装有 10GB 内存,实际只有 4GB,访问哪个页才调哪个页。
VSM 假装场景有”无限大的阴影贴图”(方向光 16384×16384 texel),实际只有一池
2048 张物理页(128×128),画面需要哪张、且缓存里没有,才临时画哪张。
**”阴影”从此不再是”一整张图”,而是”一堆可复用的小块”**——这是全书最重要的心智转换。

1.3 一句话定义与四根支柱

VSM = 以”页(Page)”为粒度、以”页表 + 物理页池”为结构、由 GPU 计算着色器驱动分配、以”多级缓存 + 失效”实现跨帧复用的虚拟阴影图。

四根支柱(也是全书主线):

  1. 虚拟页表:每盏灯有一块巨大的”虚拟阴影贴图”地址空间(方向光 16384×16384),页表记录”虚拟页 → 物理页”映射——像虚拟内存的页表,映射关系每帧由 GPU 更新;
  2. 物理页池:所有灯共享一个 128×128 页的物理池(默认 2048 页,静态缓存时双层),页可以属于任何灯、任何级——池化 = 复用
  3. GPU 驱动页分配:页的标记、分配、复用、淘汰全在 GPU 上用计算着色器完成(Nanite 流送还有 CPU 参与,VSM 连 CPU 都省了);
  4. 多级缓存复用:跨帧复用的三个层次——级缓存平移(相机移动时整个 clipmap 级平移复用)、页级缓存(KEEP_PAGE + LRU)、静态页缓存(100 帧无失效转静态层)。缓存是 VSM 的性能灵魂,也是三个专题章反复出现的主题。

1.4 一点历史:从 2021 到 5.8

  • 2021.08:SIGGRAPH 2021《A Deep Dive into Nanite Virtualized Geometry》演讲中,Brian Karis 等首次公开 VSM 架构(”Shadows: Virtual Shadow Maps” 章节)——VSM 从设计之初就是 Nanite 的影子系统;
  • UE 5.0(2022):VSM 随 Lumen 一起登场,方向光 clipmap 路径成熟,局部光路径逐步完善;
  • 5.1~5.8:加入缓存静态层、SMRT 软阴影、PrefilteredDistant、OnePass 投影(MaskBits)、MegaLights×VSM(本快照)等;
  • 本仓库(5.9 移动优化 fork)对 VSM 零改动git diff 验证,VSM 代码全部来自上游);但移动端从不实际渲染 VSM——MobileShadingRenderer.cpp:1364-1365 只做占位初始化(见 14.1 的避坑)。

避坑:网上的旧教程(UE 5.0~5.2 时代)会教你开 per-light 的 CastVirtualShadowMapUseCachedShadowMap 或全局
bEnableVirtualShadowmaps——这两个开关在当前版本已删除。现在的唯一入口是项目设置
Shadow Map MethodRendererSettings.h:671,绑定 CVar r.Shadow.Virtual.Enable):
Virtual Shadow Maps 即启用,全局生效,没有 per-light 开关。判断教程新旧:提到
per-light 开关的一律按旧资料处理。

1.5 CSM vs VSM:一张图看懂差距

图 F2 CSM vs VSM(同一个开放世界场景的两种阴影命运)
┌─────────────────────── CSM(传统级联阴影) ───────────────────────┐
│ 固定 3~4 个级联:近级 2048² 贴 50m,远级 2048² 贴 1000m           │
│    → 级联切换处分辨率跳变;相机移动阴影边缘抖动                   │
│ 每帧:全部阴影投射体重新光栅化(成本恒定,与画面变化无关)         │
│ 每盏局部光 = 一整张贴图(20 盏灯 = 20 张图 + 20 次重绘)           │
│ 分辨率预算在"内容端":世界硬塞进固定级联                          │
└───────────────────────────────────────────────────────────────────┘

┌─────────────────────── VSM(虚拟阴影图) ─────────────────────────┐
│ 方向光:6~22 级 clipmap 洋葱皮,每级独立 16384² 虚拟分辨率 │
│ → 每级 texel 密度只服务”那一圈距离”,无级间切换跳变 │
│ 页池:2048 张 128×128 物理页共享给所有灯 │
│ 每帧:只画”画面需要且缓存没有”的页(相机移动→页表平移复用) │
│ 局部光:小灯 1 页、大灯多页,页池按需分配 │
│ 分辨率预算在”运行端”:按需按像素请求 │
└───────────────────────────────────────────────────────────────────┘

核心差异一句话:CSM 在内容端做预算(世界被塞进固定级联),VSM 在运行端做预算(画面需要多少页就画多少页)——所以 VSM 的成本跟着”画面变化量”走,而不是跟着”世界大小”走。这正是开放大世界的解药,也是第 11 章的主题。

1.6 小结

这一章记住三句话:

  1. CSM 的三座大山:分辨率撕裂、每帧全量重光栅、局部光数量受限;
  2. VSM 的心智模型:阴影版的虚拟内存——页、页表、物理池、缓存;
  3. 四根支柱:虚拟页表、物理页池、GPU 驱动分配、多级缓存复用。

下一章,六大核心概念,每个配一个类比。

第 2 章 六大核心概念速览(每个概念 = 一个类比)

TL;DR|VSM 的六个核心概念,用六个你早就懂的东西类比:页 = 乐高块页表 = 虚拟内存页表
Clipmap = 洋葱皮缓存平移 = 纸带平移缓存失效 = 整页重抄静态页缓存 = 存档复用
这一章只建立直觉不给代码——每个概念在源码里的落点在第 4~8 章展开,末尾给”概念 → 源码”预告表。

2.1 页(Page)= 乐高块

定义:页是 VSM 的原子单位——128×128 个阴影像素的一块正方形区域(VSM_PAGE_SIZE = 128VirtualShadowMapDefinitions.h:14)。所有灯的所有阴影都切成页,所有页共享同一个”乐高仓库”——物理页池

为什么是 128? 与 Nanite 的 128 三角形异曲同工:太大则”缺一页补一页”的粒度粗(画面只露一角也要整页重画)、缓存复用率低;太小则每页的元数据开销占比高。128×128 = 16384 texel,每页 4 字节 = 64KB/页,2048 页 = 128MB/层,是显存友好、页表索引友好的量级。

图 F3 页 = 乐高块:场景的阴影切成小块,按需从仓库(物理页池)取用
  画面需要的阴影范围(虚线 = 某盏灯的一级 clipmap)
  ┌──────────────────────────────────────────┐
  │  ┌────┐ ┌────┐ ┌────┐ ┌────┐            │
  │  │ 页 │ │ 页 │ │ 页 │ │ 页 │            │   每块 = 128×128 阴影像素
  │  └────┘ └────┘ └────┘ └────┘            │   每块可以属于任何灯、任何级
  │  ┌────┐ ┌────┐ ┌────┐ ┌────┐            │
  │  │ 页 │ │ 页 │ │ 页 │ │ 页 │            │
  │  └────┘ └────┘ └────┘ └────┘            │
  └──────────────────────────────────────────┘
              │  页表映射(第 4 章)
              ▼
  物理页池(默认 2048 页 = 2048 块乐高,所有灯共享)
  ┌────┬────┬────┬────┬────┬────┬────┬────┐
  │ A3 │ B1 │ A1 │ C2 │ A2 │ B3 │ …  │    │  ← 页头上写着属于谁(Id/Mip/地址)
  └────┴────┴────┴────┴────┴────┴────┴────┘

它在这条流水线里的位置:标记(第 6 章)的最小单位、分配(第 7 章)的最小单位、光栅(第 9 章)的最小单位、缓存(第 8 章)的最小单位——一切以页为边界

2.2 虚拟页表 = 虚拟内存页表

定义:每盏灯有一块巨大的”虚拟阴影贴图”地址空间(方向光 16384×16384 texel = 128×128 页)。页表(一张 Texture2D<uint> 纹理,每 texel 一项)记录”虚拟页 → 物理页”的映射——像操作系统的页表,虚拟地址连续,物理页随意放置。

关键:页表是每帧由 GPU 更新的。虚拟页映射到物理池的哪一层、哪个槽,由第 7 章的分配计算着色器决定。采样阴影时,像素先查页表拿到物理地址,再去物理池读深度(ShadowVirtualToPhysicalUVVirtualShadowMapPageAccessCommon.ush:357)。

没映射的页 = 透明缺页:页表项为空时,采样结果是”无阴影”(或用更粗的 mip 级顶上)——这与虚拟内存”访问未映射页 → 段错误”不同:VSM 的缺页是优雅降级,不是错误。

2.3 Clipmap = 洋葱皮

定义:方向光(太阳)的阴影用一个 clipmap 结构覆盖大世界:一组同心正方形”级(Level)”,每级覆盖上一级 2 倍的世界半径,共 6~22 级(默认 17 级),每级都是一张 16384×16384 的虚拟阴影贴图

图 F4 Clipmap 洋葱皮:每级覆盖 2 倍半径,texel 密度逐级减半(近处细、远处粗)
相机 L6 ≈ 1.28m(脚下) L8 ≈ 5m L10 ≈ 20m L13 ≈ 164m(城镇街区) L16 ≈ 1.3km(视野中景)

每级都是 16384² 虚拟分辨率
→ texel 密度随半径翻倍递减
L20 ≈ 21km(地平线山体)
L22 ≈ 84km(最远兜底)
采样时按像素深度选级
(CalcAbsoluteClipmapLevel)
相机移动时各级独立判定
是否平移复用(第 5 章)

为什么叫 clipmap? 来自经典算法 “Clipmap”(clip + mipmap):把 mipmap 的”级”对应到”距离”,每一级只画它那一圈范围(外圈被 clip 掉)。与 CSM 的关键区别:级数多得多(17 vs 3~4)且每级独立缓存——CSM 的级切换是整个级联重画,VSM 的级切换只是”采样时换一张表”。

texel 密度的含义:L6 的 16384² 贴 1.28 米范围 = 每 texel 0.08mm(脚下阴影精细到像素级);L22 的 16384² 贴 84km = 每 texel 5 米(远处阴影只是一片明暗)。分辨率预算全花在近处——这正是”虚拟化”的意义:远处不配拥有高分辨率。

2.4 缓存平移 = 纸带平移

定义:相机向前移动时,clipmap 各级的覆盖范围跟着移动。VSM 的绝招:不重新画,而是把页表整体”平移”——级缓存判定通过时,GPU 只需给页表加一个偏移(PageAddressOffset),已经渲染过的页原地保留,只有新露出的边缘页需要新画。

类比实验室:纸带平移
想象一条印着世界地图的纸带(页表)铺在桌面上,你用放大镜看其中一段(相机视野)。
放大镜向右移 1 厘米——你不必重印整条纸带,只需把纸带向左拉 1 厘米(平移),
只有右侧新露出的纸面需要补印。VSM 的 PageAddressOffset 就是”拉纸带”这个动作:
O(1) 的指针移动,而不是 O(世界大小) 的重画。这是开放大世界优化的第一功臣(第 11 章)。

2.5 缓存失效 = 整页重抄

定义:缓存的敌人是”变化”。VSM 用缓存键(Cache Key)记录”这帧的阴影依赖什么”——对方向光,键里存着太阳方向(LightDirection)FClipmapCacheKey);对局部光,键里存着光的变换矩阵(WorldToLight)。键一变 → 缓存失效 → 相关页标记为”要重画”。

为什么方向在键里? 因为阴影的内容由”光从哪来”决定:太阳转 1°,地面上所有物体的影子方向全变,之前画的所有页内容全错——必须整页重抄

持续移动的代价:如果太阳每帧都转(真实昼夜循环),那么每帧键都不匹配 → 缓存形同虚设 → 走 uncached 路径(每帧全量重光栅化)。这是动态太阳优化的核心矛盾,第 13 章整章处理它。

2.6 静态页缓存 = 存档复用

定义:一个物体连续 100 帧Cache.FramesStaticThreshold)没有失效(没移动、没变形、没被太阳转动影响),它的页就从”动态页”升级到”静态页”——存进物理池的静态层,此后只要不失效就永不重画

类比实验室:存档复用
游戏存档——存一次,读 N 次。动态页像”手写便签”(随时可能撕掉重写),静态页像
“装订成册的存档”(一旦定稿就只读)。100 帧 ≈ 1.7 秒(60fps),所以”站着不动看 2 秒”
的物体都会自动转正。第 8 章讲完整机制,第 11 章讲它在开放大世界里的收益。

2.7 六个概念串起来:一帧的旅程 + 落点预告

一帧的 VSM 故事(对照图 F1):

  1. 设置:CPU 给每盏灯建 clipmap/阴影映射,比较缓存键(2.5)——太阳动了?失效!
  2. 标记:GPU 从深度缓冲算出”画面上哪些像素需要阴影”→ 换算成”需要哪些页”(2.1);
  3. 分配:GPU 查页表(2.2):需要的页里,能平移复用的平移(2.4)、静态层有的直接用(2.6)、都没有的从空闲池分配并标记”要画”;
  4. 光栅:Nanite/非 Nanite 把阴影深度画进分配的物理页;
  5. 投影:屏幕像素查页表(2.2)取阴影;
  6. 帧末:dirty 页标记合并,缓存键与页元数据持久化给下一帧(2.5/2.6)。
概念 类比 源码落点 章节
页(128×128) 乐高块 VirtualShadowMapDefinitions.h:14(VSM_PAGE_SIZE) Ch4
页表 虚拟内存页表 VirtualShadowMapArray.h(PageTableRDG)+ PageAccessCommon.ush:357 Ch4/Ch7
Clipmap 洋葱皮 VirtualShadowMapClipmap.cpp:200(FVirtualShadowMapClipmap) Ch5
缓存平移 纸带平移 PhysicalPageManagement.usf:185(KEEP_PAGE)+ UpdateClipmapLevel(CacheManager.cpp:259) Ch5/Ch7
缓存失效 整页重抄 FClipmapCacheKey(CacheManager.h:135)+ ProcessInvalidations(CacheManager.cpp:1852) Ch8/Ch13
静态页缓存 存档复用 FramesStaticThreshold=100(CacheManager.cpp:142)+ 静态层(Definitions.h 双 layer) Ch8/Ch11

第 3 章 设计哲学与取舍:数字从哪来

TL;DR|VSM 的每个数字都不是拍脑袋:页 128×128 是”复用粒度”与”元数据开销”的平衡点
页表 128×128 页 × 8 级 mip 让单灯虚拟分辨率到 16384²物理池 2048 页是”显存预算”与
“缺页惩罚”的权衡
。所有页管理在 GPU 上做(连 CPU 都省了),因为页分配本质是”大规模并行
位图操作”,正是 GPU 的主场。

3.1 目标列表

VSM 的设计目标(从论文与源码注释归纳):

  1. 极近处高分辨率:脚下 1 米的阴影也要清晰(L6 级 texel 0.08mm);
  2. 动态物体正确:移动的物体阴影必须每帧正确(动态页路径);
  3. 缓存友好:静止场景跨帧复用,成本趋近于零(级平移 + 静态页);
  4. 大世界可扩展:成本随”画面变化量”而不是”世界大小”(页池化 + 按需分配);
  5. 统一阴影方案:方向光、局部光、单页小灯共用一个池子、一套流程。

3.2 数字从哪来:128 / 128×128 / 16384² / 8 mip

常量宪法(VirtualShadowMapDefinitions.h):

常量 为什么
VSM_PAGE_SIZE 128 复用粒度与元数据开销的平衡(2.1)
VSM_LEVEL0_DIM_PAGES_XY 128(页) 页表单轴页数,2^7,配合 mip 天然二进制
VSM_VIRTUAL_MAX_RESOLUTION_XY 16384 128 页 × 128 texel,单灯虚拟分辨率
VSM_MAX_MIP_LEVELS 8 页表 mip:L0 全分辨率,L7 最粗(1 页覆盖全图)
VSM_MAX_SINGLE_PAGE_SHADOW_MAPS 8192 单页局部光(小灯)的固定槽位数

为什么页表有 mip? 局部光阴影用:灯的覆盖范围大 → 需要多页(L0 层);范围小 → 1 页就够(直接进高 mip 层)。GetMipLevelLocalDefinitions.h:181)按”灯的屏幕足迹”算 mip 级,小灯阴影的页表项直接落在粗 mip 层,占用 1 页 = 1 个槽位——这就是 8192 个单页小灯槽的来源。

3.3 为什么 GPU 分配:页管理本质是位图操作

Nanite 的流送有 CPU 参与(读盘、fixup),VSM 的页分配全在 GPUFUpdatePhysicalPagesCSFPackAvailablePagesCSFAllocateNewPageMappingsCS 三个计算着色器(第 7 章),CPU 只维护页列表 buffer。

为什么? 页分配的对象是”当前帧标记出的页集合”——它来自 GPU 侧深度缓冲分析,天然在 GPU 上;分配算法是”位图查空位 + 原子计数”,正是 GPU 并行擅长的操作;而且避免了 CPU-GPU 往返——每帧的页分配结果直接留在 GPU 供光栅化消费。这是”GPU 驱动渲染(GPU-Driven Rendering)”的又一次贯彻:数据产生在哪,就在哪消费

3.4 静态/动态双层物理池

物理页池是 Texture2DArray:静态缓存启用时双层——layer 0 = 动态页(随时重画)、layer 1 = 静态页(定稿只读)。为什么要两层?因为双线性过滤需要”邻居页内容稳定”:采样一个动态页时,如果邻居是静态页(内容早就画好),混合结果才正确;把静态页单独放一层,GPU 光栅时按页标志写对应层(GetVirtualShadowMapStaticArrayIndexPageAccessCommon.ush:395),采样时静态页直接从静态层读——层分离让”混合新旧”成为可能

3.5 代价与边界

代价/边界 说明 怎么办
页池内存 2048 页 × 64KB × 2 层 ≈ 256MB(含 mip 更多) MaxPhysicalPages 按平台预算调(第 11/14 章)
缺页惩罚 页池不足 → 新页挤掉旧页 → 阴影块状闪烁 预算管理(11.7)
移动端不支持 官方平台不含移动端;移动端渲染器从不调用 VSM 移动端用 CSM + Distance Field Shadows
局部光室内 比 CSM 慢 50~100%(公开资料) 室内场景评估用 CSM 或 MegaLights
失效抖动 缓存频繁失效 → 每帧重画 → 性能与闪烁 invalidation budget、阶梯式太阳(13 章)
需要 Nanite/SM5 DoesPlatformSupportVirtualShadowMaps = ProjectEnabled && 支持 Nanite 无 Nanite 平台自动回退

3.6 小结

这一章回答了”数字从哪来”:128/128×128/16384²/8 mip 是硬件与算法权衡的产物;GPU 分配是 GPU 驱动哲学的贯彻;双层物理池为缓存服务。代价清单(内存、缺页、平台)决定了 VSM 的使用边界。

下一章进入机制篇:先看 VSM 的”虚拟内存”本体——页表与物理页池。

第 4 章 页表与物理页池:阴影的”虚拟内存”

TL;DR|VSM 的”虚拟内存”本体:页表(128×128 页的 2D 纹理,记录”虚拟页→物理页”映射,
8 级 mip 支持局部光缩放)和物理页池(默认 2048 张 128×128 页的 Texture2DArray,静态缓存时双层:
0=动态层 1=静态层)。每张虚拟阴影贴图的 ID 有固定布局:前 8192 个 ID 是”单页小灯”,之后是
方向光与全尺寸局部光。类比延续:页表=虚拟内存页表,物理池=乐高仓库

4.1 常量宪法:一页纸的 VSM 世界观

所有 VSM 常量定义在 VirtualShadowMapDefinitions.h(C++ 与着色器共用的共享头,改它需要重编引擎——文件头注释原话 !!! Changing this file requires recompilation of the engine !!!):

1
2
3
4
5
6
7
8
9
10
11
// Page size is 128x128
#define VSM_PAGE_SIZE (1u << 7u) // 128:一页 = 128×128 阴影像素
// Page table size is 128x128 (total 16k)
#define VSM_LEVEL0_DIM_PAGES_XY (1u << 7u) // 128:页表每轴 128 个虚拟页
#define VSM_MAX_MIP_LEVELS (7u + 1u) // 8:页表 mip 层级
#define VSM_VIRTUAL_MAX_RESOLUTION_XY (128u * 128u) // 16384:单灯虚拟分辨率
#define VSM_RASTER_WINDOW_PAGES (4u) // 光栅窗口 = 4 页
#define VSM_MAX_SINGLE_PAGE_SHADOW_MAPS (1024U * 8U) // 8192:单页小灯槽位
// 页表纹理:128 列 × 192 行(多出 64 行放 mip 层级)
#define VSM_PAGE_TABLE_TEX2D_SIZE_X (128u)
#define VSM_PAGE_TABLE_TEX2D_SIZE_Y (128u + 64u)

一句话世界观:每盏灯拥有一块 16384×16384 的虚拟阴影贴图地址空间(页表 128×128 项),所有灯共享一个 2048 页的物理池。虚拟地址空间免费(只是页表项),物理页是稀缺资源(要显存)——这和虚拟内存一模一样:虚拟地址 64 位随便要,物理页帧要省着用。

4.2 页表:虚拟页 → 物理页

页表是每张 VSM 一张的 Texture2D<uint> 纹理(PageTableRDGVirtualShadowMapArray.h),尺寸 128×192(X=128 列对应页表 L0,Y=192 行 = 128 行 L0 + 64 行给 mip 层级)。

每个页表项存一个物理页地址:物理池里的页槽号 + LOD 偏移。采样时像素坐标 → 虚拟页坐标 → 查页表 → 得到物理页坐标 → 读物理池深度(ShadowVirtualToPhysicalUVVirtualShadowMapPageAccessCommon.ush:357)。

图 F6 页表 + 物理池:虚拟地址空间(免费)映射到物理池(稀缺)
  虚拟地址空间(每灯一张,16384×16384 texel = 128×128 页表项)
  ┌──────────────────────────────────────────────┐
  │ 页表项 (0,0)  页表项 (1,0)  …  页表项 (127,0) │       页表 = Texture2D
  │   └→ 物理页 17    └→ 空          └→ 物理页 3 │       每项 = 物理池地址
  │  …                    …                      │
  │ 页表项 (0,127)  …         页表项 (127,127)    │       mip 层:Y 方向下方 64 行
  └──────────────────────────────────────────────┘
        │  ShadowVirtualToPhysicalUV 查表
        ▼
  物理页池(Texture2DArray,128×128 页,默认 2048 页)
  ┌────┬────┬────┬────┬────┬────┬────┬────┬────┐
  │ 0  │ 1  │ …  │ 17 │ …  │ 3  │ …  │2047│    │   layer 0 = 动态页
  ├────┼────┼────┼────┼────┼────┼────┼────┼────┤
  │ 0  │ 1  │ …  │    │    │    │    │    │    │   layer 1 = 静态页(存档)
  └────┴────┴────┴────┴────┴────┴────┴────┴────┘
  每页元数据(FPhysicalPageMetaData):Flags / LastRequestedFrame / VSM ID / Mip / 页地址

每页元数据 FPhysicalPageMetaDataDefinitions.h:150-158):

1
2
3
4
5
6
7
8
struct FPhysicalPageMetaData
{
uint Flags; // VSM_FLAG_* 与 VSM_EXTENDED_FLAG_*(页状态)
uint LastRequestedSceneFrameNumber; // 上次被请求的帧号(LRU 淘汰用)
uint VirtualShadowMapId; // 回指哪张 VSM(谁的页)
uint MipLevel; // 页表 mip 级
uint2 PageAddress; // 虚拟页地址(页表项)
};

避坑:早期资料(UE 5.2 时代)里的 FShadowPhysicalPage 结构在本仓库已不存在——它已被
FPhysicalPageMetaData 取代(旧结构存的是”物理地址+LOD 偏移”,新结构更完整)。
读老博客时见到旧名,知道是同一个概念即可。

4.3 物理页池:双层乐高仓库

物理池(PhysicalPagePoolRDG)是 Texture2DArray<uint>:每页 128×128 texel、每 texel 32 位打包深度,总页数默认 2048r.Shadow.Virtual.MaxPhysicalPages)。静态缓存启用时双层

  • layer 0(动态层):动态页——内容随时可能被重画(移动物体、失效页);
  • layer 1(静态层):静态页——100 帧无失效的”存档”页,只读。

为什么双层? 第 3.4 节讲过:双线性过滤需要稳定邻居。光栅化时按页标志写对应层(GetVirtualShadowMapStaticArrayIndexPageAccessCommon.ush:395),采样时静态页直接从静态层读。

内存怎么算? 页 128×128 × 4 字节 = 64KB;2048 页 × 64KB = 128MB/层;双层 = 256MB(这只是页池本体,不含 mip 与 HZB 物理池)。

避坑:Epic 官方文档说 VSM “约 512MB 页池”——那是包含更大页池配置与完整资源(mip、
HZB、衰减层)的估算口径。按本仓库默认值(2048 页、双层、含 mip 与 HZB)实测内存
约为 256MB~512MB 区间,文档给出的公式(128×128×4B×页数×2 层)才是可复算的基准。

4.4 页标志:一张状态机

每个物理页的 Flags 字段用位标志表达生命周期状态(VSM_EXTENDED_FLAG_*VirtualShadowMapPageAccessCommon.ush:45-70):

标志 含义
DYNAMIC_INITIALIZED / STATIC_INITIALIZED 动态层/静态层内容是否已画好
DYNAMIC_DIRTY / STATIC_DIRTY 动态层/静态层是否需要重画(光栅化时写、帧末合并)
INVALIDATE_DYNAMIC / INVALIDATE_STATIC 等待被失效(下一帧重画)
VIEW_UNCACHED 本帧走 uncached 路径(光只画动态层)
UNREFERENCED 本帧无人引用(可被淘汰)
DEFERRED_INVALIDATION 延迟失效(Nanite LOD 变化等,配合 DeferredInvalidationBudget
图 F7 页的生命周期状态机(简化版)
        页分配(AllocateNewPageMappingsCS)
        │
        ▼
   ┌──────────┐   光栅化    ┌─────────────┐   连续 100 帧    ┌──────────────┐
   │ 空页      │ ────────▶ │ 动态页(画好) │ ──────────────▶ │ 静态页(存档)  │
   │ (未初始化) │            │ 可随时重画   │ 无失效(转静态层) │ 只读,永不重画 │
   └──────────┘            └─────────────┘                  └──────────────┘
        ▲                         │  ▲                             │
        │     LRU 淘汰(1000帧)     │  │ 失效(键不匹配/primitive动)    │ 失效
        └─────────────────────────┘  └─────────────────────────────┘
           回收到空闲池                   标记 INVALIDATE_* → 重画

4.5 ID 布局:谁排在前面

VSM ID(VirtualShadowMapId)的分配顺序有讲究(VirtualShadowMapArray.h:318-359):

1
2
3
4
ID 0 ───────────────────── 8191 ─────────────── 8191+N ────────────── …
│ 单页小灯(distant 灯) │ 方向光 clipmap │ 全尺寸局部光 │ 未引用光 │
└───────────────────────────┴──────────────────┴───────────────┴─────────┘
IsSinglePage(id):id < 8192 IsDirectional:id-8192 < NumDirectional
  • 单页小灯(8192 个固定槽):屏幕足迹小于一页的局部光(远处的路灯、蜡烛),每个只占 1 个物理页——固定槽位让索引开销最小;
  • 方向光必须排在所有局部光之前(注释原话 “All directional lights must be allocated before all local lights due to receiver mask allocation logic”);
  • 未引用光:离屏但可能还有缓存页的灯,排在最后,缓存页可以多活几帧(UpdateUnreferencedCacheEntries)。

代码落点AllocateDirectional/AllocateLocal/AllocateUnreferencedVirtualShadowMapArray.h:322-324),IsSinglePage/IsDirectional 判断(:335-344)。

4.6 源码考古:常量宪法与物理页元数据

源码考古:想看真东西的人进。

① 常量宪法VirtualShadowMapDefinitions.h:12-22

1
2
3
4
5
6
7
8
// Page size is 128x128
#define VSM_LOG2_PAGE_SIZE 7u
#define VSM_PAGE_SIZE (1u << VSM_LOG2_PAGE_SIZE) // 128
// Page table size is 128x128 (total 16k)
#define VSM_LOG2_LEVEL0_DIM_PAGES_XY 7u
#define VSM_LEVEL0_DIM_PAGES_XY (1u << VSM_LOG2_LEVEL0_DIM_PAGES_XY) // 128 页
#define VSM_MAX_MIP_LEVELS (VSM_LOG2_LEVEL0_DIM_PAGES_XY + 1u) // 8
#define VSM_VIRTUAL_MAX_RESOLUTION_XY (VSM_LEVEL0_DIM_PAGES_XY * VSM_PAGE_SIZE) // 16384

这段代码在干什么:7 位 = 128,8 级 mip = 128+1——所有数字都是 2 的幂,因为页表寻址、
mip 选择、页坐标换算全部是移位运算,零除法。VSM_VIRTUAL_MAX_RESOLUTION_XY 直接由
“页数 × 页大小”导出,C++ 与着色器读到同一个值,永远不会漂移。

② 虚拟阴影贴图常量集合VirtualShadowMapArray.h:58-96

1
2
3
4
5
6
7
8
9
10
11
12
class FVirtualShadowMap
{
public:
// PageSize * Level0DimPagesXY defines the virtual address space, e.g., 128x128 = 16k
static constexpr uint32 PageSize = VSM_PAGE_SIZE;
static constexpr uint32 Level0DimPagesXY = VSM_LEVEL0_DIM_PAGES_XY;
static constexpr uint32 MaxMipLevels = VSM_MAX_MIP_LEVELS;
static constexpr uint32 VirtualMaxResolutionXY = VSM_VIRTUAL_MAX_RESOLUTION_XY;
static constexpr uint32 RasterWindowPages = VSM_RASTER_WINDOW_PAGES;
...
static_assert(MaxMipLevels <= 8, ">8 mips requires more PageFlags bits");
};

这段代码在干什么FVirtualShadowMap 这个类没有数据成员——它只是把共享头的常量
复制进 C++ 命名空间的”常量集合”(注释自己也说 “probably rename the cache structure…”)。
注意 static_assert(MaxMipLevels <= 8):mip 数受页标志位宽限制,改常量前编译器会先拦一道。

③ 页 ID 布局VirtualShadowMapArray.h:318-359

1
2
3
4
5
6
7
8
9
10
// NOTE: All directional lights must be allocated before all local lights
// due to receiver mask allocation logic. Unreferenced lights must be allocated last.
int32 AllocateDirectional(int32 Count);
int32 AllocateLocal(bool bSinglePageShadowMap, int32 Count);
int32 AllocateUnreferenced(bool bSinglePageShadowMap, int32 Count);
...
static bool IsSinglePage(int32 VirtualShadowMapId)
{
return (VirtualShadowMapId < VSM_MAX_SINGLE_PAGE_SHADOW_MAPS);
}

这段代码在干什么:ID 就是”地址空间分配”——单页灯固定在前 8192 槽(IsSinglePage 一次
比较搞定),方向光次之,未引用灯垫底。注释明说顺序受 receiver mask 分配逻辑约束:
改变布局顺序会破坏页标志与掩码的索引假设——这就是”常量宪法”的约束力。


第 5 章 Clipmap:方向光的”洋葱皮”

TL;DR|太阳的阴影用一个 clipmap 覆盖 1 米到 80 公里的范围:6~22 级同心正方形,每级
覆盖 2 倍半径、每级都是完整的 16384² 虚拟阴影贴图(texel 密度逐级减半,近细远粗)。
相机移动时每级独立判定缓存:Z 范围没越界 → 页表平移复用(纸带平移),只有新露出的边缘
要新画
。近级别禁 WPO 动画(阈值 2 的幂量化防连续失效)。类比:洋葱皮——一层包一层,
每层是独立的一整片

5.1 什么是 clipmap:不是 CSM 的加大版

CSM 把空间切成 3~4 个级联(近细远粗各一张图),切换处分辨率跳变。VSM 的方向光方案叫 clipmap(clip + mipmap 的合体):**级数更多(默认 17 级)、每级独立缓存、级与级之间没有”切换”只有”采样时选级”**。

级半径公式VirtualShadowMapClipmap.cpp:172-180):

1
2
3
4
5
float FVirtualShadowMapClipmap::GetLevelRadius(float AbsoluteLevel)
{
// Clipmap level rounds *down*, so radius needs to cover out to 2^(Level+1), where it flips
return FMath::Pow(2.0f, AbsoluteLevel + 1.0f);
}

级半径 = 2^(L+1) 厘米。默认 FirstLevel=6 / LastLevel=22VirtualShadowMapClipmap.cpp:57-68):

半径 一句话
L6 128 cm 脚下阴影
L8 ≈ 5 m 角色周围
L10 ≈ 20 m 庭院
L13 ≈ 164 m 街区
L16 ≈ 1.3 km 城市中景
L20 ≈ 21 km 地平线山体
L22 ≈ 84 km 最远兜底

避坑:Epic 官方文档说 clipmap 覆盖”64cm~40km”,与本仓库源码公式(L6=128cm、L22≈84km)
不一致——官方按有效覆盖/不同配置表述,以源码公式为准(半径 = 2^(L+1) 厘米)。

5.2 每级恒定 16384²:分辨率预算全花在近处

关键设计:每级的虚拟分辨率都是 16384×16384,不管半径是 1 米还是 80 公里。因此:

  • L6:16384² 贴 1.28m → 每 texel ≈ 0.08mm(脚下阴影精细到亚毫米);
  • L22:16384² 贴 84km → 每 texel ≈ 5 米(远处只有明暗轮廓)。

“近处 texel 密、远处 texel 疏”是按距离连续衰减的,没有 CSM 的级联切换跳变。代价是:远处的级”浪费”了页表空间(16384² 的虚拟地址免费),但物理页只按需分配——远级通常只分配少量页(第 11 章的 PrefilteredDistant 进一步压缩)。

5.3 ZRangeScale:缓存防抖的关键

每级阴影不是无限深的——深度范围 = 半径 × ZRangeScale(默认 1000,Clipmap.cpp:84-85)。L16(半径 1.3km)的深度范围 ≈ 1300km——比地球直径还大。

为什么这么大? 阴影贴图的 Z 范围决定”哪些物体会被画进这级”。如果 Z 范围太小,相机微动就会让远处物体进出 Z 范围 → 页内容频繁失效 → 缓存防抖(caching stability)被破坏。ZRangeScale=1000 是”宁可浪费深度精度也要缓存稳定”的取舍(深度精度损失换来的是不重画)。

5.4 级缓存判定:四条件 + 页表平移

每帧每级独立做缓存判定(FVirtualShadowMapCacheEntry::UpdateClipmapLevelVirtualShadowMapCacheManager.cpp:259-314),四个条件全过才复用:

条件 说明
① 上帧渲染过 PerLightEntry.RenderedFrameNumber >= 0
② Z 半径不变 ViewRadiusZ == Clipmap.ViewRadiusZ(级覆盖深度范围没变)
③ Z 中心在 guardband 内 (DeltaZ + LevelRadius) > 0.9 * ViewRadiusZ 则失效(Z 中心移动超过保护带)
④ WPO 阈值不变 WPODistanceDisableThresholdSquared 变化(分辨率/FOV 变化触发)则失效

有效 → VSM_NEXT_FLAG_KEEP_PAGE + PageAddressOffset:页表整体平移(当前页空间位置 - 缓存页空间位置 = 偏移),已渲染页原地保留,只有新露出的边缘页要新画

图 F8 级缓存判定与页表平移(纸带平移的源码形态)
相机向右移动 → 该级覆盖范围右移
┌──────────────────────────────┐
│ 上次缓存:页表记录这 8 页      │    ● = 已渲染页
│ [●][●][●][●][●][●][●][●]    │
└──────────────────────────────┘
            │  UpdateClipmapLevel:四条件全过
            ▼
┌──────────────────────────────┐
│ 本帧:页表平移 offset = +2 页  │    ● = 平移复用(不重画)
│ [●][●][●][●][●][●][●][●][○][○]│    ○ = 新露出的边缘页(唯一要画的)
└──────────────────────────────┘
            │  PageAddressOffset = (2, 0)  ← 改指针,不是重画
            ▼
     光栅化只画 2 个新边缘页

源码考古的代码对照(节选自 CacheManager.cpp:285-305):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (bCacheValid)
{
// NOTE: Leave the view center and radius where they were previously for the cached page
FInt64Point CurrentToPreviousPageOffset(PageSpaceLocation - Clipmap.PageSpaceLocation);
NextData.Flags = VSM_NEXT_FLAG_KEEP_PAGE; // ← 复用
NextData.PageAddressOffset = FIntVector2(CurrentToPreviousPageOffset.X, CurrentToPreviousPageOffset.Y);
}
else
{
NextData.Flags = 0u; // ← 失效:重建
NextData.PageAddressOffset = FInt32Point(0, 0);
Clipmap.ViewCenterZ = ViewCenterZ; // 记录新基准
Clipmap.ViewRadiusZ = ViewRadiusZ;
Clipmap.WPODistanceDisableThresholdSquared = WPODistanceDisableThresholdSquared;
}
Clipmap.PageSpaceLocation = PageSpaceLocation; // 更新本帧位置

这段代码在干什么:缓存有效时只写”偏移量”(PageAddressOffset),光栅化阶段按偏移量把页表内容平移;失效时清空偏移并记录新的基准(Z 中心/半径/WPO 阈值)供下帧比较。注意 ViewCenterZ/ViewRadiusZ 在缓存有效时故意不更新——它们保留的是”上次画这级时的位置”,只有失效时才更新,这正是”平移复用”的基础。

5.5 WPO 禁用距离:近处的动画豁免

Clipmap.WPODisableDistance=1Clipmap.cpp:99-100)+ LodBias=3(:107):近级别禁用 WPO(世界位置偏移)动画——草、树的顶点动画不进近级阴影(它们在近级按静态画),因为:

  1. WPO 动画每帧改变几何 → 相关页每帧失效 → 缓存白搭;
  2. 近级阴影的像素级细节里,WPO 的摆动本来就不可见。

关键细节:2 的幂量化。WPO 禁用阈值按 2 的幂取整(WPODistanceDisableThresholdSquared),保证相机移动时阈值不连续变化——否则阈值一变,所有级一起失效(UpdateClipmapLevel 第④条)。这就是 Clipmap.WPODisableDistance.InvalidateOnScaleChange=0CacheManager.cpp:190)默认关闭的原因。

避坑:WPO 禁用距离与第 11 章要讲的”开放大世界 WPO 优化”是同一机制的两面:
引擎默认在近级(LodBias=3 即前 3 级)禁 WPO;如果你把草/树做成”永远在 WPO 距离外”
(Stray Spark 实测:草 30m、树 80m),它们就永远不会触发失效——这是内容侧配合。

5.6 FirstPerson 独立 clipmap 与其他细节

  • 第一人称独立 clipmapEVirtualShadowTypeId::FirstPerson 有自己的一套层级(r.FirstPerson.Shadow.Virtual.Clipmap.FirstLevel=8/LastLevel=18)——第一人称近处阴影要更高分辨率,不能与主 clipmap 混用;
  • **CullDynamicTightly=true**(Clipmap.cpp:153):未缓存级收紧远裁剪面——失效重建时少画一点;
  • 正交相机r.Ortho.VSM.* 四个 CVar 处理正交视图下的 clipmap 估算(LodScale 用 OrthoWidth 而非视口宽,Clipmap.cpp:114-140)。

5.7 源码考古:clipmap 构造与分辨率偏置

源码考古:想看真东西的人进。

VirtualShadowMapClipmap.cpp:200-258(节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FVirtualShadowMapClipmap::FVirtualShadowMapClipmap(...)
: Config(InConfig), LightSceneInfo(InLightSceneInfo), DependentView(InDependentView)
{
LightDirection = LightSceneInfo.Proxy->GetDirection().GetSafeNormal(); // 太阳方向(缓存键之一!)
const FMatrix WorldToLightRotationMatrix = FInverseRotationMatrix(LightDirection.Rotation());
WorldToLightViewRotationMatrix = WorldToLightRotationMatrix * FaceMatrix;
...
float LodScale = 0.5f / CameraViewMatrices.GetProjectionScale().X;
LodScale *= float(FVirtualShadowMap::VirtualMaxResolutionXY) / float(CameraViewportWidth);
// 移动/静止插值 + 光的自身偏置 + 相机缩放 → 最终分辨率偏置
ResolutionLodBias = FVirtualShadowMapArray::InterpolateResolutionBias(
Config.ResolutionLodBias, Config.ResolutionLodBiasMoving, LightMobilityFactor)
+ FMath::Log2(LodScale);
ResolutionLodBias += GetLightSceneInfo().Proxy->GetVSMResolutionLodBias();
ResolutionLodBias = FMath::Max(0.0f, ResolutionLodBias);
...
}

这段代码在干什么:clipmap 构造做了三件事——① 取太阳方向(存进 LightDirection
与缓存键 FClipmapCacheKey 对应,第 13 章的主角);② 建立”世界→光空间”旋转矩阵
WorldToLightViewRotationMatrix,阴影投影矩阵的基础);③ 算分辨率偏置:
InterpolateResolutionBias(静止档, 移动档, MobilityFactor) ——光在移动时用”移动档”分辨率
(更粗),静止后平滑切回”静止档”。这就是第 13 章”动态太阳自动降分辨率”的机制源头。
FMath::Log2(LodScale) 把相机视口宽度折算进偏置——
分辨率偏置 = 移动状态 + 相机状态 + 光自身

第 6 章 页标记:这一帧需要哪些页

TL;DR|页标记是 VSM 的”需求收集”:BasePass 画完场景后,GPU 从深度缓冲逐像素算出
“这个像素需要多细的阴影”→ 换算成”需要哪些(灯,级,页)”→ 写进页请求标志。先做
粗页标记(低分辨率需求兜底),再做逐像素细标记(把请求覆盖写为最高优先级)。
类比:点外卖——先按片区订大单(粗页),再按人补小单(像素页)

6.1 一帧的完整形状:六阶段的”编年史”

先把第 0 章的图 F1 落到函数级(每帧主流程,DeferredShadingRenderer 视角):

阶段 函数 位置
阴影设置 FShadowSceneRenderer::AddDirectionalLightShadow ShadowSceneRenderer.cpp:497
VSM ID 分配 FShadowSceneRenderer::AllocateVirtualShadowMapIds ShadowSceneRenderer.cpp:644
页标记 FVirtualShadowMapArray::BeginMarkPages VirtualShadowMapArray.cpp:2545
页分配 FVirtualShadowMapArray::BuildPageAllocations VirtualShadowMapArray.cpp:3256
光栅化 RenderVirtualShadowMapsNanite / RenderVirtualShadowMapsNonNanite VirtualShadowMapArray.cpp:4343 / :4515
帧末处理 FVirtualShadowMapArray::PostRender + UpdateHZB :1953 / :4961
投影 RenderVirtualShadowMapProjection VirtualShadowMapProjection.cpp:596
缓存持久化 CacheManager->ExtractFrameData CacheManager.cpp:1409

本章讲”页标记”(阶段②),下一章讲”页分配”(阶段③)。

6.2 BeginMarkPages:需求收集的编排

BeginMarkPagesVirtualShadowMapArray.cpp:2545)在 BasePass 之后运行,四步:

1
2
3
4
5
BeginMarkPages
├─ ① ClearPageTable 清空页请求标志(每帧从零开始收集需求)
├─ ② InitPageRectBounds 初始化页矩形边界(非 Nanite 实例剔除用)
├─ ③ MarkCoarsePages 粗页标记(view-independent,先跑!)
└─ ④ GeneratePageFlagsFromPixels 逐像素细标记(深度缓冲 → 页请求)

为什么粗页必须先跑? 源码注释原话:”Must do this first. In the case where bIncludeNonNaniteGeometry is false we need to ensure that the request can be over-written by any pixel pages that do want Non-Nanite geometry. We avoid writing with atomics since that is much slower.”——细标记会用普通写入(非原子)覆盖粗标记,所以粗页先写、细页后写覆盖,顺序不能反。

6.3 MarkCoarsePages:先订片区大单

MarkCoarsePagesVirtualShadowMapPageMarking.usf:802)为低分辨率需求(体积雾、半透明光照)标记粗页。方向光的关键技巧(注释原话翻译):

“clipmap 的上级已经覆盖了低级(superset),所以想要更粗的页,只需标记最粗 clipmap 级的中心页——即使只标记一页,其世界空间半径通常也足够覆盖需要粗数据的系统(体积雾、半透明光体积)。”

也就是说:粗页不用到处标,标最粗级的中心页就够了(它的一页就覆盖大片世界)。MarkCoarsePagesLocal=2BaseScalability.ini:155)控制局部光的粗页标记强度。

6.4 GeneratePageFlagsFromPixels:深度缓冲 → 页请求

GeneratePageFlagsFromPixelsVirtualShadowMapPageMarking.usf:320,C++ 调度于 :3192)是页标记的主力:每个像素(按 PageMarkingPixelStride 步长采样,默认 2 像素)做:

  1. 读场景深度 → 重建世界坐标;
  2. 对每盏相关灯:算出这个像素在世界空间的位置落在灯的哪个(级,页);
  3. 按像素的”阴影足迹”(footprint)决定需要多细的 mip 级;
  4. 写页请求标志(VSM_FLAG_PRIMARY_REQUEST 等)——**这就是”缺页请求”**,与 Nanite 流送请求异曲同工(一个来自光栅前的几何遍历,一个来自光栅后的深度分析)。

关键细节:像素不需要全屏采样——PageMarkingPixelStrideX/Y=2VirtualShadowMapArray.cpp:644/653)即每 2×2 像素采一个(1/4 密度);页是 128×128 的粗粒度目标,像素级误差无害。这是”标记比渲染便宜得多”的原因。

图 F9 页标记流程:深度缓冲 → 像素需求 → 页请求标志
   场景深度缓冲(BasePass 产物)
   ┌──────────────────────┐
   │ ▓▓░░▓▓▓▓░░░░▓▓▓▓▓▓  │   每 2×2 像素采一个(PageMarkingPixelStride=2)
   │ ▓▓▓▓▓▓░░▓▓▓▓▓▓▓░░  │
   │ ░░░░▓▓▓▓▓▓▓░░░░░░  │   ← 深度 → 世界坐标 → 投影到每盏灯的 (级,页)
   └──────────────────────┘
        │  GeneratePageFlagsFromPixels
        ▼
   页请求标志(PageRequestFlags,每灯一页表大小)
   ┌──────────────────────┐
   │ [▲][▲][ ][▲][▲][ ][ ]│   ▲ = 本帧需要这个页(含请求级 mip)
   │ [▲][▲][▲][ ][ ][ ][ ]│
   │ [ ][ ][▲][▲][▲][ ][ ]│   粗页先标(MarkCoarsePages),细页后覆盖
   └──────────────────────┘
        │  第 7 章的分配阶段消费这些标志
        ▼
   分配:复用的复用 / 平移的平移 / 缺的新分配

6.5 源码考古:页标记的两个核心函数

源码考古:想看真东西的人进。

① 粗页标记VirtualShadowMapPageMarking.usf:802-844(节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[numthreads(VSM_DEFAULT_CS_GROUP_X, 1, 1)]
void MarkCoarsePages(uint DispatchThreadId : SV_DispatchThreadID)
{
// Remap thread ID [0..NumShadowMaps) to full / single page ranges.
uint VirtualShadowMapId = DispatchThreadId;
if (VirtualShadowMapId < VirtualShadowMap.NumFullShadowMaps)
{
VirtualShadowMapId += VSM_MAX_SINGLE_PAGE_SHADOW_MAPS; // 全尺寸图偏移
}
...
// NOTE: Coarse pages are very large and tend to get invalidated a lot due to anything in the scene moving
// Rendering non-nanite geometry into these pages can be very expensive and thus isn't always desirable.
uint Flags = VSM_FLAG_ALLOCATED | VSM_FLAG_PRIMARY_REQUEST;
...
else if (ProjectionData.LightType == LIGHT_TYPE_DIRECTIONAL)
{
// Idea here is the clipmaps already cover supersets of lower levels
// Thus to get coarser pages we can just mark the center page(s) offset by a level/LOD bias
// The limit on how far dense data goes out from the camera then becomes the world space size
// of the marked page(s) on the coarses clipmap
if (ProjectionData.bIsCoarseClipLevel) { ... }
}
}

这段代码在干什么:线程 ID → VSM ID 的映射(单页小灯在前 8192 槽,全尺寸图加偏移),
然后只标记”粗级中心页”。注释点破两个工程真相:粗页常因场景移动而失效,且非 Nanite
几何画进粗页很贵
——所以默认不画非 Nanite 进粗页(NonNanite.IncludeInCoarsePages)。

② 页标记的标记者VirtualShadowMapPageMarking.ush:82MarkPage,摘要):

1
2
3
4
5
6
void MarkPage(FVirtualShadowMapHandle VSMHandle, uint2 Page, uint MipLevel, uint Flag)
{
FVSMPageOffset PageOffset = CalcPageOffset(VSMHandle, MipLevel, Page);
// 按 (灯, mip, 页地址) 找到页请求槽位,把 Flag 写进去
InterlockedOr(PageRequestFlags[PageOffset.GetResourceAddress()], Flag);
}

这段代码在干什么MarkPage 就是”需求收集”的原子操作——InterlockedOr 累加标志
(同一页可以被多个像素/多个系统标记,用或运算合并)。CalcPageOffset
(VSM ID, mip, 页坐标) 换算成页请求缓冲的一维地址——所有页寻址都走这个函数
它是 VSM 的”地址译码器”。


第 7 章 页分配与缓存复用:从虚拟页到物理页

TL;DR|页分配把”请求标志”变成”物理页归属”:**UpdatePhysicalPages 先处理旧页**
(请求到了 → 保活;没请求 → 准备回收;被失效 → 标重画),**AllocateNewPageMappingsCS
再为新需求分配物理页(从 AVAILABLE 池弹出 → 写页表),PackAvailablePages 用前缀和
重建可用池。上一帧的页表通过 KEEP_PAGE + PageAddressOffset 平移复用(纸带平移的
GPU 形态)。类比:
仓库盘点——旧货分类(保/丢/修),新货上架,空架位重新登记**。

7.1 BuildPageAllocations:分配总编排

BuildPageAllocationsVirtualShadowMapArray.cpp:3256):

1
2
3
4
5
6
7
BuildPageAllocations
├─ ① PropagateFilterableRequests 预过滤请求传播(PrefilteredDistant 用)
├─ ② FUpdatePhysicalPagesCS 更新物理页状态(核心,7.2)
├─ ③ PackAvailablePages 重建可用页池(7.4)
├─ ④ AllocateNewPageMappingsCS 为新请求分配物理页(7.3)
├─ ⑤ GenerateHierarchicalPageFlags 生成层级页标志(mip 联动)
└─ ⑥ InitializePhysicalMemoryIndirect 清空新分配页(最远深度)

关键参数(② 的输入,:3279-3330):DeferredInvalidationBudget(延迟失效预算)、MaxPageAgeSinceLastRequest(页龄上限,LRU)、bAllocateViaLRU(LRU 分配开关)、bDynamicPageInvalidation(动态页失效开关)——缓存策略全部在此刻决定

7.2 UpdatePhysicalPages:旧页的”保活/失效/回收”判定

UpdatePhysicalPagesVirtualShadowMapPhysicalPageManagement.usf:236)逐物理页(或按 LRU 链顺序)问四个问题:

  1. 本帧还被请求吗?(查页请求标志)→ 被请求 → 保活(更新 LastRequestedFrame);
  2. 被失效了吗?(失效标志 INVALIDATE_DYNAMIC/STATIC)→ 标重画;
  3. 页龄超限了吗?SceneFrameNumber - LastRequested > MaxPageAgeSinceLastRequest,默认 1000 帧)→ 清标志回收;
  4. 延迟失效有预算吗?DEFERRED_INVALIDATION 页按 DeferredInvalidationBudget 每帧限量处理)。

结果写入 OutPhysicalPageMetaData[page].FlagsFlags==0 的页被推进 EMPTY 列表(等待重新分配)。

先于它的兄弟UpdatePhysicalPageAddresses:155)负责跨帧页表平移——把上一帧的物理页映射通过 PageAddressOffset 平移到本帧的虚拟位置(clipmap 移动时的”纸带平移”),并传播失效标志。它俩的分工:**前者管”物理页归谁”,后者管”页表指向哪”**。

避坑:设计上把 UpdatePhysicalPageAddresses(页表平移)与 UpdatePhysicalPages(页状态)
分开是两个函数——早期资料常混为一谈。读代码时认准:**KEEP_PAGE/PageAddressOffset 在
UpdatePhysicalPageAddresses,LRU/失效/淘汰在 UpdatePhysicalPages**。

7.3 AllocateNewPageMappingsCS:新页上架

AllocateNewPageMappingsCS:671,实际逻辑在 AllocateNewPageMappings :475):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void AllocateNewPageMappings(FVirtualShadowMapHandle VSMHandle, FVSMPageOffset GlobalPageOffset, uint MipLevel, uint2 PageAddress)
{
const uint RequestFlags = PageRequestFlags[GlobalPageOffset.GetResourceAddress()];
if (RequestFlags != 0)
{
// 若页表项还没映射,且请求标志非零 → 分配新物理页
if (PageFlags == 0u)
{
int PhysicalPageIndex = PopPhysicalPageList(PHYSICAL_PAGE_LIST_AVAILABLE); // 从可用池弹出
if (PhysicalPageIndex >= 0)
{
// 若该物理页曾属于别的虚拟页,先清掉旧页表项(防悬挂引用!)
// ...
PushPhysicalPageList(PHYSICAL_PAGE_LIST_REQUESTED, PhysicalPageIndex); // 进本帧已用列表
// 写页表:虚拟页 → 物理页;更新元数据(VSM ID / Mip / PageAddress)
}
}
}
}

工程要点:分配前必须清掉旧页表项(注释原文 “FIRST, check if there’s a valid page already mapped to this physical page. If so, we must go back and clear out its page table entry before we reallocate”)——否则两个虚拟页指向同一物理页,采样会串数据。页表项清空 = 把那个旧虚拟页的映射置 0。

7.4 PackAvailablePages:可用池的前缀和重建

PackAvailablePages:686)把 LRU 链上剩余的有效页紧凑打包进 AVAILABLE 列表,用组内前缀和ThreadGroupPrefixSum)数出偏移——这是 GPU 上”筛选+紧凑”的标准手法(和 Nanite 的 binning scatter 同款)。物理页列表用原子计数(PushPhysicalPageList/PopPhysicalPageList,:71/:90)实现 lock-free 的池管理。

图 F10 页分配流水:旧页三分类 + 新页分配 + 可用池重建
  上一帧物理页(2048 个,各有 Flags 元数据)
  ┌────┬────┬────┬────┬────┬────┬────┐
  │ A1 │ B2 │ A3 │    │ C1 │ A2 │ B1 │
  └────┴────┴────┴────┴────┴────┴────┘
     │  UpdatePhysicalPages 逐页判定(保活/失效/页龄)
     ▼
  ┌────────────────────────────────────────┐
  │ 保活(本帧被请求)→ 留在 LRU 链         │
  │ 失效(INVALIDATE_*)→ 标重画,留链      │
  │ 页龄超限 / 无人要  → Flags=0 → EMPTY 池 │
  └────────────────────────────────────────┘
     │  PackAvailablePages(前缀和紧凑)
     ▼
  AVAILABLE 池:[空1][空2]…(本帧可分配页)
     │  AllocateNewPageMappingsCS(新请求 → 弹出 → 写页表)
     ▼
  页表:虚拟页 → 物理页(本帧最终映射,光栅化消费)

7.5 源码考古:页表平移(KEEP_PAGE)的 GPU 形态

源码考古:想看真东西的人进。

VirtualShadowMapPhysicalPageManagement.usf:155-222UpdatePhysicalPageAddresses,节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
[numthreads(VSM_DEFAULT_CS_GROUP_X, 1, 1)]
void UpdatePhysicalPageAddresses(uint3 Index : SV_DispatchThreadID)
{
if (Index.x >= VirtualShadowMap.MaxPhysicalPages) { return; }
int PhysicalPageIndex = Index.x;
#if HAS_CACHE_DATA
FPhysicalPageMetaData PrevMetaData = OutPhysicalPageMetaData[PhysicalPageIndex];
bool bKeepPage = false;
if (PrevMetaData.Flags != 0)
{
FVirtualShadowMapHandle PrevVSMHandle = FVirtualShadowMapHandle::MakeFromId(PrevMetaData.VirtualShadowMapId);
if (PrevVSMHandle.IsValid() && PrevVSMHandle.GetDataIndex() < NextVirtualShadowMapDataCount)
{
FNextVirtualShadowMapData NextData = NextVirtualShadowMapData[PrevVSMHandle.GetDataIndex()];
// Check if it maps to a valid virtual shadow map for which we wish to keep data
if (NextData.Flags & VSM_NEXT_FLAG_KEEP_PAGE)
{
// Clipmap panning; zeroed otherwise so safe
int2 TestPageAddress = int2(PrevMetaData.PageAddress) + NextData.PageAddressOffset;
if (IsVirtualShadowMapPageAddressValid(TestPageAddress, PrevMetaData.MipLevel))
{
// Valid physical page in the cache! ... maintain it
OutPhysicalPageMetaData[PhysicalPageIndex].VirtualShadowMapId = VSMHandle.Id;
OutPhysicalPageMetaData[PhysicalPageIndex].PageAddress = uint2(TestPageAddress);
bKeepPage = true;
}
}
}
}
if (bKeepPage) { /* 传播失效标志 */ }
else { OutPhysicalPageMetaData[PhysicalPageIndex].Flags = 0; } // 无效 → 清空待回收
#endif
}

这段代码在干什么:上一帧的物理页(PrevMetaData)如何活到本帧——查本帧的
NextVirtualShadowMapData(C++ 侧 UpdateNextData 写入,VirtualShadowMapArray.cpp:905):
标志带 VSM_NEXT_FLAG_KEEP_PAGE(级缓存判定通过,第 5 章)→ 页地址加偏移平移(clipmap
panning)→ 地址合法 → 保留数据,只改归属(VSM ID + 页地址)。bKeepPage=false 的页
直接清标志——下帧分配器看到 Flags==0 就当它是空页。**”平移”在 GPU 上就是一次
加法 + 一次写**——这就是开放大世界阴影便宜的底层原因。


第 8 章 缓存与失效:让阴影”记住过去”

TL;DR|VSM 的性能灵魂是缓存,缓存的敌人是变化。三个层次的复用:级缓存(clipmap
级平移)、页缓存(KEEP_PAGE + LRU)、静态缓存(100 帧无失效转静态层,存档复用)。
失效由缓存键驱动:FClipmapCacheKey 里存着太阳方向FLocalLightCacheKey 里存着
光的变换矩阵——键一变,整帧失效走 uncached 路径(每帧全量重光栅)。本章埋下
第 13 章(动态太阳优化)的伏笔。类比:存档系统——键是存档版本号,版本号变了
只能从头打

8.1 缓存键:什么变化会杀死缓存

缓存管理器 FVirtualShadowMapArrayCacheManagerCacheManager.h:242)用缓存键区分”这帧的阴影依赖什么”。三层键:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 通用:这帧属于哪个视图、哪盏灯、哪种阴影类型
struct FVirtualShadowMapCacheKey
{
uint32 ViewUniqueID;
FLightSceneId LightSceneId;
uint32 ShadowTypeId;
};

// 方向光(clipmap)专用:太阳方向 + 起始级
struct FClipmapCacheKey
{
FVector LightDirection; // ← 太阳方向(第 13 章的主角)
int FirstLevel = 0;
};

// 局部光专用:光的变换 + 平移 + 是否 distant
struct FLocalLightCacheKey
{
FMatrix WorldToLight; // ← 光的完整变换(旋转/位移)
FVector PreShadowTranslation;
bool bIsDistantLight = false;
};

判定逻辑FVirtualShadowMapPerLightCacheEntry::UpdateCacheManager.cpp:362):

1
2
3
4
5
6
7
8
9
10
11
if (bForceInvalidate || InKey != OutCacheKey)
{
RenderedFrameNumber = -1; // 键变了 → 标记"从未渲染"→ 走 uncached
}
...
// If the cache was invalidated for any reason (light movement, etc), we render the next frame as
// uncached as this is more efficient. Thus continuously moving lights will automatically take the
// uncached path always without needing to explicitly set ForceInvalidateDirectional. After one static
// frame though we will swap back so that we can begin establishing static cache data. Thus it is still
// useful to explicitly set ForceInvalidateDirectional in cases where the light is invalidating frequently
// but not every single frame to keep the performance consistent.

这段注释是全文档最重要的注释之一,逐句翻译:

  1. *”任何原因失效(灯移动等)→ 下一帧按 uncached 渲染,这更高效”*——失效帧不尝试部分复用,整帧走无缓存路径;
  2. *”持续移动的灯自动永远走 uncached 路径,不需要显式设置 ForceInvalidateDirectional”*——太阳每帧转 = 永远 uncached
  3. *”静止一帧后切回缓存路径,开始建立静态缓存数据”*——太阳停住 → 下帧恢复缓存;
  4. *”在灯’频繁但非每帧’失效的情况下,显式 ForceInvalidateDirectional 仍有用——保持性能一致”*——官方 CVar 的语义在这里。

8.2 静态缓存:100 帧规则

Cache.FramesStaticThreshold=100CacheManager.cpp:142-144):一个对象连续 100 帧没有失效(没移动、没被键变化波及)→ 转静态缓存(CachePrimitiveAsDynamic 位数组翻转,CacheManager.h:497)。

  • 动态页:画进物理池 layer 0,随时可重画;
  • 静态页:画进 layer 1,此后只要不失效就永不重画——失效的静态页走”清静态层 → 重画”。

为什么 100 帧? 60fps 下约 1.7 秒。太短则”刚转正就失效”的震荡(页在两层间反复搬家);太长则静止场景的收益来得慢。100 是 Epic 实测的平衡点。

8.3 uncached 路径:缓存失效后的”重打一遍”

当键不匹配或强制失效时,本帧走 uncached 路径VSM_PROJ_FLAG_UNCACHEDDefinitions.h:61):

  • 只渲染动态层(静态层内容不可信);
  • 不尝试页表平移/复用;
  • 每帧全量光栅化(与 CSM 的代价形态相同,但加上页表与标记开销 → 更贵)。

这是第 13 章”动态太阳光优化”的全部问题根源:真实昼夜循环里太阳每帧转 → 缓存键永远不匹配 → 永远 uncached → 每帧全量画阴影。优化 = 让太阳”少动”(阶梯式)或”动得便宜”(降分辨率),第 13 章展开。

8.4 dirty flags 与失效传播

dirty flags(6 slice,VSM_DIRTY_PAGE_FLAGS_NUM_SLICES):光栅化时写”这页画过了/画脏了”,帧末 PostRenderUpdateAndClearDirtyFlagsVirtualShadowMapArray.cpp:1963)合并进页元数据并清零——HZB 更新只重建 dirty 页(省大量计算)。

失效传播ProcessInvalidationsCacheManager.cpp:1852):primitive 移动/增删时,FInvalidatingPrimitiveCollector 收集变更 → GPU 端 InvalidateInstancePagesLoadBalancerCSVirtualShadowMapCacheLoadBalancer.usf:28)逐实例把包围盒变换到阴影视锥,把”这个实例覆盖的页”标记失效(InterlockedOr 写页请求标志的失效位)。

失效也要被剔除VirtualShadowMapCacheInvalidation.ush:124-159):先 OverlapsAnyValidPage 跳过没有页分配的区域,再对每个候选页做 HZB 遮挡测试IsPageVisibleHZB)——看不见的失效不处理,这是 Cache.InvalidateUseHZB=1CacheManager.cpp:105)的机制:远处移动的物体不再引发大量失效。

8.5 淘汰策略:页龄与灯龄

策略 默认 位置
页龄上限 MaxPageAgeSinceLastRequest=1000 CacheManager.cpp:129
灯龄上限 MaxLightAgeSinceLastRequest=10 CacheManager.cpp:136
LRU 分配 Cache.AllocateViaLRU=1 VirtualShadowMapArray.cpp:527
延迟失效预算 DeferredInvalidationBudget=-1(无界) VirtualShadowMapArray.cpp:120

灯龄比页龄短得多——离屏的灯(未被引用)只保留 10 帧缓存页(UpdateUnreferencedCacheEntries),而页龄 1000 帧 ≈ 16 秒是”页池容量管理”的兜底。

8.6 源码考古:级缓存判定的完整代码

源码考古:想看真东西的人进。

第 5 章给过 UpdateClipmapLevel 的核心分支,这里补上完整逻辑(CacheManager.cpp:259-314):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
void FVirtualShadowMapCacheEntry::UpdateClipmapLevel(
const FVirtualShadowMapPerLightCacheEntry& PerLightEntry,
FInt64Point PageSpaceLocation, double LevelRadius,
double ViewCenterZ, double ViewRadiusZ,
double WPODistanceDisableThresholdSquared)
{
// Not valid if it was never rendered
bool bCacheValid = (PerLightEntry.RenderedFrameNumber >= 0);
// Not valid if radius has changed
bCacheValid = bCacheValid && (ViewRadiusZ == Clipmap.ViewRadiusZ);
// Invalidate if the new Z radius strayed too close/outside the guardband of the cached shadow map
if (bCacheValid)
{
double DeltaZ = FMath::Abs(ViewCenterZ - Clipmap.ViewCenterZ);
if ((DeltaZ + LevelRadius) > 0.9 * Clipmap.ViewRadiusZ)
{
bCacheValid = false; // Z 中心越出保护带 → 失效
}
}
// Not valid if WPO threshold has changed
if (bCacheValid && CVarClipmapWPODisableDistanceInvalidate.GetValueOnRenderThread() != 0
&& (WPODistanceDisableThresholdSquared != Clipmap.WPODistanceDisableThresholdSquared))
{
bCacheValid = false; // WPO 阈值变 → 失效(分辨率/FOV 变化会触发)
}
if (bCacheValid)
{
// KEEP_PAGE + 页表平移
FInt64Point CurrentToPreviousPageOffset(PageSpaceLocation - Clipmap.PageSpaceLocation);
NextData.Flags = VSM_NEXT_FLAG_KEEP_PAGE;
NextData.PageAddressOffset = FIntVector2(CurrentToPreviousPageOffset.X, CurrentToPreviousPageOffset.Y);
}
else
{
NextData.Flags = 0u; // 失效:重建
NextData.PageAddressOffset = FInt32Point(0, 0);
Clipmap.ViewCenterZ = ViewCenterZ; // 记录新基准
Clipmap.ViewRadiusZ = ViewRadiusZ;
Clipmap.WPODistanceDisableThresholdSquared = WPODistanceDisableThresholdSquared;
}
Clipmap.PageSpaceLocation = PageSpaceLocation;
}

这段代码在干什么:级缓存的”四条件判定”(① 上帧渲染过 ② Z 半径不变 ③ Z 中心在
guardband 内 ④ WPO 阈值不变)→ 有效则 KEEP_PAGE + 平移偏移;无效则记录新基准
ViewCenterZ/ViewRadiusZ/WPODistanceDisableThresholdSquared)供下帧比较。注意
0.9 * Clipmap.ViewRadiusZ0.9 保护带——Z 中心可以移动 10% 的半径而不失效,
这就是”缓存防抖”的具体数值。

第 9 章 光栅化与合并:Nanite 与非 Nanite 双路径

TL;DR|分配完页之后,谁把阴影深度画进物理页?两条路:Nanite 路径(几何走 Nanite
GPU 驱动光栅化,输出端做”虚拟页→物理页”重定向,直接写物理页池)和非 Nanite 路径
(传统网格复用 CSM 的 mesh pass,但按页裁剪实例、在 16384² 虚拟视口里画)。帧末
PostRender 做三件事:合并静态页进动态层(双线性采样需要)、PrefilteredDistant 远处
预过滤、HZB 更新。类比:两家快递公司送同一批货——Nanite 是智能分拣流水线(GPU 驱动),
传统网格是人工分拣(逐实例裁剪)

9.1 Nanite 路径:物理页池就是”深度目标”

RenderVirtualShadowMapsNaniteVirtualShadowMapArray.cpp:4343)把 Nanite 渲染器整条搬来,只换三样东西:

1
2
3
4
5
6
7
8
9
const Nanite::FRasterContextInitParams RasterInitParams =
{
.TextureSize = VirtualShadowSize, // 物理池尺寸(2048 页 × 128 = 262144)
.TextureRect = VirtualShadowViewRect, // 视口 = 整个物理池
.RasterMode = Nanite::EOutputBufferMode::DepthOnly, // 只写深度
.ExternalDepthBuffer = PhysicalPagePoolRDG, // ← 深度目标 = 物理页池!
.bClearTarget = false, // 不清(页按需清)
...
};

核心思想:Nanite 平时把深度写进 VisBuffer(第 11 章),VSM 模式下把深度直接写进物理页池——光栅化器通过 VIRTUAL_TEXTURE_TARGET 编译排列(Nanite 所有剔除/光栅 shader 都带这个布尔排列)感知”我在画阴影”。

页翻译CreateRasterNaniteRasterizer.usf:251-296):每个可见簇的光栅化参数里,把”虚拟页坐标”翻译成”物理页坐标”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#if VIRTUAL_TEXTURE_TARGET
Raster.vPage = VisibleCluster.vPage;
// 单页阴影(簇只落在一个页内):直接查页表拿物理地址
if (Raster.bSinglePage)
{
FShadowPhysicalPage PhysicalPage = ShadowGetPhysicalPage(
CalcPageOffset(NaniteView.TargetLayerIndex, NaniteView.TargetMipLevel, Raster.vPage));
Raster.pPage = PhysicalPage.bThisLODValidForRendering ? PhysicalPage.PhysicalAddress : 0xffff;
}
// 静态缓存簇画进静态层(layer 1),否则动态层(layer 0)
const bool bCacheAsStatic = (VisibleCluster.Flags & NANITE_CULLING_FLAG_CACHE_AS_STATIC) != 0u;
Raster.ArrayIndex = bCacheAsStatic ? GetVirtualShadowMapStaticArrayIndex() : 0;
// 视口平移:虚拟坐标 → 物理坐标的偏移
Raster.vTranslation = ((float2)Raster.pPage - (float2)Raster.vPage) * VSM_PAGE_SIZE;
Raster.ViewportBias += Raster.vTranslation;
#endif

这段代码在干什么vTranslation 把光栅化的窗口从”虚拟页位置”挪到”物理页位置”——簇在虚拟空间算出的屏幕坐标,加上平移后直接落在物理池的正确位置。ArrayIndex 决定画进动态层还是静态层(GetVirtualShadowMapStaticArrayIndexPageAccessCommon.ush:395)。NANITE_LATE_VSM_PAGE_TRANSLATION 分支则把翻译推迟到像素写入时(组缓存优化)。

剔除联动:Nanite 的 FBoxCull::HZB 在 VSM 模式下用页级 HZB——先把屏幕矩形裁剪到”已映射页区域”(VirtualShadowMapGetUncachedScreenRect),再 OverlapsAnyValidPage 测试(NaniteCullingCommon.ush:595-617);两遍遮挡的主遍用上一帧页表CacheManager->GetPrevBuffers()),post pass 用本帧页表(NaniteCullRaster.cpp:6884/7112)——HZB 是上帧的,页表也必须配上一帧的,否则”上帧哪里被挡”就查错了。

深度输出:像素着色器 EmitShadowMapPSNaniteExportGBuffer.usf:229)从 VisBuffer 深度缓冲取深度,按投影类型(标准/正交/透视)换算后写 SV_Depth——正交阴影用 1 - DeviceZ + DepthBias,透视阴影用 ViewToClip 换算。

9.2 非 Nanite 路径:传统网格的”逐页快递”

没有 Nanite 的几何(普通静态网格、骨骼网格、植被)走 RenderVirtualShadowMapsNonNaniteVirtualShadowMapArray.cpp:4515):

  1. 复用 CSM 的 mesh pass:每个 FProjectedShadowInfo::GetShadowDepthPass()EShadowDepthType::VSMShadowDepthRendering.cpp:383)——阴影深度渲染的基础设施与 CSM 共享;
  2. 按页裁剪实例CullPerPageDrawCommandsCsVirtualShadowMapBuildPerPageDrawCommands.usf:155):逐实例 × 逐页判断”这个实例的包围盒覆盖这页吗?这页被请求了吗?”——输出 FVSMVisibleInstanceCmd(页信息 + 实例 ID + indirect 参数索引);
  3. 批量光栅化FInstanceCullingMergedContext::MergeBatches 把多个阴影的实例裁剪合并成一次提交(SinglePassBatched=1VirtualShadowMapArray.cpp:638),在 16384×16384 虚拟视口里用 indirect args 绘制——一个 pass 画完所有非 Nanite 阴影
  4. HZB 剔除NonNanite.UseHZB=1(:477)让非 Nanite 实例也享受两遍遮挡剔除。

避坑:非 Nanite 的 VSM 需要单独的项目开关 r.Shadow.Virtual.NonNanite.ProjectEnabled
(默认开),且 shader 需要 SM5(VirtualShadowMapShaders.h:27-32)。
场景里非 Nanite 几何越多,这条路径越重——第 11 章会讲”把资产转 Nanite”为什么是
大世界 VSM 优化的第一件事。

9.3 PostRender:合并、预过滤、HZB

帧末 PostRenderVirtualShadowMapArray.cpp:1953)四步:

  1. UpdateAndClearDirtyFlags(:1963):合并光栅阶段的 dirty 标志进页元数据,清零;
  2. SelectPagesToPostProcess(:1988):选出需要后处理的页(静态合并 + 预过滤两类);
  3. MergeStaticPhysicalPagesIndirect(:2019,shader 在 :1077):静态页内容拷进动态层——静态页平时躺在 layer 1,双线性采样需要邻居数据在可读位置,合并后采样才能混合新旧(DebugSkipMergePhysical 调试开关可跳过,会产生明显错误);
  4. PrefilteredDistant(:2036-2175):远距离页做可分离模糊(SeparableX → SeparableY)+ 历史重投影HistoryBlendFactor=0.87,与上一帧的预过滤结果混合,抖动用时域平滑)→ 产出带 1 texel border 的合并池。

PrefilteredDistant 是什么? 远级(L18+)的阴影 texel 密度极低(每 texel 数十米),直接采样会闪。预过滤 = 在阴影页上做低通滤波 + 时域累积,远处阴影从”闪烁的点”变成”稳定的糊”——质量下降换取缓存稳定,这是开放大世界远处的标准解法(第 11 章详述)。

9.4 源码考古:Nanite VSM 的页翻译

源码考古:想看真东西的人进。

页翻译的完整上下文(NaniteRasterizer.usf:262-296)已在 9.1 展示。补充两个联动点:

① 剔除侧NaniteCullingCommon.ush:606-616

1
2
3
4
5
6
7
8
9
10
11
#if VIRTUAL_TEXTURE_TARGET
BRANCH
if( bIsVisible )
{
FVirtualShadowMapHandle VirtualShadowMapHandle = FVirtualShadowMapHandle::MakeFromId(NaniteView.TargetLayerIndex);
// Clamp the screen rect to the mapped page area.
Rect = VirtualShadowMapGetUncachedScreenRect( Rect, VirtualShadowMapHandle, NaniteView.TargetMipLevel );
bIsVisible = OverlapsAnyValidPage( VirtualShadowMapHandle, NaniteView.TargetMipLevel, Rect, PageFlagMask, bDetailGeometry, bUseReceiverMask );
RectPages = VirtualShadowMapGetPageRect( Rect );
}
#endif

这段代码在干什么:Nanite 剔除簇时,VSM 模式下额外问一句”这个簇的屏幕矩形
覆盖了已分配的页吗
“(OverlapsAnyValidPagePageOverlap.ush:91)——没覆盖就不画(页都没分配,画了也白画,还浪费)。NaniteView.TargetLayerIndex 就是”我画进哪张 VSM”(由 AddRenderViewsClipmap 在 C++ 侧填充,VirtualShadowMapArray.cpp:5144)。

② 输出侧NaniteExportGBuffer.usf:229-260(节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void EmitShadowMapPS(in float4 SvPosition : SV_Position, out float OutDepth : SV_Depth)
{
uint DepthInt = DepthBuffer[PixelPos];
if (DepthInt != 0)
{
float DeviceZ = asfloat(DepthInt);
#if DEPTH_OUTPUT_TYPE == 1
OutDepth = 1 - DeviceZ + DepthBias; // 正交阴影
#else
OutDepth = ViewToClip22 * (DeviceZ - 1) / (DeviceZ - ViewToClip22) + DepthBias; // 透视阴影
#endif
}
else { discard; }
}

这段代码在干什么:Nanite 的 VisBuffer 深度是”前序编码”(更近 = 更大),阴影贴图要的是
“光源视角的深度”——正交投影下取反 + bias(防自阴影 acne),透视投影下做 Z 换算。
discard 处理”没有几何”的像素(页已被清为最远深度,这里不用再写)。


第 10 章 投影与软阴影:从 16384² 到屏幕

TL;DR|画完阴影页,最后一步是投影:屏幕每个像素算自己落在哪盏灯的哪个(级,页),
查页表拿物理地址、读深度、比较,得出”被挡/没被挡”。质量旋钮:SMRT(阴影贴图光线追踪,
方向光 7 条射线/8 次采样默认)做软阴影、屏幕空间 ray march 提前缩短射线(省 SMRT 步数)、
NormalBias 防自阴影。局部光走 OnePass 投影(全部灯打包进 MaskBits,每灯 4 bit)。
类比:查档——每个像素拿自己的”地址”去档案馆(页表)查档,翻到那页(物理页)比对

10.1 投影管线:两条路进屏幕

路径 流程 位置
逐灯全屏投影 方向光 RenderVirtualShadowMapProjection 全屏 CS → ScreenShadowMask Projection.cpp:596
OnePass 投影 局部光 RenderVirtualShadowMapProjectionMaskBits 全部灯打包进 MaskBits(每灯 4bit)→ 光照 pass 解包 ShadowSceneRenderer.cpp:907

方向光始终走逐灯全分辨率投影(ShadowSceneRenderer.cpp:1066);局部光 OnePass 是性能关键——一次分派处理所有局部光阴影,光照 shader 直接采样 MaskBits 而不是逐灯查阴影贴图(OnePassProjection.MaxLightsPerPixel=16 限制每像素灯数,超限走回退)。

10.2 全屏 CS:每个像素的”查档”流程

VirtualShadowMapProjectionVirtualShadowMapProjection.usf:794,工作 tile 8×8,WORK_TILE_SIZE 定义在 Composite.usf:8):

1
2
3
4
5
6
7
8
9
10
每个像素(tile 内 Morton 序,缓存友好):
① 读场景深度 → 重建世界坐标(SvPositionToTranslatedWorld)
② 方向光:按"到 clipmap 原点的距离"选级
CalcBiasedAbsoluteClipmapLevelForSampling(ProjectionCommon.ush:48)
= 绝对级 + DOF 偏置 + 该级的 ResolutionLodBias
③ 查页表:ShadowVirtualToPhysicalUV(PageAccessCommon.ush:357)
→ 物理页地址 + LOD 偏移
④ 读物理深度:SampleVirtualShadowMapPhysicalDepth(:166)
⑤ 深度比较 → 阴影因子
⑥ 软阴影(SMRT)+ 屏幕空间 ray march(见 10.3)

Morton 序:tile 内线程按 Z 曲线访问(MortonDecode(GroupIndex))——同一 tile 的像素在页表/物理池里的访问地址相近,缓存命中率高(和 Nanite ShadeBinning 的 ZOrder2D 同款技巧)。

10.3 SMRT 软阴影:阴影贴图上的光线追踪

SMRT(Shadow Map Ray Tracing) 是 VSM 的软阴影方案:在阴影贴图空间里沿光线方向走几步(默认方向光 7 步 × 8 次采样,SMRT.RayCountDirectional=7/SamplesPerRayDirectional=8VirtualShadowMapArray.cpp:729/736),每步采样阴影深度做遮挡累积——**把”点采样阴影”变成”区域平均阴影”**,产生软半影(接触阴影硬化、远处阴影柔化)。

实现是模板化的(SMRTRayCastVirtualShadowMapSMRTTemplate.ush:26,文件无 include guard,可被多次包含实例化——方向光/局部光/头发各一份特化)。

屏幕空间 ray marchVirtualShadowMapScreenRayCastVirtualShadowMapScreenRayTrace.ush:12):在屏幕深度缓冲里先走一遍射线(SCREEN_RAY_SAMPLES 步),如果射线撞到屏幕上的几何就提前停——SMRT 不需要从接收面出发走全程,屏幕空间已告诉它”哪里可能有遮挡”。r.Shadow.Virtual.ScreenRayLength=0.015(世界空间比例)控制屏幕射线长度。

避坑:SMRT 的成本与”射线数 × 采样数”线性相关。低端配置在 BaseScalability
SMRT.RayCountDirectional=0([ShadowQuality@0],BaseScalability.ini:151)——直接关闭 SMRT 退回硬阴影,这是移动端/低端 PC 的默认选择。

10.4 源码考古:跨级采样与深度读取

源码考古:想看真东西的人进。

① 方向光选级VirtualShadowMapProjectionCommon.ush:47-75(节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
float CalcBiasedAbsoluteClipmapLevelForSampling(
FVirtualShadowMapProjectionShaderData BaseProjectionData,
float DistanceToClipmapOriginSquared, float SceneDepth)
{
float AbsoluteLevel = CalcAbsoluteClipmapLevel(DistanceToClipmapOriginSquared);
float BiasedLevel = AbsoluteLevel;
#if VSM_WITH_DOF_BIAS
float DOFResolutionBias = CalculateResolutionBiasFromDepthOfField(SceneDepth);
BiasedLevel += DOFResolutionBias; // 景深模糊处可以放心用更粗的级
#endif
...
// 从基础级偏移到实际 VSM,叠加每 VSM 的分辨率偏置
float PerVSMBias = GetVirtualShadowMapProjectionData(VSMHandle).ResolutionLodBias;
BiasedLevel += PerVSMBias;
...
}

这段代码在干什么:像素离 clipmap 原点越远 → 绝对级越大(越粗的级);DOF 偏置让
景深虚化区域用更粗的级(人眼看不见,省性能);每 VSM 的 ResolutionLodBias
构建期算好的”移动/静止分辨率偏置”(第 5 章)加进来。选级 = 距离 + DOF + 分辨率偏置
三者的叠加

② 深度读取VirtualShadowMapProjectionCommon.ush:166-173

1
2
3
4
float SampleVirtualShadowMapPhysicalDepth(uint2 PhysicalTexelAddress)
{
return asfloat(VirtualShadowMap.PhysicalPagePool.Load(uint4(PhysicalTexelAddress, 0, 0)));
}

这段代码在干什么asfloat 把物理池里打包的 uint 深度还原成 float——页池 texel
是 R32_UINT 存储(光栅化写入位深度的位模式),读时按位还原。PhysicalTexelAddress
由 9.1 的 ShadowVirtualToPhysicalUV 算好:物理页地址 × 页大小 + 页内偏移。

第 11 章 专题一:开放大世界的 VSM 优化

TL;DR|大世界阴影的敌人不是”阴影多”,而是”每帧重新画“。VSM 的解药是让阴影
“记住”:相机移动时级缓存平移复用(纸带平移,O(1) 改指针)、静止对象 100 帧后
转静态缓存(存档复用)、远距离预过滤合成(PrefilteredDistant)、远处灯轮流更新
(MaxDistantUpdatePerFrame)。内容侧的配合:WPO 禁用距离、资产转 Nanite、控制失效源。
本章给出一套从 5 分钟调优到 1 天调优的完整路线图(11.10)。
类比:搬家公司的纸箱策略——书架整体平移、装好的箱子不重装、远处杂物柜拍张照片带走

11.1 大世界阴影的账本:成本模型

CSM 的成本:每帧全量重光栅化,成本 ∝ 阴影距离 × 场景几何。社区实测(ixueyouxi《UE5.8 大世界光照与阴影策略》):阴影距离 8000→15000,Shadow Depths 2.1ms→4.8ms——世界越大越贵,与画面变化无关

VSM 的成本:标记 + 分配 + 光栅(只花在”需要更新的页”)+ 投影 + 缓存管理。把成本拆开看:

1
2
3
4
5
6
7
每帧 VSM 成本 ≈
页标记(深度缓冲分析,与场景大小弱相关)
+ 页分配(页数 × 常数,GPU 前缀和)
+ 光栅化(新页 + 失效页 + 移动物体页 —— 关键项!)
+ 合并/预过滤(合并页数)
+ 投影(屏幕像素 × SMRT 采样数,与分辨率相关)
+ 缓存管理(页表平移等,几乎免费)

关键结论:光栅化成本跟着”变化量”走——相机平移只画边缘新页、太阳不动则静态缓存全命中、静止场景的光栅成本趋近于零。**优化 = 减少”每帧需要重画的页数”**,下面五个解药全是这个公式的展开。

11.2 核心解药一:clipmap 级缓存平移

机制回顾(第 5.4 章):相机平移 → 各级独立判定 → 四条件通过 → KEEP_PAGE + PageAddressOffset 页表平移 → 只画新露出的边缘页。

大世界的收益量化:假设相机以速度 v 移动,某级半径 R。每帧新露出的边缘面积 ≈ 周长 × 移动距离 = 2πR × v·Δt;级总面积 = πR²。新页占比 ≈ (2v·Δt)/R。R 越大(远级),新页占比越小——远级几乎零成本,近级是主要开销。这正好与”近处重要”的需求匹配:成本集中在近处,远处白送

实践要点

  1. **别让相机”抖”**:相机抖动(瞬移/传送)会让所有级同时失效——大世界传送时接受一次全量重建(或用 r.Shadow.Virtual.Cache.ForceInvalidateDirectional 主动控制时机);
  2. 级数宁多勿少Clipmap.FirstLevel/LastLevel 覆盖不足时,最近/最远的物体阴影消失或过糊——但每级虚拟分辨率固定 16384²,级数只影响页表与少量分配开销(空级近零成本,官方文档语),大世界默认 6~22 覆盖足够;
  3. FOV 变化也会失效ResolutionLodBias 里的 Log2(LodScale) 随视口宽度变化(第 5.7 章),切 FOV 会触发级失效——固定 FOV 的游戏不受影响。

11.3 核心解药二:静态页缓存

机制回顾(第 8.2 章):100 帧无失效 → 转静态层 → 只要不失效永不重画。

大世界的收益:开放世界绝大部分资产是静态的(建筑、地形、岩石、道路)。场景一旦静止,全部静态页缓存命中——光栅成本归零,只剩投影。实测感知(Stray Spark Studio《Virtual Shadow Map Optimization for Open Worlds in UE5.7》):关闭场景里装饰性动画道具(旋转的风车、摇摆的旗帜)的阴影投射,可省约 0.9ms——因为每个动画道具都在持续制造失效。

实践要点

  1. 静态资产优先 Nanite:Nanite 资产可以走 NANITE_CULLING_FLAG_CACHE_AS_STATICNaniteDefinitions.h:188)快速转静态;非 Nanite 静态网格同样支持,但页裁剪路径更重;
  2. 别让”静止”变”微动”:程序化放置的资产如果每帧有微小变换(哪怕 0.01 单位),永远不会满 100 帧——CachePrimitiveAsDynamic 或关卡设计保证真静止
  3. 调试r.Shadow.Virtual.Visualize.CachedPage(可视化模式)看哪些页是静态命中;r.Shadow.Virtual.Cache.DebugSkipDynamicPageInvalidation=1 临时关掉动态失效,对比最大缓存收益。

11.4 核心解药三:PrefilteredDistant

机制回顾(第 9.3 章):远级页做可分离模糊 + 历史重投影(HistoryBlendFactor=0.87)。

为什么远处需要它:远级 texel 密度极低(L20 每 texel ≈ 1.6 米),直接采样阴影会随相机移动剧烈闪烁——一 texel 的变化就是 1.6 米的阴影位移。预过滤把”高频闪烁”变成”低频稳定”,配合时域累积(87% 历史 + 13% 新帧),远处阴影稳定且便宜。

实践要点

  1. 开关r.Shadow.Virtual.PrefilteredDistant.ProjectEnable(默认关)——开启后方向光远级自动走预过滤路径;
  2. 联动:预过滤页有自己的物理池(PrefilteredDistantPhysicalPagePoolMergedRDG,带 1 texel border),内存预算要算上;
  3. 何时需要:场景有大量远距离阴影可见(山谷、平原、城市天际线)时收益巨大;室内/封闭场景可关。

11.5 更新节流:远处灯轮流上班

MaxDistantUpdatePerFrame=1ShadowSceneRenderer.cpp:34-38,注释原文 “Maximum number of distant lights to update each frame. Invalidated lights that were missed may be updated in a later frame (round-robin)”):每帧最多更新 1 盏远处灯,被跳过的下帧补(round-robin)。远处小灯(路灯、篝火)的阴影不需要每帧精确——2 帧更新一次人眼完全无感。

DistantLightMode=1(:40-47,”lights with a pixel footprint below the threshold are marked as distant… Updates to distant lights are throttled (force-cached), they use simpler page-table logic and the memory cost is lower”):屏幕足迹小于阈值的局部光自动标记为 distant——节流更新 + 强制缓存 + 简单页表 + 更低内存。这就是”8192 个单页小灯槽”的由来:绝大多数局部光都该是单页小灯。

实践要点:路灯/篝火这类”多且不重要”的灯,确保它们被判为 distant(足迹小)——足迹大的灯(重要近景灯)不会节流,别把它们放在大世界里当路灯用。

11.6 WPO 禁用距离:内容侧的第一道保险

机制回顾(第 5.5 章):近级禁用 WPO(Clipmap.WPODisableDistance=1 + LodBias=3),阈值 2 的幂量化防连续失效。

内容侧的配合(Stray Spark 实测建议):

资产类型 建议 WPO 距离 原因
草/植被 30m 30m 内用静态阴影(风吹草动的阴影在近处本来就不该有)
80m 树的 WPO 摆动范围大,失效半径大

原理:WPO 资产每帧改变几何 → 覆盖的页每帧失效 → 一棵摆动的树可以废掉一大片页缓存。让 WPO 资产”超出 WPO 生效距离”(阴影按静态画)后,它们不再制造失效。

避坑Clipmap.WPODisableDistance.InvalidateOnScaleChange=0(默认关)意味着”分辨率/FOV
变化时 WPO 阈值变化不触发失效”——这是防抖优先的选择:宁可 WPO 状态错一帧,也不让
全部级一起失效。如果你改了它,观察画面有没有突然的阴影抖动。

11.7 页池预算管理:缺页的代价

页池默认 2048 页(r.Shadow.Virtual.MaxPhysicalPagesVirtualShadowMapArray.cpp:174),低画质档 512 页(BaseScalability.ini:146)。

缺页的表现:页池满 → 新请求挤掉 LRU 最旧页 → 被挤掉的页下次需要时重新光栅化——连续缺页 = 同一批页反复”画了扔、扔了画” → 块状闪烁(blocky flicker),这是 VSM 最著名的质量问题(官方文档承认的已知问题)。

内存公式(第 4.3 章):128×128×4B × 页数 × 2 层。2048 页 → 256MB 页池本体。512 页 → 64MB(低端档)。

预算策略(按优先序):

  1. 先降 ResolutionLodBias 再降页数ResolutionLodBiasDirectional/LocalShadowSceneRenderer.cpp:62/69)让所有页”更粗”(每页覆盖更大世界面积)——同样的页数覆盖更大的世界,这是”降档不降质”的首选;
  2. 页数按平台定(Stray Spark 实测:PS5 8192 页、XSS 6144 页——按显存预算逐平台设);
  3. **DynamicRes.MaxPagePoolLoadFactor=0.85**(CacheManager.cpp:183):页池占用超 85% 时动态分辨率接管,自动降级。

11.8 invalidation budget:给失效上预算

失效是”重画”的触发器,所以失效本身也要预算

  • r.Shadow.Virtual.DeferredInvalidationBudgetVirtualShadowMapArray.cpp:120,默认 -1 无界):Nanite LOD 变化触发的失效每帧限量处理——一帧太多失效就推迟到下一帧,把”一次大卡顿”摊成”几次小卡顿”(DEFERRED_INVALIDATION 标志,PageAccessCommon.ush:66);
  • HZB 剔除失效Cache.InvalidateUseHZB=1):看不见的失效不处理(8.4 章);
  • r.Shadow.Scene.LightActiveFrameCount=10ShadowScene.cpp:20-26):灯的”移动→静止”过渡拉长到 10 帧,MobilityFactor 在 10 帧内线性衰减——把失效与降分辨率摊开,避免突变。

11.9 与 World Partition 配合:流送区的阴影代价

开放大世界用 World Partition 流送区块,VSM 的配合要点:

  1. 流送区进出 = 大量 primitive 增删 = 大量失效ProcessInvalidations 每帧处理,流送高峰帧会有一次失效洪峰——用 DeferredInvalidationBudget 摊平;
  2. HLOD 切换同样触发失效:HLOD 替代(远处用简化网格)时包围盒变化 → 相关页失效——HLOD 切换尽量在”阴影不可见”的时机(摄像机快速移动时)发生;
  3. 内容侧:装饰性动画道具(风车、旗帜、粒子驱动的网格)阴影需求低——直接关 Cast Shadow(Stray Spark 省 0.9ms 的实测就是这类);
  4. 骨骼网格:角色/动物在开放世界永远在动 → 它们的页永远动态——这是合理开销,但远离镜头的骨骼网格(300m 外的人形)可以走 HLOD 或距离剔除(r.Shadow.Virtual.Culling.DrawDistance 类)。

11.10 实战调优路线图:五步法

图 F11 开放大世界 VSM 调优决策树(五步法)
① 看数字    r.Shadow.Virtual.ShowStats 1(或 r.Shadow.Virtual.Stats.Visible 1)
            关注:REQUESTED_THIS_FRAME_PAGES(本帧请求页)
                  STATIC_CACHED / DYNAMIC_CACHED / *_INVALIDATED
                  PAGES_TO_MERGE、NANITE_CLUSTERS_HW/SW
            判断:STATIC_CACHED 占比低 → 缓存没吃上 → 失效源太多
                  ALLOCATED_NEW 高 → 页池压力大
                  ──────────────────────────────────────
② 看画面    编辑器视图 → Virtual Shadow Map 可视化
            CachedPage(绿色=静态命中)/ GPUInvalidatedPage(红=被失效)
            Overview(总览)/ NaniteOverdraw(光栅过绘制)
            ──────────────────────────────────────
③ 先调大件   a) 页池告警(块状闪烁)→ 先 ResolutionLodBias 再 MaxPhysicalPages
             b) 静态命中率低 → 查失效源:WPO 资产距离、动画道具、程序化微动
             c) 远处闪烁 → PrefilteredDistant.ProjectEnable 1
             d) 灯太多 → 检查 DistantLightMode 是否生效(足迹小的灯是否被判 distant)
            ──────────────────────────────────────
④ 再调细节   WPO 禁用距离(草 30m/树 80m)→ DeferredInvalidationBudget
            → MaxDistantUpdatePerFrame → CullDynamicTightly
            ──────────────────────────────────────
⑤ 定案      ShowStats 前后对比,记录每平台页池预算表
            (PC 4096 / PS5 8192 / XSS 6144 之类的分级写入 ini)

TL;DR 级总结:大世界优化 = ① 让缓存吃上(静态化 + 去失效源)② 让远级便宜(预过滤)③ 让灯节流(distant)④ 页池预算(分辨率偏置优先)⑤ 内容侧(WPO 距离、关装饰阴影)。


第 12 章 专题二:昼夜变化循环

TL;DR引擎没有”昼夜系统”——昼夜是”动画”,不是引擎功能。它由五件套组成:
太阳光旋转、大气参数、SkyLight 实时捕获、云、曝光。核心机制是”大气-太阳耦合链“:
太阳高度角 → 大气透射率(LUT)→ 日盘亮度 → 晨昏时太阳变红变暗。对阴影系统而言,
太阳旋转 = 缓存键失效 = 每帧全量重光栅(第 13 章解决它)。本章给出一套可落地的
实践架构(Sequencer/蓝图)与全链路核对清单。
类比:舞台灯光师的调度——大灯沿轨道走,滤色片跟着变,补光灯要实时跟着

12.1 引擎里没有”昼夜系统”

搜索整个引擎源码:没有 UTimeOfDay 类、没有昼夜示例关卡。官方模板 TP_AEC_ArchvisBP 里的做法是 Sequencer 驱动太阳旋转Templates/TP_AEC_ArchvisBP/Content/.../Sequences/SunSky/)。

这意味着:昼夜循环是内容团队的设计——引擎只提供”太阳方向是数据、大气参数是数据、阴影系统响应这些数据”的机制。你的昼夜系统 = 一套”驱动这些数据的动画”。

12.2 昼夜五件套:谁负责什么

组件 昼夜中的职责 关键属性/CVar
DirectionalLight(太阳) 旋转(方向)、亮度、色温、阴影 LightSourceAngle(默认 0.5357°)、Intensity/LightColor(可蓝图插值)、bAtmosphereSunLight
SkyAtmosphere 天空颜色(散射)、日盘 大气参数(可运行时改)、OverrideAtmosphericLightDirection(解耦演出)
SkyLight 环境光(漫反射/镜面) **bRealTimeCapture**(实时捕获!)
VolumetricCloud 云、云阴影 bCastShadowsOnCloudsCloudShadowStrength
曝光/雾 整体亮暗 Auto Exposure、Height Fog

避坑:DirectionalLight 的 LightSourceAngleDirectionalLightComponent.h:133-138,注释原文 “Defaults to 0.5357 which is the angle for our sun”)不只是”日盘大小”——它还决定太阳阴影的软硬(半影宽度)。晨昏时调大它可以模拟”低角度太阳的柔和长影”。

12.3 大气-太阳耦合链:为什么晨昏的太阳是红的

核心机制在 PrepareSunLightProxySkyAtmosphereRendering.cpp:577-594),每帧把大气算出的数据写回灯光代理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static FLinearColor GetLightDiskLuminance(FLightSceneInfo& Light)
{
const float SunSolidAngle = 2.0f * PI * (1.0f - FMath::Cos(Light.Proxy->GetSunLightHalfApexAngleRadian()));
return Light.Proxy->GetAtmosphereSunDiskColorScale()
* Light.Proxy->GetOuterSpaceIlluminance() / SunSolidAngle; // 日盘亮度 = 辐照度 / 立体角
}

void PrepareSunLightProxy(const FSkyAtmosphereRenderSceneInfo& SkyAtmosphere, uint32 AtmosphereLightIndex, FLightSceneInfo& AtmosphereLight)
{
const FSkyAtmosphereSceneProxy& Proxy = SkyAtmosphere.GetSkyAtmosphereSceneProxy();
const FVector AtmosphereLightDirection =
Proxy.GetAtmosphereLightDirection(AtmosphereLightIndex, -AtmosphereLight.Proxy->GetDirection()); // 大气视角的太阳方向
const FLinearColor TransmittanceTowardSun =
Proxy.GetAtmosphereSetup().GetTransmittanceAtGroundLevel(AtmosphereLightDirection); // ← 地面朝向太阳的透射率

const FLinearColor SunDiskOuterSpaceLuminance = GetLightDiskLuminance(AtmosphereLight);

AtmosphereLight.Proxy->SetAtmosphereRelatedProperties(TransmittanceTowardSun, SunDiskOuterSpaceLuminance);
}

耦合链(这是全书最值得记的机制链):

1
2
3
4
5
6
7
太阳旋转(Rotation 组件)
→ GetDirection()(WorldToLight 矩阵第一行,LightSceneProxy.h:206)
→ GetAtmosphereLightDirection(大气视角方向)
→ GetTransmittanceAtGroundLevel(透射 LUT 查询)
太阳低 → 光线斜穿大气层 → 蓝光被散射掉 → 透射率偏红
→ 写回灯光:TransmittanceTowardSun(光色变红)+ SunDiskOuterSpaceLuminance(日盘变暗)
→ 阴影/光照/大气/云全部消费这两个值 → 晨昏的红色太阳与柔和光

实践要点:这套链是自动的——你只需要旋转太阳,红/暗/柔全自动。但注意两个前提:bAtmosphereSunLight 必须勾选(DirectionalLightComponent.h:160-170,注释 “Whether the directional light can interact with the atmosphere, cloud and generate a visual disk”);AtmosphereSunLightIndex(0/1)决定它是”太阳”还是”月亮”(双大气光源"Two atmosphere lights are supported. For instance: a sun and a moon, or two suns.")。

12.4 实践架构 A:Sequencer 方案(推荐)

官方模板的做法,适合”可预览、可剪辑、可横跨关卡”:

1
2
3
4
5
6
7
8
9
10
11
Sequencer 主序列(24 小时 = 全天)
├─ 太阳轨道(父 Actor 旋转 0°→360°)
│ └─ 太阳角度 = 时刻的函数:高度角 → 大气/阴影自动响应(12.3 的链)
├─ DirectionalLight 参数关键帧
│ ├─ Intensity 曲线(正午峰值、夜晚归零)
│ ├─ LightColor 色温曲线(晨昏暖色)
│ └─ LightSourceAngle(晨昏调大 → 柔和长影)
├─ SkyAtmosphere 参数关键帧(密度/高度/散射色,可选)
├─ SkyLight:bRealTimeCapture 常开,不需要关键帧
├─ VolumetricCloud:太阳方向自动(云跟随大气光)
└─ 曝光:Auto Exposure 或手动曲线

关键设计阴影光与视觉光可以分离——你可以在 Sequencer 里让”视觉太阳”(驱动大气的光)与”阴影太阳”(Cast Shadows 的光)是两个 DirectionalLight:视觉光连续旋转,阴影光按第 13 章的阶梯式步进——视觉连续、阴影阶梯,两全其美。这是昼夜 + VSM 场景的黄金结构。

12.5 实践架构 B:蓝图/数据驱动

适合”运行时动态”(天气系统联动、剧情控制):

1
2
3
4
5
6
7
GameMode / 天气管理器(Blueprint)
Tick:
当前时刻 → 太阳高度角/方位角(天文公式或查表)
→ DirectionalLight.SetWorldRotation(...)
→ 参数插值表(Intensity/Color 按时刻查曲线)
→ SkyAtmosphere 参数(可选)
→ 天气状态机(晴/阴/雨 → 云密度、雾、光强缩放)

月亮:第二个 DirectionalLight(AtmosphereSunLightIndex=1)当月亮,方向与太阳相反(绕 24 小时轴的相反相位);月光 LightSourceAngle 更小(月亮角直径 ≈ 0.52°)、强度约为太阳的 1/10⁵(人眼感知约 1/1000,靠曝光补偿)。

12.6 SkyLight 与反射更新:昼夜最容易漏的一环

  • SkyLight bRealTimeCaptureSkyLightComponent.h:105-108):实时捕获天空并卷积成环境光。关闭 = 天空光永远是初始帧的样子——白天亮着、夜晚也亮着,昼夜感全毁。开启有成本(每帧或每几帧捕获),但昼夜场景必须开;
  • Lumen:屏幕探针/辐射缓存自动消费 SkyLight 的辐照度,无需额外开关;
  • 移动端避坑静态反射探针不随昼夜更新——移动端没有 Lumen 时,反射环境是烘焙的,夜晚反射还闪着白天的光。要么接受(低配妥协),要么用实时反射捕获(成本高)或减少反射面。

12.7 阴影系统在昼夜中的行为:24 小时性能曲线

太阳一天转 360°,各阴影系统如何响应:

系统 太阳移动时的行为
CSM(Movable 光) 每帧全量重光栅化,成本恒定(不受太阳影响,但也从不复用)
CSM(Stationary 光) IsCachedShadowValidSceneManagement.h:1011)逐项比较 WorldToLight → 太阳一转就失效 → 重渲染(保留缓存结构)
VSM FClipmapCacheKey.LightDirectionCacheManager.h:135每帧不匹配 → 每帧全量 uncached → 成本 = CSM 成本 + VSM 开销
VSM + 静态缓存 静止期间转静态 → 太阳一动全失效 → 昼夜过渡的瞬间是性能尖峰
图 F12 24 小时阴影成本曲线(日出日落两处尖峰,正午/午夜低谷)
阴影光栅成本
  ▲
  │         ╱╲
  │        ╱  ╲            ← 日出/日落:太阳快速移动 + 角度变化 → 缓存全失效
  │       ╱    ╲
  │      ╱      ╲                ╱╲        ← 另一个尖峰(日落)
  │  ───╱────────╲──────────────╱  ╲
  │    正午      ╲            ╱    午夜
  │  (太阳最慢,  ╲          ╱   (月亮上岗,太阳光消失
  │   缓存最稳)    ╲        ╱      → 阴影成本反而低)
  └──────────────────────────────────────────▶ 时刻
    0:00     6:00(日出)  12:00   18:00(日落)  24:00
  实际形状取决于:太阳角速度(赤纬)、WPO 资产、阶梯式步进(13.6)

为什么日出日落是尖峰:太阳在低角度时角速度不变但阴影变化最剧烈(影子最长、移动最快)——同角度变化下,低角度太阳的阴影位移更大;且晨昏时大气让光变弱,人眼对阴影细节的敏感度反而高。优化尖峰 = 第 13 章的阶梯式太阳

12.8 演出技巧:大气与光解耦

GetAtmosphereLightDirectionSkyAtmosphereComponent.cpp:642-649):

1
2
3
4
5
6
7
8
FVector FSkyAtmosphereSceneProxy::GetAtmosphereLightDirection(int32 AtmosphereLightIndex, const FVector& DefaultDirection) const
{
if (OverrideAtmosphericLight[AtmosphereLightIndex])
{
return OverrideAtmosphericLightDirection[AtmosphereLightIndex]; // 大气用我指定的方向
}
return DefaultDirection; // 默认 = 灯光方向
}

用途OverrideAtmosphericLightDirection大气天空的”太阳位置”与灯光方向解耦——比如剧情里”太阳已落山,但天空还留着晚霞”(大气方向停留在日落前的角度,灯光已经转到夜晚)。这是昼夜演出的常用技巧:天空归天空,光照归光照

12.9 全链路核对清单

时刻 太阳高度角 大气 SkyLight 阴影系统 预期性能
黎明前 < 0° 深蓝/微光 暗(实时捕获) VSM uncached(太阳在转)
日出 ≈ 0° 红/橙(透射率偏红) 亮起 全失效尖峰
上午 30~60° 阶梯式步进中(若启用) 中低
正午 90° 亮蓝 最亮 太阳角速度最慢,缓存最稳
日落 ≈ 0° 橙红 转暗 全失效尖峰
夜晚 < 0° 深蓝/黑 太阳光消失;月光(若有)走同样的键 低~中

第 13 章 专题三:动态太阳光优化

TL;DR|太阳是”持续移动光”——每帧方向都变,而 VSM 的缓存键里正好存着方向
FClipmapCacheKey.LightDirection),于是太阳一动、缓存全失效、自动落入 uncached 路径
(每帧全量光栅化)。优化三板斧:官方旋钮(MobilityFactor 自动降分辨率、MaxDistantUpdatePerFrame
节流、ForceInvalidateDirectional 主动控制)、实践技巧(阶梯式太阳:N 帧步进一次、
暂停式太阳:战斗/过场停表)。本章给出”昼夜 + 动态太阳 + VSM”的黄金参数组合。
类比:演唱会追光灯——灯跟着歌手持续动最费电;对策是灯别抖(阶梯式)、
不用全亮度(降分辨率)、只在关键瞬间追(暂停式)

13.1 问题根源:缓存键里的 LightDirection

第 8 章的伏笔在此兑现。FClipmapCacheKeyCacheManager.h:135-140):

1
2
3
4
5
struct FClipmapCacheKey
{
FVector LightDirection; // ← 太阳方向
int FirstLevel = 0;
};

太阳每帧旋转 → LightDirection 每帧变 → InKey != OutCacheKeyRenderedFrameNumber = -1 → 本帧 uncached → 每帧全量重光栅化。没有例外,没有折中——这是设计使然(阴影内容确实依赖光方向,缓存了就是错的)。

图 F13 太阳每帧旋转 → 缓存键每帧不匹配 → 每帧全量光栅
  帧 N      帧 N+1     帧 N+2     ...
  ┌─────┐  ┌─────┐   ┌─────┐
  │太阳 0°│  │太阳 0.1°│  │太阳 0.2°│   每帧方向变 0.1°
  └─────┘  └─────┘   └─────┘
     │         │          │
     ▼         ▼          ▼
  键=Dir(0°) 键=Dir(0.1°) 键=Dir(0.2°)   ≠ → 全部失效
     │         │          │
     ▼         ▼          ▼
  全量光栅   全量光栅    全量光栅        ← uncached 路径,缓存形同虚设

13.2 uncached 路径的成本构成

FVirtualShadowMapPerLightCacheEntry::UpdateCacheManager.cpp:362-397)注释原文(第 8.1 章已全引)总结三点:

  1. 失效帧走 uncached 更高效(不做无谓的复用尝试);
  2. 持续移动的灯永远 uncached(不需要显式设置);
  3. 静止一帧即恢复缓存——缓存恢复速度极快

成本对比(同一场景):

状态 每帧阴影光栅成本
太阳静止 + 场景静止 ≈ 0(全静态缓存命中)
相机移动 + 太阳静止 只画边缘新页(级平移)
太阳移动(uncached) 全量光栅 ≈ CSM 成本 + VSM 页表/标记开销

结论:动态太阳的 VSM 成本 ≈ CSM 的成本 + VSM 的固定开销——比 CSM 更贵。所以”用 VSM 做昼夜循环”必须配合本章的优化,否则就是”比传统方案更差的方案”。

13.3 官方旋钮一:MobilityFactor(移动太阳自动降分辨率)

FShadowScene::UpdateForRenderedFrameShadowScene.cpp:187-222)维护每盏灯的 MobilityFactor(1.0 = 正在移动,0.0 = 静止,r.Shadow.Scene.LightActiveFrameCount=10 帧内线性衰减):

1
2
// It's not been updated for more than K frames transition to non-active state
Light.MobilityFactor = 1.0f - FMath::Clamp(float(SceneFrameNumber - Light.FirstActiveFrameNumber) / float(ActiveFrameCount), 0.0f, 1.0f);

MobilityFactor 喂给 clipmap 的分辨率偏置插值(VirtualShadowMapClipmap.cpp:49-55, 251):

1
2
3
4
ResolutionLodBias = FVirtualShadowMapArray::InterpolateResolutionBias(
Config.ResolutionLodBias, // 静止档(更细)
Config.ResolutionLodBiasMoving, // 移动档(更粗)
LightMobilityFactor);

效果:太阳正在转 → MobilityFactor≈1 → 用”移动档”分辨率偏置(更粗的级)→ 阴影分辨率自动下降;太阳停住 10 帧 → MobilityFactor→0 → 分辨率平滑回升。**”动的时候便宜点画,停了再画细”**——这是引擎对动态太阳的第一道内置优化。

BaseScalability 的默认值BaseScalability.ini:149-150):方向光移动档 = 0.0(不降档)——官方默认对方向光关闭了这个优化!局部光才有(Local=1.0 / LocalMoving=2.0)。做昼夜循环必须手动开r.Shadow.Virtual.ResolutionLodBiasDirectionalMoving=2.0(白天太阳转时降 2 级,视觉可接受,性能立省)。

13.4 官方旋钮二:更新节流与 distant 判定

  • MaxDistantUpdatePerFrame=1:远处灯轮流更新(11.5 章)——对太阳本身不适用(太阳不是 distant),但它约束场景里其它灯,间接减轻总失效量;
  • **DistantLightMode=1:小足迹灯自动 distant(force-cached,不随太阳失效波及)——把”受太阳影响的小灯”变成”不受影响的缓存灯”**,这是动态太阳场景的重要减压阀(太阳转时小路灯的阴影不会跟着每帧重画?注意:distant 灯 force-cached 意味着它们的缓存不随灯移动失效,但阴影内容仍依赖太阳角度——distant 灯走更简单的页表逻辑,其”缓存失效”成本低。文档表述以”减少局部灯失效成本”为准)。

13.5 官方旋钮三:ForceInvalidateDirectional

r.Shadow.Virtual.Cache.ForceInvalidateDirectional=0VirtualShadowMapClipmap.cpp:36-40)——注释原文:

“Force invalidation of directional shadows. Useful to emulate a moving sun to avoid misrepresenting cache performance.”

官方用它模拟移动太阳(性能测试),也给了主动用法:当太阳”频繁但非每帧”变化时(比如阶梯式步进),显式失效让每帧成本一致(否则阶梯切换帧会突然出现一次全量重画的尖峰,其它帧却假装缓存命中——性能不稳定比稳定地贵更糟)。

13.6 实践技巧:阶梯式太阳(推荐)

核心思想:太阳方向不是每帧变,而是每 N 帧步进一次。步进间隔内缓存键不变 → 级缓存/静态缓存生效 → 大部分帧享受缓存;步进帧一次性失效重画。

1
2
3
连续式太阳:       每帧变 → 每帧全量(坏)
阶梯式太阳(N=4):[静止][静止][静止][步进] [静止][静止][静止][步进] …
缓存命中×3 → 全量×1 → 平均成本 = 全量的 1/4

实现:阴影光每 4 帧旋转一次(角度步长 = 4 × 每帧角度);视觉光保持连续旋转(光照/大气不受影响,因为它们不走 VSM 缓存键)。

权衡:步长越大省得越多,但阴影”跳变”越明显——阴影边缘每秒走过的角度 vs 步长。参考值:

步进间隔 角度步长(太阳 24h=15°/h) 效果
2 帧 0.5°/s 的 1/60 几乎无感,省 ~50%
4 帧 ~0.008°/步 轻微台阶感(TAA 可掩盖),省 75%
8 帧 ~0.017°/步 可见跳变(远影明显)

工程要点:步进帧与 TAA/TSR 配合良好(时域累积掩盖小跳变);日出日落时太阳角度变化对阴影影响最大——可以动态调整步长(低角度用 2 帧、高角度用 6 帧)。

13.7 实践技巧:暂停式太阳

核心思想:战斗、过场、剧情对话等”玩家注意力在别处”的时段,让太阳停下来(时钟停摆)——缓存全命中,阴影零成本;恢复时一次性失效。

配合:暂停期间视觉时间继续流逝(灯光/大气动画照走,只是阴影光停住)——这就是 12.4 章”视觉光与阴影光分离”结构的又一用途。

适用:RPG/开放世界战斗频繁的游戏收益最大;模拟经营类(时间连续流逝)不适用。

13.8 三种 Mobility 对比:太阳的光照属性选择

Movable 太阳 Stationary 太阳 静态太阳
能否昼夜旋转 ✅(推荐) ✅(但缓存失效) ❌(定死)
VSM 行为 旋转→uncached 全量 IsCachedShadowValidSceneManagement.h:1011)WorldToLight 比较→失效重渲染 全缓存
CSM 行为 每帧全量 失效时重渲染 烘焙/缓存
光照 全动态(含间接光需 Lumen) 间接光烘焙(太阳移动时间接光不变——注意!) 全烘焙
推荐场景 昼夜循环 + Lumen 无昼夜、固定时间 性能极限

Stationary 太阳的坑:间接光(烘焙 GI)不随太阳移动更新——Stationary 太阳做昼夜循环时,阴影在动但间接光不变,画面违和。昼夜循环的正确选择:Movable 太阳 + Lumen(或动态 GI);没有 Lumen 的移动端项目,昼夜循环的间接光只能妥协(SkyLight 实时捕获是主要动态 GI 来源)。

13.9 综合方案:黄金参数组合

把本章所有旋钮拧成一个”昼夜 + 动态太阳 + VSM”的推荐配置:

参数 推荐值 理由
r.Shadow.Virtual.ResolutionLodBiasDirectionalMoving 2.0 太阳转动时降 2 级(默认 0.0 = 不降!)
r.Shadow.Scene.LightActiveFrameCount 10~30 失效摊开更久,分辨率缓降
阶梯式步进 4 帧(日出日落 2 帧) 缓存命中 75%
r.Shadow.Virtual.MaxDistantUpdatePerFrame 1 小灯节流
r.Shadow.Virtual.DistantLightMode 1 小灯 force-cached
夜间降档 ResolutionLodBiasDirectional 上调(如 2.0) 夜晚曝光低,阴影细节不可见,白白省
暂停式 战斗/过场停太阳 零成本时段
r.Shadow.Virtual.DeferredInvalidationBudget 64~256 失效洪峰摊平

预期效果:白天行走 → 级平移 + 阶梯式(75% 缓存命中);战斗 → 暂停式(全缓存);夜晚 → 降档 + 月光。**平均阴影光栅成本可以压到 CSM 的 30~50%**,同时享受 VSM 的质量(近处高分辨率、远处预过滤)。

13.10 避坑清单

  1. ResolutionLodBiasDirectionalMoving 默认 0.0——官方对方向光”移动降档”默认关闭,昼夜项目不开它 = 白扔性能;
  2. ResolutionLodBias 与 FirstLevel 联动:偏置过大 → 近处级也被跳过 → 近景阴影糊。调偏置后检查近处(L6~L8 级)质量;
  3. 降档闪烁:阶梯式太阳的跳变在 TAA 关闭时可见——昼夜项目不要关 TAA/TSR;
  4. 阴影光与视觉光分离后的一致性:阴影太阳角度与视觉太阳角度偏差超过约 2° 时,影子方向与日盘位置对不上(玩家能察觉)——步进角度控制在每步 < 1°;
  5. 双大气光源同时失效:太阳 + 月亮都开着 VSM 且都在转 → 两套 clipmap 同时 uncached,成本翻倍。月亮可以做成”低更新率”(每 30 帧步进)或干脆用静态光照;
  6. r.Shadow.Virtual.Cache.ForceInvalidateDirectional 常开:它把”阶梯步进帧”的失效提前到每帧——那是测试用途,别常开;阶梯式太阳自己控制失效时机即可。

第 14 章 平台支持与性能调优

TL;DR|VSM 的可用条件:r.Shadow.Virtual.ProjectEnabled(ini)+ 支持 Nanite(RenderCore
判定)+ r.Shadow.Virtual.Enable(项目设置 Shadow Map Method);着色器要求 SM5。
移动端不支持(移动渲染器从不调用 VSM,只做占位初始化)。本仓库(移动优化 fork)对
VSM 零改动——本文所有源码引用对官方 5.x 同样有效。调优三板斧:ShowStats 看统计 →
可视化模式定位 → 对症调 CVar。

14.1 平台开关:三层判定

VSM 可用性判定(RenderUtils.cpp:1472-1505):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool DoesPlatformSupportVirtualShadowMaps(EShaderPlatform Platform)
{
// VSM allowed for this project/platform
static FShaderPlatformCachedIniValue<int32> CVarPlatformVSMProjectEnabled(TEXT("r.Shadow.Virtual.ProjectEnabled"));
bool bIsProjectEnable = CVarPlatformVSMProjectEnabled.Get(Platform) != 0;
return bIsProjectEnable && DoesPlatformSupportNanite(Platform); // ① 项目开关 && 支持 Nanite
}

bool UseVirtualShadowMaps(EShaderPlatform ShaderPlatform)
{
return bVirtualShadowMapsEnabled && // ② r.Shadow.Virtual.Enable(项目设置)
DoesPlatformSupportVirtualShadowMaps(ShaderPlatform) &&
DoesRuntimeSupportNanite(ShaderPlatform, true, true); // ③ 运行时 Nanite 支持(原子操作+项目设置)
}
开关 位置
项目/平台 r.Shadow.Virtual.ProjectEnabled(ini) RenderUtils.cpp:1473
运行时 r.Shadow.Virtual.Enable(= 项目设置 Shadow Map Method,RendererSettings.h:671 RenderUtils.cpp:1494
Nanite 依赖 DoesPlatformSupportNanite + DoesRuntimeSupportNanite RenderUtils.cpp
Shader 门槛 SM5(VirtualShadowMapShaders.h:27-32 VirtualShadowMapShaders.h

避坑:移动端从不调用 RenderVirtualShadowMaps——MobileShadingRenderer.cpp:1362-1366 只是
“占位初始化”(注释来源是上游 2021 年提交 “Add Virtual Shadow Map Array initialization for
Mobile renderer to ensure dummy buffers are set up”)——保证移动端 shader 采样到 dummy
缓冲,不崩溃,但不渲染任何 VSM 内容
。本仓库虽是移动优化 fork,但 VSM 是桌面特性
(fork 对 VSM 零改动),移动端阴影仍是 CSM + Distance Field Shadows。

14.2 CVar 速查表(分组)

(完整声明 97 个 r.Shadow.Virtual.*;此处按用途分组列出常用项,定义处见括号)

总开关r.Shadow.Virtual.Enable(Array.cpp:161,默认 0)、r.Shadow.Virtual.ProjectEnabled(RenderUtils.cpp)

页池MaxPhysicalPages(Array.cpp:174,默认 2048)、AllocatePagePoolAsReservedResource(CacheManager.cpp:149)

缓存Cache.FramesStaticThreshold(CacheManager.cpp:143,默认 100)、Cache.AllocateViaLRU(Array.cpp:527)、Cache.MaxPageAgeSinceLastRequest(CacheManager.cpp:129,1000)、Cache.MaxLightAgeSinceLastRequest(:136,10)、Cache.InvalidateUseHZB(:105,1)、Cache.DeformableMeshesInvalidate(:112,1)、ForceInvalidateDirectional(Clipmap.cpp:37,0)、DeferredInvalidationBudget(Array.cpp:120,-1)

ClipmapClipmap.FirstLevel(Clipmap.cpp:58,6)、Clipmap.LastLevel(:64,22)、Clipmap.ZRangeScale(:85,1000)、Clipmap.WPODisableDistance(:100,1)、Clipmap.WPODisableDistance.LodBias(:107,3)、Clipmap.CullDynamicTightly(:153,true)、ResolutionLodBiasDirectional(:43,0)、ResolutionLodBiasDirectionalMoving(:50,0)

更新节流MaxDistantUpdatePerFrame(ShadowSceneRenderer.cpp:34,1)、DistantLightMode(:41,1)、ResolutionLodBiasLocal/LocalMoving(:62/:69,0/1)

标记/分配MarkCoarsePagesLocal(Array.cpp:428,2)、MarkPagesUsingFroxels(:139,0)、PageMarkingPixelStrideX/Y(:644/:653,2/2)

光栅NonNanite.Batch/SinglePassBatched(Array.cpp:491/:638,1/1)、NonNanite.UseHZB(:477,1)、Nanite.AllowTessellationDirectional/Local(:201/:213,1/1)

投影/质量SMRT.RayCountDirectional(:736,7)、SMRT.SamplesPerRayDirectional(:743,8)、SMRT.RayCountLocal(:682,7)、ScreenRayLength(:660,0.015)、NormalBias(:667,0.5)、OnePassProjection.MaxLightsPerPixel(:483,16)

远处PrefilteredDistant.ProjectEnable(Array.cpp:825,0)、PrefilteredDistant.HistoryBlendFactor(:810,0.87)

动态分辨率DynamicRes.TimeBudgetMs(Array.cpp:787,禁用)、DynamicRes.MaxPagePoolLoadFactor(CacheManager.cpp:183,0.85)、DynamicRes.MaxResolutionLodBias(:156,2.0)

调试ShowStats(Array.cpp:194)、Stats.VisibleShowLightDrawEvents(:132)、DebugSkipMergePhysical(:594)、Cache.DebugSkipDynamicPageInvalidation(:601)、DumpLightNaniteStats(CacheManager.cpp:215,控制台命令)

14.3 调优工作流:三板斧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. 看数字    r.Shadow.Virtual.Stats.Visible 1(ShaderPrint 输出)
关键统计(VirtualShadowMapDefinitions.h:78-104 的 VSM_STAT_*):
REQUESTED_THIS_FRAME_PAGES / STATIC_CACHED / DYNAMIC_CACHED /
STATIC_INVALIDATED / DYNAMIC_INVALIDATED / ALLOCATED_NEW /
PAGES_TO_MERGE / NANITE_CLUSTERS_HW / SW / OVERFLOW_FLAGS
→ 判断:缓存命中率、失效源、页池压力
2. 看画面 视口 → Virtual Shadow Map 可视化模式(16 种,Definitions.h:40-55)
CACHED_PAGE(静态命中)/ GPU_INVALIDATED_PAGE(失效)/ DIRTY_PAGE /
OVERVIEW / NANITE_OVERDRAW / SHADOW_CASTERS / RECEIVER_MASK
→ 定位:哪里的页在反复失效、哪里在过绘制
3. 调参数 每次只动一个旋钮,前后对比
页池不足 → MaxPhysicalPages 或 ResolutionLodBias
失效太多 → WPO 距离 / DeferredInvalidationBudget / 内容侧(第 11 章)
太阳在转 → ResolutionLodBiasDirectionalMoving(第 13 章)
远处闪 → PrefilteredDistant

14.4 常见坑清单

  1. 页池不足块状闪烁:官方承认的已知问题——先 ResolutionLodBiasMaxPhysicalPages
  2. 移动端静默回退:移动平台自动走 CSM,项目设置勾了 VSM 也不会生效——检查平台日志确认;
  3. 局部光室内慢:VSM 在局部光多的室内比 CSM 慢 50~100%(公开资料)——评估”室外大世界 VSM + 室内关 VSM”的混合方案(按关卡配置);
  4. ResolutionLodBias 调大后近处糊:检查 FirstLevel 与偏置的联动(13.10 避坑 2);
  5. WPO 资产大量失效:内容侧配合(11.6);
  6. r.Shadow.Virtual.Enable 改了要重启:CVar 带重建渲染状态回调(Array.cpp:160-171)——热改可能不完整生效。

第 15 章 局限、替代方案与展望

TL;DR|VSM 不是银弹:局部光室内比 CSM 慢、页池要几百 MB 显存、移动端不支持
它最适合”大世界 + Nanite + 动态太阳”的场景;室内/移动端请回到 CSM。演进方向:
MegaLights×VSM(光样本按需请求页,本快照新特性)、PrefilteredDistant 成熟化、
移动端实验(未来)。

15.1 局限与对策

局限 表现 对策
局部光室内慢 50~100% 室内灯光密集时掉帧 室内关卡关 VSM 用 CSM;或 MegaLights
页池内存大 256MB+(2048 页双层) 平台分级页池;低端 512 页
移动端不支持 移动平台静默回退 CSM 移动端用 CSM + DFS;VSM 方案仅桌面
缓存失效抖动 动态场景/动态太阳时性能波动 本章全套优化(失效预算、阶梯式、暂停式)
需要 Nanite + SM5 老平台/无 Nanite 项目不可用 无 Nanite = 无 VSM
页池不足闪烁 大世界页池压力 分辨率偏置优先、页池分级

15.2 MegaLights × VSM:光样本驱动的阴影请求

本快照(UE 5.9 线)的新特性:MegaLights(大量动态灯的统一方案,要求硬件光追,RendererSettings.h:658)与 VSM 的集成——r.MegaLights.VSM.MarkPagesMegaLights.cpp:494):MegaLights 随机采样光样本,每个样本用 ForwardLightData.VirtualShadowMapId 构造 VSM 句柄,然后 MarkPageDirectional/MarkPageLocal 按需请求阴影页MegaLightsVSMMarking.usf:19-56)——**”MegaLights 决定要哪些阴影页,VSM 负责渲染这些页”**,把”所有灯都要阴影”变成”被光样本照到的区域才要阴影”。

状态:实验性,要求硬件光追(RTX/PS5/XSX),移动端不可用(本 fork README 将 MegaLights 移动端适配列为规划项)。

15.3 演进时间线与阅读进阶

时间 里程碑
2021 SIGGRAPH Nanite Deep Dive 公开 VSM 架构
UE 5.0 VSM 随 Lumen 上线(方向光 clipmap)
5.1~5.3 缓存静态层、SMRT、PrefilteredDistant 迭代
5.4~5.8 OnePass 投影(MaskBits)、DistantLightMode、官方文档
5.9 线 MegaLights×VSM(实验)

进阶路线:官方文档(Virtual Shadow Maps)→ SIGGRAPH 2021 论文 “Shadows: Virtual Shadow Maps” 章节 → 本仓库源码(附录 A 指路)→ Into the Bytecode 的 VSM 系列(站点未能访问核实,仅作线索)。

附录 A 源码地图

全部路径基于本仓库快照(2026-08-22,HEAD 6cea9bd20f8f)。行号只用于定位;代码演进后以函数名为准。
阅读顺序建议:先 VirtualShadowMapDefinitions.h(常量宪法)→ VirtualShadowMapArray.cpp(每帧主循环)
VirtualShadowMapCacheManager.cpp(缓存与失效)→ VirtualShadowMapClipmap.cpp(方向光)→ 专题章节对应的部分。

A.1 C++ 侧(Renderer/Private/VirtualShadowMaps/,9 文件 ~1.2 万行)

文件 关键内容
VirtualShadowMapArray.cpp(5905 行) BeginMarkPages :2545BuildPageAllocations :3256RenderVirtualShadowMapsNanite :4343RenderVirtualShadowMapsNonNanite :4515PostRender :1953UpdateHZB :4961、AddRenderViewsClipmap :5080、UpdateNextData :905、CVar 区 :119-660
VirtualShadowMapArray.h FVirtualShadowMap(常量集合 :58)、ID 布局 AllocateDirectional/Local/Unreferenced :322-324、IsSinglePage :335、物理池/页表声明
VirtualShadowMapCacheManager.cpp(2385 行) UpdateClipmapLevel :259Update(uncached 注释):362-397、ExtractFrameData :1409、ProcessInvalidations :1852、UpdateUnreferencedCacheEntries :1314、CVar 区 :90-215
VirtualShadowMapCacheManager.h FClipmapCacheKey :135-140(LightDirection)、FLocalLightCacheKey :146-153(WorldToLight)、FVirtualShadowMapCacheKey :228、FVirtualShadowMapArrayCacheManager :242、CachePrimitiveAsDynamic :497
VirtualShadowMapClipmap.cpp CVar 区 :36-165、GetLevelRadius :172-180构造 :200-258、ResolutionLodBias 插值 :251
VirtualShadowMapClipmap.h FVirtualShadowMapClipmapConfig :23-40、FLevelData :164-174
VirtualShadowMapProjection.cpp RenderVirtualShadowMapProjection :596、RenderVirtualShadowMapProjectionOnePass :550、CompositeVirtualShadowMapFromMaskBits :855
VirtualShadowMapShaders.h FVirtualShadowMapGlobalShader(SM5 门槛 :27-32)

外部集成点

位置 内容
Shadows/ShadowSceneRenderer.cpp:497/644/866/907 AddDirectionalLightShadow / AllocateVirtualShadowMapIds / BeginMarkVirtualShadowMapPages / RenderVirtualShadowMapProjectionMaskBits
Shadows/ShadowScene.cpp:20-26/96-115/187-222 LightActiveFrameCount CVar / MobilityFactor 设定与衰减
Shadows/ShadowSetup.cpp:390-397 UseFarShadowCulling / Clipmap.UseConservativeCulling
ShadowDepthRendering.cpp:383-400/2713 EShadowDepthType::VSM / EMeshPass::VSMShadowDepth
ShadowRendering.cpp:2506/2529 ApplyVirtualShadowMapProjectionForLight
RenderCore/RenderUtils.cpp:1472-1505 DoesPlatformSupportVirtualShadowMaps(三层判定)
MobileShadingRenderer.cpp:1362-1366 移动端占位初始化(不实际渲染)
Nanite/NaniteCullRaster.cpp:545-551/6884/7112 FVirtualTargetParameters / 主遍上帧页表 / post pass 本帧页表
Nanite/NaniteShared.h:187-190 FNaniteView::TargetLayerIndex/TargetMipLevel
MegaLights/MegaLights.cpp:494/2024-2044 r.MegaLights.VSM.MarkPages / MarkVSMPages
Engine/Classes/Engine/RendererSettings.h:671 ShadowMapMethod(绑定 r.Shadow.Virtual.Enable)

A.2 着色器(Engine/Shaders/Private/VirtualShadowMaps/,38 文件)

分组 文件 → 主入口
页管理 VirtualShadowMapPhysicalPageManagement.usf → UpdatePhysicalPageAddresses :155 / UpdatePhysicalPages :236 / AllocateNewPageMappingsCS :671 / PackAvailablePages :686 / MergeStaticPhysicalPagesIndirectCS :1077 / BuildHZBPerPageCS :1809
页访问 VirtualShadowMapPageAccessCommon.ush → ShadowGetPhysicalPage :289 / ShadowVirtualToPhysicalUV :357 / GetVirtualShadowMapStaticArrayIndex :395
页标记 VirtualShadowMapPageMarking.usf → GeneratePageFlagsFromPixels :320 / MarkCoarsePages :802;VirtualShadowMapPageMarking.ush → MarkPage :82 / MarkPageDirectional :139 / MarkPageLocal :156
页重叠 VirtualShadowMapPageOverlap.ush → OverlapsAnyValidPage :91 / GatherPageFlags :64
缓存失效 VirtualShadowMapCacheInvalidation.ush → InvalidateInstancePages :43;VirtualShadowMapCacheLoadBalancer.usf → InvalidateInstancePagesLoadBalancerCS :28;VirtualShadowMapCacheGPUInvalidation.usf → ProcessInvalidationQueueGPUCS :151
投影 VirtualShadowMapProjection.usf → VirtualShadowMapProjection :794;VirtualShadowMapProjectionCommon.ush → CalcBiasedAbsoluteClipmapLevelForSampling :48 / SampleVirtualShadowMapPhysicalDepth :166;VirtualShadowMapProjectionDirectional.ush → TraceDirectional :175;VirtualShadowMapProjectionSpot.ush → ShadowRayCastSpotLight :192
SMRT/软阴影 VirtualShadowMapSMRTTemplate.ush → SMRTRayCast :26;VirtualShadowMapSMRTCommon.ush → GetSMRTTraceSettingsDirectional :77;VirtualShadowMapScreenRayTrace.ush → VirtualShadowMapScreenRayCast :12
OnePass VirtualShadowMapMaskBitsCommon.ush → GetVirtualShadowMapMaskForLight :16;VirtualShadowMapProjectionComposite.usf → WORK_TILE_SIZE :8 / VirtualShadowMapCompositeTileVS :14
调试 VirtualShadowMapDebug.usf → DebugVisualizeVirtualSmCS :136;VirtualShadowMapFalseColor.ushVirtualShadowMapStats.ushVirtualShadowMapPrintStats.usfVirtualShadowMapDrawFalseColor.usf
其他 VirtualShadowMapHandle.ush → FVirtualShadowMapHandle :10;VirtualShadowMapPageCacheCommon.ush → ShouldCacheInstanceAsStatic :94;VirtualShadowMapPerPageDispatch.ush → FPerPageDispatchSetup :102;VirtualShadowMapBuildPerPageDrawCommands.usf → CullPerPageDrawCommandsCs :155;VirtualShadowMapCompactViews.usf → CompactViewsVSM_CS :40;VirtualShadowMapThrottle.usfVirtualShadowMapLightGrid.ushVirtualShadowMapTransmissionCommon.ush

共享常量Engine/Shaders/Shared/VirtualShadowMapDefinitions.h(242 行,C++/HLSL 共用)——页/页表/mip 常量(:12-21)、页表纹理尺寸(:37-38)、投影标志(:60-67)、8192 单页槽(:70)、页元数据结构 FPhysicalPageMetaData(:150-158)、next-data 标志 KEEP_PAGE(:139-140)、统计枚举(:78-110)、可视化模式(:40-55)、mip 选择函数 GetMipLevelLocal(:181)。

附录 B 术语表(中英对照)

英文 中文(本文用词) 一句话解释 首次出现
Virtual Shadow Map (VSM) 虚拟阴影图 UE5 的按页虚拟化阴影方案 Ch1
Page 128×128 阴影像素的复用单位 Ch2
Page Table 页表 虚拟页→物理页映射纹理 Ch2
Physical Page Pool 物理页池 所有灯共享的物理页仓库(默认 2048 页) Ch2
Clipmap (不译) 方向光阴影的同心级结构(洋葱皮) Ch2
Level clipmap 的一层(半径 2^(L+1)) Ch2
Uncached (不译) 缓存失效后的无缓存路径(每帧全量) Ch2
FramesStaticThreshold 静态阈值 100 帧无失效转静态缓存 Ch2
KEEP_PAGE (不译) 页复用标志(级缓存判定通过) Ch2
PageAddressOffset 页表平移 页表整体偏移(纸带平移) Ch2
Cache Key 缓存键 决定缓存是否有效的输入集合 Ch8
LightDirection (不译) FClipmapCacheKey 中的太阳方向 Ch8
WorldToLight (不译) 局部光缓存键中的光变换矩阵 Ch8
Dirty Flags (不译) 页”画过/画脏”标志(6 slice) Ch8
Invalidation 失效 标记页需要重画 Ch8
Static Layer 静态层 物理池 layer 1(只读存档页) Ch4
Dynamic Layer 动态层 物理池 layer 0(随时重画) Ch4
SMRT (不译) Shadow Map Ray Tracing 软阴影 Ch10
PrefilteredDistant (不译) 远处阴影预过滤(历史重投影) Ch9
MarkCoarsePages 粗页标记 低分辨率需求的页预标记 Ch6
MaxDistantUpdatePerFrame (不译) 远处灯每帧更新上限(round-robin) Ch11
DistantLightMode (不译) 小足迹灯自动 distant(force-cached) Ch11
MobilityFactor (不译) 灯”移动→静止”的衰减因子 Ch13
ResolutionLodBias 分辨率偏置 采样级偏移(移动/静止两档) Ch5
FPhysicalPageMetaData (不译) 物理页元数据(归属/年龄/状态) Ch4
MaskBits (不译) 局部光 OnePass 投影的每灯 4bit 遮罩 Ch10
Receiver Mask 接收者掩码 页内 8×8 可见性加速 Ch3
ZRangeScale (不译) 级深度范围系数(1000,缓存防抖) Ch5
Guardband 保护带 Z 中心可移动而不失效的区间(0.9) Ch8
WPODisableDistance WPO 禁用距离 近级禁用顶点动画的距离阈值 Ch5
CSM 级联阴影图 传统级联阴影(对比对象) Ch1
Stationary Light 固定光 间接光烘焙、阴影可动的光 Ch13
IsCachedShadowValid (不译) Stationary 光的 CSM 缓存有效性判定 Ch13
LightSourceAngle 光源角直径 太阳角直径(默认 0.5357°) Ch12
bRealTimeCapture 实时捕获 SkyLight 昼夜更新的开关 Ch12
PrepareSunLightProxy (不译) 大气-太阳耦合(透射率/日盘亮度写回) Ch12
OverrideAtmosphericLightDirection (不译) 大气太阳方向覆写(解耦演出) Ch12
MegaLights (不译) 大量动态灯方案(本快照新特性) Ch15
HLOD (不译) 层次细节(远处简化网格) Ch11
World Partition (不译) 大世界流送分区 Ch11

附录 C 参考资料

  1. Epic 官方文档Virtual Shadow Maps in Unreal Engine(UE 5.8)—— 6-22 级 clipmap、空级近零成本、页池与已知问题(块状闪烁)。注意:官方”64cm~40km”覆盖与源码公式(L6=128cm、L22≈84km)不一致,以源码为准(第 5.1 章避坑)。
  2. SIGGRAPH 2021:Brian Karis 等《A Deep Dive into Nanite Virtualized Geometry》”Shadows: Virtual Shadow Maps” 章节 —— VSM 架构的第一公开来源。
  3. ixueyouxi(中文):《UE5.8 大世界光照与阴影策略》(CSM 距离 8000→15000:2.1ms→4.8ms 实测)、《UE5.8 Virtual Shadow Maps》(页池/缓存/远阴影分析)。
  4. Stray Spark Studio:《Virtual Shadow Map Optimization for Open Worlds in UE5.7》—— WPO 禁用距离(草 30m/树 80m)、invalidation budget、PS5 8192 页 vs XSS 6144 页、装饰动画道具省 0.9ms。
  5. Into the Bytecode(Marco Giustino)VSM 系列 —— 未能在写作时访问核实,仅作线索;基于 UE 5.0~5.2,类名与结构有差异。
  6. 本仓库源码(一级来源,附录 A 指路)。

附录 D 自测练习

入门级

  1. 用三句话 + 一个类比向同事解释 VSM(要求用上”页”和”缓存”两个词)。
  2. CSM 与 VSM 的核心差异是什么?为什么 VSM 的成本跟着”变化量”走?
  3. r.Shadow.Virtual.MaxPhysicalPages 设太小会发生什么?为什么?

进阶级

  1. 画一张”相机向右移动 10 米”时某 clipmap 级的页表变化图(标注哪些页平移复用、哪些页新画)。
  2. 解释为什么太阳每帧旋转必然破坏 FClipmapCacheKey,并推导 uncached 路径下每帧的成本构成。
  3. 推导页池内存:2048 页 × 双层 × 128×128×4B = 多少 MB?512 页呢?官方”512MB”估算为什么比这个高?
  4. 昼夜循环需要”五件套”是哪五个?其中哪个最容易漏掉(会导致夜晚天空光还是白天)?
  5. ResolutionLodBiasDirectionalMoving 默认值是多少?为什么昼夜项目必须改它?

源码级

  1. VirtualShadowMapCacheManager.cpp:259(UpdateClipmapLevel)找到”四条件判定”,解释每一条在什么场景下触发失效。
  2. VirtualShadowMapCacheManager.cpp:362(Update)找到 uncached 注释原文,翻译”持续移动的光自动走 uncached 路径”这一句并说明官方建议。
  3. VirtualShadowMapPhysicalPageManagement.usf:155(UpdatePhysicalPageAddresses)找到 KEEP_PAGE 平移逻辑,解释 TestPageAddress 的合法性检查为什么必要。
  4. 用 grep 统计本仓库 r.Shadow.Virtual 前缀的 CVar 声明数量(预期 97 个),并把它们按用途分组。
  5. 实验:把 r.Shadow.Virtual.ResolutionLodBiasDirectionalMoving 设为 2.0,转动太阳,观察 ShowStats 与画面——验证”移动降档”机制。