본문 바로가기

Graphics/Ronja's Unity Shader tutorials

Hull 외곽선

 

 

Hull Outlines

Summary So far we only ever wrote a color to the screen once per shader (or let unity generate multiple passes for us via surface shaders). But we have the possibility to draw our mesh multiple times in a single shader. A great way to use this is to draw o

www.ronja-tutorials.com

 

Ronja 님의 허락을 받고 번역한 튜토리얼입니다. 원문은 위 링크에서 확인하실 수 있습니다.
몇몇 부분은 생략·추가하였습니다.
의역과 오역이 넘쳐날 수 있으니 편하게 봐주시고 잘못된 부분은 알려주시면 감사하겠습니다!

 

목차

  • 개요
  • 언릿(Unlit) 셰이더용 외곽선
  • 서피스(Surface) 셰이더용 외곽선

 


 

개요

지금까지 우리는 하나의 셰이더에서 색을 한 번씩만 출력해 왔습니다. (또는 서피스 셰이더를 통해서 Unity가 자동으로 여러 패스를 생성하도록 했습니다.) 하지만 하나의 셰이더 안에서 동일한 메시를 여러 번 그릴 수도 있습니다. 이를 활용하는 대표적인 방법이 바로 외곽선(Outline)을 그리는 것입니다.

먼저 오브젝트를 평소처럼 한 번 렌더링한 뒤, 같은 오브젝트를 다시 한 번 그립니다. 이 때 정점(Vertex)을 약간 변경하여, 원본 오브젝트의 바깥 부분에서만 보이도록 만들면, 외곽선이 그려지게 됩니다.

이 튜토리얼을 이해하기 위해선 서피스 셰이더에 대해 어느정도 이해하는 편이 좋습니다.
이 셰이더의 첫 번째 버전은 기본적인 언릿 셰이더를 기반으로 구성했습니다.

 

 

언릿(Unlit) 셰이더용 외곽선

이미 아래의 셰이더에는 하나의 셰이더 패스가 존재하므로, 일단 그 패스를 그대로 복제합니다.
같은 정보를 두 번 출력하고 있기 때문에, 이 상태에서는 셰이더가 적용된 모습에는 아무런 변화가 없습니다.

 

// 외곽선을 렌더링하는 두 번째 패스
Pass
{
    CGPROGRAM

    // 유용한 셰이더 함수들을 포함해줍니다.
    #include "UnityCG.cginc"

    // 버텍스 셰이더와 프래그먼트 셰이더를 정의합니다.
    #pragma vertex vert
    #pragma fragment frag

    // 텍스처와 텍스처 변환 값입니다.
    sampler2D _MainTex;
    float4 _MainTex_ST;

    // 텍스처의 색조(Tint) 값입니다.
    fixed4 _Color;

    // 버텍스 셰이더에 전달되는 오브젝트 데이터
    struct appdata
    {
        float4 vertex : POSITION;
        float2 uv : TEXCOORD0;
    };

    // 프래그먼트를 생성할 때 사용되며, 프래그먼트 셰이더에서 읽을 수 있는 데이터
    struct v2f
    {
        float4 position : SV_POSITION;
        float2 uv : TEXCOORD0;
    };

    // 버텍스 셰이더
    v2f vert(appdata v)
    {
        v2f o;
        // 렌더될 수 있도록 버텍스 좌표를 오브젝트 공간에서 클립 공간으로 변환합니다.
        o.position = UnityObjectToClipPos(v.vertex);
        o.uv = TRANSFORM_TEX(v.uv, _MainTex);
        return o;
    }

    // 프래그먼트 셰이더
    fixed4 frag(v2f i) : SV_TARGET
    {
        fixed4 col = tex2D(_MainTex, i.uv);
        col *= _Color;
        return col;
    }

    ENDCG
}

 

다음 단계는 프로퍼티와 변수를 설정하는 것입니다.

이 두번째 패스에서는 화면에 단순한 색상만을 출력할 것이므로 텍스처는 필요하지 않고, 외곽선 색상과 외곽선 두께 값만 필요합니다.

프로퍼티는 평소처럼 상단의 프로퍼티 영역에 추가해줍니다. 다만, 새로 추가하는 변수들은 반드시 두 번째 패스 안에서 선언해야 합니다.

 

