본문 바로가기

Graphics/Ronja's Unity Shader tutorials

폴리곤 클리핑

 

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

Polygon Clipping

Summary Of course everything we render so far is made of polygons, but someone asked me how to clip a polygon shape based on a list of points in a shader so I’ll explain how to do that now. I will explain how to do that with a single shader pass in a fra

www.ronja-tutorials.com

 

목차

  • 개요
  • 선 그리기
  • 여러개의 선으로 폴리곤 그리기
    • 꼭짓점 배열 채우기
  • 폴리곤 클리핑 및 색상 적용

 


 

개요

당연히 우리가 렌더하는 모든 것들은 폴리곤으로 만들어져 있습니다. 하지만 어떤 분이 특정한 점 목록을 기반으로 폴리곤을 클리핑하는 방법을 물어보셔서, 이번 튜토리얼에서 그 방법을 설명하려 합니다.

이번 튜토리얼에서는 프래그먼트 셰이더에서 단일 셰이더 패스를 사용하여 폴리곤을 클리핑하는 방법을 다룹니다.
다른 방법으로는 폴리곤을 기반으로 직접 삼각형을 생성한 후, 스텐실 버퍼를 사용하여 클리핑하는 방법도 있지만, 이번 튜토리얼에서 이 방법은 다루지 않겠습니다.

 

이번 튜토리얼은 복잡한 그래픽 처리를 하지 않는 간단한 기법을 설명하기 때문에, 언릿(Unlit) 셰이더를 사용해서 설명하겠습니다. 하지만 서피스 셰이더에서도 같은 방식으로 동작합니다. 이 튜토리얼은 프로퍼티가 포함된 간단한 셰이더를 사용할 예정입니다. 따라서 이 튜토리얼을 시작하기 전에, 기본적인 셰이더에 대한 지식이 있어야 합니다.

 

 


 

선 그리기

첫 번째로 해야 할 일은 월드 좌표를 셰이더에 추가하는 것 입니다. 다른 셰이더들 (플래너, 트라이플래너 그리고 체크무늬) 처럼 오브젝트 좌표를 Object-to-World 행렬과 곱한 뒤, 프래그먼트 셰이더로 그 값을 전달해 구현하겠습니다.

 

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

// 버텍스 셰이더
v2f vert (appdata v)
{
    v2f o;
    // 렌더될 수 있도록 버텍스 좌표를 오브젝트 공간에서 클립 공간으로 변환
    o.vertex = UnityObjectToClipPos(v.vertex);
    // 월드 공간 상의 버텍스 좌표를 계산하고 할당
    float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
    o.worldPos = worldPos.xyz;
    return o;
}

 

그 다음엔 프래그먼트 셰이더 작업으로 넘어갈 수 있습니다. 여기에서는 먼저 한 점이 선의 어느 쪽에 위치하는지를 계산하는 것부터 시작합니다. 이후에 점들을 기반으로 선을 생성할 것이기 때문에, 선을 선이 통과하는 두 점으로 정의하는 것이 가장 쉬운 방법입니다.

한 점이 선의 어느 쪽에 위치하는지를 계산하기 위해, 두 개의 벡터를 생성합니다. 첫 번째는 선 위의 임의의 한 점에서 해당 점까지 향하는 벡터, 두 번째는 선의 노말 벡터입니다. 일반적으로 선의 노말이라는 개념은 이치에 맞지는 않지만, 여기서는 선의 왼쪽과 오른쪽을 구분해야 할 필요가 있기 때문에, 선의 방향에 수직이면서 왼쪽을 향하는 벡터를 선의 노말로 정의합니다.

두 벡터를 구하고 나면, 둘의 내적을 계산하여 점이 선의 어느 쪽에 있는지 판단할 수 있습니다.
내적 결과가 양수이면, 점까지의 벡터가 선의 노말과 비슷한 방향을 향하고 있으므로, 점은 노말 벡터가 가리키는 쪽에 위치합니다.
내적 결과가 음수이면, 점까지의 벡터가 선의 노말과 대체로 반대의 방향을 향하고 있으므로, 점은 반대쪽에 위치하게 됩니다.
만약 내적 결과가 정확히 0 이라면, 점까지의 벡터가 선의 노말과 수직이며, 이 경우 점은 선 위에 위치해 있다는 의미입니다.

 

 

