ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [UE5] GPUScene 과 Auto-Instancing (2/2)
    UE4 & UE5/Rendering 2022. 2. 17. 08:00

    [UE5] GPUScene 과 Auto-Instancing

     

    최초 작성 : 2022-02-17
    마지막 수정 : 2022-02-17
    최재호

     

    목차

    1. 환경
    2. 목표
    3. 내용

     3.1. GPUScene을 사용하는 경우 PrimitiveId를 얻는 과정
      3.2. GPUScene의 InstanceSceneData 준비
       3.2.1. GPUScene의 InstanceSceneData 업로드 예약
       3.2.2. GPUScene의 InstanceSceneDataBuffer에 InstanceSceneData를 업로드
      3.3. InstanceIdOffset 의 준비
       3.3.1. Auto-Instancing을 위해 MeshDrawCommand 병합
      3.4. InstanceIdOffsetBuffer와 InstanceIdsBuffer 의 준비

       3.4.1. InstanceIdOffsetBuffer와 InstanceIdsBuffer 의 생성을 위한 Compute Shader 준비

      3.5. 실제 렌더링 시 버퍼들의 사용되는 과정

    4. 레퍼런스

     

    1. 환경

    Unreal Engine 5 (ue5-main branch acc8c5f399ca01f6f549108be1fb75381fecbca8)

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

    개인적으로 분석한 내용이라 틀린 점이 있을 수 있습니다. 그런 부분은 알려주시면 감사하겠습니다.

    필요한 사전 지식
    Graphic API 기본 이해
    Shader 기본 이해

    MeshDrawCommand 의 이해

     

    아래 내용을 먼저 읽고 이해한 뒤 보면 더욱 좋습니다.

    [UE5] MeshDrawCommand (1/2)

    [UE5] Shader Compile [2/3] - 쉐이더 컴파일 과정

    [UE5] GPUScene 과 Auto-Instancing (1/2)

     

    이번글은 한번에 많은 길이의 코드를 분석하는 부분이 종종 등장합니다. 그래서 글을 2개 띄우고 한쪽은 설명 부분을 한쪽은 코드 이미지를 최대화 해서 보는 것을 추천합니다.

     

    2. 목표

    UE5의 오토인스턴싱이 어떻게 구현되어있는지 확인해봅시다. 

     

    오토인스턴싱은 StaticMesh에만 적용됩니다. 화면내에 동일한 StaticMesh가 여러개 렌더링되면 인스턴싱 렌더링 할 수 있도록 MeshDrawCommand를 구성해줍니다.

     

    이전 글에서 GPUScene을 사용하는 경우 PrimitiveData를 얻어오는 부분을 보면, PrimitiveId를 이미 어디선가 얻어왔다고 가정하고 설명하고 있습니다. 이 글에서는 GPUScene을 사용할 때 어떻게 PrimitiveId를 얻어올 수 있는지에 대해서 먼저 알아볼 것입니다. 그리고나서 Auto-Instancing에 대해서 알아봅시다.

     

    1. 언리얼에서는 이런 Primitive 전용 데이터를 어떻게 관리하는지 알아봅시다.

    2. 렌더링 될 수 있는 모든 Primitive 의 전용 데이터의 한 개의 버퍼에 모두 넣어서 사용하는 방식인 GPUScene을 알아봅시다.

    3. 언리얼의 Auto-Instancing 기능을 알아봅시다.

     

    3. 내용

    3.1. GPUScene을 사용하는 경우 PrimitiveId를 얻는 과정

    Auto-Instancing에 대해서 알아보기 전에 GPUScene을 사용하여 PrimitiveData를 얻어오는 과정에 대해서 조금 더 알아봅시다. 이전글에서는 GPUScene을 사용하는 경우 이미 PrimitiveId가 어디선가 구해져있다는 것을 가정하고 코드를 봤습니다. 이글에서는 GPUScene이 어떻게 PrimitiveId를 얻어오는지를 확인해봅시다.

     

    이전글의 기억을 되살림겸 다시 한번 BasePassVertexShader.usf 에서 PrimitiveData를 얻는 부분까지 봅시다.

    1. FVertexFactoryInput 입니다. 버택스 인풋과정에서 받은 Attributes를 갖고 있습니다.

    2. GetVertexFactoryIntermediates() 함수에서 PrimitiveData를 얻습니다. 계속해서 이 함수를 따라가 봅시다.

    3. 함수 내부에서는 VF_GPUSCENE_GET_INTERMEDIATES(Input) 매크로를 통해서 SceneData를 얻어옵니다. SceneData에 PrimitiveData가 포함되어있습니다.

    4. VF_USE_PRIMITIVE_SCENE_DATA == 1 입니다. (그림2와 그림3의 내용에서 확인 가능)

    5. 매크로 VF_GPU_SCENE_GET_INTERMEDIATES는 GetSceneDataIntermediates()를 호출하고 파라메터로 InstanceIdOffset과 DrawInstanceId를 같이 넘깁니다.  DrawInstanceId는 인스턴스를 사용할 때 해당 인스턴스의 인덱스인 것 처럼 보이고, InstanceIdOffset이 뭔지는 계속 확인해봅시다.

    6. GetSceneDataIntermediates() 함수 내부에서는 InstanceCulling.InstanceIdsBuffer 의 (InstanceIdOffset + DrawInstanceId) 위치에 있는 값을 가져옵니다. 그리고 이 값이 바로 InstanceId 입니다.

    7. InstanceId를 GetInstanceSceneData() 함수의 인자로 사용하여 Intermediates.InstanceData를 채워줍니다. 함수명으로 봐서는 아마도 InstanceSceneData 역시 PrimitiveSceneData 처럼 단일 버퍼에 모든 InstanceSceneData를 담아서 바인딩 하는 것으로 보입니다.

    8. 바로 전과정에서 얻어온 InstanceData에는 PrimitiveId 가 있습니다. 이 값을 얻어와서 PrimitiveData를 최종적으로 얻습니다.

    그림1. GPUScene에서 PrimitiveData를 얻는 과정

    아래 그림은 VF_USE_PRIMITIVE_SCENE_DATA 매크로가 정의되는 과정과 그에 따라 FVertexFactoryInput에 입력으로 받는 Attributes 가 추가되는 것을 볼 수 있습니다.

    1. VF_SUPPORTS_PRIMITIVE_SCENE_DATA 는 GPUScene을 사용하고 PrimitiveIdBuffer를 사용하면 1이 됩니다. (UE5 에서는 PrimitiveIdBuffer라고 하지만 실제로는 InstanceIdsBuffer가 바인딩 됩니다.)우리는 가장 기본인 FLocalVertexFactory를 기준으로 이 코드를 분석하고 있기 때문에 FLocalVertexFactory 코드에서 VF_SUPPORTS_PRIMITIVE_SCENE_DATA 항목이 어떻게 1로 설정되는지 그림3에서 확인해 봅시다.

    2. 모바일 쉐이더를 사용하지 않으므로 VF_USE_PRIMITIVE_SCENE_DATA는 1로 설정됩니다.

    3. LocalVertexFactory.ush에 FVertexFactoryInput의 정의가 있는데, 여기에 GPUScene을 사용하게 되면 2개의 Attributes가 추가됩니다. InstanceIdOffset, DrawInstanceId 입니다. DrawInstanceId는 SV_InstanceID로 자동으로 생성되는 InstanceID 입니다. 그것 외에 InstanceIdOffset은 버택스 인풋 과정에서 추가로 받는 Attributes라는 것을 알 수 있습니다. 어떤 데이터가 들어가는지는 차차 알아봅시다.

    4. 그림1에서 본 VF_GPUSCENE_GET_INTERMEDIATES 로 SceneData들을(PrimitiveSceneData, InstanceSceneData) 얻어오는데 쓰입니다.

    5. VF_GPUSCENE_DECLARE_INPUT_BLOCK을 FVertexFactoryInput 의 정의 사이에 끼워넣어줍니다.

    그림2. GPUScene을 사용하는 경우 설정되는 매크로와 FVertexFactoryInput 의 변화

     

    1. ModifyCompilationEnvironment 함수는 레퍼런스5 에서 볼 수 있듯 쉐이더 컴파일 과정에 호출되는 부분입니다. 여기서 쉐이더 컴파일에 필요한 define을 추가할 수 있습니다.

    2. VF_SUPPORTS_PRIMITIVE_SCENE_DATA를 설정해주는 부분이 있는데, 1이 될 조건은 VertexFactoryType이 SupportsPrimitiveIdStream 플래그를 가지는 것과 GPUScene이 사용되는 경우입니다.

    3. FLocalVertexFactory를 보면, 생성시에 SupportsPrimitiveIdStream 플래그를 추가해주는 것을 볼 수 있습니다. 그렇기 때문에 FLocalVertexFactory는 VF_SUPPORTS_PRIMITIVE_SCENE_DATA가 1이 됩니다.

    그림3. FLocalVertexFactory가 VF_SUPPORTS_PRIMITIVE_SCENE_DATA를 설정하는 과정

     

    조금 긴 호흡이 었습니다. 지금까지의 코드를 보면 아래 4가지 과정을 통해서 PrimitiveData와 InstanceData를 얻어오는 것을 볼 수 있습니다.

    1). InstanceIdsBuffer 로 부터 InstanceId를 얻어옴
    2). InstanceId 를 사용하여 InstanceData를 얻어옴
    3). InstanceData 로 부터 PrimitiveId를 얻어옴
    4). PrimitiveId 를 사용하여 PrimitiveData를 얻어옴

     

    지금까지 본 PrimitiveData를 얻는 코드에서  우리가 모르는 것을 추려보겠습니다. 아래의 순서대로 이 데이터들이 무엇인지 알아봅시다

    1). InstanceSceneData
    2). InstanceIdOffset
    3). InstanceIdsBuffer

     

     

    3.2. GPUScene의 InstanceSceneData 준비

    3.2.1. GPUScene의 InstanceSceneData 업로드 예약

    Primitive가 렌더링 스레드에 등록되는 시점인 UpdateAllPrimitiveSceneInfos() 으로 돌아가봅시다. 여기서 InstanceSceneData를 추가하는 코드를 살펴 볼 것입니다. StaticMesh 렌더링을 하고 있다고 가정하고 코드를 볼 것입니다. 왜냐하면 Auto-Instancing 은 StaticMesh에 대해서 적용되기 때문입니다.

    1. 이전글에서 봤던 부분입니다. UpdateAllPrimitiveSceneInfos 에서 새로 추가된 PrimitiveData를 GPUScene에 등록해달라고 예약하는 부분입니다.

    2. FPrimitiveSceneInfo::AllocateGPUSceneInstances 함수를 호출하여 새로 추가된 FPrimitiveSceneInfos에 InstanceSceneDataOffset을 생성해줍니다.

    3. 이 함수에서는 추가된 모든 PrimitiveSceneInfo의 InstanceSceneDataOffset 정보를 생성해줍니다.

    4. 여기서는 SupportsInstanceDataBuffer() 여부를 체크하는데요. InstancedStaticMesh나 SkeletalMesh와 같은 경우 이 부분이 활성화 됩니다. 현재는 StaticMesh를 기준으로 코드를 확인하고 있으므로 조건문이 false인 경우로 계속해서 코드를 봅시다.

    5. InstanceSceneDataOffset을 생성하고, FPrimitiveSceneInfos 에 설정해주는 부분입니다.

    6. AllocateInstanceSceneDataSlots 함수를 따라가봅니다.

    7. AllocateInstanceSceneDataSlots는 내부에서 InstanceSceneDataAllocator.Allocate를 호출합니다. 이 Allocator는 FGrowOnlySpanAllocator 클래스입니다. 실제로 메모리를 Allocate 하지는 않으며, Allocate 되었을때의 메모리 레이아웃을 관리해주는 클래스입니다. 각 메모리 레이아웃는 FLinearAllocation(StartOffset과 Num)로 나뉘어서 관리됩니다. 그림5에 간략한 클래스의 모양을 볼 수 있습니다.

    8. 새로 추가한 InstanceSceneData를 초기화 하기 위해서 예약합니다. 이 작업은 GPUScene이 업데이트 될때 수행됩니다.

    9. 할당된 InstanceSceneDataOffset 를 리턴합니다.

    그림4. Auto-Instancing을 위해서는 렌더스레드에 Primitive가 등록될 때 InstanceSceneData를 추가해야 합니다.

     

    InstanceSceneData의 레이아웃을 관리하는 FGrowOnlySpanAllocator는 아래와 같습니다. 클래스 이름 그대로 Allocate 된 메모리를 Free 를 하진 않고, Free를 호출하면 FreeSpans에 담아뒀다가 다시 필요할때 꺼내씁니다.

    1. Allocte 함수를 통해서 메모리의 시작주소를 리턴받습니다. (실제 메모리를 할당하는게 아니라 메모리 레이아웃만 할당합니다.)

    2. Allocate 함수 내부에서는 기존 FreeList에서 가져다 쓸 수 있는 것이 있으면 그것을 사용하고 그렇지 않으면, 가장 끝에 새로운 메모리 레이아웃을 추가합니다.

    그림5. FGrowOnlySpanAllocator 는 실제 메모리를 Allocate 하진 않습니다. 대신 메모리가 Allocate 되었을 때 배치되는 메모리 레이아웃을 FLinearAllocation(Offset과 Num)을 사용하여 관리합니다. 이 클래스 이름 그대로 메모리가 늘어나기만 하도록 관리 되며, 삭제되는 경우는 FreeSpans에 담아뒀다가 다시 Allocate할때 재활용합니다.

     

    3.2.2. GPUScene의 InstanceSceneDataBuffer에 InstanceSceneData를 업로드

     

    이제 FPrimitiveSceneInfos에 InstanceSceneDataOffset을 추가하였고, InstanceSceneData의 레이아웃을 구성하였습니다. 이제 InstanceSceneData를 InstanceSceneDataBuffer에 올리는 것을 확인해봅시다.

     

    먼저 계속 진행하기 전에 InstanceSceneData를 업로드하기 위해 사용되는 FInstanceSceneShaderData 클래스를 봅시다. PrimitiveSceneData를 업로드할때는 FPrimitiveSceneShaderData 클래스를 사용했으며 이 클래스와 거의 같은 형태로 동작합니다.

    1. InstanceSceneShaderData를 FVector4로 구성될 때 몇개의 FVector4가 필요한지 여부입니다. 이 크기는 SceneData.ush에 선언된 FInstanceSceneData와 맞춰줘야 합니다. (그림7 FInstanceSceneData 참고)

    2. Setup 함수를 호출하여 FInstanceSceneShaderData의 TStaticArray<FVector4, DataStrideInFloat4s> Data를 채웁니다.

    4. 채워지는 것들 중 Auto-Instancing에서 가장 중요한 것은 PrimitiveId를 저장한다는 점입니다. 쉐이더에서는 InstanceSceneData에 있는 PrimitiveId를 사용하여 PrimitiveData를 찾아냅니다. (이후 과정에서 볼 예정)

    그림6. FInstanceSceneShaderData 는 C++ 측에서 쉐이더 쪽에 InstanceSceneData를 올리기 위해 사용되는 클래스
    그림7. 쉐이더에서 사용하는 FInstanceSceneData 클래스

     

    InstanceSceneData의 업데이트는 FGPUScene의 UploadGeneral 에서 수행됩니다. InstanceSceneUploadBuffer 를 사용하여 InstanceSceneDataBuffer를 채우며, FScatterUploadBuffer를 사용하여 업로드합니다.(FScatterUploadBuffer는 이전글을 참고하면 됩니다)

     

    InstanceSceneDataBuffer의 업로드 전에 InstanceSceneDataClearList 로 예약된 데이터들을 Clear 해줍니다.

    1. FGPUScene::UploadGeneral 과정에서 시작합니다.

    2. 이전글에서 본 PrimitiveData는 여기서 업로드 합니다. 이전글에서 본 것으로 생략합니다

    3. 그림1에서 AllocateInstanceSceneDataSlots 를 호출할 때 InstanceSceneData를 Allocate 하고, ClearList에 추가했었습니다. 그 부분을 처리합니다. 코드에서 한번에 Clear 하는 개수를 제한하고 있습니다. 제한 개수 이상이 되는 경우 InstanceSceneDataClearOverflow 에 넣어뒀다가 InstanceSceneDataClearList로 다시 옮겨서 다음에 업데이트 하도록 합니다.

    4. InstanceSceneUploadBuffer 를 할당 및 초기화합니다.

    5. Clear 에 사용할 더미 FInstanceSceneShaderData를 하나 생성합니다.

    6. Clear 할 InstanceSceneData 의 주소를 얻어옵니다.

    7. 얻어온 주소에 ClearShaderData를 복사하여 InstanceToClearList로 설정되었던 InstanceSceneData를 Clear 해줍니다.

    그림8. GPUScene에서 InstanceSceneData를 Clear 하는 과정

     

    계속해서 바로 다음 코드에서 FInstanceSceneShaderData를 업로드 버퍼에 복사하고, ResourceUploadTo 함수를 호출하여 InstanceSceneDataBuffer에 업로드 합니다.

    1. UploadDataSourceAdapter::GetInstanceInfo 함수에서 Instance 정보를 얻어옵니다. UploadDataSourceAdapter는 이전글 에서 봤듯 갱신해야할 PrimitiveId 정보를 갖고 있습니다. 자세한 것은 이전글을 참고해주세요.

    2. GetInstanceInfo 함수의 내부 구현입니다. PrimitivesToUpdate는 이번에 PrimitiveData를 업데이트할 PrimitiveId 배열입니다. 먼저 PrimitiveID가 범위를 벗어나는지 PrimitiveId가 변경된 상태가 아닌지 확인하여 InstnaceInfo를 가져올 수 있는지 확인합니다.

    3. FInstanceUploadInfo를 채우는데, 이전 과정에서 만들어둔 InstanceSceneDataOffset과 PrimitiveID를 담습니다.

    4. FPrimitiveSceneProxy가 SupportsInstanceDataBuffer() 여부에 따라 FInstanceUploadInfo를 추가로 채우는데, StaticMesh의 경우 SupportsInstanceDataBuffer()는 false 입니다. 필요한 추가 정보를 채우고 PrimitiveInstances를 더미 데이터로 1개 채웁니다. PrimitiveInstances는 인스턴싱이 몇개나 될 지 여부입니다. 여기서는 1개로 설정하기 때문에 인스턴싱이 1개만 사용될 것입을 예상할 수 있습니다. 즉, 아직은 오토인스턴싱으로 커맨드를 묶어주지 않았다는 것입니다.

    5. 다시 원래의 코드로 돌아와서 PrimitiveInstances 들 개수 만큼 FInstanceSceneDataShader 정보를 채우는 과정을 진행합니다. 여기서는 StaticMesh만 보고 있기 때문에 1개입니다.

    6. FInstanceSceneShaderData를 설정합니다. 여기서 PrimitiveID가 저장되는 것을 볼 수 있습니다.

    7. InstanceSceneUploadBuffer에서 현재 Primitive Instance가 저장될 주소를 얻어옵니다.

    8. 7에서 얻은 주소에 6에서 생성한 FInstanceSceneShaderData를 저장합니다. 

    9. 이렇게 모든 InstanceSceneData를 UploadBuffer에 올린 후에 ResourceUploadTo 함수를 사용하여 InstanceSceneDataBuffer에 실제 데이터를 업로드 합니다.

    그림9. GPUScene에서 InstanceSceneData를 업로드하는 과정

     

    이제 준비된 InstanceSceneDataBuffer를 UniformBuffer View.InstanceSceneData 에 바인딩합니다.

    그림10. FViewUniformShaderParameters 의 InstanceSceneData 에 GPUScene의 InstanceSceneDataBuffer 가 들어감

     

    InstanceSceneDataBuffer 는 모든 Instance의 InstanceSceneData를 담고 있으므로 View UniformBuffer에 포함시켜서 한번만 바인딩하고 실제 Primitive들을 FMeshDrawCommand를 통해 개개별로 렌더링 할때는 따로 바인딩하지 않습니다. 그림11은 InstanceSceneDataBuffer 에 GPUScene에서 생성한 InstanceSceneDataBuffer를 바인딩 하는 과정을 볼 수 있습니다.

    그림11. GPUScene이 UniformBufferParameter View.InstanceSceneData에 설정되고, Parameters를 사용하여 UniformBuffer를 생성하는 과정

     

    지금까지의 코드를 보면 InstanceSceneDataBuffer가 그림12와 같이 구성되어있을 것이라고 생각해볼 수 있습니다. 단, 여기서는 StaticMesh만이라고 가정했기 때문에 아마 초록색과 파랑색 부분처럼 Primitive 가 여러개의 인스턴스로 인스턴싱되는 부분은 없을 것입니다.

    그림12. InstnaceSceneDataBuffer 내에서 같은 색상의 블록들은 PrimitiveId가 동일한 경우입니다. 초록색과 파란색의 경우 동일한 Primitive를 사용하지만 Instance는 각각 3개 2개 인것을 볼 수 있습니다.

     

    그럼 어떻게 오토인스턴싱을 하는 것일까요?

     

    오토인스턴싱을 위해서는 InstanceIdsBuffers(UniformBuffer), InstanceIdOffsetBuffer(Per-Instance 버택스 버퍼) 가 필요합니다. 이제 부터는 MeshDrawCommand를 런타임에 머지하는 과정을 보면서 이 두 버퍼를 구성하는 것을 확인해봅시다.

     

    3.3. InstanceIdOffset 의 준비

    MeshDrawCommand를 머지하는 과정은 UE4가 서로 조금 다릅니다. UE4에서는 SortAndMergeDynamicPassMeshDrawCommands() 함수에서 PrimitiveIdBuffer를 생성하고, 같이 묶을 수 있는 MeshDrawCommand를 묶어줍니다. 하지만 UE5에서는 FInstanceCullingContext 를 사용하여 이 과정들을 수행하며 조금 더 복잡해 졌습니다. 여기서는 UE5 기준으로 코드를 보겠습니다.

     

    3.3.1. Auto-Instancing을 위해 MeshDrawCommand 병합

    InstanceIdOffset, InstanceIdsBuffer는 FMeshDrawCommand를 병합하는 과정에서 만들어집니다. 이제 MeshDrawCommand의 병합이 어디에서 수행되는지 확인해봅시다.

     

    각 렌더링 패스별로 FParallelMeshDrawCommandPass::DispatchPassSetup 함수를 통해서 FMeshDrawCommand를 준비합니다. 이 ParallelMeshDrawCommandPass는 각 렌더링 패스별로 존재합니다. 그래서 지금 보는 코드는 각 렌더링 패스 별로 병렬로 수행된다는 점을 기억해야 합니다.

    1. FMeshDrawCommandPassSetupTask 는 언리얼의 GraphTask로 만들어지는데, 이 Task에서 현재 렌더링 패스에서 렌더링 할 FMeshDrawCommand들을 FMeshBatch로 부터 생성합니다. 또한 ConstructAndDispatchWhenReady 함수를 바로 호출했기 때문에 이 GraphTask는 바로 수행될 수 있게 언리얼 TaskGraphSystem에 전달되었을 것입니다. 리턴값으로 나온 TaskEventRef는 FMeshDrawCommandPassSetupTask 의 수행완료 여부를 알 수 있는 변수입니다. 이 변수는 각 렌더링 패스별로 렌더링을 수행하기 직전 이 TaskEventRef 를 기다려 렌더링 관련 준비가 모두 마쳐진 것을 보장하는데 사용됩니다.(레퍼런스4에 관련내용 있음)

    그림13. 각 렌더링 패스별로 FMeshDrawCommand를 준비하는 함수인 DispatchPassSetup

     

    계속해서 FMeshDrawCommandPassSetupTask 에서 하는 일을 봅시다.

    1. FMeshDrawCommand를 생성합니다.

    2. FMeshDrawCommand의 렌더링 순서를 정렬합니다.

    3. SetupDrawCommands에서 FMeshDrawCommand를 병합합니다. 여기서 Auto-Instancing 작업이 수행됩니다. (UE4 의 경우는 여기서 SortAndMergeDynamicPassMeshDrawCommands를 호출하여 PrimitiveIdBuffer를 채우고, Auto-Instancing을 위해서 FMeshDrawCommand를 병합 합니다.)

    그림14. FMeshDrawCommandPassSetupTask 에서 처리하는 작업들

     

    이제 Auto-Instancing 의 핵심인 MeshDrawCommand의 병합과정을 보겠습니다. 그리고 이 병합과정은 각 렌더링 패스별로 별도로 수정된다는 점을 다시 생각한 채로 코드를 보겠습니다. 가장 핵심이 되는 코드라 호흡이 좀 길 것입니다.

    1. VisibleMeshDrawCommandsInOut 배열에 있는 MeshDrawCommand 중 병합 가능한 항목들을 머지합니다. 이 배열에 있는 MeshDrawCommand는 이미 이 함수를 호출하기 전에 정렬이 되었습니다. 그래서 같은 MeshDrawCommand 끼리 모여있을 것입니다.

    2. VisibleMeshDrawComandInOut의 요소 수를 기준으로 필요한 컨테이너를 초기화 합니다.

    3. 그 중 MeshDrawCommandInfos, InstanceIdOffsets 배열의 주목해야 합니다. MeshDrawCommandInfos 에는 각 MeshDrawCommand의 인스턴싱 개수를 IndirectArgsOffsetOrNumInstances 변수에 기록합니다. 같은 MeshDrawCommand의 병합되면 이 값을 증가시켜줍니다. 그리고 InstanceIdOffsets 은 InstanceIdsBuffer 에 사용되는 Offset 입니다. InstanceIdOffset을 사용해서 병합된 Primitive의 InstanceId를 얻어올 수 있습니다.

    4. 모든 MeshDrawCommand를 순회하면서 병합을 시작합니다.

    5. 현재 처리할 MeshDrawCommand 를 얻습니다.

    6. 병합 여부를 결정할 때, 3가지 항목이 있습니다. (bCompactIdenticalCommands는 이 함수가 실행될 때 true로 넘어왔기 때문에 무시합니다.) CurrentStateBucketId을 비교하는 부분이 있습니다. 그럼 StateBucketId가 뭔지 알아봐야 할것 같은데요. 이 Id는 GPUScene을 사용하는 경우 MeshDrawCommand를 만들 때, FStateBucketMap(TRobinHoodHashMap) 에 FMeshDrawCommand를 관리할 수 있도록 추가해줍니다. 이때 이 FStateBucketMap에서 FMeshDrawCommand에 해당하는 Id를 돌려줍니다. 그래서 이 Id가 일치하면 병합 가능한 MeshDrawCommand라고 볼 수 있습니다. 추가로 FStateBucketMap에서 동일한 MeshDrawCommand로 판정하기 위해서 그림16의 GetDynamicInstancingHash() 함수를 사용합니다. 여기서는 Vertex, Index Buffer들과 PipelineId 등등의 정보가 모두 일치해야 함을 확인할 수 있습니다. 두번째 조건문의 CurrentStateBucketId 이 -1이라면 병합하고 있는 MeshDrawCommand가 없다는 것입니다. 세번째 조건문은 CurrentStateBucketId와 현재 처리중인 VisibleMeshDrawCommand.StateBucketId를 비교하는데 이것이 같다는 것은 병합이 가능하다는 의미입니다. (이글 최하단에 추가그림1에 StateBucketId 가 생성되는 코드를 확인할 수 있습니다)

    7. 먼저 병합하는 것이 없는 상태라고 가정하면 조건문의 else로 넘어와서 이 코드가 실행될 것입니다.

    8. 여기서는 MeshDrawCommandInfos를 하나 추가해줍니다.

    9. IndirectDraw를 사용하고 있지 않기 때문에 else 구문이 실행되며 IndirectArgs.OffsetOrNumInstances = 1로 설정해줍니다. 여기서는 IndirectDraw가 아니므로 NumInstance로 사용되며 1개의 인스턴스로 설정합니다. 병합이 이루어지면 이 변수가 증가한다고 예상 할 수 있습니다.

    10. MeshDrawCommand는 Per-Instance VertexBuffer로 InstanceIdOffsetBuffer를 가집니다. 이 버퍼의 원본 소스인 InstanceIdOffsets의 배열에 이번에 새로 추가된 MeshDrawCommand의 InstanceIdOffset을 추가합니다. 여기서 '아직 InstanceIdsBuffers도 아직 안만들었는데 무슨 InstanceIdOffset 데이터 부터 구성하냐?'고 할 수 있는데, 추후 InstanceIdOffsetBuffer를 기반으로 Compute Shader에서 InstanceIdsBuffer를 구성합니다. 새로 병합되는 MeshDrawCommand가 생성될 때마다 InstanceIdOffsets 배열에는 CurrentNumInstances가 추가되며 이 값은 TotalInstances 값입니다. (여기서 View가 1개라고 가정). TotalInstance는 아래의 13번에서 AddInstancesToDrawCommand가 호출될 때마다 1 증가합니다. AddInstancesToDrawCommand는 MeshDrawCommand의 인스턴스가 추가될 때 마다 호출됩니다.

    11. CurrentStateBucketId 는 현재 처리중인 MeshDrawCommand의 StateBucketId로 설정합니다. 그래서 다음에 처리되는 MeshDrawCommand 의 병합가능성 체크를 위해서 사용됩니다.

    12. 이 함수의 입력으로 받은 VisibleMeshDrawCommandsInOut 배열에 최종 병합된 결과를 저장합니다. 현재의 MeshDrawCommand를 새로 배치 합니다.

    13. 현재 Instance 정보를 LoadBalancers 에 Add 함수를 사용해 추가합니다. 여기서 중요한 점은 LoadBalancers에 Item 배열에 InstanceDataOffset을 추가해주는 것입니다. 이 InstanceDataOffset은 뒤쪽에서 나오는 InstanceIdsBuffer를 만드는 Compute Shader 내에서 사용됩니다. 이때 이 InstanceDataOffset은 InstanceIdOffsetBuffer에서 InstanceId를 얻어오는데 사용됩니다. 또한 InstanceId는 InstanceSceneData 를 얻어오는데 사용됩니다. (그림17에 함수 내부 추가)

    14. 계속해서 다음 VisibleMeshDrawCommand 를 처리한다고 해봅시다. 그리고 이전의 StateBucketId와 현재 StateBucketId가 동일하면 이 코드를 실행합니다. 여기서 IndirectArgsOffsetOrNumInstances를 1 증가시킵니다. 이런 방식으로 렌더링해야할 인스턴스를 하나 더 늘려줍니다.

    15. 병합이 되었다면 VisibleMeshdrawCommandsInOut 의 크기가 줄었을 것이므로 배열의 크기를 줄입니다.

    그림15. FMeshDrawCommand 병합과정, VisibleMeshDrawCommandsInOut 을 병합합니다.

     

    그림16. FMeshDrawCommand의 멤버함수 GetDynamicInstancingHash()

     

    그림15의 과정에서 AddInstancesToDrawCommand에 대한 내용입니다.

    1. Instance가 생길때마다 AddInstancesToDrawCommand 함수를 호출합니다.

    2. LoadBalancers 에 Add 함수를 통해서 InstanceDataOffset을 전달합니다. 이 값은 추후 InstanceIdsBuffer를 만드는 Compute Shader에서 사용됩니다. 이 쉐이더 안에서 InstanceDataOffset 은 InstanceId로 취급되며, FInstanceSceneData를 얻어오는데 쓰입니다.

    3. 추가된 Instances를 Items 배열에 추가해줍니다.

    그림17. LoadBalancers의 Add함수를 통해서 InstanceDataOffset이 전달된다.

     

    지금까지 코드로 우리는 렌더링 패스별로 병합된 MeshDrawCommand의 InstanceIdOffset 정보를 생성했습니다. 그리고 이 InstanceIdOffset만 있으면 InstanceIdsBuffer에서 InstanceId를 얻어올 수 있을 것입니다. 또한 이 MeshDrawCommand가 2개 이상의 인스턴스를 인스턴싱한다면, (MeshDrawCommand의 InstanceIdOffset + DrawInstanceId(SV_InstanceID)) 를 사용하여 InstanceIdsBuffer 로 부터 InstanceId를 얻어올 수 있을 것입니다.

     

    또하나 여기서 중요한점은 이 InstanceIdOffset 들이 렌더링 패스별로 생성되었다는 점입니다. 이것을 합쳐서 하나의 InstanceIdOffsetBuffer를 생성합니다.

     

    그런 뒤 Compute Shader를 사용하여 InstanceIdsBuffer를 생성합니다.

     

    3.4. InstanceIdOffsetBuffer와 InstanceIdsBuffer 의 준비

    3.4.1. InstanceIdOffsetBuffer와 InstanceIdsBuffer 의 생성을 위한  Compute Shader 준비

    1. InitViews 호출

    2. GPUScene 업데이트

    3. InstanceCullingManager.BeginDeferredCulling에서 실제로 InstanceIdsBuffer를 생성하는 Compute Shader를 실행합니다.

    4. 계속해서 내부에서 CreateDeferredContext를 실행합니다.

    5. Compute Shader를 실행하기 전에 ProcessBatched 가 실행되는데 이 부분에서 여러개의 렌더링 패스에서 만들어졌던 InstanceIdOffset 배열을 합칩니다. 합친 후 InstanceIdOffset을 기준으로 InstanceIdsBuffer를 생성합니다. 이 부분은 Compute Shader에서 수행됩니다.

    6. InstanceIdOffsetBuffer를 생성합니다. 이 버퍼는 Per-InstanceBuffer로 사용됩니다.

    7. InstanceIdsBuffer 를 생성합니다. 이 버퍼는 StructuredBuffer 로 생성됩니다. MeshDrawCommand를 렌더링 할 때, 버택스 쉐이더에서 Per-Instance 데이터로 전달받은 InstanceIdOffset 을 사용하여 자신의 InstanceId를 얻는데 사용됩니다.

    8. Per-Instance로 사용할 InstanceDataBuffer 를 InstanceIdOffsetBuffer로 설정해줍니다. 실제 MeshDrawCommand를 렌더링할 때 이 Per-Instance VertexBuffer를 바인딩합니다.

    9. PassParametersTmp.InstanceIdsBufferOut 을 InstanceIdsBufferUAV로 설정합니다. 이 Compute Shader가 InstanceIdsBuffer를 생성한다는 것을 알 수 있습니다.

    10. Compute Shader의 입력 파라메터로 InstanceIdOffsetBuffer가 들어가는 것을 볼 수 있습니다.

    11. Items는 아까 위에서 그림17의 AddInstancesToDrawCommand 함수를 통해서 LoadBalancers 에 추가된 Items 입니다. 여기에는 InstanceDataOffset이 있습니다. 이것은 현재 Instance의 InstanceSceneDataBuffer의 Id입니다.

    12. Compute Shader에 필요한 Permutation 설정을 전달하여, Compute Shader를 얻어 옵니다. Compute Shader는 BuildInstanceDrawCommands.usf 의 InstanceCullBuildInstanceIdBufferCS 함수에 있다는 것을 알 수 있습니다.

    13. 최종 결과에 사용할 Per-Instance VertexBuffer를 InstanceIdOffsetBufferRDG 로 설정해줍니다.

    그림18. InstanceIdsBuffer를 생성해주는 Compute Shader를 준비하는 과정, InstanceIdOffsetBuffer와 InstanceIdsBuffer도 여기서 생성해둠.

     

    Compute Shader로 이동하기 전에 ProcessBatched 함수를 보겠습니다. 이 함수는 그림18의 INST_CULL_CALLBACK_MODE 에서 불리도록 되어있으며, Compute Shader가 실행되기전에 먼저 실행됩니다. INST_CULL_CALLBACK_MODE가 여러번 불리지만 bProcessed 변수를 사용하여 딱 한번만 처리되는 것을 알 수 있습니다.

    1. Batches 는 렌더링 패스당 한개씩 생성되는 자료구조 입니다. 그림20에서 어떻게 Batches가 채워지는지 알아봅시다.

    2. 모든 패스의 InstanceIdOffsets 배열 여러개를 통합하여 한개의 InstanceIdOffsets 배열로 모읍니다.

    3. InstanceIdBufferOffset을 증가시켜주는 부분인데, 기존 렌더링 패스별 InstanceIdOffsets의 경우 각 렌더링 패스별로 InstanceIdBufferOffset을 가지고 있다고 가정하면, 시작 Offset을 0으로 설정했을 것입니다. 여기서는 모든 렌더링 패스의 InstanceIdOffsets 로 통합하기 때문에 거기에 맞게 다음 렌더링 패스의 InstanceIdOffsets를 이전 렌더링 패스의 InstanceIdOffsets 만큼 밀어주는 것입니다.

    그림19. 여러 렌더링 패스의 InstanceIdOffsets 배열을 하나의 InstanceIdOffsets 배열로 합쳐주는 부분

     

    그림19에서 Batches가 렌더링 패스별 존재하는 정보고 이 것을 합치고 있다고 했습니다. 여기서는 대표적인 렌더링 패스인 BasePass의 Batch가 생성되는 부분을 보겠습니다.

    1. 렌더링패스를 시작합니다.

    2. BuildRenderingCommands 함수에서 FInstanceCullingManager의 DeferredContext->Batches 배열에 Batch를 추가해줍니다. 바로 아래에는 GraphBuilder 에 실행할 람다함수를 추가해줍니다. DispatchDraw는 현재 렌더링 패스의 MeshDrawCommand를 렌더링해주는 함수입니다.

    3. WaitForMeshPassSetupTask는 FParallelMeshDrawCommandPass 가 렌더링할 MeshDrawCommand 생성을 수행하고 있는 GraphTask를 기다리는 부분입니다.(레퍼런스4의 FMeshDrawCommandPassSetupTask 를 참고하면 됩니다.)

    4. 계속해서 InstancingCullingContext.BuildRenderingCommands()를 호출합니다.

    5. BuildRenderingCommands() 함수 내부에서는 DeferredCullingActive() 여부를 확인합니다. 만약 DeferredCullingActive()가 false 라면, 각 렌더링 패스별로 InstanceIdOffsetBuffer를 생성합니다. 하지만 여기서는 모든 렌더링 패스의 정보를 하나의 버퍼로 모으는 방식인 DefferedCullingActive()가 true인 경우라고 생각하고 코드를 보겠습니다. (실제 IsDeferredCullingActive() 가 false 인 경우 바로 아래에 코드가 더 있고, 그림18과 같이 Compute Shader를 설정하고 실행하는 과정이 나옵니다.)

    6. DeferredContext->Batches 에 Batch를 추가합니다. 그리고 InstanceDataBuffer를 DeferredContext->InstanceDataBuffer로 설정해줍니다. 여기서 InstanceDataBuffer는 바로 InstanceIdOffsetBuffer 입니다. (그림18 참고)

    7. BuildRenderingCommands를 빠져나와서 GetDrawParameters 함수를 호출하여 이 렌더링 패스에서 사용하게 될 데이터들을 가져갑니다. 그리고 여기에서 InstanceCulling UniformBuffer에 InstanceIdsBuffer가 포함되어 있습니다. 이 부분은 뒤에서 다시 한번 더 봅시다.

    그림20. MeshDrawCommand가 렌더링할 때 사용하는 InstanceIdsBuffer 와 InstanceIdOffsetBuffer를 얻어오는 과정

     

    이제 Compute Shader에서 어떻게 InstanceIdsBuffer를 만드는지 확인해봅시다.

    1. 우리가 관심있는 것은 바로 InstanceIdsBufferOut에 어떻게 데이터가 채워지는가? 입니다. 이 부분을 중심으로 보겠습니다.

    2. InstanceCullBuildInstanceIdBufferCS 쉐이더가 메인 함수입니다.

    3. InstanceCullingLoadBalancer_Setup 함수를 사용하여 현재 작업에 필요한 데이터들을 준비합니다. 이 과정은 그림 22에서 더 자세하게 나옵니다. 일단 필요한 데이터가 잘 채워졌다고 생각하고 계속 진행합니다.

    4. 전 과정에서 준비한 데이터에서 InstanceDataOffset을 가져옵니다.

    5. InstanceDataOffset과 LocalItemIndex(이 값은 항상 0, 추가그림2 참고)를 사용하여 InstanceId를 얻습니다.

    6. WriteInstance에서 최종적으로 InstanceIdsBufferOut에 InstanceId를 기록합니다.

    7. ViewIndex와 InstanceId를 InstanceIdsBufferOut에 저장하고 Compute Shader를 마칩니다.

    그림21. Compute Shader에서 InstanceIdsBuffer를 생성하는 과정

     

    위의 Compute Shader를 실행하기 위해서 필요한 데이터를 초기화하는 과정을 추가로 보겠습니다.

    1. InstanceCullingLoadBalacner_Setup 함수는 그림21에서 보듯 Compute Shader에서 가장 먼저 호출됩니다. 그리고 이 함수에서 리턴값 중 가장 중요한것은 Item.InstanceDataOffset 입니다. InstanceDataOffset 은 InstanceId 를 얻는데 사용됩니다.

    2. UnpackItem 함수를 통해서 C++에서 Pack 해 넣었던 Item 정보를 풀어내는 것을 볼 수 있습니다. 그림20의 Batches에 추가되는 FBatchItem이 바로 이 데이터입니다.

    3. Unpack 되어있던 Item 값을 Setup.Item에 넣어줍니다.

    4. 완성된 Setup 값을 리턴합니다.

    5. UnpackItem에서 InstanceDataOffset 값을 잘 얻어내는 것을 볼 수 있습니다.

    그림22. Compute Shader 의 시작 부분에서 필요한 데이터를 초기화 하는 InstanceCullingLoadBalacner_Setup 함수

     

    추가그림2. LocalItemIndex 이 0이 항상 0이 되는 것을 확인하기 위해서 렌더독 쉐이더 디버깅 사용. 실제 작동하는 쉐이더코드 확인해보면, Auto-Instancing 사용 시 LocalItemIndex의 값을 설정해주는 부분은 사용하지 않기 때문에 0이 됩니다.

     

    이제 처음 이글을 시작하면서 몰랐던 아래의 3가지 데이터가 어떻게 구성되는지 확인했습니다.

    1). InstanceSceneData
    2). InstanceIdOffset
    3). InstanceIdsBuffer

     

    3.5. 실제 렌더링 시 버퍼들의 사용되는 과정

    실제 렌더링 시에 어떻게 이 버퍼들이 설정되고, Auto-Instancing으로 합쳐진 MeshDrawCommand가 어떻게 실행되는지 봅시다.

    1. 각 렌더링 패스는 DispatchDraw 함수를 사용해 렌더링됩니다. InstanceCullingDrawParams로 부터 만들어 두었던 Per-Instance 버택스 버퍼인 InstanceIdOffsetBuffer를 얻어옵니다.

    2. GPUScene을 사용하는 경우 InstanceCullingContext.SubmitDrawCommands를 호출합니다.

    3. InstanceFactor 값을 조정해주는데 MeshDrawCommand를 병합할 때, 같은 MeshDrawCommand인 경우 +1 해주었던 IndirectArgsOffsetOrNumInstances 를 사용합니다. 이 값이 2라면 드로콜 1회로 2개의 인스턴스를 렌더링할 것입니다.

    4. SubmitDraw 함수를 호출하여, 현재 처리중인 MeshDrawCommand를 렌더링합니다. 이 때 위에서 계산한 InstanceFactor가 같이 넘어가는 것을 볼 수 있습니다.

    5. InstanceIdOffsetBuffer 가 SubmitDraw 함수로 전달되고, 여기서는 ScenePrimitiveIdsBuffer 로 불리게 됩니다. InstanceFactor는그대로 전달됩니다. 편의상 혼란을 피하기 위해서 ScenePrimitiveIdsBuffer 를 InstanceIdOffsetBuffer라고 계속해서 부르겠습니다.

    6. SubmitDrawBegin에서 렌더링에 필요한 리소스들을 바인딩합니다. 여기에 InstanceIdOffsetBuffer를 전달합니다.

    7. MeshDrawCommand가 PrimitiveIdStreamIndex를 가지고 있다면(PrimitiveIdStream을 사용한다면) 전달받은 InstanceIdOffsetBuffer를 설정해줍니다.

    그림23. 실제 렌더링 패스별로 MeshDrawCommand를 렌더링 할 때, InstanceIdOffsetBuffer를 설정해주는 부분

     

     

    마지막으로 InstanceIdsBuffer 를 바인딩 하는 과정을 봅시다. 이것은 UniformBuffer 형태로 바인딩합니다.

    1. 모든 렌더링 패스의 Parameters에는 FInstancecullingDrawParams가 추가되어있습니다. 대표적으로 사용하는 OpaqueBasePassPrameters를 봅시다.

    2. InstanceCullingDrawParams 부분을 채우는 과정입니다. BuildRenderingCommands 호출하여 내부에서 채웁니다. 함수에 전달된 InstanceCullingDrawParams는 레퍼런스 타입으로 전달되기 때문에 최종 결과가 여기에 담깁니다.

    3. BuildRenderingCommands 함수에서는 InstanceCullingDrawParams 에 이 렌더링 패스에서 사용할 InstancCulling관련 파라메터들을 담아줍니다.

    4. 계속해서 InstanceCullingContext.BuildRenderingCommands를 호출합니다.

    5. BuildRenderingCommands는 실행결과를 FInstanceCullingResult에 담아서 돌려줍니다.

    6. FInstanceCullingResult의 'UniformBuffer(변수명)'에 FInstanceCullingGlobalUniforms 를 채워줍니다. 이 'UniformBuffer(변수명)'에 InstanceIdsBuffer가 포함되어있습니다.

    7. InstanceCullingContext의 실행 결과를 FInstanceCullingDrawParams 의 InstanceCulling 에 담아줍니다.  위에서 이야기 했듯 InstanceCulling에 InstanceIdsBuffer가 포함되어있습니다.) 이제 BasePass 파라메터가 바인딩 될때 'UniformBuffer(변수명)' 내부에 있는 InstanceIdsBuffer도 함께 바인딩 될 것입니다.

    그림24. 렌더링 패스별로 렌더링 시에 InstanceIdsBuffer를 바인딩 하는 과정 확인. 각각의 렌더링 패스에 사용되는 유니폼버퍼는 모두&nbsp; InstanceIdsBuffer가 포함되어있기 때문에 같이 바인딩 됨.

     

    이제 InstancIdOffset을 Per-Instance 버택스버퍼로 바인딩 했고, InstanceIdsBuffer 또한 UniformBuffer 형태로 바인딩 했습니다. 모든 과정을 다 확인하였습니다.

     

    이제 그림1에서와 같이 쉐이더에서 Auto-Instancing 된 인스턴스들의 PrimitiveData와 InstanceData를 얻을 수 있게 되었습니다.

     


    추가로 StateBucketId를 생성하는 부분입니다.(MeshDrawCommand가 만들어지고 나서 호출되는 FinalizeCommand 가 실행되는 과정과 FDrawCommandRelevancePacket::AddCommandsForMesh 함수(아래 3번에 설명된)는 레퍼런스4 글에서 볼 수 있습니다.)

    1. GPUScene을 사용하는 경우 이 코드를 실행합니다. 여기서는 CachedMeshDrawCommandStateBuckets에 MeshDrawCommand를 추가합니다.

    2. 그리고 CachedMeshDrawCommandStateBuckets에서 돌려받은 Id를 CommandInfo.StateBucketId에 저장합니다. 이 데이터는 추후에 FDrawCommandRelevancePacket::AddCommandsForMesh 부분에서 FVisibleMeshDrawCommand를 만드는 재료로 사용됩니다. 그래서 FVisibleMeshDrawCommand에서 StateBucektId 정보를 사용할 수 있는 것입니다.

    3. 실제 CachedMeshDrawCommandStateBuckets이 있는 위치는 FScene에 있으며 각 렌더링 패스별로 버킷을 가지고 있다는 것을 알 수 있습니다. 그래서 서로 다른 렌더링 패스에서 생성된 StateBucketId 끼리 겹치지 않다는 것을 보장하지 못합니다. 코드를 볼 때 이 점은 중요하다고 생각합니다. CachedDrawLists의 경우는 GPUScene을 사용하지 않는 경우에 사용됩니다.

    추가그림1. MeshDrawCommand에 StateBucketId를 생성하는 과정과 FMeshDrawCommand를 FScene에 캐싱해두는 위치 확인. StaticMesh이고 위치가 변하지 않는 경우는 CachedMeshDrawCommandStateBuckets or CachedDrawLists에 한번 캐싱해둔 FMeshDrawCommand를 계속해서 사용한다. (변경 사항이 있을 때 까지 FMeshBatch에서 새로 FMeshDrawCommand를 생성할 필요 없음)

     

    4. 레퍼런스

    1. Unreal Engine 5 (ue5-main branch acc8c5f399ca01f6f549108be1fb75381fecbca8)

    2. [UE5] GPUScene 과 Auto-Instancing (1/2)

    3. [UE5] MeshDrawCommand (1/2)

    4. [UE5] MeshDrawCommand (2/2)

    5. [UE5] Shader [1/3] - 주요 클래스 파악

    6. [UE5] Shader Compile [2/3] - 쉐이더 컴파일 과정

     

     

    댓글

Designed by Tistory & scahp.