이런 분이 읽으면 좋습니다!

  • 기존 Shadow Map의 어떤 한계 때문에 VSM이 등장했는지 궁금한 분
  • UE5 Virtual Shadow Map이 내부에서 어떻게 동작하는지 궁금한 분
  • Shadow Cache의 Uncached → Clear → Rebuild 흐름을 코드 수준에서 이해하고 싶은 분

이 글로 알 수 있는 내용

  • CSM(Cascaded Shadow Map)의 구체적인 한계들
  • Virtual Memory 개념을 GPU Shadow에 적용하는 방법
  • Shadow Depth Pass와 Shadow Projection의 연결 구조
  • Shadow Cache가 Static/Dynamic을 어떻게 나누고, Uncached → Clear → Rebuild를 어떻게 처리하는지
  • Non-Nanite 메시의 4단계 Culling(Frustum / Empty Rect / Page Mask / HZB)이 왜 필요한지
  • r.Shadow.Virtual.ShowStats 2의 각 수치가 어느 단계에서 나오는지


00 — 개요

Virtual Shadow Map 개요

UE5의 Virtual Shadow Map(VSM)은 고해상도 Shadow Map을 GPU VRAM 한계 없이 사용하기 위해, OS의 Virtual Memory 개념을 GPU Shadow에 적용한 기술이다. 이 글에서는 기존 Shadow Map의 한계에서 시작해 VSM의 핵심 아이디어, 구조, 그리고 Shadow Cache의 Uncached → Clear → Rebuild 과정을 UE5 소스 코드를 통해 분석한다.

01 — 배경

배경: CSM의 한계

UE5 이전에 Unreal Engine이 주로 사용한 Shadow 기법은 Cascaded Shadow Map(CSM)이다. CSM은 카메라 거리에 따라 Shadow Map을 여러 단계(Cascade)로 나눠, 가까운 거리는 고해상도·먼 거리는 저해상도를 할당하는 방식이다. 현실적인 타협이었지만, UE5 규모의 오픈 월드에서는 여러 한계가 겹쳐 드러났다.

한계 1
먼 거리의 shadow detail 저하
CSM의 각 Cascade는 고정된 세계 영역을 고정 해상도로 커버한다. 카메라에서 멀어질수록 Shadow Map의 1 texel이 담당하는 세계 면적이 커지고, 그림자 경계가 흐려지거나 계단 현상이 생긴다. Cascade 수를 늘려도 근본적으로 해결되지 않는다.
한계 2
해상도가 화면 픽셀 수요와 무관하게 고정
CSM은 Cascade 경계로 해상도가 결정된다. 실제로 화면에 몇 픽셀이 그림자를 필요로 하는지와 상관없이, 보이지 않는 영역까지 동일한 해상도로 Shadow Map 전체를 렌더링한다. 해상도를 화면 픽셀 수요에 맞게 유동적으로 조절할 수 없다.
한계 3
로컬 라이트 확장성 부재
CSM은 Directional Light에 특화된 구조다. Point/Spot Light가 수십~수백 개인 오픈 월드 씬에서 각각 Shadow Map을 주려면 메모리와 렌더링 비용이 폭발적으로 증가해, 현실적으로 대부분의 로컬 라이트는 그림자를 포기해야 했다.
한계 4
정적 씬에서도 매 프레임 재렌더링
CSM은 캐싱 구조가 없다. 아무것도 움직이지 않는 정적 환경에서도 Shadow Map 전체를 매 프레임 다시 렌더링한다. 정적 오브젝트가 대부분인 씬에서 막대한 GPU 시간을 낭비한다.

이 한계들의 공통 원인은 결국 하나다. 해상도를 높이면 메모리와 렌더링 비용이 그대로 따라온다. 16K Shadow Map으로 detail 문제를 정면 돌파하면 어떨까? Directional Light 1개 기준으로 16개 Clipmap Level × 16K = 약 20.8 GB가 필요하다. VRAM에서 현실적으로 쓸 수 없는 수치다. VSM의 "Virtual"은 바로 이 딜레마를 푸는 열쇠다.

02 — 핵심 아이디어

VSM의 핵심 아이디어: Virtual Memory

OS의 Virtual Memory는 매우 큰 가상 주소 공간을 할당하되, 실제로 접근하는 부분만 물리 메모리(Physical Memory)에 올려두는 개념이다. VSM은 이 개념을 GPU Shadow에 그대로 적용한다.

Virtual 16K × 16K

128×128 Virtual Pages (16,384개)

V
V
V
V
V
V

