[유니티 쉐이더 스타트업] Part 14 | 커스텀 라이트3: NPR 렌더링

jungizz_·2023년 2월 28일
0

Unity Shader StartUp

목록 보기
14/17
post-thumbnail

1. NPR 렌더링이란?

  • 지금까지 배운 Standard, Lambert, Blinn-Phong 방식은 서로 표현하는 수준이 다를 뿐, 실재하는 사물의 재질과 비슷하게 만들려고 하는 시도를 한다는 공통점이 있으며 이러한 렌더링 방식을 통틀어서 PR(Photo Realistic) 렌더링이라고 한다.
  • PR과 다르게 회화나 드로잉, 선화, 만화 등 사실적이지 않은 렌더링 방식을 통칭하여 NPR(Non-Photo Realistic) 렌더링 혹은 쉐이딩 이라고 한다. (ex-cell Rendering)

☑️ 셀 쉐이딩의 대표적인 특징

  1. 외곽선이 있다.
  2. 음영이 끊어져 있다.
PR(Plastic Shader) / NPR(Toon Shader)

2. 외곽선 만들기 이론

  • 외곽선 제작 방법은 크게 3가지로 나눠진다.
  1. 2Pass (한 번 그리는 것은 1Pass, 두 번 그리는 것은 2Pass)
  2. Fresnel
  3. 후처리(Post Effect)

☑️ 2Pass로 외곽선 제작

  • 첫 번째 패스로 오브젝트를 그리고, 똑같은 자리에 오브젝트를 한번 더 그린다. 이때, 두번째 오브젝트는 완전히 검게 그리고, 크기를 키워서 노멀을 뒤집는다.
  • 노멀을 뒤집는 이유는 노멀 방향으로 확장하여 균일한 외곽선을 얻기 위함이다.

❗ 2Pass의 문제점

  1. 두 번 그리는 것이므로 무겁다.
  2. Plane으로 끝난 오브젝트에는 외곽선이 생기지 않는다. (완전히 닫힌 오브젝트가 아닌경우)
  3. 면을 뒤집었을 뿐, 온전한 오브젝트 이기 때문에 메쉬끼리 침범하거나 찌꺼기가 보일 수 있다.
  4. 각진 면(Hard Edge)에서는 선이 끊어진다.

☑️ Frsenel으로 외곽선 제작

  • Rim 라이트를 만들었던 결과물을 단순히 뒤집거나, if문을 이용해서 외각 라인을 검출해 외곽선을 칠해준다.
  • 의도적으로 선의 강약이 들어가도록 모델링이 가능하고, 품질이 좋다.

❗ Fresnel의 문제점

: 완전한 평면을 가진 오브젝트에서는 이상한 모양으로 보인다.
(각 픽셀의 노멀 방향의 시선 방향의 차이로 계산되기 때문)


3. 2Pass✔️ / 면뒤집기

  • Lambert라이트를 가진 기본형에서 시작한다.
