DevLog/유니티 프로젝트

[HDProject] UGUI Text Mesh Pro Style Applier 에디터 제작

뽀또치즈맛 2025. 4. 27. 05:55

 
Git : https://github.com/kwon1232/HDProject
 
UGUI에서 Text Mesh Pro를 사용하다보면
다른 아웃라인과 색깔을 적용하기 위해서는 매번 새로운 머티리얼을 할당해줘야 한다.
 
Legacy Text 보다 이는 사용하기 번거롭기 때문에,
동적으로 머티리얼을 할당하는 코드를 제작하여 해당 프로젝트에 적용하였다.
 
먼저 
TMPStyleMainColor, TMPStyleOutline 클래스를 제작하였다.
 
해당 클래스들은 구조체처럼 서로 다른 변수의 모음을 담기 위해서 제작하였으며
따라서 MonoBehaviour를 상속받지 아니한다.
 
TMPTextStyle 내부에는
컬러를 조정할 변수 모음인 TMPStyleMainColor 클래스를 인스턴스화 하여 맴버 변수로 삼고,
아웃라인을 조정할 변수를 담은 TMPStyleOutline 클래스를 인스턴스화 하여 맴버 변수로 삼는다.
 

[System.Serializable]
public class TMPStyleOutline
{
    public bool useOutline = true;
    [Range(0, 1)] public float width = 0.1f;
    public Color color = Color.black;
}

[System.Serializable]
public class TMPStyleMainColor
{
    public bool overrideColor = false;
    public Color color = Color.white;
}

 
 
TMPTextStyle 역시, MonoBehaviour를 상속받지 아니한다.
TMPTextStyle 는 ApplyTo라는 함수를 통해 (제작자가 정의한 임의의 함수이다.)
TextMeshProUGUI 타입을 변수로 받아
사용되는 TextMeshProUGUI 를 에디터에서 값을 변화시키면
해당 변화된 값이 TextMeshProUGUI 클래스의 인스턴스화 된 값들이
 실시간으로 계속해서 업데이트 해줄 수 있도록 한다.
 
세 클래스 모두 
[System.Serializable] 어트리뷰트를 설정하여
Inspector창에 직렬화되도록 지정한다.
그러므로 해당 필드의 값을 인스펙터창에서 수정할 수 있고,
프리팹 또는 씬이 저장될 때 해당 정보들이 유지된다.
 

    [Header("Target")]
    public TextMeshProUGUI targetText;

    [Header("Style Settings")]
    public TMPTextStyle style;

    private Material sharedMaterialInstance; // 재사용할 Material 인스턴스

 
직렬화 즉, Serializable는
객체의 상태를 저장하거나 전송하기 위한 과정으로
일반적으로 객체의 메모리 상태는 해당 객체가 실행 중인 동안에만 유지된다.
프로그램이 종료되면 객체 상태는 사라지게 되지만,
직렬화를 사용하게 되면 객체는 메모리에서 상태를 보존하지 않고도
파일에 저장하거나 네트워크를 전송할 수 있다.
 
즉 [System.Serializable]를 사용하여
세 클래스 모두 변수를 담을 뿐이다.
이 말은 Component의 역할 보다는 Component의 변수의 역할을 수행하므로
MonoBehaviour를 상속받지 않아도 정상 작동한다는 의의가 된다.
 
해당 이야기는
매 틱마다 돌도록 설정한 Update나 OnTrigger와 같은
컴포넌트를 위한 함수를 불필요하게 상속받을 필요가 없다는 뜻이다.
 
따라서 필자는
불필요한 작업을 상속시키는 건 안하는 게 좋다는 판단하에
MonoBehaviour를 상속받지 않도록 하였다.
 
이 세 클래스는 모두 변수처럼 쓰기 위해 제작된 클래스들이기 때문이다.
 
그럼 다시, 컴포넌트로 돌아와보자.
TMPStyleApplier는 컴포넌트이다.
 
해당 변수들을 이용하여 실질 동작을 담당하는 클래스이다.
TMPStyleApplier는 MonoBehaviour를 상속시켜주는 것이 옳다.
OnValidate 함수를 통해 에디터에서 변화하는 값을 갱신시켜줘야 하기 때문이다.
 
또한 다양한 오브젝트이 값을 한번에 인스펙터 커스텀 에디터가 적용된 상태로 편집하기 위해서,

[CanEditMultipleObjects]
[CustomEditor(typeof(TMPStyleApplier))]

 
어트리뷰트를 다음과 같이
TMPStyleApplier 컴포넌트에 적용해준다.
 

