UniText 의 모디파이어는 방금 찍은 쿼드 네 정점만 고친다
Esc

    UniText 의 모디파이어는 방금 찍은 쿼드 네 정점만 고친다

    2026년 10월 6일 수정–Unity라이브러리UIuGUI
    label.Text = "HP <color=#FF4040>12</color> / 100";

    글자는 화면에 사각형으로 그려집니다. 글리프 하나마다 정점 넷으로 된 사각형(쿼드) 하나를 만들고, 아틀라스에서 그 글리프가 든 칸을 입힙니다. 텍스트 한 덩어리의 메시는 이 쿼드를 글리프 수만큼 이어 붙인 것입니다.

    색, 굵게, 기울임 같은 효과는 이 정점을 고쳐서 냅니다. 색은 정점 색을 바꾸고, 기울임은 위쪽 정점 둘을 옆으로 밉니다. 효과를 넣는 코드(모디파이어)마다 완성된 메시에서 자기 구간을 찾아다니게 하면, 효과 수만큼 정점 배열 전체를 훑고 그 대부분을 “내 구간이 아니다” 를 확인하는 데 씁니다.

    UniText 의 메시 생성기는 글리프 쿼드를 한 장 찍을 때마다 OnGlyph 를 불러, 모디파이어가 방금 찍힌 정점 네 개만 고치게 합니다. ColorModifier 는 1 을 찍은 순간 불려 그 네 정점의 색만 바꾸고, HP 를 찍을 때는 한 칸 읽고 빠집니다. 기준은 1.0.0 브랜치의 커밋 2087eff 입니다.

    쿼드를 한 장 찍고 바로 알린다

    UniTextMeshGenerator 는 폰트마다 필요한 용량을 한 번 잡고, 글리프마다 정점 네 개(좌하, 좌상, 우상, 우하)와 삼각형 둘을 찍은 뒤 vertexCount 를 4 올리고 OnGlyph 를 부릅니다.

    foreach (var glyph in glyphs)
    {
        // 이 글리프가 쓸 정점 네 칸
        i0 = vertexCount;
        i1 = i0 + 1;
        i2 = i0 + 2;
        i3 = i0 + 3;
    
        verts[i0..i3] = 좌하, 좌상, 우상, 우하;     // uv 와 색도 같은 칸에 쓴다
        tris[..] = (i0, i1, i2), (i2, i3, i0);      // 삼각형 둘이 네 정점을 나눠 쓴다
    
        // 콜백이 읽을 공개 필드를 이 글리프 값으로 바꾼다
        currentCluster = glyph.cluster;
        x = glyph.x;
    
        // 카운터를 먼저 올리고 알린다
        vertexCount += 4;
        triangleCount += 6;
    
        OnGlyph?.Invoke();                          // 모디파이어는 vertexCount - 4 부터 네 칸을 고친다
    
        // 콜백이 버퍼를 키웠을 수 있으니 배열을 다시 읽는다
        verts = vertices.data;
        cols = colors.data;
    }

    HP 12 / 100 의 12 두 글자가 찍힐 때 모디파이어가 보는 값입니다.

    글리프 currentCluster 콜백 때 vertexCount 고칠 정점
    1 3 4 0 ~ 3
    2 4 8 4 ~ 7

    currentCluster 는 태그를 걷어 낸 텍스트의 코드포인트 번호입니다. 카운터를 올린 뒤에 콜백을 부르므로, 방금 찍은 쿼드는 늘 vertexCount - 4 부터입니다.

    OnGlyph 는 인자가 없는 콜백입니다. 모디파이어는 “지금 어느 글리프인가” 를 인자로 받지 않고, 생성기의 공개 필드(currentCluster, vertexCount, Vertices, Colors 등)에서 읽습니다. 생성기는 UniTextMeshGenerator.Current 로 얻습니다.

    Current 는 스레드마다 값이 따로인 정적 필드([ThreadStatic])입니다. 메시 생성은 워커 스레드 여럿에서 동시에 돌 수 있는데, 그때도 각 스레드의 콜백은 자기 스레드가 만들고 있는 생성기를 봅니다.

    이 구조에서는 모디파이어가 몇 개든 정점 배열을 한 번만 지나가고, 모디파이어끼리는 서로를 모릅니다. 라틴 2,000자(1,992글리프)의 메시만 다시 만든 시간입니다.

    모디파이어 메시 단계
    0개 0.267 ms
    1개 (<color>) 0.293 ms
    3개 (<color><b><i>) 0.359 ms

    모디파이어 하나가 글리프당 십여 나노초를 더했습니다.

    이렇게 쓴다

    콜백은 워커 스레드에서도 불립니다. 콜백 안에서 Time.time, transform 같은 메인 스레드 전용 유니티 API 를 부르면, 병렬로 도는 프레임에만 예외가 납니다. 에디터에서 멀쩡하던 것이 글자가 많은 특정 화면에서만 터집니다.

    필요한 값은 메인 스레드에서 불리는 PrepareForParallel() 에서 필드에 담아 두고, 콜백은 그 필드만 읽습니다.

    float time;
    
    public override void PrepareForParallel()
    {
        time = Time.time;          // 메인 스레드에서 미리 읽어 둔다
    }
    
    void OnGlyph()
    {
        float t = time;            // 콜백은 필드만 읽는다
    }

    콜백 뒤에 배열을 다시 읽는다

    밑줄은 선 쿼드를, 말줄임은 ... 쿼드를 더 찍습니다. 공간이 모자라면 모디파이어가 EnsureCapacity 를 부르고, 버퍼는 풀에서 새 배열을 빌리고 옛 배열을 풀에 반납합니다. 루프가 지역 변수로 들고 있던 배열이 반납된 것이라, 루프는 콜백이 끝날 때마다 필드에서 배열을 다시 읽습니다.

    var verts = vertices.data;          // 루프 전에 한 번 받아 둔다
    
    foreach (var glyph in glyphs)
    {
        verts[vertexCount..+3] = 쿼드;
        vertexCount += 4;
    
        OnGlyph?.Invoke();              // 밑줄 모디파이어가 EnsureCapacity 를 불러 버퍼를 키울 수 있다
    
        verts = vertices.data;          // 그래서 콜백이 끝날 때마다 다시 읽는다
    }

    마지막 줄이 없으면 이렇게 됩니다. vertices.data 가 8칸짜리 배열 A 라고 합시다.

    1. 첫 글리프를 A 의 0 ~ 3 칸에 씁니다.
    2. 밑줄 모디파이어가 12칸을 더 요구합니다. 버퍼가 16칸짜리 새 배열 B 를 풀에서 빌려 복사하고, A 를 풀에 반납합니다. vertices.data 는 B 가 됩니다.
    3. 루프의 지역 변수 verts 는 여전히 A 를 가리킵니다. 둘째 글리프를 A 에 쓰려 하면 범위 밖 예외가 나거나, 다른 곳이 빌려 간 배열을 덮어씁니다.

    다시 읽는 줄이 있으면 3 에서 verts 가 B 가 되어 둘째 글리프가 제자리에 찍힙니다. 버퍼는 글리프 수 × 4 로 미리 잡으므로 쿼드만 찍을 때는 커지지 않고, 모디파이어가 정점을 더할 때만 커집니다.

    같은 함정이 모디파이어 쪽에도 있습니다. gen.Vertices 를 먼저 받아 두고 EnsureCapacity 를 부르면 그 지역 변수도 반납된 배열을 가리킵니다.

    이렇게 쓴다

    정점을 더하는 모디파이어는 공간을 먼저 확보하고, 그다음에 배열을 받습니다.

    // 틀림: 받아 둔 배열이 EnsureCapacity 뒤에는 반납된 배열이다
    var verts = gen.Vertices;
    gen.EnsureCapacity(4, 6);
    verts[gen.vertexCount] = ...;
    
    // 맞음
    gen.EnsureCapacity(4, 6);      // 정점 4개, 인덱스 6개를 더 쓰겠다
    var verts = gen.Vertices;
    var tris = gen.Triangles;

    삼각형이 가리키는 정점 번호는 배열 전체 기준이 아니라 정점 번호 - gen.CurrentSegmentVertexStart 로 적습니다. 메시가 폰트마다 잘려 넘어가기 때문에, 전체 기준 번호를 적으면 자를 때 어긋납니다.

    파생 클래스에는 구멍 셋만 열려 있다

    GlyphModifier<T> 는 OnEnable, OnDisable, OnDestroy, OnApply 를 sealed override 로 막아 구독, 해제, 버퍼 대여를 파생 클래스가 틀릴 수 없게 했습니다. 남은 것은 AttributeKey, DoApply, GetOnGlyphCallback 셋입니다. 모디파이어는 텍스트에 제 태그가 처음 나올 때 OnGlyph 에 붙으므로, 씬에 UniText 가 많아도 태그가 실제로 쓰인 곳만 값을 냅니다. 글리프마다 Y 를 흔드는 <wave=8> 을 만들면 이렇습니다.

    using System;
    using UnityEngine;
    using LightSide;
    
    [Serializable]
    public sealed class WaveParseRule : TagParseRule
    {
        protected override string TagName      => "wave";
        protected override bool   HasParameter => true;
    }
    
    [Serializable]
    public sealed class WaveModifier : GlyphModifier<float>
    {
        float time;   // 워커 스레드에서는 Time.time 을 못 읽는다
    
        protected override string AttributeKey => "wave";
        protected override Action GetOnGlyphCallback() => OnGlyph;       // 메서드 그룹
    
        public override void PrepareForParallel() => time = Time.time;   // 메인 스레드에서 미리
    
        protected override void DoApply(int start, int end, string parameter)
        {
            if (!float.TryParse(parameter, out var amplitude) || amplitude == 0f) return;
            var data = attribute.buffer.data;
            if (data == null) return;
            var last = Math.Min(Math.Min(end, buffers.codepoints.count), data.Length);
            for (var i = Math.Max(0, start); i < last; i++) data[i] = amplitude;
        }
    
        void OnGlyph()
        {
            var gen = UniTextMeshGenerator.Current;
            var data = attribute.buffer.data;
            var cluster = gen.currentCluster;
            if (data == null || (uint)cluster >= (uint)data.Length || data[cluster] == 0f) return;
    
            var offset  = Mathf.Sin(time * 4f + cluster * 0.5f) * data[cluster];
            var baseIdx = gen.vertexCount - 4;                            // 방금 찍힌 쿼드
            var verts   = gen.Vertices;
            for (var k = 0; k < 4; k++) verts[baseIdx + k].y += offset;
        }
    }

    <wave=8>hello</wave> 의 쿼드는 태그 없는 텍스트보다 글자마다 6.71, 3.81, -0.03, -3.87, -6.75 만큼 위아래로 어긋났습니다. 1초 뒤 메시만 다시 만들자 PrepareForParallel 이 새로 읽은 시간으로 값이 바뀌었습니다.

    이렇게 쓴다

    GetOnGlyphCallback 은 부를 때마다 같은 델리게이트를 돌려줘야 합니다. 켤 때와 끌 때 이 메서드를 따로 불러 += 와 -= 에 쓰기 때문입니다.

    // 틀림: 부를 때마다 새 델리게이트라 -= 가 아무것도 빼지 못한다
    protected override Action GetOnGlyphCallback() => () => OnGlyph();
    
    // 맞음: 메서드 그룹은 같은 대상으로 비교된다
    protected override Action GetOnGlyphCallback() => OnGlyph;

    틀린 쪽은 예외도 경고도 없습니다. 컴포넌트를 껐다 켤 때마다 구독이 쌓여 효과가 두 번, 세 번 겹칩니다.

    정리

    UniText 의 모디파이어는 방금 찍은 쿼드 네 정점만 고칩니다.

    • 생성기는 쿼드를 찍고 vertexCount 를 4 올린 뒤 OnGlyph 를 부르고, 모디파이어는 vertexCount - 4 부터 네 칸을 봅니다. 정점 배열은 한 번만 지나갑니다.
    • 모디파이어가 버퍼를 키우면 옛 배열이 반납되므로, 루프는 콜백 뒤에 배열을 다시 읽고 모디파이어는 EnsureCapacity 뒤에 배열을 받습니다.
    • 파생 클래스에는 AttributeKey, DoApply, GetOnGlyphCallback 셋만 열려 있고, 콜백은 같은 델리게이트를 돌려줘야 풀립니다.

    그래서 콜백에서는 유니티 API 를 부르지 않고, 정점을 더할 때는 EnsureCapacity 를 먼저 부르고, 콜백은 메서드 그룹으로 돌려줍니다.