본문 바로가기
[Unity]

[Unity] 선을 그리는 UI를 만들어보자

by 김기승 2024. 4. 28.

이전 글에서 UI의 모양을 마음대로 변경하는 방법에 대해 알아보았다.

[Unity] UI 모양을 내 맘대로 바꿔보자(VertexHelper) (tistory.com)

 

[Unity] UI 모양을 내 맘대로 바꿔보자(VertexHelper)

이전에 Mesh를 생성하여 도형을 만들어봤다. [Unity] Mesh를 생성하여 도형 만들기 (tistory.com) [Unity] Mesh를 생성하여 도형 만들기 3D 게임을 만들기 위해서는 다양한 모델링을 활용한다. 이 모델링들은

giseung.tistory.com

 

이를 활용하여 을 그리는 스크립트를 작성해보았다.

일단, 결과물부터 보도록 하자.


결과물


스크립트

구현하다보니 생각보다 많이 길어졌다.

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

public class LineDrawer : Image
{
    [Tooltip("중간 지점들")]
    public RectTransform[] points;

    [Header("Texture")]
    [Tooltip("사용할 Sprite")]
    public Sprite targetSprite;
    [Tooltip("틴트 색상")]
    public Color tintColor = Color.white;

    [Header("Mesh")]
    [Tooltip("두께")]
    [Min(0f)] public float thickness = 50f;
    [Tooltip("선의 면 개수")]
    [Min(1)] public int lineSegment = 5;
    [Tooltip("곡선 사용여부")]
    public bool useCurve = true;
    [Tooltip("곡선 면 개수 (곡선 사용 시 적용)")]
    [Min(1)] public int curveSegment = 10;

    [Header("UV")]
    [Tooltip("UV 반복하는 거리")]
    public float uvDistance = 50f;
    [Tooltip("UV 흐르는 속도")]
    public float uvFlowSpeed = 1f;

    [Header("Option")]
    [Tooltip("최소 소수점(선택 사항)")]
    [Min(0f)] public float epsilon = 0.001f;

    // Mesh
    private List<(Vector2 pos, Color32 color, Vector2 uv)> _vertices;
    private List<(int idx, int idx2, int idx3)> _triangles;

    // UV
    private float _uv;
    private float _uvFlow;

    //__________________________________________________________________________ Draw
    protected override void OnPopulateMesh(VertexHelper vh) // UI 요소에 정점을 생성할 때 실행되는 함수
    {
        base.OnPopulateMesh(vh);
        if (this.points == null) return;

        // 정점을 재정의 하기 위해 기존 정점을 모두 제거한다.
        vh.Clear();

        // 정점과 삼각형 데이터를 담을 리스트들을 초기화한다.
        if (_vertices == null) _vertices = new List<(Vector2 pos, Color32 color, Vector2 uv)>();
        if (_triangles == null) _triangles = new List<(int idx, int idx2, int idx3)>();

        // 현재 Transform 기준으로 포인트들을 변환하고, 겹치는 포인트들을 제거한다.
        Vector2[] points = getLinePoints();

        // 선의 시작 지점과 끝 지점을 담는 튜플로써, 왼쪽 지점과 오른쪽 지점을 담는다.
        (Vector2 left, Vector2 right) start, end = default;

        // 포인트들을 반복하면서, 시작 지점과 끝 지점을 구해 라인을 쌓아준다.
        for (int i = 0, l = points.Length - 1; i < l; ++i)
        {
            start = GetLineSide(points[i], points[i + 1], 0f, thickness);
            // 이전의 끝 지점과 현재의 시작 지점으로 커브를 쌓아준다.
            if (useCurve && i > 0) stackMeshCurve(end, start);
            end = GetLineSide(points[i], points[i + 1], 1f, thickness);

            stackMeshLine(start, end);
        }

        // 현재까지 쌓은 데이터로 정점을 재구성한다.
        applyMesh(vh);
    }
    private Vector2[] getLinePoints() // 포인트를 정리하는 함수
    {
        // 포인트의 위치를 현재 Transform 기준으로 변환한다.
        List<Vector2> points = new List<Vector2>();
        for (int i = 0; i < this.points.Length; ++i)
        {
            if (this.points[i]) points.Add(InverseRectTransformPoint(transform, this.points[i].position));
        }

        // 겹치는 포인트는 제거한다.
        for (int i = 0; i < points.Count - 1; ++i)
        {
            if (Approximately(points[i], points[i + 1], epsilon))
            {
                points.RemoveAt(i + 1);
                --i;
            }
        }

        return points.ToArray();
    }

