본문 바로가기

STUDY/Unreal Engine

[ UE 5 ]Texture Streaming (1)

Texture Streaiming은 게임에서 필요한 텍스쳐를 load/Unload하는 기술입니다. 

필요한 텍스쳐만 로드하여 VRAM과 메모리 사용량을 최적화하고, 렌더링 성능을 향상시켜줍니다. 

UE의 Texture Streaming System은 Mapping 퀄리티를 낮춰 메모리 사용량을 줄일 수 있고, 더 세밀한 밉맵 레이어를 사용하기 때문에 Bandwidth를 줄이고 성능를 개선할 수 있습니다. UE 텍스처 스트리밍은 최적화를 위한 훌륭한 툴이지만, 최적화 과정에서 UE 텍스처 스트리밍을 제어하기 어렵고 기대에 미치지 못하는 경우가 많아서 더 나은 사용을 위해 UE 텍스처 스트리밍의 구현 메커니즘을 살펴보는 시간을 가졌습니다.

 

텍스처를 스트리밍하는 이유? 

텍스처를 축소할 때 다음 두 가지 문제를 해결하기 위해 밉맵을 사용합니다.

  1. 깜박임
    UV 좌표의 보간은 텍스처 요소가 차지하는 Render Target의 픽셀 수에 따라 결정됩니다. 예를 들어 UV 좌표 범위가 0에서 1까지이고 총 10픽셀에 걸쳐 있다면 FS의 각 픽셀에 해당하는 UV 좌표는 0, 0.1, 0.2 등이 되어야 합니다. 그러나 매핑의 텍스처가 축소되므로 픽셀은 하나 이상의 텍스처에 해당하며 부동 소수점 숫자의 정확도 때문에 실제 UV 좌표는 소수점 뒤에 유효한 자릿수가 여러 개인 소수점인 0.05, 0.13, 0.22일 수 있습니다(소수점 뒤에 유효한 실제 자릿수는 부동 소수점의 정밀도에 따라 결정되거나 반으로 줄어듭니다). 이로 인해 카메라가 움직일 때 깜박임이 발생할 수 있으며 화면의 동일한 픽셀이 다른 리플 요소를 샘플링할 수 있습니다.
  2. 성능 문제
    RenderTarget에서 겨우 몇 픽셀의 작은 범위를 차지하는 이미지를 원본으로 사용하게되면,  렌더링시 큰 텍스쳐를 GPU메모리에 로드하게 되어 Bandwidth 부하가 생기고, 캐시 적중이 떨어지게 됩니다.

프레임의 각 텍스쳐에 대해 어떤 밉맵 레이어를 스트리밍할지를 동적으로 계산하는 기술이 필요하며, 이것이 UE 텍스쳐 스트리밍 기술의 목적입니다.

UE 의 텍스처 스트리밍 공식 알고리즘 문서가 없기 때문에 코드에서 역추적할 수 있을 뿐입니다. 

일부 세부 사항은 완전히 파악되지 않았지만 요약하자면 이 알고리즘은 매우 러프하여 정확하지 않으며, 최적의 결과를 얻기 위해 파라미터를 조정해야 한다는 것입니다.


SamplingScale

- Texture 해상도가 클수록 Mipmap Level이 높아집니다.
- UV 타일 계산에서 타일 수의 변경만으로도 텍셀밀도에 영향을 미칠 수 있습니다.

 

▼ UV Tiling값 = SamplingScale값 에 따른 텍스쳐  매핑 결과물

위 UV (3.0, 3.0) , 아래 UV (6.0, 6.0)

같은 해상도와 같은 Mesh인 경우 UV Tiling값이 클수록 UV Density가 커집니다. 

따라서 Tiling값이 클수록 더 작은 Mipmap Level이, Tiling값이 작을수록 더 큰 Mipmap Level이 유지되어야 비슷한 수준의 Texel Density를 유지할 수 있겠죠. 

위와 같이 UV (1.0, 3.0) 인 경우에는 SamplingScale값이 1.0으로 전부 채워집니다. 
타일링 값이 작을수록 더 큰 mipmap수준이 유지되니, 보수적으로 선택하는 것이라 생각하면 됩니다.

UE에서는 이 값을 FMaterialTextureInfo::SamplingScale로 나타냅니다, Material의 Property이므로 Material이 변경될 때마다 다시 계산됩니다.

RenderMaterialTexCoordScales은 SamplingScale을 계산하기 위해 다음과 같이 함수를 호출합니다. 