실제로 사용되는 페이지만 표시

Physical Page Pool

실제 할당된 Physical Pages

P0
P1
P2
P3
P4
P5

r.Shadow.Virtual.MaxPhysicalPages로 제한

가상 16K 텍스처를 128×128 픽셀 단위의 Virtual Page로 쪼갠다. 그러면 16K 텍스처는 128×128 = 16,384개의 Virtual Page로 구성된다.

Depth Pass가 끝나면 현재 카메라 시점의 GBuffer 픽셀을 분석한다. 각 픽셀의 UV와 Depth를 World 좌표로 변환하고, 다시 Shadow Space로 변환하면 해당 픽셀에 영향을 미치는 Virtual Page를 알 수 있다. 이렇게 실제로 사용되는 Virtual Page만 모아서 Physical Page Pool에서 공간을 할당한다.

Page Pool Overflow — 그림자가 갑자기 사라진다

장면이 넓고 Depth 분포가 다양하면 설정된 MaxPhysicalPages를 초과할 수 있다. 이 때 초과된 페이지는 Physical Pool에서 공간을 얻지 못하고, 해당 영역의 그림자가 통째로 사라지는 현상이 발생한다. 픽셀 단위의 노이즈가 아니라 page(128×128px) 단위로 그림자 블록이 없어지기 때문에 시각적으로 매우 두드러진다.

r.Shadow.Virtual.ShowStats 2에서 AllocatedMaxPhysicalPages에 근접하면 Overflow 직전이다. Cleared가 갑자기 급증하는 시점(카메라 컷 전환, 많은 Dynamic 오브젝트 동시 등장)에 Overflow가 터지기 쉽다. 대응 방법은 두 가지다.
r.Shadow.Virtual.MaxPhysicalPages를 늘려 Physical Pool 크기를 확보한다 (VRAM 추가 사용).
r.Shadow.Virtual.ResolutionLodBiasDirectional로 page당 커버 범위를 넓혀 필요한 page 수 자체를 줄인다 (그림자 해상도 하락).

03 — 왜 빠른가

VSM이 빠른 이유 — 필요한 Shadow만 유지한다

CSM이 느린 근본 원인은 "Shadow Map 전체 = 렌더링 대상 전체"라는 등식이다. 보이지 않는 영역도, 변하지 않은 정적 배경도, 화면에 그림자가 닿지 않는 멀리 있는 오브젝트도 매 프레임 똑같이 처리된다. VSM은 이 등식을 깨는 것에서 출발한다.

핵심 원칙

VSM은 Shadow를 그리는 게 아니라, 필요한 Shadow만 유지한다. 이번 프레임에 화면 픽셀이 실제로 필요로 하는 shadow page만 Physical Pool에 올리고, 변한 page만 Rebuild하고, 나머지는 이전 프레임 캐시를 그대로 쓴다.

이 원칙은 UE5의 또 다른 핵심 기술인 Nanite와 같은 철학을 공유한다. Nanite는 화면 픽셀 기준으로 Geometry를 최소화하고, VSM은 Shadow 픽셀(Page) 기준으로 Shadow 렌더링을 최소화한다.

구분 Nanite Virtual Shadow Map
최소화 대상 렌더링할 Geometry (Triangle) 렌더링할 Shadow (Page)
기준 화면 픽셀이 실제로 보는 것 화면 픽셀이 실제로 필요로 하는 Shadow
단위 Cluster (128 triangle) Page (128×128 texel)
결정 주체 GPU (Cluster HW/SW Raster) GPU (GBuffer 분석 → Page Marking)
흐름 Cluster → Screen Pixel → VisBuffer → Material Resolve Geometry → Shadow Page → Physical Page → Lighting
기존 방식과 차이 모든 Triangle → 화면에 보이는 Cluster만 Shadow Map 전체 → 필요한 Page만

두 기술 모두 "GPU가 무엇을 그릴지 스스로 결정한다"는 GPU Driven 철학을 공유한다. 기존 방식이 CPU가 오브젝트 단위로 드로우콜을 내리던 것과 달리, UE5는 Nanite로 Geometry를 줄이고 VSM으로 Shadow를 줄여 두 축에서 동시에 렌더링 비용을 낮춘다.

단, Shadow Depth를 렌더링하는 Geometry가 Nanite 메시인지 Non-Nanite 메시인지에 따라 GPU Driven의 정도가 달라진다.