셰이더 코드에서 이를 구현하기 위해, 먼저 선을 정의하는 두 점을 설정하고, 필요한 세 개의 벡터를 계산합니다. 선의 방향 벡터를 계산하는 것부터 시작하겠습니다. 두 번째 점 위치에서 첫 번째 점을 빼서 벡터의 값을 구합니다. (방향이 중요할 경우, 두 점의 차를 계산할 때에는 항상 끝점에서 시작점을 빼야 합니다.) 그 다음, 선의 노말 벡터를 구하기 위해 선의 방향 벡터를 90도 회전시켜 x와 y 성분을 서로 바꾸고, 새로운 x 값을 반전시킵니다. (만약 y값을 반전시켰다면, 선의 오른쪽을 향하는 벡터가 나왔을 것입니다.) 마지막으로 확인하려는 점 (위 사진의 빨간 점) 에서 선을 정의하는 두 점 중 하나를 빼서 확인하려는 점까지의 벡터를 구합니다.

그 다음 점까지의 벡터와 선의 노말 벡터의 내적을 구하여 그 값을 화면에 출력합니다.

 

fixed4 frag (v2f i) : SV_Target
{
    float2 linePoint1 = float2(-1, 0);
    float2 linePoint2 = float2(1, 1);

    // 계산을 위해 필요한 변수들
    float2 lineDirection = linePoint2 - linePoint1;
    float2 lineNormal = float2(-lineDirection.y, lineDirection.x);
    float2 toPos = i.worldPos.xy - linePoint1;

    // 테스트할 위치가 선의 어느 쪽에 있는지 계산
    float side = dot(toPos, lineNormal);

    return side;
}

 

 

보시다시피, 우리가 정의한 선을 기준으로 작은 그라디언트가 생기는 것을 확인할 수 있습니다. 하지만 우리는 그라디언트가 아닌, 명확하게 구분되는 경계를 필요로 합니다. 이러한 그라디언트가 생기는 이유는 0보다 작은 값 (선의 오른쪽) 은 검정색, 0 에서 1 사이의 값 (선의 왼쪽 바로 근처) 는 회색조로, 1 이상의 값(선의 왼쪽 멀리)은 흰색으로 표시되기 때문입니다.

이 문제를 쉽게 해결하는 방법은, step 함수를 사용하는 것입니다. step(a, b)는 b가 a보다 작으면 0을, 그 외에는 1을 반환해줍니다. 그래서 step 함수의 입력값으로 0과 내적 연산의 결과값을 사용하면 선을 중심으로 양쪽을 구분하는 뚜렷한 경계를 만들 수 있습니다.

 

// 테스트할 위치가 선의 어느 쪽에 있는지 계산
float side = dot(toPos, lineNormal);
side = step(0, side);

return side;

 

 

다음으로는 새로운 점 하나와 선 두개를 추가하여 삼각형을 만들 수 있도록 합니다. 이를 위해, 지금까지 작성한 계산을 함수로 만들어 여러 번 사용하기 편리하게 합니다. 그래서 지금까지의 계산을 새로운 함수로 옮기고, 필요한 정보를 매개변수로 전달하도록 합니다. 이 경우에는, 위치를 확인하려는 점, 선의 첫 번째 점과 두 번째 점을 인자로 넘겨주면 됩니다.

 

// 점이 선의 왼쪽에 위치한다면 1을, 아니라면 0을 반환합니다.
float isLeftOfLine(float2 pos, float2 linePoint1, float2 linePoint2)
{
    // 계산을 위해 필요한 변수들
    float2 lineDirection = linePoint2 - linePoint1;
    float2 lineNormal = float2(-lineDirection.y, lineDirection.x);
    float2 toPos = pos - linePoint1;

    // 테스트할 위치가 선의 어느 쪽에 있는지 계산
    float side = dot(toPos, lineNormal);
    side = step(0, side);

    return side;
}

// 프래그먼트 셰이더
fixed4 frag (v2f i) : SV_Target
{
    float2 linePoint1 = float2(-1, 0);
    float2 linePoint2 = float2(1, 1);

    float side = isLeftOfLine(i.worldPos.xy, linePoint1, linePoint2);

    return side;
}

 


 

여러 개의 선으로 폴리곤 그리기

