ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [UE5] Dynamic Shadow (1/2) - Directional light(CSM)
    UE4 & UE5/Rendering 2022. 5. 2. 22:43

    [UE5] Dynamic Shadow (1/2)

     

     

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

     

    목차

    1. 환경
    2. 목표
    3. 내용
     3.1. Directional Light의 쉐도우맵 렌더링 둘러보기
     3.2. 주요 클래스 둘러보기
     3.3. 이번 프레임에 사용 할 FDynamicShadowsTaskData 생성
     3.4. 라이트가 쉐도우맵을 렌더링 할 수 있게 해주는 FProjectedShadowInfo를 생성
     3.5. 렌더링 될 프리미티브 수집 시작
      3.5.1. 렌더링 될 프리미티브들이 포함된 Octree node 수집
      3.5.2. 수집된 Octree node 기준으로 쉐도우맵에서 렌더링 할 프리미티브 FProjectedShadowInfo에 수집
     3.6. 프리미티브들을 렌더링 할 수 있게 준비하고 쉐도우 렌더타겟 생성
      3.6.1. 프리미티브들을 렌더링 가능하도록 준비
      3.6.2. 쉐도우 렌더타겟 생성
     3.7. 쉐도우맵에 프리미티브 렌더링
    4. 레퍼런스

    1. 환경

    Unreal Engine 5 (5.0 branch 2170f78d3d94abd7b24b7d6d5c5104cfb43245ea)

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

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

    필요한 사전 지식
    CSM Shadow

    아래 내용을 먼저 읽고 이해한 뒤 보면 더욱 좋습니다.
    [UE5] MeshDrawCommand (1/2)
    [UE5] MeshDrawCommand (2/2)
    [UE5] GPUScene 과 Auto-Instancing (1/2)
    [UE5] GPUScene 과 Auto-Instancing (2/2)

     

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

     

    2. 목표

    언리얼의 쉐도우 구현을 알아봅니다.
    언리얼의 라이트의 종류, 라이트의 Mobility 그리고 PrimitiveComponent의 Mobility 조합에 따라서 다양한 방식으로 쉐도우를 생성합니다. 이 글은 라이트의 Mobility 가 Movable 이고, Directional light 인 경우(CSM)에 어떻게 쉐도우맵을 렌더링 하는지 알아볼 것입니다.
    이 글에서는 CSM 쉐도우의 알고리즘의 원리는 다루지 않을 것이며, 언리얼에서 CSM을 사용 하는 코드 구조에 집중할 것입니다.

    다음 글 DynamicShadow (2/2) 에서는 Point/Spot/Rect 라이트의 쉐도우맵을 만드는 과정을 볼 것입니다.

     

    3. 내용

    쉐도우맵은 Light를 기준으로 ShadowDepthMap을 렌더링 합니다. 이 글에서는 라이트의 Mobility 가 Movable인 경우를 가정합니다. 이 경우 라이트의 종류에 따라 렌더링 하는 방식이 서로 다르며, 렌더링 할 프리미티브들을 관리하는 방식이 다릅니다.  아래를 참고해주세요.

    특정 방향으로 향하는 라이트 (2D 쉐도우맵)
    - Directional light
    - Spot light

    전방향(Omnidirectional)으로 향하는 라이트 (3D 쉐도우맵)
    - Point light
    - Rect light

    매 프레임 렌더링할 프리미티브를 새로 구하는 라이트
    - Directional light

    FScene 에 프리미티브가 추가/수정/삭제 될 때 렌더링 할 프리미티브를 업데이트하는 라이트
    - Spot light
    - Point light
    - Rect light

    3.1. Directional Light의 쉐도우맵 렌더링 둘러보기

    Directional light의 쉐도우맵 렌더링 방식에는 다양한 알고리즘이 있습니다. 언리얼에서는 CSM 쉐도우를 사용합니다. CSM에 대한 구현은 레퍼런스6을 참고해주세요. CSM에 대해서 잘 모르더라도 언리얼의 쉐도우맵 렌더링 구조를 이해하는 데는 문제없습니다.

    쉐도우맵을 렌더링 하는 데는 2가지 스탭이 있습니다.

    • 렌더링 할 프리미티브 수집 (아래 그림 1 참고)
      • BeginInitDynamicShadows
      • FinishInitDynamicShadows
    • 수집한 프리미티브를 렌더링 (아래 그림2 참고)
      • RenderShadowDepthMaps

    그림1. 쉐도우맵에 렌더링할 프리미티브를 수집하는 코드
    그림2. 쉐도우맵에 렌더링


    3.2. 주요 클래스 둘러보기

    조금 더 나아가기 전에 BasePass와 같은 패스와 쉐도우 패스의 차이에 대해서 생각해 봅시다. BasePass 같은 경우는 메인 카메라(뷰매트릭스)를 기준으로 렌더링 됩니다. 만약 2인용 게임이라면 한개의 TV에서 하면, 화면을 2개로 나눌 것입니다. 이런 경우 뷰가 2개가 되므로, 메인 카메라당 BasePass가 1번씩 실행된다고 볼 수 있습니다. 쉐도우 패스는 여기서 더해서 라이트당 한번씩 렌더링하게 됩니다. 그래서 장면에 라이트가 많을 수록 더 많은 렌더링 패스가 생길 것 입니다. 쉐도우맵은 라이트를 기준으로 렌더링하기 때문에 뷰매트릭스 또한 라이트를 기준으로 생성됩니다. 그래서 라이트마다 자신이 렌더링 해야 될 프리미티브들도 모두 다를 수 있습니다. 이렇게 라이트마다 렌더링 할 프리미티브를 모으고, 렌더링 하는 과정을 볼 텐데요. 이런 정보들은 바로 FProjectedShadowInfo에 담깁니다. 아래 그림1에서 FProjectedShadowInfo 의 중요한 멤버 변수만 확인해봅시다.


    1. 쉐도우의 ViewMatrix 입니다.
    2. 쉐도우의 ProjectionMatrix 입니다.
    3. FProjectedShadowInfo 의 정보를 구성하는 데 사용한 라이트 정보입니다. FLightSceneInfo는 FPrimitiveSceneInfo와 유사한 이름인데요. Light의 경우는 Primitive 와는 다른 FLightSceneInfo 형태로 렌더링 스레드에 미러링 됩니다.
    4. FParallelMeshDrawCommandPass 입니다.  레퍼런스3에 나오듯 이 클래스는 렌더링 할 MeshDrawCommand를 생성하고 렌더링 하는 것을 담당합니다. 라이트 별로 자신이 렌더링 할 프리미티브가 다르기 때문에 FProjectedShadowInfo 별로 FParallelMeshDrawCommandPass를 소유한 것을 볼 수 있습니다.

    그림3. 쉐도우맵 렌더링에 필요한 모든 정보를 담고 있는 FProjectedShadowInfo

    다음으로 볼 클래스는 FDynamicShadowsTaskData 입니다. 위에서 InitDynamicShadows 함수를 사용하여 쉐도우맵에 렌더링 할 프리미티브를 모은다고 하였습니다. 그리고 렌더링할 데이터를 모으고 렌더링 할 수 있는 기능을 가진 클래스는 FProjectedShadowInfo 였습니다. 현재 장면 기준으로 렌더링 해야 할 쉐도우들의 FProjectedShadowInfo를 FDynamicShadowsTaskData에서 모읍니다.

    1. 현재 우리는 Dynamic Shadow인 Directional Light를 보고 있습니다. PreShadows의 경우 Stationary light 에서 사용하는 것이므로 이번에는 무시합니다. ViewDependentWholeSceneShadows 에 여러 FProjectedShadowInfo를 저장하는 것을 볼 수 있습니다.
    2. Directional light에서 사용하는 CSM 의 경우 매프레임 렌더링 할 프리미티브를 다시 모읍니다. 이 과정을 FGatherShadowPrimitivesPacket에서 수행합니다.
    3. FGraphEventRef TaskEvent 는 언리얼 TaskGraphSystem에서 사용되는 객체입니다. FDynamicShadowsTaskData가 언리얼 TaskGraphSystem에서 백그라운드에서 동작할 수 있고, 백그라운드에서의 작업완료 여부를 TaskEvent를 통해 확인할 수 있을 것입니다.

    그림4. 현재 프레임에서 생성한 Dynamic Shadow의 모든 정보를 담고 있는 FDynamicShadowsTaskData, 이 글에서 가장 중요한 클래스인 FProjectedShadowInfo도 여기에 담김


    3.3. 이번 프레임에 사용 할 FDynamicShadowsTaskData 생성

    이제 이 주요 클래스 2개를 가지고 Directional light 의 렌더링 과정을 봅시다.

    먼저 쉐도우맵의 렌더링에 필요한 정보들과 렌더링 할 프리미티브를 준비하기 위해서 BeginInitDynamicShadows 를 호출합니다. 그리고 Dynamic Shadow 데이터를 생성할 FDynamicShadowsTaskData를 생성하는 과정을 봅시다.


    1. InitViews -> InitViewsBeforePrepass -> BeginInitDynmaicShadows 를 차례로 호출합니다.
    2. FDynamicShadowsTaskData 를 만들어 DynamicShadow의 관련 데이터를 저장할 수 있게 준비합니다. CSM에서는 이 클래스의 ViewDependentWholeSceneShadows 에 렌더링 할 쉐도우맵에 관련된 FProjectedShadowInfo가 담깁니다.
    3. ViewDependentWholeSceneShadows의 참조를 로컬 변수인 ViewDependentWholeSceneShadowsThatNeedCulling에 넘겨줍니다. 이후 과정에서 ViewDependentWholeSceneShadowsThatNeedCulling 에 FProjectedShadowInfo가 들어갈 것을 예상할 수 있습니다.

    그림5. 현재 프레임에서 DynamicShadow 정모를 저장할 FDynamicShadowsTaskData 생성

    3.4. 라이트가 쉐도우맵을 렌더링 할 수 있게 해주는 FProjectedShadowInfo를 생성

    계속해서 BeginInitDynamicShadows 함수를 봅시다.

     

    1. 모든 라이트에 대해서 FProjectedShadowInfo를 생성할 것이므로 모든 라이트에 대해서 순회합니다.
    2. 쉐도우 타입은 쉐도우맵과 레이트레이싱을 사용한 방법일 수 있습니다. 오늘 쉐도우맵에 대한 내용을 보고 있기 때문에, 당연히 쉐도우맵이라고 가정하고 계속해서 보겠습니다.
    3. 라이트에 스태틱 or 다이나믹 쉐도우를 케스팅 하는지 여부를 체크합니다. 쉐도우를 케스팅하지 않는 라이트의 경우 라이트 케스트 체크 해제해두면 이후 과정이 무시될 것이므로 상당한 성능 향상을 생각할 수 있을 것 같습니다.
    4. 현재 순회 중인 라이트가 여러 뷰들에 대해서 어느 하나에서라도 렌더링 되고 있는지를 확인합니다. 뷰프러스텀 내부에 있는지와 확인합니다.
    5. 현재는 Directional light 가 Movable mobility를 가지기 때문에 bShouldCreateShadowForMovableLight와 bCreateShadowForMovableLight 둘 모두 true 가 됩니다. 아래에 여러 조건에 따라 몇 개의 플래그가 더 있는데 여기서는 이 두 개만 확인하는 것으로 충분합니다.
    6. 다음으로 CreateWholeSceneProjectedShadow를 호출합니다. 이 함수는 LightSceneInfo->Proxy->GetWholeSceneProjectedShadowInitializer를 호출하고 그 결과 FProjectedShadowInfo를 생성할 수 있는 FWholeSceneProjectedShadowInitializer를 생성합니다. 그런 뒤 호출이 성공하면 계속해서 FProjectedShadowInfo를 생성하는 함수입니다. 하지만 Directional light의 경우 라이트의 GetWholeSceneProjectedShadowInitializer의 구현부가 비어있습니다. 다른 라이트와 달리 이 처리를 다른 곳에서 처리합니다.
    7. Directional light의 경우 CSM을 사용하고 AddViewDependentWholeSceneShadowsForView 에서 FProjectedShadowInfo를 생성합니다. 함수 내부를 확인해 봅시다.

    그림6. FProjectedShadowInfo를 생성하기 위해서 FWholeSceneProjectedShadowInitializer를 구성하는 코드

    계속해서 AddViewDependentWholeSceneShadowsForView의 내부 구현을 보겠습니다.


    1. 각 View 별로 FProjectedShadowInfo를 생성하는데, 여기서는 View가 한 개라고 가정하고 계속 보겠습니다.
    2. VR에서 사용하는 스테레오 렌더링 관련 코드입니다. 여기서는 무조건 if 문 내부가 실행된다고 생각하면 됩니다. 이 부분은 VR에서 양쪽 눈에 대해 FProjectedShadowInfo를 두벌 만들지 않도록 해줍니다.
    3. CSM의 경우 메인카메라의 뷰프러스텀을 N개로 나눠서 렌더링 할 수 있습니다. ProjectionCount 에 이 개수가 담깁니다. 초록색 화살표를 따라가서 어떻게 ProjectionCount를 구하는지 봅시다.
    4. FarShadowCascadeCount를 계산합니다. FarShadowCascade 와 CSM 의 분할 개수를 합쳐서 ProjectionCount가 됩니다. FarShadowCascade의 위치는 메인 카메라 Eye 위치에서 가장 멀리 있는 분할 구간을 사용합니다. FarShadowCascade 의 경우 PrimitiveComponent에서 CastFarShadow가 체크된 경우만 FarShadowCascade 분할 구간에서 렌더링 될 수 있습니다. 멀리 있는 CSM 구간에 렌더링 될 프리미티브를 별도로 관리하고 싶은 경우에 활용할 수 있습니다.
    5. GetNumShadowMappedCascades 함수에서는, DynamicShadowCascades 변수를 기본 CSM 분할 개수로 사용하는 것을 볼 수 있습니다. DynamicShadowCascades 는 EffectiveNumDynamicShadowCascades 변수에 넣고 필요한 경우 업데이트합니다.
    6. GetCSMMaxDistance가 0보다 큰 경우 5번에서 준비한 EffectiveNumDynamicShadowCascades 를 CSM 개수로 사용합니다.
    7. GetCSMMaxDistance 함수 내에서는 r.Shadow.DistanceScale 콘솔 변수를 얻어옵니다. 만약 이 스케일이 0이면 최종적으로 Distance 변수가 0이 되면서 GetNumShadowMappedCascades는 0개가 됩니다.
    8. GetCSMMaxDistance 에서 GetEffectiveWholeSceneDynamicShadowRadius를 호출하여 설정해둔 CSM 거리를 구합니다.
    9. 8에서 얻은 Radius는 WholeSceneDynamicShadowRadius는 DirectionalLightComponent으로 부터 얻어온 값입니다. 이 값은 모빌리티에 따라서 DynamicShadowDistanceMovableLight 혹은 DynamicShadowDistanceStationaryLight 가 사용됩니다.
    10. CSM의 분할 수인 ProjectionCount를 구하는 함수를 모두 확인했습니다. 계속해서 구한 ProjectionCount 수만큼 FWholeSceneProjectedShadowInitializer를 만듭니다. 이 객체는 이름에서 유추할 수 있듯 FProjectedShadowInfo 를 초기화하는 데 사용하는 클래스입니다.
    11. LightSceneInfo.Proxy->GetViewDependentWholeSceneProjectedShadowInitializer 함수를 통해서 FWholeSceneProjectedShadowInitializer를 채웁니다. 초록색 화살표를 통해서 함수 내부에서 하는 일을 확인해봅시다.
    12. GetShadowSplitBounds 을 호출하여 메인 카메라 프러스텀 기준으로 CSM 을 통해 분할된 영역의 FSphere 바운드를 구합니다. 이때 InCascadeIndex는 현재 처리 중에 CSM 분할 영역의 인덱스입니다.
    13. FWholeSceneProjectedShadowInitializer 에 현재 케스케이드의 인덱스인 InCascadeIndex를 저장합니다.
    14. 그리고 계속해서 FWholeSceneProjectedShadowInitializer 에 CSM 분할 부분을 감싸는 프러스텀을 생성하기 위해 필요한 정보들을 채웁니다. 이 정보들을 사용하여 렌더링에 필요한 매트릭스들을 구성하게 됩니다.
    15. Scalability에 설정된 최대 CSM 텍스쳐의 해상도를 기반으로, CSM 텍스쳐의 해상도를 결정합니다.
    16. ComputeViewDependentWholeSceneShadowCacheModes 는 쉐도우 캐시 모드에 대한 설명입니다. 언리얼 5.0에서는 CSM 캐싱하는 기능이 추가되어있습니다. 분석할 코드를 최소화하기 위해서, 현재는 캐시를 하지 않고 계속해서 새로 쉐도우맵을 렌더링 한다고 가정하고 코드를 보겠습니다. 추후에 이 부분은 따로 리뷰해보면 좋을 것 같습니다.
    17. 드디어 FProjectedShadowInfo를 생성합니다. 이때 NumShadowMaps 만큼 순회하면서 생성하는데 17-1 코드를 보면 1개로 고정된 것을 알 수 있습니다. ComputeViewDependentWholeSceneShadowCacheModes 를 실행하는 도중 캐시 모드에 따라 숫자가 바뀔 수도 있는데, 캐시를 사용하지 않는 경우 1개입니다.
    18. FProjectedShadowInfo를 생성하고 SetupWholeSceneProjection 함수를 호출하여 필요한 정보들을 채웁니다. 이때 위에서 생성한 ProjectedShadowInitializer와 쉐도우맵 해상도 등의 정보가 전달되는 것을 볼 수 있습니다. 함수 내부 구현은 그림8에서 계속 보도록 하겠습니다.
    19. ShadowInfosThatNeedCulling 에 이번에 생성한 FProjectedShadowInfo를 추가합니다. 이 배열은 FDynamicShadowsTaskData의 ViewDependentWholeSceneShadows 입니다.

    그림7. FProjectedShadowInfo의 생성

    FProjectedShadowInfo 를 생성하고 필요한 정보들을 설정하는 부분을 보겠습니다.


    1. 연관된 LightSceneInfo나 쉐도우맵 해상도 그리고 기타 라이트 타입 등등의 필요한 기본 정보를 설정합니다.
    2. Directional light 인 경우 추가 설정을 해줍니다.
    3. Directional light의 경우 CSM 의 분할 인덱스에 맞게 FSphere 타입인 ShadowBound를 조정해줍니다.
    4. 쉐도우맵을 렌더링 할 때 사용할 MVP 매트릭스를 구하고 그것을 통해서 프러스텀의 바운드 박스를 구합니다.

    그림8. FProjectedShadowInfo의 데이터 설정

    호흡이 조금 길었습니다.. 지금까지 BeginInitDynamicShadows 의 AddViewDependentWholeSceneShadowsForView 함수 호출을 보았습니다. 다시 이 함수 위치로 돌아가서 코드를 보겠습니다.
    다음으로 실행되는 코드는 InitProjectedShadowVisibility 입니다. 여기서는 FProjectedShadowInfo의 Visibility와 Relevance를 설정합니다.


    1. 모든 라이트를 순회하면서 라이트가 소유한 FProjectedShadowInfo의 Visibility와 Relevance를 설정합니다.
    2. Visibility와 Relevance는 View.VisibleLightInfos 의 FVisibleLightViewInfo에 저장합니다. Visibility와 Relevance는 View에 따라 달라질 수 있기 때문에 모든 View에 대해서 이 정보들을 생성합니다. 여기서는 View가 1개로 가정하고 계속해서 코드를 보겠습니다. Visibility와 Relevance는 FProjectedShadowInfo 수만큼 생성될 것이므로 그 개수만큼 초기화해둡니다.
    3. 라이트가 소유한 FProjectedShadowInfo 를 순회합니다.
    4. 라이트가 해당 View의 뷰프러스텀 내에 있는 경우만 Visibility와 Relevance 를 갱신합니다.
    5. CSM의 경우 ParentSceneInfo가 없기 때문에 모든 Relevance가 true로 설정됩니다. ParentSceneInfo가 있는 경우는 SetPerObjectProjection 으로 FProjectedShadowInfo가 초기화된 경우입니다. 이 타입은 Staionary 타입의 라이트에 Movable 프리미티브를 쉐도우에 그릴 때 사용됩니다. 자세한 내용은 이후 글에서 계속해서 알아봅시다.
    6. 5번에서 설정한 Relevance를 2에서 초기화한 ProjectedShadowViewRelevanceMap에 저장합니다.
    7. ShadowRelevance 여부를 bPrimitiveIsShadowRelevance에 담아서 8번에서 사용됩니다.
    8. bPrimitiveIsShadowRelevance 가 true이고, 오클루젼 쿼리 결과 렌더링 가능하다면 2번에서 초기화한 ProjectedShadowVisibilityMap에 true를 설정합니다. 여기서는 오클루젼 쿼리를 사용하지 않는다고 가정하여 bShadowIsOccluded 가 false로 두고 코드를 보겠습니다.

    그림9. 각 View 와 라이트별로 Visibility 정보를 준비


    3.5. 렌더링 될 프리미티브 수집 시작

    3.5.1. 렌더링 될 프리미티브들이 포함된 Octree node 수집

    BeginInitDynamicShadows 로 돌아갑니다. 이다음에 실행되는 코드는 UpdatePreshadowCache입니다만 Preshadow 는 라이트 모빌리티가 Stationary 인 경우만 사용되는 쉐도우이므로 무시하겠습니다.
    그다음에 실행될 코드는 BeginGatherShadowPrimitives 입니다. 이 함수에서 실제 렌더링 될 프리미티브를 수집하게 됩니다. 그리고 함수 이름에 붙은 Begin 에서 예상할 수 있듯 이와 짝이 되는 함수가 있습니다. 바로 FinishGatherShadowPrimitives 함수이며 여기서 프리미티브 수집을 완료합니다. 이 함수는 뒤에서 계속해서 알아봅시다.


    1. BeginGatherShadowPrimitives 에서는 프리미티브를 모읍니다. FGatherShadowPrimitivesPrepareTask 를 사용하여 TaskGraphSystem에 작업을 요청합니다.
    2. AnyThreadTask 함수는 쉐도우맵을 렌더링 할 때 사용하는 Frustum과 교차하는 Octree node들을 구합니다. 그런 뒤 이렇게 구한 Octree node 있는 프리미티브를 대상으로 쉐도우맵에 렌더링 할지 여부를 결정합니다. 초록색 화살표를 따라가서 AnyThreadTask의 내용을 확인해봅시다.
    3. Octree 사용 여부입니다. 기본적으로 Octree를 사용하므로, 사용한다고 가정하고 코드를 보겠습니다.
    4. FindNodesWithPredicate 함수를 사용해 Octree node를 순회합니다. 이 함수에는 2개의 람다 함수가 인자로 입력됩니다. 첫 번째는 조건, 두 번째는 조건이 만족되었을 때 실행될 함수입니다.
    5. 첫 번째 람다 함수는 DynamicShadow를 위해 생성된 FProjectedShadowInfo의 Frustum과 Octree node가 교차하는지 비교하는 것을 볼 수 있습니다.
    6. 두 번째 람다 함수는 첫 번째 람다 함수가 성공한 경우 Octree node를 AddSubTask 함수를 사용해 모읍니다.
    7. AddSubTask 함수에서는 FGatherShadowPrimitivesPacket 클래스를 사용하여 Octree node를 추가해줍니다.
    8. 이제 수집한 FGatherShadowPrimitivesPacket 클래스들을 순회합니다.
    9. FGatherShadowPrimitivesPacket 또한 TaskGraphSystem에 전달하여 자신의 작업을 수행하도록 합니다. 이때 DonCompleteUntil 함수를 사용하여 FGatherShadowPrimitivesPrepareTask의 완료 Event가 FGatherShadowPrimitivesPacket 들이 완료되기 전까지 완료 처리되지 않도록 해줍니다. 그래서 FGatherShadowPrimitivesPrepareTask의 완료 Event가 완료되는 경우 FGatherShadowPrimitivesPacket 까지 모두 완료된다는 것을 보장해줍니다.. Packet의 AnyThreadTask가 어떤 일을 하는지는 그림11 에서 계속해서 보도록 합시다.

    그림10. BeginGatherShadowPrimitives에서 렌더링할 프리미티브가 포함된 Octree node 수집

     

    3.5.2. 수집된 Octree node 기준으로 쉐도우맵에서 렌더링 할 프리미티브 FProjectedShadowInfo에 수집

     

    1. FGatherShadowPrimitivesPacket 은 Octree node에 대한 정보와 프리미티브의 인덱스와 개수가 담겨 있습니다. 이전 그림10의 6번 코드를 보면 StartPrimitveIndex와 NumPrimitives 는 0으로 설정된 것을 알 수 있습니다.
    2. ViewDependentWholeSceneShadows.Num() 는 앞 과정에서 구한 FProjectedShadowInfo 의 수입니다. CSM 을 3개로 분할했다면 3개입니다.
    3. 각각의 FProjectedShadowInfo 마다 렌더링 할 프리미티브 정보를 ViewDependentWholeSceneShadowSubjectPrimitves 에 저장합니다. 이 배열에는 FPrimitiveSceneInfo와 이 프리미티브의 정보를 담고 있는 FAddSubjectPrimitiveResult 를 저장합니다. 어떤 내용이 저장되는지는 실제 프리미티브를 추가할 때 알아봅시다.
    4. Octree node 가 유효한지 확인합니다.
    5. Octree node 가 가진 프리미티브들을 순회합니다.
    6. 다이나믹 쉐도우를 사용하는지 여부를 확인합니다.
    7. FilterPrimitiveForshadows 함수를 호출하여 이 프리미티브를 추가해줍니다. 함수 내부는 그림12 에서 계속해서 알아봅시다.

    그림11. Octree node에 포함된 프리미티브들을 처리하는 FGatherShadowPrimitivesPacket 클래스

     

    1. 각각의 FProjectedShadowInfo 마다 이 프리미티브를 추가할지를 비교합니다.
    2. 프리미티브가 FProjectedShadowInfo 의 렌더링 영역 내에 있는지 확인합니다.
    3. 프리미티브가 일정 이하로 작다면 렌더링 하지 않도록 하기 위한 코드입니다. 프리미티브와 쉐도우의 카메라 Eye 위치 사이의 거리에 특정 Threshold 값(r.Shadow.RadiusThreshold 콘솔 변수에 설정되어있고 기본은 0.01f)을 곱해준 것을 기준 값으로 둡니다. 그리고 이 기준 값 보다 프리미티브의 바운드가 작으면 컬링 하게 됩니다.
    4. 캐시 모드는 현재 사용하지 않으므로 무시합니다. 
    5. 이 프리미티브를 추가할 수 있는지 여부를 최종적으로 확인합니다. ScreenSpaceSizeCulled, ShadowRelevance 기타 등등의 조건을 확인합니다.
    6. 이제 프리미티브를 현재 FProjectedShadowInfo 에 추가해줍니다. 이때 어떤 식으로 프리미티브를 추가했는지에 힌트를 FAddSubjectPrimitiveResult 에 담아서 반환해줍니다. 이 함수의 내부는 아래 그림13 에서 확인해봅시다.
    7. 현재 순회 중인 FProjectedShadowInfo 해당하는 ViewDependentWholeSceneShadowSubjectPrimitves 배열을 얻어옵니다.
    8. 7번에서 얻어온 배열에 Primitive 정보를 저장합니다.

    그림12. 각각의 FProjectedShadowInfo에 렌더링되야 하는 프리미티브들을 ViewDependentWholeSceneShadowSubjectPrimitives 에 수집

     

    이제 계속해서 FProjectedShadowInfo가 렌더링 할 프리미티브를 추가하는 AddSubjectPrimitive_AnyThread의 구현을 봅시다.


    1. Preshadow에서 사용한 경우 제외하기 위한 코드입니다. Preshadow는 여기서 다루지 않기 때문에 무시합니다.
    2. CurrentView로 DependentView를 설정합니다. 여기서 DependentView는 FProjectedShadowInfo의 SetupWholeSceneProjection 함수를 호출할 때 설정한 View 입니다. 이 View 로 부터 Relevance를 얻어옵니다.
    3. 2번 과정에서 얻어온 Relevance 정보를 사용하여 bShadowRelevance가 아닌 경우 프리미티브를 추가하지 않습니다.
    4. 프리미티브에 갱신해줘야 할 UniformBuffer와 StaticMeshes 를 확인하고 FAddSubjectPrimitiveResult 에 플래그를 설정해줍니다.
    5. StaticMeshes 배열에 엘리먼트가 있는지 확인합니다. 이 배열은 MeshDrawCommand를 만들 수 있는 MeshBatch 정보를 담고 있습니다.
    6. 여기서 그림12 의 3번에 있는 컬링을 한번 더 수행합니다. (이 부분은 중복되는 부분이기 때문에 제거되어도 괜찮지 않을까 싶네요.)
    7. bStaticRelevance 인 경우 캐시 된 MeshDrawCommand를 사용할 수 있을 것입니다. 이 함수를 통해서 캐시된 MeshDrawCommand를 사용하거나 혹은 아직 MeshDrawCommand가 만들어지지 않았다면 생성을 요청합니다. 이 함수의 내부는 초록색 화살표를 따라가서 계속 확인해 봅시다.
    8. Shadow 패스에서 렌더링 될 때 사용할 LOD 정보를 얻어옵니다.
    9. 현재 Directional light 의 쉐도우를 보고 있기 때문에 이 값은 true 입니다.
    10. ShouldDrawStaticMesh 함수에서는 프리미티브들을 사용할 수 있는지 여부를 확인합니다.
    11. 프리미티브에 쉐도우 캐스팅 옵션이 켜져 있는지, 렌더링 할 LOD 를 갖고 있는지 체크합니다.
    12. 캐시 된 MeshDrawCommand를 사용할 수 있다면 AddCachedMeshDrawCommands_AnyThread 를 호출하여 MeshDrawCommand를 추가합니다.
    13. AddCachedMeshDrawCommands_AnyThread 에서는 캐시 된 MeshDrawCommand를 사용여부 옵션을 확인합니다.  (콘솔명령어 r.MeshDrawCommands.UseCachedCommands를 확인함)
    14. 캐시 된 MeshDrawCommand를 사용할 수 있다면, AcceptMDC 함수를 통해서 캐시된 MeshDrawCommand를 사용할 수있도록 추가해줍니다.
    15. 만약 캐시된 MeshDrawCommand를 사용할 수 없다면, 매번 MeshBatch에서 MeshDrawCommand를 생성할 것입니다. 이 경우는 MeshDrawCommand를 새로 만들어달라고 요청할 수 있도록 AcceptMesh 함수를 통해 MeshBatch의 인덱스를 추가해줍니다.
    16. AcceptMDC 의 경우 FAddSubjectPrimitiveResult의 bCopyCachedMeshDrawCommand 에 true를 설정하여 캐시 된 MeshDrawCommand를 사용한다고 힌트를 남깁니다.
    17. AcceptMesh 의 경우 FAddSubjectPrimitiveResult의 bRequestMeshCommandBuild 에 true를 설정하여 MeshBatch 로부터 MeshDrawCommand를 만들 것이라고 힌트를 남깁니다.
    18. bDynamicRelevance 인 경우 캐시 된 MeshDrawCommand를 사용할 수 없습니다. 이 경우 항상 MeshBatch와 MeshDrawCommand를 새로 생성합니다. FAddSubjectPrimitiveResult의 bDynamicSubjectPrimitive를 true로 설정하여 이런 작업을 수행할 수 있게 힌트를 남깁니다.

    그림13. FProjectedShadowInfo 에 프리미티브 추가

    위 과정까지 마치게 되면 FilterPrimitiveForShadows 함수를 마치게 됩니다. 그러면 FGatherShadowPrimitivesPacket 의 작업이 완료됩니다. 그리고 FProjectedShadowInfo 들은 자신이 렌더링 해야 할 프리미티브 정보들을 모두 수집한 상태일 것입니다. 또한 어떤 프리미티브가 캐시 된 MeshDrawCommand를 사용해야 하는지 또는 새로 만들어야 할지 등의 힌트를 갖고 있을 것입니다.

     

    3.6. 프리미티브들을 렌더링 할 수 있게 준비하고 쉐도우 렌더타겟 생성

    3.6.1. 프리미티브들을 렌더링 가능하도록 준비

    이제 BeginGatherShadowPrimitives 함수의 호출을 마쳤습니다. TaskGraphSystem에다 다른 스레드에서 작업할 수 있게 작업을 넘겼기 때문에 렌더링 스레드는 다른 작업을 하다가 FinishGatherShadowPrimitives 함수에서 작업을 마무리합니다. 계속해서 수집한 프리미티브를 렌더링 할 수 있는 MeshDrawCommand로 만드는 코드를 봅시다.


    1. 이제 한동안 다른 작업을 하다가 InitViewsAfterPrepass 함수가 실행됩니다. 여기서 FinishInitDynamicShadows 함수가 호출됩니다. 이 함수만 확인하면 쉐도우를 렌더링 하기 위한 준비과정은 모두 마치게 됩니다.
    2. FinishGatherShadowPrimitives 함수에서는 FGatherShadowPrimitivesPacket 에서 FProjectedShadowInfo가 렌더링 하려고 모아둔 프리미티브와 사용 방식의 힌트를 갖고 FParallelMeshDrawCommandPass에 넘길 수 있도록 준비합니다. 캐싱된 MeshDrawCommand는 여기에서 복사되어 준비됩니다. 함수 내부 구현은 그림15 를 봐주세요.
    3. 쉐도우맵을 할당합니다. CSM의 경우 메인카메라를 일정 개수로 분할 하여 렌더링하기 때문에 여러개의 FProjectedShadowInfo를 가질 수 있다고 하였습니다. 여기서 각각의 FProjectedShadowInfo 당 쉐도우맵을 한개씩 가질 수 있지만 언리얼 기본 옵션은 한장의 쉐도우맵을 만들어서 아틀라스 형태로 각각의 FProjectedShadowInfo를 렌더링 합니다. 함수 내부 구현은 그림17 를 봐주세요.
    4. 여기서는 2번 과정에서 DynamicRelevance인 프리미티브를 따로 모아둔 배열을 사용하여 MeshBatch를 생성합니다. 그리고 FParallelMeshDrawCommandPass의 DispatchPassSetup 함수를 사용하여 렌더링 할 MeshDrawCommand를 준비시킵니다. 이 과정까지 실행하면 이제 쉐도우맵을 렌더링 하기 위한 모든 준비가 마쳐진 것입니다. 함수 내부 구현은 그림18 을 봐주세요.

    그림14. FinishInitDynamicShadows에서 렌더링에 필요한 모든 작업을 마무리함. 수집한 모든 프리미티브가 렌더링될 수 있게 준비하며, 쉐도우 렌더타겟을 생성함.

     

    먼저 FinishGatherShadowPrimitives를 봅시다.


    1. 언리얼 TaskGraphSystem에 요청했던 작업들이 완료되길 기다립니다. 이 작업이 완료되면 FGatherShadowPrimitivePacket 이 FProjectedShadowInfo 마다 렌더링 하려고 모아둔 프리미티브와 사용 방식 힌트 생성을 모두 완료됩니다.
    2. 프리미티브를 담아둘 배열을 크기를 미리 할당합니다. 초록색 화살표를 따라가 구현 내부를 확인해봅시다.
    3. FGatherShadowPrimitivePacket들의 RenderThreadFinalize 함수를 호출합니다. 이 함수에서 FParallelMeshDrawCommandPass 에 렌더링 할 프리미티브를 넘겨줄 수 있게 최종 준비합니다. 초록색 화살표를 따라가 구현 내부를 확인해봅시다.
    4. CSM 의 FProjectedShadowInfo들을 순회합니다. PreShadow의 경우는 이번에 확인할 쉐도우 타입이 아니므로 무시합니다.
    5. 캐시 모드 또한 이번에 살펴볼 기능이 아니므로 무시합니다.
    6. ViewDependentWholeSceneShadowSubjectPrimitives 배열에는 현재 순회 중인 FProjectedShadowInfo 가 렌더링 하려고 모아둔 프리미티브와 힌트 배열이 있습니다. 이제 프리미티브들 하나하나를 FParallelMeshDrawCommandPass 에 전달할 수 있게 준비합니다. 그림16 에서 함수 내부를 계속 확인해 보겠습니다.

    그림15. FinishGatherShadowPrimitives 에서는 이전과정에서 모아둔 프리미티브와 힌트를 사용하여 FParallelMeshDrawCommandPass에 넘길 데이터를 준비함.

     

    이제 FinalizeAddSubjectPrimitives 에서는 FParallelMeshDrawCommandPass 가 렌더링 할 수 있게 MeshDrawCommand를 준비할 수 있도록, FParallelMeshDrawCommandPass 가 받을 수 있는 형태로 렌더링 할 데이터들을 준비하는 작업이 수행합니다.


    1. 프리미티브 힌트가 캐시 된 MeshDrawCommand 타입인 경우입니다. 이 경우는 Static mobility 프리미티브로 FScene 에 캐시된 MeshDrawCommand를 재활용합니다. 그래서 캐시된 MeshDrawCommand를 복사해오는 것을 볼 수 있습니다. 이 MeshDrawCommand는 ShadowDepthPassVisibleCommands 로 복사합니다.
    2. 만약 Static mobility 프리미티브의 MeshDrawCommand가 아직 생성되지 않았다면 MeshDrawCommand를 생성해야 합니다. 혹은 MeshDrawCommand를 캐시하는 기능을 사용하지 않으면 매번 MeshDrawCommand를 다시 생성해야합니다. 이 경우 SubjectMeshCommandBuildRequests 배열에 MeshBatch를 담아서 MeshDrawCommand를 생성시키도록 합니다.
    3. Dynamic mobility 프리미티브의 경우 매번 MeshBatch와 MeshDrawCommand를 새로 생성합니다. 이 경우 DynamicSubjectPrimitives 배열에 프리미티브를 담아서 이 과정이 수행되도록 합니다.

    그림16. Relevance (static or dynamic) 에 따라서 FParallelMeshDrawCommandPass에 넘길 프리미티브 데이터를 준비

     

    3.6.2. 쉐도우 렌더타겟 생성

    이제 프리미티브들의 준비를 마쳤으므로 쉐도우맵 렌더링에 사용할 렌더타겟을 생성합니다. 다시 FinishInitDynamicShadows 함수로 돌아가 AllocateShadowDepthTarget를 봅시다.


    1. 모든 라이트를 순회합니다.
    2. 라이트는 자신이 렌더링 해야 하는 FProjectedShadowInfo를 갖고 있고, 이 것을 각각을 순회합니다.
    3. CSM의 경우 프로젝션이 필요하기 때문에 bNeedProjection 은 true가 됩니다.
    4. 프로젝션이 필요한 경우이기 때문에 ShadowsToProject 배열에 FProjectedShadowInfo를 담아줍니다.
    5. 쉐도우맵에 렌더링 하고, Directional light CSM 을 사용하고 있는 경우 FProjectedShadowInfo를 적절한 배열에 담아줍니다.
    6. Directional light CSM 의 경우 WholeSceneDirectionalShadows 배열에 FProjectedShadowInfo를 담아줍니다.
    7. 캐시 된 쉐도우에 대해서는 알아보지 않고 있으므로 AllocatedCachedShadowDepthTargets 에서는 아무런 작업도 수행하지 않습니다.
    8. 여기서 CSM의 쉐도우맵을 할당합니다. 초록색 선을 따라가 계속해서 함수 내부를 확인해봅시다.
    9. FLayoutAndAssignedShadows는 FProjectedShadowInfo와 쉐도우맵을 서로 짝지어주는 역할을 합니다. 일단 최소 1개의 FLayoutAndAssignedShadows는 항상 존재할 것이므로 먼저 할당합니다. 여기서 알아두면 좋은 것은 CSM의 경우 메인 카메라의 Frustum을 N개로 분할하여 렌더링 합니다. 그리고 여기서는 아틀라스 형태로 N개의 렌더링 된 결과를 텍스쳐에 저장합니다. 만약 CSM이 3개의 FProjectedShadowInfo를 가지고 모두 아틀라스화 된다면 3개의 FProjectedShadowInfo와 1개의 렌더타겟이 서로 연결됩니다.
    10. 모든 FProjectedShadowInfo에 대해서 순회합니다.
    11. 만약 아틀라스 화하여 CSM의 분할 부분을 저장하지 않는다면, 이 부분에서 FLayoutAndAssignedShadows를 추가해줍니다. 즉, 렌더타겟 1개에 FProjectedShadowInfo 1개가 대응됩니다.
    12. 앞에서 추가한 Layouts 에다 FProjectedShadowInfo를 렌더링 할 텍스쳐에 영역을 설정합니다.
    13. 그리고 Layouts의 Shadows 배열에 FProjectedShadowInfo를 담아줍니다.
    14. 이제 생성된 Layouts을 순회합니다. 위에서 CSM을 아틀라스화 했기 때문에 Layouts는 1개입니다.
    15. OutAtlases 배열에 FSortedShadowMapAtlas 를 하나 할당해줍니다. 여기에 Layouts 의 FProjectedShadowInfo들과 렌더타겟의 정보를 FSortedShadowMapAtlas 클래스로 옮겨줄 것입니다.
    16. ShadowMapAtlas.Rendertargets.DepthTarget에 쉐도우맵 렌더타겟을 생성합니다.
    17. 현재 Layout이 갖고 있는 FProjectedShadowInfo를 ShadowMapAtlas에 담아줍니다. CSM 분할이 3개인 경우 3개가 담깁니다.

    그림17. 쉐도우맵의 렌더타겟을 생성

     

    이까지 수행하면 CSM을 위한 렌더타겟 생성은 모두 완료됩니다. 

    다시 FinishInitDynamicShadows 함수로 돌아가 GatherShadowDynamicMeshElement를 봅시다. 이 함수가 쉐도우 렌더링 준비의 마지막입니다.
    이 부분은 Dynamic mobility 인 프리미티브의 MehBatch를 생성하고 FParallelMeshDrawCommandPass 에 렌더링 할 내용들을 전달하여 MeshDrawCommand의 렌더링 준비를 요청하는 부분입니다.
    1. SortedShadowsForShadowDepthPass는 바로 전 과정에서 쉐도우 렌더타겟과 FProjectedShadowInfo를 연결시킨 클래스입니다. 이것을 순회합니다.
    2. Atlas가 가지고 있는 FProjectedShadowInfo들이 MeshBatch를 생성할 수 있게 GatherDynamicMeshElements를 호출합니다. 초록색 화살표를 따라가 함수 내부를 확인해 봅시다.
    3. DynamicSubjectPrimitives 는 그림16 의 3번에서 Movable mobility를 가진 프리미티브를 모아둔 부분입니다. 이런 프리미티브들은 MeshBatch를 캐싱해두지 않기 때문에 여기서 만들어줍니다.
    4. 이제 GatherDynamicMeshElementsArray 함수를 호출하여 MeshBatch를 생성합니다. 초록색 화살표를 따라가 함수 내부를 봅시다.
    5. FSimpleElementCollector는 MeshBatch를 생성합니다. 이 단계에서의 모든 프리미티브는 이 Collector를 통해 MeshBatch를 생성하게 됩니다.
    6. 처리할 모든 DynamicRelevance 프리미티브를 순회합니다.
    7. 프리미티브마다 GetDynamicMeshElement를 호출합니다. 이 함수에 5번 과정에서 본 Collector를 함께 넘깁니다. 이 함수 내부의 동작 과정은 이전 MeshDrawCommand에서 소개하고 있습니다. MeshDrawCommand에 대한 내용인 레퍼런스2레퍼런스3을 참고해주세요.
    8. 모든 FProjectedShadowInfo의 MeshBatch 생성이 완료되었습니다. 이제 FParallelMeshDrawCommandPass 를 준비할 시간입니다. SetupMeshDrawCommandsForShadowDepth 함수를 호출합니다. 스텐실 렌더링은 하지 않기 때문에 SetupMeshDrawCommandsForProjectionStenciling 함수에서는 아무것도 하지 않습니다.
    9. FShadowDepthPassMeshProcessor 를 생성합니다. 이 MeshPassProcessor 는 ShadowDepthPass의 MeshDrawCommand를 생성하는 기능이 있습니다.
    10. 이제 FParallelMeshDrawCommandPass 에 준비된 모든 데이터를 전달합니다. 그리고 MeshDrawCommand를 최종적으로 준비합니다.
     - DynamicSubjectMeshElements : Movable mobility 프리미티브로 생성된 MehBatch. 이 것을 기반으로 MeshDrawCommand를 생성
     - SubjectMeshCommandBuildRequests : 이번에 새로 생성해야 할 MeshDrawCommand 리스트
     - ShadowDepthPassVisibleCommands : Static mobility 프리미티브로 생성된 캐시 된 MeshDrawCommand 리스트

    그림18. DynamicRelevance인 프리미티브들의 MeshBatch를 생성하고, 모든 종류의 프리미티브들이 렌더링 할 준비를 할 수 있도록 FParallelMeshDrawCommandPass에 데이터 전달

     

    3.7. 쉐도우맵에 프리미티브 렌더링

    이제 쉐도우맵에 렌더링 하는 과정만 남아있습니다. 모든 렌더링 함수를 처리하는 Render 함수로 이동합니다.


    1. RenderShadowDepthMaps 함수를 수행합니다.
    2. GPUScene에 업로드할 프리미티브가 있다면 갱신합니다.
    3. CSM의 경우 RenderShadowDepthMapAtlas를 호출하여 쉐도우맵을 렌더링 합니다.
    4. SortedShadowsForShadowDepthPass 를 순회합니다. 이 배열에 FProejctedShadowInfo와 그에 대응하는 쉐도우 렌더타겟 정보가 연결되어 있습니다.
    5. 현재 순회 중인 아틀라스의 FProjectedShadowInfo 들을 순회하여 ParallelShadowPasses 또는 SerialShadowPasses 배열에 담습니다.
    6. 기본은 순차 렌더링이기 때문에 SerialShadowPasses 배열에 담습니다.
    7. 렌더타겟을 클리어하는 패스를 추가합니다.
    8. SerialShadowPasses 에 담은 FProjectedShadowInfo를 순회합니다.
    9. FProjectedShadowInfo의 RenderDepth 함수를 호출하여 렌더링을 진행합니다. 그림20 에서 함수의 내부 구현을 봅시다.

    그림19. 쉐도우맵을 렌더링하기 위해 최종 준비를 하고 RenderDepth 함수를 호출

     

    마지막 RenderDepth 함수입니다.


    1. SetupShadowDepthPassUniformBuffer 함수로 쉐도우 패스에서 사용할 UniformBuffer를 설정합니다.
    2. 쉐도우 프로젝션, 뷰 매트릭스를 설정합니다. 이외에도 다양한 파라메터들을 설정합니다.
    3. IntanceCullingContext를 사용한다면 여기서 Indirect draw를 위해서 렌더 커맨드를 준비하거나 인스턴싱을 위한 버퍼를 준비합니다. 이 과정은 GPUSene 에 대한 내용인 레퍼런스4레퍼런스5 에서 자세히 다룹니다. 여기서는 최종 렌더링을 위해 준비한다고 생각하고 계속 진행하겠습니다. 초록색 선을 따라가 함수 내부를 봅시다.
    4. FParallelMeshDrawCommandPass 에 렌더링 할 MeshDrawCommand를 준비해달라고 요청한 작업 완료를 기다립니다.
    5. InstanceCullingContext를 사용하여 Indirect draw 버퍼나 인스턴싱을 위한 버퍼를 준비합니다.
    6. 이제 FParallelMeshDrawCommandPass에 쉐도우맵에 렌더링을 요청합니다.

    그림20. 최종적으로 쉐도우맵에 렌더링

     

    4. 레퍼런스

    1. Unreal Engine 5 (5.0 branch 2170f78d3d94abd7b24b7d6d5c5104cfb43245ea)

    2. [UE5] MeshDrawCommand (1/2)
    3. [UE5] MeshDrawCommand (2/2)
    4. [UE5] GPUScene 과 Auto-Instancing (1/2)
    5. [UE5] GPUScene 과 Auto-Instancing (2/2)

    6. Cascade Shadow Map

     

     

     
     
     
     
     
     
     
     
     

    댓글

Designed by Tistory & scahp.