수학적 계산 방식

수학적으로 모서리가 둥근 사각형을 만드는 방법에 대해서 실제 구현을 통해 알아보겠습니다. 먼저 알고리즘을 정리해보면 다음과 같습니다.

  • 사각형의 꼭지점으로 부터 반지름이 r인 가상의 원을 그린다.

  • 원의 중심점에서 각각의 픽셀들까지의 거리를 구한다.

  • 거리가 r보다 더 길다면 원의 바깥쪽에 있다고 판단하여 해당 픽셀을 렌더링에서 제외한다.

  • 단, 모서리쪽에 위치하는 픽셀들만 골라내기 위해 특수한 좌표변환을 수행하여 계산한다.

여기에 사용되는 수학적 개념은 벡터와 더하기 빼기 뿐이므로 그리 어려운것은 없을겁니다.

프래그먼트 셰이더와 UV좌표, 벡터 계산에 대한 기본적인 배경 지식이 필요합니다.

셰이더 구현

픽셀의 위치에 따라 렌더링 여부를 결정할 것이기 때문에 대부분의 코드는 프래그먼트 셰이더에서 구현하게 될겁니다. 일단 셰이더 몸체는 다음과 같습니다.

fixed4 frag (v2f i) : SV_Target {
	// uv좌표를 픽셀내의 좌표로 변환하는 작업.
	// 0 ~ 1 사이의 uv좌표를 -0.5 ~ 0.5 의 범위로 변환한다.
	// 변환된 좌표에 이미지의 너비를 곱해서 픽셀기반의 좌표로 한번 더 변환한다.
	float2 uvInPixel = (i.uv - 0.5) * float2(_Width, _Height);

	// 렌더링 할 영역의 절반을 계산함.
	// 알파값 계산할 때 사용된다.
	float2 halfRes = float2(_Width, _Height) * 0.5;

	// 픽셀 위치에 따른 알파값 계산.
	// 제외할 픽셀들의 알파값은 0, 나머지는 1의 값을 갖는다.
	float alpha = HardRounded( uvInPixel, halfRes, _Radius );

	// 원본 색상에 알파값을 적용하여 렌더링.
	fixed4 col = tex2D(_MainTex, i.uv) * i.color;

	// alpha값에 따라 투명도를 부여한다.
	col.a = alpha;
	
	return col;
}

기본 로직은 생각보다 짧고 간단합니다. 이제 코드의 나머지 부분을 보면서 알고리즘이 어떻게 구현되는지 확인해 보겠습니다. 실제 구현 코드에는 여러가지 좌표변환과 트릭들이 사용됩니다.

UV좌표를 픽셀 기반의 좌표로 변환

fixed4 frag (v2f i) : SV_Target {
	// uv좌표를 픽셀내의 좌표로 변환하는 작업.
	// 0 ~ 1 사이의 uv좌표를 -0.5 ~ 0.5 의 범위로 변환한다.
	// 변환된 좌표에 이미지의 너비를 곱해서 픽셀기반의 좌표로 한번 더 변환한다.
	float2 uvInPixel = (i.uv - 0.5) * float2(_Width, _Height);
	...
}

제일 첫번째로 해야할 과정은 UV좌표를 픽셀 너비와 높이 기반의 좌표로로 변환하는 부분입니다. uv좌표는 0~1사이의 값을 갖게 되는데 -0.5를 해주면 -0.5에서 +0.5사이의 값을 갖게 됩니다. 여기에 이미지의 너비와 높이값을 곱해주면 다음과 같이 변환된 좌표를 얻을 수 있습니다.

이 계산을 통하여 0~1로 정규화된 uv좌표에서 실제 이미지 기반의 좌표를 구할 수 있는데 결국 사각형의 가운데를 원점으로 하는 좌표가 되는 셈입니다. 이렇게 하는 이유는 모서리에서 얼마만큼 라운딩을 줄 것인지를 픽셀값으로 계산하는것이 직관적이기 때문입니다. 실제 화면에 보이는 좌표는 0에서 1이 아닌 사각형의 너비와 높이로 계산되기 때문이죠. 또한 사각형의 가운데를 원점으로 하는 이유는 상하좌우가 대칭이 되는 좌표를 만들기 위해서 입니다. 대칭이 된다면 한쪽 모서리만 계산하고 나머지 모두를 같은 공식으로 적용할 수 있기 때문이죠. 위 그림에서 원점을 기준으로 각각의 모서리를 사분면으로 본다면 오른쪽 위가 1사분면의 영역이 됩니다. 즉, 1사분면에서 모서리의 라운딩을 계산하면 나머지 사분면도 대칭이 되기 때문에 한번의 계산으로 모든 모서리의 라운딩을 처리할 수 있는것이죠. 마치 종이를 두번 접어서 가위로 한번 자른뒤 펼치면 네 귀퉁이가 모두 잘리는것과 같은 이치입니다.

