[Unreal Engine 5] Virtual Shadow Map - 렌더패스 분석

2025. 8. 21. 20:13·언리얼 엔진

이전 포스트

https://jooh3444.tistory.com/36

 

[Unreal Engine 5] Virtual Shadow Map

기본 아이디어Shadow Map 해상도가 낮으면 그림자에 aliasing이 발생한다.이를 해결하기 위해 다양한 시도들이 있다.Cascaded Shadow Map : 거리에 따른 Shadow map 분리Variance Shadow Map : 텍스쳐 샘플링 때, 분

jooh3444.tistory.com

 

 

먼저, 이 글에서는 분석 단순화를 위해 directional light, clipmap 동작에 대해서만 분석하는 것을 목표로 한다.
Directional Light는 Mipmap을 사용하지 않기 때문에 이 글에서 mipmap관련 처리 패스 InitPageRectBounds, GenerateHierarchicalPageFlags, PropagateMappedMips 는 분석하지 않는다.

 

FVirtualShadowMapArray::BuildPageAllocation

  • InitPageRectBounds
    • 생략
  • MarkCoarsePages
    • 간단히만
  • PrunLightGrid
  • GeneratePageFlagsFromPixels
  • ClearPageTable
  • PackAvailablePages
  • AppendPhysicalPageList
  • AllocateNewPageMappings
  • GenerateHierarchicalPageFlags
  • PropagateMappedMips
    • 생략
  • InitializePhysicalPages
    • 생략

 

MarkCoarsePages

computer science에서는 fine - coarse 개념이 있다.

fine한 것과 coarse한 것을 (보통은 중요성이나 퍼포먼스를 기준으로 나눔) 나누어 처리하는 개념인데

VSM에서는 object의 중요성 (그려지는 크기)에 따라서 fine~coarse로 분류해서 덜 detail해도 되는 부분은 coarse page로 따로 처리한다.

아래는 공식 문서에서의 설명.

Coarse Pages

Depth buffer analysis is used as the primary method of marking pages that are needed to render. There are some systems that need to sample shadows at more arbitrary locations though, such as Volumetric Fog and forward-rendered translucency. Most systems only need low-resolution shadow data that gets filtered and blurred through other data structures.

 

GeneratePageFlagsFromPixels

GBuffer 픽셀을 하나하나 체크하면서, 어떤 VSM Page가 사용되었는지 Flag를 마크하는 셰이더.

 

호출 스레드 개수

(1406, 834) 스크린에서 테스트 중인데,

Dispatch(88, 53, 1)이고, 쓰레드 개수 [numthreads((8U), (8U), 1)] 이다.

해상도가 부족해서 확인해보니 모든 GBuffer픽셀 대상으로 체크하는 것은 아니고 r.Shadow.Virtual.PageMarkingPixelStrideX, r.Shadow.Virtual.PageMarkingPixelStrideY 값이 (2,2)라서 2x2 픽셀 당 계산하는 동작이다.

일종의 최적화 동작.

 

[numthreads((8U), (8U), 1)]
void GeneratePageFlagsFromPixels(
	uint3 InGroupId : SV_GroupID,
	uint  GroupIndex : SV_GroupIndex,
	uint3 GroupThreadId : SV_GroupThreadID,
	uint3 DispatchThreadId : SV_DispatchThreadID)
{
 // 2x2 스트라이드 처리하여 UV계산
	const uint2 GroupId = InGroupId.xy;
	const uint2 PixelLocalPos = DispatchThreadId.xy * PixelStride;
	const uint2 PixelPos = uint2(View_ViewRectMin.xy) + PixelLocalPos;
	if (any(PixelPos >= uint2(View_ViewRectMin.xy + View_ViewSizeAndInvSize.xy)))
	{
		return;
	}
	
	// uv, z를 바탕으로 position 정보 계산
	const FPositionData Primary = InitPositionData(PixelPos, DeviceZ, true);
	
	// directional light를 순회하면서 해당 position이 범위안에 들어가면 flag 마크
	for (uint Index = 0; Index < NumDirectionalLightSmInds; ++Index)
	{
			MarkPageClipmap(ProjectionData, bUsePageDilation, PageDilationOffset, Primary.TranslatedWorldPosition, PrimaryExtraBias, PrimaryMinLevelClamp);
  }
}

void MarkPageClipmap(
	FVirtualShadowMapProjectionShaderData ProjectionData,
	bool bUsePageDilation, 
	float2 PageDilationOffset,
	float3 TranslatedWorldPosition,
	float ExtraBias = 0.0f,
	int MinLevelClamp = 0)
{
	// world position 을 projection 시켜서 ClipmapLevel을 추정.
	const int ClipmapLevel = max(MinLevelClamp, GetBiasedClipmapLevel(ProjectionData, TranslatedWorldPosition, ExtraBias));
	int ClipmapIndex = max(0, ClipmapLevel - ProjectionData.ClipmapLevel);
	if (ClipmapIndex < ProjectionData.ClipmapLevelCountRemaining)
	{
	  // VSMId + ClipmapId 를 사용하여 주소찾기. clipmap은 mipmap이 없으므로 miplevel = 0
		MarkPage(ProjectionData.VirtualShadowMapId + ClipmapIndex, 0, TranslatedWorldPosition, bUsePageDilation, PageDilationOffset);
	}
}
	