Nanite Mesh + VSM
완전한 GPU Driven
Cluster 단위 Culling이 GPU 안에서 완결된다. Nanite Shadow는 Visibility Buffer와 동일한 철학 — "화면(혹은 shadow page)에 실제로 기여하는 최소 단위만 래스터라이즈"를 Shadow Depth에 그대로 적용한다. CPU 개입 없이 필요한 Cluster만 선택해 해당 Physical Page에 Depth를 쓴다.
Non-Nanite Mesh + VSM
CPU-ish + Compute Culling
오브젝트 단위로 CPU가 드로우콜 구조를 잡고, GPU 컴퓨트(CullPerPageDrawCommands)가 per-page 판별을 보완한다. Cluster 단위 정밀도가 없으므로 오브젝트 전체 Bounding Box로 page 영향 여부를 판단한다. Nanite 대비 Culling 정밀도가 낮고, 오브젝트가 많을수록 컴퓨트 비용이 증가한다.
04 — 구조

구조: Shadow Depth와 Shadow Projection

VSM의 런타임 동작은 크게 두 단계로 나뉜다. Shadow Depth Pass는 Physical Page Pool에 Depth를 기록하고, Shadow Projection은 Lighting Pass에서 그 결과를 읽어 그림자 계수를 계산한다. 두 단계 사이에 Shadow Cache의 Merge 결과물이 놓이며, Lighting은 항상 이 최종 합성 데이터를 입력으로 받는다.

Shadow Depth Pass

Shadow Depth Pass는 그림자를 드리우는 Geometry를 Physical Page Pool에 렌더링하는 과정이다. 핵심은 각 Geometry가 어떤 Virtual Page에 영향을 미치는지 파악해 해당 Physical Page에만 Depth를 쓰는 것이다. Geometry는 Nanite 메시와 Non-Nanite 메시로 나뉘며 처리 경로가 다르다.

Nanite Mesh
Nanite 래스터라이저 — Cluster → Shadow Page
Nanite Shadow는 Visibility Buffer와 동일한 철학을 Shadow Depth에 적용한다. 일반 렌더링에서 Cluster → Screen Pixel → VisBuffer → Material Resolve인 것처럼, Shadow에서는 Cluster → Shadow Page → Physical Page에 Depth 기록이다.

Cluster는 128 Triangle 묶음이며, GPU가 각 Cluster가 shadow page에 실제로 기여하는지를 직접 판별한다. 작은 Cluster는 SW Rasterizer(Compute로 직접 래스터라이즈), 큰 Cluster는 HW Rasterizer(고정 함수 파이프라인)로 분기한다. CPU 개입 없이 GPU 안에서 필요한 Cluster만 골라 해당 Physical Page에 Depth를 쓰므로, 수백만 폴리곤 메시도 shadow page 기여분에 비례한 비용만 발생한다.
Non-Nanite Mesh
CullPerPageDrawCommands → ExecuteIndirect
오브젝트별로 어떤 shadow page에 영향을 미치는지 GPU 컴퓨트 셰이더(CullPerPageDrawCommands)로 판별한다. 영향받는 Uncached page가 있는 오브젝트만 모아 ExecuteIndirect로 렌더링한다. 이 판별 과정에서 4단계 Culling이 적용된다.

Non-Nanite: CullPerPageDrawCommands 4단계 Culling

Non-Nanite 오브젝트는 Nanite의 Cluster 단위 Culling이 없으므로, shadow page에 불필요한 렌더링을 막기 위해 별도로 4단계 Culling을 거친다. 각 단계는 앞 단계를 통과한 오브젝트에만 적용되어, 비용이 큰 판별일수록 뒤에 위치한다.

1. Frustum Cull

오브젝트의 Bounding Box를 Shadow View의 절두체(Frustum)와 비교한다. Shadow View 공간 밖에 있으면 어떤 shadow page에도 영향을 줄 수 없으므로 즉시 제거된다. 가장 먼저, 가장 빠르게 걸러낸다.

2. Empty Rect Cull

Frustum을 통과한 오브젝트의 Bounding Box를 Shadow Space에 투영해 2D Rect를 구한다. 이 Rect가 아무 Virtual Page도 커버하지 않으면(Empty Rect) 그릴 필요가 없으므로 제거된다. 예를 들어 매우 먼 거리에 있어 shadow space에서 서브픽셀 크기로 투영되는 경우가 해당된다.

3. Page Mask Cull

Rect가 page를 커버하더라도, 그 page들이 모두 이미 캐시된(Cached) 상태라면 다시 렌더링할 필요가 없다. OverlapsAnyValidPage 함수가 해당 Rect와 겹치는 page 중 UNCACHED Flag가 설정된 것이 하나라도 있는지 확인한다. 이 때 매 page를 직접 순회하는 대신, 미리 만들어둔 PageFlag Mipmap을 사용해 넓은 영역을 한 번에 조회한다.

