본문 바로가기

프로그래밍/Unreal

[UE5] Sparse Distance Field - 2. Distance Field의 스트리밍

 이전 글에서 대상 메시의 정보를 이용하여 디스턴스 필드 정보를 생성하고, 이를 FDistanceFieldVolumeData에 저장하는 과정을 살펴보았다.

 이 VolumeData는 UStaticMesh의 RenderData 내부의 LODResource[0]번째에 DistanceFieldData라는 이름으로 저장되어있다. 또한 이 데이터를 어떤 형태로 저장해 두었는지 또한 확인하였다.

이번 글에는 이 에셋 데이터가 어떻게 씬에 올라가게 되고, 스트리밍 되는지를 살펴볼 예정이다.

 

FDistanceFieldSceneData

 지난 번에 살펴본 FDistanceFieldVolumeData가 각 메시 에셋을 대표한다면, FDistanceFieldSceneData는 이 에셋들을 관리하고 적절히 업데이트 해주는 책임을 가지고 있다.

 기존 UE4에서는 에셋들이 씬에 등록되고, 업데이트나 지워지는 동작 만을 수행했다면, UE5에서는 스트리밍에 대한 동작이 추가가 되었다.

 이 글에서는 UE4동작과 비슷한 부분은 생략하고, 핵심적인 2가지 메서드를 통해 SceneData가 하는 일을 확인하려고 한다.

 FSceneRenderer의 PrepareDistanceFieldScene 메서드를 살펴보면, UE4와 5의 다른점을 확인할 수 있다.

 

UE4 UE5

UpdateGlobalDistanceFieldObjectBuffers 메서드가 UpdateDistanceFieldObjectBuffers와 UpdateDistanceFieldAtlas의 두 메서드로 쪼개진 것을 확인할 수 있다.

UpdateGlobalDistanceFieldObjectBuffers는 DistanceFieldSceneData가 가지고 있던 Operation들을 수행시켜 현재 씬의 상태와 DistanceFieldScene의 상태를 일치시키고, 이를 Global Distance Field Atlas에 반영시키는 메서드였다.

큰 줄거리는 UE5도 비슷하지만, 몇 가지 동작들은 꽤 다르다. 이중에서 UpdateDistanceFieldAtlas를 대체적으로 살펴보며 스트리밍을 어떻게 진행하는지 확인하자.

 

FDistanceFieldAssetState

UpdateDistanceFieldAtlas를 직접적으로 들어가기 전에 FDistanceFieldAssetState라는 구조체를 좀 확인해보는 것이 좋을 것이다.

이 데이터는 FDistanceFieldSceneData가 TSet의 형태로 들고 있는 데이터로서, FDistanceFieldVolumeData의 포인터를 키 값으로 들고 있다.

 

class FDistanceFieldAssetState
{
public:
	// 해당 디스턴스 필드 에셋의 볼륨 데이터 포인터
	const FDistanceFieldVolumeData* BuiltData;

	// 해당 에셋의 레퍼런스 카운터다. 이 값이 0이 되면 메모리에서 내려가게 된다.
	int32 RefCount;
	// 스트리밍의 결과물로 얻게 될 총 Mip 개수다.
	int32 WantedNumMips;
	// 디스턴스 필드의 각 Mip의 스트리밍에 필요한 데이터를 담고 있는 데이터다.
	TArray<FDistanceFieldAssetMipState> ReversedMips;
}

class FDistanceFieldAssetMipState
{
public:

	FIntVector IndirectionDimensions;
	int32 IndirectionTableOffset;
	FIntVector IndirectionAtlasOffset;
	int32 NumBricks;
	TArray<int32> AllocatedBlocks;
}

각각의 디스턴스 필드 에셋은 AddPrimitive 메서드를 통해 자신의 정보를 SceneData에 전달하게 되고, SceneData가 Update되면서 해당 정보를 이용해 FDistanceFieldAssetState를 SceneData에 추가하게 되어있다.

AssetState는 위의 구조체인 FDistanceFieldAssetMipState를 가지고 있는데, 각 구조체는 Mip 각각에 대한 정보를 가지고 있다. 그리고 ReversedMips라는 이름에 맞게, Mip2, 1, 0...의 순서로 배열에 적재된다.