// 인스펙터 창에서 조절할 수 있는 값을 표시합니다.
Properties
{
    _OutlineColor ("Outline Color", Color) = (0, 0, 0, 1)
    _OutlineThickness ("Outline Thickness", Range(0,.1)) = 0.03

    _Color ("Tint", Color) = (0, 0, 0, 1)
    _MainTex ("Texture", 2D) = "white" {}
}

// ···

// 외곽선의 색상
fixed4 _OutlineColor;
// 외곽선의 두께
float _OutlineThickness;

 

다음 단계로 프래그먼트 셰이더를 새로 작성해 텍스처 대신 새로 추가한 색상 변수를 사용하도록 만들어줍니다. 별도의 계산을 수행하지 않고, 해당 색상을 그대로 반환하면 됩니다.

 

// 프래그먼트 셰이더
fixed4 frag(v2f i) : SV_TARGET
{
    return _OutlineColor;
}

 

이 패스에서는 텍스처 값을 읽지 않으므로, uv 좌표도 필요하지 않습니다. 따라서, 입력 구조체 appdata 와 버텍스→프래그먼트 전달 구조체 v2f 에서 uv를 제거하고, 버텍스 셰이더에서 해당 값을 전달하는 코드도 함께 삭제합니다.

 

// 버텍스 셰이더에 전달되는 오브젝트 데이터
struct appdata
{
    float4 vertex : POSITION;
};

// 프래그먼트를 생성할 때 사용되며, 프래그먼트 셰이더에서 읽을 수 있는 데이터
struct v2f
{
    float4 position : SV_POSITION;
};

// 버텍스 셰이더
v2f vert(appdata v)
{
    v2f o;
    // 렌더될 수 있도록 버텍스 좌표를 오브젝트 공간에서 클립 공간으로 변환합니다.
    o.position = UnityObjectToClipPos(position);
    return o;
}

 

 

이 변경을 적용하면, 오브젝트가 단순히 외곽선 색상으로 표시되는 것을 확인할 수 있습니다. 이는 두 번째 패스가 첫 번째 패스에서 그린 모든 내용을 그대로 덮어쓰고 있기 때문입니다. 이 부분은 이후에 수정해 줄 예정입니다.

이를 수정하기 전에 먼저, 외곽선이 기본 오브젝트의 바깥쪽에 위치하도록 만들어야 합니다. 이를 위해 정점 노말 방향을 따라 메시를 확장합니다. 따라서 입력 구조체에 노말 데이터를 추가한 뒤, 해당 노말을 정점 위치에 더해줍니다. 또한 원하는 두께를 얻기 위해 노말을 정규화한 뒤 외곽선 두께 값과 곱해줍니다.

 

// 버텍스 셰이더에 전달되는 오브젝트 데이터
struct appdata
{
    float4 vertex : POSITION;
    float3 normal : NORMAL;
};

// ···

// 버텍스 셰이더
v2f vert(appdata v)
{
    v2f o;
    // 확장된 오브젝트의 위치를 계산합니다.
    float3 normal = normalize(v.normal);
    float3 outlineOffset = normal * _OutlineThickness;
    float3 position = v.vertex + outlineOffset;
    // 렌더될 수 있도록 버텍스 좌표를 오브젝트 공간에서 클립 공간으로 변환합니다.
    o.position = UnityObjectToClipPos(position);

    return o;
}

 

 

이제 Hull 의 두께를 조절할 수 있게 되었지만, 여전히 기본 오브젝트를 가리고 있습니다. 이를 해결하기 위해 Hull 의 앞면은 렌더링하지 않도록 합니다.

일반적으로 오브젝트를 렌더링할 때는 성능상의 이유로 앞면만 그립니다. (오브젝트 내부로 들어갔을 때 바깥이 보였던 경험이 있다면, 이런 이유 때문입니다.) 여기서는 이를 반대로 적용하여 뒷면만 그리도록 설정합니다. 그러면 Hull 이 오브젝트보다 더 크기 때문에, 오브젝트를 통해 Hull 의 내부를 볼 수 있고, 결과적으로 오브젝트 뒤쪽에 있는 Hull 이 외곽선처럼 보이게 됩니다.

Unity가 오브젝트의 앞면을 렌더링하지 않도록 하려면, HLSL 코드 영역 바깥의 헐 패스에 Cull Front 속성을 추가하면 됩니다.

 