4. HZB Cull (Cache Invalidation 시)

이동하거나 변형되는 오브젝트는 영향받는 shadow page를 UNCACHED로 표시(Invalidation)해야 한다. 그런데 해당 오브젝트가 현재 카메라 시점에서 다른 Geometry에 완전히 가려져(Occluded) 있다면, 그 오브젝트의 그림자는 어차피 보이지 않는다. HZB(Hierarchical Z-Buffer)로 이를 확인해 완전히 가려진 오브젝트의 Invalidation을 생략한다. r.Shadow.Virtual.Cache.InvalidateUseHZB CVar로 제어된다.

PageFlag Mipmap이 필요한 이유

오브젝트 하나가 커버하는 shadow page 범위가 넓을 수 있다. 모든 page를 개별적으로 UNCACHED 여부를 확인하면 느리다. GenerateHierarchicalPageFlags 패스가 미리 PageFlag Mipmap을 생성해두고, OverlapsAnyValidPage에서 오브젝트 Rect에 맞는 Mip Level을 골라 4 texel Gather로 넓은 범위를 한 번에 조회한다.

4단계를 모두 통과한 오브젝트는 DrawIndirectArgs 버퍼에 등록되고, RasterPassesExecuteIndirect로 실제 Shadow Depth가 렌더링된다. Vertex Shader에서는 Instance별 PageInfo를 읽어 해당 Shadow View로 변환한다.

HLSL — RasterPassVS
// InstanceCulling_PageInfoBuffer에서 해당 Instance의 ViewId를 가져온다 PackedPageInfo = InstanceCulling_PageInfoBuffer[InstanceIdIndex]; FPageInfo PageInfo = UnpackPageInfo(PackedPageInfo); // PageInfo.ViewId에 해당하는 Shadow View로 월드 좌표를 변환 FNaniteView NaniteView = UnpackNaniteView( ShadowDepthPass_PackedNaniteViews[PageInfo.ViewId]); PointClip = mul(float4(PointTranslatedWorld, 1), NaniteView.TranslatedWorldToClip); // Clip 좌표를 Physical Page 위치로 Scale/Bias ScaleBiasClipToPhysicalSmPage(NaniteView, PointClip, ClipPlanesOut, PageInfo);

Pixel Shader에서는 Physical Page 주소를 계산해 Depth를 InterlockedMax로 기록한다.

HLSL — RasterPassPS
// Virtual Page → Physical Page 주소 변환 FShadowPhysicalPage Page = ShadowDecodePageTable( ShadowDepthPass_VirtualSmPageTable[ CalcPageOffset(NaniteView.TargetLayerIndex, NaniteView.TargetMipLevel, vAddress >> 7u).GetResourceAddress()]); if (Page.bThisLODValidForRendering) { // Physical Page의 실제 픽셀 주소 계산 uint2 pAddress = Page.PhysicalAddress * (1u << 7u) + (vAddress & ((1u << 7u) - 1)); // Static 페이지는 ArrayIndex=1, Dynamic은 ArrayIndex=0 const int ArrayIndex = PageInfo.bStaticPage ? GetVirtualShadowMapStaticArrayIndex() : 0; // Depth 기록 (더 가까운 값 유지) InterlockedMax(ShadowDepthPass_OutDepthBufferArray[ uint3(pAddress, ArrayIndex)], asuint(DeviceZ)); }
Physical Page Pool 구조

Shadow.Virtual.PhysicalPagePoolR32_UINT 포맷의 2D Array 텍스처다. ArrayIndex 0은 Dynamic(전경) 데이터, ArrayIndex 1은 Static(배경) 캐시 데이터를 저장한다. 이 두 레이어 분리가 Shadow Cache의 핵심이다.

Shadow Projection

Lighting Pass에서 그림자를 계산할 때는 역방향으로 진행한다. GBuffer 픽셀의 World Position을 Shadow Space로 변환 → Virtual Page 주소 계산 → Page Table 참조로 Physical Page 주소 획득 → Physical Page Pool에서 Depth 읽기. Shadow Depth Pass와 대칭적인 Virtual → Physical 변환을 거쳐 저장된 Depth와 현재 픽셀 Depth를 비교해 그림자 계수를 구한다.