SceneData는 AssetState 구조체를 이용하여 디스턴스 필드의 스트리밍을 핸들링한다. 아마 Mesh Streaming을 보았다면 CachedSRRState라는 유사한 구조체를 확인할 수 있었을 것이다.

 

FDistanceFieldSceneData::UpdateDistanceFieldAtlas

이 함수에서 하는 다른 행동들은 나중에 살펴보기로 하고, 스트리밍 관련 동작만을 살펴보자.

void FDistanceFieldSceneData::UpdateDistanceFieldAtlas( ... )
{
	...

	// (1)
	TArray<FDistanceFieldReadRequest> NewReadRequest;
	ProcessStreamingRequestsFromGPU(NewReadRequest, AssetDataUploads);

	// (2)
	TArray<FDistanceFieldReadRequest> ReadRequestsToUpload;
	TArray<FDistanceFieldReadRequest> ReadRequestsToCleanUp;
	ProcessReadRequests(AssetDataUploads, DistanceFieldAssetMipAdds, ReadRequestsToUpload, ReadRequestsToCleanUp);

	// (3)
	for (int32 MipAddIndex = 0; MipAddIndex < DistanceFieldAssetMipAdds.Num(); ++MipAddIndex)
	{
		...
	}
}

(1) GPU로부터 새로운 StreamingRequest가 있는지 확인하고, FDistanceFieldReadRequest의 형태로 받아온다.

 FDistanceFieldReadRequest는 Scene에서의 Asset 관리 데이터와 스트리밍 Bulk관련 데이터, 그리고 IoRequest에 관한 데이터를 담고 있는 Request 객체다.

 

ProcessStreamingRequestsFromGPU에서는 StreamingRequestReadbackBuffers를 뒤져 가장 최근에 GPU에서 사용한 Readback Buffer를 찾아온다.

 Readback Buffer에는 맨 처음 4바이트에 Streaming Request 개수가 적혀있고, 그 뒤로는 SceneData의 AssetIndex와 따라 해당 Asset이 필요한 WantedMips값이 연속적으로 기입되어 있다.

 

 Readback Buffer를 등록하고 계산하는 과정은 FDistanceFieldSceneData::GenerateStreamingRequests에서 확인할 수 있고, GenerateDistanceFieldAssetStreamingRequestsCS와 FComputeDistanceFieldAssetWantedMipsCS를 이용한다.

WantedNumMips를 구하는 방식은 간단한데, GPU Scene에 올라간 DFObject와 View와의 거리를 계산하여 Scene→GlobalDistanceFieldViewDistance 값보다 벗어났다면 1, 이 값의 1/8값보다 안쪽이라면 3, 그렇지 않다면 2로 구분하는 식이다. (Bound 계산)

 

 위의 결과물을 읽어들여 알맞은 AssetState의 WantedNumMips를 업데이트 해준다. 또한 현재 상태와 WantedNumMips를 비교하여, 만약 StreamOut이 필요하다면 Indirection Allocator와 DistanceFieldAtlas에 메모리를 해제시키고,

그렇지 않고 StreamIn이 필요한 상태라면 IoRequest를 새로 만들어 NewReadRequest에 넣어준다.

 

(2) ProcessReadRequest는 새로 필요하다고 만들어진 Mip (DistanceFieldAssetMipAdds)을 정돈하여 ReadRequestsToUpload에 넣어준다.

 또한 가지고 있던 ReadRequests 들을 순회하면서 Io가 끝났는지 확인하고, 확인한 ReadRequest를 UploadLimit에 맞도록 ReadRequest에서 빼서 ReadRequestToUpload와 ReadRequestToCleanUp에 넣어준다.

 

