来源: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。


目录

  1. VSM 在解决什么问题
  2. 一帧的骨架与跨帧反馈
  3. 需求侧:BeginMarkPages
  4. 供给侧:BuildPageAllocations
  5. Nanite 路径:一趟多视图光栅化
  6. 非 Nanite 路径:逐页实例剔除与间接绘制
  7. PostRender:折叠脏标志与合并静态层
  8. 缓存失效的完整闭环
  9. 性能抓手与调试开关
  10. 源码坐标速查
  11. 参考链接

一、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 8192VSMArray.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) cmVirtualShadowMapClipmap.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 各跑一次。

每个样本做什么:

  1. 还原位置。 读 device Z 反投影出 translated world position,读法线和 shading model(Substrate 读 top layer),Unlit 直接跳过,免得给天空球标页。可选叠加三种额外深度来源,它们各自需要自己的阴影页:单层水面(FWaterDepth 排列)、半透明前层(r.Shadow.Virtual.TranslucentQuality 开启时,取本帧前层深度或历史重投影,FTranslucencyDepth 排列)、头发(单独一个按 hair tile 间接派发的 pass)。
  2. 方向光:
    • 选 clip 级:floor(log2(到 clipmap 原点的距离) + ResolutionLodBias + GlobalResolutionLodBias + ExtraBias)(GetBiasedClipmapLevel)。距离每翻一倍粗一级,而每级覆盖范围也翻一倍,阴影纹素密度因此大致跟着屏幕像素密度走。ExtraBias 里有景深虚化区域的降分辨率(r.Shadow.Virtual.MaxDOFResolutionBias,默认 1)和第一人称像素的偏置。
    • 背面剔除:法线背向光源的像素本来就在阴影里,不请求页(r.Shadow.Virtual.CullBackfacingPixels,次表面材质和头发除外)。
    • 投影到阴影 UV、做裁剪测试,算出虚拟纹素地址,右移 7 位得到页地址。
  3. 局部光(遍历精简后的 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 没给它挂上物理页」的虚拟页:

  1. 从 AVAILABLE 尾部 pop 一个物理页,push 进 REQUESTED。
  2. 如果这个物理页还挂着别的虚拟页(被牺牲的缓存页),先把旧主的页表项和页标志清零。
  3. 写新映射,页标志置 ALLOCATED | DYNAMIC_UNCACHED | STATIC_UNCACHED,全新的页两层都要画;元数据记下 VSM ID、mip、页地址和请求帧号。INITIALIZED 位不继承,因为动态层的「已初始化」可能意味着里面拷过旧主的静态层数据。
  4. 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 选页 + 间接派发」:

  1. CreateAndClearIndirectDispatchArgs1D 清间接参数。
  2. SelectPagesToInitializeCS(一线程一物理页):跳过未分配、未引用、完全缓存、FORCE_CACHED 的页;静态层未缓存且还没清过(无 STATIC_INITIALIZED),就排一个静态 slice 任务;动态层没初始化过、或者静态层刚被清,就排一个动态 slice 任务。每个任务占 16 个线程组,用 wave 原子追加,同时置上对应的 *_INITIALIZED | *_DIRTY。
  3. 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:

  1. SelectPagesForHZB(FSelectPagesForHZBAndUpdateDirtyFlagsCS,一线程一物理页):先调 UpdateAndClearDirtyFlags 把渲染期写下的逐页脏标志折叠进物理页元数据并清空 scratch,再挑出已分配、被引用、且静态层变脏的页(仅动态层变脏,只在开了独立动态 HZB 或灯不缓存时才算),每页追加 16 个组的间接参数,外加一个顶层归约组。没变过的页不重建。
  2. BuildHZBPerPage:每页 128×128 深度按 2×2 取 min(reverse-Z 下 min 即最远),归约出 64、32、16、8、4 五级。
  3. 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:

  1. 取几何:实例场景数据、图元数据、局部包围盒;draw command 带有动态网格包围盒(DynamicMeshBoundsIndex 有效)时,改用这份包围盒(LoadDynamicMeshBounds)。
  2. 对 primary view 做 FBoxCull:距离、屏幕尺寸、全局裁剪面、视锥;同时定下 bCacheAsStatic(画进静态层还是动态层)、是否启用 WPO,以及 bInvalidatePages(材质 WPO 失效或实例形变)。
  3. 估算足印:PixelEstRadius 是实例在 VSM 基础级的像素半径估计。源码注释专门解释了为什么用估计值:真实足印随旋转和边缘裁剪抖动,拿来做剔除不稳定。每往下一级 mip,半径减半。
  4. 对每一级 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 的实例数累加上去。

它和需求侧的 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
4WPO 允许但当前没有失效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 CullPerPageDrawCommandsCsDirtyPageFlags(按物理页索引)下一帧

前两者共用 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.MaxPhysicalPages2048(受画质档位覆盖)物理页池容量
r.Shadow.Virtual.ResolutionLodBiasDirectional / Local0(Epic 档方向光为 -1.5)分辨率偏置,+1 分辨率减半,理想情况下页数也减半
r.Shadow.Virtual.DynamicRes.MaxPagePoolLoadFactor0.85页池占用超过该比例时自动降分辨率
r.Shadow.Virtual.PageMarkingPixelStrideX / Y2页标记采样步长
r.Shadow.Virtual.Cache.MaxPageAgeSinceLastRequest1000未被请求的缓存页最长保留帧数
r.Shadow.Virtual.UseReceiverMaskDirectional / Local1 / 0Receiver mask 开关
r.Shadow.Virtual.NonNanite.IncludeInCoarsePages1非 Nanite 几何是否画进粗页
r.Shadow.Virtual.DynamicHZB0是否额外建含动态几何的 HZB
r.Shadow.Virtual.ForceFullPageClears / ForceFullHZBUpdate0关掉增量清页 / 增量 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 入口
投影数据上传FRDGScatterUploadBufferUnifiedBuffer.h:526:2608—
清页表FClearPageTableCS:1669:2673(AddClearPageTableUAVPass)VirtualShadowMapPhysicalPageManagement.usf:ClearPageTableCS
融合初始化FInitPageRectBoundsCS:2162:2812同上:InitPageRectBounds
精简灯光网格FPruneLightGridCS:1240:3008VirtualShadowMapPageMarking.usf:PruneLightGridCS
粗页标记FMarkCoarsePagesCS:1402:3041同上:MarkCoarsePages
像素标记FGeneratePageFlagsFromPixelsCS:1326:3145 / :3161同上:GeneratePageFlagsFromPixels
物理页重定位FUpdatePhysicalPageAddresses:1435:2479VirtualShadowMapPhysicalPageManagement.usf:UpdatePhysicalPageAddresses
复用判定FUpdatePhysicalPages:1458:3304同上:UpdatePhysicalPages
压紧可用页FPackAvailablePagesCS:1711:3334同上:PackAvailablePages
链表追加FAppendPhysicalPageListsCS:1724:2361(经 AppendPhysicalPageList,调用于 :3345 / :3503)同上:AppendPhysicalPageLists
新页映射FAllocateNewPageMappingsCS:1690:3360同上:AllocateNewPageMappingsCS
分层页标志FGenerateHierarchicalPageFlagsCS:1416:3387VirtualShadowMapPageManagement.usf:GenerateHierarchicalPageFlags
mip 重定向FPropagateMappedMipsCS:1738:3405同上:PropagateMappedMips
清新页FSelectPagesToInitializeCS / FInitializePhysicalPagesIndirectCS:1765 / :1784:3436 / :3455VirtualShadowMapPhysicalPageManagement.usf:SelectPagesToInitializeCS / InitializePhysicalPagesIndirectCS
压紧 viewFCompactViewsVSM_CS:4181:4309VirtualShadowMapCompactViews.usf:CompactViewsVSM_CS
Nanite 剔除与光栅Nanite::FRenderer::DrawGeometryNaniteCullRaster.cpp:6688 / :6731:4366NaniteInstanceCulling.usf / NaniteClusterCulling.usf / NaniteRasterizer.usf
HZB 选页FSelectPagesForHZBAndUpdateDirtyFlagsCS:4763:4855VirtualShadowMapPhysicalPageManagement.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:1950VirtualShadowMapPhysicalPageManagement.usf:UpdateAndClearDirtyFlagsCS
合并选页FSelectPagesToPostProcessCS:1799:1987同上:SelectPagesToPostProcessCS
静态层合并FMergeStaticPhysicalPagesIndirectCS:1820:2007同上:MergeStaticPhysicalPagesIndirectCS
场景更新失效FInvalidateInstancePagesLoadBalancerCSVirtualShadowMapCacheManager.cpp:1694—VirtualShadowMapCacheLoadBalancer.usf:InvalidateInstancePagesLoadBalancerCS
形变切换失效FVSMUpdateViewInstanceStateCS / FProcessInvalidationQueueGPUCSVirtualShadowMapCacheManager.cpp:2196 / :1730同文件 :2321 / :2352VirtualShadowMapCacheGPUInvalidation.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