Step 1
World → Shadow Space
GBuffer Pixel을 Shadow View 행렬로 변환
Step 2
Virtual Page 계산
Shadow Space UV로 Virtual Page Address 결정
Step 3
Page Table 조회
Virtual → Physical Page 주소 변환
Step 4
Depth 비교
Physical Page Pool Depth와 비교해 Shadow 계수 출력
05 — Shadow Cache

Shadow Cache: Uncached → Clear → Rebuild

Shadow Depth를 매 프레임 모든 Page에 대해 새로 렌더링하면 비용이 매우 크다. 정적 오브젝트(Static Mesh)는 이동하지 않으므로 이전 프레임의 Depth를 재사용할 수 있다. 이것이 Shadow Cache의 존재 이유다.

Static / Dynamic 분리

Static Cache (ArrayIndex = 1)
배경 레이어
이동하지 않는 Static Mesh의 Depth를 저장한다. 씬이 바뀌지 않는 한 매 프레임 재사용된다. Physical Page Pool의 Array Slice 1에 보관된다.
Dynamic Cache (ArrayIndex = 0)
전경 레이어
이동하거나 애니메이션 중인 오브젝트의 Depth를 담는다. 매 프레임 Static Cache 위에 덮어써서 최종 결과를 만든다. Array Slice 0에 저장된다.

페이지 상태는 Flag로 관리된다. 두 UNCACHED Flag의 조합으로 해당 페이지가 어떤 처리가 필요한지를 표현한다.

VSM_FLAG_DYNAMIC_UNCACHED (bit 1) VSM_FLAG_STATIC_UNCACHED (bit 2) VSM_FLAG_ALLOCATED (bit 0) VSM_EXTENDED_FLAG_VIEW_UNCACHED (bit 6)
HLSL — VirtualShadowMapPageAccessCommon.ush
// 페이지가 할당되었는지 #define VSM_FLAG_ALLOCATED (1U << 0) // Dynamic 레이어가 유효하지 않아 재렌더링 필요 #define VSM_FLAG_DYNAMIC_UNCACHED (1U << 1) // Static 레이어가 유효하지 않아 재렌더링 필요 #define VSM_FLAG_STATIC_UNCACHED (1U << 2) // 이 View 자체가 Uncached 상태 (라이트가 이동했거나 첫 프레임) #define VSM_EXTENDED_FLAG_VIEW_UNCACHED (1U << 6)

Uncached 상태가 되는 경우

Uncached 상태는 View 레벨과 Page 레벨 두 단계로 발생한다.

View Uncached — 라이트가 이동했거나 첫 프레임

CPU 측의 FVirtualShadowMapPerLightCacheEntryPrev.RenderedFrameNumber로 캐시 유효성을 추적한다. 라이트 방향이 바뀌거나, 강제 Invalidate가 발생하거나, 처음 렌더링되는 경우 Prev.RenderedFrameNumber = -1로 설정된다.

이 값이 0 미만이면 bIsUncached = true가 되고, Projection.bUnCached를 통해 GPU 셰이더에 전달된다. GPU에서는 이 페이지에 VSM_EXTENDED_FLAG_VIEW_UNCACHED가 설정되어 모든 페이지를 강제 재렌더링한다.

C++ — VirtualShadowMapCacheManager.cpp
// UpdateClipmap: 라이트 방향이 바뀌면 캐시 무효화 if (bForceInvalidate || LightDirection != ClipmapCacheKey.LightDirection || FirstLevel != ClipmapCacheKey.FirstLevel) { Prev.RenderedFrameNumber = -1; // 캐시 무효 } // RenderedFrameNumber < 0 이면 bIsUncached = true bool bNewIsUncached = Prev.RenderedFrameNumber < 0; // Uncached ↔ Cached 전환 시에도 한 번 더 Invalidate // (Static 페이지가 초기화되지 않았을 수 있으므로) if (bNewIsUncached != bIsUncached) { Prev.RenderedFrameNumber = -1; bIsUncached = bNewIsUncached; }

Page Uncached — 새로 할당되거나 오브젝트가 이동한 경우

새로 Physical Page를 할당받는 경우(AllocateNewPageMappings), 해당 페이지는 아직 한 번도 렌더링되지 않았으므로 반드시 재렌더링해야 한다. 이 경우 DYNAMIC_UNCACHED | STATIC_UNCACHED 두 Flag가 모두 설정된다.

이동하는 오브젝트는 CullPerPageDrawCommands에서 MarkPageDirty를 통해 영향받는 페이지에 UNCACHED Flag를 설정한다. Static Mesh는 STATIC_UNCACHED, Dynamic Mesh는 DYNAMIC_UNCACHED에만 영향을 준다.