-- UnrealEditor-MaterialUtilities.dll!FMaterialUtilities::ExportMaterialUVDensities(UMaterialInterface * InMaterial, EMaterialQualityLevel::Type QualityLevel, ERHIFeatureLevel::Type FeatureLevel, FMaterialUtilities::FExportErrorManager & OutErrors) 줄 2222    C++
- UnrealEditor-MaterialEditor.dll!FMaterialEditorUtilities::BuildTextureStreamingData(UMaterialInterface * UpdatedMaterial) 줄 798 C++
UnrealEditor-MaterialEditor.dll!FMaterialInstanceEditor::PreSavePackage(UPackage * Package, FObjectPreSaveContext ObjectSaveContext) 줄 1183 C++

 

Sampling Scale의 값은 Tiling값과 동일합니다. U,V 타일링 값이  각각 다른 경우에는 더 작은 타일을 선택합니다. SamplingScale(Tiling) 값이 더 작을수록 많은 밉맵수준이 유지됩니다. ( 더 텍스쳐가 확대되니까. ) 


UV Density

면적대비 Texture가 차지하는 면적이 높을 수록 더 큰 Mip Level이 필요하고 더 세밀하게 표현할 수 있습니다.

Mesh를 통해 Texture가 화면에 렌더링되기 때문에, 메쉬 모델의 크기는 화면의 Texel Density와 상관관계가 있습니다. 
따라서, 먼저 로컬좌표계에서 LocalUVDensity를 만들어줍니다. 

각 레이어의 LocalUVDensities는 uv 좌표계의 면적의 제곱근으로 로컬 좌표계의 각 삼각형의 면적으로 나뉩니다. 로컬 좌표계의 uv 밀도로 이해할 수 있습니다. ( * 기존의 TexelDensity는 높을수록 큰 MipLevel이 필요했다면 이의 경우 반대인듯 하다.. Vertex면적/UV면적으로 표현됨  )

 

UVDensities 의 계산과정 

FStaticMeshRenderData::Cache()에서 불리게 되어 StaticMeshRenderData가 기존 캐싱된 데이터와 달라질 때에만 호출됩니다. 
아래 FStaticMeshRenderData::ComputeUVDensities함수를 보면 밀도 누적 계산기인 FUVDensityAccumulator에 
 PushTriangle(Vertex면적, UV면적) 을 수행하여

Weight에 Sqrt(Vertex면적), UVDensity에 Sqrt(Vertex면적/UV면적)) 을 넣어줍니다. 
Weight는 가중치로 사용되어 면적이 더 큰 삼각형이 더 큰 Mip을 가지도록 해줍니다.
UVDensity는 UV밀도를 나타내며, 제곱근을 넣어주는 이유는 작은 면적과 큰 면적의 사이를 상대적으로 줄여주려는 목적으로 보입니다. 이렇게 되면 전체적인 UV밀도 계산에서 더 공평한 값을 얻을 수 있을 것 입니다.

▼ FStaticMeshRenderData::ComputeUVDensities함수 펼쳐보기 

더보기
void FStaticMeshRenderData::ComputeUVDensities()
{
#if WITH_EDITORONLY_DATA
    for (FStaticMeshLODResources& LODModel : LODResources)
    {
        const int32 NumTexCoords = FMath::Min<int32>(LODModel.GetNumTexCoords(), MAX_STATIC_TEXCOORDS);
 
        for (FStaticMeshSection& SectionInfo : LODModel.Sections)
        {
            FMemory::Memzero(SectionInfo.UVDensities);
            FMemory::Memzero(SectionInfo.Weights);
 
            FUVDensityAccumulator UVDensityAccs[MAX_STATIC_TEXCOORDS];
            for (int32 UVIndex = 0; UVIndex < NumTexCoords; ++UVIndex)
            {
                UVDensityAccs[UVIndex].Reserve(SectionInfo.NumTriangles);
            }
 
            FIndexArrayView IndexBuffer = LODModel.IndexBuffer.GetArrayView();
 
            for (uint32 TriangleIndex = 0; TriangleIndex < SectionInfo.NumTriangles; ++TriangleIndex)
            {
                const int32 Index0 = IndexBuffer[SectionInfo.FirstIndex + TriangleIndex * 3 + 0];
                const int32 Index1 = IndexBuffer[SectionInfo.FirstIndex + TriangleIndex * 3 + 1];
                const int32 Index2 = IndexBuffer[SectionInfo.FirstIndex + TriangleIndex * 3 + 2];
 
                const float Area = FUVDensityAccumulator::GetTriangleAera(
                                        LODModel.VertexBuffers.PositionVertexBuffer.VertexPosition(Index0),
                                        LODModel.VertexBuffers.PositionVertexBuffer.VertexPosition(Index1),
                                        LODModel.VertexBuffers.PositionVertexBuffer.VertexPosition(Index2));
 
                if (Area > UE_SMALL_NUMBER)
                {
                    for (int32 UVIndex = 0; UVIndex < NumTexCoords; ++UVIndex)
                    {
                        const float UVArea = FUVDensityAccumulator::GetUVChannelAera(
                                                FVector2D(LODModel.VertexBuffers.StaticMeshVertexBuffer.GetVertexUV(Index0, UVIndex)),
                                                FVector2D(LODModel.VertexBuffers.StaticMeshVertexBuffer.GetVertexUV(Index1, UVIndex)),
                                                FVector2D(LODModel.VertexBuffers.StaticMeshVertexBuffer.GetVertexUV(Index2, UVIndex)));
 
                        UVDensityAccs[UVIndex].PushTriangle(Aera, UVArea);
                    }
                }
            }
 
            for (int32 UVIndex = 0; UVIndex < NumTexCoords; ++UVIndex)
            {
                float WeightedUVDensity = 0;
                float Weight = 0;
                UVDensityAccs[UVIndex].AccumulateDensity(WeightedUVDensity, Weight);
 
                if (Weight > UE_SMALL_NUMBER)
                {
                    SectionInfo.UVDensities[UVIndex] = WeightedUVDensity / Weight;
                    SectionInfo.Weights[UVIndex] = Weight;
                }
            }
        }
    }
#endif // WITH_EDITORONLY_DATA
}
 