Shader "Custom/2PassShader"
{
    Properties
    {
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        
        CGPROGRAM
        #pragma surface surf Lambert

        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"
}

  • CGPROGRAM - ENDCG를 한 번 더 작성한다.
  • 완전히 같은 위치에 같은 오브젝트가 그려져서 보이진 않지만, Game 뷰의 Stats를 비교하면 Batches가 늘어난 걸 확인할 수 있다.
  • 2pass는 잠깐 숨겨두고, 1pass를 조정하기 위해 면 뒤집기를 먼저 진행할 것이다.

☑️ Backface Culling

  • 3D에서는 흔히 뒷면을 그리지 않는다. 예를 들어 Plane을 만들고 뒤집으면 보이지 않는데, 이 현상을 Backface Culling이라고 한다.
  • 현재 쉐이더에서는 자동으로 작동되어 있다.
  • 뒷면을 날리고 앞면을 살리는 Backface Culling을 명시적으로 나타내면 cull back이며, 이와 반대로 앞면을 날리고 뒷면을 보이게 해주는 FrontFace Culling은 cull front라고 작성한다.
  • 일단 아래의 확장 결과를 보기 위해 주석처리 해둔다.

4. Vertex Shader를 이용한 오브젝트 확장

  • 여태까지 다뤘던 Surface Shader에서, 버텍스의 변환을 제어하는 Vertex Shader와 화면에 출력될 픽셀의 컬러를 결정하는 Pixel Shader 중에서 Pixel Shader만 다뤘다.
  • 이제는 Vertex Shader를 다뤄보기 위해 vert()함수를 작성해서 가동시킨다.
    (surf()는 Pixel Shader의 함수, vert()는 Vertex Shader의 함수이다.)
  • vertex: vert - 버텍스 쉐이더가 vert라는 함수 이름으로 시작됨을 알려줌
  • appdata_full - surf함수에서 사용한 SurfaceOutput처럼 미리 선언되어있는 특정한 구조체.

  • appdata 구조체 (3가지 종류가 있으며 작은 것을 선택할수록 가볍다.)
//위치, 노멀과 하나의 텍스쳐 좌표게
struct appdata_base { 
    float4 vertex : POSITION;
    float3 normal : NORMAL;
    float4 texcoord : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};
//위치, 탄젠트, 노멀과 하나의 텍스쳐 좌표계
struct appdata_tan {
    float4 vertex : POSITION;
    float4 tangent : TANGENT;
    float3 normal : NORMAL;
    float4 texcoord : TEXCOORDO;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};
//위치, 탄젠트, 노멀, 4개의 텍스쳐 좌표계와 버텍스 컬러
struct appdata_full {
    float4 vertex : POSITION; //버텍스 위치
    float4 tangent : TANGENT; //접선 방향
    float3 normal : NORMAL; //버텍스의 노멀
    float4 texcoord : TEXCOORDO; //UV좌표
    float4 texcoordi : TEXCOORD1; 
    float4 texcoord2 : TEXCOORD2; 
    float4 texcoord3 : TEXCOORD3;
    fixed4 color: COLOR; //버텍스 컬러
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

  • 원래 쉐이더에서는 Vertex Shader에서 로컬 > 월드 > 카메라 > 프로젝션 좌표계로 변환하는 작업을 거치지만, 유니티의 Surface Shader의 Vertex Shader는 알아서 자동 변환을 해주므로 필요한 것만 가져다 쓰면 된다.
  • 만약 버텍스의 위치를 위로 올리고 싶다면 아래와 같이 작성할 수 있다.
void vert(inout appdata_full v) {
	v.vertex.y += 1; //실제 오브젝트의 이동이 아닌 버텍스만 이동
}
  • 각 버텍스를 노멀 방향으로 확장
void vert(inout appdata_full v) {
	v.vertex.xyz += v.normal.xyz;
}

  • 엄청나게 확장되어 공처럼 되어버렸는데, 기본적으로 모든 벡터는 길이가 1로 되어있으므로 각 버텍스는 노멀 방향으로 1유닛(1m)씩 이동한 것이다.

  • 또한, 그림자는 작은 상태의 모델링을 인식하여 만들어지고 있다. 위의 함수에서 다뤄지지 않는 영역이기 때문이다.

  • 당장은 그림자가 필요 없으므로 noshadow 구문을 추가하고, 확장할 정도를 1이 아닌 0.01(1cm)정도로 줄여준다.

#pragma surface surf Lambert vertex:vert noshadow

···

void vert(inout appdata_full v) {
	v.vertex.xyz += v.normal.xyz * 0.01;
}

❗확정 정도를 Properties로 빼서 빼빼마르거나 뚱뚱해지는 효과를 만들거나 버텍스 컬러와 연동시켜 특정 범위만 부풀게 할 수도 있다.
  • 다시 cull front로 면을 뒤집고, 오브젝트를 검게 만든다.

  • 2nd Pass의 주석을 풀고 2nd Pass는 면이 뒤집히지 않도록 시작하기 전에 cull back을 선언한다.

📝 최종코드

Shader "Custom/2PassShader"
{
    Properties
    {
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }

        cull front
        
        //1st Pass
        CGPROGRAM
        #pragma surface surf Nolight vertex:vert noshadow noambient

        sampler2D _MainTex;

        void vert(inout appdata_full v) {
            v.vertex.xyz += v.normal.xyz * 0.01;
        }

        struct Input
        {
            float4 color:COLOR;
        };
      
        void surf (Input IN, inout SurfaceOutput o)
        {

        }

        float4 LightingNolight(SurfaceOutput s, float3 lightDir, float atten) {
            return float4(0, 0, 0, 1);
        }
        ENDCG

        cull back
        
        //2nd Pass
        CGPROGRAM
        #pragma surface surf Lambert

        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"
}

5. 끊어지는 음영 만들기

  • 1st Pass는 주석처리한 후 2nd Pass를 커스텀 라이트로 만들기 위해 붉은 얼굴부터 시작한다.
cull back
//2nd Pass
CGPROGRAM
#pragma surface surf Toon noambient

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;
}

float4 LightingToon(SurfaceOutput s, float3 lightDir, float atten) {
    return float4(1, 0, 0, 1);
}
ENDCG
  • Half-Lambert 공식을 이용해서 0~1 음영을 출력한다.
float4 LightingToon(SurfaceOutput s, float3 lightDir, float atten) {
    float ndot1 = dot(s.Normal, lightDir) * 0.5 + 0.5;
    return ndot1;
}

  • if문으로 음영을 지정한다.
float ndot1 = dot(s.Normal, lightDir) * 0.5 + 0.5;
if (ndot1 > 0.5) ndot1 = 1;
else ndot1 = 0.3;
return ndot1;

  • 또는, cell 올림 함수를 사용해서 나타낼 수도 있다.
float ndot1 = dot(s.Normal, lightDir) * 0.5 + 0.5;
ndot1 *= 5;
ndot1 = ceil(ndot1) / 5;
return ndot1;


📝 최종코드(Albedo와 NormalMap 적용)

Shader "Custom/2PassShader"
{
    Properties
    {
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
        _BumpMap ("NormalMap", 2D) = "bump" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }

        cull front
        
        //1st Pass
        CGPROGRAM
        #pragma surface surf Nolight vertex:vert noshadow noambient

        void vert(inout appdata_full v) {
            v.vertex.xyz += v.normal.xyz * 0.01;
        }

        struct Input
        {
            float4 color:COLOR;
        };
      
        void surf (Input IN, inout SurfaceOutput o)
        {

        }

        float4 LightingNolight(SurfaceOutput s, float3 lightDir, float atten) {
            return float4(0, 0, 0, 1);
        }
        ENDCG

        cull back
        
        //2nd Pass
        CGPROGRAM
        #pragma surface surf Toon

        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);
            o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap));
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }

        float4 LightingToon(SurfaceOutput s, float3 lightDir, float atten) {
            float ndot1 = dot(s.Normal, lightDir) * 0.5 + 0.5;
            ndot1 *= 5;
            ndot1 = ceil(ndot1) / 5;

            float4 final;
            final.rgb = s.Albedo * ndot1 * _LightColor0.rgb;
            final.a = s.Alpha;

            return final;
        }
        ENDCG
    }
    FallBack "Diffuse"
}