HLSL — VirtualShadowMapPhysicalPageManagement.usf
// 신규 페이지 할당 시 — 두 레이어 모두 Uncached uint Flags = VSM_FLAG_ALLOCATED | VSM_FLAG_DYNAMIC_UNCACHED | VSM_FLAG_STATIC_UNCACHED | RequestDetailGeometryFlag; // 오브젝트가 이동해서 Static을 무효화한 경우 if (bStaticInvalidated) { NextPageFlags |= (VSM_FLAG_STATIC_UNCACHED | VSM_FLAG_DYNAMIC_UNCACHED); } // Dynamic만 무효화된 경우 (Dynamic 오브젝트 이동) else { NextPageFlags |= VSM_FLAG_DYNAMIC_UNCACHED; }
## Clear — Uncached 페이지 초기화

Uncached Flag가 설정된 페이지는 재렌더링 전에 반드시 초기화해야 한다. SelectPagesToInitializeCS가 MaxPhysicalPages개의 스레드로 실행되며 페이지별 처리를 결정한다.

HLSL — SelectPagesToInitializeCS
[numthreads(VSM_DEFAULT_CS_GROUP_X, 1, 1)] void SelectPagesToInitializeCS(uint PhysicalPageIndex : SV_DispatchThreadID) { FPhysicalPageMetaData MetaData = PhysicalPageMetaData[PhysicalPageIndex]; bool bFullyCached = (MetaData.Flags & VSM_FLAG_ANY_UNCACHED) == 0; bool bStaticUncached = (MetaData.Flags & VSM_FLAG_STATIC_UNCACHED) != 0; if ((MetaData.Flags & VSM_FLAG_ALLOCATED) == 0) { // 미사용 페이지, 패스 } else if (bFullyCached) { // 완전히 캐시됨, 데이터 그대로 유지 } else { // Dynamic Uncached: Dynamic 레이어 초기화 필요 EmitPageToProcess(OutInitializePagesIndirectArgsBuffer, OutPhysicalPagesToInitialize, PhysicalPageIndex); if (bStaticUncached && (MetaData.Flags & VSM_EXTENDED_FLAG_VIEW_UNCACHED) == 0U) { // Static Uncached: Static 레이어도 초기화 필요 // MaxPhysicalPages를 더한 인덱스로 Static 레이어를 구분 EmitPageToProcess(OutInitializePagesIndirectArgsBuffer, OutPhysicalPagesToInitialize, PhysicalPageIndex + VirtualShadowMap.MaxPhysicalPages); } } }

선별된 페이지들은 InitializePhysicalPagesIndirectCS로 실제 초기화된다.

HLSL — InitializePhysicalPagesIndirectCS
[numthreads(TILE_THREAD_GROUP_SIZE_XY, TILE_THREAD_GROUP_SIZE_XY, 1)] void InitializePhysicalPagesIndirectCS( uint2 TileThreadID : SV_GroupThreadID, uint GroupIndex : SV_GroupID) { FPhysicalPageMetaData MetaData; uint3 BasePos = GetTileBasePos(TileThreadID, GroupIndex, PhysicalPagesToInitialize, MetaData); // GroupIndex >= MaxPhysicalPages이면 Static 레이어(ArrayIndex=1) 대상 bool bStaticCached = (MetaData.Flags & VSM_FLAG_STATIC_UNCACHED) == 0U; if (bStaticCached && (MetaData.Flags & VSM_EXTENDED_FLAG_VIEW_UNCACHED) == 0U) { // Static 레이어는 유효 → Dynamic 레이어를 Static에서 복사해 초기화 // ArrayIndex 1 (Static) → ArrayIndex 0 (Dynamic) OutPhysicalPagePool[BasePos + uint3(0,0,0)] = OutPhysicalPagePool[BasePos + uint3(0,0,1)]; OutPhysicalPagePool[BasePos + uint3(1,0,0)] = OutPhysicalPagePool[BasePos + uint3(1,0,1)]; OutPhysicalPagePool[BasePos + uint3(0,1,0)] = OutPhysicalPagePool[BasePos + uint3(0,1,1)]; OutPhysicalPagePool[BasePos + uint3(1,1,0)] = OutPhysicalPagePool[BasePos + uint3(1,1,1)]; } else { // Static 레이어도 Uncached (신규 페이지 or 라이트 이동) → 0으로 클리어 OutPhysicalPagePool[BasePos + uint3(0,0,0)] = 0U; OutPhysicalPagePool[BasePos + uint3(1,0,0)] = 0U; OutPhysicalPagePool[BasePos + uint3(0,1,0)] = 0U; OutPhysicalPagePool[BasePos + uint3(1,1,0)] = 0U; } }
Static Cache 활용의 핵심

Dynamic Uncached이지만 Static이 유효한 경우(DYNAMIC_UNCACHED만 설정, STATIC_UNCACHED는 없음), Dynamic 레이어를 0으로 클리어하는 대신 Static 캐시 레이어를 복사해서 초기화한다. 이후 Rebuild에서 Dynamic 오브젝트만 추가로 그리면 되므로, Static Geometry를 매 프레임 재렌더링하는 비용을 완전히 회피한다.

Stats 연결 — Physical Pages: Cleared

r.Shadow.Virtual.ShowStats 2Cleared 수치는 이 Clear 단계에서 초기화된 page 수다. 정적인 씬에서 카메라만 움직이면 Clipmap이 panning되어 새 page가 소량 할당되므로 Cleared가 낮게 유지된다. 반대로 라이트가 이동하거나 많은 Dynamic 오브젝트가 움직이면 Cleared가 크게 증가한다.

## Rebuild — 실제 렌더링

Clear가 완료된 Uncached 페이지에 Shadow Depth를 다시 렌더링한다. CullPerPageDrawCommands에서 이미 각 오브젝트가 어떤 페이지에 영향을 미치는지 판별해 DrawIndirectArgs 버퍼를 채워두었으므로, RasterPasses에서 ExecuteIndirect로 필요한 오브젝트만 그린다.

DYNAMIC_UNCACHED만 설정
Dynamic 오브젝트만 재렌더링
Static Geometry는 이미 Static Cache에 있으므로 스킵한다. 이동하는 캐릭터, WPO 적용 오브젝트 등 Dynamic으로 분류된 것만 다시 그린다.
STATIC_UNCACHED도 설정
모든 오브젝트 재렌더링
신규 페이지이거나 라이트가 이동한 경우다. Static + Dynamic 모두 그린다. 완료 후 Static 캐시에도 저장한다.
Static Cache를 항상 유지하는 이유

연속적으로 이동하는 라이트는 매 프레임 bIsUncached = true가 되어 전체 Uncached 경로를 탄다. 코드 주석에서 밝히듯 "continuously moving lights will automatically take the uncached path"가 설계 의도다. 한 프레임이라도 멈추면 Static Cache 구축을 시작해 다음 프레임부터는 Dynamic만 재렌더링하는 효율적인 경로로 전환된다.

Rebuild가 완료된 후에는 SelectPagesToMerge → Merge 패스에서 Dynamic 레이어(ArrayIndex 0)와 Static 레이어(ArrayIndex 1)를 합성해 최종 Physical Page Pool을 완성한다.

Merge 결과물이 Lighting의 실제 입력이다

Shadow Projection — 즉 Lighting Pass에서 그림자를 계산할 때 읽는 Depth 데이터는 항상 Merge된 결과다. Static 배경만 있는 page는 Static 레이어가 그대로 Merge 결과가 되고, Dynamic 오브젝트가 지나가는 page는 "Static 레이어 위에 Dynamic 레이어를 덮어쓴" 합성 결과가 된다. Lighting은 이 두 레이어가 합성된 단일 depth 값을 받아 그림자 계수를 계산한다. 구조상 Lighting 셰이더는 Cache가 있는지, Static인지 Dynamic인지 신경 쓰지 않아도 된다 — 항상 Merge된 올바른 Depth가 Physical Page Pool에 준비돼 있다.

Stats 연결 — Static/Dynamic Cached · Invalidated · Merged

Static Cached: Static 레이어가 유효해 그대로 재사용된 page 수.
Static Invalidated: VSM_FLAG_STATIC_UNCACHED가 설정돼 Static 레이어를 새로 렌더링한 page 수. Static 오브젝트가 이동하거나 신규 page가 할당될 때 증가한다.
Dynamic Invalidated: VSM_FLAG_DYNAMIC_UNCACHED가 설정돼 Dynamic 레이어를 새로 렌더링한 page 수.
Dynamic Merged: Static 레이어는 유효하고 Dynamic만 Uncached인 page — Static을 복사해 초기화한 뒤 Dynamic 오브젝트만 추가로 그린 page 수. 이 수치가 높을수록 Static Cache를 잘 활용하고 있다는 의미다.

Uncached
Flag 설정
오브젝트 이동 · 신규 페이지 · 라이트 이동으로 UNCACHED 마크
Clear
페이지 초기화
Static 유효 → Static을 Dynamic에 복사. Static도 무효 → 0 클리어
Rebuild
Shadow Depth 렌더링
Uncached 페이지에 영향받는 Geometry만 선택적으로 재렌더링
Merge
레이어 합성
Static + Dynamic 합성 후 다음 프레임 캐시로 보존
06 — Stats 해석
# r.Shadow.Virtual.ShowStats 2 해석

r.Shadow.Virtual.ShowStats 2를 활성화하면 VSM의 상태를 프레임 단위로 확인할 수 있다. 각 수치가 이 글에서 설명한 어느 단계에서 나오는지 정리한다.

ShowStats 2 — 출력 예시
Physical Pages Allocated: 856 ← Physical Pool에 현재 매핑된 총 page 수 Cleared: 34 ← 이번 프레임 Clear 단계에서 초기화된 page 수 Static Cached: 732 ← Static 레이어가 유효해 그대로 재사용된 page Invalidated: 12 ← STATIC_UNCACHED — Static을 새로 렌더링한 page Dynamic Cached: 810 ← Dynamic 레이어가 캐시 상태인 page Invalidated: 34 ← DYNAMIC_UNCACHED — Dynamic을 새로 렌더링한 page Merged: 22 ← Static 복사 후 Dynamic만 추가 렌더링한 page Non-Nanite Instances Frustum Culled: 1240 ← Shadow Frustum 밖으로 제거 Empty Rect Culled: 87 ← Shadow Space 투영 결과 page가 없음 Page Mask Culled: 342 ← 겹치는 page가 모두 이미 Cached 상태 HZB Culled: 56 ← 카메라에 가려져 Invalidation 생략
Allocated vs Cleared
Pool 효율 + Overflow 진단 지표
Allocated는 Physical Pool에 현재 매핑된 page 총수다. 이 값이 MaxPhysicalPages에 가까워지면 Overflow 직전이다.

Cleared는 이번 프레임에 실제로 재렌더링이 필요했던 page 수다. 정적인 씬이라면 카메라 이동에 따른 Clipmap 경계 이동 정도만 발생해 낮게 유지된다. Cleared가 급증하는 시점(라이트 이동, 많은 Dynamic 오브젝트 동시 등장, 카메라 컷 전환)은 Overflow가 발생하기 가장 쉬운 구간이기도 하다 — Cleared가 치솟는 프레임에 Allocated도 함께 급증하기 때문이다.
Static Invalidated
Static Cache 안정성 지표
이 값이 지속적으로 높으면 Static으로 분류된 오브젝트가 실제로는 자주 변하거나, WPO(World Position Offset)가 활성화된 머티리얼이 Static으로 캐시되려 시도하고 있다는 신호다. r.Shadow.Virtual.Cache.FramesStaticThreshold로 Static 전환 조건을 조절할 수 있다.
Dynamic Merged
Cache 활용도 지표
Merged가 높고 Static Invalidated가 낮은 상태가 이상적이다. Static 배경은 캐시를 재사용하고 Dynamic 오브젝트만 추가 렌더링하는 효율적인 경로를 타고 있음을 의미한다.
Page Mask Culled
Culling 효율 지표
Page Mask Culled 수치가 높을수록 Non-Nanite 오브젝트가 Cached page에만 영향을 줘서 렌더링 없이 제거되고 있다는 뜻이다. 씬이 안정적일수록 이 값이 높아야 정상이다. 반대로 이 값이 낮고 Cleared가 높으면 많은 오브젝트가 실제로 재렌더링되고 있다.
Nanite 메시는 Non-Nanite Instances 항목에 나타나지 않는다

Culling 통계의 Non-Nanite Instances 항목은 CullPerPageDrawCommands를 통과하는 오브젝트만 집계한다. Nanite 메시는 Nanite 자체 래스터라이저 경로를 타므로 이 통계에 포함되지 않는다. Nanite Shadow 비용은 별도로 GPU 프로파일러에서 확인해야 한다.

---
정리

VSM은 16K Shadow Map의 메모리 문제를 Virtual Memory 개념으로 해결하고, Shadow Cache로 매 프레임 재렌더링 비용을 최소화한다. Page 단위로 Uncached 여부를 추적하고, Clear 단계에서 Static Cache를 재활용하며, Rebuild는 꼭 필요한 오브젝트와 페이지만 대상으로 한다. 이 세 단계가 맞물려 GB 단위 VRAM 없이도 16K급 그림자 품질을 실시간으로 유지할 수 있다.

Reference