来源:Virtual Shadow Map 源码通读笔记
引擎版本:UE 5.8
面向读者:UE 图形程序员
VSM 的概念一句话就能说完:16K×16K 的虚拟阴影图,切成 128×128 的页,按需分配、跨帧缓存。真正读源码时卡住人的是另外几件事:页请求到底是谁写的,物理页在几条链表之间怎么流转,缓存失效标志由谁产生、又被谁消费,Nanite 怎么用一趟光栅化画完成千上万个页,页池 HZB 一帧到底建几次。
下面按一帧的执行顺序把这条链路拆开:需求侧 BeginMarkPages → 供给侧 BuildPageAllocations → Nanite / 非 Nanite 两条光栅路径 → PostRender 收尾,再把散在各阶段的缓存失效逻辑收成一个闭环。路径均相对引擎根目录,VSMArray.cpp 指 Engine/Source/Runtime/Renderer/Private/VirtualShadowMaps/VirtualShadowMapArray.cpp。
目录
- VSM 在解决什么问题
- 一帧的骨架与跨帧反馈
- 需求侧:BeginMarkPages
- 供给侧:BuildPageAllocations
- Nanite 路径:一趟多视图光栅化
- 非 Nanite 路径:逐页实例剔除与间接绘制
- PostRender:折叠脏标志与合并静态层
- 缓存失效的完整闭环
- 性能抓手与调试开关
- 源码坐标速查
- 参考链接
一、VSM 在解决什么问题
1.1 固定分辨率阴影图的浪费
传统 Shadow Map 的分辨率是一刀切分配的:近处一个屏幕像素分不到一个阴影纹素,边缘出锯齿;远处大片场景挤在几个纹素里,精度又过剩。CSM 用几层级联改善了分布,但本质仍是几张固定分辨率的图,而且每帧整张重画。
VSM 把虚拟内存那一套搬到了阴影上:
| 虚拟内存 | VSM |
|---|---|
| 巨大的虚拟地址空间 | 每个 VSM 是一张 16384×16384 的虚拟阴影图;方向光 clipmap 的每一级都是一个独立的 VSM |
| 有限的物理内存 | 固定容量的物理页池 PhysicalPagePool |
| 页表 | PageTable:虚拟页 → 物理页 |
| 缺页 → 分配物理帧 | AllocateNewPageMappings |
| 工作集 | 只给本帧着色真正会采样到的页分配物理内存 |
| 页常驻与换出 | 物理页里的深度跨帧缓存,空间不够时按 LRU 淘汰 |
每个光一张大到近乎无限的虚拟阴影图,只为本帧要采样的 128×128 页分配、渲染深度,并尽量复用上一帧已经画好的页。
1.2 关键数字
| 项 | 值 | 定义位置 |
|---|---|---|
| 页 | 128×128 纹素(VSM_PAGE_SIZE) | Engine/Shaders/Shared/VirtualShadowMapDefinitions.h |
| 每级页数 | 128×128 页(VSM_LEVEL0_DIM_PAGES_XY) | 同上 |
| 每级虚拟分辨率 | 16384×16384(VSM_VIRTUAL_MAX_RESOLUTION_XY) | 同上 |
| 局部光 mip 数 | 8,mip0 = 128×128 页 … mip7 = 1 页(VSM_MAX_MIP_LEVELS) | 同上 |
| 单页 VSM 上限 | 8192(VSM_MAX_SINGLE_PAGE_SHADOW_MAPS) | 同上 |
| Receiver Mask | 每页 8×8 位,每位对应 16×16 纹素(VSM_RECEIVER_MASK_SIZE) | 同上 |
| 物理页数 | r.Shadow.Virtual.MaxPhysicalPages,CVar 默认 2048;阴影画质档位覆盖为 High 2048 / Epic 4096 / Cinematic 8192 | VSMArray.cpp、Engine/Config/BaseScalability.ini |
| 物理页池 | PF_R32_UINT 的 2D 纹理数组;slice 0 = 动态层(兼最终合并结果),slice 1 = 静态层 | VirtualShadowMapCacheManager.cpp |
| 方向光 clip 级 | 默认第 6 ~ 22 级(r.Shadow.Virtual.Clipmap.FirstLevel / LastLevel),第 L 级半径 2^(L+1) cm | VirtualShadowMapClipmap.cpp |
页池宽度取硬件最大 2D 纹理尺寸,高度按页数折算。一个页在两个 slice 里共占 128 KiB(128×128×4 B×2),2048 页约 256 MiB,Epic 档的 4096 页约 512 MiB,VSM 的显存大头就在这里。r.Shadow.Virtual.AllocatePagePoolAsReservedResource 默认开启,页池以 reserved 纹理的形式分配,由若干小块物理内存拼成,减少显存碎片。
1.3 三种形态
| 光源 | 组织方式 | 说明 |
|---|---|---|
| 方向光 | Clipmap | 以相机为中心一圈套一圈,每级只有 mip0;每粗一级覆盖范围翻倍,近处细、远处粗 |
| 聚光 / 点光 / 矩形光 | Mip 金字塔 | 8 级 mip;点光和矩形光按立方体 6 个面各一个 VSM |
| 远处的小灯 | 单页 VSM | 整盏灯只有 mip7 那 1 个页,不做逐页失效,按轮转方式整体重画 |
1.4 静态层与动态层
每个物理页存两层深度。不动的几何画进静态层,可以跨很多帧不重画;会动的几何(WPO、形变、最近移动过的图元)画进动态层。采样只读 slice 0(VirtualShadowMapProjectionCommon.ush 里 PhysicalPagePool.Load(uint4(PhysicalTexelAddress, 0, 0))),里面是两层取较近者的合并结果。
「一个角色在广场上走动,不会让整片静态阴影重画」靠的就是这套分离缓存,后面的失效判定、清页、合并都围绕它展开。
二、一帧的骨架与跨帧反馈
2.1 帧内时序
FSceneRenderer::FinishInitDynamicShadows
└─ FShadowSceneRenderer::DispatchVirtualShadowMapViewAndCullingSetup
└─ UpdatePhysicalPageAddresses 上一帧的物理页重定位到本帧 VSM,捞回失效位
RenderBasePass 深度 / GBuffer 就绪
RenderFrontLayerTranslucency (按需)半透明前层深度,供页标记使用
FShadowSceneRenderer::BeginMarkVirtualShadowMapPages
└─ BeginMarkPages 需求侧:写 PageRequestFlags
RenderShadowDepthMaps
└─ FShadowSceneRenderer::RenderVirtualShadowMaps
├─ BuildPageAllocations 供给侧:分配物理页、建页表、清页
├─ RenderVirtualShadowMapsNanite Nanite 多视图光栅化 + UpdateHZB
├─ RenderVirtualShadowMapsNonNanite 逐页实例剔除 + 间接绘制
└─ PostRender 折叠脏标志、合并静态层
RenderLights 投影 / SMRT 采样物理页池
页标记必须等深度缓冲就绪,所以放在 BasePass 之后;供给和渲染放在 RenderShadowDepthMaps 里,赶在光照之前完成。调用点见 ShadowSetup.cpp:6881、DeferredShadingRenderer.cpp:3276 / 3294、ShadowSceneRenderer.cpp:780 ~ 802、879、898。
2.2 虚拟每帧重建,物理跨帧保留
| 资源 | 生命周期 | 作用 |
|---|---|---|
Shadow.Virtual.PageRequestFlags | 每帧新建清零,帧末提取留给下一帧 | 本帧页请求;下一帧作为 PrevPageRequestFlags 承接场景更新期的失效位 |
Shadow.Virtual.PageTable / PageFlags | 每帧新建清零,帧末提取留给下一帧 | 虚拟 → 物理映射、分层页标志;上一帧的版本供 Nanite 主 pass 查上一帧 HZB |
Shadow.Virtual.PageReceiverMasks | 每帧新建清零 | 页内 8×8 接收者覆盖 |
Shadow.Virtual.DirtyPageFlags | 每帧新建清零 | 渲染期按物理页索引写的脏 / 失效 scratch |
Shadow.Virtual.PhysicalPageLists | 每帧新建 | 四条物理页链表;上一帧的 REQUESTED 链表是本帧的 LRU 输入 |
物理页池 PhysicalPagePool | 持久 | 深度本身,一个字节都不清 |
PhysicalPageMetaData | 持久 | 每个物理页挂在哪个 VSM / mip / 页地址、上次被请求的帧号、扩展标志 |
页池 HZB HZBPhysicalArray | 持久 | 遮挡剔除 |
虚拟结构是便宜的位图,每帧放心清空重建;物理数据是昂贵的深度,能不画就不画。后面所有「为什么要清、为什么不清」都从这一条推出来。
2.3 两条跨帧反馈
ProjectionData(ScatterUpload,按 VSM ID 直接索引)
│
┌─────────────── 需求侧 BeginMarkPages ───────────────┐
│ 逐像素反推 → PageRequestFlags │
└─────────────────────────┬───────────────────────────┘
▼
┌─────────────── 供给侧 BuildPageAllocations ─────────┐
│ UpdatePhysicalPages:本帧请求 × 上一帧缓存 → 留 / 放 │
│ AllocateNewPageMappings:缺页 → 新物理页 │
│ 分层页标志 / mip 重定向 / 清新页 │
└─────────────────────────┬───────────────────────────┘
▼
┌─────────────── 渲染侧 ──────────────────────────────┐
│ Nanite DrawGeometry ─┐ │
│ CullPerPage + Raster ┴→ 只写未缓存的层 → 物理页池 │
│ UpdateHZB → 页池 HZB;PostRender → 物理页元数据 │
└─────────────────────────┬───────────────────────────┘
├─→ 缓存反馈:物理页深度 + 元数据(失效位)→ 下一帧 UpdatePhysicalPages
└─→ 遮挡反馈:页池 HZB → 同帧非 Nanite 剔除、下一帧 Nanite 主 pass
视角稳定时绝大部分页命中缓存、几乎零成本,靠的就是这两条反馈。
三、需求侧:BeginMarkPages
目标只有一个:在不碰物理内存的前提下,算出「本帧哪些虚拟页必须有深度」,结果写进 PageRequestFlags。入口 FVirtualShadowMapArray::BeginMarkPages(VSMArray.cpp:2516),外层 RDG scope 为 VirtualShadowMapMarkPages。
3.1 上传投影数据
每个 VSM 一份 FVirtualShadowMapProjectionShaderData:世界到阴影 UV 的矩阵、clipmap 原点与级号、分辨率 LOD bias、MinMipLevel 等,由 FVirtualShadowMapArrayCacheManager 按光源缓存条目提供。VSM ID 空间是稀疏的,所以用 FRDGScatterUploadBuffer 把零散条目合成一次散列上传(ResourceUploadTo),写进 Shadow.Virtual.ProjectionData。缓冲按槽位总数分配,浪费一点显存,换来 shader 里直接用 VSM ID 下标访问。
同一个循环里还把每个 VSM 按 MinMipLevel 登记进 FPerPageShaderDispatcher。后面所有「逐页」的 pass(清页表、新页映射、mip 重定向)都用它派发:Z 维一个 VSM,XY 线程在页网格上做 stride 循环,并跳过这个 VSM 用不到的 mip(方向光只有 mip0,单页 VSM 只有 mip7)。
3.2 清空虚拟结构与融合初始化
PageRequestFlags、PageTable、PageFlags 都是本帧新建的 PF_R32_UINT 纹理,注释里写着本该用 uint8,受原子操作限制只能用 32 位。页表纹理的布局是「基础层 + 额外 mip 区」:X 方向 128 项,Y 方向 128 + 64 项(VSM_PAGE_TABLE_TEX2D_SIZE_X / Y),局部光的 8 级 mip 全在这一块里,多个 VSM 的页表再按行列拼进同一张纹理。
清零走 AddClearPageTableUAVPass(BeginMarkPages 里的 lambda,内部是 FClearPageTableCS,经 per-page 派发器按 VSM 执行)。PageFlags 和 receiver mask 额外挂着 HMip 链,清的时候连纹理 mip 一起清。receiver mask 只有方向光使用时只按方向光分配,用只含方向光的派发器清(RDG 名 ClearPageTableDir)。StatsBuffer 用 AddClearUAVPass 清零,DirtyPageFlags 按 MaxPhysicalPages × 6 + 1 分配并清零(第七、八章展开)。
FInitPageRectBoundsCS 是分析阶段的第一个 pass,注释直说这是几种初始化的融合:一边把每个 VSM 每级的页矩形(AllocatedPageRectBounds / UncachedPageRectBounds)重置为空,一边把四条物理页链表的计数器摆到初态 —— LRU 记为满(MaxPhysicalPages),AVAILABLE / EMPTY / REQUESTED 记为 0。合成一个 dispatch,比拆成几个小 pass 省。
3.3 PruneLightGrid
逐像素标记要遍历覆盖本像素的局部光,但前向渲染的 light grid 里混着没有 VSM 的灯。FPruneLightGridCS 对每个 cell 重排一遍:只保留有 VSM 的灯,完整 VSM 的灯排在前面,单页(远处)灯排到末尾,输出 Shadow.Virtual.LightGridData / Shadow.Virtual.NumCulledLightsGrid。像素 pass 扫灯时遇到第一盏单页灯就 break。
RDG 事件名形如 PruneLightGrid(Min=0,Max=N)。Max 是本 view 的局部光数量,本帧没有任何带 VSM 的局部光时直接置 0,所以看到 Max=0 只说明这一帧没有局部光参与标记。开了 MegaLights 且由它自己标页时,它的灯也在这里排除。
3.4 MarkCoarsePages:给整盏灯兜底
体积雾、半透明光照体(translucent light volume)会在空间任意位置采样阴影,这些采样点不对应屏幕上的 GBuffer 像素。FMarkCoarsePagesCS(一线程一个 VSM)给每盏灯兜一层低分辨率覆盖:
- 方向光:被标为 coarse 的 clip 级(默认第 15 ~ 18 级,
r.Shadow.Virtual.Clipmap.FirstCoarseLevel / LastCoarseLevel;最后一级总会被标上,注释说明是给云和大气用的)各标 clipmap 原点周围 4 个页。粗级本身就是细级的超集,4 个页在世界空间已经覆盖很大范围。 - 局部光:标最后一级 mip,也就是覆盖整盏灯的那一页;单页 VSM 不论开关都标。
粗页只写 ALLOCATED | PRIMARY_REQUEST,不带 DETAIL_GEOMETRY,而且必须排在所有像素标记之前:
// Engine/Source/Runtime/Renderer/Private/VirtualShadowMaps/VirtualShadowMapArray.cpp(BeginMarkPages)
// Mark coarse pages (view-independent)
// NOTE: 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.
// Because of this we also cannot overlap this pass with the following ones.页请求是普通写入:粗页先写,像素 pass 后写同一地址时直接覆盖成带 DETAIL_GEOMETRY 的标志,最后一次写赢。
DETAIL_GEOMETRY 的作用在剔除端:屏幕足印很小的实例(IsDetailGeometry:静态缓存实例 < 1 像素,动态非 Nanite < 16,动态 Nanite < 4,对应 r.Shadow.Virtual.CoarsePagePixelThreshold*)只画进带该标志的页,不往只有粗请求的大页里画 —— 粗页面积大,往里画一堆小物体很贵。r.Shadow.Virtual.NonNanite.IncludeInCoarsePages=0 时,非 Nanite 几何一律视为细节几何,完全不进粗页。
❗ r.Shadow.Virtual.MarkCoarsePagesDirectional / MarkCoarsePagesLocal 默认是 2(Performance Mode):只被粗请求覆盖的页不会因移动物体、WPO、动画而失效。远处动态物体在粗页里的阴影可能是旧的,这是有意的性能取舍。
3.5 GeneratePageFlagsFromPixels:从屏幕像素反推页请求
需求侧的核心。
采样方式。 不是每个像素都跑:r.Shadow.Virtual.PageMarkingPixelStrideX / Y 默认 2,即 2×2 像素取 1 个样本,相邻像素几乎总落在同一个页。线程组 8×8,沿用单层水体的 tile 尺寸 SLW_TILE_SIZE_XY,方便按水体 tile mask 做标量化;dispatch 网格 = ceil(视口 / stride / 8)。
RDG 事件名形如 GeneratePageFlagsFromPixels(GBuffer,NumShadowMaps=17,{100,52})(开 Substrate 时第一段显示为 Substrate):NumShadowMaps 是本帧完整 VSM 的数量,不含单页 VSM;花括号里是线程组网格。{100,52} 对应 800×416 个样本,stride 为 2 时视口约为 1600×832。每个 view 各跑一次。
每个样本做什么:
- 还原位置。 读 device Z 反投影出 translated world position,读法线和 shading model(Substrate 读 top layer),Unlit 直接跳过,免得给天空球标页。可选叠加三种额外深度来源,它们各自需要自己的阴影页:单层水面(
FWaterDepth排列)、半透明前层(r.Shadow.Virtual.TranslucentQuality开启时,取本帧前层深度或历史重投影,FTranslucencyDepth排列)、头发(单独一个按 hair tile 间接派发的 pass)。 - 方向光:
- 选 clip 级:
floor(log2(到 clipmap 原点的距离) + ResolutionLodBias + GlobalResolutionLodBias + ExtraBias)(GetBiasedClipmapLevel)。距离每翻一倍粗一级,而每级覆盖范围也翻一倍,阴影纹素密度因此大致跟着屏幕像素密度走。ExtraBias里有景深虚化区域的降分辨率(r.Shadow.Virtual.MaxDOFResolutionBias,默认 1)和第一人称像素的偏置。 - 背面剔除:法线背向光源的像素本来就在阴影里,不请求页(
r.Shadow.Virtual.CullBackfacingPixels,次表面材质和头发除外)。 - 投影到阴影 UV、做裁剪测试,算出虚拟纹素地址,右移 7 位得到页地址。
- 选 clip 级:
- 局部光(遍历精简后的 light grid):
- 半径测试、聚光的圆锥测试、矩形光的背面测试,任一不过就
continue;遇到单页灯break。 - 点光和矩形光按方向选立方体面(
VirtualShadowMapGetCubeFace)。 - 选 mip:把一个屏幕像素在该深度处的尺寸投到阴影空间,得到以阴影纹素计的足印
Footprint,MipLevel = clamp(floor(log2(Footprint) + 各种 bias), 0, 7)(GetMipLevelLocal)。足印越大说明分辨率越过剩,mip 越粗。r.Shadow.Virtual.MarkPixelPagesMipModeLocal可以只用偶数级或奇数级 mip,减少同一实例被重复画进多级 mip。
- 半径测试、聚光的圆锥测试、矩形光的背面测试,任一不过就
最终落到 MarkPage:
// Engine/Shaders/Private/VirtualShadowMaps/VirtualShadowMapPageMarking.ush(MarkPage 节选)
// Normal pages marked through pixel processing are not "coarse" and should include "detail geometry" - i.e., all geometry
uint Flags = VSM_FLAG_ALLOCATED | VSM_FLAG_DETAIL_GEOMETRY | VSM_FLAG_PRIMARY_REQUEST;
uint MaxVirtualAddress = CalcLevelDimsTexels(MipLevel) - 1U;
float2 VirtualAddressFloat = ShadowUVz.xy * CalcLevelDimsTexels(MipLevel);
uint2 VirtualAddress = clamp(uint2(VirtualAddressFloat), 0U, MaxVirtualAddress);
uint2 PageAddress = VirtualAddress >> VSM_LOG2_PAGE_SIZE; // 虚拟纹素 → 页地址
FVSMPageOffset PageOffset = CalcPageOffset(VirtualShadowMapHandle, MipLevel, PageAddress);
MarkPageAddress(PageOffset, Flags); // 内部就是 OutPageRequestFlags[...] = Flags成千上万个样本会砸向同一个页,但它们写的是同一个值,普通写入的竞争无害,不需要原子操作。
页膨胀。 阴影过滤和 SMRT 光线步进会跨到相邻页,邻页没分配就会在页边界出现接缝。样本离页边界不到 r.Shadow.Virtual.PageDilationBorderSize{Directional,Local}(默认 0.05 页)时,相邻页也会被标上。为了省,每个线程只沿一条对角线检查,方向由组内线程号的低两位抖动决定,在 ±offset 两个方向各探一次;只要页边附近有一个样本探到,邻页就会被标上。
Receiver Mask。 在页内再记一层 8×8 的细粒度覆盖,存成 2×2 个 uint、每个管 4×4 位,这一步用 InterlockedOr。方向光默认开(r.Shadow.Virtual.UseReceiverMaskDirectional=1),局部光默认关(r.Shadow.Virtual.UseReceiverMaskLocal=0)。剔除阶段据此跳过页内没人采样的子区域,代价见 4.3。
Froxel 路径。 r.Shadow.Virtual.MarkPagesUsingFroxels(默认 0)开启且 Froxel 渲染器可用时,改由 GeneratePageFlagsFromFroxelsCS 按 froxel 标页,替代 GBuffer 像素 pass;头发 pass 照常单独跑。
跑完 BeginMarkPages,手上只有一张「哪些虚拟页被需要」的位图外加 receiver mask,物理页一个没动。
四、供给侧:BuildPageAllocations
入口 FVirtualShadowMapArray::BuildPageAllocations(VSMArray.cpp:3227,RDG scope 名为 FVirtualShadowMapArray::BuildPageAllocation)。全是 compute pass,任务是把页请求翻译成物理页分配:能复用上一帧缓存的页,就绝不重新分配,更不重画。
4.1 四条物理页链表
分配器的账本是 Shadow.Virtual.PhysicalPageLists:4 条并排的 int 列表,每条长 MaxPhysicalPages + 1,最后一个元素是计数器,push / pop 就是对计数器做 wave 原子加减。任一时刻每个物理页只在其中一条列表里。
| 列表 | 含义 | 本帧的角色 |
|---|---|---|
| LRU | 本帧没被使用的页,按最近使用排序 | 输入是上一帧末尾的完整页列表;UpdatePhysicalPages 原位改写,移走的槽写 INDEX_NONE,剩下的是仍缓存着有效深度、可留可弃的页 |
| REQUESTED | 本帧被请求、占用的页,不可再分配 | 缓存命中的旧页 + 新分配的页;帧末并入所有剩余页,成为下一帧的 LRU |
| EMPTY | 本帧释放的页,临时中转 | 超龄或已无效的页,稍后接到 AVAILABLE 尾部 |
| AVAILABLE | 可分配池 | 前段是 LRU 幸存页压紧,尾段是 EMPTY;分配从尾部弹出 |
一帧里页的流动:
上一帧末尾的 REQUESTED(完整全页列表,按最近使用排序)
│ 本帧作为 LRU 输入(r.Shadow.Virtual.Cache.AllocateViaLRU=1)
▼
UpdatePhysicalPages(一线程一个列表槽)
├─ 本帧被请求 → push REQUESTED
├─ 超龄 / 元数据已无效 → push EMPTY
└─ 未被请求但仍在保留期 → 原位留在 LRU(移走的槽写 INDEX_NONE)
▼
PackAvailablePages:LRU 幸存项压紧 → AVAILABLE 前段
AppendPhysicalPageList(EMPTY → AVAILABLE):空页接到 AVAILABLE 尾部
▼
AllocateNewPageMappings:从 AVAILABLE 尾部 pop(先吃空页,再牺牲最久没用的缓存页)→ push REQUESTED
▼
AppendPhysicalPageList(AVAILABLE → REQUESTED):剩余页并回,REQUESTED 恰好含全部物理页
│
└──→ 下一帧的 LRU 输入
❗ 帧末的 REQUESTED 必须恰好包含每个物理页各一次。它就是下一帧的 LRU 输入,少一个索引就等于永久丢了一个物理页,AppendPhysicalPageLists 里对此有 NewOutputCount == MaxPhysicalPages 的检查。
4.2 UpdatePhysicalPageAddresses:把上一帧的物理页挪到本帧坐标系
这个 pass 不在 BuildPageAllocations 里,而是更早:在 FSceneRenderer::FinishInitDynamicShadows → DispatchVirtualShadowMapViewAndCullingSetup 中无条件执行(所有 GPU 都跑),早于页标记。VSM ID 每帧重新分配,方向光 clipmap 还跟着相机平移,上一帧物理页元数据里记的「VSM ID + 页地址」在本帧不一定还成立。
一线程一个物理页:
- 通过
NextVirtualShadowMapData(上一帧 ID → 本帧 ID 的映射)改写VirtualShadowMapId,clipmap 再加上平移带来的PageAddressOffset。映射不存在(灯没了)或平移后出界,元数据Flags清零,页作废。 - 从上一帧的
PageRequestFlags里取出INVALIDATE_*位,并进物理页元数据。场景更新期产生的失效就是这样送到物理页上的(第八章)。
物理页池、页池 HZB 的创建和尺寸调整也在这个函数里顺带完成。
4.3 UpdatePhysicalPages:留、放、重画哪一层
缓存机制的核心。一线程一个 LRU 槽位,按上一帧的 REQUESTED 顺序遍历,拿物理页记录的虚拟地址去查本帧 PageRequestFlags:
if (bRequestedThisFrame || bLightUnreferenced || PageAge <= MaxPageAgeSinceLastRequest)
{
if (!bRequestedThisFrame || bLightUnreferenced)
{
// 留在 LRU,不写页表;元数据打 UNREFERENCED,已有失效位原样保留
// PageFlags 只写 ALLOCATED:失效处理还能找到它,渲染看不到它
}
else
{
// push REQUESTED,更新 LastRequestedSceneFrameNumber
// 按物理页上累积的失效位决定重画哪层:
// 只有 INVALIDATE_DYNAMIC → DYNAMIC_UNCACHED,静态层照旧复用
// 含 INVALIDATE_STATIC → DYNAMIC_UNCACHED | STATIC_UNCACHED
// DEFERRED_INVALIDATION 且在预算内 → 两层都重画
// 灯启用了 receiver mask → 总是 DYNAMIC_UNCACHED
// 本次请求的辅助标志与缓存时不同(如 DETAIL_GEOMETRY) → 两层都重画
// 写回页表(虚拟 → 物理)与 PageFlags
}
}
else
{
// 元数据 Flags 清零 → push EMPTY
}
几点要点:
r.Shadow.Virtual.Cache.MaxPageAgeSinceLastRequest默认 1000 帧,没被请求的缓存页最多保留这么久。- 单页 VSM 在这里完全不看失效位,注释说明远处灯按轮转方式整体失效;「只被粗请求覆盖 + 粗页模式 2」的页忽略只针对动态层的失效,静态层失效照常生效。
- 每个被请求的页都计入
STATIC_CACHED / STATIC_INVALIDATED / DYNAMIC_CACHED / DYNAMIC_INVALIDATED统计,缓存命中率的数据就从这里来。
❗ 保留而未被请求的页不写页表。它们只是作为「有内容的空闲页」留在 LRU 里,本帧采样不到;下一次重新被请求时直接重新挂上映射,省掉重画。
❗ receiver mask 那一条影响很大:带 receiver mask 渲染出来的页可能不完整(页内没人采样的子区域被剔掉了),源码里直接写着 // Always invalidate dynamic when using receiver mask, as the page may be incomplete。方向光默认开 receiver mask,意味着被像素请求的方向光页,动态层每帧都会重画,真正跨帧缓存的只有静态层。相应地,CPU 侧也不再为使用 receiver mask 的灯提交动态图元的失效(FInvalidatingPrimitiveCollector::AddInvalidation 里直接 return)。
4.4 PackAvailablePages 与 AppendPhysicalPageList:空页优先被吃
FPackAvailablePagesCS只起一个 1024 线程的组(注释说是为了避免多 pass),循环做组内前缀和,把 LRU 里被挖成INDEX_NONE的洞压紧,按原顺序写进 AVAILABLE。AppendPhysicalPageList(EMPTY → AVAILABLE)把本帧释放的空页接到 AVAILABLE 尾部。
分配从尾部弹出,所以真正的空页先被用掉;缓存着有效深度的 LRU 页只有在空页耗尽后,才从尾部、也就是最久没被请求的一端开始牺牲。缓存页因此能多活几帧,跨帧复用率更高。
4.5 AllocateNewPageMappings:缺页分配
经 per-page 派发器逐虚拟页执行。对每个「被请求、但 UpdatePhysicalPages 没给它挂上物理页」的虚拟页:
- 从 AVAILABLE 尾部 pop 一个物理页,push 进 REQUESTED。
- 如果这个物理页还挂着别的虚拟页(被牺牲的缓存页),先把旧主的页表项和页标志清零。
- 写新映射,页标志置
ALLOCATED | DYNAMIC_UNCACHED | STATIC_UNCACHED,全新的页两层都要画;元数据记下 VSM ID、mip、页地址和请求帧号。INITIALIZED位不继承,因为动态层的「已初始化」可能意味着里面拷过旧主的静态层数据。 - AVAILABLE 已空:这个虚拟页拿不到物理页,该处阴影缺失。
溢出由 FeedbackStatusCS 通过 GPU message 回传剩余可用页数,非 Shipping 下 CPU 打出 Virtual Shadow Map Page Pool overflow (%d page allocations were not served) 警告,提示调 r.Shadow.Virtual.MaxPhysicalPages 或分辨率 bias。同一条回传还驱动自动降分辨率:页池占用超过 r.Shadow.Virtual.DynamicRes.MaxPagePoolLoadFactor(默认 0.85)时快速抬高全局分辨率 LOD bias(上限 r.Shadow.Virtual.DynamicRes.MaxResolutionLodBias,默认 2),占用回落后再慢慢降回来。这就是 3.5 里那个 GlobalResolutionLodBias 的来源。
4.6 GenerateHierarchicalPageFlags:剔除加速结构
这一步本身不剔除任何东西,它给后面的剔除建一座「页占用金字塔」。
一个 VSM 级有 128×128 页,本帧真正映射的只有一小撮。光栅化前,每个投射物(一个实例、一个 cluster)都要问「我盖住的这块页矩形里,有没有至少一个需要本帧渲染的页」,而且要问成千上万次,逐页遍历太慢。
做法:一线程一个物理页,读它的页标志(低 6 位:ALLOCATED、DYNAMIC_UNCACHED、STATIC_UNCACHED、DETAIL_GEOMETRY 和两个请求位),沿 PageFlags 纹理的 HMip 链逐级 InterlockedOr 上去;某一级的原值已经等于要写的值就提前停,说明别的线程已经往上传播过了。每上一级是下一级 2×2 页的 OR,和 HZB 同一个套路,只不过归纳的是页占用。开了 receiver mask 的 VSM 同时建一座 receiver mask 金字塔。
查询端是 OverlapsAnyValidPage(VirtualShadowMapPageOverlap.ush):选一个让页矩形缩到不超过 2×2 格的 HMip 层,一次 Gather 取 4 格 OR 起来,再按标志掩码测试。全 0 就说明整片没有可画的页,一次读取剔掉;非 0 才往下细查或光栅化。O(1) 早退取代了 O(面积) 遍历。
同一个 pass 还用 InterlockedMin / Max 维护每个 VSM 每级的两个页矩形:AllocatedPageRectBounds(所有映射页)和 UncachedPageRectBounds(有未缓存层、需要本帧重画的页)。剔除时先用 VirtualShadowMapGetUncachedScreenRect 把投射物的屏幕矩形夹进 uncached 矩形,再查金字塔。
渲染时用的掩码只测对应层的 UNCACHED 位(GetPageFlagMaskForRendering:静态缓存实例测 STATIC_UNCACHED,其余测 DYNAMIC_UNCACHED)。所以金字塔回答的不只是「这片有没有页」,还有「这片有没有这一层本帧要重画的页」:一片全是缓存命中的区域,剔除时和空白区域一样被整片跳过。跨帧缓存省下的光栅化开销,就是在这里兑现的。
4.7 PropagateMappedMips:采样回退
和 4.6 都叫 mip 链,目的相反:4.6 服务剔除(这片有没有页),这里服务采样(这个坐标最近的已映射页在哪)。
局部光 VSM 里,相机没细看的地方往往只有粗 mip 的页。采样时精细页没映射,就得退到更粗、真实存在的页;每次采样都逐级往上试太慢。FPropagateMappedMipsCS(VirtualShadowMapPageManagement.usf)从最粗的 mip 往细扫,记住最近一个已映射的页,遇到没映射的精细页,就把那个粗页的物理地址连同 mip 偏移直接编码进精细页的页表项。采样时读一次页表,就同时拿到可用的物理页和坐标缩放量。
例:mip0 的页 (10,20) 没映射,覆盖同一位置的 mip2 页 (10>>2, 20>>2) = (2,5) 已映射,mip0 这一项就被写成「物理页 = mip2 那一页,偏移 2 级」。方向光同理,只是「更粗」指更粗的 clip 级(下一个 VSM),查找时还要扣掉各级 clipmap 原点的相对偏移。这些重定向项的 bThisLODValid 为假,渲染不会往里写。只有存在完整 VSM 时才执行。
4.8 InitializePhysicalPages:只清需要清的页
新分配或刚失效的物理页里是旧深度,渲染前必须处理。要清的页数只有 GPU 知道,而且绝大多数页缓存命中、不用清,所以走「GPU 选页 + 间接派发」:
CreateAndClearIndirectDispatchArgs1D清间接参数。SelectPagesToInitializeCS(一线程一物理页):跳过未分配、未引用、完全缓存、FORCE_CACHED的页;静态层未缓存且还没清过(无STATIC_INITIALIZED),就排一个静态 slice 任务;动态层没初始化过、或者静态层刚被清,就排一个动态 slice 任务。每个任务占 16 个线程组,用 wave 原子追加,同时置上对应的*_INITIALIZED | *_DIRTY。InitializePhysicalPagesIndirectCS(RDG 名InitializePhysicalMemoryIndirect):每组 16×16 线程处理 32×32 纹素。静态层仍缓存时,把静态 slice 拷到动态 slice 给动态层打底;否则清零(reverse-Z 下 0 就是最远)。
INITIALIZED 的语义是「这一层处于干净的初始态,还没画过东西」,渲染写过之后会被清掉(7.1)。有它在,同一页不会被反复清。r.Shadow.Virtual.ForceFullPageClears=1 可以关掉这个优化做对比。
到这里,GPU 已经确切知道哪些虚拟页存在、各自映射到物理页池的哪个槽、哪些层复用上一帧深度、哪些层要重画,要重画的层也已经清好或打好底。深度本身还没开始画。
五、Nanite 路径:一趟多视图光栅化
入口 RenderVirtualShadowMapsNanite(VSMArray.cpp:4218,RDG scope RenderVirtualShadowMaps(Nanite))。两条光栅路径顺序固定:Nanite 先跑,并在结束时建好本帧的页池 HZB;非 Nanite 后跑,复用这张 HZB 做遮挡剔除。
5.1 一个 view 是一整级,不是一个页
Nanite 以 view 为单位剔除和光栅化。在 VSM 里,一个 view 对应一个 clip 级,或者局部光某个面的一级 mip,也就是一整张虚拟图(mip0 时 16384×16384),而不是一个 128×128 的页。光栅化在虚拟坐标系里进行,写深度时由页表把每个虚拟纹素重定向到它的物理槽,没映射的页、已缓存的层自然被跳过。「几万个页怎么一发画完」的答案就在这里。
光栅上下文用 EOutputBufferMode::DepthOnly,外部深度缓冲直接就是物理页池(ExternalDepthBuffer = PhysicalPagePoolRDG,bClearTarget = false)。
❗ 阴影的 Nanite 软光栅默认不走 async compute:r.Nanite.AsyncRasterization.ShadowDepths 默认 0,Lumen 开了 async 时也会被强制关闭。
5.2 CompactViewsVSM:压紧 view、展开 mip
输入的 view 数组是稀疏的:一盏方向光十几个 clip 级,一盏点光 6 个面、每个面 8 级 mip,本帧大多数是空的。FCompactViewsVSM_CS 一组处理一个 view range(FViewDrawGroup):组内 0 号线程按原顺序扫一遍 primary view 及其各级 mip,只保留 UncachedPageRectBounds 非空、也就是这一级有本帧要重画的页的组合,用原子计数在 Shadow.Virtual.CompactedViews 里占位并回写 InOutViewDrawRanges;然后组内各线程并行生成这些 mip view(改 ViewRect、LODScales、ClipSpaceScaleOffset 等)。Nanite 剔除和光栅面对的就是这个稠密列表。
5.3 剔除即页测试
常见的误解是「先剔出所有可见 cluster,再筛出落在待渲染页上的」。实际上页测试是可见性判据本身的一部分:NaniteCullingCommon.ush 里 FBoxCull::HZB() 的 VIRTUAL_TEXTURE_TARGET 分支先用 VirtualShadowMapGetUncachedScreenRect 把屏幕矩形夹进本级的 uncached 页矩形,再用 OverlapsAnyValidPage 查分层页标志。一个实例或 cluster 判为可见,要同时满足:
视锥 ∧ 覆盖到本层待渲染的页(OverlapsAnyValidPage)∧ HZB 遮挡测试通过
而且是分层收窄的:
InstanceCull:先拿实例包围盒测,碰不到任何待渲染页的实例连 cluster BVH 都不展开。这一步还定下实例画进静态层还是动态层(ShouldCacheInstanceAsStatic,见 8.1)以及是否算细节几何。NodeAndClusterCull:对幸存实例遍历 cluster BVH,用更紧的包围盒逐 node、逐 cluster 复测。
多视图意味着同一个 cluster 对它可能出现的每个 view 各测一次。
遮挡剔除是标准的 two-pass:主 pass(RDG scope MainPass)用上一帧的页池 HZB 测试,注释写明「HZB (if provided) comes from the previous frame, so we need last frame’s page table」,所以查的是上一帧的页表、页标志和页矩形;判为被遮挡的实例和 cluster 进队列。主 pass 画完后重建 HZB,PostPass 换用本帧页表和新 HZB 复测、补画(5.6)。
5.4 可见条目、页窗口与分桶派发
剔除的产物不是 drawcall,而是往全局 buffer Nanite.VisibleClustersSWHW 写可见条目。每条只是打包好的索引(实例、cluster 所在的流送页和下标、view ID、标志、页窗口),不拷贝任何几何。几何常驻在全局的 ClusterPageData 里,光栅阶段按索引直接寻址,这是「一发画完」的前提。SW 条目从 buffer 底部往上写,HW 条目从顶部往下写。
VSM 下还多一层页窗口:cluster 通过测试后,按它盖住的页矩形分窗口逐页检查(同一个循环里顺手打脏 / 失效标记,见 8.3),每个含可渲染页的窗口发射一个条目,记下 vPage ~ vPageEnd。窗口大小:HW cluster 是 4×4 页(VSM_RASTER_WINDOW_PAGES),SW cluster 是 8×8 页(NANITE_VSM_PAGE_TABLE_CACHE_DIM)。跨很多页的大 cluster 会产生多个条目。
SW / HW 分流在 cluster 剔除时就地决定(SmallEnoughToDraw):按投影到屏幕后的边长估计,大三角形走 HW,小三角形走 SW compute 光栅;需要近平面裁剪的 cluster 也走 HW;曲线(curve)cluster 永远走 SW。
之后 CalculateSafeRasterizerArgs 根据 SW / HW 计数算出安全的间接参数,RasterGroupInit → RasterBinCount → RasterGroupReserve → RasterBinScatter → RasterBinFinalize 在 GPU 上把可见条目按 raster bin 分桶、算好各桶的间接参数。raster bin 由光栅管线决定:几种固定功能 bin(普通、双面、蒙皮、样条等组合),外加每个可编程光栅材质(WPO、Masked、置换等)各一个 bin。
派发是「每个非空 bin 一次」:
- SW:一次间接 compute dispatch(
ClusterRasterize),一个线程组处理一个可见条目。 - HW:支持 mesh shader 时走
DispatchIndirectMeshShader(HWRasterizeMS),一个 mesh shader 线程组对应一个可见条目,用SV_GroupID取回自己的条目,从ClusterPageData拉顶点、变换后输出;否则回退到DrawPrimitiveIndirect(HWRasterizeVS)。全程没有逐网格的 VB / IB 绑定。
所以「一发画完」更准确的说法是:派发次数 = O(可见 raster bin 数),与灯的数量、clip 级数、页数、网格数都无关。这才是它顶掉 CSM 成百上千个 drawcall 的原因。
5.5 虚拟纹素到物理纹素
光栅器算出的是虚拟纹素坐标,写之前经页表换成物理坐标:
- SW(
NaniteRasterizationCommon.ush):组内先把条目页窗口里最多 8×8 个页表项取出来缓存进 groupshared(FetchAndCachePageTableEntry→ShadowGetPhysicalPage,不可渲染的页记成0xffff),逐像素再查这份缓存:PhysicalPosition.xy = pPage * VSM_PAGE_SIZE + (Pos & VSM_PAGE_SIZE_MASK),查到0xffff直接丢弃。 - HW(
HWRasterizePS):像素着色器里逐像素调VirtualToPhysicalTexelForRendering查页表,不可渲染就 return;PhysicalPosition.z取静态或动态 slice。
两条路径最后都用原子最大值写深度,reverse-Z 下越大越近:
// Engine/Shaders/Private/Nanite/NaniteWritePixel.ush(FVisBufferPixel::Write)
#if VIRTUAL_TEXTURE_TARGET
InterlockedMax( OutDepthBufferArray[ PhysicalPosition ], DepthInt );
#else「往一整级虚拟图里光栅化,却只落进零散的物理页」,就是靠这一步逐像素重定向缝起来的。
整条 Nanite 路径的数据流:
全局常驻数据(整趟 DrawGeometry 共享,不重绑)
ClusterPageData(几何)· PageTable / PageFlags 金字塔 · UncachedPageRectBounds
CompactedViews(每个 = 一个 clip 级 / 一级 mip)· 页池 HZB(HZBPhysicalArray)
│
▼
分层剔除(每个 view 各测一次)
InstanceCull ──→ NodeAndClusterCull
判据 = 视锥 ∧ OverlapsAnyValidPage ∧ HZB
逐页窗口:打脏 / 失效标记 → 窗口内有可渲染页就发射条目
│ 写入(实例, cluster, view, 页窗口)索引
▼
Nanite.VisibleClustersSWHW [ SW 段 →→→ ┊ ←←← HW 段 ]
│ CalculateSafeRasterizerArgs + raster bin 分桶
┌───────────────┴───────────────┐
▼ ▼
SW:间接 compute dispatch HW:DispatchIndirectMeshShader
ClusterRasterize / DrawPrimitiveIndirect
groupshared 缓存页表项 像素着色器逐像素查页表
└───────────────┬───────────────┘
▼ 虚拟纹素 → 物理纹素,不可渲染页丢弃
PhysicalPosition = pPage * 128 + (Pos & 127),z = 静 / 动态 slice
▼ InterlockedMax(reverse-Z)
PhysicalPagePool
5.6 页池 HZB:一个函数,一张图,一帧多次
UpdateHZB(VSMArray.cpp:4829)对物理页池建 furthest HZB,三个子 pass:
SelectPagesForHZB(FSelectPagesForHZBAndUpdateDirtyFlagsCS,一线程一物理页):先调UpdateAndClearDirtyFlags把渲染期写下的逐页脏标志折叠进物理页元数据并清空 scratch,再挑出已分配、被引用、且静态层变脏的页(仅动态层变脏,只在开了独立动态 HZB 或灯不缓存时才算),每页追加 16 个组的间接参数,外加一个顶层归约组。没变过的页不重建。BuildHZBPerPage:每页 128×128 深度按 2×2 取 min(reverse-Z 下 min 即最远),归约出 64、32、16、8、4 五级。BuildHZBPerPageTop:再归约出 2、1 两级。
❗ 默认(r.Shadow.Virtual.DynamicHZB=0)页池 HZB 只有一个 slice,数据取自静态层(不缓存的灯取 slice 0)。也就是说默认只有静态世界充当遮挡者,动态几何不进 HZB;打开 DynamicHZB 才会额外建一个静态 + 动态合并深度的 slice。
调用点有两处,其中一处在 Nanite 内部:
// Engine/Source/Runtime/Renderer/Private/Nanite/NaniteCullRaster.cpp(FRenderer::DrawGeometry)
// Occlusion post pass. Retest instances and clusters that were not visible last frame. If they are visible now, render them.
if (Configuration.bTwoPassOcclusion)
{
// Build a closest HZB with previous frame occluders to test remainder occluders against.
if (VirtualShadowMapArray)
{
RDG_EVENT_SCOPE(GraphBuilder, "BuildPreviousOccluderHZB(VSM)");
VirtualShadowMapArray->UpdateHZB(GraphBuilder);
CullingParameters.HZBTextureArray = VirtualShadowMapArray->HZBPhysicalArrayRDG;
...
VirtualTargetParameters.HZBPageTable = VirtualShadowMapArray->PageTableRDG;❗ 截帧里看到的 BuildPreviousOccluderHZB(VSM) 不是另一张 HZB,它就是 UpdateHZB,写的是同一张持久纹理 HZBPhysicalArray。一帧里的调用点:
- 每个 Nanite VSM render pass 的
DrawGeometry内部,MainPass与PostPass之间各一次,post pass 用它复测主 pass 判为被遮挡的实例和 cluster; - 所有 Nanite pass 结束后(
RenderVirtualShadowMapsNanite末尾)再来一次,把 post pass 画的内容也纳入。
这张 HZB 的消费者:
- 同帧的非 Nanite 剔除:
r.Shadow.Virtual.NonNanite.UseHZB=1,且本帧确实建过 HZB 才用; - 下一帧 Nanite 主 pass:借上一帧的页表寻址;
- 场景更新期的缓存失效:被完全遮住的实例不产生失效(
r.Shadow.Virtual.Cache.InvalidateUseHZB=1)。
六、非 Nanite 路径:逐页实例剔除与间接绘制
入口 RenderVirtualShadowMapsNonNanite(VSMArray.cpp:4389,RDG scope RenderVirtualShadowMaps(Non-Nanite))。传统网格同样要把深度写进正确的物理页,但绝不能让一个实例往每个页都画一遍。整条路径是:逐页剔除 → 分配输出区间 → 重排 → 间接光栅化。
6.1 批处理与缓冲定尺
r.Shadow.Virtual.NonNanite.Batch=1(默认)时,所有带剔除命令的 VSM 阴影 pass,局部光和 clipmap 一样,都经 FInstanceCullingMergedContext::AddBatch 合进一个批次,一次剔除完;r.Shadow.Virtual.NonNanite.SinglePassBatched=1(默认)时再用一个 RasterPasses pass 把所有灯的 mesh draw command 依次画完。关掉 Batch 才会逐灯各自剔除、各自光栅。
可见实例输出缓冲按「实例数 × primary view 数 × mip 层数」这个保守上界预分配,即假设每个实例都画进每一级可能的 mip,再乘 r.Shadow.Virtual.NonNanite.CulledInstanceAllocationFactor(默认 1.0),并夹到 r.Shadow.Virtual.NonNanite.MaxCulledInstanceAllocationSize(默认 128M)。实际输出远小于上界,显存紧张时可以调低系数,溢出会记进 VSM_STAT_OVERFLOW_FLAG_VISIBLE_INSTANCES。剔除前由 FInstanceCullingContext::AddClearIndirectArgInstanceCountPass 把各 draw command 间接参数里的实例数清零。
6.2 CullPerPageDrawCommands:逐页剔除
一个线程处理一个实例(由 instance culling 的 GPU 负载均衡器从批次里分发),循环该批次的所有 primary view(clip 级或立方体面),每个 view 再循环各级 mip:
- 取几何:实例场景数据、图元数据、局部包围盒;draw command 带有动态网格包围盒(
DynamicMeshBoundsIndex有效)时,改用这份包围盒(LoadDynamicMeshBounds)。 - 对 primary view 做
FBoxCull:距离、屏幕尺寸、全局裁剪面、视锥;同时定下bCacheAsStatic(画进静态层还是动态层)、是否启用 WPO,以及bInvalidatePages(材质 WPO 失效或实例形变)。 - 估算足印:
PixelEstRadius是实例在 VSM 基础级的像素半径估计。源码注释专门解释了为什么用估计值:真实足印随旋转和边缘裁剪抖动,拿来做剔除不稳定。每往下一级 mip,半径减半。 - 对每一级 mip(
MipViewId = MipLevel * TotalPrimaryViews + PrimaryViewId):- 包围盒投影成屏幕矩形,判断是否细节几何,夹进 uncached 页矩形,转成页矩形
RectPages; OverlapsAnyValidPage(叠加 receiver mask)不过,计入PAGE_MASK_CULLED,跳过;- 用本帧页池 HZB 做遮挡测试(
IsVisibleMaskedHZB),被挡住就跳过; - 在页矩形上逐页打脏 / 失效标记(见 8.3),顺带数出映射页数。≤ 8 页的小矩形由当前线程内联做完;大矩形压进 group-shared 作业队列,shader 末尾由组内所有线程协作分摊,免得一个背着大实例的线程拖住整组;
- 有映射页就
WriteCmd:向VisibleInstancesOut追加一条可见实例命令(打包的 view 与静 / 动态层信息、实例 ID 与 culling flags、间接参数下标),并把对应 draw command 的实例数累加上去。
- 包围盒投影成屏幕矩形,判断是否细节几何,夹进 uncached 页矩形,转成页矩形
它和需求侧的 GeneratePageFlagsFromPixels 是对称的:一个从接收者反推要哪些页,一个从投射者推它该画进哪些页。成本量级约为实例数 × view 数 × mip 数,外加覆盖页的标记写。
6.3 两步重排
每条 draw command 最终有多少实例,要等剔除全跑完才知道,所以先把「实例 + 页信息」无序缓存下来,再归位:
AllocateCommandInstanceOutputSpaceCs:一线程一个间接参数,读累计的实例数,用全局InterlockedAdd在最终实例缓冲里预留一段连续区间,记下基址,外加一份临时拷贝给下一步用。OutputCommandInstanceListsCs(间接派发,一线程一条可见命令):用临时偏移做InterlockedAdd,把命令散射进自己 draw command 的区间,写出Shadow.Virtual.InstanceIdsBuffer和Shadow.Virtual.PageInfoBuffer。
结果是每条 draw command 对应一段紧凑的实例列表和一份间接绘制参数,间接绘制可以连续读取。
6.4 光栅化
RasterPasses 的视口是虚拟分辨率 16384×16384(FVirtualShadowMap::VirtualMaxResolutionXY),render pass 不绑定任何渲染目标(ERDGPassFlags::SkipRenderPass,名为 RasterizeVirtualShadowMaps(Non-Nanite)),深度由像素着色器自己写:
// Engine/Shaders/Private/ShadowDepthPixelShader.usf(SUPPORT_SHADOW_DEPTH_VSM 分支节选)
FShadowPhysicalPage Page = ShadowDecodePageTable( PassStruct.VirtualSmPageTable[ CalcPageOffset( NaniteView.TargetLayerIndex, NaniteView.TargetMipLevel, vAddress >> VSM_LOG2_PAGE_SIZE ).GetResourceAddress() ] );
if( Page.bThisLODValidForRendering)
{
uint2 pAddress = Page.PhysicalAddress * VSM_PAGE_SIZE + (vAddress & VSM_PAGE_SIZE_MASK);
// If requested, render to the static page
const int ArrayIndex = PageInfo.bStaticPage ? GetVirtualShadowMapStaticArrayIndex() : 0;
InterlockedMax( PassStruct.OutDepthBufferArray[ uint3( pAddress, ArrayIndex ) ], asuint( DeviceZ ) );
}和 Nanite 同一个模式:虚拟坐标查页表 → 物理地址 → 原子最大值写深度。每个实例通过 PageInfoBuffer 知道自己属于哪个 mip view、该画静态层还是动态层。
七、PostRender:折叠脏标志与合并静态层
入口 PostRender(VSMArray.cpp:1934)。光栅化只把深度写进了物理页,还剩两件事。
7.1 UpdateAndClearDirtyFlags
一线程一物理页,把渲染期写进 DirtyPageFlags 的 6 个 slot 折叠进持久的 PhysicalPageMetaData.Flags,然后清空 scratch:
| slot | 含义 | 折叠成 |
|---|---|---|
| 0 | 动态层本帧被画过 | 置 DYNAMIC_DIRTY,清 DYNAMIC_INITIALIZED |
| 1 | 静态层本帧被画过 | 置 STATIC_DIRTY,清 STATIC_INITIALIZED |
| 2 | 动态层缓存已过时 | INVALIDATE_DYNAMIC |
| 3 | 静态层缓存已过时 | INVALIDATE_STATIC |
| 4 | WPO 允许但当前没有失效 | FORCE_CACHED |
| 5 | 流送出的 Nanite LOD 与期望不符 | DEFERRED_INVALIDATION |
UpdateHZB 里的 SelectPagesForHZB 已经对它之前的脏标志折叠过;PostRender 这一趟兜住 HZB 之后才产生的,主要是非 Nanite 光栅写下的那部分。
7.2 合并静态层
FVirtualShadowMapArray::PostProcessPages scope 下两步:
SelectPagesToPostProcessCS(一线程一物理页):挑出已分配、被引用、灯不是 uncached、且STATIC_DIRTY的页 —— 只有静态层本帧被重写过的页才需要合并。同一个 pass 也为可选的 prefiltered distant 功能选页。MergeStaticPhysicalPagesIndirectCS:间接派发,逐像素合并:
// Engine/Shaders/Private/VirtualShadowMaps/VirtualShadowMapPhysicalPageManagement.usf
// Helper function to merge static and dynamic depth.
void MergePhysicalPixel(uint2 PixelCoord)
{
// 1:1 pixels so this is safe RMW
OutPhysicalPagePool[uint3(PixelCoord, 0)] = max(
OutPhysicalPagePool[uint3(PixelCoord, 0)],
OutPhysicalPagePool[uint3(PixelCoord, GetVirtualShadowMapStaticArrayIndex())]);
}reverse-Z 下 max 就是离光最近的遮挡者,slice 0 于是成为「静态世界 + 动态物体」的最终深度,采样只读它。
为什么只在静态层变脏时才合并:静态层没变时,动态层在 4.8 已经用静态层打过底,动态几何再用 InterlockedMax 往上叠,结果本来就是合并好的;只有静态层被重画时,动态层会被一并清零(SelectPagesToInitializeCS 里静态层一清,动态层也跟着清),只含动态几何,才需要补这一步。
静态层跨很多帧不重画,动态层每帧只画会动的东西,按需合并 —— 静态场景的阴影成本就是这样压下来的。
八、缓存失效的完整闭环
UpdatePhysicalPages 只读失效位,自己不产生失效。失效位有三个生产者,走两条不同的通道,生效时机也不同。
8.1 画进静态层还是动态层
每个实例画进哪一层由 ShouldCacheInstanceAsStatic(VirtualShadowMapPageCacheCommon.ush)决定,满足任一条件就进动态层:
- 灯本身不缓存(view uncached);
- 材质会让缓存失效:WPO 在该 VSM 下处于启用状态(考虑了 WPO 禁用距离),或者材质带
bAlwaysEvaluateWorldPositionOffset,且图元没有关掉材质失效; - 图元被标为动态缓存:最近移动或失效过,由 CPU 侧维护,
r.Shadow.Virtual.Cache.FramesStaticThreshold(默认 100)帧内没有再失效才转回静态; - 实例在当前 view 下处于形变状态(骨骼、顶点动画等),由 GPU 侧逐实例的位图记录。
方向光 clipmap 里 WPO 是否生效还受 r.Shadow.Virtual.Clipmap.WPODisableDistance(默认开)控制:按图元的 WPO 禁用距离,再往外放 r.Shadow.Virtual.Clipmap.WPODisableDistance.LodBias(默认 3)级。
8.2 三个失效生产者
| 生产者 | 触发 | 实现 | 写到哪 | 何时生效 |
|---|---|---|---|---|
| 场景更新 | 图元移动、增删、始终失效的图元 | FVirtualShadowMapInvalidationSceneUpdater:PreSceneUpdate(旧变换)与 PostGPUSceneUpdate(新变换、新增)→ InvalidateInstancePagesLoadBalancerCS | 上一帧的 PageRequestFlags | 本帧,经 UpdatePhysicalPageAddresses 并入物理页 |
| 形变状态切换 | 实例由静止转为形变 | VSMUpdateViewInstanceStateCS → ProcessInvalidationQueueGPUCS | 同上,只失效动态层 | 本帧 |
| 渲染期逐页标记 | WPO、形变几何每次渲染都标 | Nanite NaniteClusterCulling.usf,非 Nanite CullPerPageDrawCommandsCs | DirtyPageFlags(按物理页索引) | 下一帧 |
前两者共用 InvalidateInstancePages(VirtualShadowMapCacheInvalidation.ush):把实例包围盒投进每个相关 VSM 的每级 mip,用 OverlapsAnyValidPage 找有物理页的区域,可选用页池 HZB 排除被完全遮住的实例,再逐页打位。静态缓存的实例打 INVALIDATE_STATIC,动态的打 INVALIDATE_DYNAMIC。移动的图元在更新前后各处理一次,旧位置和新位置两处的阴影都会被作废。
失效位为什么绕道页请求纹理,源码注释说得很清楚:
// Engine/Shaders/Private/VirtualShadowMaps/VirtualShadowMapCacheInvalidation.ush(InvalidateInstancePages)
// We don't look up the page table here because pages that weren't requested this frame
// are not in the page table, but still need to be invalidated. Thus we mark a bit in
// the page request flags instead which will get picked up during the next page update pass.
InterlockedOr(OutPageRequestFlags[PageFlagOffset.GetResourceAddress()], InvalidationFlags);保留而未被请求的页不在页表里(4.3),但同样需要被失效,所以只能按虚拟页位置打在页请求纹理上,等 UpdatePhysicalPageAddresses 再搬到物理页元数据里。
❗ 形变切换只在「转为动态」时失效,而且只失效动态层(OverrideInvalidationFlags = VSM_EXTENDED_FLAG_INVALIDATE_DYNAMIC)。注释里承认这会让静态层残留一份旧阴影(double shadow),用来换掉代价高昂的静态失效。
8.3 渲染期逐页标记
WPO 摆动的树、一直在播动画的角色,transform 不变,场景更新那条路抓不到。让它们每帧重画的是渲染期标记:cluster 剔除(非 Nanite 是 CullPerPageDrawCommandsCs)在发射之前,对盖到的每个「已映射且本层待渲染」的页调用 VirtualShadowMapMarkPageDirty,写进按物理页索引寻址的 DirtyPageFlags —— 失效位要跟着物理页跨帧走,和它保护的缓存深度一样。标志由 VirtualShadowMapGetMarkPageDirtyFlags 算出:
// Engine/Shaders/Private/VirtualShadowMaps/VirtualShadowMapPageOverlap.ush
uint VirtualShadowMapGetMarkPageDirtyFlags(
bool bCacheAsStatic,
bool bInvalidatePage,
bool bWPOAllowed,
bool bInvalidateLODDelta)
{
uint Flags = bCacheAsStatic ? VSM_MARK_PAGE_DIRTY_FLAG_STATIC : VSM_MARK_PAGE_DIRTY_FLAG_DYNAMIC;
if (bInvalidatePage)
{
Flags |= bCacheAsStatic ? VSM_MARK_PAGE_DIRTY_FLAG_INVALIDATE_STATIC : VSM_MARK_PAGE_DIRTY_FLAG_INVALIDATE_DYNAMIC;
}
else if (bWPOAllowed)
{
Flags |= VSM_MARK_PAGE_DIRTY_FLAG_WPO_ALLOWED;
}
if (bInvalidateLODDelta)
{
// Additive bit: independent of the must-respect invalidate path; budget-gated downstream.
Flags |= VSM_MARK_PAGE_DIRTY_FLAG_INVALIDATE_LOD_DELTA;
}
return Flags;
}四类信息分开看:
- DIRTY(静 / 动):这页这帧被画过。每个发射的 cluster 都写。
SelectPagesForHZB靠它只重建变过的页的 HZB,SelectPagesToPostProcess靠它只合并静态层变过的页。 - INVALIDATE(静 / 动):这页缓存的深度已经过时,下一帧必须重画。只有
bInvalidatePages为真才写,来源两个:ShouldMaterialInvalidateShadowCache(材质 WPO 等)或实例形变bIsDeforming。 - WPO_ALLOWED:WPO 允许,但当前没失效(例如被距离禁用)。折叠成
FORCE_CACHED:下一帧这页仍按缓存页处理,不清、不画、不合并,但在分层页标志里挂上DYNAMIC_UNCACHED,让剔除继续考虑它。WPO 一旦重新动起来,失效链马上能接上。 - LOD delta:Nanite 流送出的 LOD 与期望不符(
r.Nanite.VSMInvalidateOnLODDelta,实验性,默认关)。单独占一个 slot,折叠成DEFERRED_INVALIDATION,在UpdatePhysicalPages里受r.Shadow.Virtual.DeferredInvalidationBudget的每帧预算限制(默认 -1 不限),超预算的留到之后的帧。源码注释特意强调不要把它并进bInvalidatePages,那条通道是必须遵守的几何与材质失效。
8.4 WPO 植被残影
WPO 摆动不改实例 transform,能让它每帧重画的只有这条链:材质开 WPO → ShouldMaterialInvalidateShadowCache 每帧为真 → 每帧写 INVALIDATE_DYNAMIC → 下一帧动态层重画。链一断,比如 WPO 被距离禁用、或者包围盒没撑开导致摆到的新位置不在标记范围内,那些页就继续复用旧深度,表现为残影。所以 cluster 剔除里无论 WPO 当前是否被距离禁用,都按「允许 WPO」撑开包围盒:
// Engine/Shaders/Private/Nanite/NaniteClusterCulling.usf
bool bInvalidatePages = ShouldMaterialInvalidateShadowCache(PrimitiveData, bEnableWPO)
|| GetInstanceViewData(VisibleCluster.InstanceId, NaniteView.SceneRendererPrimaryViewId).bIsDeforming;
const bool bWPOAllowed = ShouldMaterialInvalidateShadowCache(PrimitiveData, VirtualShadowMapIsWPOAllowed(PrimitiveData, NaniteView.TargetLayerIndex));
...
// We always need to expand the bounds even if WPO is distance disabled because we still need to
// mark the whole region in case it starts animating next frame/in the future.
bExpandWPOBounds = bWPOAllowed;宁可多标,不能漏标。
8.5 闭环
┌─ 场景更新 ── PreSceneUpdate(旧变换)/ PostGPUSceneUpdate(新变换、新增)
│ └─ InvalidateInstancePagesLoadBalancerCS
├─ 形变切换 ── VSMUpdateViewInstanceStateCS → ProcessInvalidationQueueGPUCS
│ (仅“转为动态”,只失效动态层)
│ 两者都经 InvalidateInstancePages:InterlockedOr 进上一帧的 PageRequestFlags
│ └─ 同帧 UpdatePhysicalPageAddresses 把失效位并入 PhysicalPageMetaData
│
└─ 渲染期标记 ── Nanite 剔除 / 非 Nanite CullPerPageDrawCommands
└─ VirtualShadowMapMarkPageDirty → DirtyPageFlags(按物理页索引)
└─ UpdateAndClearDirtyFlags 折叠进 PhysicalPageMetaData(下一帧生效)
PhysicalPageMetaData.Flags
▼
UpdatePhysicalPages:决定哪一层 UNCACHED → 清页 / 打底 → 只重画那一层
动的东西把自己盖到的页标脏 → UpdatePhysicalPages 读到 → 只重画脏掉的那一层。
九、性能抓手与调试开关
9.1 先看哪里
GeneratePageFlagsFromPixels:⚠️ 多数场景下是需求侧最贵的一项,成本随样本数 × 相关灯数增长。先看r.Shadow.Virtual.PageMarkingPixelStrideX / Y和参与标记的局部光数量。CullPerPageDrawCommands:非 Nanite 投射物多时的主要开销,量级是实例 × view × mip,外加大量标记写。RenderVirtualShadowMaps(Nanite):看各 raster bin 的耗时。可编程光栅材质(WPO、Masked)越多,bin 越多;WPO 材质还会让所在页每帧失效重画(8.4)。- 缓存命中率:
DYNAMIC_INVALIDATED居高不下,说明动态几何或 WPO 太多,或者失效策略该调了。方向光开着 receiver mask 时,动态层本来就每帧重画(4.3),看的应该是静态层的命中情况。
9.2 常用命令与 CVar
| 命令 / CVar | 默认 | 作用 |
|---|---|---|
r.Shadow.Virtual.Stats 1 | — | 屏幕显示 VSM 统计;也可传 basic、all 或 PhysicalPages,NonNanite,Clusters 选分区(旧的 r.Shadow.Virtual.ShowStats 已废弃) |
r.Shadow.Virtual.MaxPhysicalPages | 2048(受画质档位覆盖) | 物理页池容量 |
r.Shadow.Virtual.ResolutionLodBiasDirectional / Local | 0(Epic 档方向光为 -1.5) | 分辨率偏置,+1 分辨率减半,理想情况下页数也减半 |
r.Shadow.Virtual.DynamicRes.MaxPagePoolLoadFactor | 0.85 | 页池占用超过该比例时自动降分辨率 |
r.Shadow.Virtual.PageMarkingPixelStrideX / Y | 2 | 页标记采样步长 |
r.Shadow.Virtual.Cache.MaxPageAgeSinceLastRequest | 1000 | 未被请求的缓存页最长保留帧数 |
r.Shadow.Virtual.UseReceiverMaskDirectional / Local | 1 / 0 | Receiver mask 开关 |
r.Shadow.Virtual.NonNanite.IncludeInCoarsePages | 1 | 非 Nanite 几何是否画进粗页 |
r.Shadow.Virtual.DynamicHZB | 0 | 是否额外建含动态几何的 HZB |
r.Shadow.Virtual.ForceFullPageClears / ForceFullHZBUpdate | 0 | 关掉增量清页 / 增量 HZB,做对比用 |
9.3 截帧里的事件树
Shadow.Virtual.ProcessInvalidations 场景更新期的失效
VSMUpdateViewInstanceState
Shadow.Virtual.ProcessInvalidationsGPU
└─ ProcessInvalidationQueueGPU
FVirtualShadowMapArray::UpdatePhysicalPageAddresses
VirtualShadowMapMarkPages
└─ FVirtualShadowMapArray::BeginMarkPages
├─ ClearPageTable(...) / ClearPageTableDir(...)
├─ InitPageRectBounds
├─ PruneLightGrid(Min=0,Max=N)
├─ MarkCoarsePages
└─ GeneratePageFlagsFromPixels(GBuffer,NumShadowMaps=N,{X,Y})
FVirtualShadowMapArray::BuildPageAllocation
├─ UpdatePhysicalPages → PackAvailablePages → AppendPhysicalPageList
├─ AllocateNewPageMappings → GenerateHierarchicalPageFlags → PropagateMappedMips
├─ InitializePhysicalPages
│ ├─ SelectPagesToInitialize
│ └─ InitializePhysicalMemoryIndirect
└─ Feedback Status → AppendPhysicalPageList
RenderVirtualShadowMaps(Nanite)
├─ CompactViewsVSM
├─ MainPass → BuildPreviousOccluderHZB(VSM) → PostPass (每个 Nanite pass 一轮)
└─ SelectPagesForHZB → BuildHZBPerPage → BuildHZBPerPageTop
RenderVirtualShadowMaps(Non-Nanite)
└─ Batched
├─ CullingPasses:CullPerPageDrawCommands → AllocateCommandInstanceOutputSpaceCs → OutputCommandInstanceListsCs
└─ RasterPasses
UpdateAndClearDirtyFlags
FVirtualShadowMapArray::PostProcessPages
├─ SelectPagesToPostProcess
└─ MergeStaticPhysicalPagesIndirect
十、源码坐标速查
C++ 行号均指 VSMArray.cpp(UE 5.8),另注文件名的除外;shader 在 Engine/Shaders/Private/VirtualShadowMaps/,Nanite 相关在 Engine/Shaders/Private/Nanite/。
主流程函数
| 函数 | 位置 |
|---|---|
FVirtualShadowMapArray::UpdatePhysicalPageAddresses | :2400,调用于 ShadowSceneRenderer.cpp:802 |
FVirtualShadowMapArray::BeginMarkPages | :2516,调用于 ShadowSceneRenderer.cpp:879 |
FVirtualShadowMapArray::BuildPageAllocations | :3227,调用于 ShadowSceneRenderer.cpp:898 |
FVirtualShadowMapArray::RenderVirtualShadowMapsNanite | :4218 |
FVirtualShadowMapArray::RenderVirtualShadowMapsNonNanite | :4389 |
FVirtualShadowMapArray::UpdateHZB | :4829,调用于 :4385 与 NaniteCullRaster.cpp:7028 |
FVirtualShadowMapArray::PostRender | :1934 |
各 pass
| 阶段 | Shader 类 | 定义 | 派发 | Shader 入口 |
|---|---|---|---|---|
| 投影数据上传 | FRDGScatterUploadBuffer | UnifiedBuffer.h:526 | :2608 | — |
| 清页表 | FClearPageTableCS | :1669 | :2673(AddClearPageTableUAVPass) | VirtualShadowMapPhysicalPageManagement.usf:ClearPageTableCS |
| 融合初始化 | FInitPageRectBoundsCS | :2162 | :2812 | 同上:InitPageRectBounds |
| 精简灯光网格 | FPruneLightGridCS | :1240 | :3008 | VirtualShadowMapPageMarking.usf:PruneLightGridCS |
| 粗页标记 | FMarkCoarsePagesCS | :1402 | :3041 | 同上:MarkCoarsePages |
| 像素标记 | FGeneratePageFlagsFromPixelsCS | :1326 | :3145 / :3161 | 同上:GeneratePageFlagsFromPixels |
| 物理页重定位 | FUpdatePhysicalPageAddresses | :1435 | :2479 | VirtualShadowMapPhysicalPageManagement.usf:UpdatePhysicalPageAddresses |
| 复用判定 | FUpdatePhysicalPages | :1458 | :3304 | 同上:UpdatePhysicalPages |
| 压紧可用页 | FPackAvailablePagesCS | :1711 | :3334 | 同上:PackAvailablePages |
| 链表追加 | FAppendPhysicalPageListsCS | :1724 | :2361(经 AppendPhysicalPageList,调用于 :3345 / :3503) | 同上:AppendPhysicalPageLists |
| 新页映射 | FAllocateNewPageMappingsCS | :1690 | :3360 | 同上:AllocateNewPageMappingsCS |
| 分层页标志 | FGenerateHierarchicalPageFlagsCS | :1416 | :3387 | VirtualShadowMapPageManagement.usf:GenerateHierarchicalPageFlags |
| mip 重定向 | FPropagateMappedMipsCS | :1738 | :3405 | 同上:PropagateMappedMips |
| 清新页 | FSelectPagesToInitializeCS / FInitializePhysicalPagesIndirectCS | :1765 / :1784 | :3436 / :3455 | VirtualShadowMapPhysicalPageManagement.usf:SelectPagesToInitializeCS / InitializePhysicalPagesIndirectCS |
| 压紧 view | FCompactViewsVSM_CS | :4181 | :4309 | VirtualShadowMapCompactViews.usf:CompactViewsVSM_CS |
| Nanite 剔除与光栅 | Nanite::FRenderer::DrawGeometry | NaniteCullRaster.cpp:6688 / :6731 | :4366 | NaniteInstanceCulling.usf / NaniteClusterCulling.usf / NaniteRasterizer.usf |
| HZB 选页 | FSelectPagesForHZBAndUpdateDirtyFlagsCS | :4763 | :4855 | VirtualShadowMapPhysicalPageManagement.usf:SelectPagesForHZBAndUpdateDirtyFlagsCS |
| HZB 构建 | FVirtualSmBuildHZBPerPageCS / FVirtualSmBBuildHZBPerPageTopCS | :4784 / :4808 | :4883 / :4911 | 同上:BuildHZBPerPageCS / BuildHZBPerPageTopCS |
| 非 Nanite 剔除 | FCullPerPageDrawCommandsCs | :3765 | :4058(AddCullingPasses,:3940) | VirtualShadowMapBuildPerPageDrawCommands.usf:CullPerPageDrawCommandsCs |
| 输出区间 | FAllocateCommandInstanceOutputSpaceCs | :3852 | :4081 | 同上:AllocateCommandInstanceOutputSpaceCs |
| 重排 | FOutputCommandInstanceListsCs | :3890 | :4107 | 同上:OutputCommandInstanceListsCs |
| 非 Nanite 光栅 | RasterPasses / AddRasterPass | :4616 / :4138 | — | ShadowDepthPixelShader.usf |
| 折叠脏标志 | FUpdateAndClearDirtyFlagsCS | :1920 | :1950 | VirtualShadowMapPhysicalPageManagement.usf:UpdateAndClearDirtyFlagsCS |
| 合并选页 | FSelectPagesToPostProcessCS | :1799 | :1987 | 同上:SelectPagesToPostProcessCS |
| 静态层合并 | FMergeStaticPhysicalPagesIndirectCS | :1820 | :2007 | 同上:MergeStaticPhysicalPagesIndirectCS |
| 场景更新失效 | FInvalidateInstancePagesLoadBalancerCS | VirtualShadowMapCacheManager.cpp:1694 | — | VirtualShadowMapCacheLoadBalancer.usf:InvalidateInstancePagesLoadBalancerCS |
| 形变切换失效 | FVSMUpdateViewInstanceStateCS / FProcessInvalidationQueueGPUCS | VirtualShadowMapCacheManager.cpp:2196 / :1730 | 同文件 :2321 / :2352 | VirtualShadowMapCacheGPUInvalidation.usf |
十一、参考链接
🔗 Epic 官方文档 - Virtual Shadow Maps in Unreal Engine
🔗 Epic 官方文档 - Nanite Virtualized Geometry in Unreal Engine
🔗 Epic 技术博客 - Virtual Shadow Maps in ‘Fortnite Battle Royale’ Chapter 4
🔗 SIGGRAPH 2021 Advances in Real-Time Rendering - A Deep Dive into Nanite Virtualized Geometry