이번엔 UI 둥근 모서리를 구현했다. 예전에는 둥근 이미지를 넣고 9-slice로 늘려서 했는데, 해상도를 키우면 모서리가 뿌옇게 깨지는 게 영 거슬렸다. 그래서 이번엔 셰이더 공부도 할 겸 직접 만들어서 컴포넌트로 붙이는 방식으로 갔다.
Shader "UI/RoundedCorners"
{
Properties
{
[PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
_Color ("Tint", Color) = (1,1,1,1)
// x = width, y = height, z = radius (in pixels)
_WidthHeightRadius ("WidthHeightRadius", Vector) = (100000, 100000, 0, 0)
_StencilComp ("Stencil Comparison", Float) = 8
_Stencil ("Stencil ID", Float) = 0
_StencilOp ("Stencil Operation", Float) = 0
_StencilWriteMask ("Stencil Write Mask", Float) = 255
_StencilReadMask ("Stencil Read Mask", Float) = 255
_ColorMask ("Color Mask", Float) = 15
[Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0
}
SubShader
{
Tags
{
"Queue"="Transparent"
"IgnoreProjector"="True"
"RenderType"="Transparent"
"PreviewType"="Plane"
"CanUseSpriteAtlas"="True"
}
Stencil
{
Ref [_Stencil]
Comp [_StencilComp]
Pass [_StencilOp]
ReadMask [_StencilReadMask]
WriteMask [_StencilWriteMask]
}
Cull Off
Lighting Off
ZWrite Off
ZTest [unity_GUIZTestMode]
Blend SrcAlpha OneMinusSrcAlpha
ColorMask [_ColorMask]
Pass
{
Name "Default"
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 2.0
#include "UnityCG.cginc"
#include "UnityUI.cginc"
#pragma multi_compile_local _ UNITY_UI_CLIP_RECT
#pragma multi_compile_local _ UNITY_UI_ALPHACLIP
struct appdata_t
{
float4 vertex : POSITION;
float4 color : COLOR;
float2 texcoord : TEXCOORD0;
float2 texcoord1 : TEXCOORD1; // RoundedCorners 가 IMeshModifier 로 박아넣은 rect-local 좌표
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct v2f
{
float4 vertex : SV_POSITION;
fixed4 color : COLOR;
float2 texcoord : TEXCOORD0;
float4 worldPosition : TEXCOORD1;
float2 localPos : TEXCOORD2; // RectTransform 로컬 좌표 (rect.center 기준 오프셋)
UNITY_VERTEX_OUTPUT_STEREO
};
sampler2D _MainTex;
fixed4 _Color;
fixed4 _TextureSampleAdd;
float4 _ClipRect;
float4 _MainTex_ST;
float4 _WidthHeightRadius;
v2f vert(appdata_t v)
{
v2f OUT;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT);
OUT.worldPosition = v.vertex;
OUT.vertex = UnityObjectToClipPos(OUT.worldPosition);
OUT.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex);
// UV1 에 rect-local 좌표를 박아넣어둠 → 캔버스 배칭이 vertex 위치를 변환해도 그대로 통과.
// RoundedCorners.ModifyMesh 에서 (vert.position - rect.center) 를 uv1 에 기록함.
OUT.localPos = v.texcoord1;
OUT.color = v.color * _Color;
return OUT;
}
float RoundedBoxSDF(float2 p, float2 b, float r)
{
float2 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
fixed4 frag(v2f IN) : SV_Target
{
half4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color;
#ifdef UNITY_UI_CLIP_RECT
color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect);
#endif
// Compute rounded-corner mask in pixel space.
float2 halfSize = _WidthHeightRadius.xy * 0.5;
float radius = min(_WidthHeightRadius.z, min(halfSize.x, halfSize.y));
float dist = RoundedBoxSDF(IN.localPos, halfSize - radius, radius);
// 1px anti-aliased edge
float mask = saturate(0.5 - dist);
color.a *= mask;
#ifdef UNITY_UI_ALPHACLIP
clip (color.a - 0.001);
#endif
return color;
}
ENDCG
}
}
}
Signed Distance Field
대충 확대를 하면 부드러운 테두리 결과가 나오도록 도와주는 그래픽스 기술이라고 보면 된다. 원래는 물리 체크하는 함수지만 여기서는 픽셀을 그릴지 말지 결정을 도와주는 함수라고 보면된다.
float RoundedBoxSDF(float2 p, float2 b, float r)
{
float2 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
CGPROGRAM와 ENDCG 안에 있는 코드가 기존에 OpenGL에서 쓰던 GLSL 코드라고 보면 된다.
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 2.0
#include "UnityCG.cginc"
#include "UnityUI.cginc"
#pragma multi_compile_local _ UNITY_UI_CLIP_RECT
#pragma multi_compile_local _ UNITY_UI_ALPHACLIP
struct appdata_t
{
float4 vertex : POSITION;
float4 color : COLOR;
float2 texcoord : TEXCOORD0;
float2 texcoord1 : TEXCOORD1; // RoundedCorners 가 IMeshModifier 로 박아넣은 rect-local 좌표
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct v2f
{
float4 vertex : SV_POSITION;
fixed4 color : COLOR;
float2 texcoord : TEXCOORD0;
float4 worldPosition : TEXCOORD1;
float2 localPos : TEXCOORD2; // RectTransform 로컬 좌표 (rect.center 기준 오프셋)
UNITY_VERTEX_OUTPUT_STEREO
};
sampler2D _MainTex;
fixed4 _Color;
fixed4 _TextureSampleAdd;
float4 _ClipRect;
float4 _MainTex_ST;
float4 _WidthHeightRadius;
v2f vert(appdata_t v)
{
v2f OUT;
UNITY_SETUP_INSTANCE_ID(v);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT);
OUT.worldPosition = v.vertex;
OUT.vertex = UnityObjectToClipPos(OUT.worldPosition);
OUT.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex);
// UV1 에 rect-local 좌표를 박아넣어둠 → 캔버스 배칭이 vertex 위치를 변환해도 그대로 통과.
// RoundedCorners.ModifyMesh 에서 (vert.position - rect.center) 를 uv1 에 기록함.
OUT.localPos = v.texcoord1;
OUT.color = v.color * _Color;
return OUT;
}
// Signed distance from a rounded box centered at origin.
// p = pixel offset from center, b = half-size minus radius, r = corner radius
float RoundedBoxSDF(float2 p, float2 b, float r)
{
float2 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
fixed4 frag(v2f IN) : SV_Target
{
half4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color;
#ifdef UNITY_UI_CLIP_RECT
color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect);
#endif
// Compute rounded-corner mask in pixel space.
float2 halfSize = _WidthHeightRadius.xy * 0.5;
float radius = min(_WidthHeightRadius.z, min(halfSize.x, halfSize.y));
float dist = RoundedBoxSDF(IN.localPos, halfSize - radius, radius);
// 1px anti-aliased edge
float mask = saturate(0.5 - dist);
color.a *= mask;
#ifdef UNITY_UI_ALPHACLIP
clip (color.a - 0.001);
#endif
return color;
}
ENDCG
Image 컴포넌트가 들어있는 UI에 추가하면 둥글게 된다.
using UnityEngine;
using UnityEngine.UI;
namespace VR_Baseball.UI
{
/// <summary>
/// UI Graphic(Image/RawImage) 의 모서리를 셰이더 기반으로 둥글게 만든다.
/// 그래서 IMeshModifier 로 각 정점의 rect-local 좌표(rect.center 기준 오프셋)를 UV1 에 박아넣는다.
/// UV1 은 캔버스 배칭이 변환하지 않고 그대로 통과시키므로 SDF 가 정확히 동작한다.
/// </summary>
[ExecuteAlways]
[DisallowMultipleComponent]
[RequireComponent(typeof(RectTransform))]
public class RoundedCorners : MonoBehaviour, IMeshModifier, IMaterialModifier
{
// 모서리 반경(px). RectTransform 의 짧은 변의 절반을 넘으면 셰이더에서 자동 클램프.
[SerializeField, Min(0f)] private float radius = 20f;
// 셰이더 이름 — Resources/UIRoundedCorners.shader 와 일치해야 함
private const string ShaderName = "UI/RoundedCorners";
private static readonly int WidthHeightRadiusId = Shader.PropertyToID("_WidthHeightRadius");
private static readonly int MainTexId = Shader.PropertyToID("_MainTex");
private Material _material;
private Graphic _graphic;
private RectTransform _rect;
public float Radius
{
get => radius;
set
{
radius = Mathf.Max(0f, value);
MarkDirty();
}
}
private Graphic GetGraphic()
{
if (_graphic == null) _graphic = GetComponent<Graphic>();
return _graphic;
}
private RectTransform GetRect()
{
if (_rect == null) _rect = (RectTransform)transform;
return _rect;
}
private void OnEnable()
{
MarkDirty();
}
private void OnDisable()
{
MarkDirty();
}
private void OnDestroy()
{
if (_material != null)
{
if (Application.isPlaying) Destroy(_material);
else DestroyImmediate(_material);
_material = null;
}
}
private void OnValidate()
{
MarkDirty();
}
private void OnRectTransformDimensionsChange()
{
MarkDirty();
}
private void MarkDirty()
{
var g = GetGraphic();
if (g != null)
{
// 정점 데이터(UV1)와 머티리얼 모두 갱신 필요
g.SetVerticesDirty();
g.SetMaterialDirty();
}
}
// IMeshModifier (deprecated overload) — 새 API 로 위임
public void ModifyMesh(Mesh mesh) { }
// IMeshModifier — Graphic 이 mesh 를 만든 직후, 캔버스 배칭이 변환하기 전에 호출됨.
// 이 시점의 vert.position 은 rect-local 좌표. 이걸 rect.center 만큼 빼서 SDF 가 기대하는 형태(중심 기준 오프셋)로
// 만든 뒤 uv1 에 저장한다.
public void ModifyMesh(VertexHelper vh)
{
if (!isActiveAndEnabled) return;
var rect = GetRect().rect;
var center = rect.center;
var vert = new UIVertex();
for (int i = 0; i < vh.currentVertCount; i++)
{
vh.PopulateUIVertex(ref vert, i);
vert.uv1 = new Vector4(vert.position.x - center.x, vert.position.y - center.y, 0f, 0f);
vh.SetUIVertex(vert, i);
}
}
// IMaterialModifier — Unity 가 렌더링할 머티리얼을 요청할 때마다 호출됨.
// baseMaterial: 원본 Image 머티리얼(보통 UI/Default). 우리는 우리 셰이더의 머티리얼을 돌려준다.
public Material GetModifiedMaterial(Material baseMaterial)
{
if (!isActiveAndEnabled) return baseMaterial;
if (_material == null)
{
var shader = Shader.Find(ShaderName);
if (shader == null)
{
Debug.LogWarning($"[RoundedCorners] Shader '{ShaderName}' not found. Place under Resources or Always Included Shaders.", this);
return baseMaterial;
}
_material = new Material(shader) { hideFlags = HideFlags.DontSave };
}
// 원본 머티리얼의 메인 텍스처를 그대로 가져온다(스프라이트 텍스처 등)
if (baseMaterial != null && baseMaterial.HasProperty(MainTexId))
{
_material.SetTexture(MainTexId, baseMaterial.GetTexture(MainTexId));
}
// RectTransform 크기/반경 갱신. rect.center 는 셰이더가 아니라 ModifyMesh 에서 처리됨.
var r = GetRect().rect;
_material.SetVector(WidthHeightRadiusId, new Vector4(r.width, r.height, radius, 0f));
return _material;
}
}
}

적용이 잘된다.