라운딩 영역 계산

이제 좌표 변환이 끝났으니 라운딩 영역을 계산할 단계 입니다.

// 렌더링 할 영역의 절반을 계산함.
// 알파값 계산할 때 사용된다.
float2 halfRes = float2(_Width, _Height) * 0.5;

// 픽셀 위치에 따른 알파값 계산.
// 제외할 픽셀들의 알파값은 0, 나머지는 1의 값을 갖는다.
float alpha = HardRounded( uvInPixel, halfRes, _Radius );

HardRounded함수를 통해 라운딩 영역을 계산하여 그 값을 알파값으로 사용하게 됩니다. alpha값이 0이면 완전 투명한것이므로 렌더링 되지 않을 것이며, 1이면 정상적인 컬러값이 렌더링 될것입니다. 이 값을 통해서 우리가 원하는 부분의 픽셀을 렌더링에서 제외 시키고 결국 둥근 모양의 사각형을 그릴 수 있게 됩니다. 아래는 HardRounded의 코드 입니다. 이 구현의 핵심부분 입니다.

// radius를 반지름으로 하는 가상의 원을 그려, 
// 그 중심점에서부터 픽셀로 향하는 벡터를 구한다.
float2 GetRadiusToPointVector(float2 pixel, float2 halfRes, float radius) {
	// 모든 점을 1사분면으로 이동시킨다.
	float2 firstQuadrant = abs(pixel);
	
	// 가상의 원의 중심점으로부터 픽셀로 향하는 벡터를 구한다.
	float2 radiusToPoint = firstQuadrant - (halfRes - radius);
	
	// -값이 나오지 않도록 최소값을 0으로 보정해준다.
	radiusToPoint = max(radiusToPoint, 0.0);
	
	return radiusToPoint;
}

// radius만큼의 라운딩을 계산한다.
// 라운딩 바깥 영역은 0, 안쪽 영역은 1을 리턴한다.
float HardRounded(float2 pixel, float2 halfRes, float radius) {
	// 모서리에 그려진 가상의 원으로부터 픽셀까지 향하는 벡터.
	float2 v = GetRadiusToPointVector(pixel, halfRes, radius);
	
	// 벡터의 크기와 반지름을 비교하여 알파값을 구한다.
	float alpha = 1.0 - floor(length(v) / radius);
	return alpha;
}

그냥 훑어보면 잘 이해가 안가는 부분이 있을 수 있는데 코드 하나하나 따라가 보면 아주 쉬운 내용들입니다.

HardRounded함수에서는 GetRadiusToPointVector함수를 호출해서 하나의 벡터를 리턴받게 됩니다(v). 이 벡터는 모서리에 그려진 가상의 원으로부터 각 픽셀까지 향하는 벡터입니다. 아래 그림은 사각형의 오른쪽 위 모서리에 가상의 원을 그린 모습입니다.

만약 v의 길이가 r보다 작거나 같다면(B) 원 안에 있는 픽셀인 것이고, v의 길이가 r보다 크다면(A) 원 밖에 존재하는 픽셀이라고 할 수 있습니다. 현재 픽셀이 점 A와 같다면 렌더링에서 제외하기 위해 alpha값을 0으로 만들어 주면 되고, B와 같다면 원래 색상대로 렌더링 해주면 되는 것입니다.

그런데 이 방식대로만 하면 몇가지 문제가 있습니다. 위 그림은 1사분면(사각형의 오른쪽 위 모서리)에서 계산하는 과정입니다. 나머지 세개의 모서리에도 같은 계산을 해줘야 하는데 2,3,4사분면은 x,y값의 부호가 각각 다릅니다. 1사분면은 x,y좌표가 모두 양수, 2사분면은 x좌표가 음수, 3사분면은 x,y좌표가 모두 음수, 4사분면은 y좌표가 음수이죠. 각 사분면마다 부호가 다르니 뭔가 벌써부터 복잡해지기 시작합니다. 게다가 반지름과 벡터의 길이를 단순히 비교하면 아래와 같은 문제도 발생합니다.