여러 선의 결과를 하나로 결합하고 싶을 때엔 여러 가지 방법이 있습니다. 모든 선의 왼쪽 방향에 있을 때만 참으로 간주하고, 그렇지 않다면 거짓으로 할 수도 있고, 또는 하나 이상의 선의 왼쪽에 있다면 참으로. 모든 선의 오른 쪽에 있을 때만 거짓으로 판단할 수도 있습니다.

제가 정의한 삼각형은 시계방향으로 그려지는데, 이는 각 선의 왼쪽이 바깥쪽이라는 뜻입니다. 따라서 그려진 폴리곤의 외부와 내부를 구분하려면, 모든 선 기준의 왼쪽 조각들의 집합을 찾아야 합니다.

이를 위해 각 선의 결과 값을 더합니다. 폴리곤의 외부는 하나 이상의 선의 왼쪽에 있기 때문에 값이 1 이상이 되고, 폴리곤 내부는 어떤 선의 왼쪽에도 속하지 않기 때문에 값이 0이 됩니다.

 

// 프래그먼트 셰이더
fixed4 frag (v2f i) : SV_Target
{
    float2 linePoint1 = float2(-1, 0);
    float2 linePoint2 = float2(1, 1);
    float2 linePoint3 = float2(1, -1);

    float outsideTriangle = isLeftOfLine(i.worldPos.xy, linePoint1, linePoint2);
    outsideTriangle = outsideTriangle + isLeftOfLine(i.worldPos.xy, linePoint2, linePoint3);
    outsideTriangle = outsideTriangle + isLeftOfLine(i.worldPos.xy, linePoint3, linePoint1);

    return outsideTriangle;
}

 

 

이제 폴리곤을 성공적으로 화면에 표시할 수 있게 되었으니, 셰이더 코드를 수정하지 않고도 폴리곤을 쉽게 편집할 수 있도록 기능을 확장하고자 합니다. 이를 위해 두 개의 변수를 추가하겠습니다. 하나는 점들의 배열이고, 다른 하나는 그 배열이 얼마나 채워져 있는지를 나타내는 변수입니다. 첫 번째 변수는 폴리곤을 구성하는 모든 점들의 좌표를 들고있고, 두 번째 변수는 셰이더에서 동적 배열을 지원하지 않기 때문에 필요합니다. 즉, 우리는 배열의 길이를 고정하고 그 중에서 얼마나 많은 칸을 사용하는지를 변수에 지정해야 합니다.

 

// 꼭짓점 처리를 위한 변수들
uniform float2 _corners[1000];
uniform uint _cornerCount;

 

꼭짓점 배열 채우기

셰이더에는 배열 프로퍼티가 없기 때문에, C# 코드로 배열을 채워야 합니다. 새로운 C# 클래스에는 두 가지 속성을 추가했습니다. [ExcuteInEditMode] 는 게임을 실행하지 않더라도 에디터상에서 폴리곤이 업데이트되도록 해주며, [RequireComponent()] 는 이 스크립트가 있는 게임오브젝트에 셰이더가 적용된 렌더러가 함께 사용되도록 강제해줍니다.

 

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[ExecuteInEditMode]
[RequireComponent(typeof(Renderer))]
public class PolygonController : MonoBehaviour
{

}

 

그런 다음 클래스에 두 개의 변수를 추가합니다. 하나는 셰이더가 적용된 메테리얼, 다른 하나는 셰이더로 전달할 점들의 배열입니다. 메테리얼 변수는 코드에서 가져올 것이고, 이 클래스 내에서만 사용되므로 private 으로 선언합니다. 점들의 배열도 외부에서 접근할 필요가 없기 때문에 마찬가지로 private 으로 선언하지만, 유니티가 값을 저장하고 인스펙터에 표시될 수 있도록 [SerializeField] 속성을 붙여줍니다.

 

[SerializeField]
private Vector2[] corners;

private Material _mat;

 

그 다음엔 셰이더에 정보를 전달하는 메소드를 작성합니다. 이 안에서 먼저 이미 메테리얼을 가져왔는지 확인하고, 아직 메테리얼을 가져오지 않았다면 현재 게임오브젝트의 렌더러를 통해 메테리얼을 가져옵니다. 이 때 shared material 필드를 사용하는데, material 필드를 사용하면 메테리얼의 인스턴스를 생성하기 때문이며, 지금은 그 동작을 원하지 않기 때문입니다.