void MarkPage(uint VirtualShadowMapId, uint MipLevel, float3 TranslatedWorldPosition, bool bUsePageDilation, float2 PageDilationOffset)
{
  // shadow space uv를 계산하여 실제 virtual page 찾아가기
	FVirtualShadowMapProjectionShaderData ProjectionData = GetVirtualShadowMapProjectionData(VirtualShadowMapId);
	 { };
	float3 ViewToShadowTranslation = DFFastLocalSubtractDemote(ProjectionData.PreViewTranslation, GetPrimaryView().PreViewTranslation);
	float3 ShadowTranslatedWorldPosition = TranslatedWorldPosition + ViewToShadowTranslation;
	float4 ShadowUVz = mul(float4(ShadowTranslatedWorldPosition, 1.0f), ProjectionData.TranslatedWorldToShadowUVMatrix);
	ShadowUVz.xyz /= ShadowUVz.w;
	bool bInClip = ShadowUVz.w > 0.0f && 
		all( and_internal( ShadowUVz.xyz <= ShadowUVz.w , 
				ShadowUVz.xyz >= float3(-ShadowUVz.ww, 0.0f) ));
	// NDC 안에 없으면 생략
	if (!bInClip)
	{
		return;
	}
	
	// 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;
	
	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;
	// PageOffset 계산하기. VSMId + MipLevel + VirtualPageAddress => PageOffset
	FVSMPageOffset PageOffset = CalcPageOffset(VirtualShadowMapId, MipLevel, PageAddress);
	// 해당 PageOffset이 사용되었다고 체크하기.
	MarkPageAddress(PageOffset, Flags);
	
	
	[branch]
	if (VirtualShadowMap_bEnableReceiverMasks)
	{
	  // 역으로 PageOffset -> VirtualAddress 찾아갈 수 있게 Receiver를 저장해두기.
		MarkPageReceiverMask(PageOffset, VirtualAddress);
	}
	if (bUsePageDilation)
	{
		uint MaxPageAddress = MaxVirtualAddress >> 7u;
		float2 PageAddressFloat = VirtualAddressFloat / float((1u << 7u));
		uint2 PageAddress2 = clamp(uint2(PageAddressFloat + PageDilationOffset), 0U, MaxPageAddress);
		FVSMPageOffset PageOffset2 = CalcPageOffset(VirtualShadowMapId, MipLevel, PageAddress2);
		if (PageOffset2.GetPacked() != PageOffset.GetPacked())
		{
			MarkPageAddress(PageOffset2, Flags);
		}
		uint2 PageAddress3 = clamp(uint2(PageAddressFloat - PageDilationOffset), 0U, MaxPageAddress);
		FVSMPageOffset PageOffset3 = CalcPageOffset(VirtualShadowMapId, MipLevel, PageAddress3);
		if (PageOffset3.GetPacked() != PageOffset.GetPacked())
		{
			MarkPageAddress(PageOffset3, Flags);
		}
	}
}

 

Page Dilation

static TAutoConsoleVariable<float> CVarPageDilationBorderSizeDirectional(
	TEXT("r.Shadow.Virtual.PageDilationBorderSizeDirectional"),
	0.05f,
	TEXT("If a screen pixel falls within this fraction of a page border for directional lights, the adacent page will also be mapped.")
	TEXT("Higher values can reduce page misses at screen edges or disocclusions, but increase total page counts."),
	ECVF_RenderThreadSafe
);

page 경계쪽에 있을 때의 bias를 두고 널널하게 page mapping하는 옵션. 오차에 의한 page miss를 줄일 수 있다.

 

Shadow.Virtual.PageRequestFlags

사용하는 모든 VSM을 하나의 텍스쳐의 Offset으로 바꿔주는게 FVSMPageOffset 이다.

텍스쳐는 8192x192 , R32_UINT 타입인데,

각 픽셀은 하나의 Virtual Page를 가리킨다.

Shadow.Virtual.PageRequestFlags는 해당하는 virtual page의 flag 정보를 저장한다.

Flag 는 dirty, cache, allocated 등의 정보를 포함하고 있다. 아래에서 다시 설명예정.

 

Virtual Page는 Directional Light의 Clipmap 6~22의 각 page들일수도 있고, Local Light의 Mipmap의 page일 수도 있다.

모든 Virtual Page는 8192x192중 하나와 매핑되어있다.

 

SinglePageVSM

  • page 한개짜리 VSM 텍스쳐는 따로 모아서 Index 앞부분에 모아둔다.
  • VSMId 0~ 8191 까지 사용한다.
  • RequestFlag 텍스쳐에서 그림에 해당하는 부분을 차지한다.
  • PageOffset = (VSMId / 128, VSMId % 128)

 

MultiPageVSM

  • page가 한 개보다 많은 VSM texture는 각각 128 * 128 page공간을 차지한다. (안쓰면 그냥 냅둔다.)
  • VSMId 8192 부터 사용한다.
  • 아래코드와 같이 PageOffset 계산되며, VirtualShadowMap_PageTableRowShift = 6, VirtualShadowMap_PageTableRowMask = 2^6-1 이다.
  • local Light의 경우에는 Mipmap을 사용하는데, 이 경우에는 그림의 VSM3 처럼 192높이 안에 넣어버릴 수 있다.
