---
title: "UniText 는 폭을 모르는 일을 한 번만 하고 바뀐 층만 다시 돈다"
description: "정렬만 바꿨는데 셰이핑부터 다시 하면 낭비입니다. UniText 는 폭과 무관한 분석과 셰이핑을 첫 패스에 모아 두고, 무엇이 바뀌었는지에 따라 첫 패스, 줄, 배치 세 층 중 필요한 층부터 다시 돕니다."
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-pipeline/
---
```csharp
label.HorizontalAlignment = HorizontalAlignment.Center;   // HarfBuzz 를 다시 불러야 할까?
```

텍스트 처리에는 비싼 일(셰이핑)과 싼 일(좌표 매기기)이 섞여 있고, UI 에서는 정렬, 크기, 색이 수시로 바뀝니다. 바뀔 때마다 처음부터 다시 하면 창 크기를 끌기만 해도 매 프레임 셰이핑을 합니다.

UniText 는 **컨테이너 폭을 몰라도 되는 일을 첫 패스에 모아 한 번 끝내 두고, 바뀐 것에 따라 필요한 층부터만** 다시 돕니다. 기준은 1.0.0 브랜치의 커밋 `2087eff` 입니다.

## 문자열이 화면에 오기까지 하는 일

문자열 하나를 그리려면 아래 일을 차례로 해야 합니다. 표의 마지막 열은 그 일이 텍스트 상자의 폭을 알아야 하는지입니다.

| 순서 | 하는 일 | 누가 | 상자 폭 |
| --- | --- | --- | --- |
| 1 | 문자열을 글자 번호(코드포인트)의 배열로 바꾼다 | C# | 몰라도 됨 |
| 2 | 글자 사이마다 "여기서 줄을 바꿔도 되나" 를 표시한다 | C# | 몰라도 됨 |
| 3 | 글자마다 방향(왼쪽에서 오른쪽, 오른쪽에서 왼쪽)을 정한다 | C# | 몰라도 됨 |
| 4 | 글자마다 어느 문자 체계(라틴, 한글, 아랍 등)인지 정한다 | C# | 몰라도 됨 |
| 5 | 방향, 문자 체계, 폰트가 같은 구간으로 자른다 | C# | 몰라도 됨 |
| 6 | 구간마다 글리프를 고르고 폭을 얻는다 (셰이핑) | HarfBuzz (네이티브) | 몰라도 됨 |
| 7 | 글자마다의 폭을 모아 둔다 | C# | 몰라도 됨 |
| 8 | 실제로 줄을 나눈다 | C# | **알아야 함** |
| 9 | 줄마다 정렬해 글리프의 좌표를 정한다 | C# | **알아야 함** |
| 10 | 좌표에 사각형을 찍어 메시를 만든다 | C# | 알아야 함 |

몇 가지 말을 풀어 둡니다.

- **문자 체계(스크립트)** 는 라틴, 한글, 아랍, 히브리처럼 글자가 속한 묶음입니다. HarfBuzz 가 문자 체계마다 다른 규칙으로 글리프를 고르기 때문에 미리 정해서 알려 줘야 합니다.
- **런** 은 5번에서 잘린 한 토막입니다. 방향, 문자 체계, 폰트가 모두 같은 구간이고, HarfBuzz 는 런을 하나씩 받습니다.
- **셰이핑** 은 글자 번호 열을 받아 어느 글리프를 어디에 놓을지 정하는 일입니다. 이 표에서 가장 비싼 단계이고, 네이티브 라이브러리를 부르는 유일한 단계입니다.
- 2번의 "바꿔도 되나" 와 8번의 "실제로 나눈다" 는 다른 일입니다. 공백 뒤에서 끊어도 된다는 것은 글자만 보고 알 수 있지만, 그중 어디서 끊을지는 상자 폭이 있어야 정해집니다.

1번부터 7번까지는 상자 폭과 상관없습니다. 창 크기가 바뀌어도, 정렬이 바뀌어도 결과가 같습니다.

## 폭과 상관없는 일곱 단계를 첫 패스에 모았다

UniText 는 이 일곱 단계를 `TextProcessor.DoFirstPass` 한 곳에 모으고, 끝나면 플래그 하나로 "여기까지는 끝났다" 고 표시해 둡니다.

```csharp
Parse(text);                    // 1. UTF-16 문자열을 코드포인트 배열로
ComputeBreakOpportunities();    // 2. 끊어도 되는 자리 (UAX #14). 폭은 아직 모른다
Parsed?.Invoke();

// 태그만 있어 글자가 0개면 여기서 끝낸다
if (buf.codepoints.count == 0)
{
    // 플래그를 모두 false 로
    return;
}

AnalyzeBidi();                  // 3. 문자마다 방향 레벨 (UAX #9)
AnalyzeScripts();               // 4. 문자마다 스크립트 (UAX #24)
Itemize();                      // 5. 레벨, 스크립트, 폰트가 같은 구간(런)으로 자른다
Shape();                        // 6. 런마다 HarfBuzz 를 한 번 부른다
Shaped?.Invoke();
ComputeCpWidths();              // 7. 글리프 폭을 코드포인트 칸으로 모은다

hasValidFirstPassData = true;   // 봉인. 폭이 바뀌어도 여기까지는 다시 돌지 않는다
```