struct FUVDensityAccumulator
{
private:
 
    struct FElementInfo
    {
        float Weight;
        float UVDensity;
        FElementInfo(float InWeight, float InUVDensity) : Weight(InWeight), UVDensity(InUVDensity) {}
    };
 
    TArray<FElementInfo> Elements;
 
public:
 
    void Reserve(int32 Size) { Elements.Reserve(Size); }
 
    void PushTriangle(float InAera, float InUVArea)
    {
        if (InAera > UE_SMALL_NUMBER && InUVArea > UE_SMALL_NUMBER)
        {
            Elements.Add(FElementInfo(FMath::Sqrt(InAera), FMath::Sqrt(InAera / InUVArea)));
        }
    }
 
    ...
}

 

그런다음 모든 LODModel의 LOD Section에 대해 UVIndex별 Weight와  WeightedUVDensities(UVDensities * Weights) 를 모두 더한 뒤,  WeightedUVDensities/Weight 를 수행해 LocalUVDensities를 구해줍니다. 

코드를 간단히 정리하면 다음과 같습니다. 

for( LODModel : LODModels )  // 모든 LODModel의 LOD Section에 대해
{
       for( Section : LODModel.Sections )
       {
               for( UVIndex : UVNum)
              {
                     // UVIndex별 Weight와  WeightedUVDensities(UVDensities * Weights) 를 모두 더한 뒤

                     WeightedUVDensity[UVIndex] += Section.UVDensity[UVIndex] * Section.Weight[UVIndex];
                     Weight[UVIndex] += Section.Weight[UVIndex];
              }
       }
}

for(UVIndex : UV_MAX) { LocalUVDensities[UVIndex] = WeightedUVDensity[UVIndex] / Weight[UVIndex]; }


전체코드는 아래에 있어요 

▼ UStaticMesh::UpdateUVChannelData함수 펼쳐보기 