6. Fresnel✔️

  • 위 코드에서 외곽선을 나타낸 1st Pass만 지운 상태에서 시작한다.
Shader "Custom/FresnalShader"
{
    Properties
    {
        _MainTex("Albedo (RGB)", 2D) = "white" {}
        _BumpMap("NormalMap", 2D) = "bump" {}
    }
    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        cull back

        CGPROGRAM
        #pragma surface surf Toon

        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);
            o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap));
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }

        float4 LightingToon(SurfaceOutput s, float3 lightDir, float atten) {
            float ndot1 = dot(s.Normal, lightDir) * 0.5 + 0.5;
            ndot1 *= 5;
            ndot1 = ceil(ndot1) / 5;

            float4 final;
            final.rgb = s.Albedo * ndot1 * _LightColor0.rgb;
            final.a = s.Alpha;

            return final;
        }
        ENDCG
    }
    FallBack "Diffuse"
}
  • 커스텀라이트 구문에 viewDir을 받아와서 Fresnel연산을 하고, if문을 이용해 음영을 만들어 준다. rim을 반환해서 확인한다.

📝 최종코드

Shader "Custom/FresnalShader"
{
    Properties
    {
        _MainTex("Albedo (RGB)", 2D) = "white" {}
        _BumpMap("NormalMap", 2D) = "bump" {}
    }
    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        cull back

        CGPROGRAM
        #pragma surface surf Toon

        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);
            o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap));
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }

        float4 LightingToon(SurfaceOutput s, float3 lightDir, float3 viewDir, float atten) {
            float ndot1 = dot(s.Normal, lightDir) * 0.5 + 0.5;
            ndot1 *= 5;
            ndot1 = ceil(ndot1) / 5;

            float rim = abs(dot(s.Normal, viewDir));
            if (rim > 0.3) rim = 1;
            else rim = -1;

            float4 final;
            final.rgb = s.Albedo * ndot1 * _LightColor0.rgb * rim;
            final.a = s.Alpha;

            return final;
        }
        ENDCG
    }
    FallBack "Diffuse"
}