`"Hi שלום"` 을 넣으면 버퍼가 이렇게 채워집니다. 히1 ~ 히4 는 히브리 글자 넷입니다.

| 단계 | 채워지는 것 |
| --- | --- |
| 1 | 코드포인트 7개: H, i, 공백, 히1, 히2, 히3, 히4 |
| 2 | 끊어도 되는 자리: 공백 뒤 |
| 3 | 레벨 `0 0 0 1 1 1 1`. 1 은 오른쪽에서 왼쪽 |
| 4 | 스크립트: Latin, Latin, Common, Hebrew × 4 |
| 5 | 런 둘: `[0..3)` Latin, `[3..7)` Hebrew. 공백은 앞 런에 붙음 |
| 6 | HarfBuzz 호출 두 번. 히브리 런의 글리프는 화면 순서로 나와 cluster 가 6, 5, 4, 3 |
| 7 | 코드포인트 7칸의 폭 |

런은 방향 레벨, 문자 체계, 폰트 가운데 하나라도 바뀌는 자리에서 잘리고, 런 하나가 HarfBuzz 호출 하나입니다. 공백, 숫자, 구두점은 어느 문자 체계에도 속하지 않는 공용(Common)으로 분류되고, 문자 체계가 바뀐 것으로 치지 않아 앞 런에 붙습니다.

표의 `cluster` 는 글리프마다 붙는 번호로, 그 글리프가 원문의 몇 번째 글자에서 나왔는지를 가리킵니다. 히브리 런은 오른쪽에서 왼쪽이라 HarfBuzz 가 글리프를 화면에 놓을 순서로 돌려주고, 그래서 번호가 6, 5, 4, 3 으로 거꾸로 옵니다. 에디터에서 이 문자열의 버퍼를 읽으면 레벨 `0001111`, 런 둘, 히브리 런의 cluster 가 6, 5, 4, 3 순서로 위 표와 같았습니다.

## 바뀐 것에 따라 버리는 층이 다르다

결과는 층마다 플래그 하나로 지켜집니다. 첫 패스(`hasValidFirstPassData`), 줄(`hasValidLinesData`), 배치(`hasValidPositionedGlyphs`)입니다. 컴포넌트의 `SetDirty` 가 바뀐 것에 맞춰 가장 센 플래그 하나를 골라 그 층부터 아래로만 버립니다.

```csharp
if ((flags & DirtyFlags.FullRebuild) != 0)
{
    // 텍스트가 바뀌었다. 태그 파싱부터 전부 버린다
    textIsParsed = false;
    textProcessor?.InvalidateFirstPassData();
    InvalidateLayoutCache();
}
else if ((flags & DirtyFlags.LayoutRebuild) != 0)
{
    // 폭이 달라진다. 셰이핑 결과는 두고 줄부터 버린다
    textProcessor?.InvalidateLayoutData();
    InvalidateLayoutCache();
}
else if ((flags & DirtyFlags.Alignment) != 0)
{
    // 줄은 그대로다. 줄 안의 좌표만 버린다
    textProcessor?.InvalidatePositionedGlyphs();
}

RegisterDirty(this);   // 목록에 올린다. 처리는 캔버스가 그리기 직전에 한다

// 크기가 달라질 수 있는 변경만 uGUI 레이아웃에 알린다
if ((flags & (DirtyFlags.FullRebuild | DirtyFlags.LayoutRebuild)) != 0)
    LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
```

`else if` 사슬이라 가장 센 플래그 하나만 골라 그 층부터 아래로 버립니다.

| 바꾼 것 | 버리는 층 | 다시 도는 일 |
| --- | --- | --- |
| `Text` | 파싱, 첫 패스, 줄, 배치 | 전부 |
| `FontSize` | 줄, 배치 | 줄 → 배치 → 메시. HarfBuzz 는 부르지 않음 |
| `HorizontalAlignment` | 배치 | 배치 → 메시. uGUI 레이아웃은 건드리지 않음 |
| `color` | 없음 | 메시만 |

라틴과 아랍어를 번갈아 이은 2,000자 컴포넌트에서 변경을 하나씩 하고 `Shaped` 이벤트 수와 캔버스 훅 시간을 쟀습니다.

| 변경 | `Shaped` 증가 | 첫 패스 (IL2CPP) | 메시 단계 (IL2CPP) |
|---|---|---|---|
| `HorizontalAlignment` Left → Center | 0 | 0.020 ms | 0.54 ms |
| `FontSize` 36 → 48 | 0 | 0.005 ms | 0.34 ms |
| `color` 흰색 → 노랑 | 0 | 0.004 ms | 0.33 ms |
| 같은 문자열을 `SetText(char[], 0, n)` | 1 | 1.86 ms | 0.62 ms |
| 다른 문자열 대입 | 1 | 1.66 ms | 0.62 ms |

정렬, 크기, 색은 셰이핑을 부르지 않습니다. 같은 내용이어도 `SetText` 는 셰이핑부터 다시 합니다.