더보기
void UStaticMesh::UpdateUVChannelData(bool bRebuildAll)
{
#if WITH_EDITORONLY_DATA
    TRACE_CPUPROFILER_EVENT_SCOPE(UStaticMesh::UpdateUVChannelData);
 
    // 한번 쿠킹되면, Scales를 계산하는데 필요한 데이터는 CPU에 액세스 할 수 없게 됩니다.
    if (FPlatformProperties::HasEditorOnlyData() && GetRenderData())
    {
        bool bDensityChanged = false;
 
        for (int32 MaterialIndex = 0; MaterialIndex < GetStaticMaterials().Num(); ++MaterialIndex)
        {
            FMeshUVChannelInfo& UVChannelData = GetStaticMaterials()[MaterialIndex].UVChannelData;
 
            float WeightedUVDensities[TEXSTREAM_MAX_NUM_UVCHANNELS] = {0, 0, 0, 0};
            float Weights[TEXSTREAM_MAX_NUM_UVCHANNELS] = {0, 0, 0, 0};
 
            //이 Material Index를 사용해서 모든 LOD-Section을 파싱합니다.
            for (const FStaticMeshLODResources& LODModel : GetRenderData()->LODResources)
            {
                const int32 NumTexCoords = FMath::Min<int32>(LODModel.GetNumTexCoords(), TEXSTREAM_MAX_NUM_UVCHANNELS);
                for (const FStaticMeshSection& SectionInfo : LODModel.Sections)
                {
                    if (SectionInfo.MaterialIndex == MaterialIndex)
                    {
                        for (int32 UVIndex = 0; UVIndex < NumTexCoords; ++UVIndex)
                        {
                            WeightedUVDensities[UVIndex] += SectionInfo.UVDensities[UVIndex] * SectionInfo.Weights[UVIndex];
                            Weights[UVIndex] += SectionInfo.Weights[UVIndex];
                        }
 
                        // 업데이트 해야 할 것이 있다면, lightmap 밀도들도 합께 업데이트 하십시오.
                        bDensityChanged = true;
                    }
                }
            }
 
            UVChannelData.bInitialized = true;
            UVChannelData.bOverrideDensities = false;
            for (int32 UVIndex = 0; UVIndex < TEXSTREAM_MAX_NUM_UVCHANNELS; ++UVIndex)
            {
                UVChannelData.LocalUVDensities[UVIndex] = (Weights[UVIndex] > UE_SMALL_NUMBER) ? (WeightedUVDensities[UVIndex] / Weights[UVIndex]) : 0;
            }
        }
 		// 변경된 경우 
        if (bDensityChanged || bRebuildAll)
        {
            SetLightmapUVDensity(GetUVDensity(GetRenderData()->LODResources, GetLightMapCoordinateIndex()));
 
            // 비동기 Static mesh 컴파일 중에 잠재적으로 모든 스레드에서 실행될 수 있습니다.
            if (GEngine)
            {
                if (IsInGameThread())
                {
                    GEngine->TriggerStreamingDataRebuild();
                }
                else
                {
                    // Task 그래프에서 작업을 실행할 때 GEngine이 null일 수 있습니다.
                    Async(EAsyncExecution::TaskGraphMainThread, []() { if (GEngine) { GEngine->TriggerStreamingDataRebuild(); } });
                }
            }
        }
 
        // renderthread debug viewmodes에 대한 데이터 업데이트
        GetRenderData()->SyncUVChannelData(GetStaticMaterials());
    }
#endif
}

 

Local UVDensities는 SamplingScale( Tiling 값 ) 로 나누어 새로운 UVDensities를 얻습니다. 
Tiling 값이 클 수록 단위 면적당 텍스쳐 밀도가 낮아지고 필요한 밉맵이 적어지므로 SamplingScale로 나누어줍니다.

그리고 가장 큰 LocalUVDensity를 찾아 TextureDensity로 사용합니다. 

▼ UMaterialInterface::GetTextureDensity함수 펼쳐보기 

더보기
float UMaterialInterface::GetTextureDensity(FName TextureName, const FMeshUVChannelInfo& UVChannelData) const
{
    int32 LowerIndex = INDEX_NONE;
    int32 HigherIndex = INDEX_NONE;
    if (FindTextureStreamingDataIndexRange(TextureName, LowerIndex, HigherIndex))
    {
        // 최대값을 계산해 줍니다.
        float MaxDensity = 0;
        for (int32 Index = LowerIndex; Index <= HigherIndex; ++Index)
        {
            const FMaterialTextureInfo& MatchingData = TextureStreamingData[Index];
            ensure(MatchingData.IsValid() && MatchingData.TextureName == TextureName);
            MaxDensity = FMath::Max<float>(UVChannelData.LocalUVDensities[MatchingData.UVChannelIndex] / MatchingData.SamplingScale, MaxDensity);
        }
        return MaxDensity;
    }
    return 0;
}


Mesh를 로컬 좌표계에서 월드 좌표계로 변환하면 TrasformScale이 곱해집니다. TransformScale이 클수록 픽셀밀도가 커지고 그에 비례하여 필요한 밉맵 레이어 수가 증가합니다.
Info.TexelFactor = TextureDensity * ComponentScaling;

 

이 값은 매번 계산해줄 필요가 없으므로 FStreamingRenderAssetPrimitiveInfo::FElement::TexelFactor에 저장됩니다.

StaticMesh나 Material이 변경되는 경우 다시 계산해줍니다. 

 


실제로 값을 사용하는 부분은 다음 포스팅에 있습니다. 

'STUDY > Unreal Engine' 카테고리의 다른 글

[ UE 5 ]Texture Streaming (2)  (0) 2023.04.02