ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [UE5] MeshDrawCommand (1/2)
    UE4 & UE5/Rendering 2021. 6. 20. 11:58

    [UE5] MeshDrawCommand (1/2)


    최초 작성 : 2021-06-20
    마지막 수정 : 2021-06-20
    최재호

     

    1. 환경

    Unreal Engine 5 Early access

    UE4.26.2 코드와 거의 유사하므로 UE4에 익숙하시면 보시는데 문제가 없을 것 같습니다.
    일부 코드는 줄 바꿈 되거나 접은 상태로 중요한 코드를 위주로 설명하니 보실 때 참고해주세요.
    또한 코드를 직접 붙여넣어서 진행했기 때문에 작아서 잘 보이지 않는 코드는 클릭하여 봐주세요.

    필요한 사전지식
    1. NamedThread (TaskGraphSystem에 대한 이해가 있으면 더 좋음)
    2. Actor와 ActorComponent의 관계

    * 설명하는 문맥이 게임스레드로 변경되면 [게임스레드] 렌더스레드로 변경되면 [렌더스레드] 를 넣을 예정입니다.
    * 이 글에서 StaticMesh, DynamicMesh가 등장하는데, Relevance에 따라 어떤 객체든 StaticMesh, DynamicMesh가 될 수 있습니다. 하지만 설명을 간단하게 하기 위해서 StaticMesh == UStaticMeshComponent, DynamicMesh == USkeletalMeshComponent로 두고 설명합니다.
    * 코드에는 설명에 대응하는 1, 2, 3 등의 번호가 매겨져 있으니 참고해주세요.
    * 개인적인 공부 용도로 분석한 내용이라 틀린 내용이 있을 수 있습니다. 그런 부분은 지적 부탁드립니다.

    2. 목표

    1. UPrimitiveComponent 에서 렌더스레드에서 최종적으로 렌더링 커맨드를 호출하는 FMeshDrawCommand의 생성과정을 알아봅니다.
    2. FMeshDrawCommand는 StaticMesh냐 DynamicMesh냐 여부에 따라서 캐싱해놓고 반복해서 사용되는지 매 프레임 새로 만들어지는지 결정됩니다. 실제로 어떻게 그렇게 구현되는지 확인해봅시다.
    3. 렌더스레드는 UPrimitiveComponent와 대응하는 렌더링에 필요한 모든 자료를 관리합니다. 그래서 게임스레드에서 UPrimitiveComponent가 소멸되더라도 이와 대응되는 리소스들이 렌더스레드에서 즉시 소멸되지 않습니다. 이 부분이 어떻게 가능한지도 코드를 분석하며 알아봅시다.
    4. 주요 목표는 StaticMesh와 DynamicMesh가 어떻게 FMeshDrawCommand를 생성하고 렌더링 하는지 알아보는 것입니다. 그래서 그 외의 사항(쉐이더가 어떻게 컴파일되는지와 같은 기타 사항)은 다음에 다뤄볼 예정입니다.


    이 글은 분량이 많아서 2개로 쪼개 집니다.
    MeshDrawCommand (1/2) 에서는 StaticMesh가 아래 과정대로 변화는 과정을 알아볼 것입니다.
    UPrimitivieComponent → FPrimitiveSceneProxy/FPrimitiveSceneInfo → FMeshBatch → MeshDrawCommand(캐싱)

    다음 글 MeshDrawCommand (2/2)에서는 아래의 과정을 알아봅니다.
    1. DynamicMesh에서 FMeshBatch와 FMeshDrawCommand를 생성
    2. 렌더링 될 FMeshDrawCommand(Static, Dynamic 모두)를 모으기
    3. 전 과정에 모은 FMeshDrawCommand를 렌더링

    3. 내용

    렌더링을 하기 위해서 우리는 UPrimitiveComponent 에서 FMeshDrawCommand를 만들어줘야 합니다. 그렇게 하기 위한 과정은 아래와 같습니다.
    UPrimitivieComponent → FPrimitiveSceneProxy/FPrimitiveSceneInfo → FMeshBatch → MeshDrawCommand

    그림1

    3.1. UPrimitiveComponent와 FPrimitiveSceneProxy/FPrimitiveSceneInfo 의 생성

    [게임스레드]
    UActorComponent가 생성되면서 UActorComponent::ExecuteRegisterEvents 함수가 호출됩니다.
    그리고 UPrimitiveComponent는 렌더스레드에서 사용할 Primitive 를 생성하여 FScene에 추가합니다. 이 Primitive가 바로 FPrimitiveSceneProxy, FPrimitiveSceneInfo 입니다. 이 객체의 생성은 게임스레드에서 하고 소멸은 렌더스레드(그림20 참고)에서 처리합니다. 이러한 처리가 렌더링 도중 게임스레드에서 UPrimitiveComponent가 소멸될때 렌더스레드에 미러링 된 내용들이 즉시 소멸되지 않도록 해줍니다. 만약 렌더스레드가 렌더링하는 중간에 게임스레드에서 렌더스레드에 미러링된 내용을 제거해버리면 크래시가 나거나 이상한 결과가 출력될 것입니다.

    그림2.

    AddPrimitive 함수에서는 FPrimitiveSceneProxy/Info을 생성하고 FScene에 등록합니다. 그림3를 봐주세요. 여기서 실제로 Transform 정보도 생성하고 전달하는데 지금은 무시합니다. 우리의 목표는 FDrawMeshCommand를 캐싱하고 렌더링하는 것에 중점을 두므로 나머지는 다음에 알아보도록 합니다.
    그림 2의 ENQUEUE_RENDER_COMMAND 는 렌더스레드에서 실행될 Lambda 함수를 받아서 렌더스레드에서 실행하도록 해줍니다.
    1. FPrimitiveSceneProxy와 FPrimitiveSceneInfo를 생성하고 서로 상호참조 합니다.
    [렌더스레드] - 이후 모든 과정은 렌더스레드에서 진행
    2. FPrimitiveSceneProxy를 사용하여 렌더링에 필요한 리소스를 생성합니다. 그리고 FScene에 FPrimitiveSceneInfo를 등록하기 위해서 AddPrimitiveSceneInfo_RenderThread를 호출합니다.

    그림3.


    AddPrimitiveSceneInfo_RenderThread는 _RenderThread라는 이름이 붙어있는데, 언리얼에서 이런 이름은 렌더스레드에서 호출되길 기대한다는 의미입니다.

    이 함수는 간단히 AddedPrimitiveSceneInfos에 새로 생성된 FPrimitiveSceneInfo를 등록하고 끝냅니다. AddedPrimitiveSceneInfos는 등록 예정 중인 FPrimitiveSceneInfo의 Queue라고 생각하면 됩니다. 그렇다면 실제 등록은 다른 곳에서 이루어진다는 것인데 어디인지 계속해서 알아봅시다.

    그림4.

    FPrimitiveSceneInfo의 등록은 FScene::UpdateAllPrimitiveSceneInfos 이 함수에서 처리합니다. 이 함수는 모든 렌더링이 시작되는 FDeferredShadingSceneRenderer::Render 함수가 호출되기 전 항상 먼저 불립니다. 이 함수는 추가 예정, 삭제 예정, 수정 예정인 FPrimitiveSceneInfo들을 처리합니다.

    1. 추가된 FPrimitiveSceneInfo 를 로컬 배열에 담아 정렬한 후 사용할 준비를 합니다.
    2. FScene의 Primitives에 FScnenePrimitiveInfo를 넣어주고 있습니다. 그리고 Primitives에 대응하는 바운드박스나 기타 정보를 설정하는 것을 볼 수 있습니다. 이렇게 여러 배열에 나눠서 정보를 두는 이유는 캐시효율을 위해입니다. 예를 들어 프러스텀 컬링을 하는도중에는 바운드박스 정보 가장 많이 필요로 할 것입니다. 이때 캐시에 다른 메모리를 최대한 올리지 않도록 바운드 박스로 이루어진 배열을 별도로 만들어두는 것입니다.

    그림5.

    FScene::UpdateAllPrimitiveSceneInfos 함수에서 FPrimitiveSceneInfo의 추가에 가장 중요한 부분은 FPrimitiveSceneInfo::AddToScene 입니다. 이 함수는 실제 FScene의 Primitives, PrimitiveBounds 등등 위에서 캐시 효율을 위해 만들어둔 컨테이너에 실제로 값을 채우는 과정을 합니다.

    그림6. 

    이 단계까지 마치면 FPrimitiveSceneInfo의 등록은 잘 끝납니다.

    FPrimitiveSceneInfo::AddToScene 과정에서 Static과 Dynamic 여부에 따라 처리가 조금 달라지는 부분을 봅시다. 바로 여기서 FStaticMeshBatch(FMeshBatch)와 FMeshDrawCommand를 생성합니다.

    다시 방금 전 본 FPrimitiveSceneInfo::AddToScene로 갑니다. 그 안에서 AddStaticMeshes 함수를 호출하는데요. 이 함수에서 이름 그대로 StaticMesh에 대해 FMeshBatch와 FMeshDrawCommand를 만들어 줍니다. 그리고 이 두 가지 정보는 변경이나 소멸이 되기 전까지는 계속 캐싱해둡니다.
    반면에 DynamicMesh의 경우는 FMeshBatch와 FMeshDrawCommand를 매 프레임 새로 만들어두기 때문에 FMeshBatch나 FMeshDrawCommand를 매프레임 다시 만들어 사용합니다. 이것은 StaticMesh의 설명이 끝난 후에 다시 알아봅시다. 여기서는 StaticMesh에 집중합니다.
    (StaticMesh는 대표적으로 UStaticMeshComponent가 있고, DynamicMesh는 USkeletalMeshComponent가 있습니다. 저는 이 두 가지 컴포넌트를 대상으로 설명을 진행합니다.)

    그림7. 


    AddStaticMeshes 함수 내에는 크게 3개의 단락이 있습니다.
    [1]. FStaticMeshBatch(FMeshBatch)를 생성하고 FPrimitiveScneneInfo의 StaticMeshes에 캐싱
    [2]. 전 단계에서 만든 FMeshBatch를 FScene의 StaticMeshes 넣고 이 배열에서의 인덱스를 FMeshBatch와 FStaticMeshBatchRelevance의 Id 멤버에 등록
    [3]. CachedMeshDrawCommands 함수를 호출하여 FMeshDrawCommand 또한 FScene에 캐싱

    이제 위의 내용을 하나씩 뜯어봅시다.
    [1] FStaticMeshBatch(FMeshBatch)를 생성하고 FPrimitiveScneneInfo의 StaticMeshes에 캐싱
    시작하기 전 FMeshBatch의 구조를 한번 보도록 합니다. FMeshBatch는 주석의 설명 그대로 같은 머터리얼과 버택스 버퍼를 사용하는 메시 요소들을 갖고 있는 객체입니다.
    TArray의 Allocator로 TInlineAllocator<1>이 있는데요. 보통은 FMeshBatchElement가 1개 할당되므로 1로 한 것입니다. 이렇게 되면 Allocator는 1개짜리 배열을 만들어 Element 1개까지는 미리 할당해둔 메모리를 사용합니다. Element가 2개 이상되면 그때부터 새로 메모리를 할당하도록 동작합니다. 자주 사용될 배열이 주로 몇 개의 Element를 사용하게 될 것이라는 것을 알 때 사용하면 좋은 Allocator입니다.

    그림8.&nbsp;

    그렇다면 FMeshBatchElement는 이 메시의 서브 메시를 렌더링 할 수 있는 정보들이 있을 것이라는 것을 예상할 수 있습니다. 아래 그림처럼 IndexBuffer와 렌더링 해야 되는 프리미티브 수, 인스턴스 수 등등이 있는 것을 알 수 있습니다. 이 정보로 버택스 버퍼에 있는 메시들 중 원하는 섹션을 렌더링 할 수 있습니다.

    그림9.

    아래 그림 10 과정은 호흡이 조금 긴데요. FBatchingSPDI와 FPrimitveSceneProxy의 DrawStaticElements를 사용하여 FStaticMeshBatch를 FPrimitiveSceneInfo에 캐싱하는 과정입니다. (그림10의 9. PDI->DrawMesh 그리고 그림11 과정에서 FPrimitiveSceneInfo->StaticMeshes에 FStaticMeshBatch 캐싱)
    그림11을 보면 FStaticMeshBatchRelevance 또한 FPrimitiveSceneInfo에 캐싱합니다. FStaticMeshBatchRelevance 이것은 InitViews 과정에서 캐시 효율을 위해 FStaticMeshBatch에서 필요한 프로퍼티를 모아둔 구조라고 보면 됩니다. 이런 것이 있다 정도만 보고 계속 진행하면 됩니다.

    그림10

     

    그림11.

    [2]. 전 단계에서 만든 FMeshBatch를 FScene의 StaticMeshes 넣고 이 배열에서의 인덱스를 FMeshBatch와 FStaticMeshBatchRelevance의 Id 멤버에 등록
    FScene도 StaticMeshes라는 배열이 있으며 여기에 추가해줍니다. 추가 후 FScene->StaticMeshes의 인덱스를 FMeshBatch와 FStaticMeshBatchRelevance의 멤버 변수 Id에 넣어줍니다.
    이제 이 Id만 있으면 FScene에서 FStaticMeshBatch나 FStaticMeshBatchRelevance를 즉시 찾을 수 있습니다. 또한 FPrimitiveSceneInfo는 자신의 StaticMeshes에 FStaticMeshBatch들을 갖고 있기 때문에 PrimitiveSceneInfo도 역시 FScene에 캐싱된 FStaticMeshBatch를 찾을 수 있을 것입니다.

    그림12.

    [3]. CachedMeshDrawCommands 함수를 호출하여 FMeshDrawCommand 또한 FScene에 캐싱
    세 번째로 FMeshDrawCommand를 캐싱하는 부분입니다.
    1. FMeshDrawCommand는 모든 호한 되는 Pass에 대해 생성합니다.
    2. StaticMesh의 경우 FMeshDrawCommand를 캐싱하기 때문에 Pass가 EMeshPassFlags::CachedMeshCommands 지원해야 합니다.
    3. FCachedMeshDrawCommandInfo는 FPrimitiveSceneInfo->StaticMeshCommandInfos 배열에 추가되며 FPrimitiveSceneInfo가 FScene에 캐싱된 FMeshDrawCommand를 찾을 때 사용합니다.
    4. FMeshDrawCommand 생성에 필요한 변수들을 모아서 Context를 만들어줍니다. 생성 도중 여러 구조로부터 필요한 변수를 찾지 않고 바로 Context에서 필요한 변수를 찾을 수 있다는 장점이 있습니다.

    1. FScene 캐싱된 FMeshDrawCommand를 위해서 아래와 같은 컨테이너가 있습니다.
      1. CachedMeshDrawCommandLock[EMeshPass::Num] : 크리티컬 섹션
      2. CachedDrawLists[EMeshPass::Num] : FMeshDrawCommand 보관
        1. GPUScene을 사용하게 되면 CachedDrawLists 대신 CachedMeshDrawCommandStateBuckets를 사용하게 되는데, 분석범위를 줄이기 위해서 우리는 GPUScene을 사용하지 않다고 가정
      3. FCachedPassMeshDrawListContext에 위에서 나온 컨테이너의 레퍼런스를 모두 넘겨서 Context를 완성합니다.

    5. 현재 Pass에 해당하는 FMeshDrawCommand를 만들어줄 수 있는 FMeshPassProcessor를 생성합니다. 이 객체는 FMeshDrawCommand 생성하는 로직을 담고 있는 클래스로 볼 수 있으며, 이 로직이 담겨있는 클래스와 데이터를 담고 있는 Context를 사용하여 FMeshDrawCommand를 만들어냅니다. 또한 FMeshPassProcessor는 각 Pass 별로 하나씩 존재합니다.

    1. Pass별 FMeshPassProcessor 생성하는 함수는 엔진이 초기화될 때 static 변수인 FRegisterPassProcessorCreateeFunction 함수를 통해서 함수 포인터가 등록됩니다. 이 부분은 흐름을 이해하는 큰 영향을 미치지 않으므로 이 정도만 확인합니다. 그림14 참고.
    2. 여러 종류의 FMeshPassProcessor 중 FDepthPassMeshProcessor를 타겟으로 잡고 진행해봅시다.

    6. 실제로 FMeshDrawCommand를 생성해주는 부분입니다. 이 부분이 가장 중요하므로 아래 부분에서 계속해서 추적해봅시다.
    7. AddMeshBatch를 통해 생성된 FCachedMeshDrawCommandInfo 를 FPrimitiveSceneInfo에 캐싱합니다.

    그림13.
    그림14.

    1. 이 Pass에서 렌더링 될 FMeshBatch인지 확인하고 그려진다면 FMeshDrawCommand 생성을 계속해서 진행합니다.
    2. 사용될 Meterial 종류에 따라서 분기하여 Process를 실행합니다.

    그림15.

    계속해서 Process함수에서는 필요한 Shader들을 결정하고, BuildMeshDrawCommands 함수를 호출합니다.

    그림16.

    BuildMeshDrawCommands는 크게 두 부분으로 나뉘는데요.
    SharedMeshDrawCommand를 만드는 부분과 SharedMeshDrawCommand를 템플릿으로 하여 각각의 FMeshBatchElement의 데이터를 설정하여 FMeshDrawCommand를 FScene에 실제로 캐싱하는 부분입니다.

    SharedMeshDrawCommand를 만드는 이유는 FMeshBatch가 같은 머터리얼과 VertexBuffer를 공유하는 FMeshBatchElement들을 갖고 있다는 것을 기억하면 이유를 알 수 있습니다. 머터리얼과 연관된 PipelineState들과 VertexBuffer와 그에 따른 PrimitiveType등을 먼저 설정하고 이 설정한 것을 각 FMeshBatchElement를 생성 시 재활용하겠다는 의미입니다.

    1. SharedMeshDrawCommand를 생성
    2. PipelineState를 생성하고, 아래 과정에서 내용을 채웁니다. 그리고 SharedMeshDrawCommand에 설정
    3. VertexDeclaration 정보를 가져와 SharedMeshDrawCommand에 설정
    4. VertexStream을 SharedMeshDrawCommand에 설정
    5. 쉐이더 별로 바인딩할 데이터를 바인딩합니다.

    그림17.

    1. 여기서 위에서 생성한 SharedMeshDrawCommand에 추가 데이터를 더 붙일 수 있도록 준비합니다.
    2. FMeshBatchElement 에서 추가로 쉐이더 별로 바인딩할 데이터를 설정해줍니다.
    3. FinalizeCommand 함수에서 최종적으로 FMeshDrawCommand에 FMeshBatchElement에 대한 정보를 채웁니다. 예상해보면 FMeshDrawCommand의 IndexBuffer와 렌더링 할 Primitive 수 등의 정보를 FMeshDrawCommand에 넣어줄 것 같네요.

    그림18.

    계속해서 DrawListContext->FinalizeCommand 함수는 아래와 같습니다.
    1. SetDrawParametersAndFinalize 함수에서는 IndexBuffer와 렌더링할 Primitive와 Instance 수를 설정합니다. 이 내용들은 실제 DrawIndexed와 같은 Graphics API를 Draw함수를 호출할 때 사용됩니다.
    2. 1과 동일한 내용
    3. 이제 완성한 FMeshDrawCommand를 FScene의 CachedDrawLists 에 넣어줍니다.
    4. FCachedMeshDrawCommandInfo 정보를 채웁니다. 위에서도 설명했듯이 이 데이터는 FPrimitiveSceneInfo가 FScene 캐싱된 자신의 FDrawMeshCommand에 접근할 수 있는 정보들을 담고 있습니다.

    그림19.

    지금까지 StaticMesh의 FMeshBatch와 각 Pass별로 FMeshBatch를 사용해 FMeshDrawCommand를 생성한 후 그 정보들을 FPrimitiveSceneInfo와 FScene에 캐싱하였습니다.

    이제 렌더링 시에는 FScene에 캐싱된 FMeshDrawCommand에 대해 가시성 결정만 한 다음 바로 FMeshDrawCommand를 렌더링 할 때 사용하게 됩니다.
    캐싱된 데이터는 FPrimitiveSceneProxy가 수정되거나 새로 만들어지는 경우가 아니면 그대로 유지될 것입니다.

    여기까지 본 것으로 우리가 다시 되돌아볼 점은 다음과 같습니다.
    1. UPrimitiveComponent는 게임스레드에서 관리하는 데이터 FPrimitiveSceneProxy/FPrimitiveSceneInfo는 렌더스레드에 관리하는 데이터입니다.
    2. UPrimitiveComponent로부터 추가 삭제된 FPrimitiveSceneProxy/FPrimitiveSceneInfo는 즉시 삭제되지 않고 렌더링스레드에서 FScene::UpdateAllPrimitiveSceneInfos를 호출할 때 한 번에 처리한다. FScene::UpdateAllPrimitiveSceneInfos는 Render 함수를 호출하기 전에 실행하여 렌더링 할 준비를 마칩니다.
    3. FPrimitiveSceneProxy/FPrimitiveSceneInfo 생성은 게임스레드에서 소멸은 렌더스레드에서 책임집니다. (소멸 부분은 글 맨 하단의 그림20 참고)
    4. 2, 3번의 이유로 렌더링 중인 FPrimitiveSceneProxy/FPrimitiveSceneInfo에 대응하는 UPrimitiveComponent가 제거되더라도 기존에 렌더링 중인 리소스들이 깨지지 않습니다. 그 이유는 렌더링에 필요한 리소스는 FPrimitiveSceneProxy/FPrimitiveSceneInfo가 갖고 있으며 소멸에 대한 책임은 렌더스레드가 가지고 있기 때문입니다.
    5. StaticMesh의 경우 CachedDrawLists에 FMeshDrawCommand를 캐싱해두며, 가시성 검사만 한 뒤 이미 만들어둔 FMeshDrawCommand를 그대로 렌더링 하는 데 사용합니다.
    6. DynamicMesh의 경우는 별도의 캐싱 과정이 없었기 때문에 매 프레임 FMeshBatch, FMeshDrawCommand를 새로 만들 것으로 보입니다.

    추가 이미지

    그림20.

    다음글 [UE5] MeshDrawCommand (2/2)

     

    4. 레퍼런스

    1. https://github.com/EpicGames/UnrealEngine/tree/ue5-early-access

    'UE4 & UE5 > Rendering' 카테고리의 다른 글

    [UE5] Shader [1/3] - 주요 클래스 파악  (8) 2021.09.03
    [UE5] MeshDrawCommand (2/2)  (3) 2021.06.20
    [UE4]Geometry Shader  (0) 2020.04.05
    [UE4]Compute Shader  (2) 2020.03.20
    [UE4]Global Shader  (0) 2020.03.17

    댓글

Designed by Tistory & scahp.