### 이렇게 쓴다

`Text` 세터는 이전 문자열과 같으면 아무것도 하지 않고 돌아갑니다. `SetText(char[] ...)` 는 비교하지 않고 텍스트가 바뀌었다고 표시합니다. 가장 센 표시라 태그 파싱부터 메시까지 전부 다시 돕니다.

```csharp
// 매 프레임 전부 다시 돈다. 2,000자에서 첫 패스만 약 1.7 ~ 1.9 ms
void Update()
{
    int n = score.TryFormat(buffer, out int written) ? written : 0;
    uniText.SetText(buffer, 0, n);
}

// 값이 바뀐 프레임에만 넣는다
void Update()
{
    if (score == shownScore)
        return;

    shownScore = score;
    int n = score.TryFormat(buffer, out int written) ? written : 0;
    uniText.SetText(buffer, 0, n);
}
```

## 첫 패스 뒤의 번호는 전부 코드포인트 번호다

C# 의 `string` 은 글자를 16비트 `char` 로 담습니다. 이모지처럼 번호가 큰 글자는 `char` 하나에 안 들어가서 `char` 둘로 적습니다(서로게이트 쌍). 그래서 `string` 의 길이와 글자 수가 다를 수 있습니다.

1번 단계 `Parse` 가 `char` 둘을 글자 번호 하나로 접습니다. 그 뒤로는 글리프의 `cluster`, 런과 줄의 범위, 모디파이어가 받는 범위가 모두 **글자 하나에 번호 하나인 코드포인트 번호**입니다.

접는 것은 여기까지입니다. 유니코드에는 같은 글자를 적는 방법이 둘 이상인 경우가 있습니다. `가` 는 완성된 한 글자(U+AC00)로도, 자음과 모음 둘(U+1100, U+1161)로도 적을 수 있습니다. 앞의 것을 NFC, 뒤의 것을 NFD 라고 부르고, 하나로 맞추는 일이 정규화입니다. 첫 패스는 정규화를 하지 않습니다. 화면에 같아 보여도 적는 방법이 다르면 다른 입력입니다. 기본 스택 뒤에 NotoSansKR 을 붙이고 `가나` 를 두 형태로 넣었습니다.

| 입력 | 코드포인트 | 글리프 | (glyphId, cluster) |
|---|---|---|---|
| NFC `U+AC00 U+B098` | 2 | 2 | (10316, 0) (11492, 1) |
| NFD `U+1100 U+1161 U+1102 U+1161` | 4 | 2 | (10316, 0) (11492, 2) |

폰트가 자모를 완성형 글리프로 합쳐 화면은 똑같은데 `cluster` 는 NFD 쪽이 0, 2 로 건너뜁니다. 정규화가 없는 대신 "cluster 는 원본 코드포인트의 절대 번호" 라는 약속이 단순하게 지켜집니다.

### 이렇게 쓴다

**바깥에서 온 문자열은 넣기 전에 정규화합니다.** 조합형으로 적힌 한글은 완성형과 글자 수부터 다르게 잡혀서, 태그 범위와 클릭 위치가 눈에 보이는 것과 어긋납니다. macOS 의 파일 이름이나 붙여넣은 글에서 가끔만 생겨 원인을 찾기 어렵습니다.

```csharp
uniText.Text = fileName.Normalize(NormalizationForm.FormC);
```

**`string` 에서 찾은 위치를 그대로 쓰지 않습니다.** `string.IndexOf` 가 돌려주는 것은 `char` 번호입니다. `cluster` 나 모디파이어 범위와 견주려면 그 앞에 있는 서로게이트 쌍의 수만큼 빼야 합니다. 빼지 않으면 이모지 뒤부터 한 칸씩 밀립니다.

**문자열을 자를 때는 글자 단위로 자릅니다.** `Substring` 으로 이모지의 `char` 둘 사이를 자르면 짝 잃은 반쪽이 예외 없이 셰이핑까지 가서 빈 네모로 나옵니다. `System.Globalization.StringInfo` 로 사람이 보는 글자 단위로 자릅니다.

## 정리

UniText 는 **폭을 모르는 일을 첫 패스에 한 번 끝내 두고, 바뀐 층만 다시 돕니다.**

- 첫 패스는 글자 번호로 바꾸기, 줄바꿈 표시, 방향, 문자 체계, 런 나누기, 셰이핑, 폭 모으기 일곱 단계입니다. 네이티브를 부르는 것은 셰이핑뿐이고, 런 하나가 HarfBuzz 호출 하나입니다.
- 텍스트, 폰트, 방향은 전부, 크기와 레이아웃 설정은 줄부터, 정렬은 배치만, 색은 메시만 다시 돕니다. `SetText` 는 같은 내용이어도 전부입니다.
- 첫 패스 뒤 번호는 코드포인트 번호이고, 정규화가 없어 조합형 입력은 화면이 같아도 번호가 다릅니다.

그래서 같은 내용을 매 프레임 `SetText` 로 다시 넣지 않고, 바깥에서 온 문자열은 정규화해서 넣습니다.