// 외곽선을 렌더링하는 두 번째 패스
Pass
{
    Cull Front
// ···

 

이제 우리가 원하는 외곽선이 만들어졌습니다.

 

 

소스 코드

Shader "Tutorial/19_InvertedHull/Unlit"
{
    // 인스펙터 창에서 조절할 수 있는 값을 표시합니다.
    Properties
    {
        _OutlineColor ("Outline Color", Color) = (0, 0, 0, 1)
        _OutlineThickness ("Outline Thickness", Range(0,.1)) = 0.03

        _Color ("Tint", Color) = (0, 0, 0, 1)
        _MainTex ("Texture", 2D) = "white" {}
    }

    SubShader
    {
        // 머티리얼이 완전히 불투명하며, 다른 불투명 지오메트리와 동일한 시점에 렌더링되도록 지정합니다.
        Tags { "RenderType"="Opaque" "Queue"="Geometry" }

        // 오브젝트 자체를 렌더링하는 첫 번째 패스
        Pass
        {
            CGPROGRAM

            // 유용한 셰이더 함수들을 포함해줍니다.
            #include "UnityCG.cginc"

            // 버텍스 셰이더와 프래그먼트 셰이더를 정의합니다.
            #pragma vertex vert
            #pragma fragment frag

            // 텍스처와 텍스처 변환 값입니다.
            sampler2D _MainTex;
            float4 _MainTex_ST;

            // 텍스처의 색조(Tint) 값입니다.
            fixed4 _Color;

            // 버텍스 셰이더에 전달되는 오브젝트 데이터
            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            // 프래그먼트를 생성할 때 사용되며, 프래그먼트 셰이더에서 읽을 수 있는 데이터
            struct v2f
            {
                float4 position : SV_POSITION;
                float2 uv : TEXCOORD0;
            };

            // 버텍스 셰이더
            v2f vert(appdata v)
            {
                v2f o;
                // 렌더될 수 있도록 버텍스 좌표를 오브젝트 공간에서 클립 공간으로 변환합니다.
                o.position = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            // 프래그먼트 셰이더
            fixed4 frag(v2f i) : SV_TARGET
            {
                fixed4 col = tex2D(_MainTex, i.uv);
                col *= _Color;
                return col;
            }

            ENDCG
        }

        // 외곽선을 렌더링하는 두 번째 패스
        Pass
        {
            Cull Front

            CGPROGRAM

            // 유용한 셰이더 함수들을 포함해줍니다.
            #include "UnityCG.cginc"

            // 버텍스 셰이더와 프래그먼트 셰이더를 정의합니다.
            #pragma vertex vert
            #pragma fragment frag

            // 외곽선의 색상
            fixed4 _OutlineColor;
            // 외곽선의 두께
            float _OutlineThickness;

            // 버텍스 셰이더에 전달되는 오브젝트 데이터
            struct appdata
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
            };

            // 프래그먼트를 생성할 때 사용되며, 프래그먼트 셰이더에서 읽을 수 있는 데이터
            struct v2f
            {
                float4 position : SV_POSITION;
            };

            // 버텍스 셰이더
            v2f vert(appdata v)
            {
                v2f o;
                // 확장된 오브젝트의 위치를 계산합니다.
                float3 normal = normalize(v.normal);
                float3 outlineOffset = normal * _OutlineThickness;
                float3 position = v.vertex + outlineOffset;
                // 렌더될 수 있도록 버텍스 좌표를 오브젝트 공간에서 클립 공간으로 변환합니다.
                o.position = UnityObjectToClipPos(position);

                return o;
            }

            // 프래그먼트 셰이더
            fixed4 frag(v2f i) : SV_TARGET
            {
                return _OutlineColor;
            }

            ENDCG
        }
    }

    // 그림자 및 메타 패스 등 구현하지 않은 기능을 추가하기 위한 Fall-Back 입니다.
    FallBack "Standard"
}

 


 

서피스(Surface) 셰이더용 외곽선

서피스 셰이더에도 외곽선을 적용하는 것은 비교적 간단합니다. Unity가 서피스 셰이더의 패스를 자동으로 생성해주지만, 우리가 직접 작성한 패스도 함께 사용할 수 있으며, 이러한 패스는 Unity에 의해 수정되지 않고 동작합니다.

따라서 언릿 셰이더에서 작성한 외곽선 패스를 그대로 서피스 셰이더에 복사해 넣기만 하면, 정상적으로 동작하게 됩니다.

 

 

Inverted Hull 셰이더를 이용한 외곽선과 포스트프로세싱 효과를 이용한 외곽선의 차이점은, Inverted Hull 방식은 머티리얼 단위로 외곽선을 적용할 수 있어 모든 오브젝트에 각 적용할 필요가 없다는 점입니다. 또한 뎁스와 노말을 기반으로 외곽선을 생성하는 방식과는 시각적인 결과값도 서로 다릅니다. 따라서 두 가지 기법을 모두 충분히 이해한 뒤, 자신의 게임에 더 적합한 방식을 선택하는 것이 좋습니다.

이제 멀티 패스를 사용하는 셰이더가 어떻게 동작하는지, 그리고 이를 활용해 어떻게 외곽선을 만드는지 이해하셨다면 좋겠습니다.

 

소스 코드

Shader "Tutorial/020_InvertedHull/Surface"
{
    // 인스펙터(Inspector) 창에서 조절 가능한 값을 노출시킵니다
    Properties
    {
        _Color ("Tint", Color) = (0, 0, 0, 1)
        _MainTex ("Texture", 2D) = "white" {}
        _Smoothness ("Smoothness", Range(0, 1)) = 0
        _Metallic ("Metalness", Range(0, 1)) = 0
        [HDR] _Emission ("Emission", color) = (0,0,0)

        _OutlineColor ("Outline Color", Color) = (0, 0, 0, 1)
        _OutlineThickness ("Outline Thickness", Range(0,1)) = 0.1
    }

    SubShader
    {
        // 이 머티리얼은 완전히 불투명하며, 다른 불투명한 지오메트리들과 동일한 타이밍에 렌더링됩니다
        Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

        CGPROGRAM

        // 이 셰이더는 서피스 셰이더로,
        // Unity가 백그라운드에서 확장하여 다양한 조명 효과와 기타 기능들을 자동으로 추가해줍니다
        // 우리의 서피스 셰이더 함수 이름은 surf이며, 사용자 정의 조명 모델을 사용합니다
        // fullforwardshadows는 Unity가 셰이더에 필요한 그림자 패스를 추가하도록 합니다
        // vertex:vert는 vert 함수를 버텍스 셰이더 함수로 사용하도록 지정합니다
        #pragma surface surf Standard fullforwardshadows
        #pragma target 3.0

        sampler2D _MainTex;
        fixed4 _Color;

        half _Smoothness;
        half _Metallic;
        half3 _Emission;

        // Unity가 자동으로 값을 채워주는 입력 구조체
        struct Input
        {
            float2 uv_MainTex;
        };

        // 조명 함수가 사용하는 다양한 표면 속성을 설정하는 서피스 셰이더 함수
        void surf (Input i, inout SurfaceOutputStandard o)
        {
            // 알베도 텍스처를 샘플링하고 색상 조정
            fixed4 col = tex2D(_MainTex, i.uv_MainTex);
            col *= _Color;
            o.Albedo = col.rgb;
            // Metalic, Smoothness, Emission 값을 직접 적용
            o.Metallic = _Metallic;
            o.Smoothness = _Smoothness;
            o.Emission = _Emission;
        }
        ENDCG

        // 외곽선을 렌더링하는 두 번째 패스
        Pass
        {
            Cull Front

            CGPROGRAM

            // 유용한 셰이더 함수들을 포함해줍니다.
            #include "UnityCG.cginc"

            // 버텍스 셰이더와 프래그먼트 셰이더를 정의합니다.
            #pragma vertex vert
            #pragma fragment frag

            // 외곽선의 색상
            fixed4 _OutlineColor;
            // 외곽선의 두께
            float _OutlineThickness;

            // 버텍스 셰이더에 전달되는 오브젝트 데이터
            struct appdata
            {
                float4 vertex : POSITION;
                float4 normal : NORMAL;
            };

            // 프래그먼트를 생성할 때 사용되며, 프래그먼트 셰이더에서 읽을 수 있는 데이터
            struct v2f
            {
                float4 position : SV_POSITION;
            };

            // 버텍스 셰이더
            v2f vert(appdata v)
            {
                v2f o;
                // 렌더될 수 있도록 버텍스 좌표를 오브젝트 공간에서 클립 공간으로 변환합니다.
                o.position = UnityObjectToClipPos(v.vertex + normalize(v.normal) * _OutlineThickness);
                return o;
            }

            // 프래그먼트 셰이더
            fixed4 frag(v2f i) : SV_TARGET
            {
                return _OutlineColor;
            }

            ENDCG
        }
    }

    // 그림자 및 메타 패스 등 구현하지 않은 기능을 추가하기 위한 Fall-Back 입니다.
    FallBack "Standard"
}