7. Diffuse Warping 기법

  • 노멀과 라이트 벡터의 내적을 UV로 이용
  • 가볍고 응용이 편리해서 지금도 많이 사용되는 기법

  • 텍스쳐 두 장을 받고 커스텀 라이트로 ndot1을 구현하는 쉐이더에서 시작
Shader "Custom/DiffuseWrapingShader"
{
    Properties
    {
        _MainTex("Albedo (RGB)", 2D) = "white" {}
        _RampTex("RampTex", 2D) = "white"{}
    }
    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        CGPROGRAM
        #pragma surface surf warp noambient

        sampler2D _MainTex;
        sampler2D _RamTex;

        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 Lightingwarp(SurfaceOutput s, float3 lightDir, float3 viewDir, float atten) {
            float ndot1 = dot(s.Normal, lightDir) * 0.5 + 0.5;
            return ndot1;
        }
        ENDCG
    }
    FallBack "Diffuse"
}
  • _RampTex에는 아래와 같은 이미지를 넣는다.

  • 이번에는 텍스쳐 연산을 커스텀 라이트 함수에서 진행한다.

float4 Lightingwarp(SurfaceOutput s, float3 lightDir, float3 viewDir, float atten) {
    float ndot1 = dot(s.Normal, lightDir);
    
    //텍스쳐 연산, UV자리에 (0.1, 0.1)를 넣었다.
    float4 ramp = tex2D(_RampTex, float2(0.1, 0.1));
    
    return ramp;
}

  • UV자리에 float2(0.1, 0.1)이 들어가 있으니 UV가 0.1, 0.1인 자리의 컬러를 출력한 것이다.
  • Half Lambert로 연산한 ndot1으로 밝은 부분은 1, 어두운 부분은 0으로 나오는 상태이고, 이것을 ramp 텍스쳐 UV 자리에 U로 넣는다.
float4 ramp = tex2D(_RampTex, float2(ndot1, 0.5));

☑️ Ramp 텍스쳐

  • Wrap Mode를 Repeat로 하면 마지막 텍스쳐 색이 처음 부분에 반복되어 묻어나올 수 있기 때문에 Clamp를 사용하는 것이 좋다.
  • 텍스쳐의 사이즈를 줄이고 압축을 None으로 하여 컬러의 손실이 생기지 않도록 한다.

