강의 필기/3-1

<10> 엔진 활용

YujinK 2020. 6. 16. 10:48

렌더링 파이프 라인 (Rendering Pipeline)


렌더링 파이프라인에 대해 알아봅시다. 지금까지 셰이더 코드를 짜고, 유니티 씬/게임뷰를 통해 너무나 당연히도 결과물을 봐왔는데요.


셰이더는 어떤 과정을 거쳐 데이터를 받아, 우리에게 이미지를 출력하는 걸까요?


(자료 출처: https://kblog.popekim.com/2011/11/)



(1) 데이터를 받아오고

(2) 정점 셰이더 계산

(3) 래스터라이저

(4) 픽셀 셰이더 계산


크게 네 단계의 과정을 거칩니다. 하나씩 살펴봅시다.


(1) 정점 데이터를 받아온다.

먼저 데이터를 받아옵니다. '정점'은 꼭짓점, 즉 Vertex를 뜻합니다. 우리가 3D 데이터는 폴리곤으로 이루어져 있고, 폴리곤(다각형)은 삼각형의 집합이며, 삼각형은 버텍스의 집합입니다. '3D 데이터(=버텍스 입력값)를 받아온다'라고 쉽게 풀어 이해하면 되겠습니다.


(2) 정점 셰이더 (Vertex Shader)

버텍스의 좌표,위치와 관련한 작업이 이루어집니다. 버텍스의 로컬 좌표계를 월드 좌표계로 변환하고, 월드 좌표계를 또 카메라 좌표계로 변환하는 작업을 거칩니다. '버텍스'만으로 작업하는 단계입니다.


(3) 래스터라이저

3D데이터를 2D 화면에 그리기 위한 작업, 렌더링을 합니다. 본격적으로 2D 화면을 구성하는 단계입니다. (픽셀화)


(4) 픽셀 셰이더

지금까지 우리가 해온 작업이 이 '픽셀 셰이더'입니다. 2D 화면에 출력된 3D데이터에 색상을 주고, 질감을 입히고, 알파를 적용하는 등의 모든 작업이 이 단계에서 이루어집니다. 



지금까지 작업해온 surf 함수, surface = pixel shader입니다. surf 함수에서 작업한 것은 모두 픽셀 셰이더입니다.



랜더링 파이프 라인에 대해 훑고나니 한 가지 의문이 생깁니다...

- 그렇다면 라이팅은 연산은 어디에서 하나요?

: 버텍스 세이더와 픽셀 셰이더 두 단계에서 모두 가능합니다. NdotL을 버텍스 셰이더에서 연산하느냐, 픽셀 셰이더에서 연산하느냐, 사용자의 선택에 따릅니다. 버텍스 단위로 라이팅 계산을 할 것인지 픽셀 단위로 라이팅 연산을 할 것인지...원하는 형태에 맞추어 설정합시다.



버텍스 셰이더


버텍스 셰이더를 가동해봅시다.


void surf가 픽셀 셰이더 함수이듯, 버텍스 셰이더를 위한 함수 또한 지정되어있습니다.


1
2
3
4
5
6
7
8
9
CGPROGRAM
        #pragma surface surf Lambert vertex:vert addshadow
        #pragma target 3.0
 
        sampler2D _MainTex;
 
        void vert(inout appdata_full v)
        {
        }
cs


버텍스 셰이더 함수는 위와 같이 작성합니다. 외우도록 합시다.


여기서 잠시 appdata_에 대해 알아보자면


appdata_


(출처: https://docs.unity3d.com/kr/2018.4/Manual/SL-VertexProgramInputs.html)



appdata는 _base, _tan,_full 세 가지로 분류할 수 있고 텍스쳐 좌표의 개수, 탄젠트 정보를 포함하고 있는가에 따라 나뉘어집니다. 


내장 값에는

float4 vertex / float3 normal / float4 texcoord / float4 texcoord1 /float4 texcoord2 /float4 texcoord3 / float4 tangent / float4 color 가 있습니다. (자료 참고)  



버텍스 셰이더 : 버텍스 조작1


1
2
3
4
void vert(inout appdata_full v)
        {
            v.vertex.y = v.vertex.y + 1;
        }
cs


위 코드는 

v.vertex.y - 오브젝트의 y좌표 버텍스값를 불러와, y좌표 버텍스값에 1unit(단위m)을 더한 값을 넣어줍니다. 최종적으론 버텍스가 y축으로 1m 움직이게 될 것입니다.



이런식으로 버텍스를 조작할 수 있습니다.


_Time 함수를 활용하면



버텍스 조작으로 애니메이션을 줄 수도 있습니다. 위는 Sin(_Time.y)를 활용한 것입니다.



셀 셰이딩 구현: Outline


2pass outline을 활용해 셀 셰이딩의 외곽선을 구현해봅시다. 2pass outline은 오브젝트를 두 번 그려 외곽선을 표현한다 했습니다.


먼저 오브젝트를 두 번 그려봅시다.


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
Shader "Custom/10week"
{
    Properties
    {
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
    }
 
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 200
 
        CGPROGRAM
        #pragma surface surf Lambert 
        #pragma target 3.0
 
        sampler2D _MainTex;
 
        struct Input
        {
            float2 uv_MainTex;
        };
 
        void surf (Input IN, inout SurfaceOutput o)
        {
            fixed4 c = tex2D (_MainTex, IN.uv_MainTex);
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }
        ENDCG
 
        CGPROGRAM
        #pragma surface surf Lambert 
        #pragma target 3.0
 
        sampler2D _MainTex;
 
        struct Input
        {
            float2 uv_MainTex;
        };
 
        void surf(Input IN, inout SurfaceOutput o)
        {
            fixed4 c = tex2D(_MainTex, IN.uv_MainTex);
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }
        ENDCG
    }
    FallBack "Diffuse"
}
 
cs


방법은 간단합니다. CGPROGRAM ~ ENDCG까지를 한번 더 써줍니다. (복붙해줬습니다..)

지금 이 코드에선 오브젝트를 두 번 그리는 게 됩니다.



1pass - 외곽선 구현


면을 뒤집은(검은색의) 오브젝트를 구현해 봅시다.


유니티 기본 상태는 backface culling이 자동으로 적용되어있는데요, (보이지 않는 면: 뒷면, 안쪽 면은 그리지 않습니다.) 이 설정을 바꿔봅시다.




(1) SubShader의 상단에 'cull back'을 작성하면 = backface culling을 적용합니다. 유니티 기본상태와 같습니다.


1
2
3
4
5
6
7
8
  SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 200
        cull back
 
        CGPROGRAM
...
cs


(2) 'cull off'를 작성하면 '2side' 가 적용되어 보이지 않는 면도 그리게 됩니다.


1
2
3
4
5
6
7
8
   SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 200
        cull off
 
        CGPROGRAM
...
cs


(3) 'cull front'을 작성하면 '뒷면'만 그리게 됩니다. 


1
2
3
4
5
6
7
8
  SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 200
        cull front
 
        CGPROGRAM
...
cs





외곽선을 위해선 뒤집힌 면이 필요하니, cull front를 활용하면 되겠습니다.


뒤집힌 면을 그리는 방법은 알았습니다! 그럼 1pass 오브젝트를 노말방향으로 확장하려면 어떻게 해야할까요? 

버텍스 셰이더를 조작해봅시다.


1
2
3
4
5
6
7
8
CGPROGRAM
        #pragma surface surf Lambert vertex:vert addshadow
        #pragma target 3.0
 
        void vert(inout appdata_full v)
        {
            v.vertex.xyz = v.vertex.xyz + v.normal*0.05;
        }
cs

각 버텍스에 v.normal 값을 더합니다. 여기서 연산하는 normal은 단위벡터이므로 1m의 값을 갖고있습니다. 1m는 값이 너무 크니 *0.05를 해서 줄여봅시다.



얼굴이 많이 부었습니다...값을 더 줄입니다.



0.01로 값을 줄였습니다. 여기서 cull front를 활성화하여 뒤집힌 면을 그려봅니다.



이어서 o.Albedo = 0;를 입력하여 검정색 알베도를 출력합니다.



필요한 1pass 결과물을 얻었습니다.



2pass - 오브젝트


여기서 기존 오브젝트를 그려 얹으면 



외곽선이 생기긴했는데, 뒤집히면 안될 두 번째 오브젝트까지 뒤집혔습니다.


해결 방법은 간단한데요, 두 번째 오브젝트를 그리는 코드의 상단에 'cull back'을 입력해 주는 것입니다. 



결과물입니다. (엠비언트 라이트도 적용했습니다)


이렇게 면을 뒤집고, 노말 방향으로 확장한 후 알베도를 검정색으로 출력한 첫 번째 오브젝트와

두 번째 오브젝트를 겹쳐 그려 셀 셰이딩의 외곽선을 구현했는데요,


여기서 한 가지 의문이 듭니다. 현재 1pass에까지 Lambert 라이팅 연산이 들어가고 있는 상태인데, 굳이 그럴 필요가 있나요? 낭비인 것 같습니다.


라이팅 연산을 전부 날리고(커스텀 라이트로 해결합시다) 1pass에 검은색 값만 리턴하도록 만들어줍니다.


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
Shader "Custom/10week"
{
    Properties
    {
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
        _Outline("외곽선 두께",Range(0,0.01)) = 0.01
        _Color("외곽선 컬러",Color) = (0,0,0,1)
    }
 
    SubShader
    {
 
        Tags { "RenderType"="Opaque" }
        LOD 200
 
        //1pass 외곽선
        cull front
 
        CGPROGRAM
        #pragma surface surf no vertex:vert noshadow
        #pragma target 3.0
 
        sampler2D _MainTex;
        float _Outline;
        float4 _Color;
 
        void vert(inout appdata_full v)
        {
            v.vertex.xyz = v.vertex.xyz + v.normal*_Outline;
        }
 
        struct Input
        {
            float2 uv_MainTex;
        };
 
        void surf (Input IN, inout SurfaceOutput o)
        {
           
        }
 
        float4 Lightingno(SurfaceOutput s, float3 lighhtDir, float atten)
            {
                return _Color;
            }
 
        ENDCG
 
        
        //2pass 오브젝트
        cull back
 
        CGPROGRAM
 
        #pragma surface surf Lambert 
        #pragma target 3.0
 
        sampler2D _MainTex;
 
        struct Input
        {
            float2 uv_MainTex;
        };
 
        void surf(Input IN, inout SurfaceOutput o)
        {
            fixed4 c = tex2D(_MainTex, IN.uv_MainTex);
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }
        ENDCG
    }
    FallBack "Diffuse"
}
 
cs


전체 코드입니다.



v.normal에 연산할 값을 변수로 지정하여 인스펙터에 외곽선 두께 옵션을 조절할 수 있도록 해주었습니다.



1pass에 리턴할 컬러값(외곽선 컬러)도 조절 가능하게 해두었습니다.


2pass에 텍스쳐를 적용하여 마무리합니다.



응용



1pass 오브젝트의 Albedo에 검정색이 아닌, 2pass 오브젝트 텍스쳐와 같은 텍스쳐를 넣어 출력하면 

캐릭터 칼라와 같은 색의 외곽선을 구현할 수 있습니다.


1
2
3
4
5
6
7
8
9
10
    void surf (Input IN, inout SurfaceOutput o)
        {
            fixed4 c = tex2D(_MainTex, IN.uv_MainTex);
            o.Albedo = c*0.7;
        }
 
        float4 Lightingno(SurfaceOutput s, float3 lighhtDir, float atten)
        {
            return float4(s.Albedo,1);
        }
cs



셀 셰이딩 구현: 음영


2pass 오브젝트에 커스텀 라이트를 적용합니다. 하프 램버트 공식인 *0.5+0.5를 적용하여 가장 어두운 면의 수치를(최솟값을) 0.5로 고정합니다.



if문 활용


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float4 Lightingcus(SurfaceOutput s, float3 lightDir, float atten)
        {
            float ndotl = dot(s.Normal, lightDir) *0.5+0.5 ;
            
            if (ndotl > 0.6) 
            {
                ndotl = 1;
            }
            else
            {
                ndotl = 0.5;
            }
 
            return ndotl;
        }
cs


위와 같은 if 구문을 작성합니다.

ndotl 연산에서 0.6보다 큰 값은 '1' 로,(밝은 면) 그 밖의 값은 전부 0.5로 고정합니다.(어두운 면)




else if 문


else if문으로 if문을 한 번 더 작성할 수 있습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
    if (ndotl > 0.7) 
            {
                ndotl = 1;
            }
            
            else if (ndotl > 0.5)
            {
                ndotl = 0.5;
            }
            else
            {
                ndotl = 0.2;
            }
cs


이 경우 0.7보다 큰 값은 1로 고정 (가장 밝은 면)
0.5보다 큰 값은 0.5로 고정 (중간 면)
그 밖의 값은 0.2로 고정 (어두운 면) 합니다.

음영을 한 단계 더 주었습니다.




1
2
3
4
            float3 diff;
            diff = s.Albedo * ndotl*_LightColor0.rgb;
 
            return float4(diff,1);
cs

2pass의 알베도와 음영 계산을 곱하는 변수 diff를 선언한 후, 리턴해주면


텍스쳐를 입힌 상태로 툰 셰이딩의 단계별 음영이 적용됩니다. (너무 어두워서 엠비언트 다시 켜주었습니다)



지금까지는 라이팅 연산에서 atten을 곱해주는 게 일반적이었지만

툰 셰이딩에선 atten이 어색해 보이기도 합니다. 더 예쁜 쪽으로 진행합시다.

저는 atten을 연산하지 않고 진행하겠습니다.



음영 단계 별로 컬러값 주기


위 연산을 활용하여 단계별로 각기 다른 컬러를 주는 것도 가능합니다.



컬러값을 받을 변수 toonCol을 선언하여, 출력할 컬러 값을 넣어주고

ndotl의 자리에 넣어 연산합니다.



각 음영 단계에 원하는 컬러를 넣고, 인스펙터에서 조절할 수 있게 되었습니다.



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
Shader "Custom/10week"
{
    Properties
    {
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
        _Outline("외곽선 두께",Range(0,0.01)) = 0.01
        _Color("외곽선 컬러",Color) = (0,0,0,1)
        _ToonColor1("밝은컬러",Color)=(0,0,0,1)
        _ToonColor2("중간컬러",Color)=(0,0,0,1)
        _ToonColor3("어둠컬러",Color)=(0,0,0,1)
    }
 
    SubShader
    {
 
        Tags { "RenderType"="Opaque" }
        LOD 200
 
        //1pass 외곽선
        cull front
 
        CGPROGRAM
        #pragma surface surf cus vertex:vert noshadow 
        #pragma target 3.0
 
        sampler2D _MainTex;
        float _Outline;
        float4 _Color;
 
 
        void vert(inout appdata_full v)
        {
            v.vertex.xyz = v.vertex.xyz + v.normal*_Outline;
        }
 
        struct Input
        {
            float2 uv_MainTex;
        };
 
        void surf (Input IN, inout SurfaceOutput o)
        {
            
        }
 
        float4 Lightingcus(SurfaceOutput s, float3 lighhtDir, float atten)
        {
            return _Color;
        }
            
 
        ENDCG
 
        
        //2pass 오브젝트
        cull back
 
        CGPROGRAM
 
        #pragma surface surf cus noambient
        #pragma target 3.0
 
        sampler2D _MainTex;
        float4 _ToonColor1;
        float4 _ToonColor2;
        float4 _ToonColor3;
 
        struct Input
        {
            float2 uv_MainTex;
        };
 
        void surf(Input IN, inout SurfaceOutput o)
        {
            fixed4 c = tex2D(_MainTex, IN.uv_MainTex);
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }
 
        float4 Lightingcus(SurfaceOutput s, float3 lightDir, float atten)
        {
            float ndotl = dot(s.Normal, lightDir) *0.5+0.5 ;
 
            float3 toonCol;
 
 
            if (ndotl > 0.7
            {
                toonCol = _ToonColor1;
            }
            
            else if (ndotl > 0.5)
            {
                toonCol = _ToonColor2;
            }
            else
            {
                toonCol = _ToonColor3;
            }
 
            float3 diff;
            diff = s.Albedo * toonCol*_LightColor0.rgb;
 
            return float4(diff,1);
        }
 
        ENDCG
    }
    FallBack "Diffuse"
}
cs

전체 코드



ceil()함수 활용하여 단계별 음영주기


ceil()함수는 '올림'함수입니다.소수점을 올린 값을 리턴합니다.  (0.1은 1 ,1.3은 2, 4.2는 5 ....)


1
2
ndotl = ceil(ndotl);
return ndotl;
cs



이런 코드를 입력하면



모든 값을 올림처리하기 때문에 전부 밝은 면이 됩니다.


여기서 잠시 ceill()함수를 내려놓고

ndotl = ndotl*3; 연산을 봅시다.


해당 연산 후 ndotl을 리턴하면



오른쪽 그림과 같이 됩니다. 단순히 밝은 부분이 늘어났다..라고 이해하지 말고 음영의 단계가, 수치의 폭이 넓어졌다고 생각합시다. (기존 0~1에서 0~3으로)


다시 ceil() 함수로 돌아와서


ndotl = ceil(ndotl*3); 을 해봅시다.



전부 하얀 색으로 보이지만 역시 수치로 생각해 봅시다. 위 오브젝트에는 1,2,3 세 단계의 음영값이 존재할 것입니다.


마지막으로 ndotl = ceil(ndotl*3)/3; 을 해봅니다.


1의 값은 0.3 / 2의 값은 0.66 ... / 3의 값은 1이 될텐데요 따라서

 


이와 같은 결과를 얻을 수 있습니다. if문을 사용하지 않아도 단계별 음영이 구현됩니다!


여기서 '곱하는 값'과 '나누는 값'을 변수로 선언하면 더욱 쉽게 음영단계를 조절할 수 있겠죠.

ndotl = ceil(ndotl*_Cel)/_Cel;



_Cel값을 높일수록 음영 단계가 늘어나는 모습



셀 셰이딩 구현: 림라이트 활용 + 종합


새 셰이더 코드를 만들어봅시다.


림라이트 연산을 활용하여 외곽선을 구현해봅니다. 이후 단계별 음영을 구현하고, 스페큘러까지 적용해봅시다. -> 이 경우 1pass로 셀셰이딩 구현이 가능합니다!



1
2
3
4
5
6
7
8
9
10
11
12
13
14
float4 Lightingcus(SurfaceOutput s, float3 lightDir, float3 viewDir, float atten)
        {
            float rim = dot(s.Normal, viewDir);
            if (rim < 0.3)
            {
                rim = 0;
            }
            else
            {
                rim = 1;
            }
            
            return rim;
        }
cs



if문을 활용했습니다. rim연산 후, 음영값이 0.3보다 작은 곳은 값을 0으로 고정하여 어둡게 그리고

그 외의 값은 1로 밝게 그립니다.



rim을 활용한 외곽선이 구현되었습니다.


위에서 진행했듯이 if문을 통해 음영을 세 단계로 나누어주고, Albedo까지 연산한 후 텍스쳐를 넣어주면



외곽선+음영까지 적용되었습니다.


스페큘러 구현


1
2
3
4
5
6
7
8
9
10
11
12
13
14
//스페큘러
            float3 H = normalize(viewDir + lightDir);
            float spec = dot(s.Normal, H);
            
            if (spec > 0.99)
            {
                spec = 1;
            }
            else
            {
                spec = 0;
            }
 
            return float4 (spec.xxx,1);
cs


viewDir와 lightDir를 더해 하프벡터를 구해주고,

노말과 하프벡터를 dot연산하여 스페큘러를 연산합니다.


pow는 if문으로 조절합니다.


spec의 값이 0.99보다 클 경우 1로 밝게 그리고, 이외에는 전부 0으로 처리합니다.



스페큘러를 연산했습니다.


최종 연산에 spec을 더해주면,



기존의 툰셰이딩에 스페큘러가 얹어졌습니다.



노말까지 적용해보았습니다.


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
Shader "Custom/10Week2"
{
    Properties
    {
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
        _BumpMap ("Normal(RGB",2D)="bump"{}
    }
 
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 200
 
        CGPROGRAM
        #pragma surface surf cus fullforwardshadows noambient
        #pragma target 3.0
 
        sampler2D _MainTex;
        sampler2D _BumpMap;
 
        struct Input
        {
            float2 uv_MainTex;
            float2 uv_BumpMap;
        };
 
 
        void surf (Input IN, inout SurfaceOutput o)
        {
            fixed4 c = tex2D (_MainTex, IN.uv_MainTex);
            fixed4 d = tex2D (_BumpMap, IN.uv_BumpMap);
            
            o.Albedo = c.rgb;
            o.Normal = UnpackNormal(d);
 
            o.Alpha = c.a;
        }
 
        float4 Lightingcus(SurfaceOutput s, float3 lightDir, float3 viewDir, float atten)
        {
            //라이트
            float ndotl = dot(s.Normal, lightDir)*0.5 + 0.5;
            
            if (ndotl > 0.7)
            {
                ndotl = 1;
            }
            else if (ndotl > 0.5)
            {
                ndotl = 0.5;
            }
            else 
            {
                ndotl = 0.35;
            }
            
            //스페큘러
            float3 H = normalize(viewDir + lightDir);
            float spec = dot(s.Normal, H);
            
            if (spec > 0.99)
            {
                spec = 1;
            }
            else
            {
                spec = 0;
            }
 
            //외곽선
            float rim = dot(s.Normal, viewDir);
            if (rim < 0.3)
            {
                rim = 0;
            }
            else
            {
                rim = 1;
            }
            
            float3 final;
            final = (rim * s.Albedo * ndotl) + (spec*s.Albedo*0.5);
 
            return float4 (final,1);
        }
 
        ENDCG
    }
    FallBack "Diffuse"
}
 
cs


전체 코드입니다.