위 그림에서 점C를 향하는 벡터의 길이는 반지름 r 보다 큽니다. 따라서 렌더링에서 제외될 것인데 이것은 우리가 원하는 결과가 아닙니다. 점 A처럼 모서리에 해당하는 영역만 특정해서 제외시킬 수 있는 방법이 필요합니다. 복잡하게 느껴지시겠지만 몇번의 좌표계산만으로 위 문제들을 모두 해결할 수 있습니다. 다음 코드를 보겠습니다.

// radius를 반지름으로 하는 가상의 원을 그려, 
// 그 중심점에서부터 픽셀로 향하는 벡터를 구한다.
float2 GetRadiusToPointVector(float2 pixel, float2 halfRes, float radius) {
	// 모든 점을 1사분면으로 이동시킨다.
	float2 firstQuadrant = abs(pixel);
	
	// 가상의 원의 중심점으로부터 픽셀로 향하는 벡터를 구한다.
	float2 radiusToPoint = firstQuadrant - (halfRes - radius);
	
	// -값이 나오지 않도록 최소값을 0으로 보정해준다.
	radiusToPoint = max(radiusToPoint, 0.0);
	
	return radiusToPoint;
}

이 코드는 원의 중심으로부터 각 픽셀까지의 벡터 v를 구하는 코드 입니다.

// 모든 점을 1사분면으로 이동시킨다.
float2 firstQuadrant = abs(pixel);

먼저 모든 점을 1사분면으로 이동시킵니다. 이 계산은 abs함수를 쓰면 간단히 처리 됩니다. 왜냐하면 1사분면은 x,y값이 모두 양수이기 때문에 절대값을 구하는 abs함수를 쓰면 2,3,4분면에 있는 점들이 그대로 대칭되서 1사분면으로 옮겨가는 효과가 나타나게 되기 때문이죠.

이제 1사분면에 대해서만 계산하면 나머지 모서리 영역도 자동으로 계산되는 환경이 만들어 졌습니다. 이것으로 사분면에 따라 부호가 달라지는 문제는 해결이 된 것이죠. 다음 코드로 넘어가봅시다.

// 가상의 원의 중심점으로부터 픽셀로 향하는 벡터를 구한다.
float2 A = firstQuadrant;
float2 B = halfRes - radius;
float2 radiusToPoint = A - B;

이 코드는 모서리에 그려진 가상의 원으로부터 각 픽셀로 향하는 벡터를 구하는 코드 입니다. 이해를 쉽게 하기 위해 벡터 A, B로 나눴습니다. A는 원점에서 각 픽셀을 향하는 벡터, B는 원점에서 가상의 원의 중심을 향하는 벡터 입니다.

벡터B를 풀어서 설명하면 다음과 같습니다.

float2 halfRes = float2(_Width, _Height) * 0.5;

halfRes도 그냥 벡터 입니다. 앞서 프래그먼트 셰이더 첫부분에서 이미 계산해서 넘겨준 값이죠. 수학적으로 보면 (width, height)인 벡터를 0.5만큼 스케일 한 것이죠. 결국 원점에서 사각형의 모서리를 가리키는 벡터가 됩니다.

radius는 임의의 반지름값 입니다. radius = 10이라고 한다면 사각형의 모서리에서 반지름이 10인 원이라는 뜻입니다.

halfRes - radius는 원점에서 가상의 원의 중심을 향하는 벡터입니다.

이제 다음과 같은 벡터들이 구해졌습니다.

다음으로 A - B를 하면 B에서 A로 향하는 벡터가 나옵니다. 즉, 가상의 원의 중심에서 각 픽셀을 향하는 벡터를 구하는 계산 과정인 것입니다.

float2 radiusToPoint = A - B;

이제 벡터를 구하는 마지막 계산 과정이 남았습니다.

// -값이 나오지 않도록 최소값을 0으로 보정해준다.
radiusToPoint = max(radiusToPoint, 0.0);

이 계산은 벡터의 최소값을 0으로 만들어 주는 과정입니다. 벡터의 각 성분 x,y값이 0.0보다 작을 경우 0으로 보정시켜주는 계산 입니다.

