从”普通程序员能听懂”到”能改 UE 渲染源码”——基于源码逐行考证的 VSM(虚拟阴影图)深度讲解,
附开放大世界优化 / 昼夜循环 / 动态太阳光三大专题
- 文档版本:1.0(2026-08-22)
- 源码快照:本地仓库
d:\Project\GameDevelop\UnrealEngine,UE 5.9 移动优化 fork,HEAD6cea9bd20f8f - 写作原则:文中所有”源码考古”块引用的文件路径、行号、函数名均在本仓库中实际验证过;行号只用于定位,请以
路径:函数名为准 - 姊妹文档:本文是《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 文档相同):
- 显存有限——数据放显存才能被 GPU 读,显存要省着用;
- 深度缓冲(Z-Buffer)——每个像素记一个”离相机多远”,阴影的本质就是”从光源视角记录谁挡了谁”;
- 每帧重算——游戏每秒 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 一张图看懂全局
读图方法:从左到右五步是”一帧的路”: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 计算着色器驱动分配、以”多级缓存 + 失效”实现跨帧复用的虚拟阴影图。
四根支柱(也是全书主线):
- 虚拟页表:每盏灯有一块巨大的”虚拟阴影贴图”地址空间(方向光 16384×16384),页表记录”虚拟页 → 物理页”映射——像虚拟内存的页表,映射关系每帧由 GPU 更新;
- 物理页池:所有灯共享一个 128×128 页的物理池(默认 2048 页,静态缓存时双层),页可以属于任何灯、任何级——池化 = 复用;
- GPU 驱动页分配:页的标记、分配、复用、淘汰全在 GPU 上用计算着色器完成(Nanite 流送还有 CPU 参与,VSM 连 CPU 都省了);
- 多级缓存复用:跨帧复用的三个层次——级缓存平移(相机移动时整个 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 Method(RendererSettings.h:671,绑定 CVarr.Shadow.Virtual.Enable):
选Virtual Shadow Maps即启用,全局生效,没有 per-light 开关。判断教程新旧:提到
per-light 开关的一律按旧资料处理。
1.5 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 小结
这一章记住三句话:
- CSM 的三座大山:分辨率撕裂、每帧全量重光栅、局部光数量受限;
- VSM 的心智模型:阴影版的虚拟内存——页、页表、物理池、缓存;
- 四根支柱:虚拟页表、物理页池、GPU 驱动分配、多级缓存复用。
下一章,六大核心概念,每个配一个类比。
第 2 章 六大核心概念速览(每个概念 = 一个类比)
TL;DR|VSM 的六个核心概念,用六个你早就懂的东西类比:页 = 乐高块、页表 = 虚拟内存页表、
Clipmap = 洋葱皮、缓存平移 = 纸带平移、缓存失效 = 整页重抄、静态页缓存 = 存档复用。
这一章只建立直觉不给代码——每个概念在源码里的落点在第 4~8 章展开,末尾给”概念 → 源码”预告表。
2.1 页(Page)= 乐高块
定义:页是 VSM 的原子单位——128×128 个阴影像素的一块正方形区域(VSM_PAGE_SIZE = 128,VirtualShadowMapDefinitions.h:14)。所有灯的所有阴影都切成页,所有页共享同一个”乐高仓库”——物理页池。
为什么是 128? 与 Nanite 的 128 三角形异曲同工:太大则”缺一页补一页”的粒度粗(画面只露一角也要整页重画)、缓存复用率低;太小则每页的元数据开销占比高。128×128 = 16384 texel,每页 4 字节 = 64KB/页,2048 页 = 128MB/层,是显存友好、页表索引友好的量级。
画面需要的阴影范围(虚线 = 某盏灯的一级 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 章的分配计算着色器决定。采样阴影时,像素先查页表拿到物理地址,再去物理池读深度(ShadowVirtualToPhysicalUV,VirtualShadowMapPageAccessCommon.ush:357)。
没映射的页 = 透明缺页:页表项为空时,采样结果是”无阴影”(或用更粗的 mip 级顶上)——这与虚拟内存”访问未映射页 → 段错误”不同:VSM 的缺页是优雅降级,不是错误。
2.3 Clipmap = 洋葱皮
定义:方向光(太阳)的阴影用一个 clipmap 结构覆盖大世界:一组同心正方形”级(Level)”,每级覆盖上一级 2 倍的世界半径,共 6~22 级(默认 17 级),每级都是一张 16384×16384 的虚拟阴影贴图。
为什么叫 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):
- 设置:CPU 给每盏灯建 clipmap/阴影映射,比较缓存键(2.5)——太阳动了?失效!
- 标记:GPU 从深度缓冲算出”画面上哪些像素需要阴影”→ 换算成”需要哪些页”(2.1);
- 分配:GPU 查页表(2.2):需要的页里,能平移复用的平移(2.4)、静态层有的直接用(2.6)、都没有的从空闲池分配并标记”要画”;
- 光栅:Nanite/非 Nanite 把阴影深度画进分配的物理页;
- 投影:屏幕像素查页表(2.2)取阴影;
- 帧末: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 米的阴影也要清晰(L6 级 texel 0.08mm);
- 动态物体正确:移动的物体阴影必须每帧正确(动态页路径);
- 缓存友好:静止场景跨帧复用,成本趋近于零(级平移 + 静态页);
- 大世界可扩展:成本随”画面变化量”而不是”世界大小”(页池化 + 按需分配);
- 统一阴影方案:方向光、局部光、单页小灯共用一个池子、一套流程。
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 层)。GetMipLevelLocal(Definitions.h:181)按”灯的屏幕足迹”算 mip 级,小灯阴影的页表项直接落在粗 mip 层,占用 1 页 = 1 个槽位——这就是 8192 个单页小灯槽的来源。
3.3 为什么 GPU 分配:页管理本质是位图操作
Nanite 的流送有 CPU 参与(读盘、fixup),VSM 的页分配全在 GPU:FUpdatePhysicalPagesCS → FPackAvailablePagesCS → FAllocateNewPageMappingsCS 三个计算着色器(第 7 章),CPU 只维护页列表 buffer。
为什么? 页分配的对象是”当前帧标记出的页集合”——它来自 GPU 侧深度缓冲分析,天然在 GPU 上;分配算法是”位图查空位 + 原子计数”,正是 GPU 并行擅长的操作;而且避免了 CPU-GPU 往返——每帧的页分配结果直接留在 GPU 供光栅化消费。这是”GPU 驱动渲染(GPU-Driven Rendering)”的又一次贯彻:数据产生在哪,就在哪消费。
3.4 静态/动态双层物理池
物理页池是 Texture2DArray:静态缓存启用时双层——layer 0 = 动态页(随时重画)、layer 1 = 静态页(定稿只读)。为什么要两层?因为双线性过滤需要”邻居页内容稳定”:采样一个动态页时,如果邻居是静态页(内容早就画好),混合结果才正确;把静态页单独放一层,GPU 光栅时按页标志写对应层(GetVirtualShadowMapStaticArrayIndex,PageAccessCommon.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 | // Page size is 128x128 |
一句话世界观:每盏灯拥有一块 16384×16384 的虚拟阴影贴图地址空间(页表 128×128 项),所有灯共享一个 2048 页的物理池。虚拟地址空间免费(只是页表项),物理页是稀缺资源(要显存)——这和虚拟内存一模一样:虚拟地址 64 位随便要,物理页帧要省着用。
4.2 页表:虚拟页 → 物理页
页表是每张 VSM 一张的 Texture2D<uint> 纹理(PageTableRDG,VirtualShadowMapArray.h),尺寸 128×192(X=128 列对应页表 L0,Y=192 行 = 128 行 L0 + 64 行给 mip 层级)。
每个页表项存一个物理页地址:物理池里的页槽号 + LOD 偏移。采样时像素坐标 → 虚拟页坐标 → 查页表 → 得到物理页坐标 → 读物理池深度(ShadowVirtualToPhysicalUV,VirtualShadowMapPageAccessCommon.ush:357)。
虚拟地址空间(每灯一张,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 / 页地址
每页元数据 FPhysicalPageMetaData(Definitions.h:150-158):
1 | struct FPhysicalPageMetaData |
避坑:早期资料(UE 5.2 时代)里的
FShadowPhysicalPage结构在本仓库已不存在——它已被FPhysicalPageMetaData取代(旧结构存的是”物理地址+LOD 偏移”,新结构更完整)。
读老博客时见到旧名,知道是同一个概念即可。
4.3 物理页池:双层乐高仓库
物理池(PhysicalPagePoolRDG)是 Texture2DArray<uint>:每页 128×128 texel、每 texel 32 位打包深度,总页数默认 2048(r.Shadow.Virtual.MaxPhysicalPages)。静态缓存启用时双层:
- layer 0(动态层):动态页——内容随时可能被重画(移动物体、失效页);
- layer 1(静态层):静态页——100 帧无失效的”存档”页,只读。
为什么双层? 第 3.4 节讲过:双线性过滤需要稳定邻居。光栅化时按页标志写对应层(GetVirtualShadowMapStaticArrayIndex,PageAccessCommon.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) |
页分配(AllocateNewPageMappingsCS)
│
▼
┌──────────┐ 光栅化 ┌─────────────┐ 连续 100 帧 ┌──────────────┐
│ 空页 │ ────────▶ │ 动态页(画好) │ ──────────────▶ │ 静态页(存档) │
│ (未初始化) │ │ 可随时重画 │ 无失效(转静态层) │ 只读,永不重画 │
└──────────┘ └─────────────┘ └──────────────┘
▲ │ ▲ │
│ LRU 淘汰(1000帧) │ │ 失效(键不匹配/primitive动) │ 失效
└─────────────────────────┘ └─────────────────────────────┘
回收到空闲池 标记 INVALIDATE_* → 重画
4.5 ID 布局:谁排在前面
VSM ID(VirtualShadowMapId)的分配顺序有讲究(VirtualShadowMapArray.h:318-359):
1 | ID 0 ───────────────────── 8191 ─────────────── 8191+N ────────────── … |
- 单页小灯(8192 个固定槽):屏幕足迹小于一页的局部光(远处的路灯、蜡烛),每个只占 1 个物理页——固定槽位让索引开销最小;
- 方向光:必须排在所有局部光之前(注释原话 “All directional lights must be allocated before all local lights due to receiver mask allocation logic”);
- 未引用光:离屏但可能还有缓存页的灯,排在最后,缓存页可以多活几帧(
UpdateUnreferencedCacheEntries)。
代码落点:AllocateDirectional/AllocateLocal/AllocateUnreferenced(VirtualShadowMapArray.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
// Page table size is 128x128 (total 16k)这段代码在干什么: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 | float FVirtualShadowMapClipmap::GetLevelRadius(float AbsoluteLevel) |
级半径 = 2^(L+1) 厘米。默认 FirstLevel=6 / LastLevel=22(VirtualShadowMapClipmap.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::UpdateClipmapLevel,VirtualShadowMapCacheManager.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:页表整体平移(当前页空间位置 - 缓存页空间位置 = 偏移),已渲染页原地保留,只有新露出的边缘页要新画。
相机向右移动 → 该级覆盖范围右移
┌──────────────────────────────┐
│ 上次缓存:页表记录这 8 页 │ ● = 已渲染页
│ [●][●][●][●][●][●][●][●] │
└──────────────────────────────┘
│ UpdateClipmapLevel:四条件全过
▼
┌──────────────────────────────┐
│ 本帧:页表平移 offset = +2 页 │ ● = 平移复用(不重画)
│ [●][●][●][●][●][●][●][●][○][○]│ ○ = 新露出的边缘页(唯一要画的)
└──────────────────────────────┘
│ PageAddressOffset = (2, 0) ← 改指针,不是重画
▼
光栅化只画 2 个新边缘页
源码考古的代码对照(节选自 CacheManager.cpp:285-305):
1 | if (bCacheValid) |
这段代码在干什么:缓存有效时只写”偏移量”(PageAddressOffset),光栅化阶段按偏移量把页表内容平移;失效时清空偏移并记录新的基准(Z 中心/半径/WPO 阈值)供下帧比较。注意 ViewCenterZ/ViewRadiusZ 在缓存有效时故意不更新——它们保留的是”上次画这级时的位置”,只有失效时才更新,这正是”平移复用”的基础。
5.5 WPO 禁用距离:近处的动画豁免
Clipmap.WPODisableDistance=1(Clipmap.cpp:99-100)+ LodBias=3(:107):近级别禁用 WPO(世界位置偏移)动画——草、树的顶点动画不进近级阴影(它们在近级按静态画),因为:
- WPO 动画每帧改变几何 → 相关页每帧失效 → 缓存白搭;
- 近级阴影的像素级细节里,WPO 的摆动本来就不可见。
关键细节:2 的幂量化。WPO 禁用阈值按 2 的幂取整(WPODistanceDisableThresholdSquared),保证相机移动时阈值不连续变化——否则阈值一变,所有级一起失效(UpdateClipmapLevel 第④条)。这就是 Clipmap.WPODisableDistance.InvalidateOnScaleChange=0(CacheManager.cpp:190)默认关闭的原因。
避坑:WPO 禁用距离与第 11 章要讲的”开放大世界 WPO 优化”是同一机制的两面:
引擎默认在近级(LodBias=3 即前 3 级)禁 WPO;如果你把草/树做成”永远在 WPO 距离外”
(Stray Spark 实测:草 30m、树 80m),它们就永远不会触发失效——这是内容侧配合。
5.6 FirstPerson 独立 clipmap 与其他细节
- 第一人称独立 clipmap:
EVirtualShadowTypeId::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:需求收集的编排
BeginMarkPages(VirtualShadowMapArray.cpp:2545)在 BasePass 之后运行,四步:
1 | BeginMarkPages |
为什么粗页必须先跑? 源码注释原话:”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:先订片区大单
MarkCoarsePages(VirtualShadowMapPageMarking.usf:802)为低分辨率需求(体积雾、半透明光照)标记粗页。方向光的关键技巧(注释原话翻译):
“clipmap 的上级已经覆盖了低级(superset),所以想要更粗的页,只需标记最粗 clipmap 级的中心页——即使只标记一页,其世界空间半径通常也足够覆盖需要粗数据的系统(体积雾、半透明光体积)。”
也就是说:粗页不用到处标,标最粗级的中心页就够了(它的一页就覆盖大片世界)。MarkCoarsePagesLocal=2(BaseScalability.ini:155)控制局部光的粗页标记强度。
6.4 GeneratePageFlagsFromPixels:深度缓冲 → 页请求
GeneratePageFlagsFromPixels(VirtualShadowMapPageMarking.usf:320,C++ 调度于 :3192)是页标记的主力:每个像素(按 PageMarkingPixelStride 步长采样,默认 2 像素)做:
- 读场景深度 → 重建世界坐标;
- 对每盏相关灯:算出这个像素在世界空间的位置落在灯的哪个(级,页);
- 按像素的”阴影足迹”(footprint)决定需要多细的 mip 级;
- 写页请求标志(
VSM_FLAG_PRIMARY_REQUEST等)——**这就是”缺页请求”**,与 Nanite 流送请求异曲同工(一个来自光栅前的几何遍历,一个来自光栅后的深度分析)。
关键细节:像素不需要全屏采样——PageMarkingPixelStrideX/Y=2(VirtualShadowMapArray.cpp:644/653)即每 2×2 像素采一个(1/4 密度);页是 128×128 的粗粒度目标,像素级误差无害。这是”标记比渲染便宜得多”的原因。
场景深度缓冲(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:82(MarkPage,摘要):
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:分配总编排
BuildPageAllocations(VirtualShadowMapArray.cpp:3256):
1 | BuildPageAllocations |
关键参数(② 的输入,:3279-3330):DeferredInvalidationBudget(延迟失效预算)、MaxPageAgeSinceLastRequest(页龄上限,LRU)、bAllocateViaLRU(LRU 分配开关)、bDynamicPageInvalidation(动态页失效开关)——缓存策略全部在此刻决定。
7.2 UpdatePhysicalPages:旧页的”保活/失效/回收”判定
UpdatePhysicalPages(VirtualShadowMapPhysicalPageManagement.usf:236)逐物理页(或按 LRU 链顺序)问四个问题:
- 本帧还被请求吗?(查页请求标志)→ 被请求 → 保活(更新 LastRequestedFrame);
- 被失效了吗?(失效标志
INVALIDATE_DYNAMIC/STATIC)→ 标重画; - 页龄超限了吗?(
SceneFrameNumber - LastRequested > MaxPageAgeSinceLastRequest,默认 1000 帧)→ 清标志回收; - 延迟失效有预算吗?(
DEFERRED_INVALIDATION页按DeferredInvalidationBudget每帧限量处理)。
结果写入 OutPhysicalPageMetaData[page].Flags;Flags==0 的页被推进 EMPTY 列表(等待重新分配)。
先于它的兄弟:UpdatePhysicalPageAddresses(:155)负责跨帧页表平移——把上一帧的物理页映射通过 PageAddressOffset 平移到本帧的虚拟位置(clipmap 移动时的”纸带平移”),并传播失效标志。它俩的分工:**前者管”物理页归谁”,后者管”页表指向哪”**。
避坑:设计上把
UpdatePhysicalPageAddresses(页表平移)与UpdatePhysicalPages(页状态)
分开是两个函数——早期资料常混为一谈。读代码时认准:**KEEP_PAGE/PageAddressOffset 在UpdatePhysicalPageAddresses,LRU/失效/淘汰在UpdatePhysicalPages**。
7.3 AllocateNewPageMappingsCS:新页上架
AllocateNewPageMappingsCS(:671,实际逻辑在 AllocateNewPageMappings :475):
1 | void AllocateNewPageMappings(FVirtualShadowMapHandle VSMHandle, FVSMPageOffset GlobalPageOffset, uint MipLevel, uint2 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 的池管理。
上一帧物理页(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-222(UpdatePhysicalPageAddresses,节选):
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 缓存键:什么变化会杀死缓存
缓存管理器 FVirtualShadowMapArrayCacheManager(CacheManager.h:242)用缓存键区分”这帧的阴影依赖什么”。三层键:
1 | // 通用:这帧属于哪个视图、哪盏灯、哪种阴影类型 |
判定逻辑(FVirtualShadowMapPerLightCacheEntry::Update,CacheManager.cpp:362):
1 | if (bForceInvalidate || InKey != OutCacheKey) |
这段注释是全文档最重要的注释之一,逐句翻译:
- *”任何原因失效(灯移动等)→ 下一帧按 uncached 渲染,这更高效”*——失效帧不尝试部分复用,整帧走无缓存路径;
- *”持续移动的灯自动永远走 uncached 路径,不需要显式设置 ForceInvalidateDirectional”*——太阳每帧转 = 永远 uncached;
- *”静止一帧后切回缓存路径,开始建立静态缓存数据”*——太阳停住 → 下帧恢复缓存;
- *”在灯’频繁但非每帧’失效的情况下,显式 ForceInvalidateDirectional 仍有用——保持性能一致”*——官方 CVar 的语义在这里。
8.2 静态缓存:100 帧规则
Cache.FramesStaticThreshold=100(CacheManager.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_UNCACHED,Definitions.h:61):
- 只渲染动态层(静态层内容不可信);
- 不尝试页表平移/复用;
- 每帧全量光栅化(与 CSM 的代价形态相同,但加上页表与标记开销 → 更贵)。
这是第 13 章”动态太阳光优化”的全部问题根源:真实昼夜循环里太阳每帧转 → 缓存键永远不匹配 → 永远 uncached → 每帧全量画阴影。优化 = 让太阳”少动”(阶梯式)或”动得便宜”(降分辨率),第 13 章展开。
8.4 dirty flags 与失效传播
dirty flags(6 slice,VSM_DIRTY_PAGE_FLAGS_NUM_SLICES):光栅化时写”这页画过了/画脏了”,帧末 PostRender 的 UpdateAndClearDirtyFlags(VirtualShadowMapArray.cpp:1963)合并进页元数据并清零——HZB 更新只重建 dirty 页(省大量计算)。
失效传播(ProcessInvalidations,CacheManager.cpp:1852):primitive 移动/增删时,FInvalidatingPrimitiveCollector 收集变更 → GPU 端 InvalidateInstancePagesLoadBalancerCS(VirtualShadowMapCacheLoadBalancer.usf:28)逐实例把包围盒变换到阴影视锥,把”这个实例覆盖的页”标记失效(InterlockedOr 写页请求标志的失效位)。
失效也要被剔除(VirtualShadowMapCacheInvalidation.ush:124-159):先 OverlapsAnyValidPage 跳过没有页分配的区域,再对每个候选页做 HZB 遮挡测试(IsPageVisibleHZB)——看不见的失效不处理,这是 Cache.InvalidateUseHZB=1(CacheManager.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.ViewRadiusZ的 0.9 保护带——Z 中心可以移动 10% 的半径而不失效,
这就是”缓存防抖”的具体数值。
第 9 章 光栅化与合并:Nanite 与非 Nanite 双路径
TL;DR|分配完页之后,谁把阴影深度画进物理页?两条路:Nanite 路径(几何走 Nanite
GPU 驱动光栅化,输出端做”虚拟页→物理页”重定向,直接写物理页池)和非 Nanite 路径
(传统网格复用 CSM 的 mesh pass,但按页裁剪实例、在 16384² 虚拟视口里画)。帧末
PostRender 做三件事:合并静态页进动态层(双线性采样需要)、PrefilteredDistant 远处
预过滤、HZB 更新。类比:两家快递公司送同一批货——Nanite 是智能分拣流水线(GPU 驱动),
传统网格是人工分拣(逐实例裁剪)。
9.1 Nanite 路径:物理页池就是”深度目标”
RenderVirtualShadowMapsNanite(VirtualShadowMapArray.cpp:4343)把 Nanite 渲染器整条搬来,只换三样东西:
1 | const Nanite::FRasterContextInitParams RasterInitParams = |
核心思想:Nanite 平时把深度写进 VisBuffer(第 11 章),VSM 模式下把深度直接写进物理页池——光栅化器通过 VIRTUAL_TEXTURE_TARGET 编译排列(Nanite 所有剔除/光栅 shader 都带这个布尔排列)感知”我在画阴影”。
页翻译(CreateRaster,NaniteRasterizer.usf:251-296):每个可见簇的光栅化参数里,把”虚拟页坐标”翻译成”物理页坐标”:
1 | #if VIRTUAL_TEXTURE_TARGET |
这段代码在干什么:vTranslation 把光栅化的窗口从”虚拟页位置”挪到”物理页位置”——簇在虚拟空间算出的屏幕坐标,加上平移后直接落在物理池的正确位置。ArrayIndex 决定画进动态层还是静态层(GetVirtualShadowMapStaticArrayIndex,PageAccessCommon.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 是上帧的,页表也必须配上一帧的,否则”上帧哪里被挡”就查错了。
深度输出:像素着色器 EmitShadowMapPS(NaniteExportGBuffer.usf:229)从 VisBuffer 深度缓冲取深度,按投影类型(标准/正交/透视)换算后写 SV_Depth——正交阴影用 1 - DeviceZ + DepthBias,透视阴影用 ViewToClip 换算。
9.2 非 Nanite 路径:传统网格的”逐页快递”
没有 Nanite 的几何(普通静态网格、骨骼网格、植被)走 RenderVirtualShadowMapsNonNanite(VirtualShadowMapArray.cpp:4515):
- 复用 CSM 的 mesh pass:每个
FProjectedShadowInfo::GetShadowDepthPass()(EShadowDepthType::VSM,ShadowDepthRendering.cpp:383)——阴影深度渲染的基础设施与 CSM 共享; - 按页裁剪实例(
CullPerPageDrawCommandsCs,VirtualShadowMapBuildPerPageDrawCommands.usf:155):逐实例 × 逐页判断”这个实例的包围盒覆盖这页吗?这页被请求了吗?”——输出FVSMVisibleInstanceCmd(页信息 + 实例 ID + indirect 参数索引); - 批量光栅化:
FInstanceCullingMergedContext::MergeBatches把多个阴影的实例裁剪合并成一次提交(SinglePassBatched=1,VirtualShadowMapArray.cpp:638),在 16384×16384 虚拟视口里用 indirect args 绘制——一个 pass 画完所有非 Nanite 阴影; - 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
帧末 PostRender(VirtualShadowMapArray.cpp:1953)四步:
- UpdateAndClearDirtyFlags(:1963):合并光栅阶段的 dirty 标志进页元数据,清零;
- SelectPagesToPostProcess(:1988):选出需要后处理的页(静态合并 + 预过滤两类);
- MergeStaticPhysicalPagesIndirect(:2019,shader 在
:1077):静态页内容拷进动态层——静态页平时躺在 layer 1,双线性采样需要邻居数据在可读位置,合并后采样才能混合新旧(DebugSkipMergePhysical调试开关可跳过,会产生明显错误); - 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 模式下额外问一句”这个簇的屏幕矩形
覆盖了已分配的页吗“(OverlapsAnyValidPage,PageOverlap.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:每个像素的”查档”流程
VirtualShadowMapProjection(VirtualShadowMapProjection.usf:794,工作 tile 8×8,WORK_TILE_SIZE 定义在 Composite.usf:8):
1 | 每个像素(tile 内 Morton 序,缓存友好): |
Morton 序:tile 内线程按 Z 曲线访问(MortonDecode(GroupIndex))——同一 tile 的像素在页表/物理池里的访问地址相近,缓存命中率高(和 Nanite ShadeBinning 的 ZOrder2D 同款技巧)。
10.3 SMRT 软阴影:阴影贴图上的光线追踪
SMRT(Shadow Map Ray Tracing) 是 VSM 的软阴影方案:在阴影贴图空间里沿光线方向走几步(默认方向光 7 步 × 8 次采样,SMRT.RayCountDirectional=7/SamplesPerRayDirectional=8,VirtualShadowMapArray.cpp:729/736),每步采样阴影深度做遮挡累积——**把”点采样阴影”变成”区域平均阴影”**,产生软半影(接触阴影硬化、远处阴影柔化)。
实现是模板化的(SMRTRayCast,VirtualShadowMapSMRTTemplate.ush:26,文件无 include guard,可被多次包含实例化——方向光/局部光/头发各一份特化)。
屏幕空间 ray march(VirtualShadowMapScreenRayCast,VirtualShadowMapScreenRayTrace.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 | 每帧 VSM 成本 ≈ |
关键结论:光栅化成本跟着”变化量”走——相机平移只画边缘新页、太阳不动则静态缓存全命中、静止场景的光栅成本趋近于零。**优化 = 减少”每帧需要重画的页数”**,下面五个解药全是这个公式的展开。
11.2 核心解药一:clipmap 级缓存平移
机制回顾(第 5.4 章):相机平移 → 各级独立判定 → 四条件通过 → KEEP_PAGE + PageAddressOffset 页表平移 → 只画新露出的边缘页。
大世界的收益量化:假设相机以速度 v 移动,某级半径 R。每帧新露出的边缘面积 ≈ 周长 × 移动距离 = 2πR × v·Δt;级总面积 = πR²。新页占比 ≈ (2v·Δt)/R。R 越大(远级),新页占比越小——远级几乎零成本,近级是主要开销。这正好与”近处重要”的需求匹配:成本集中在近处,远处白送。
实践要点:
- **别让相机”抖”**:相机抖动(瞬移/传送)会让所有级同时失效——大世界传送时接受一次全量重建(或用
r.Shadow.Virtual.Cache.ForceInvalidateDirectional主动控制时机); - 级数宁多勿少:
Clipmap.FirstLevel/LastLevel覆盖不足时,最近/最远的物体阴影消失或过糊——但每级虚拟分辨率固定 16384²,级数只影响页表与少量分配开销(空级近零成本,官方文档语),大世界默认 6~22 覆盖足够; - 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——因为每个动画道具都在持续制造失效。
实践要点:
- 静态资产优先 Nanite:Nanite 资产可以走
NANITE_CULLING_FLAG_CACHE_AS_STATIC(NaniteDefinitions.h:188)快速转静态;非 Nanite 静态网格同样支持,但页裁剪路径更重; - 别让”静止”变”微动”:程序化放置的资产如果每帧有微小变换(哪怕 0.01 单位),永远不会满 100 帧——用
CachePrimitiveAsDynamic或关卡设计保证真静止; - 调试:
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% 新帧),远处阴影稳定且便宜。
实践要点:
- 开关:
r.Shadow.Virtual.PrefilteredDistant.ProjectEnable(默认关)——开启后方向光远级自动走预过滤路径; - 联动:预过滤页有自己的物理池(
PrefilteredDistantPhysicalPagePoolMergedRDG,带 1 texel border),内存预算要算上; - 何时需要:场景有大量远距离阴影可见(山谷、平原、城市天际线)时收益巨大;室内/封闭场景可关。
11.5 更新节流:远处灯轮流上班
MaxDistantUpdatePerFrame=1(ShadowSceneRenderer.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.MaxPhysicalPages,VirtualShadowMapArray.cpp:174),低画质档 512 页(BaseScalability.ini:146)。
缺页的表现:页池满 → 新请求挤掉 LRU 最旧页 → 被挤掉的页下次需要时重新光栅化——连续缺页 = 同一批页反复”画了扔、扔了画” → 块状闪烁(blocky flicker),这是 VSM 最著名的质量问题(官方文档承认的已知问题)。
内存公式(第 4.3 章):128×128×4B × 页数 × 2 层。2048 页 → 256MB 页池本体。512 页 → 64MB(低端档)。
预算策略(按优先序):
- 先降 ResolutionLodBias 再降页数:
ResolutionLodBiasDirectional/Local(ShadowSceneRenderer.cpp:62/69)让所有页”更粗”(每页覆盖更大世界面积)——同样的页数覆盖更大的世界,这是”降档不降质”的首选; - 页数按平台定(Stray Spark 实测:PS5 8192 页、XSS 6144 页——按显存预算逐平台设);
- **
DynamicRes.MaxPagePoolLoadFactor=0.85**(CacheManager.cpp:183):页池占用超 85% 时动态分辨率接管,自动降级。
11.8 invalidation budget:给失效上预算
失效是”重画”的触发器,所以失效本身也要预算:
r.Shadow.Virtual.DeferredInvalidationBudget(VirtualShadowMapArray.cpp:120,默认 -1 无界):Nanite LOD 变化触发的失效每帧限量处理——一帧太多失效就推迟到下一帧,把”一次大卡顿”摊成”几次小卡顿”(DEFERRED_INVALIDATION标志,PageAccessCommon.ush:66);- HZB 剔除失效(
Cache.InvalidateUseHZB=1):看不见的失效不处理(8.4 章); r.Shadow.Scene.LightActiveFrameCount=10(ShadowScene.cpp:20-26):灯的”移动→静止”过渡拉长到 10 帧,MobilityFactor 在 10 帧内线性衰减——把失效与降分辨率摊开,避免突变。
11.9 与 World Partition 配合:流送区的阴影代价
开放大世界用 World Partition 流送区块,VSM 的配合要点:
- 流送区进出 = 大量 primitive 增删 = 大量失效:
ProcessInvalidations每帧处理,流送高峰帧会有一次失效洪峰——用DeferredInvalidationBudget摊平; - HLOD 切换同样触发失效:HLOD 替代(远处用简化网格)时包围盒变化 → 相关页失效——HLOD 切换尽量在”阴影不可见”的时机(摄像机快速移动时)发生;
- 内容侧:装饰性动画道具(风车、旗帜、粒子驱动的网格)阴影需求低——直接关 Cast Shadow(Stray Spark 省 0.9ms 的实测就是这类);
- 骨骼网格:角色/动物在开放世界永远在动 → 它们的页永远动态——这是合理开销,但远离镜头的骨骼网格(300m 外的人形)可以走 HLOD 或距离剔除(
r.Shadow.Virtual.Culling.DrawDistance类)。
11.10 实战调优路线图:五步法
① 看数字 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 | 云、云阴影 | bCastShadowsOnClouds、CloudShadowStrength |
| 曝光/雾 | 整体亮暗 | Auto Exposure、Height Fog |
避坑:DirectionalLight 的 LightSourceAngle(DirectionalLightComponent.h:133-138,注释原文 “Defaults to 0.5357 which is the angle for our sun”)不只是”日盘大小”——它还决定太阳阴影的软硬(半影宽度)。晨昏时调大它可以模拟”低角度太阳的柔和长影”。
12.3 大气-太阳耦合链:为什么晨昏的太阳是红的
核心机制在 PrepareSunLightProxy(SkyAtmosphereRendering.cpp:577-594),每帧把大气算出的数据写回灯光代理:
1 | static FLinearColor GetLightDiskLuminance(FLightSceneInfo& Light) |
耦合链(这是全书最值得记的机制链):
1 | 太阳旋转(Rotation 组件) |
实践要点:这套链是自动的——你只需要旋转太阳,红/暗/柔全自动。但注意两个前提: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 | Sequencer 主序列(24 小时 = 全天) |
关键设计:阴影光与视觉光可以分离——你可以在 Sequencer 里让”视觉太阳”(驱动大气的光)与”阴影太阳”(Cast Shadows 的光)是两个 DirectionalLight:视觉光连续旋转,阴影光按第 13 章的阶梯式步进——视觉连续、阴影阶梯,两全其美。这是昼夜 + VSM 场景的黄金结构。
12.5 实践架构 B:蓝图/数据驱动
适合”运行时动态”(天气系统联动、剧情控制):
1 | GameMode / 天气管理器(Blueprint) |
月亮:第二个 DirectionalLight(AtmosphereSunLightIndex=1)当月亮,方向与太阳相反(绕 24 小时轴的相反相位);月光 LightSourceAngle 更小(月亮角直径 ≈ 0.52°)、强度约为太阳的 1/10⁵(人眼感知约 1/1000,靠曝光补偿)。
12.6 SkyLight 与反射更新:昼夜最容易漏的一环
- SkyLight
bRealTimeCapture(SkyLightComponent.h:105-108):实时捕获天空并卷积成环境光。关闭 = 天空光永远是初始帧的样子——白天亮着、夜晚也亮着,昼夜感全毁。开启有成本(每帧或每几帧捕获),但昼夜场景必须开; - Lumen:屏幕探针/辐射缓存自动消费 SkyLight 的辐照度,无需额外开关;
- 移动端避坑:静态反射探针不随昼夜更新——移动端没有 Lumen 时,反射环境是烘焙的,夜晚反射还闪着白天的光。要么接受(低配妥协),要么用实时反射捕获(成本高)或减少反射面。
12.7 阴影系统在昼夜中的行为:24 小时性能曲线
太阳一天转 360°,各阴影系统如何响应:
| 系统 | 太阳移动时的行为 |
|---|---|
| CSM(Movable 光) | 每帧全量重光栅化,成本恒定(不受太阳影响,但也从不复用) |
| CSM(Stationary 光) | IsCachedShadowValid(SceneManagement.h:1011)逐项比较 WorldToLight → 太阳一转就失效 → 重渲染(保留缓存结构) |
| VSM | FClipmapCacheKey.LightDirection(CacheManager.h:135)每帧不匹配 → 每帧全量 uncached → 成本 = CSM 成本 + VSM 开销 |
| VSM + 静态缓存 | 静止期间转静态 → 太阳一动全失效 → 昼夜过渡的瞬间是性能尖峰 |
阴影光栅成本
▲
│ ╱╲
│ ╱ ╲ ← 日出/日落:太阳快速移动 + 角度变化 → 缓存全失效
│ ╱ ╲
│ ╱ ╲ ╱╲ ← 另一个尖峰(日落)
│ ───╱────────╲──────────────╱ ╲
│ 正午 ╲ ╱ 午夜
│ (太阳最慢, ╲ ╱ (月亮上岗,太阳光消失
│ 缓存最稳) ╲ ╱ → 阴影成本反而低)
└──────────────────────────────────────────▶ 时刻
0:00 6:00(日出) 12:00 18:00(日落) 24:00
实际形状取决于:太阳角速度(赤纬)、WPO 资产、阶梯式步进(13.6)
为什么日出日落是尖峰:太阳在低角度时角速度不变但阴影变化最剧烈(影子最长、移动最快)——同角度变化下,低角度太阳的阴影位移更大;且晨昏时大气让光变弱,人眼对阴影细节的敏感度反而高。优化尖峰 = 第 13 章的阶梯式太阳。
12.8 演出技巧:大气与光解耦
GetAtmosphereLightDirection(SkyAtmosphereComponent.cpp:642-649):
1 | FVector FSkyAtmosphereSceneProxy::GetAtmosphereLightDirection(int32 AtmosphereLightIndex, const FVector& DefaultDirection) const |
用途: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 章的伏笔在此兑现。FClipmapCacheKey(CacheManager.h:135-140):
1 | struct FClipmapCacheKey |
太阳每帧旋转 → LightDirection 每帧变 → InKey != OutCacheKey → RenderedFrameNumber = -1 → 本帧 uncached → 每帧全量重光栅化。没有例外,没有折中——这是设计使然(阴影内容确实依赖光方向,缓存了就是错的)。
帧 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::Update(CacheManager.cpp:362-397)注释原文(第 8.1 章已全引)总结三点:
- 失效帧走 uncached 更高效(不做无谓的复用尝试);
- 持续移动的灯永远 uncached(不需要显式设置);
- 静止一帧即恢复缓存——缓存恢复速度极快。
成本对比(同一场景):
| 状态 | 每帧阴影光栅成本 |
|---|---|
| 太阳静止 + 场景静止 | ≈ 0(全静态缓存命中) |
| 相机移动 + 太阳静止 | 只画边缘新页(级平移) |
| 太阳移动(uncached) | 全量光栅 ≈ CSM 成本 + VSM 页表/标记开销 |
结论:动态太阳的 VSM 成本 ≈ CSM 的成本 + VSM 的固定开销——比 CSM 更贵。所以”用 VSM 做昼夜循环”必须配合本章的优化,否则就是”比传统方案更差的方案”。
13.3 官方旋钮一:MobilityFactor(移动太阳自动降分辨率)
FShadowScene::UpdateForRenderedFrame(ShadowScene.cpp:187-222)维护每盏灯的 MobilityFactor(1.0 = 正在移动,0.0 = 静止,r.Shadow.Scene.LightActiveFrameCount=10 帧内线性衰减):
1 | // It's not been updated for more than K frames transition to non-active state |
MobilityFactor 喂给 clipmap 的分辨率偏置插值(VirtualShadowMapClipmap.cpp:49-55, 251):
1 | ResolutionLodBias = FVirtualShadowMapArray::InterpolateResolutionBias( |
效果:太阳正在转 → 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=0(VirtualShadowMapClipmap.cpp:36-40)——注释原文:
“Force invalidation of directional shadows. Useful to emulate a moving sun to avoid misrepresenting cache performance.”
官方用它模拟移动太阳(性能测试),也给了主动用法:当太阳”频繁但非每帧”变化时(比如阶梯式步进),显式失效让每帧成本一致(否则阶梯切换帧会突然出现一次全量重画的尖峰,其它帧却假装缓存命中——性能不稳定比稳定地贵更糟)。
13.6 实践技巧:阶梯式太阳(推荐)
核心思想:太阳方向不是每帧变,而是每 N 帧步进一次。步进间隔内缓存键不变 → 级缓存/静态缓存生效 → 大部分帧享受缓存;步进帧一次性失效重画。
1 | 连续式太阳: 每帧变 → 每帧全量(坏) |
实现:阴影光每 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 全量 | IsCachedShadowValid(SceneManagement.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 避坑清单
ResolutionLodBiasDirectionalMoving默认 0.0——官方对方向光”移动降档”默认关闭,昼夜项目不开它 = 白扔性能;- ResolutionLodBias 与 FirstLevel 联动:偏置过大 → 近处级也被跳过 → 近景阴影糊。调偏置后检查近处(L6~L8 级)质量;
- 降档闪烁:阶梯式太阳的跳变在 TAA 关闭时可见——昼夜项目不要关 TAA/TSR;
- 阴影光与视觉光分离后的一致性:阴影太阳角度与视觉太阳角度偏差超过约 2° 时,影子方向与日盘位置对不上(玩家能察觉)——步进角度控制在每步 < 1°;
- 双大气光源同时失效:太阳 + 月亮都开着 VSM 且都在转 → 两套 clipmap 同时 uncached,成本翻倍。月亮可以做成”低更新率”(每 30 帧步进)或干脆用静态光照;
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 | bool DoesPlatformSupportVirtualShadowMaps(EShaderPlatform Platform) |
| 层 | 开关 | 位置 |
|---|---|---|
| 项目/平台 | 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)
Clipmap:Clipmap.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.Visible、ShowLightDrawEvents(:132)、DebugSkipMergePhysical(:594)、Cache.DebugSkipDynamicPageInvalidation(:601)、DumpLightNaniteStats(CacheManager.cpp:215,控制台命令)
14.3 调优工作流:三板斧
1 | 1. 看数字 r.Shadow.Virtual.Stats.Visible 1(ShaderPrint 输出) |
14.4 常见坑清单
- 页池不足块状闪烁:官方承认的已知问题——先
ResolutionLodBias后MaxPhysicalPages; - 移动端静默回退:移动平台自动走 CSM,项目设置勾了 VSM 也不会生效——检查平台日志确认;
- 局部光室内慢:VSM 在局部光多的室内比 CSM 慢 50~100%(公开资料)——评估”室外大世界 VSM + 室内关 VSM”的混合方案(按关卡配置);
ResolutionLodBias调大后近处糊:检查 FirstLevel 与偏置的联动(13.10 避坑 2);- WPO 资产大量失效:内容侧配合(11.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.MarkPages(MegaLights.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 :2545、BuildPageAllocations :3256、RenderVirtualShadowMapsNanite :4343、RenderVirtualShadowMapsNonNanite :4515、PostRender :1953、UpdateHZB :4961、AddRenderViewsClipmap :5080、UpdateNextData :905、CVar 区 :119-660 |
VirtualShadowMapArray.h |
FVirtualShadowMap(常量集合 :58)、ID 布局 AllocateDirectional/Local/Unreferenced :322-324、IsSinglePage :335、物理池/页表声明 |
VirtualShadowMapCacheManager.cpp(2385 行) |
UpdateClipmapLevel :259、Update(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.ush;VirtualShadowMapStats.ush;VirtualShadowMapPrintStats.usf;VirtualShadowMapDrawFalseColor.usf |
| 其他 | VirtualShadowMapHandle.ush → FVirtualShadowMapHandle :10;VirtualShadowMapPageCacheCommon.ush → ShouldCacheInstanceAsStatic :94;VirtualShadowMapPerPageDispatch.ush → FPerPageDispatchSetup :102;VirtualShadowMapBuildPerPageDrawCommands.usf → CullPerPageDrawCommandsCs :155;VirtualShadowMapCompactViews.usf → CompactViewsVSM_CS :40;VirtualShadowMapThrottle.usf;VirtualShadowMapLightGrid.ush;VirtualShadowMapTransmissionCommon.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 参考资料
- Epic 官方文档:Virtual Shadow Maps in Unreal Engine(UE 5.8)—— 6-22 级 clipmap、空级近零成本、页池与已知问题(块状闪烁)。注意:官方”64cm~40km”覆盖与源码公式(L6=128cm、L22≈84km)不一致,以源码为准(第 5.1 章避坑)。
- SIGGRAPH 2021:Brian Karis 等《A Deep Dive into Nanite Virtualized Geometry》”Shadows: Virtual Shadow Maps” 章节 —— VSM 架构的第一公开来源。
- ixueyouxi(中文):《UE5.8 大世界光照与阴影策略》(CSM 距离 8000→15000:2.1ms→4.8ms 实测)、《UE5.8 Virtual Shadow Maps》(页池/缓存/远阴影分析)。
- 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。
- Into the Bytecode(Marco Giustino)VSM 系列 —— 未能在写作时访问核实,仅作线索;基于 UE 5.0~5.2,类名与结构有差异。
- 本仓库源码(一级来源,附录 A 指路)。
附录 D 自测练习
入门级:
- 用三句话 + 一个类比向同事解释 VSM(要求用上”页”和”缓存”两个词)。
- CSM 与 VSM 的核心差异是什么?为什么 VSM 的成本跟着”变化量”走?
r.Shadow.Virtual.MaxPhysicalPages设太小会发生什么?为什么?
进阶级:
- 画一张”相机向右移动 10 米”时某 clipmap 级的页表变化图(标注哪些页平移复用、哪些页新画)。
- 解释为什么太阳每帧旋转必然破坏
FClipmapCacheKey,并推导 uncached 路径下每帧的成本构成。 - 推导页池内存:2048 页 × 双层 × 128×128×4B = 多少 MB?512 页呢?官方”512MB”估算为什么比这个高?
- 昼夜循环需要”五件套”是哪五个?其中哪个最容易漏掉(会导致夜晚天空光还是白天)?
ResolutionLodBiasDirectionalMoving默认值是多少?为什么昼夜项目必须改它?
源码级:
- 在
VirtualShadowMapCacheManager.cpp:259(UpdateClipmapLevel)找到”四条件判定”,解释每一条在什么场景下触发失效。 - 在
VirtualShadowMapCacheManager.cpp:362(Update)找到 uncached 注释原文,翻译”持续移动的光自动走 uncached 路径”这一句并说明官方建议。 - 在
VirtualShadowMapPhysicalPageManagement.usf:155(UpdatePhysicalPageAddresses)找到 KEEP_PAGE 平移逻辑,解释TestPageAddress的合法性检查为什么必要。 - 用 grep 统计本仓库
r.Shadow.Virtual前缀的 CVar 声明数量(预期 97 个),并把它们按用途分组。 - 实验:把
r.Shadow.Virtual.ResolutionLodBiasDirectionalMoving设为 2.0,转动太阳,观察 ShowStats 与画面——验证”移动降档”机制。