(3) 현재 프레임에 추가된 Add 요청을 처리해주어야 한다. MipAdds에 담긴 ReadRequest들을 순회하면서 메모리 레이아웃을 할당 받아 놓는다.

 DistanceField의 메모리는 "r.DFShadowOffsetDataStructure" 콘솔 변수에 따라 저장되는 공간의 형태가 달라지는데, 이 값에 따라 Linear Buffer에 저장할지, Texture3d 레이아웃 형태에 저장할지가 달라진다.

 이 메모리 레이아웃은 각각 IndirectionTableAllocator와 IndirectionAtlasLayout로 관리되고, 이를 통해 할당받은 레이아웃 오프셋은 MipState의 IndirectionTableOffset, IndirectionAtlasOffset으로 따로 관리된다.

 물론 실제 데이터 적재 공간 또한 IndirectionTable과 IndirectionAtlas로 달라지는데, 우리는 텍스쳐 형태의 저장 방식을 따라가는 것으로 생각하자.

 

 

void FDistanceFieldSceneData::UpdateDistanceFieldAtlas( ... )
{
	...

	// (4)
	ResizeBrickAtlasIfNeeded(GraphBuilder, GlobalShaderMap);
	ResizeResourceIfNeeded(AssetDataBuffer, AssetDataSizeBytes);
	ResizeIndirectionAtlasIfNeeded(GraphBuilder, GlobalShaderMap);

	// (5)
	FDistanceFieldAsyncUpdateParameters UpdateParameters;
	UpdateParameters.DistanceFieldSceneData = this;

	UpdateParameters.IndirectionIndicesUploadPtr = IndirectionAtlasUpload.IndirectionUploadIndicesPtr;
	UpdateParameters.IndirectionDataUploadPtr = IndirectionAtlasUpload.IndirectionUploadDataPtr;

	UpdateParameters.BrickUploadDataPtr = AtlasUpload.BrickUploadDataPtr;
	UpdateParameters.BrickUploadCoordinatesPtr = AtlasUpload.BrickUploadCoordinatesPtr;

	UpdateParameters.NewReadRequests = MoveTemp(NewReadRequests);
	UpdateParameters.ReadRequestsToUpload = MoveTemp(ReadRequestsToUpload);
	UpdateParameters.ReadRequestsToCleanUp = MoveTemp(ReadRequestsToCleanUp);

	// Kick off an async task to copy completed read requests into upload staging buffers, and issue new read requests
	AsyncTaskEvents.Add(TGraphTask<FDistanceFieldStreamingUpdateTask>::CreateTask().ConstructAndDispatchWhenReady(UpdateParameters));

	// (6)
	AddPass()[...] {
		WaitUntil(AsyncTaskEvents);
		TShaderMapRef<FScatterUploadDistanceFieldIndirectionAtlasCS> ComputeShader(GlobalShaderMap);
		...
		FComputeShaderUtils::Dispatch();
	};

	// (7)
	AddPass()[...] {
		auto ComputeShader = GlobalShaderMap->GetShader<FScatterUploadDistanceFieldAtlasCS>();
		PassParameters->RWDistanceFieldBrickAtlas = GraphBuilder.CreateUAV(DistanceFieldBrickVolumeTextureRDG);
		...
	};
}

(4) 필요한 Buffer를 현재 Asset 개수에 맞게 Resizing 한다.

 ResizeBrickAtlasIfNeeded는 위에서 할당했던 Brick Allocator의 레이아웃에 따라 DistanceFieldBrickVolumeTexture를 리사이징 해준다.

 AssetDataBuffer는 DistanceFieldObject들의 데이터가 올라가는 Buffer다. DistanceFieldLightingShared.usf의 SceneDistanceFieldAssetData에 이 데이터가 올라가며, LoadDFAssetData에서 AssetIndex와 ReversedMipIndex를 통해 해당 Mip의 Dimension, TableOffset, Scale등을 얻어갈 수 있다.

 ResizeIndirectionAtlasIfNeeded 내부에서는 현재 IndirectionAtlas의 크기와 원하는 Atlas 크기가 다른 경우 Resizing이 일어나는데, 이 과정에서 단편화를 막기위해 에셋 사이즈 크기 순서로 소팅을 한 다음 IndirectionIndex를 바꿔주는 동작이 들어가 있다. 이건 추후에 한 번 살펴보도록 하자.

 