[CanEditMultipleObjects]
[CustomEditor(typeof(TMPStyleApplier))]
public class TMPStyleApplier : MonoBehaviour
{

 
TextMeshProUGUI는 에디터에서 값을 실시간으로 변화하는 것을 가시적으로 확인하고
실시간으로 변동되는 그 값을 각 텍스트마다 다른 머티리얼에 적용해야한다.
이는 Apply 함수와 OnValidate함수를 사용하여 구현할 수 있다.
 
따라서 MonoBehaviour를 상속받아야 하는 이유는
1. TMPStyleApplier를 프리팹에 붙여주어야 하기 때문이며,
2. Text Material를 갱신시켜줘야 하는 컴포넌트의 역할을 지니기 때문이다.
 
이를 위해서는

public void Apply()
{
    if (targetText != null && style != null)
    {
        style.ApplyTo(targetText);
    }
}

해당 코드로
선택한 style을 targetText에 실제로 적용해주는 역할을 수행할 수 있다.
직접 버튼 눌러 실행하거나, 에디터 변경 시 자동 호출하는 기능을 담당한다.
 

#if UNITY_EDITOR
    void OnValidate()
    {
        if (!Application.isPlaying)
        {
            Apply();
        }
    }
#endif

해당 코드로
에디터에서 인스펙터 값이 바뀔 때 마다 자동으로 호출된다.
 
더 면밀히 OnValidate() 의 호출 시기를 말하자면,
targetText나 style의 값을 인스펙터에서 수정하거나
스크립트 컴포넌트를 붙이거나 리컴파일할 때
OnValidate()가 자동으로 호출된다.
 
즉, Apply()는 수동 적용용 ( 사용자 입력을 받고 호출되는 함수 )
OnValidate()는 자동 적용용 ( 사용자 입력과 무관하게 특정 트리거에 의해 호출되는 함수 )
가 되는 셈인 것이다.
 
이런 머티리얼의 정보를 갱신하는 코드는
함수로 호출하여 가시성을 높였다.

    private void ApplyStyleToMaterial(Material mat, TextMeshProUGUI text)
    {
        if (mat == null || text == null) return;

        // Outline
        if (style.outline.useOutline)
        {
            mat.SetFloat(ShaderUtilities.ID_OutlineWidth, style.outline.width);
            mat.SetColor(ShaderUtilities.ID_OutlineColor, style.outline.color);
        }
        else
        {
            mat.SetFloat(ShaderUtilities.ID_OutlineWidth, 0f);
        }

        // 텍스트 메인 컬러
        if (style.mainColor.overrideColor)
        {
            text.color = style.mainColor.color;
        }

        // 텍스트에 변경사항 갱신
        targetText.UpdateMeshPadding();
        targetText.SetMaterialDirty();
    }

 
 
이와 별개로, 
매번 동적할당을 시도한다면 메모리 누수가 생길 수 있기 때문에,
한 번 생성된 머티리얼을 재사용하는 Meterial 인스턴스를 가지는 변수를 제작해주었다.
 

public class TMPStyleApplier : MonoBehaviour
{
    [Header("Target")]
    public TextMeshProUGUI targetText;

    [Header("Style Settings")]
    public TMPTextStyle style;

    private Material sharedMaterialInstance; // 재사용할 Material 인스턴스

 
이렇게 해주면 Apply()함수에서
이미 제작자가 만든 sharedMaterialInstance인지 검수하기만 한다면
씬 안에서 오브젝트 1개에 대해 안전하게 재사용하는 구조로 변화한다.
 
여러번 Apply함수를 호출 하더라도
재사용 하는 구조이기 때문에
Material의 낭비가 없으며
런타임에서도 정상 작동 하는 것을 아래 영상으로 확인할 수 있다.
 
또한 프로젝트를 껐다 키더라도 
앞서 말했던 [System.Serializable] 어트리뷰트를 추가해주었기 때문에
씬에 저장되므로 유지될 수 있다.
 
다른 오브젝트들이 동일한 Mererial을 공유한다고 하더라도,
현재 구조에서 오브젝트별로 복사본을 만들었기에 큰 문제가 되지 않는다.
 
추가적으로 TMPStyleApplierEditor 클래스를 Editor에 상속시킨 뒤

기능기능 설명
TMP 하나의 스타일 적용[Apply Style] 버튼 클릭
TMP 여러 개 선택해서 모두 적용[Batch Apply to All Selected] 버튼 클릭
Apply하고 잠그기[Apply Once and Lock]
원래 머티리얼로 복원[Reset to Default]
적용된 스타일 저장[Save Material as Asset]

 
해당 기능들을 에디터에서 사용할 수 있도록 추가하였다.
 
자세한 기능은 아래 영상과,
위 링크의 깃허브 링크를 통해 확인할 수 있다.
 
 
에디터 기능 시연 영상

 
 
기능 추가 후 시연 영상

 
 
 
개인적인 의견으로는,
이렇게 쓰면 Legacy Text보다 오히려
컴포넌트를 하나만 추가해도 된다는 점에서 더 편하게 쓸 수 있는 것 같다.
 
추가적으로 유니티는 내가 잘 몰라서 그런지는 몰라도,
비교적 언리얼 보다는
기능에 따른 상속구조가 깊이 들어가지는 않아,
구현하기에는 입문자자가 접하기 좋다고 생각이 들었다.
 
물론 필자의 기분탓일 수도 있고 무지에서 나온 판단일 수도 있다.
여튼 현재로써는 깊은 상속구조를 가지기 보다는
모노비헤이비어와 어트리뷰트만을 이해하고
에디터를 제작할 수 있다는 것에 사용자 편의를 굉장히 크게 제공해주는 엔진이라 느꼈다.
 
유니티에서 제작한 것들을
나중에 언리얼을 더 공부해서 
언리얼 프로젝트에 적용하고자 한다.
 
혼자 뭘 만들고
생각하고 구현하는 건 너무 즐겁다.
 
요즘 근래 회사일로 바빴고
2차 수정까지 하고 나니, 오전 6시가 다 되어
비록 밤을 꼴딱 지새웠더라도
개인 플젝을 하니 행복하다.
가끔 이렇게 밤새면서 공부하는 시간도 있어야 
아 내가 그래도 하고싶은 거 하면서 잘 살고 있구나 싶다.