    //__________________________________________________________________________ Mesh
    private void stackMeshLine((Vector2 left, Vector2 right) start, (Vector2 left, Vector2 right) end) // 라인을 쌓는 함수
    {
        // 선의 면 개수만큼 나누어, 시작 지점과 끝 지점을 쌓는다.
        for (int i = 0; i < lineSegment; ++i)
        {
            Vector2 startLeft = Vector2.Lerp(start.left, end.left, (float)i / lineSegment);
            Vector2 startRight = Vector2.Lerp(start.right, end.right, (float)i / lineSegment);
            Vector2 endLeft = Vector2.Lerp(start.left, end.left, ((float)i + 1) / lineSegment);
            Vector2 endRight = Vector2.Lerp(start.right, end.right, ((float)i + 1) / lineSegment);

            stackMeshSquare((startLeft, startRight), (endLeft, endRight));
        }
    }
    private void stackMeshCurve((Vector2 left, Vector2 right) start, (Vector2 left, Vector2 right) end) // 커브를 쌓는 함수
    {
        // 커브 지점의 두 방향벡터가 같으면 커브가 필요없으므로 종료한다.
        Vector2 startDir = (start.right - start.left).normalized;
        Vector2 endDir = (end.right - end.left).normalized;
        if (Approximately(startDir, endDir, epsilon)) return;

        // 두 방향벡터의 각도를 미리 계산한다.
        float signedAngle = Vector2.SignedAngle(startDir, endDir);
        float angleSegment = signedAngle / curveSegment;

        // 시작 지점의 오른쪽을 바라보는 각도를 기준 각도로 설정한다.
        Quaternion baseLookRot = Quaternion.LookRotation(startDir, -Vector3.forward);
        Vector2 center = (start.left + start.right) * 0.5f;

        // 곡선의 면 개수만큼 반복하면서, 시작 지점과 끝 지점을 쌓아준다.
        for (int i = 0; i < curveSegment; ++i)
        {
            // 두 방향벡터의 중점을 미리 담아둔다.
            Vector2 startLeft, startRight, endLeft, endRight;
            startLeft = startRight = endLeft = endRight = center;

            // 기준 각도의 정면이 오른쪽이므로, 정면은 오른쪽, 후면은 왼쪽이 된다.
            Quaternion startLookRot = baseLookRot * Quaternion.Euler(0f, -angleSegment * i, 0f);
            startLeft += (Vector2)(startLookRot * Vector3.back * thickness * 0.5f);
            startRight += (Vector2)(startLookRot * Vector3.forward * thickness * 0.5f);

            Quaternion endLookRot = baseLookRot * Quaternion.Euler(0f, -angleSegment * (i + 1), 0f);
            endLeft += (Vector2)(endLookRot * Vector3.back * thickness * 0.5f);
            endRight += (Vector2)(endLookRot * Vector3.forward * thickness * 0.5f);

            stackMeshSquare((startLeft, startRight), (endLeft, endRight));
        }
    }
    private void stackMeshSquare((Vector2 left, Vector2 right) start, (Vector2 left, Vector2 right) end) // 사각형을 쌓는 함수
    {
        // 거리에 따라 UV를 조절하여, 새로운 UV를 정의한다.
        float distance = (Vector2.Distance(start.left, end.left) + Vector2.Distance(start.right, end.right)) * 0.5f;
        float newUV = _uv + distance / uvDistance;

        // 정점과 색상을 쌓는다. (UV Flow 적용 포함)
        _vertices.Add((start.left, color, new Vector2(0f, _uv - _uvFlow)));
        _vertices.Add((end.left, color, new Vector2(0f, newUV - _uvFlow)));
        _vertices.Add((end.right, color, new Vector2(1f, newUV - _uvFlow)));
        _vertices.Add((start.right, color, new Vector2(1f, _uv - _uvFlow)));

        // 삼각형을 쌓는다. (왼쪽 아래에서 시계방향으로)
        int count = _vertices.Count;
        _triangles.Add((count - 4, count - 3, count - 2));
        _triangles.Add((count - 2, count - 1, count - 4));

        // 새로운 UV를 저장한다.
        _uv = newUV;
    }
    private void applyMesh(VertexHelper vh) // 정점을 적용하는 함수
    {
        // 현재까지 쌓은 데이터를 바탕으로 정점을 적용한다.
        for (int i = 0, l = _vertices.Count; i < l; ++i)
            vh.AddVert(_vertices[i].pos, _vertices[i].color, _vertices[i].uv);
        for (int i = 0, l = _triangles.Count; i < l; ++i)
            vh.AddTriangle(_triangles[i].idx, _triangles[i].idx2, _triangles[i].idx3);

        // 데이터를 쌓은 변수들을 초기화한다.
        _vertices.Clear();
        _triangles.Clear();
        _uv = 0f;
        _uvFlow = (_uvFlow + uvFlowSpeed * Time.deltaTime) % 1f;
    }