☑️ Diffuse Warping 기법의 장점

  • 사용하는 Ramp 텍스쳐에 따라 PR/NPR 렌더링을 아우르는 다양한 효과를 낼 수 있다.
  • 특히, 빛이 어둡게 넘어가는 중간 부분에 붉은 색을 강제로 칠해주면 매우 간단하게 가짜 피부 SSS(Subsurface-Scattering) 효과를 만들 수 있다.

📝 최종 코드 (NormalMap, 텍스쳐 추가)

Shader "Custom/DiffuseWrapingShader"
{
    Properties
    {
        _MainTex("Albedo (RGB)", 2D) = "white" {}
        _BumpMap("NormalMap", 2D) = "bump"{}
        _RampTex("RampTex", 2D) = "white"{}
    }
    SubShader
    {
        Tags { "RenderType" = "Opaque" }

        CGPROGRAM
        #pragma surface surf warp noambient

        sampler2D _MainTex;
        sampler2D _BumpMap;
        sampler2D _RampTex;

        struct Input
        {
            float2 uv_MainTex;
            float2 uv_BumpMap;
        };

        void surf(Input IN, inout SurfaceOutput o)
        {
            fixed4 c = tex2D(_MainTex, IN.uv_MainTex);
            o.Normal = UnpackNormal(tex2D(_BumpMap, IN.uv_BumpMap));
            o.Albedo = c.rgb;
            o.Alpha = c.a;
        }

        float4 Lightingwarp(SurfaceOutput s, float3 lightDir, float3 viewDir, float atten) {
            float ndot1 = dot(s.Normal, lightDir) * 0.5 + 0.5;
            float4 ramp = tex2D(_RampTex, float2(ndot1, 0.5));

            float4 final;
            final.rgb = s.Albedo.rgb * ramp.rgb;
            final.a = s.Alpha;
            return final;
        }
        ENDCG
    }
    FallBack "Diffuse"
}


8. Diffuse Warping 응용

  • Diffuse Warping 기법은 응용할 수 있는 방식이 많다.

1. Specular

  • 위의 Ramp 텍스쳐의 UV Y축에 Specular 연산을 넣으면, pow 연산을 절약할 수 있다. (pow는 무거운 함수이다.)
  • 위에 흰 줄이 그려진 Ramp 텍스쳐를 사용한다.
float4 Lightingwarp(SurfaceOutput s, float3 lightDir, float3 viewDir, float atten) {
    float ndot1 = dot(s.Normal, lightDir) * 0.5 + 0.5;
    
    //specular
    float3 H = normalize(lightDir + viewDir);
    float spec = saturate(dot(s.Normal, H));
    
    //X에 N·L, Y에 H·V
    float4 ramp = tex2D(_RampTex, float2(ndot1, spec));

    float4 final;
    final.rgb = (s.Albedo.rgb * ramp.rgb) + (ramp.rgb * 0.1); //ramp 텍스쳐를 10% 강하게
    final.a = s.Alpha;
    return final;
}


2. Fresnel

  • N·V를 UV Y축으로 이용하면 Fresnel에 따라 카메라 방향의 가짜 스펙큘러를 구현할 수 있다.
  • Ramp텍스쳐 위의 흰 줄은 카메라 방향의 스펙큘러이고, 아래의 검은 줄은 Lambert 연산의 밝기에 따라 두께의 변화를 만든다.
    (밝은 부분 외곽선은 얇고, 어두운 부분은 두껍다.)
float4 Lightingwarp(SurfaceOutput s, float3 lightDir, float3 viewDir, float atten) {
    float ndot1 = dot(s.Normal, lightDir) * 0.5 + 0.5;
    
    //Fresnel
    float rim = abs(dot(s.Normal, viewDir));
    
    //X에 N·L, Y에 N·V
    float4 ramp = tex2D(_RampTex, float2(ndot1, rim));

    float4 final;
    final.rgb = (s.Albedo.rgb * ramp.rgb) + (ramp.rgb * 0.1);
    final.a = s.Alpha;
    return final;
}

profile
( •̀ .̫ •́ )✧

0개의 댓글

관련 채용 정보