그런 다음, 4차원 벡터를 요소로 가지는 크기가 1000인 배열을 새로 할당합니다. 2차원 벡터 대신 4차원 벡터를 사용하는 이유는, 유니티 API가 셰이더로 전달할 수 있는 배열이 4차원 벡터만 가능하기 때문입니다. 또한 배열의 크기를 1000으로 설정한 이유는 앞서 언급했듯 셰이더는 동적 배열을 지원하지 않기 때문에, 최대 점 개수를 미리 정해놓고 그 크기로 항상 할당해야 하기 때문입니다. 1000이라는 숫자는 임의로 정한 값입니다.

그 다음에는 이 배열을 점들의 좌표로 채웁니다. 2차원 벡터는 자동으로 세번째와 네번째 성분 값이 0인 4차원 벡터로 변환됩니다.벡터 배열을 준비한 뒤에는 이 배열을 메테리얼에 전달하고, 추가로 실제로 사용 중인 점의 개수도 함께 전달합니다.

 

void UpdateMaterial()
{
    // 아직 메테리얼을 가져오지 않았다면 가져오기
    if(_mat == null)
        _mat = GetComponent<Renderer>().sharedMaterial;

    // 셰이더로 전달할 배열 할당 및 채우기
    Vector4[] vec4Corners = new Vector4[1000];

    for(int i=0 ;i<corners.Length; i++)
    {
        vec4Corners[i] = corners[i];
    }

    // 배열을 메테리얼에 전달
    _mat.SetVectorArray("_corners", vec4Corners);
    _mat.SetInt("_cornerCount", corners.Length);
}

 

다음 단계는 이 함수를 실제로 호출하는 것입니다. 우리는 이를 두 가지 메소드 안에서 실행하려 하는데, 하나는 Start(), 다른 하나는 OnValidate() 입니다. Start() 메소드는 게임이 시작될 때 유니티가 자동으로 호출해주며, OnValidate() 메소드는 스크립트의 변수가 인스펙터에서 변경되었을 때 호출됩니다.

 

void Start()
{
    UpdateMaterial();
}

void OnValidate()
{
    UpdateMaterial();
}

 

스크립트 작성을 마친 뒤에는, 프로젝트에 추가해 실제로 동작하도록 할 수 있습니다. 해당 스크립트를 우리가 만든 메테리얼이 적용된 렌더러가 있는 게임오브젝트에 컴포넌트로 추가하기만 하면 됩니다. 그리고, 인스펙터에서 배열에 점들을 추가하는 식으로 쉽게 꼭짓점들을 설정할 수 있습니다.

 

 

이제 다시 셰이더로 돌아가서 실제로 배열을 사용합니다. 이를 위해, 폴리곤의 바깥 쪽을 나타낼 outsideTriangle 변수를 0으로 초기화합니다.

그 다음에는 일반적인 for 반복문을 사용해 배열을 순회합니다. 반복문은 0부터 시작하는데, HLSL에서 배열의 첫 번째 인덱스는 0, 두 번째는 1... 이기 때문입니다. 반복은 C#에서 지정한 꼭짓점 개수보다 커질 때까지 계속되며, 한 루프마다 반복자 (iterator) 를 1씩 증가시킵니다. 또한, 우리는 HLSL에 명시적으로 반복문을 사용하도록 지시합니다. 이 반대의 경우는 Unroll 이라고 부르는데, 이는 반복문 내부의 코드를 복사-붙여넣기로 나열하는 방식입니다. 셰이더에서는 보통 Unroll 이 더 빠르지만, 이번 케이스처럼 반복 횟수가 동적일 경우엔 반복문을 사용해야 합니다.

반복문 안에서는 isLeftOfLine 함수의 반환값을 더해줍니다. 이 때 선을 구성하는 두 점은 현재 반복자에 해당하는 꼭짓점과, 그 인덱스에 +1 한 꼭짓점을 사용합니다. 하지만 여기서 마지막 꼭짓점에 +1을 하게 되면, 설정되지 않은 배열의 영역에 접근하는 오류가 생깁니다. 우리가 원하는 것은 마지막 점 다음에는 첫 번째 점으로 돌아가는 것입니다.

이럴 때 모듈로 (modulo) 연산이 유용합니다. 반복자에 1을 더한 뒤, 꼭짓점 개수로 나눈 나머지를 취하면, 마지막 인덱스 다음에는 자동으로 0으로 되돌아가므로 올바르게 처음 점과 연결될 수 있게 됩니다.

 

 