(5) 준비해두었던 데이터들을 올릴 UpdateParameters 준비를 한다.

 첫 번째 단락은 DistanceFieldSceneData를 넣어준다. 두 번째는 바뀐 Indirection Data가 있다면 이를 업로드해주기 위한 바인딩이다. 세번째는 바뀐 BrickData와 이 위치 좌표다.

 네 번째 단락이 우리가 아까 만들었던 ReadRequest들과, 골라내었던 Upload, Cleanup Request들을 바인딩 해주는 장면이다.

 마지막 단락은 FDistanceFieldStreamingUpdateTask를 통해 이 UpdateParameters를 넘겨주는 부분이고, 이 Task에서는 ReadRequest의 스트리밍 과정을 관리한다.

 첫 번째로 AsyncTask는 Upload 준비가 끝난(Io가 끝난) ReadRequest들을(ReadRequestToUpload) 순회하면서 스트리밍 된 BulkData를 읽어들인다.

 BulkData를 읽어 만들어두었던 Indirection 데이터와 Brick데이터를 UpdateParameter에 바인딩 된 UploadBuffer에 알맞게 적재시켜준다.

 그 다음으로 ReadRequestsToCleanUp을 순회하면서 할당된 메모리를 소멸시켜준다.

 이 과정이 끝나면 NewReadRequest를 살펴보면서 IoDispatcher를 이용해 Io를 진행시키고, 이 리스트를 SceneData의  ReadRequest에 추가하여 Io가 완료될 때까지의 관리 책임을 진다.

 

(6) AsyncTask가 끝나길 기다려준다. AsyncTask에서 ReadRequestsToUpload의 BulkData를 읽어 우리가 GPU에 Upload를 원하는 IndirectionTable과 BrickData를 채워주어야하기 때문이다.

이 단락에서는 AsyncTask를 위해 (5)에서 걸었던 버퍼들의 Lock을 해제해주고, FScatterUploadDistanceFieldIndirectionAtlasCS를 이용해 채웠던 Indirection 데이터들을 GPU로 업로드 해준다.

 

(7) 위에 이어서 AsyncTask에서 채웠던 BrickData를 GPU로 업로드해준다.

 

void FDistanceFieldSceneData::UpdateDistanceFieldAtlas( ... )
{
	...
	// (8)
	UploadAllAssetData(GraphBuilder);

	// (9)
	GenerateStreamingRequests(GraphBuilder, View, Scene, bLumenEnabled, GlobalShaderMap);
}

(8) 위와 마찬가지로 AssetData를 업로드 해준다.

 

(9) 위에서 언급했던 GenerateStreamingRequests다.

FComputeDistanceFieldAssetWantedMipsCS를 이용해 DistanceField WantedMip 계산을 요청해둔 뒤,

FGenerateDistanceFieldAssetStreamingRequestsCS를 이용해 새로운 버퍼를 할당받아 ReadbackBuffer를 등록해둔다.

 

 

스트리밍 과정만을 살펴보려고 했지만, 분석을 하다보니 다른 부분까지 더 건드리게 된 것 같다.

 

 

요약

  • DistanceField 데이터의 스트리밍은 FSceneRenderer의 PrepareDistanceFieldScene에서 진행되며, 특히 UpdateDistanceFieldAtlas 메서드에서 진행된다.
  • ProcessStreamingRequestsFromGPU 메서드를 통해 GPU에서 요구하는 신규 Mip 요청을 받아온다. 이는 Readback Buffer를 통해 확인하며, GPU 내부에서는 DFObject와 View 사이의 거리와 GlobalDistanceFieldViewDistance를 비교하여 결정한다.
  • GenerateStreamingRequests는 위의 계산 동작과 Readback Buffer를 등록하는 동작을 담당한다.
  • ProcessReadRequests는 내부에서 완료된 ReadRequest와 새로 등록된 Mip 데이터를 RequestToUpload에 담아주는 역할을 수행한다.
  • RequestToUpload를 받아 실제적으로 스트리밍을 관리하는 부분은 FDistanceFieldStreamingUpdateTask이고, 스트리밍 완료된 데이터를 업로드 버퍼에 적재하고, 메모리를 해제하고, 신규 리퀘스트들의 스트리밍을 시작하는 동작을 수행한다.