FVirtualSMLevelOffset CalcPageTableLevelOffset(uint VirtualShadowMapId, uint MipLevel)
{
	FVirtualSMLevelOffset Result;
	Result.bIsSinglePageSM = IsSinglePageVirtualShadowMap(VirtualShadowMapId);
	if (Result.bIsSinglePageSM)
	{
		Result.LevelTexelOffset.y = uint(VirtualShadowMapId) >> 7u;
		Result.LevelTexelOffset.x = uint(VirtualShadowMapId) & ((1u << 7u) - 1u);
	}
	else
	{
		uint FullId = uint(VirtualShadowMapId - (1024U * 8U)) + 1u; 
		Result.LevelTexelOffset.y = (FullId >> VirtualShadowMap_PageTableRowShift) * ((1u << 7u) + (1u << 7u) / 2);
		Result.LevelTexelOffset.x = (FullId & VirtualShadowMap_PageTableRowMask) * ((1u << 7u));
		Result.LevelTexelOffset += CalcLevelOffsets(MipLevel);
	}
	return Result;
}
		
uint2 CalcLevelOffsets(uint MipLevel)
{
	uint2 Result = uint2(0u, 0u);
	if (MipLevel > 0u)
	{
		Result.y += (1u << 7u);
		uint MaxMask = (1u << ((7u + 1u) - 1)) - 1u;
		uint StartBit = (7u + 1u) - MipLevel;
		Result.x += MaxMask & (MaxMask << StartBit);
	}
	return Result;
}

 

Shadow.Virtual.PageRequestFlags의 실제 리소스

  • Directional Light 한 개는 16장의 16K clipmap을 가지고있다.
  • Point Light의 경우 16k 텍스쳐 6장이 필요하다.

64장 이내이므로 8192 * 192 텍스쳐로 충분하다.

 

그림자가 사용되는 page에 값을 적음. 여기서는 값 = 9 (0비트 + 3비트)

Flag 값으로는 9종류가 있는데, 일반적으로 사용하는 값

  • VSM_FLAG_ALLOCATED : 페이지가 할당되었는지,
  • VSM_FLAG_DYNAMIC_UNCACHED : 동적 페이지가 uncache 되었는지,
  • VSM_FLAG_STATIC_UNCACHED : 정적 페이지가 uncache 되었는지
  • VSM_FLAG_DETAIL_GEOMETRY : coarse 인지 not coarse 인지 마크. coarse인 경우 detail geometry를 생략할 수 있다.
// NOTE: These page flags are combined hierarchically using bitwise *OR*, so plan/negate them appropriately
// Marks pages that are allocated
#define VSM_FLAG_ALLOCATED			(1U << 0)
// Marks pages whose dynamic pages are uncached
#define VSM_FLAG_DYNAMIC_UNCACHED	(1U << 1)
// Marks pages whose static pages are uncached
#define VSM_FLAG_STATIC_UNCACHED	(1U << 2)
// Marks pages that are _not_ coarse (i.e., "normal" pages) that should include all geometry and conversely to mark geometry that is 
// "detail geometry" and which can skip rendering to coarse pages
#define VSM_FLAG_DETAIL_GEOMETRY	(1U << 3)

 

이렇게하여 GeneratePageFlagsFromPixels이 하는 일 분석 완료.

GBuffer pixel에 대해서 해당 픽셀에 영향을 미치는 VSM Page를 모두 Flag Mark 하였다.

 

ClearPageTable

  • Shadow.Virtual.PageTable 텍스쳐 Clear
  • Shadow.Virtual.PageFlags 텍스쳐 Clear (RequestPageFlag랑 다른 텍스쳐임!)

 

UpdatePhysicalPages

실행은 (MaxPhysicalPages, 1, 1) 스레드.

우리가 사용할 수 있는 PhysicalPage개수는 4096개 인데, (physicalPagePool)

그 개수만큼 실행한다.

즉 지금 할당되어있는 PhysicalPage에 대해서 업데이트를 수행하는 셰이더다.

  • 그 PhysicalPage가 새로 Request되었으면 out → in
  • 그 PhysicalPage가 사용 중인데 마지막으로 사용된게 3 frame 전이라면 in → out
  • PhysicalPage가 계속 사용 중이라면 in → in 유지

먼저 PhyicalPageMetaData, PhyiscalPageLists 데이터부터 살펴보자.

 

PhyicalPageMetaData

해당 버퍼는 1프레임 버퍼가 아니고 계속 유지 및 갱신 시키는 버퍼.

PhysicalPageIndex → FPhysicalPageMetaData 정보를 가지고 있는 List형태의 구조이다.

struct FPhysicalPageMetaData
{
		// 플래그
    int Flags;
    // 마지막으로 request된 frame index
    int LastRequestedSceneFrameNumber;
    // 밑에 3개가 있으면 PageOffset을 알 수 있음
    int VirtualShadowMapId;
    int MipLevel;
    int2 PageAddress;
}

 

PhyiscalPageLists

// Stores available pages (i.e. ones not used this frame) for allocation in LRU order
#define PHYSICAL_PAGE_LIST_LRU 0
// Packed available list
// Pages invalidated this frame will be added to the end. Allocations come from the end.
#define PHYSICAL_PAGE_LIST_AVAILABLE 1
// Stores invalidated/empty pages temporarily before they are re-added to the AVAILABLE list
#define PHYSICAL_PAGE_LIST_EMPTY 2
// Stores pages requested/used this frame, not available for allocation
#define PHYSICAL_PAGE_LIST_REQUESTED 3
// Number of page lists
#define PHYSICAL_PAGE_LIST_COUNT 4

 

