본문 바로가기

프로그래밍/Unreal

[UE5] Sparse Distance Field - 1. Distance Field를 만드는 과정

 언리얼 엔진이 5 버전으로 넘어오면서, 디스턴스 필드 또한 눈에 띄진 않지만 많은 부분을 개선했다.

 기존 4 버전에서는 메시의 디스턴스 필드를 사용하는데 있어서 가장 큰 문제는 너무 많은 메모리를 소비한다는 점이었다. 그렇기 때문에 디스턴스 필드는 해상도가 굉장히 제한적이었고, 많은 부분에 사용하기도 어려웠다.

 5 버전에서는 이것을 개선하기 위해 디스턴스 필드에도 텍스쳐와 같이 Mip Level을 나누고, 이를 스트리밍할 수 있도록 만들었다.

 스트리밍이 가능해졌기 때문에 상대적으로 가까운 물체에는 높은 해상도의 디스턴스 필드를, 그렇지 않은 물체에는 낮은 메모리 점유라는 이득을 취할 수 있었다. 물론 스트리밍에 드는 비용을 트레이드 오프로 지불해야만 한다.

 

FMeshUtilities::GenerateSignedDistanceFieldVolumeData

 메시의 디스턴스 필드를 만드는 과정 자체는 4버전과 크게 달라진 것이 없다. 실제 메시의 디스턴스 필드는 위의 메서드에서 만들어주기 때문에, 이 함수를 좀 분석해서 어떤 방식으로 디스턴스 필드를 만들고 적재시키는지를 확인하자.

 

void FMeshUtilities::GenerateSignedDistanceFieldVolumeData( ... )
{
	// (1)
	FEmbreeScene EmbreeScene;
	MeshRepresentation::SetupEmbreeScene(MeshData, LODModel, MaterialBlendNodes, EmbreeScene);

	// (2)
	bool bMostlyTwoSided;
	{
		...
	}

	// (3)
	TArray<FVector3f> SampleDirections;
	{
		...
	}
}

(1) SignedDistanceField를 만들기 위해서는 Embree를 이용한다. Embree는 Intel의 RayTracing 라이브러리를 지칭한다.

Embree를 이용하기 위해 EmbreeScene을 생성하고, 생성하고자하는 Mesh의 데이터를 EmbreeScene에 전달해준다.

SetupEmbreeScene 내부에서는 EmbreeScene Device를 생성하고, MeshData의 Vertex 정보등을 EmbreeScene에 바인딩 시켜준다.

 

(2) Mesh의 Distance Field의 Generate Two Sided 옵션이 false라고 하더라도, 메시의 LODModel.Section을 뒤져서 TwoSidedTriangle 개수가 전체의 25% 이상이라면 bMostlyTwoSided 옵션을 활성화시킨다.

양면 디스턴스 필드를 만드는 경우 단면 디스턴스 필드보다 메시의 LocalSpace 크기가 커지게 되고, 그 만큼 비용이 커질 수 있다. 만약 단면 디스턴스 필드를 생성하였는데 양면처럼 보인다면 이 부분을 디버그해보자.

 

(3) RayTracing을 위한 샘플링 벡터들을 만들어준다. 120개의 벡터를 가진 반구 두 개를 이어붙여 총 240개의 샘플링 레이가 담기게 된다.

 

 

void FMeshUtilities::GenerateSignedDistanceFieldVolumeData( ... )
{
	// (4)
	FVector DesiredDimensions;
	FIntVector Mip0IndirectionDimensions;
	{
		DesiredDimensions = DistanceFieldResolutionScale * VoxelDensity * LocalSpaceMeshBounds.GetSize() / DistanceField::UniqueDataBrickSize;
	}

	int32 NumMips = 3;
	for (int32 MipIndex = 0; MipIndex < NumMips; ++MipIndex)
	{
	 	// (5)
		FVector IndirectionDimensions = Mip0IndirectionDimensions >> MipIndex;

		// (6)
		TArray<FSparseMeshDistanceFieldAsyncTask> AsyncTasks;
		for (int32 ZIndex = 0; ZIndex <  IndirectionDimensions.Z; ++ZIndex) {
			for (int32 YIndex = 0; YIndex <  IndirectionDimensions.Y; ++YIndex) {
				for (int32 XIndex = 0; XIndex <  IndirectionDimensions.X; ++XIndex) {
					AsyncTasks.Emplace(EmbreeScene, SampleDirections, ... FIntVector(XIndex, YIndex, ZIndex));
				}
			}
		}
		AsyncTasks.DoWork()
	}
}

 

(4) 최고 해상도 디스턴스 필드 Mip이 가지게 될 Dimension을 결정한다. (Mip0IndirectionDimensions)

UE5에서는 디스턴스 필드를 여러개의 Brick이라는 단위로 나누어 데이터를 담아두게 된다. Dimensions는 XYZ축에 대해 해당 디스턴스 필드가 각각 몇 개의 Brick으로 이루어져있을지를 계산해 둔 것이다.

이 값은 메시의 로컬 스페이스 사이즈에 유닛당 Voxel의 개수를 곱해준 뒤(디스턴스 필드 해상도, 콘솔 변수인 VoxelDensity를 곱한 값), Brick의 한 변의 길이인 7을 나눠주어 계산한다.