이렇게하면 몇 개의 점들로 구성된 폴리곤을 만들 수 있습니다. (만약 폴리곤이 화면에 보이지 않는다면, OnValidate() 가 호출되도록 인스펙터에서 값들을 약간만 변경해 보세요.)

 


 

폴리곤 클리핑 및 색상 적용

이 튜토리얼을 요청한 분은 폴리곤을 클리핑하는 방법을 물어보셨기 때문에, 마지막으로 그 부분을 추가해 보겠습니다. HLSL에는 폴리곤을 클리핑하는 clip() 이라는 함수가 있습니다. 이 함수에 값을 넘기면, 값이 0보다 작을 때에 해당 프래그먼트는 렌더되지 않고, 0이 아닐 때엔 아무 일도 하지 않습니다.

outsideTriangle 변수의 값을 clip 함수에 전달할 수 있지만, 모든 값이 0 이거나 그 이상이기 때문에 원하는 대로 동작하지 않습니다. 폴리곤 외부만 클리핑하려면, 값을 반전해주면 됩니다. 그렇게 하면 폴리곤 내부의 값은 0을 유지하고, 외부는 음수 값이 되어 클리핑되게 됩니다.

이제 outsideTriangle 변수를 원래 목적대로 클리핑에 사용하게 되었으므로, 이 값을 더 이상 색상으로 사용하지 않고, 대신 원래 출력하던 색상 값 (_Color) 을 출력하면 됩니다.

 

clip(-outsideTriangle);
return _Color;

 

 

이 셰이더의 가장 큰 단점은, 오직 볼록한 폴리곤만 렌더링할 수 있다는 점입니다. 오목한 폴리곤을 사용하려고 하면 제대로 동작하지 않습니다.

셰이더 코드 전문은 아래와 같습니다.

 

Shader "Tutorial/014_Polygon"
{
    // 인스펙터에서 조작할 수 있는 값들이 보여집니다.
    Properties
    {
        _Color ("Color", Color) = (0, 0, 0, 1)
    }

    SubShader
    {
        // 메테리얼은 완전히 불투명하고, 다른 불투명 지오메트리와 같은 타이밍에 렌더됩니다.
        Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

        Pass
        {
            CGPROGRAM

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

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

            fixed4 _Color;
            
            // 꼭짓점 처리를 위한 변수들
            uniform float2 _corners[1000];
            uniform uint _cornerCount;

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

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

            // 버텍스 셰이더
            v2f vert (appdata v)
            {
                v2f o;
                // 렌더될 수 있도록 버텍스 좌표를 오브젝트 공간에서 클립 공간으로 변환
                o.position = UnityObjectToClipPos(v.vertex);
                // 월드 공간 상의 버텍스 좌표를 계산하고 할당
                float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
                o.worldPos = worldPos.xyz;
                return o;
            }

            // 점이 선의 왼쪽에 위치한다면 1을, 아니라면 0을 반환합니다.
            float isLeftOfLine(float2 pos, float2 linePoint1, float2 linePoint2)
            {
                // 계산을 위해 필요한 변수들
                float2 lineDirection = linePoint2 - linePoint1;
                float2 lineNormal = float2(-lineDirection.y, lineDirection.x);
                float2 toPos = pos - linePoint1;

                // 테스트할 위치가 선의 어느 쪽에 있는지 계산
                float side = dot(toPos, lineNormal);
                side = step(0, side);

                return side;
            }

            // 프래그먼트 셰이더
            fixed4 frag (v2f i) : SV_Target
            {
                float outsideTriangle = 0;

                [loop]
                for(uint index; index < _cornerCount; index++)
                {
                    outsideTriangle += isLeftOfLine(i.worldPos.xy, _corners[index], _corners[(index+1) % _cornerCount]);
                }

                // 값이 0 미만이면 렌더되지 않음
                clip(-outsideTriangle);
                return _Color;
            }

            ENDCG
        }
    }
}

 

 

 

'Graphics > Ronja's Unity Shader tutorials' 카테고리의 다른 글

커스텀 라이팅 (램프 효과)  (0) 2025.02.23
프레넬  (0) 2025.02.02
체크 무늬 만들기  (0) 2024.08.19
트라이플래너 매핑 (Triplanar Mapping)  (0) 2024.07.08
색상 보간  (0) 2024.06.15