바로 위 그림에서 점C를 향하는 벡터 v`의 길이는 반지름r보다 큽니다. 따라서 이 상태로 계산을 하게 되면 점C는 렌더링에서 제외되어버리는 문제가 있죠. 이 문제를 해결하기 위해서 벡터의 최소값을 0으로 맞춰주는 것입니다. 그러면 점 C는 이렇게 옮겨가게 되죠.

결국 max(radiusToPoint, 0.0)의 계산을 거치고 나면 모든 점들이 오른쪽 모서리 위에서 반지름 r이 만드는 사각형 안에 모이게 됩니다.

이제 다시 HardRounded함수로 돌아가서 알파값을 구하는 부분을 살펴봅시다.

// 모서리에 그려진 가상의 원으로부터 픽셀까지 향하는 벡터.
float2 v = GetRadiusToPointVector(pixel, halfRes, radius);
	
// 벡터의 크기와 반지름을 비교하여 알파값을 구한다.
float alpha = 1.0 - floor(length(v) / radius);

지금까지의 과정을 통해서 벡터 v가 구해졌습니다. 이제 벡터 v의 길이를 반지름의 길이로 나누면 반지름 이내의 픽셀들은 0~1사이의 값이 나오고, 반지름보다 큰 픽셀들은 1을 초과하는 값이 나오게 됩니다. floor함수는 소수점 이하의 값을 버리는 함수 입니다. 예를들어 0.5라면 0으로 만드는 것이죠. 1.2가 나왔다면 1로 바뀝니다. 이제 1에서 이 값을 빼면 반지름 이내의 픽셀들은 1 - 0 = 1이 될테고, 반지름보다 큰 픽셀들은 1 - 1 = 0이 되겠죠. 계산은 다 끝났습니다! 이렇게 구한 알파값을 최종 컬러에 적용해 봅시다.

// 픽셀 위치에 따른 알파값 계산.
// 제외할 픽셀들의 알파값은 0, 나머지는 1의 값을 갖는다.
float alpha = HardRounded( uvInPixel, halfRes, _Radius );

// 원본 색상에 알파값을 적용하여 렌더링.
fixed4 col = tex2D(_MainTex, i.uv) * i.color;

// alpha값에 따라 투명도를 부여한다.
col.a = alpha;

나머지 셰이더 코드들은 이제 식은죽 먹기 입니다. HardRounded함수에서 계산되어 나온 alpha값을 최종 컬러의 알파값으로 사용하면 되겠죠.

전체 셰이더 코드와 결과물 입니다.

Shader "Custom/2D/RoundedRectSimple" {
	Properties {
		_MainTex ("Texture", 2D) = "white" {}
		_Radius ("Radius px", Float) = 10
		_Width ("Width px", Float) = 100
		_Height ("Height px", Float) = 100
	}
	SubShader {
		Tags {
			"RenderType"="Transparent"
			"Queue"="Transparent"
		}
	
		Cull Off
		Lighting Off
	
		// Alpha blending.
		ZWrite Off
		Blend SrcAlpha OneMinusSrcAlpha
	
		Pass {
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
	
			#include "UnityCG.cginc"
	
			struct appdata {
				float4 vertex : POSITION;
				float2 uv : TEXCOORD0;
				float4 color : COLOR;
			};
	
			struct v2f {
				float2 uv : TEXCOORD0;
				float4 vertex : SV_POSITION;
				float4 color : COLOR;
			};
	
			sampler2D _MainTex;
			float4 _MainTex_ST;
			float _Radius;
			float _Width;
			float _Height;
	
			v2f vert (appdata v) {
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.uv = TRANSFORM_TEX(v.uv, _MainTex);
				o.color = v.color;
				return o;
			}
	
			float2 GetRadiusToPointVector(float2 pixel, float2 halfRes, float radius) {
				float2 firstQuadrant = abs(pixel);
				float2 radiusToPoint = firstQuadrant - (halfRes - radius);
				radiusToPoint = max(radiusToPoint, 0.0);
				return radiusToPoint;
			}
	
			float HardRounded(float2 pixel, float2 halfRes, float radius) {
				float2 v = GetRadiusToPointVector(pixel, halfRes, radius);
				float alpha = 1.0 - floor(length(v) / radius);
				return alpha;
			}
	
			fixed4 frag (v2f i) : SV_Target {
				float2 uvInPixel = (i.uv - 0.5) * float2(_Width, _Height);
				float2 halfRes = float2(_Width, _Height) * 0.5;
	
				float alpha = HardRounded( uvInPixel, halfRes, _Radius );
	
				fixed4 col = tex2D(_MainTex, i.uv) * i.color;
				col.a = alpha;
				return col;
			}
			ENDCG
		}
	}
}

Last updated