Mip0 Dimensions는 DesiredDimensions를 1~1023 값으로 Clamp 한 값이다.

 

(5) 현재 디스턴스 필드의 Mip 개수는 3으로 고정되어 있다. 이제 루프를 돌며 각 Mip에 대한 디스턴스 필드 데이터를 만들 것이다.

IndirectionDimensions는 현재 MipIndex에 대한 Brick 측정치이다. Mip이 1 늘어날때마다, Dimension의 각 변은 1/2이 되는 것을 확인할 수 있다.

 

(6) 현재 Mip의 Dimension.XYZ, 즉 Brick의 총 개수 만큼 AsyncTask를 생성한다.

FSparseMeshDistanceFieldAsyncTask 내부에서는 EmbreeScene에 바인딩 된 메시의 정보와 샘플링 된 벡터들을 이용해 RayTracing 결과를 측정하여 현재 Voxel에서 가장 가까운 메시거리를 측정할 것이다.

각 Task의 결과물은 uint8 array 타입의 DistanceFieldVolume이고, 각 변이 8 * 8 * 8인 큐브 형태의 볼륨 데이터다. 각 데이터는 0~255로 투사된, 가장 가까운 메시와의 거리 정보를 담고 있다.

 

 

 

void FMeshUtilities::GenerateSignedDistanceFieldVolumeData( ... )
{
  	for (int32 MipIndex = 0; MipIndex < NumMips; ++MipIndex)
 	{
		...

		// (7)
		TArray<uint32> IndirectionTable;
		TArray<uint8> DistanceFieldBrickData;

		for (int32 BrickIndex = 0; BrickIndex < AsyncTasks.Num(); ++BrickIndex)
		{
			FSparseMeshDistanceFieldAsyncTask& Brick = AsyncTasks[BrickIndex];
			if (Brick.IsValid() == false) continue;
			int32 IndirectionIndex = ComputeLinearVoxelIndex(Brick.BrickCoordinate, IndirectionDimensions);

			IndirectionTable[IndirectionIndex] = BrickIndex;
			Memcpy(&DistanceFieldBrickData[BrickIndex * BrickSizeBytes], Brick.DistanceFieldVolume.GetData(), Brick.DistanceFieldVolume.Num() * Size());
		}

		// (8)
		if (MipIndex == 2)
		{
			Memcpy IndirectionTable to OutData.AlwaysLoadedMip
 			Memcpy DistanceFieldBrickData to OutData.AlwaysLoadedMip
		}
		else
		{
 			Memcpy IndirectionTable to OutData.StreamableMips
 			Memcpy DistanceFieldBrickData to OutData.StreamableMips
		}
	}

	// (9)
	DeleteEmbreeScene(EmbreeScene);
	OutData.bMostlyTwoSided = bMostlyTwoSided;
	OutData.LocalSpaceMeshBounds = LocalSpaceMeshBounds;
}

(7) 만들어진 DistanceFieldVolume (Brick) 데이터를 순회하면서 DistanceFieldBrickData에 적재시켜준다.

이 과정에서 Brick의 Index를 IndirectionTable에 담는 과정이 필요하다. Brick.BrickCoordinate 값은 우리가 Task 생성시 넣어주었던 (XIndex, YIndex, ZIndex)값이다.

ComputeLinearVoxelIndex 함수는 Volume 공간을 Linear 공간으로 변환시키는 함수다. (Coordinate.Z * Dimension.Y + Coordinate.Y) * Dimension.X + Coordinate.X 와 같은 방식으로 계산하고, 앞으로도 많이 사용하게 된다.

이렇게 Brick의 좌표를 2차원 공간으로 투사한 Index를 이용하여 IndrectionTable에 BrickIndex를 추가해준다.

 

(8) 만든 데이터를 목표했던 데이터에 적재시킨다.

확인해보면 IndirectionTable을 먼저, BrickData는 그 이후에 옮겨주는 것을 확인할 수 있다.

만약 최하위 Mip이라면 FDistanceFieldVolumeData의 AlwaysLoadedMip에 적재시켜준다. 이 데이터는 메시가 메모리에 올라온 순간부터 스트리밍 되지 않고 항상 가지고 있는 최하위 밉 데이터이다.

그렇지 않고 Streamable한 Mip의 데이터라면, BulkData 타입인 StreamableMips에 적재시켜 스트리밍이 가능하도록 만든다.

 

(9) EmbreeScene을 없애준 뒤, 그 외의 데이터를 VolumeData에 기입해준다.

 

 

요약

  • Mesh 공간을 Brick이라는 단위로 쪼개어 DistanceField를 만든다. 이 DistanceField는 0~255로 투사된 메시와의 거리 값을 Brick당 8^3개의 데이터로 가지고 있다.
  • 이 데이터를 FDistanceFieldVolumeData에 저장하게 되는데, Brick의 위치를 저장한 BrickCoordinate를 가지고 있는 IndirectionTable과 함께 저장하게 된다.
  • 디스턴스 필드를 3개의 Mip으로 나누어 저장하게 되는데, 최하위 Mip 데이터는 항상 로드되어있고, 나머지 두 개의 Mip 데이터는 스트리밍 가능하도록 저장된다.