{{ label!='' ? 'Label : ' : (q!='' ? '검색 : ' : '전체 게시글') }} {{ label }} {{ q }} {{ ('('+(pubs|date:'yyyy-MM')+')') }}

[코딩의탑] 4층: 툰 쉐이딩

가끔은 만화스러운 느낌이 좋을 때도 있죠.

  셀 쉐이딩(Cel shading)이라고도 불리는 툰 쉐이딩 기법은 절제된 빛 표현으로 화면을 그리는 비사실적(non-photorealistic) 렌더링 기법 중 하나입니다. 

<Borderlands 시리즈>

  이러한 툰 쉐이딩은 다양한 방식으로 실현되고 있으며, 언리얼 엔진을 활용한 툰 쉐이딩 역시 어렵지 않습니다. 특히, 포스트 프로세싱을 이용하여 만화스럽게 후처리를 해주는 방법은 클라이언트 프로그래머들이 손쉽게 사용할 수 있는 방법입니다.

  하지만, 포스트 프로세싱을 이용한 툰 쉐이딩은 다음과 같은 단점이 존재하는데요:

  • 포스트 프로세싱 자체의 비용이 존재합니다.
  • 특정한 머티리얼에만 툰 쉐이딩을 적용하기 어렵습니다. (커스텀 스텐실 버퍼를 활용하는 것이 대표적인 방법이며, 이는 개발자를 피곤하게 합니다)

  따라서, 우리는 포스트 프로세싱을 활용하지 않는 툰 쉐이딩을 구현해보도록 하죠. 우리는 다음을 구현할 것입니다:

  • 특정 물체의 윤곽선을 추가로 렌더링합니다.
  • 특정 물체의 음영을 부드럽지 않은 단계별 음영으로 변경합니다.
  • 이들의 사용 여부와 윤곽선 굵기, 색을 머티리얼 에디터 속성으로 결정할 수 있습니다.
  • 반복되는 컴파일을 줄이기 위해 머티리얼 인스턴스로 만들 수 있으며, 윤곽선의 굵기를 재컴파일 없이 변경할 수 있습니다.

  이는 언리얼 엔진 5의 소스를 직접 수정하는 방법임에 주의하세요. 이러한 방법 역시 마법이 아니므로, 비용이 존재합니다. 이는 차차 이야기하도록 하겠습니다. 그 전에 언리얼 엔진 5를 살펴보도록 하죠.


렌더링 패스

디퍼드 쉐이딩으로 렌더링에 필요한 다양한 정보를 담는 버퍼들

  언리얼 엔진은 기본적으로 PC 게임에 한해 디퍼드 쉐이딩을 수행합니다. 디퍼드 쉐이딩이란 포워드 쉐이딩의 반대 개념입니다. 한 물체를 렌더링한다고 생각해보죠. 그 물체의 3차원 세계 좌표와 물체 표면의 법선, 표면 거칠기의 정도, 표면에 매핑된 텍스처와 변위 등 수많은 요소들을 고려해 우리 눈에 보이는 물체의 색깔을 결정할 수 있을 것입니다. 

  포워드 쉐이딩은 이들을 한 번에 고려해 한 번에 쉐이딩합니다. 반면 디퍼드 쉐이딩은 렌더링에 필요한 각각의 정보를 각각의 버퍼에 저장하고, 이들을 종합해 마지막의 쉐이딩 결과를 얻습니다. 디퍼드 쉐이딩은 조금 더 비용이 높지만, 유연하게 화면을 렌더링할 수 있다는 장점이 있습니다.

  따라서 최종 화면을 렌더링하기 위한 일련의 작업들이 존재할 것입니다. 이들을 추상화한 것이 렌더링 패스입니다. 렌더링 패스의 개요에 대해서는 여기를 참고하세요. 우리는 이러한 일련의 작업들에 우리만의 렌더링 작업을 삽입하여 툰 쉐이딩을 수행할 것입니다.


머티리얼과 쉐이더

  언리얼 엔진은 클라이언트 프로그래머, 아티스트가 머티리얼을 손쉽게 만들 수 있는 시스템을 갖추고 있습니다. 머티리얼 노드 그래프는 쉐이더로 번역되며, 쉐이더는 엔진 내부에서 실행돼 렌더링을 수행하죠.


조감도

  언리얼 엔진이 화면을 렌더링하는 단계를 대략적으로 상상해봅시다. 아마 다음과 같을 것입니다. 정확한 세부사항이 아님에 주의하세요:

  1. 사전에 결정된 렌더링 패스들을 확인하고, 화면의 한 프레임을 렌더링할 준비를 합니다.
  2. 각 렌더링 패스에 대해서 3~5를 수행합니다.
  3. 해당 렌더링 패스에서 사용할 쉐이더를 결정합니다.
  4. 렌더링해야 할 오브젝트와 그렇지 않은 오브젝트를 구분합니다. 예를 들어, 하늘을 그리기 위한 렌더링 패스에서는 월드 오브젝트들의 세부사항에 주목하지 않아도 됩니다.
  5. 렌더링을 수행합니다.
  6. 마지막으로, 게임 사용자에게 보일 최종 화면을 렌더링합니다.


윤곽선

  만화스러운 표현을 극대화시켜주는 요소 중 하나는 바로 뚜렷한 물체의 윤곽선입니다. 이러한 윤곽선을 구현하는 방법은 여러가지가 있습니다. 포스트 프로세싱을 이용한 방법으로는 대표적으로 Sobel Filter를 활용한 경계선 검출이 있습니다. 최종 렌더링된 색을 이용해서 경계선을 검출하기도 하고, 깊이 버퍼와 법선 버퍼를 이용해서 경계선을 검출하기도 하죠. 우리는 Wireframe Method라고도 불리는 Vertex Extrusion을 활용할 것입니다.

  이는 아주 단순합니다. 윤곽선이라 함은 해당 물체를 덮는 무언가입니다. 우리가 특정 물체의 윤곽선을 그리고 싶다면, 해당 물체보다 살짝 크기만 키워서 렌더링한다면 그 윤곽을 그대로 얻을 수 있을 것입니다. 다만 크기를 키운 물체가 기존의 물체를 완전히 덮어버려 의미가 없어질 뿐이죠.

  이런 문제는 후면 선별(Backface culling)을 역이용하여 해결할 수 있습니다. 후면 선별이란  뒤를 바라보고 있는 삼각형(Primitive)을 굳이 그리지 않는 것입니다. 사람을 그린다고 생각해보죠. 사람의 등을 구성하는 부분을 화면에 색칠할 이유는 없습니다. 어차피 가슴과 배를 그릴 때, 등의 부분들은 뒤에 있는 부분들이므로 덮어씌워질 것이기 때문이죠. 이러한 문제를 해결하기 위해서 3차원 모델의 삼각형들 중 뒤를 바라보고 있는 삼각형은 애초에 그리지 않습니다. 우리를 바라보고 있는 평면들만 렌더링하게 되죠. 다음 그림처럼요:

Backface Culling

  후면 선별을 반대로 수행하면 어떻게 될까요? 이 경우, 내부가 보이게 됩니다. 다음 그림처럼요:

Frontface Culling

  이 상태에서, 조금 크기를 줄여 직육면체를 하나 더 렌더링해보죠. 후면 선별을 반대로 수행한 크기가 큰 오브젝트는 주황색, 정상적으로 렌더링한 크기가 작은 오브젝트는 파란색입니다:

Vertex Extrusion

  여러분의 눈이 카메라에 있다고 생각해보세요. 정상적인 파란색 오브젝트가 보이고 그 뒤에 윤곽선으로만 주황색의 오브젝트가 보이게 될 것입니다. 주황색 오브젝트를 우리가 원하는 윤곽선의 색깔로만 렌더링하게 된다면, 윤곽선이 만들어지는 것이죠. 멋진 방법이지 않나요? 

  이러한 방식에는 당연히 다음과 같은 비용이 있습니다: 오브젝트를 두 번 렌더링하게 된다는 것이죠. 화면에 윤곽선을 그려야 할 오브젝트가 많으면 많을수록, 그려야 할 오브젝트가 복잡하면 복잡할수록 비용이 증가합니다.


윤곽선 렌더링 패스 추가하기

  특정 오브젝트를 우리 방식으로 새롭게 렌더링해야 하므로, 렌더링 패스를 추가할 수 있습니다. 언리얼 엔진 5는 Engine/Source/Runtime/Renderer/Public/MeshPassProcessor.h 파일에서 EMeshPass를 관리합니다. 이는 렌더링 패스의 종류를 열거형으로 선언한 것입니다. 이곳에 우리의 렌더링 패스를 추가합니다. 이때, EMeshPass가 변경되었으므로 이를 검증하는 정적 assertion 문들 역시 적절히 수정해야 합니다.


소스 코드: MeshPassProcessor.h 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
// ...

/** Mesh pass types supported. */
namespace EMeshPass
{
    enum Type : uint8
    {
        DepthPass,
        BasePass,
        AnisotropyPass,
        SkyPass,
        SingleLayerWaterPass,
        SingleLayerWaterDepthPrepass,
        CSMShadowDepth,
        VSMShadowDepth,
        Distortion,
        Velocity,
        TranslucentVelocity,
        TranslucencyStandard,
        TranslucencyStandardModulate,
        TranslucencyAfterDOF,
        TranslucencyAfterDOFModulate,
        TranslucencyAfterMotionBlur,
        TranslucencyAll, /** Drawing all translucency, regardless of separate or standard.  Used when drawing translucency outside of the main renderer, eg FRendererModule::DrawTile. */
        LightmapDensity,
        DebugViewMode, /** Any of EDebugViewShaderMode */
        CustomDepth,
        MobileBasePassCSM,  /** Mobile base pass with CSM shading enabled */
        VirtualTexture,
        LumenCardCapture,
        LumenCardNanite,
        LumenTranslucencyRadianceCacheMark,
        LumenFrontLayerTranslucencyGBuffer,
        DitheredLODFadingOutMaskPass, /** A mini depth pass used to mark pixels with dithered LOD fading out. Currently only used by ray tracing shadows. */
        NaniteMeshPass,
        MeshDecal,

        // Begin nyvux's modify : Toon shading
        ToonOutlinePass,
        // End nyvux's modify : Toon shading

#if WITH_EDITOR
        HitProxy,
        HitProxyOpaqueOnly,
        EditorLevelInstance,
        EditorSelection,
#endif

        Num,
        NumBits = 6,
    };
}
static_assert(EMeshPass::Num <= (1 << EMeshPass::NumBits), "EMeshPass::Num will not fit in EMeshPass::NumBits");
static_assert(EMeshPass::NumBits <= sizeof(EMeshPass::Type) * 8, "EMeshPass::Type storage is too small");