Cirular queue 처럼 사용. LRU, Available, empty, requested 목록 physicalpageIndex를 저장하는 자료구조

맨 마지막에 Count를 넣어둔다.

 

UpdatePhysicalPages 다시 돌아와서

FPhysicalPageMetaData 를 먼저 업데이트 해준다.

FPhysicalPageMetaData PrevMetaData = OutPhysicalPageMetaData[PhysicalPageIndex];
uint MipLevel = PrevMetaData.MipLevel;
	
if (PrevMetaData.Flags != 0) // 이전 PhysicalPage에 무언가 플래그가 있다면
{
   ..
	const uint RequestFlags = PageRequestFlags[GlobalPageOffset.GetResourceAddress()];
   if (bRequestedThisFrame || Projection.bUnreferenced || PhysicalPageRequestedAge <= MaxPageAgeSinceLastRequest)
		{
		  // 이 Page가 request가 되었다면, PageOffset관련 정보를 meta에 넣고
				const uint PrevPhysicalFlags = PrevMetaData.Flags;
				OutPhysicalPageMetaData[PhysicalPageIndex].VirtualShadowMapId = VirtualShadowMapId;
				OutPhysicalPageMetaData[PhysicalPageIndex].PageAddress = PageAddress;
			
			// Allocate되었다는 플래그도 넣고
			uint NextPageFlags = VSM_FLAG_ALLOCATED;

			... 
			// 필요한 Flag도 넣고 (생략)
				// Always invalidate dynamic when using receiver mask, as the page may be incomplete
				if (Projection.bUseReceiverMask)
				{
					NextPageFlags |= VSM_FLAG_DYNAMIC_UNCACHED;
				}
				
			// 
			if (bRequestedThisFrame)
			{
				// PhysicalPageLists (REQUESTED)도 갱신해준다.
				PushPhysicalPageList(PHYSICAL_PAGE_LIST_REQUESTED, PhysicalPageIndex);
				OutPhysicalPageMetaData[PhysicalPageIndex].LastRequestedSceneFrameNumber = VirtualShadowMap.SceneFrameNumber;
				bRemovedPageFromList = true;
			}
		// Map the page to the physical page
		// If we later allocate over top of this page (for one requested this frame), we will zero this out again. See AllocateNewPageMappings
		OutPageTable[GlobalPageOffset.GetResourceAddress()] = ShadowEncodePageTable(VSMPhysicalIndexToPageAddress(PhysicalPageIndex), bPageValidForRendering);
		OutPageFlags[GlobalPageOffset.GetResourceAddress()] = NextPageFlags;
	}
	
	// 여기는 Request 아닐 때도 지나가는 로직임
	// 페이지가 더이상 사용안할 때 Empty List에 index넣기.
	if (NextPhysicalFlags == 0)
	{
		StatsBufferInterlockedInc(VSM_STAT_EMPTY_PAGES);
		PushPhysicalPageList(PHYSICAL_PAGE_LIST_EMPTY, PhysicalPageIndex);
		bRemovedPageFromList = true;
	}
	OutPhysicalPageMetaData[PhysicalPageIndex].Flags = NextPhysicalFlags;
	
	// Write out the LRU list while maintaining order, with anything we removed marked as INDEX_NONE
	SetPhysicalPageListItem(PHYSICAL_PAGE_LIST_LRU, PhysicalPageListIndex, bRemovedPageFromList ? INDEX_NONE : PhysicalPageIndex);
}

 

코드가 길긴한데 Request Flag가 있다면 PageMeta 데이터에 PageOffset관련 정보들을 채우고, PHYSICAL_PAGE_LIST_REQUESTED 를 업데이트한다.

Request Flag가 없다면 PHYSICAL_PAGE_LIST_EMPTY를 업데이트한다.

 

PackAvailablePages

PhysicalPageLists 에서 PHYSICAL_PAGE_LIST_AVAILABLE 리스트를 채우는 작업.

따로 Dispatch를 하는 이유는 PhysicalPageLists를 모아놓고 다시 읽어야하기 때문

 

AppendPhysicalPageList, AppendPhysicalPageList(Count)

EmptyList → AvaliableList 로 index list를 붙여주는 작업이다.

 

AllocateNewPageMapping

기존에는 할당되지 않았다가 새로 Request된 페이지를 처리한다.

그래서 새로 쓸 Flag는 ALLOCATED | UNCACHED 플래그를 모두 사용한다.

 

GenerateHierarchicalPageFlags

Shadow.Virtual.PageFlags의 밉맵 제작.

하위 4개 픽셀→ 상위 1개 픽셀로 이동할 때, or 연산을 통해 flag를 모두 합쳐준다.

Shadow.Virtual.PageFlags의 밉맵의 사용처는 이후의 CullPerPageDrawCommands .

Non-Nanite의 InstanceDraw 때 특정 VSM texture에 Instance를 래스터라이즈 할지말지 결정할 때, 사용하는데, Mipmap이 없으면 판별해야하는 픽셀이 많아 느리다.

그 때 Object Bound를 Rect로 나타내서 해당 Rect의 Page들의 모든 Flag 를 빠르게 판별할 때 Mipmap을 사용한다.

 

