이런 분이 읽으면 좋습니다!
- 기존 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의 각 수치가 어느 단계에서 나오는지
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 규모의 오픈 월드에서는 여러 한계가 겹쳐 드러났다.
이 한계들의 공통 원인은 결국 하나다. 해상도를 높이면 메모리와 렌더링 비용이 그대로 따라온다. 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에 그대로 적용한다.
128×128 Virtual Pages (16,384개)
실제로 사용되는 페이지만 표시
실제 할당된 Physical Pages
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에서 공간을 할당한다.
장면이 넓고 Depth 분포가 다양하면 설정된 MaxPhysicalPages를 초과할 수 있다. 이 때 초과된 페이지는 Physical Pool에서 공간을 얻지 못하고, 해당 영역의 그림자가 통째로 사라지는 현상이 발생한다. 픽셀 단위의 노이즈가 아니라 page(128×128px) 단위로 그림자 블록이 없어지기 때문에 시각적으로 매우 두드러진다.
r.Shadow.Virtual.ShowStats 2에서 Allocated가 MaxPhysicalPages에 근접하면 Overflow 직전이다. Cleared가 갑자기 급증하는 시점(카메라 컷 전환, 많은 Dynamic 오브젝트 동시 등장)에 Overflow가 터지기 쉽다. 대응 방법은 두 가지다.
① r.Shadow.Virtual.MaxPhysicalPages를 늘려 Physical Pool 크기를 확보한다 (VRAM 추가 사용).
② r.Shadow.Virtual.ResolutionLodBiasDirectional로 page당 커버 범위를 넓혀 필요한 page 수 자체를 줄인다 (그림자 해상도 하락).
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의 정도가 달라진다.
CullPerPageDrawCommands)가 per-page 판별을 보완한다. Cluster 단위 정밀도가 없으므로 오브젝트 전체 Bounding Box로 page 영향 여부를 판단한다. Nanite 대비 Culling 정밀도가 낮고, 오브젝트가 많을수록 컴퓨트 비용이 증가한다.구조: 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 메시로 나뉘며 처리 경로가 다르다.
Cluster는 128 Triangle 묶음이며, GPU가 각 Cluster가 shadow page에 실제로 기여하는지를 직접 판별한다. 작은 Cluster는 SW Rasterizer(Compute로 직접 래스터라이즈), 큰 Cluster는 HW Rasterizer(고정 함수 파이프라인)로 분기한다. CPU 개입 없이 GPU 안에서 필요한 Cluster만 골라 해당 Physical Page에 Depth를 쓰므로, 수백만 폴리곤 메시도 shadow page 기여분에 비례한 비용만 발생한다.
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로 제어된다.
오브젝트 하나가 커버하는 shadow page 범위가 넓을 수 있다. 모든 page를 개별적으로 UNCACHED 여부를 확인하면 느리다. GenerateHierarchicalPageFlags 패스가 미리 PageFlag Mipmap을 생성해두고, OverlapsAnyValidPage에서 오브젝트 Rect에 맞는 Mip Level을 골라 4 texel Gather로 넓은 범위를 한 번에 조회한다.
4단계를 모두 통과한 오브젝트는 DrawIndirectArgs 버퍼에 등록되고, RasterPasses의 ExecuteIndirect로 실제 Shadow Depth가 렌더링된다. Vertex Shader에서는 Instance별 PageInfo를 읽어 해당 Shadow View로 변환한다.
Pixel Shader에서는 Physical Page 주소를 계산해 Depth를 InterlockedMax로 기록한다.
Shadow.Virtual.PhysicalPagePool은 R32_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를 비교해 그림자 계수를 구한다.
Shadow Cache: Uncached → Clear → Rebuild
Shadow Depth를 매 프레임 모든 Page에 대해 새로 렌더링하면 비용이 매우 크다. 정적 오브젝트(Static Mesh)는 이동하지 않으므로 이전 프레임의 Depth를 재사용할 수 있다. 이것이 Shadow Cache의 존재 이유다.
Static / Dynamic 분리
페이지 상태는 Flag로 관리된다. 두 UNCACHED Flag의 조합으로 해당 페이지가 어떤 처리가 필요한지를 표현한다.
Uncached 상태가 되는 경우
Uncached 상태는 View 레벨과 Page 레벨 두 단계로 발생한다.
View Uncached — 라이트가 이동했거나 첫 프레임
CPU 측의 FVirtualShadowMapPerLightCacheEntry는 Prev.RenderedFrameNumber로 캐시 유효성을 추적한다. 라이트 방향이 바뀌거나, 강제 Invalidate가 발생하거나, 처음 렌더링되는 경우 Prev.RenderedFrameNumber = -1로 설정된다.
이 값이 0 미만이면 bIsUncached = true가 되고, Projection.bUnCached를 통해 GPU 셰이더에 전달된다. GPU에서는 이 페이지에 VSM_EXTENDED_FLAG_VIEW_UNCACHED가 설정되어 모든 페이지를 강제 재렌더링한다.
Page Uncached — 새로 할당되거나 오브젝트가 이동한 경우
새로 Physical Page를 할당받는 경우(AllocateNewPageMappings), 해당 페이지는 아직 한 번도 렌더링되지 않았으므로 반드시 재렌더링해야 한다. 이 경우 DYNAMIC_UNCACHED | STATIC_UNCACHED 두 Flag가 모두 설정된다.
이동하는 오브젝트는 CullPerPageDrawCommands에서 MarkPageDirty를 통해 영향받는 페이지에 UNCACHED Flag를 설정한다. Static Mesh는 STATIC_UNCACHED, Dynamic Mesh는 DYNAMIC_UNCACHED에만 영향을 준다.
Uncached Flag가 설정된 페이지는 재렌더링 전에 반드시 초기화해야 한다. SelectPagesToInitializeCS가 MaxPhysicalPages개의 스레드로 실행되며 페이지별 처리를 결정한다.
선별된 페이지들은 InitializePhysicalPagesIndirectCS로 실제 초기화된다.
Dynamic Uncached이지만 Static이 유효한 경우(DYNAMIC_UNCACHED만 설정, STATIC_UNCACHED는 없음), Dynamic 레이어를 0으로 클리어하는 대신 Static 캐시 레이어를 복사해서 초기화한다. 이후 Rebuild에서 Dynamic 오브젝트만 추가로 그리면 되므로, Static Geometry를 매 프레임 재렌더링하는 비용을 완전히 회피한다.
r.Shadow.Virtual.ShowStats 2의 Cleared 수치는 이 Clear 단계에서 초기화된 page 수다. 정적인 씬에서 카메라만 움직이면 Clipmap이 panning되어 새 page가 소량 할당되므로 Cleared가 낮게 유지된다. 반대로 라이트가 이동하거나 많은 Dynamic 오브젝트가 움직이면 Cleared가 크게 증가한다.
Clear가 완료된 Uncached 페이지에 Shadow Depth를 다시 렌더링한다. CullPerPageDrawCommands에서 이미 각 오브젝트가 어떤 페이지에 영향을 미치는지 판별해 DrawIndirectArgs 버퍼를 채워두었으므로, RasterPasses에서 ExecuteIndirect로 필요한 오브젝트만 그린다.
연속적으로 이동하는 라이트는 매 프레임 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을 완성한다.
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에 준비돼 있다.
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를 잘 활용하고 있다는 의미다.
r.Shadow.Virtual.ShowStats 2를 활성화하면 VSM의 상태를 프레임 단위로 확인할 수 있다. 각 수치가 이 글에서 설명한 어느 단계에서 나오는지 정리한다.
MaxPhysicalPages에 가까워지면 Overflow 직전이다.Cleared는 이번 프레임에 실제로 재렌더링이 필요했던 page 수다. 정적인 씬이라면 카메라 이동에 따른 Clipmap 경계 이동 정도만 발생해 낮게 유지된다. Cleared가 급증하는 시점(라이트 이동, 많은 Dynamic 오브젝트 동시 등장, 카메라 컷 전환)은 Overflow가 발생하기 가장 쉬운 구간이기도 하다 — Cleared가 치솟는 프레임에 Allocated도 함께 급증하기 때문이다.
r.Shadow.Virtual.Cache.FramesStaticThreshold로 Static 전환 조건을 조절할 수 있다.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
- UE5 Virtual Shadow Maps Documentation
- 知乎 — Virtual Shadow Maps 분석
- UE5 Source:
Engine/Source/Runtime/Renderer/Private/VirtualShadowMaps/VirtualShadowMapCacheManager.cpp - UE5 Shader:
Engine/Shaders/Private/VirtualShadowMaps/VirtualShadowMapPhysicalPageManagement.usf