inline const TCHAR* GetMeshPassName(EMeshPass::Type MeshPass)
{
    switch (MeshPass)
    {
    case EMeshPass::DepthPass: return TEXT("DepthPass");
    case EMeshPass::BasePass: return TEXT("BasePass");
    case EMeshPass::AnisotropyPass: return TEXT("AnisotropyPass");
    case EMeshPass::SkyPass: return TEXT("SkyPass");
    case EMeshPass::SingleLayerWaterPass: return TEXT("SingleLayerWaterPass");
    case EMeshPass::SingleLayerWaterDepthPrepass: return TEXT("SingleLayerWaterDepthPrepass");
    case EMeshPass::CSMShadowDepth: return TEXT("CSMShadowDepth");
    case EMeshPass::VSMShadowDepth: return TEXT("VSMShadowDepth");
    case EMeshPass::Distortion: return TEXT("Distortion");
    case EMeshPass::Velocity: return TEXT("Velocity");
    case EMeshPass::TranslucentVelocity: return TEXT("TranslucentVelocity");
    case EMeshPass::TranslucencyStandard: return TEXT("TranslucencyStandard");
    case EMeshPass::TranslucencyStandardModulate: return TEXT("TranslucencyStandardModulate");
    case EMeshPass::TranslucencyAfterDOF: return TEXT("TranslucencyAfterDOF");
    case EMeshPass::TranslucencyAfterDOFModulate: return TEXT("TranslucencyAfterDOFModulate");
    case EMeshPass::TranslucencyAfterMotionBlur: return TEXT("TranslucencyAfterMotionBlur");
    case EMeshPass::TranslucencyAll: return TEXT("TranslucencyAll");
    case EMeshPass::LightmapDensity: return TEXT("LightmapDensity");
    case EMeshPass::DebugViewMode: return TEXT("DebugViewMode");
    case EMeshPass::CustomDepth: return TEXT("CustomDepth");
    case EMeshPass::MobileBasePassCSM: return TEXT("MobileBasePassCSM");
    case EMeshPass::VirtualTexture: return TEXT("VirtualTexture");
    case EMeshPass::LumenCardCapture: return TEXT("LumenCardCapture");
    case EMeshPass::LumenCardNanite: return TEXT("LumenCardNanite");
    case EMeshPass::LumenTranslucencyRadianceCacheMark: return TEXT("LumenTranslucencyRadianceCacheMark");
    case EMeshPass::LumenFrontLayerTranslucencyGBuffer: return TEXT("LumenFrontLayerTranslucencyGBuffer");
    case EMeshPass::DitheredLODFadingOutMaskPass: return TEXT("DitheredLODFadingOutMaskPass");
    case EMeshPass::NaniteMeshPass: return TEXT("NaniteMeshPass");
    case EMeshPass::MeshDecal: return TEXT("MeshDecal");
        // Begin nyvux's modify : Toon shading
    case EMeshPass::ToonOutlinePass: return TEXT("ToonOutlinePass");
        // End nyvux's modify : Toon shading
#if WITH_EDITOR
    case EMeshPass::HitProxy: return TEXT("HitProxy");
    case EMeshPass::HitProxyOpaqueOnly: return TEXT("HitProxyOpaqueOnly");
    case EMeshPass::EditorLevelInstance: return TEXT("EditorLevelInstance");
    case EMeshPass::EditorSelection: return TEXT("EditorSelection");
#endif
    }

#if WITH_EDITOR
    // nyvux's modify : 29 -> 30
    static_assert(EMeshPass::Num == 30 + 4, "Need to update switch(MeshPass) after changing EMeshPass"); // GUID to prevent incorrect auto-resolves, please change when changing the expression: {A6E82589-44B3-4DAD-AC57-8AF6BD50DF43}
#else
    // nyvux's modify : 29 -> 30
    static_assert(EMeshPass::Num == 30, "Need to update switch(MeshPass) after changing EMeshPass"); // GUID to prevent incorrect auto-resolves, please change when changing the expression: {A6E82589-44B3-4DAD-AC57-8AF6BD50DF43}
#endif

// ...
cs


  그리고 FDeferredShadingSceneRenderer의 Render() 함수에서 중간에 윤곽선 렌더링 패스를 수행하도록 추가해주어야 합니다. 이때 호출되는 함수는 부모 클래스인 FSceneRenderer 클래스가 구현하고 있습니다. 즉, FSceneRenderer는 각 렌더링 패스를 수행하는 로직을 지녔으며, 이들의 순서나 매개변수는 자식 클래스인 FDeferredShadingSceneRenderer가 결정하는 셈입니다.


소스 코드: SceneRendering.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    // ...

    bool RenderCustomDepthPass(
        FRDGBuilder& GraphBuilder,
        FCustomDepthTextures& CustomDepthTextures,
        const FSceneTextureShaderParameters& SceneTextures,
        TConstArrayView<Nanite::FRasterResults> PrimaryNaniteRasterResults,
        TConstArrayView<Nanite::FPackedView> PrimaryNaniteViews,
        bool bNaniteProgrammableRaster);

    // Begin nyvux's modify : Toon shading
    void RenderToonOutlinePass(
        FRDGBuilder& GraphBuilder,
        FRDGTextureRef SceneColorTexture);
    // End nyvux's modify : Toon shading

    void OnStartRender(FRHICommandListImmediate& RHICmdList);

    // ...
cs


소스 코드: DeferredShadingRenderer.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    // ...

    GraphBuilder.SetCommandListStat(GET_STATID(STAT_CLM_Lighting));

    RenderLights(GraphBuilder, SceneTextures, TranslucencyLightingVolumeTextures, LightingChannelsTexture, SortedLightSet);

    // Begin nyvux's modify : Toon shading
    RenderToonOutlinePass(GraphBuilder,
        SceneTextures.Color.Target);
    // End nyvux's modify : Toon shading

    GraphBuilder.SetCommandListStat(GET_STATID(STAT_CLM_AfterLighting));

    // ...
cs


  RenderToonOutlinePass()는 해당 렌더링 패스에서 어떤 쉐이더를 통해, 어떤 오브젝트들을 렌더링할 것인지 결정해야 합니다. 

  •   먼저, 우리가 렌더링할 각 뷰들이 있을 것입니다. 대표적인 예로 플레이어가 보는 화면이죠. 또, 플레이어가 게임 내의 모니터 오브젝트를 통해 CCTV 화면을 보고 있다고 생각해보세요. 그 화면은 CCTV가 있는 좌표에 카메라를 두어서 임시 뷰에 렌더링을 한 뒤, 그것을 플레이어 화면 내 모니터 오브젝트 평면에 적절히 변환해 그려주게 될 것입니다.
  •   씬(Scene, World의 렌더러 표현) 내의 어떤 오브젝트들을 그릴지도 결정해야 합니다. 가령, 절두체 선별된 오브젝트들은 굳이 그릴 필요가 없습니다. 이러한 "그릴 오브젝트"들의 단위는 MeshBatch로 관리됩니다. 메쉬는 정적 메쉬와 동적 메쉬로 나뉘는데, 정적 메쉬의 경우 움직이지 않는 메쉬의 경우로 생각할 수 있습니다. 이들은 변하지 않으므로, MeshBatch 역시 게임 시작에만 생성되고 변하지 않습니다. 반면 동적 메쉬는 움직이는 메쉬(스켈레탈 메쉬 등)의 경우로 생각할 수 있습니다. 이들은 매 프레임마다 새롭게 생성되어 관리됩니다. 이런 MeshBatch들을 생성하고 관리하는 것은 우리의 영역이 아닙니다. 다만 FPrimitiveSceneInfo를 통해서 이들에 접근할 수 있죠.
  •   마지막으로, 어떤 쉐이더를 통해서 그려야 할 지 결정해야 합니다. 

  이들을 PassParameters에 정리합니다. FToonOutlineMeshPassProcessor::AddMeshBatch()는 주어진 MeshBatch가 윤곽선을 그려야 한다면, 쉐이더들을 연결하고 정점들의 입력을 도와줍니다. 마지막으로, GraphBuilder::AddPass()를 통해 우리가 원하는 렌더링이 적절한 시기에 수행되도록 엔진에게 요청합니다. 다음 코드를 참고하세요:


소스 코드: ToonOutlineRendering.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
#include "ToonOutlinePassRendering.h"

#include "RHIStaticStates.h"
#include "SimpleMeshDrawCommandPass.h"
#include "ScenePrivate.h"
#include "SceneRendering.h"
#include "CustomDepthRendering.h"
#include "ToonOutlineMeshPassProcessor.h"
#include "StaticMeshBatch.h"

FInt32Range GetDynamicMeshElementRange(const FViewInfo& View, uint32 PrimitiveIndex)
{
    int32 Start = 0;    // inclusive
    int32 AfterEnd = 0;    // exclusive

    // DynamicMeshEndIndices contains valid values only for visible primitives with bDynamicRelevance.
    if (View.PrimitiveVisibilityMap[PrimitiveIndex])
    {
        const FPrimitiveViewRelevance& ViewRelevance = View.PrimitiveViewRelevanceMap[PrimitiveIndex];
        if (ViewRelevance.bDynamicRelevance)
        {
            Start = (PrimitiveIndex == 0) ? 0 : View.DynamicMeshEndIndices[PrimitiveIndex - 1];
            AfterEnd = View.DynamicMeshEndIndices[PrimitiveIndex];
        }
    }

    return FInt32Range(Start, AfterEnd);
}

BEGIN_SHADER_PARAMETER_STRUCT(FToonOutlineMeshPassParameters, )
SHADER_PARAMETER_STRUCT_REF(FViewUniformShaderParameters, View)
SHADER_PARAMETER_STRUCT_INCLUDE(FInstanceCullingDrawParams, InstanceCullingDrawParams)
RENDER_TARGET_BINDING_SLOTS()
END_SHADER_PARAMETER_STRUCT()

void FSceneRenderer::RenderToonOutlinePass(
    FRDGBuilder& GraphBuilder,
    FRDGTextureRef SceneColorTexture)
{
    for (int32 ViewIndex = 0; ViewIndex < Views.Num(); ++ViewIndex)
    {
        FViewInfo& View = Views[ViewIndex];
        if (View.Family->Scene == nullptr)
        {
            UE_LOG(LogShaders, Log, TEXT("View.Family->Scene is NULL! GettingNextNow... - RenderToonOutlinePass()"));
            continue;
        }
        FSimpleMeshDrawCommandPass* SimpleMeshPass = GraphBuilder.AllocObject<FSimpleMeshDrawCommandPass>(View, nullptr);

        FMeshPassProcessorRenderState DrawRenderState;
        DrawRenderState.SetDepthStencilState(TStaticDepthStencilState<true, CF_LessEqual>().GetRHI());

        FToonOutlineMeshPassProcessor MeshProcessor(
            Scene,
            &View,
            DrawRenderState,
            SimpleMeshPass->GetDynamicPassMeshDrawListContext());

        // Gather & Flitter MeshBatch from Scene->Primitives.
        for (int32 PrimitiveIndex = 0; PrimitiveIndex < Scene->Primitives.Num(); PrimitiveIndex++)
        {
            const FPrimitiveSceneInfo* PrimitiveSceneInfo = Scene->Primitives[PrimitiveIndex];
            if (View.PrimitiveVisibilityMap[PrimitiveSceneInfo->GetIndex()])
            {
                const FPrimitiveViewRelevance& ViewRelevance = View.PrimitiveViewRelevanceMap[PrimitiveSceneInfo->GetIndex()];

                if (ViewRelevance.bRenderInMainPass && ViewRelevance.bStaticRelevance)
                {
                    for (int32 StaticMeshIdx = 0; StaticMeshIdx < PrimitiveSceneInfo->StaticMeshes.Num(); StaticMeshIdx++)
                    {
                        const FStaticMeshBatch& StaticMesh = PrimitiveSceneInfo->StaticMeshes[StaticMeshIdx];

                        if (View.StaticMeshVisibilityMap[StaticMesh.Id])
                        {
                            constexpr uint64 DefaultBatchElementMask = ~0ul;
                            MeshProcessor.AddMeshBatch(StaticMesh, DefaultBatchElementMask, StaticMesh.PrimitiveSceneInfo->Proxy);
                        }
                    }
                }

                if (ViewRelevance.bRenderInMainPass && ViewRelevance.bDynamicRelevance)
                {
                    const FInt32Range MeshBatchRange = GetDynamicMeshElementRange(View, PrimitiveSceneInfo->GetIndex());


                    for (int32 MeshBatchIndex = MeshBatchRange.GetLowerBoundValue(); MeshBatchIndex < MeshBatchRange.GetUpperBoundValue(); ++MeshBatchIndex)
                    {
                        const FMeshBatchAndRelevance& MeshAndRelevance = View.DynamicMeshElements[MeshBatchIndex];
                        constexpr uint64 BatchElementMask = ~0ull;
                        MeshProcessor.AddMeshBatch(*MeshAndRelevance.Mesh, BatchElementMask, MeshAndRelevance.PrimitiveSceneProxy);
                    }
                }
            }
        }//for PrimitiveIndex

        const FSceneTextures& SceneTextures = ViewFamily.GetSceneTextures();

        FToonOutlineMeshPassParameters* PassParameters = GraphBuilder.AllocParameters<FToonOutlineMeshPassParameters>();
        PassParameters->View = View.ViewUniformBuffer;
        PassParameters->RenderTargets[0] = FRenderTargetBinding(SceneTextures.Color.Target, ERenderTargetLoadAction::ELoad);
        PassParameters->RenderTargets.DepthStencil = FDepthStencilBinding(
            SceneTextures.Depth.Target,
            ERenderTargetLoadAction::ELoad,
            ERenderTargetLoadAction::ELoad,
            FExclusiveDepthStencil::DepthWrite_StencilNop);

        SimpleMeshPass->BuildRenderingCommands(GraphBuilder, View, Scene->GPUScene, PassParameters->InstanceCullingDrawParams);

        FIntRect ViewportRect = View.ViewRect;
        FIntRect ScissorRect = FIntRect(FIntPoint(EForceInit::ForceInitToZero), SceneColorTexture->Desc.Extent);

        GraphBuilder.AddPass(
            RDG_EVENT_NAME("ToonOutlinePass"),
            PassParameters,
            ERDGPassFlags::Raster,
            [this, ViewportRect, ScissorRect, SimpleMeshPass, PassParameters](FRHICommandList& RHICmdList)
            {
                RHICmdList.SetViewport(ViewportRect.Min.X, ViewportRect.Min.Y, 0.0f, ViewportRect.Max.X, ViewportRect.Max.Y, 1.0f);

                RHICmdList.SetScissorRect(
                    true,
                    ScissorRect.Min.X >= ViewportRect.Min.X ? ScissorRect.Min.X : ViewportRect.Min.X,
                    ScissorRect.Min.Y >= ViewportRect.Min.Y ? ScissorRect.Min.Y : ViewportRect.Min.Y,
                    ScissorRect.Max.X <= ViewportRect.Max.X ? ScissorRect.Max.X : ViewportRect.Max.X,
                    ScissorRect.Max.Y <= ViewportRect.Max.Y ? ScissorRect.Max.Y : ViewportRect.Max.Y);

                SimpleMeshPass->SubmitDraw(RHICmdList, PassParameters->InstanceCullingDrawParams);
            });

    }//for View
}
cs


소스 코드: ToonOutlineMeshPassProcessor.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#pragma once

#include "MeshPassProcessor.h"

class FPrimitiveSceneProxy;
class FScene;
class FStaticMeshBatch;
class FViewInfo;

class FToonOutlineMeshPassProcessor : public FMeshPassProcessor
{
public:
    FToonOutlineMeshPassProcessor(
        const FScene* InScene,
        const FSceneView* InViewIfDynamicMeshCommand,
        const FMeshPassProcessorRenderState& INDrawRenderState,
        FMeshPassDrawListContext* InDrawListContext
    );

    virtual void AddMeshBatch(
        const FMeshBatch& RESTRICT MeshBatch,
        uint64 BatchElementMask,
        const FPrimitiveSceneProxy* RESTRICT PrimitiveSceneProxy,
        int32 StaticMeshId = -1
    ) override final;

private:
    template<bool bPositionOnly, bool bUsesMobileColorValue>
    bool Process(
        const FMeshBatch& RESTRICT MeshBatch,
        uint64 BatchElementMask,
        int32 StaticMeshId,
        const FPrimitiveSceneProxy* RESTRICT PrimitiveSceneProxy,
        const FMaterialRenderProxy& RESTRICT MaterialRenderProxy,
        const FMaterial& RESTRICT MaterialResource,
        ERasterizerFillMode MeshFillMode,
        ERasterizerCullMode MeshCullMode
    );

    FMeshPassProcessorRenderState PassDrawRenderState;
};
cs


소스 코드: ToonOutlineMeshPassProcessor.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
#include "ToonOutlineMeshPassProcessor.h"

#include "ToonOutlinePassRendering.h"
#include "ScenePrivate.h"
#include "MeshPassProcessor.inl"

IMPLEMENT_MATERIAL_SHADER_TYPE(, FToonOutlineVS, TEXT("/Engine/Private/ToonOutline.usf"), TEXT("MainVS"), SF_Vertex);
IMPLEMENT_MATERIAL_SHADER_TYPE(, FToonOutlinePS, TEXT("/Engine/Private/ToonOutline.usf"), TEXT("MainPS"), SF_Pixel);

FToonOutlineMeshPassProcessor::FToonOutlineMeshPassProcessor(
    const FScene* Scene,
    const FSceneView* InViewIfDynamicMeshCommand,
    const FMeshPassProcessorRenderState& InPassDrawRenderState,
    FMeshPassDrawListContext* InDrawListContext)
    :FMeshPassProcessor(Scene, Scene->GetFeatureLevel(), InViewIfDynamicMeshCommand, InDrawListContext),
    PassDrawRenderState(InPassDrawRenderState)
{
    {
        PassDrawRenderState.SetDepthStencilState(TStaticDepthStencilState<false, CF_NotEqual>().GetRHI());
    }
    if (PassDrawRenderState.GetBlendState() == nullptr)
    {
        PassDrawRenderState.SetBlendState(TStaticBlendState<>().GetRHI());
    }
}

void FToonOutlineMeshPassProcessor::AddMeshBatch(
    const FMeshBatch& MeshBatch,
    uint64 BatchElementMask,
    const FPrimitiveSceneProxy* PrimitiveSceneProxy,
    int32 StaticMeshId)
{
    const FMaterialRenderProxy* MaterialRenderProxy = MeshBatch.MaterialRenderProxy;
    const FMaterialRenderProxy* FallBackMaterialRenderProxyPtr = nullptr;
    const FMaterial& Material = MaterialRenderProxy->GetMaterialWithFallback(Scene->GetFeatureLevel(), FallBackMaterialRenderProxyPtr);

    // only set in Material will draw outline
    if (Material.GetRenderingThreadShaderMap()
        && Material.UseOutline())
    {
        // Determine the mesh's material and blend mode.
        const EBlendMode BlendMode = Material.GetBlendMode();

        bool bResult = true;
        if (BlendMode == BLEND_Opaque || BlendMode == BLEND_Masked)
        {
            Process<false, false>(
                MeshBatch,
                BatchElementMask,
                StaticMeshId,
                PrimitiveSceneProxy,
                *MaterialRenderProxy,
                Material,
                FM_Solid,
                CM_CCW);
        }
    }
}

template <bool bPositionOnly, bool bUsesMobileColorValue>
bool FToonOutlineMeshPassProcessor::Process(
    const FMeshBatch& MeshBatch,
    uint64 BatchElementMask,
    int32 StaticMeshId,
    const FPrimitiveSceneProxy* PrimitiveSceneProxy,
    const FMaterialRenderProxy& MaterialRenderProxy,
    const FMaterial& MaterialResource,
    ERasterizerFillMode MeshFillMode,
    ERasterizerCullMode MeshCullMode)
{
    const FVertexFactory* VertexFactory = MeshBatch.VertexFactory;

    TMeshProcessorShaders<
        FToonOutlineVS,
        FToonOutlinePS> ToonOutlineShaders;

    // Try Get Shader.
    {
        FMaterialShaderTypes ShaderTypes;
        ShaderTypes.AddShaderType<FToonOutlineVS>();
        ShaderTypes.AddShaderType<FToonOutlinePS>();

        const FVertexFactoryType* VertexFactoryType = VertexFactory->GetType();

        FMaterialShaders Shaders;
        if (!MaterialResource.TryGetShaders(ShaderTypes, VertexFactoryType, Shaders))
        {
            UE_LOG(LogShaders, Warning, TEXT("**********************!Shader Not Found!*************************"));
            return false;
        }

        Shaders.TryGetVertexShader(ToonOutlineShaders.VertexShader);
        Shaders.TryGetPixelShader(ToonOutlineShaders.PixelShader);
    }

    FToonOutlinePassShaderElementData ShaderElementData;
    ShaderElementData.InitializeMeshMaterialData(ViewIfDynamicMeshCommand, PrimitiveSceneProxy, MeshBatch, StaticMeshId, false);

    const FMeshDrawCommandSortKey SortKey = CalculateMeshStaticSortKey(ToonOutlineShaders.VertexShader, ToonOutlineShaders.PixelShader);

    PassDrawRenderState.SetDepthStencilState(
        TStaticDepthStencilState<
        true, CF_GreaterEqual,// Enable DepthTest, It reverse about OpenGL(which is less)
        false, CF_Never, SO_Keep, SO_Keep, SO_Keep,
        false, CF_Never, SO_Keep, SO_Keep, SO_Keep,// enable stencil test when cull back
        0x00,// disable stencil read
        0x00>// disable stencil write
        ::GetRHI());
    PassDrawRenderState.SetStencilRef(0);

    BuildMeshDrawCommands(
        MeshBatch,
        BatchElementMask,
        PrimitiveSceneProxy,
        MaterialRenderProxy,
        MaterialResource,
        PassDrawRenderState,
        ToonOutlineShaders,
        MeshFillMode,
        MeshCullMode,
        SortKey,
        EMeshPassFeatures::Default,
        ShaderElementData
    );

    return true;
}
cs


  Process() 함수 호출 시 CM_CCW를 인자로 넘기는 것에 주목하세요. 이는 전면을 Counter-Clockwise로 인식하겠다는 의미입니다.


쉐이더 구현하기

블렌드 모드

  언리얼 엔진 머티리얼은 다양한 블렌드 모드를 제공합니다. Opaque, Masked, Translucent, Additive 등이 있습니다. Vertex Extrusion 기법을 활용하는 경우, 불투명한 물체에만 적용하는 것이 합리적입니다. 

  하지만 하나의 회색지대가 있는데, Masked의 경우입니다. 이 경우, 텍스처를 이용해서 알파를 적용하게 됩니다. 따라서 윤곽선 오브젝트의 경우에도 마스킹을 적용할 수 있어야 합니다. 만약 적용되지 않는다면, 마스킹된 부분은 배경이 보여야 함에도 불구하고 윤곽선의 색으로만 가득찰 것입니다. 다음 그림은 가운데가 마스킹된 도넛 모양 오브젝트(파란색)의 윤곽선(주황색)을 렌더링한 경우입니다.

마스킹이 적절히 수행되지 못한 경우(좌)와 마스킹이 적절히 수행된 경우(우)

  이들을 구현하는 방법에 대해서는 다음 단락에서 설명하겠습니다.


HLSL 코드 구현하기

  커스텀 쉐이더를 전체적으로 적용하기 앞서, 쉐이더 코드부터 준비해보도록 하죠. 언리얼 엔진은 쉐이더 코드를 *.usf, *.ush 파일로 관리합니다. 이들의 세부사항에 대해서는 다루지 않습니다. 이 파일 내에서 HLSL 문법을 활용한다는 것은 변하지 않습니다. 먼저 코드를 보도록 합시다.

ToonOutline.usf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include "Common.ush"
#include "/Engine/Generated/Material.ush"
#include "/Engine/Generated/VertexFactory.ush"

struct FSimpleMeshPassVSToPS
{
    FVertexFactoryInterpolantsVSToPS FactoryInterpolants;
    float4 Position : SV_POSITION;
};

float OutlineScale;
float3 OutlineColor;

#if VERTEXSHADER
void MainVS(
    FVertexFactoryInput Input,
    out FSimpleMeshPassVSToPS Output)
{
    ResolvedView = ResolveView();
    
    FVertexFactoryIntermediates VFIntermediates = GetVertexFactoryIntermediates(Input);
    
    float4 WorldPos = VertexFactoryGetWorldPosition(Input, VFIntermediates);
    float3 WorldNormal = VertexFactoryGetWorldNormal(Input, VFIntermediates);
    
    float3x3 TangentToLocal = VertexFactoryGetTangentToLocal(Input, VFIntermediates);
    FMaterialVertexParameters VertexParameters = GetMaterialVertexParameters(Input, VFIntermediates, WorldPos.xyz, TangentToLocal);

    WorldPos.xyz += GetMaterialWorldPositionOffset(VertexParameters);
    
    Output.FactoryInterpolants = VertexFactoryGetInterpolantsVSToPS(Input, VFIntermediates, VertexParameters);
    Output.Position = mul(WorldPos, ResolvedView.TranslatedWorldToClip);

    float4 ClipNormal = normalize(mul(float4(WorldNormal, 1.0f), ResolvedView.TranslatedWorldToClip));

    float2 ExtentDir = ClipNormal.xy;

    Output.Position.xy += ExtentDir * OutlineScale;
}
#endif // VERTEXSHADER

void MainPS(
    FSimpleMeshPassVSToPS Input,
    out float4 OutColor : SV_Target0)
{
#if MATERIALBLENDING_MASKED
    FMaterialPixelParameters MaterialParameters =
        GetMaterialPixelParameters(Input.FactoryInterpolants, Input.Position);

    FPixelMaterialInputs PixelMaterialInputs;
    CalcMaterialParameters(MaterialParameters, PixelMaterialInputs, Input.Position, false);

    clip(GetMaterialMask(PixelMaterialInputs));
#endif
    OutColor = float4(OutlineColor, 1.0);
}
cs


설명

  • 먼저, include는 엔진 내에서 자체적으로 처리하는 가상 주소에서 이루어지게 됩니다.
  • FSimpleMeshPassVSToPS 구조체는 FVertexFactoryInterpolantsVSToPS를 지닙니다. VertexFactory 및 Interpolants에 대해서는 공식 문서를 참고하세요. 간단하게 설명하면, 수많은 쉐이더들이 서로 다른 입력의 형태에서 잘 동작하기 위해 적절히 만들어낸 인터페이스라고 할 수 있습니다. 예를 들어, FVertexFactoryIntermediates 구조체와 GetVertexFactoryIntermediates()는 수많은 VertexFactory들이 각자 구현하며, 우리는 각기 다른 VertexFactory에서 공통된 인터페이스만을 활용함으로써 유연성을 지닐 수 있습니다. 이러한 인터페이스들을 통해서 좌표, 법선 등을 얻을 수 있죠. 
  • 우리는 Vertex Extrusion을 수행해야 하므로, 각 정점들을 법선의 방향으로 이동시켜주어야 합니다. Clip, TranslatedWorld 등 좌표계의 용어에 관련한 세부사항은 언리얼 엔진의 좌표계 용어집 문서를 참고하세요. VertexFactoryGetWorldPosition은 World 좌표가 아닌 TranslatedWorld 좌표를 반환함에 유의하세요.
  • 11~12행: 윤곽선을 그리는데 필요한 매개변수입니다. 이는 이후에 우리가 구현할 FMeshMaterialShader가 바인딩하게 됩니다.
  • 19행: 해당 View를 얻습니다.
  • 21행: 정해진 인터페이스를 통해 Intermediates를 얻습니다. 이를 통해서 우리가 원하는 정보를 얻을 수 있습니다.
  • 23~26행: 세계 좌표, 세계 법선, 접선 공간에서 지역 공간으로의 변환 행렬을 얻습니다.
  • 28~29행: 머티리얼에 정의된 오프셋을 반영합니다.
  • 30~31행: 정점 쉐이더 출력에 맞게 이들을 채워줍니다. 이때 역시 정해진 인터페이스를 활용합니다. SV_Position의 경우 세계 좌표에서 동차 좌표계로의 변환 행렬을 곱하여 넣습니다.
  • 34행: 법선 역시 동차좌표계로 변환합니다.
  • 36~37행: 이를 기반으로 정점의 위치를 법선 방향으로 재조정합니다.
  • 46~52행: 머티리얼의 블렌드 모드가 MASKED일 경우, MATERIALBLENDING_MASKED가 1로 정의됩니다. 따라서 이 경우에는 머티리얼의 마스크 값을 얻은 뒤, clip 함수로 픽셀 기각의 여부를 결정합니다.
  • 55행: 픽셀의 색을 바인딩된 파라미터로 결정합니다.


FShader 구현하기

  이렇게 구현된 쉐이더를 담당하는 클래스가 있습니다. 바로 FShader입니다. 언리얼 엔진에서 정의하는 쉐이더의 종류는 총 세 가지가 있습니다:

  • 글로벌 쉐이더: 어떤 머티리얼, 어떤 메쉬인지의 정보가 필요 없는 쉐이더입니다. 쉐이더 코드만으로 동작하는 것이죠. 가령 디퍼드 쉐이딩에서 빛의 결과를 종합하는 경우, 메쉬나 머티리얼 정보 없이 각 버퍼에 대한 정보만 있으면 됩니다. 혹은 다양한 계산 쉐이더들이 있겠군요.
  • 머티리얼 쉐이더: 머티리얼 노드 그래프 정보를 필요로 하는 쉐이더들입니다. Light function이나 Deferred decal 머티리얼을 렌더링할 때 쓰이는 쉐이더들이 대표적인 예입니다.
  • 메쉬 머티리얼 쉐이더: 머티리얼 정보뿐만 아니라 메쉬 정보까지 필요로 하는 쉐이더들입니다. 정점 정보를 얻기 위해 Vertex Factory를 활용하게 됩니다. 우리의 윤곽선 쉐이더는 여기에 포함됩니다. 당연히, 기존 메쉬의 정점들을 이동시켜야 되기 때문입니다.

언리얼 엔진의 쉐이더 처리

  언리얼 엔진이 쉐이더를 처리하는 세부사항에 대해서 간단히 알아봅시다. FShader를 상속받는 클래스들은 반드시 두 가지 함수를 구현해야 합니다. ModifyCompilationEnvironment(), ShouldCompilePermutation()입니다.

  • ModifyCompilationEnvironment()는 쉐이더를 컴파일하기 이전, 환경을 설정하는 작업을 합니다. 예를 들어 쉐이더의 전처리 지시자를 선언하는 경우가 있겠네요.
  • ShouldCompilePermuation()은 글로벌 쉐이더의 경우 플랫폼, 머티리얼 쉐이더의 경우 머티리얼 등의 여부까지, 메쉬 머티리얼 쉐이더의 경우 여기에 Vertex Factory의 종류 등까지 확인하며 해당 쉐이더를 컴파일해야 하는지의 여부를 결정합니다. 우리는 LocalVertexFactory, TGPUSkinVertexFactoryDefault만을 사용할 것이며, 머티리얼에서 외곽선을 사용하겠다고 체크한 경우에 대해서만 렌더링할 것입니다. 

  또한 LAYOUT_FIELD 매크로를 이용해서 쉐이더에서 활용할 파라미터를 선언합니다. 생성자에서는 이를 바인딩하며, GetShaderBindings()에서 이를 업데이트합니다. 다음 코드를 참고하세요:


소스 코드: ToonOutlinePassRendering.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
#pragma once

#include "DataDrivenShaderPlatformInfo.h"
#include "MeshPassProcessor.h"
#include "MeshMaterialShader.h"
#include "ShaderParameterStruct.h"

class FToonOutlinePassShaderElementData
    : public FMeshMaterialShaderElementData
{
public:
    float ParameterValue;
};
/**

* Vertex shader for rendering a single, constant color.
*/
class FToonOutlineVS : public FMeshMaterialShader
{
    DECLARE_SHADER_TYPE(FToonOutlineVS, MeshMaterial)

public:
    /** Default constructor. */
    FToonOutlineVS() = default;
    FToonOutlineVS(const ShaderMetaType::CompiledShaderInitializerType& Initializer)
        : FMeshMaterialShader(Initializer)
    {
        OutlineScale.Bind(Initializer.ParameterMap, TEXT("OutlineScale"));
    }

    static void ModifyCompilationEnvironment(const FMaterialShaderPermutationParameters& Parameters, FShaderCompilerEnvironment& OutEnvironment)
    {
    }

    static bool ShouldCompilePermutation(const FMeshMaterialShaderPermutationParameters& Parameters)
    {
        return IsFeatureLevelSupported(Parameters.Platform, ERHIFeatureLevel::SM5) &&
            Parameters.MaterialParameters.bUseOutline &&
            (Parameters.VertexFactoryType->GetFName() == FName(TEXT("FLocalVertexFactory")) ||
                Parameters.VertexFactoryType->GetFName() == FName(TEXT("TGPUSkinVertexFactoryDefault")));
    }

    // You can call this function to bind every mesh personality data
    void GetShaderBindings(
        const FScene* Scene,
        ERHIFeatureLevel::Type FeatureLevel,
        const FPrimitiveSceneProxy* PrimitiveSceneProxy,
        const FMaterialRenderProxy& MaterialRenderProxy,
        const FMaterial& Material,
        const FMeshPassProcessorRenderState& DrawRenderState,
        const FToonOutlinePassShaderElementData& ShaderElementData,
        FMeshDrawSingleShaderBindings& ShaderBindings) const
    {
        FMeshMaterialShader::GetShaderBindings(Scene, FeatureLevel, PrimitiveSceneProxy, MaterialRenderProxy, Material, DrawRenderState, ShaderElementData, ShaderBindings);

        // Get ToonOutline Data from Material
        ShaderBindings.Add(OutlineScale, Material.GetOutlineScale());
    }

    /** The parameter to use for setting the Mesh Outline Scale. */
    LAYOUT_FIELD(FShaderParameter, OutlineScale);
};

/**
 * Pixel shader for rendering a single, constant color.
 */
class FToonOutlinePS : public FMeshMaterialShader
{
    DECLARE_SHADER_TYPE(FToonOutlinePS, MeshMaterial)

public:
    FToonOutlinePS() = default;
    FToonOutlinePS(const ShaderMetaType::CompiledShaderInitializerType& Initializer)
        : FMeshMaterialShader(Initializer)
    {
        OutlineColor.Bind(Initializer.ParameterMap, TEXT("OutlineColor"));
    }

    static void ModifyCompilationEnvironment(const FMaterialShaderPermutationParameters& Parameters, FShaderCompilerEnvironment& OutEnvironment)
    {
    }

    static bool ShouldCompilePermutation(const FMeshMaterialShaderPermutationParameters& Parameters)
    {
        return IsFeatureLevelSupported(Parameters.Platform, ERHIFeatureLevel::SM5) &&
            Parameters.MaterialParameters.bUseOutline &&
            (Parameters.VertexFactoryType->GetFName() == FName(TEXT("FLocalVertexFactory")) ||
                Parameters.VertexFactoryType->GetFName() == FName(TEXT("TGPUSkinVertexFactoryDefault")));
    }

    void GetShaderBindings(

        const FScene* Scene,
        ERHIFeatureLevel::Type FeatureLevel,
        const FPrimitiveSceneProxy* PrimitiveSceneProxy,
        const FMaterialRenderProxy& MaterialRenderProxy,
        const FMaterial& Material,
        const FMeshPassProcessorRenderState& DrawRenderState,
        const FToonOutlinePassShaderElementData& ShaderElementData,
        FMeshDrawSingleShaderBindings& ShaderBindings) const
    {
        FMeshMaterialShader::GetShaderBindings(Scene, FeatureLevel, PrimitiveSceneProxy, MaterialRenderProxy, Material, DrawRenderState, ShaderElementData, ShaderBindings);

        // Get ToonOutline Data from Material
        const FLinearColor OutlineColorFromMat = Material.GetOutlineColor();
        FVector3f Color(OutlineColorFromMat.R, OutlineColorFromMat.G, OutlineColorFromMat.G);

        // Bind to Shader
        ShaderBindings.Add(OutlineColor, Color);
    }

    /** The parameter to use for setting the Mesh Outline Color. */
    LAYOUT_FIELD(FShaderParameter, OutlineColor);
};
cs


(첨언: LAYOUT_FIELD 매크로를 이용하는 방식은 구식이라는 추측이 있는데요. 쉐이더 변수를 정의하는 다른 방식이 존재하는 것 같기 때문입니다. 하지만 다른 코드들을 봐도 이와 관련해 확언하기가 어렵네요. 제 언리얼 엔진 5에 대한 이해가 부족하여 이에 대해서 자세하게 기술할 수 없는 점 양해 부탁드립니다)

  추가로, IMPLEMENT_MATERIAL_SHADER_TYPE 매크로를 이용해서 엔진에게 필요한 구현을 제공해야 합니다. 이는 앞선 코드에서 DECLARE_SHADER_TYPE 매크로와 대응되는 관계입니다. ToonOutlineMeshProcessor.cpp에서 이와 관련한 코드를 볼 수 있습니다.


머티리얼 수정하기

  앞서 설명하였던 쉐이더 관련 코드에서는 머티리얼의 값을 이용하는 부분들이 있습니다. 이들은 두 가지 측면에서 수정되어야 합니다.

  • 엔진이 다루는 데이터로서의 머티리얼이 윤곽선 속성 등에 대해서 적절히 행위할 수 있도록 해야합니다.
  • 에디터에서 수정할 수 있는 인터페이스로서의 머티리얼이 적절히 행위할 수 있도록 해야합니다.


  머티리얼은 변경 시마다 다시 컴파일을 해야 한다는 단점이 존재합니다. 이를 극복하기 위해서 언리얼 엔진은 머티리얼 인스턴스를 제공합니다. 머티리얼 인스턴스는 특정 머티리얼을 부모로 삼아 다양한 변화를 컴파일 없이 만들 수 있습니다. 즉, 컴파일은 머티리얼에서 수행되고 쉐이더 파라미터의 변화는 머티리얼 인스턴스로 실현할 수 있습니다. 윤곽선의 경우 색과 굵기, 단순히 스칼라와 벡터이므로 이들을 파라미터화할 수 있습니다.

  수정해야 할 코드가 꽤 많으므로, 세부사항은 생략하도록 하겠습니다. 자세한 수정사항은 깃 리포지토리를 참고하세요.

수정이 적절히 된 경우, 머티리얼 세부 속성을 조절할 수 있습니다.


만화스러운 그림자

  우리는 음영이 부드럽지 않고 단계적으로 있을 때 만화스럽다고 느낍니다. 이러한 단계별 음영을 구현하는 방법은 다양합니다. 극단적으로는, 음영뿐만 아니라 픽셀 자체의 색 단계를 줄일 수도 있습니다. 가령 32비트 컬러에서 R, G, B 각각은 0부터 255의 정수 강도를 지니는데, 이들을 0부터 7 사이로 줄여버리는 것이죠.

  우리는 단계별 음영을 적용하기 위해서, 법선 벡터를 수정하도록 합시다. 법선 벡터의 각도를 양자화하면, 이 역시 단계별 음영이 적용되는 효과를 얻을 수 있습니다. 이는 아주 단순한 방법이지만, 강력한 효과를 얻을 수 있습니다. 특정 값 범위 안에 있는 값들을 전부 하나의 값으로 통일하는 것이죠. 다음 코드를 참고하세요:


소스 코드: BasePixelShader.usf


// ...

// Begin nyvux's modify : Toon shading
#if TOON_SHADING
float CalcQunatizedValue(float Value)
{
    if(Value >= 0.8f)
        Value = 1.0f;
    else if(Value >= 0.2f)
        Value = 0.5f;
    else
        Value = 0.0f;
    return Value;
}

float3 CalcQuantizedNormal(float3 Normal)
{
    Normal.r = CalcQunatizedValue(Normal.r);
    Normal.g = CalcQunatizedValue(Normal.g);
    Normal.b = CalcQunatizedValue(Normal.b);
    return Normal;
}
#endif
// End nyvux's modify : Toon shading

// ...

// is called in MainPS() from PixelShaderOutputCommon.usf
void FPixelShaderInOut_MainPS(
    FVertexFactoryInterpolantsVSToPS Interpolants,
    FBasePassInterpolantsVSToPS BasePassInterpolants,
    in FPixelShaderIn In,
    inout FPixelShaderOut Out)
{
    // ...

    const float Dither = InterleavedGradientNoise(MaterialParameters.SvPosition.xy, View.StateFrameIndexMod8);

// Begin nyvux's modify : Toon shading
#if TOON_SHADING
    MaterialParameters.WorldNormal = CalcQuantizedNormal(MaterialParameters.WorldNormal);
#endif
// End nyvux's modify : Toon shading

#if !STRATA_ENABLED
    // Store the results in local variables and reuse instead of calling the functions multiple times.
    half3 BaseColor = GetMaterialBaseColor(PixelMaterialInputs);
    half  Metallic = GetMaterialMetallic(PixelMaterialInputs);
    half  Specular = GetMaterialSpecular(PixelMaterialInputs);

    // ...
}
cs

 

 특히, 해당 쉐이더는 모든 렌더링에서 적용되는 Base pass이므로, 툰 쉐이딩이 적용된 메쉬들의 법선 벡터만 수정할 수 있도록 해야 합니다. 이를 위해서 전처리 구문을 활용하며, 머티리얼 속성에서 툰 쉐이딩이 활성화된 경우에만 전처리 지시자를 정의합니다:


소스 코드: BasePassRendering.cpp

1
2
3
4
5
6
7
8
9
// ...
void ModifyBasePassCS
{
    // ...
    // Begin nyvux's modify : Toon shading
    if(Parameters.MaterialParameters.bUseToonShading)
        OutEnvironment.SetDefine(TEXT("TOON_SHADING"), 1);
    // End nyvux's modify : Toon shading
}
cs


이들을 종합하면, 이렇게 됩니다.

왼쪽은 툰 쉐이딩을 적용하지 않은 경우입니다




Future

  구현된 방법은 여러 가지 한계를 갖습니다. 그 한계는 다음과 같습니다:

  • Nanite에 대해서 작동하지 않습니다.
  • Two sided 머티리얼에 대해서 제대로 작동하지 않습니다.
  • 각진 평면들, 복잡한 폴리곤에 대해서 윤곽선이 정확하지 않습니다. (다음 그림 참고)

각진 부분은 법선이 극단적으로 변하기 때문에 윤곽선이 제대로 그려지지 않습니다.



참고

언리얼 엔진 공식 문서

https://scahp.tistory.com/78

https://medium.com/@lordned/unreal-engine-4-rendering-overview-part-1-c47f2da65346

https://alexanderameye.github.io/notes/rendering-outlines/

https://zhuanlan.zhihu.com/p/552283835


소스 코드

https://github.com/SubinHan/UnrealEngine-nyvux/tree/toon


프로젝트 기간: 3주


끝.

댓글

이 블로그의 인기 게시물

[코딩의탑] 3층: 바다 렌더링

[Java] 자바로 프로그래밍 입문하기: 3.3. 자료형 설계하기 (4)