---
title: "UniText 의 모디파이어는 방금 찍은 쿼드 네 정점만 고친다"
description: "모디파이어마다 완성된 메시를 다시 훑으면 효과 수만큼 정점 배열을 돕니다. UniText 는 쿼드를 한 장 찍을 때마다 OnGlyph 를 불러, 모디파이어가 vertexCount - 4 부터 네 정점만 고치게 합니다."
author: "유니얼"
pubDate: 2026-09-29
updatedDate: 2026-10-06
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "UI", "uGUI"]
canonical: https://blog.unialgames.com/posts/unity-unitext-mesh/
---
```csharp
label.Text = "HP <color=#FF4040>12</color> / 100";
```

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

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

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

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

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

```csharp
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()` 에서 필드에 담아 두고, 콜백은 그 필드만 읽습니다.

```csharp
float time;

public override void PrepareForParallel()
{
    time = Time.time;          // 메인 스레드에서 미리 읽어 둔다
}

void OnGlyph()
{
    float t = time;            // 콜백은 필드만 읽는다
}
```

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

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

```csharp
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` 를 부르면 그 지역 변수도 반납된 배열을 가리킵니다.

### 이렇게 쓴다

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

```csharp
// 틀림: 받아 둔 배열이 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>` 을 만들면 이렇습니다.

```csharp
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` 은 부를 때마다 **같은 델리게이트**를 돌려줘야 합니다. 켤 때와 끌 때 이 메서드를 따로 불러 `+=` 와 `-=` 에 쓰기 때문입니다.

```csharp
// 틀림: 부를 때마다 새 델리게이트라 -= 가 아무것도 빼지 못한다
protected override Action GetOnGlyphCallback() => () => OnGlyph();

// 맞음: 메서드 그룹은 같은 대상으로 비교된다
protected override Action GetOnGlyphCallback() => OnGlyph;
```

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

## 정리

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

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

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