    //__________________________________________________________________________ Util
    public static bool Approximately(Vector2 a, Vector2 b, float epsilon) // 두 변수가 거의 일치하는지 판단하는 함수(Vector2)
    {
        return Approximately(a.x, b.x, epsilon) && Approximately(a.y, b.y, epsilon);
    }
    public static bool Approximately(float a, float b, float epsilon) // 두 변수가 거의 일치하는지 판단하는 함수(Float)
    {
        // Epsilon 이내의 차이이면 같은 것으로 판단한다.
        return Mathf.Abs(a - b) <= epsilon;
    }
    public static Vector2 InverseRectTransformPoint(Transform tr, Vector2 world) // Transform의 Local Point으로 변경하는 함수
    {
        // Canvas의 크기가 변경될 수 있으므로, LossyScale을 나눠준다.
        Vector2 scale = tr.lossyScale;
        world -= (Vector2)tr.position;
        world.x /= scale.x;
        world.y /= scale.y;
        return world;
    }
    public static (Vector2 left, Vector2 right) GetLineSide(Vector2 start, Vector2 end, float t, float thickness) // 라인의 양쪽 지점을 구하는 함수
    {
        // 방향벡터를 통해 각도를 구한다.
        Vector3 dir = end - start;
        Quaternion lookRot = Quaternion.LookRotation(dir, -Vector3.forward);

        // 중간 지점에서 각각 -90, 90도 회전하여 왼쪽 지점와 오른쪽 지점을 구한다.
        Vector2 center = Vector2.Lerp(start, end, t);
        Vector2 left = center + (Vector2)(lookRot * Quaternion.Euler(0f, -90f, 0f) * Vector3.forward * thickness * 0.5f);
        Vector2 right = center + (Vector2)(lookRot * Quaternion.Euler(0f, 90f, 0f) * Vector3.forward * thickness * 0.5f);

        return (left, right);
    }

    //__________________________________________________________________________ Update
    protected virtual void Update()
    {
        // OnPopulateMesh는 UI 요소에 변경될 때만(크기, 피봇, 앵커 등) 실행되므로,
        // 매 프레임 실행하기 위해 SetVerticesDirty 함수를 실행한다.
        SetVerticesDirty();
    }

    //__________________________________________________________________________ Editor
    protected override void OnValidate() // 인스펙터 창에서 프로퍼티를 변경할 때 실행되는 함수
    {
        base.OnValidate();

        // Sprite를 적용한다.
        if (targetSprite != sprite)
            sprite = targetSprite;

        // WrapMode를 Repeat로 한다.
        if (targetSprite && targetSprite.texture.wrapMode != TextureWrapMode.Repeat)
            targetSprite.texture.wrapMode = TextureWrapMode.Repeat;

        // Color를 적용한다.
        if (tintColor != color)
            color = tintColor;
    }
}

#if UNITY_EDITOR
[UnityEditor.CustomEditor(typeof(LineDrawer))]
public class LineDrawerInspector : UnityEditor.Editor
{
    public override void OnInspectorGUI() // 인스펙터를 새로 정의하는 함수
    {
        // LineDrawer 클래스에서 사용하는 프로퍼티만을 표시한다.
        foreach (var field in typeof(LineDrawer).GetFields())
            UnityEditor.EditorGUILayout.PropertyField(serializedObject.FindProperty(field.Name), true);
        serializedObject.ApplyModifiedProperties();
    }
}
#endif

 

주석이 대부분 달려있으므로, 부가 설명만 하겠다.

 

두 포인트 사이에는 을,

선 사이에는 곡선을 그리는 것이 핵심이다.

OnPopulateMesh 함수에서 이를 구현하고 있다.

 

getLinePoints 함수는 포인트들을 현재 Transform을 기준으로 변환하여 반환한다.

변환 작업은 InverseRectTransformPoint 함수에서 이뤄지고,

Canvas의 크기가 변경될 것을 고려해 lossyScale로 나눠준다.

※ Canvas의 UI Scale Mode를 Scale With Screen Size로 하면,

수시로 Canvas의 크기가 변경된다.

 

stackMeshSquare 함수는 사각형 메쉬를 쌓는 함수다.

이 함수를 활용하여, stackMeshLine 함수는 선을,

stackMeshCurve 함수는 커브를 세그먼트만큼 나누어 쌓는다.

 

Approximately 함수는 계산 후에

매우 작은 소수점이 남아있는 문제가 있어서

epsilon 변수 값보다 작은 차이이면 같은 값으로 처리한다.

 

마지막 부분에, LineDrawerInspector 클래스는

Image를 상속받으면 변수를 선언해도 안보여서

Inspector GUI를 새로 그린다.


사용 방법

UI 요소(Image, Slider, Button, Toggle 등) 같이

Canvas와 함께 사용하면 된다.

Inspector 창은 다음과 같다.

 

Points : 선을 잇는 중간 지점들이다. RectTransform을 할당하고,

부모가 달라도 상관없다.

 

Target Sprite : 사용할 Sprite이다.

 

Tint Color : 색조이다.

 

Thickness : 두께이다.

 

LineSegment : 선을 표현하는 면의 개수이다.

 

Use Curve : 선과 선 사이의 곡선 사용 여부이다.

 

CurveSegment : 곡선을 표현하는 면의 개수이다.

 

UV Distance : UV가 반복되는 거리이다.

 

UV Flow Speed : UV가 흐르는 속도이다.

 

Epsilon : 코드 내에서 처리하는 실수의 최솟값이다. (선택사항)


이번 글을 작성하면서 많은 시행착오를 겪게 되었다.

지금까지 쓴 글 중에서 가장 헤매지 않았나 싶다.

 

그래도 나름 많은 시간을 공부하면서 얻어가는 것이 많은,

보람찬 작업이었다.

댓글