RenderVirtualShadowMaps (Non-Nanite)

CullPerPageDrawCommands

이 패스가 하는 일은, 각 Non-Nanite Object들을 순회하면서 모든 VSM에 대해서 어떤 페이지에 영향을 미치는지 판별하는 패스이다. 만약 A page에 영향을 미친다면, 그 page는 새로 depth를 써주어야한다.

page가 렌더링되는지 판별하기 위해서 object bound를 기준으로 각 shadow view마다 Culling작업 + projection작업이 우선적으로 실행되어야한다.

따라서 전체적인 순서는

  • 각 ShadowView를 순회하면서 ObjectBound에 대해서 DistanceCull, FrustumCull 진행
  • 어떤 page가 영향이 미치는지 판별
    • 해당 page가 invalidate하다는 것을 표시
    • 해당 VSM에 Rasterize가 되어야함을 표시 + IndirectDrawArg를 채우기. (이후 RasterPass에서 Depth를 써야하므로)
[numthreads(64, 1, 1)]
void CullPerPageDrawCommandsCs(uint3 GroupId : SV_GroupID, int GroupThreadIndex : SV_GroupIndex)
{
	uint DispatchGroupId = GetUnWrappedDispatchGroupId(GroupId);
	if (DispatchGroupId >= InstanceCullingLoadBalancer_GetNumBatches())
	{
		return;
	}
	if (GroupThreadIndex == 0)
	{
		NumSharedMarkingJobs = 0U;
	}
	GroupMemoryBarrierWithGroupSync();
	FContextBatchInfo BatchInfo = LoadBatchInfo(DispatchGroupId);
	FVSMCullingBatchInfo VSMCullingBatchInfo = VSMCullingBatchInfos[BatchInds[DispatchGroupId]];
	....

먼저 우리의 씬은 간단하기 때문에 4개의 Non-Nanite Object만 처리해서 InstanceCullingBatch 개수는 1, NumItem = 4 이다.

DispatchGroupId = 0 인 스레드만 (1개) 실행이 되고, 해당 스레드는 4개의 Object를 처리한다.

먼저 VSMCulling을 위한 BatchInfo를 가져온다.

 

 

0번 Batch는 0~17까지의 ShadowView를 사용한다.

17개인 이유는 우리의 씬이 clipmap level 6~22를 사용하는 1개의 directional light를 가지고있기 때문이다.

17개의 16K Texture를 필요로하고, Non-Nanite Object는 17개의 texture중 어떤 곳에 영향을 미칠지 현재 셰이더에서 판별한다.

 

for (uint PrimaryViewId = VSMCullingBatchInfo.FirstPrimaryView; PrimaryViewId < VSMCullingBatchInfo.FirstPrimaryView + VSMCullingBatchInfo.NumPrimaryViews; ++PrimaryViewId)
	{
		FNaniteView NaniteView = GetNaniteView(PrimaryViewId);
		uint CullingFlags = ((1u << 0u));
		bool bEnableWPO = DrawCommandDesc.bMaterialUsesWorldPositionOffset;
		FInstanceDynamicData DynamicData = CalculateInstanceDynamicData(NaniteView, InstanceData);
		FBoxCull Cull;
		Cull.Init(
			NaniteView,
			InstanceData.LocalBoundsCenter,
			InstanceData.LocalBoundsExtent,
			InstanceData.NonUniformScale,
			DynamicData.LocalToTranslatedWorld,
			DynamicData.PrevLocalToTranslatedWorld );
		Cull.bSkipWPODisableDistance |= DrawCommandDesc.bMaterialAlwaysEvaluatesWorldPositionOffset;
		Cull.Distance( PrimitiveData );
		bEnableWPO = bEnableWPO && Cull.bEnableWPO;
		const bool bAllowWPO = VirtualShadowMapIsWPOAllowed(PrimitiveData, NaniteView.TargetLayerIndex);
		bEnableWPO = bEnableWPO && bAllowWPO;
		bool bCacheAsStatic = ShouldCacheInstanceAsStatic(InstanceId, (NaniteView.Flags & 0x8), bAllowWPO, NaniteView.SceneRendererPrimaryViewId);
		Cull.bUseReceiverMask = Cull.bUseReceiverMask && !bCacheAsStatic;
		Cull.bIsStaticGeometry = bCacheAsStatic;
		if (!bEnableWPO)
		{
			CullingFlags &= ~(1u << 0u);
		}
		Cull.ScreenSize(DrawCommandDesc.MinScreenSize, DrawCommandDesc.MaxScreenSize);
		Cull.GlobalClipPlane();
		bool bInvalidatePages = ShouldMaterialInvalidateShadowCache(PrimitiveData, bEnableWPO)
				|| GetInstanceViewData(InstanceId, NaniteView.SceneRendererPrimaryViewId).bIsDeforming;
		FFrustumCullData FrustumCull = (FFrustumCullData)0;
		[branch]
		if( Cull.bIsVisible )
		{
			FrustumCull = Cull.Frustum();
		}
		StatsBufferInterlockedAdd(6, NaniteView.TargetNumMipLevels);
		[branch]
		if (Cull.bIsVisible)
		{
		   ...

Culling 부분은 보던 코드라서 간결하다. Object bound에 대해서 distance cull, frustum cull을 수행한다. culling을 통과한 object들에 대해 다음과 같은 처리를 한다.

 

culling통과 object에 대해서 page단위 체크

위에서 걸러진 것은 Object자체가 culling된 경우이다.

이제는 그려지는 Object가 어떤 page에 영향을 미치는지 판별한다.

FScreenRect Rect 는 Object bound가 Screen에서 차지하는 rect를 의미한다.

이 정보를 바탕으로

[branch]
			if (Cull.bIsVisible)
			{
				float PixelEstRadius = CalcClipSpaceRadiusEstimate(Cull.bIsOrtho, InstanceData, Cull.LocalToTranslatedWorld, NaniteView.ViewToClip) * float(((1u << 7u) * (1u << 7u)));
				uint FlagMask = GetPageFlagMaskForRendering(bCacheAsStatic, InstanceData.InstanceId, NaniteView.SceneRendererPrimaryViewId);
				for (uint MipLevel = 0U; MipLevel < uint(NaniteView.TargetNumMipLevels); ++MipLevel)
				{
					uint MipViewId = MipLevel * TotalPrimaryViews + PrimaryViewId;
					FNaniteView MipView = GetNaniteView(MipViewId);
					uint VirtualShadowMapId = uint(MipView.TargetLayerIndex);
					FScreenRect Rect = GetScreenRect(MipView.ViewRect, FrustumCull, 4);
					bool bDetailGeometry = IsDetailGeometry(bCacheAsStatic, false, PixelEstRadius);
					PixelEstRadius *= 0.5f;
					Rect = VirtualShadowMapGetUncachedScreenRect( Rect, VirtualShadowMapId, MipLevel );
					uint4 RectPages = VirtualShadowMapGetPageRect( Rect );
					if (OverlapsAnyValidPage(VirtualShadowMapId, MipLevel, Rect, FlagMask, bDetailGeometry, Cull.bUseReceiverMask))
					{
					...
					}
				}
			}

OverlapsAnyValidPage 함수가 이를 판별하는데,

하나의 page라도 MarkFlag 할일이 있는지?를 판별한다. 이 때, 이전에 만들어둔 PageFlagMipmap으로 빠르

게 체크할 수 있다.

bool OverlapsAnyValidPage(uint ShadowMapID, uint MipLevel, FScreenRect ClampedScreenRect, uint FlagMask, bool bDetailGeometry, bool bUseReceiverMask)
{
	if (any(ClampedScreenRect.Pixels.zw < ClampedScreenRect.Pixels.xy))
	{
		return false;
	}
	uint4 RectPages = VirtualShadowMapGetPageRect(ClampedScreenRect); 
	uint HMipLevel = MipLevelForRect(RectPages, 2);
	FVSMPageOffset VSMPageOffset = CalcPageOffset(ShadowMapID, MipLevel, RectPages.xy);
	uint4 PageFlags2x2 = GatherPageFlags(VSMPageOffset.TexelAddress, HMipLevel);
	uint4 UnitRect = RectPages >> HMipLevel;
	PageFlags2x2.yz = (UnitRect.x == UnitRect.z) ? 0u : PageFlags2x2.yz;	
	PageFlags2x2.xy = (UnitRect.y == UnitRect.w) ? 0u : PageFlags2x2.xy;	
	uint PageFlags = PageFlags2x2.x | PageFlags2x2.y | PageFlags2x2.z | PageFlags2x2.w;
	if ((PageFlags & FlagMask) != 0 &&
		(!bDetailGeometry || ((PageFlags & (1U << 3)) != 0)))
	{
		[branch]
		if (VirtualShadowMap_bEnableReceiverMasks && bUseReceiverMask)
		{
			...
		}
		return true;
	}
	return false;
}

OverlapsAnyValidPage에서 영향이 받는 페이지가 있었다면, 차지하는 Screen Rect 크기에 따라서 SmallJob or LargeJob으로 나눈다. 이 글에서는 SmallJob만 보면, RectPage를 순회하면서 PageDirty를 체크한다.

uint4 RectPages = VirtualShadowMapGetPageRect( Rect );
if (OverlapsAnyValidPage(VirtualShadowMapId, MipLevel, Rect, FlagMask, bDetailGeometry, Cull.bUseReceiverMask))
{
	uint NumMappedPages = 0U;
	{
		const uint MarkPageDirtyFlags = VirtualShadowMapGetMarkPageDirtyFlags(bInvalidatePages, bCacheAsStatic, Cull.bIsViewUncached, bAllowWPO);
		uint2 RectPagesSize = (RectPages.zw + 1u) - RectPages.xy;
		bool bIsSmallJob = RectPagesSize.x * RectPagesSize.y <= (8U);
		bool bDoLargeJob = !bIsSmallJob && (MarkPageDirtyFlags != 0);
		uint LargeJobIndex = 0U;
		if (bDoLargeJob)
		{
			...
		}
		if (bIsSmallJob || bMarkingJobQueueOverflow)
		{
			FVirtualSMLevelOffset PageTableLevelOffset = CalcPageTableLevelOffset(VirtualShadowMapId, MipLevel);
			for (uint Y = RectPages.y; Y <= RectPages.w; ++Y)
			{
				for (uint X = RectPages.x; X <= RectPages.z; ++X)
				{
					if (MarkPageDirty(PageTableLevelOffset, uint2(X, Y), MipLevel, FlagMask, MarkPageDirtyFlags))
					{
						++NumMappedPages;
					}
				}
			}
		}
		else
		{
			if (bDoLargeJob)
			{
				...
			}
			NumMappedPages = 1U;
		}
	}
	if (NumMappedPages > 0U)
	{
		++ThreadTotalForAllViews;
		bVisibleInstancesOverflow |= !WriteCmd(MipViewId, InstanceId, Payload.IndirectArgIndex, CullingFlags, bCacheAsStatic);
	}
}
else
{
	StatsBufferInterlockedInc(VSM_STAT_NON_NANITE_INSTANCES_PAGE_MASK_CULLED);
}
StatsBufferInterlockedAdd(7, ThreadTotalForAllViews);
StatsBufferInterlockedEnableFlags(22, (1<<0), bMarkingJobQueueOverflow, true);
StatsBufferInterlockedEnableFlags(22, (1<<3), bVisibleInstancesOverflow, true);
InterlockedAdd(DrawIndirectArgsBufferOut[Payload.IndirectArgIndex * 5 + 1], ThreadTotalForAllViews);

그리고 NumMappedPages가 있는 경우에는 ++ThreadTotalForAllViews해주고 모아서

bVisibleInstancesOverflow |= !WriteCmd(MipViewId, InstanceId, Payload.IndirectArgIndex, CullingFlags, bCacheAsStatic);

WriteCmd 함수 안에서 VisibleInstancesOut에 FVSMVisibleInstanceCmd정보를 넣는다.

struct FVSMVisibleInstanceCmd
{
	uint PackedPageInfo;
	uint InstanceIdAndFlags;
	uint IndirectArgIndex;
};
InterlockedAdd(DrawIndirectArgsBufferOut[Payload.IndirectArgIndex * 5 + 1], ThreadTotalForAllViews);

다음 일련의 처리들로 인해 이 shadow view는 나중에 RasterPasses 에서 ExecuteIndirect 때 그려지게 된다.

그리고 그 ExecuteIndirect 의 argument로는 DrawIndirectArgsBufferOut을 사용하면 된다.

 

 

RasterPasses

위의 패스들에서 만들어진 DrawIndirectArgs 버퍼를 사용해서 각 primitive별로 ExecuteIndirect 호출.

만약 Object크기가 꽤 커서 VSM 3개에 걸쳐서 영향을 끼친다고 가정하면,

이 Indirect호출로 DrawIndexedInstance(vertexcount, 3, x, x); 의 호출이 일어난다.

이 패스의 VS, PS를 분석해보자

RasterPassVS

void PositionOnlyMain(
	in FPositionAndNormalOnlyVertexFactoryInput Input,
	out FShadowDepthVSToPS OutParameters,
	out nointerpolation uint PackedPageInfo : TEXCOORD8,
	out float4 OutPosition : SV_POSITION
	, out float4 OutVirtualSmPageClip : SV_ClipDistance
	)
{
	....
	OutVirtualSmPageClip = float4(1.0f, 1.0f, 1.0f, 1.0f);
	if (ShadowDepthPass_bRenderToVirtualShadowMap != 0)
	{
		uint InstanceIdIndex = VertexFactoryGetInstanceIdLoadIndex(Input);
		PackedPageInfo = InstanceCulling_PageInfoBuffer[InstanceIdIndex];
		FPageInfo PageInfo = UnpackPageInfo(PackedPageInfo);
		TransformToVirtualSmPage(OutPosition, OutVirtualSmPageClip, PageInfo, WorldPos.xyz);
	}
}

OutPosition이 Vertex Shader의 결과물인데 , 이론적으로 생각하면 각 3장의 VSM은 다른 Shadow View를 가지고있을 것이고 그에 따라 맞는 projection을 진행해줘야한다.

InstanceCulling_PageInfoBuffer에서 해당하는 Instance의 FPageInfo를 얻어오면

struct FPageInfo
{
	uint ViewId;
	bool bStaticPage;		
};

해당하는 ViewId를 얻을 수 있고, TransformToVirtualSmPage 함수를 진입해서

void TransformToVirtualSmPage(inout float4 PointClip, inout float4 ClipPlanesInOut, FPageInfo PageInfo, float3 PointTranslatedWorld)
{
	FNaniteView NaniteView = UnpackNaniteView(ShadowDepthPass_PackedNaniteViews[PageInfo.ViewId]);
	PointTranslatedWorld += DFFastSubtractDemote( NaniteView.PreViewTranslation, ResolvedView.PreViewTranslation );
	PointClip = mul( float4( PointTranslatedWorld, 1 ), NaniteView.TranslatedWorldToClip );
	if (ShadowDepthPass_bClampToNearPlane > 0 && PointClip.z > PointClip.w)
	{
		PointClip.z = 0.999999f;
		PointClip.w = 1.0f;
	}
	ScaleBiasClipToPhysicalSmPage(NaniteView, PointClip, ClipPlanesInOut, PageInfo);
}

PageInfo.ViewId 에 해당하는 NaniteView를 가져와 mul( float4( PointTranslatedWorld, 1 ), NaniteView.TranslatedWorldToClip ) Shadow View Space로 공간변환을 해준다.

ScaleBiasClipToPhysicalSmPage은 뭔가 Scaling을 해주는데 여기서는 생략.

 

RasterVS 결론

호출된 InstanceCount 개수만큼 각각 다른 shadow view에 대해서 transform 해준다.

 

RasterPS

void Main( 
	FShadowDepthVSToPS Inputs,
	nointerpolation uint PackedPageInfo : TEXCOORD8,
	in float4 SvPosition : SV_Position		
	)
{
	ResolvedView = ResolveView();
	ClipLODTransition(SvPosition.xy);
	uint2 vAddress = (uint2)SvPosition.xy;
	float DeviceZ = SvPosition.z;
	FPageInfo PageInfo = UnpackPageInfo( PackedPageInfo );
	FNaniteView NaniteView = UnpackNaniteView( ShadowDepthPass_PackedNaniteViews[ PageInfo.ViewId ] );
	FShadowPhysicalPage Page = ShadowDecodePageTable( ShadowDepthPass_VirtualSmPageTable[ CalcPageOffset( NaniteView.TargetLayerIndex, NaniteView.TargetMipLevel, vAddress >> 7u ).GetResourceAddress() ] );
	if( Page.bThisLODValidForRendering)
	{
		uint2 pAddress = Page.PhysicalAddress * (1u << 7u) + (vAddress & ((1u << 7u) - 1u));
		const int ArrayIndex = PageInfo.bStaticPage ? GetVirtualShadowMapStaticArrayIndex() : 0;
		InterlockedMax( ShadowDepthPass_OutDepthBufferArray[ uint3( pAddress, ArrayIndex ) ], asuint( DeviceZ ) );
	}
}

PS 셰이더는 Main함수가 깔끔해서 함수이름만으로도 파악이 용이하다.

PageInfo는 VS에서 잘 넘어왔고, 이 정보로 NaniteView도 얻을 수 있다.

ShadowDecodePageTable 함수를 통해 FShadowPhysicalPage 정보를 얻는데, 이 구조체의 PhysicalAddress 값을 통해 실제 Physical Page가 저장되어있는 공간에 접근가능하다.

 

출처 : https://zhuanlan.zhihu.com/p/489550318

 

이 PhysicalAddress 값을 바탕으로 실제 메모리인 ShadowDepthPass_OutDepthBufferArray에 depth를 쓰는 작업을 한다.

		uint2 pAddress = Page.PhysicalAddress * (1u << 7u) + (vAddress & ((1u << 7u) - 1u));
		const int ArrayIndex = PageInfo.bStaticPage ? GetVirtualShadowMapStaticArrayIndex() : 0;
		InterlockedMax( ShadowDepthPass_OutDepthBufferArray[ uint3( pAddress, ArrayIndex ) ], asuint( DeviceZ ) );

VSM 마무리

많은 패스를 거쳐서 복잡하지만 결국에 가장 중요한 구조인 VirtualPage to Phyiscal Page이런 형태이다.

출처 : https://zhuanlan.zhihu.com/p/489550318

 

우리의 예시 씬에서는 16K Texture를 17장이나 사용하고 있지만, GB단위의 VRAM을 사용하지 않고도, lighting pass에서 접근하는 모든 16K Texture pixel에 대해서 Phyiscal Page Pool에 존재함을 보장할 수 있다.

즉, 16K Texture 17장을 사용하는 것과 같은 퀄리티로 렌더링하고 있다.

 

Reference

https://zhuanlan.zhihu.com/p/489550318

https://github.com/EpicGames/UnrealEngine

'언리얼 엔진' 카테고리의 다른 글

[Unreal Engine 5] Virtual Shadow Map  (1) 2025.07.29
[Unreal Engine 5] Nanite Basepass 분석  (5) 2025.07.25
Expert's guide to unreal engine performance  (0) 2023.09.01
UE5.1 Planar reflection과 VSM 동시 사용 시 프레임 드랍 문제  (0) 2023.08.03
[언리얼 엔진] Referencing Assets 쿠킹 테스트  (0) 2023.03.24
'언리얼 엔진' 카테고리의 다른 글
  • [Unreal Engine 5] Virtual Shadow Map
  • [Unreal Engine 5] Nanite Basepass 분석
  • Expert's guide to unreal engine performance
  • UE5.1 Planar reflection과 VSM 동시 사용 시 프레임 드랍 문제
jooh3444
jooh3444
게임엔진 / 그래픽스 개발 블로그
  • jooh3444
    Jooh 개발 블로그
    jooh3444
  • 전체
    오늘
    어제
    • Dev blog (18)
      • OpenGL (7)
        • CS-248 셰이더 프로그래밍 (7)
      • 언리얼 엔진 (7)
      • 기타 (1)
      • Computer Graphics (3)
  • 블로그 메뉴

    • 홈
    • About
    • github
  • 인기 글

  • 태그

    Virtual Shadow Map
    multi-scattering brdf
    Computer Graphics
    Enviroment Lighting
    범프 매핑
    UE5 bugfix
    twopass occlusion culling
    bDontLoadBlueprintOutsideEditor
    Shader Programming
    Unreal Engine 5
    Nanite
    셰이더
    OpenGL
    Blueprint load by path
    UE5
    셰이더 프로그래밍
    Unreal Engine
    그래픽스
    Shader
    Shadow map
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
jooh3444
[Unreal Engine 5] Virtual Shadow Map - 렌더패스 분석
상단으로

티스토리툴바