---
title: "UniText 는 셰이핑을 HarfBuzz 에 맡기고 캐시만 쥔다"
description: "아랍어 연결 형태와 합자는 폰트의 GSUB, GPOS 규칙을 돌려야 나옵니다. UniText 는 이 셰이핑을 HarfBuzz 에 통째로 맡기고, C# 쪽에는 문단째 넘기는 호출과 폰트, 글리프, 스레드 버퍼 캐시 세 겹만 둡니다."
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-shaping/
---
```csharp
var result = Shaper.Shape(cp, run.range.start, run.range.length,
                          fontProvider, run.fontId, run.script, run.Direction);   // 런 하나에 한 번
```

셰이핑은 글자 번호(코드포인트)의 배열을 받아, 어느 글리프를 쓰고 그 글리프를 얼마나 옮겨 놓을지를 정하는 단계입니다. 규칙은 폰트 파일 안의 표에 있고, 그 표를 어떤 순서로 적용하는지는 문자 체계마다 다릅니다. 명세가 수천 쪽이라 직접 짜면 틀린 언어에서만 글자가 깨집니다.

UniText 는 **셰이핑을 HarfBuzz 에 통째로 맡기고, C# 쪽에는 HarfBuzz 를 부르는 코드와 그 호출을 싸게 만드는 캐시만** 둡니다. 셰이핑 경로는 `Runtime/Native/HB.cs` 와 `Runtime/Core/Shaper.cs` 두 파일로 끝납니다. 기준은 1.0.0 브랜치의 커밋 `2087eff` 입니다.

## C# 에서 HarfBuzz 를 부르는 과정

HarfBuzz 는 C++ 로 짠 네이티브 라이브러리입니다. C# 에서는 함수를 하나씩 `DllImport` 로 선언해 부르고, HarfBuzz 가 만든 객체는 C# 객체가 아니라 주소(`IntPtr`)로만 들고 다닙니다. GC 가 관리하지 않으므로 만든 쪽이 직접 없애야 합니다.

셰이핑 한 번에는 HarfBuzz 객체 넷이 필요합니다.

| 객체 | 무엇 | 만드는 때 |
| --- | --- | --- |
| blob | 폰트 파일의 바이트 덩어리 | 폰트마다 한 번 |
| face | blob 을 폰트 파일로 해석한 것. 규칙 표의 목록을 안다 | 폰트마다 한 번 |
| font | face 에 크기 설정을 입힌 것. 셰이핑에 실제로 넘기는 객체 | 폰트마다 한 번 |
| buffer | 글자를 넣고 글리프를 받는 통 | 셰이핑할 때마다 씀 |

부르는 순서는 이렇습니다.

1. 폰트 파일 바이트를 네이티브 메모리로 복사합니다. HarfBuzz 가 그 주소를 폰트를 쓰는 내내 붙들고 있어서, GC 가 옮길 수 있는 C# 배열을 그대로 넘길 수 없습니다.
2. 그 주소로 blob, face, font 를 차례로 만듭니다. 여기서 폰트 파일을 해석합니다.
3. buffer 에 글자 번호들을 넣고, 방향과 문자 체계를 적습니다. HarfBuzz 는 이 둘을 스스로 정하지 않습니다.
4. `shape(font, buffer)` 를 부릅니다. HarfBuzz 가 폰트의 규칙 표를 실행해 buffer 안의 글자를 글리프로 바꿉니다.
5. buffer 에서 글리프 번호, 이동량, `cluster` 를 읽어 C# 배열로 옮깁니다.

1번과 2번은 비싸고 폰트마다 한 번이면 됩니다. 3번부터 5번은 런마다 합니다. 이 차이가 뒤에 나오는 캐시의 모양을 정합니다.

## 런 하나를 셰이핑할 때도 문단 전체를 넘긴다

셰이핑은 런 단위로 부릅니다. 런은 방향, 문자 체계, 폰트가 모두 같은 구간이고, 셋 중 하나만 바뀌어도 끊깁니다. 한 단어 안에서 폰트가 바뀌면 단어 중간에서도 끊깁니다.

그런데 아랍어 글자는 앞뒤 글자와 이어지는지에 따라 모양이 달라집니다. 런만 잘라서 넘기면 HarfBuzz 는 런의 첫 글자 앞에 무엇이 있는지 모릅니다. 그래서 buffer 에는 문단 전체를 넣고, 셰이핑할 구간만 시작 위치와 길이로 알려 줍니다(`hb_buffer_add_codepoints` 의 `item_offset`, `item_length`).

<x-compare>
<script type="application/json">
{
  "label": "아랍어 단어 중간에서 런이 갈렸을 때 뒤쪽 런의 첫 글자",
  "tabs": [
    {
      "label": "런만 잘라 넘긴다면",
      "code": "var slice = context.Slice(run.start, run.length);\nHB.AddCodepoints(buffer, slice, 0, slice.Length);",
      "text": "HarfBuzz 가 보는 세상은 이 런이 전부입니다. 첫 글자 앞에 아무것도 없으니 단어의 시작으로 보고 어두형을 고릅니다. 실제로는 앞 글자와 이어지는 어중형이어야 합니다. 런 경계에 걸친 결합 마크와 합자도 같은 이유로 깨집니다.",
      "stats": [["HarfBuzz 가 보는 앞 글자", "없음"], ["고르는 형태", "어두형 (틀림)"], ["돌아오는 cluster", "런 안의 상대 번호"]],
      "tone": "bad"
    },
    {
      "label": "문단 전체와 구간 (실제 코드)",
      "code": "HB.AddCodepoints(buffer, context, itemOffset, itemLength);",
      "text": "HarfBuzz 는 구간 앞뒤의 글자를 문맥으로 읽기만 하고, 글리프는 구간 안에서만 만듭니다. 앞 글자와 이어진다는 것을 알고 어중형을 고릅니다.",
      "stats": [["HarfBuzz 가 보는 앞 글자", "앞 런의 마지막 글자"], ["고르는 형태", "어중형"], ["돌아오는 cluster", "문단 전체 기준 번호"]],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

`cluster` 는 글리프마다 붙는 번호로, 그 글리프가 원문의 몇 번째 글자에서 나왔는지를 가리킵니다. 문단 전체를 넣었으니 이 번호가 곧 문단 기준 번호입니다. 런마다 시작 위치를 더할 필요 없이 줄나누기와 클릭 위치 판정이 이 번호 하나로 원래 글자까지 돌아갑니다.

## 캐시는 수명이 다른 세 겹이다

앞에서 본 대로 blob, face, font 는 폰트마다 한 번 만들면 되고 buffer 는 호출마다 씁니다. UniText 는 이 차이를 그대로 캐시로 옮겼습니다.

| 층 | 무엇 | 수명 |
| --- | --- | --- |
| `fontCache` | 폰트마다 네이티브 메모리의 폰트 사본과 blob, face, font | 프로그램이 끝날 때까지. 여러 스레드가 같이 써서 `lock` 으로 지킨다 |
| `glyphCache`, `advanceCache` | 글자 → 글리프, 글리프 → 폭 | 폰트와 같이 |
| `reusableBuffer`, `outputBuffer` | buffer 와 결과 배열 | 스레드마다 하나씩 (`[ThreadStatic]`) |

buffer 를 스레드마다 따로 두는 것은 buffer 가 셰이핑하는 동안 안이 바뀌는 통이라서입니다. 워커 스레드 여럿이 동시에 셰이핑해도 서로의 통을 건드리지 않습니다.

폰트 캐시에서 폰트를 찾는 열쇠는 폰트 에셋이 아니라 **폰트 파일 바이트의 해시**(`FontDataHash`)입니다. 같은 TTF 를 가리키는 폰트 에셋이 여럿이어도 HarfBuzz 의 font 는 하나만 만들어 나눠 씁니다. 이모지 폰트만 상수 `-1` 을 열쇠로 씁니다.

앞의 다섯 순서가 코드에서는 이렇게 보입니다.

```csharp
if (itemLength == 0)
    return 빈 결과;

// 폰트 캐시는 모든 스레드가 같이 쓰므로 락을 잡는다
lock (fontCacheLock)
{
    if (!fontCache.TryGetValue(fontId, out fontEntry))
    {
        // 이 폰트로 처음이다. 폰트 바이트를 네이티브 힙에 복사하고 hb_font 를 만든다
        fontCache[fontId] = fontEntry = new FontCacheEntry(fontData);
    }
}

var buffer = AcquireBuffer();          // 스레드마다 하나. 처음만 만들고 다음부터는 비워서 다시 쓴다

// HarfBuzz 는 방향과 스크립트를 스스로 정하지 않는다. 런의 값을 적어 준다
ut_hb_buffer_set_direction(...);
ut_hb_buffer_set_script(...);
HB.SetFlags(buffer, BUFFER_FLAG_REMOVE_DEFAULT_IGNORABLES);

HB.AddCodepoints(buffer, context, itemOffset, itemLength);   // 문단 전체를 넣고 셰이핑할 구간을 알려 준다
ut_hb_shape(fontEntry.hbFont, buffer);                       // GSUB 와 GPOS 를 실행한다

var infos = HB.GetGlyphInfos(buffer);

// HarfBuzz 의 값은 폰트 설계 단위다. upem 으로 나눠 픽셀 배율을 만든다
var scale = fontSize * fontScaleMul / fontEntry.upem;

for (i < glyphCount)
    outBuf[i] = infos[i] 의 여섯 값 × scale;

return outBuf.AsSpan(0, glyphCount);   // 이 스레드의 출력 버퍼를 가리킨다. 호출한 쪽이 곧바로 복사한다
```

같은 폰트로 처음 셰이핑할 때와 두 번째부터가 갈리는 곳은 두 군데입니다.

| | 처음 | 두 번째부터 |
| --- | --- | --- |
| 폰트 캐시 | 락을 쥔 채 폰트 바이트를 복사하고 파일을 해석 | 락은 조회하는 동안만 |
| `hb_buffer` | 새로 만듦 | 내용만 비움. 네이티브 할당 없음 |

WebGL 플레이어에서는 이모지 런이 이 함수 앞머리에서 `WebGLEmojiShaper.Shape` 로 빠집니다.

락이 길게 잡히는 것은 새 폰트를 처음 쓰는 순간(폰트 바이트 복사와 파일 해석)뿐입니다. 작은 폴백 폰트 다섯 개 몫이 IL2CPP 에서 약 0.18 ms 였습니다.

### 이렇게 쓴다

네이티브 쪽 객체는 GC 가 치우지 않습니다. UniText 가 알아서 치워 주지 않는 경우가 둘 있습니다.

**런타임에 만든 폰트는 부수기 전에 캐시에서 지웁니다.** 셰이퍼는 폰트 에셋의 인스턴스 ID 로 폰트 해시를 찾는 표(`instanceIdToFontHash`)를 따로 둡니다. 폰트 에셋을 `Destroy` 해도 이 표의 항목은 남습니다. 유니티가 새 폰트 에셋에 같은 ID 를 다시 주면, 새 폰트가 옛 폰트의 데이터로 셰이핑됩니다. 자바 문자 폰트를 넣었는데 글자가 모두 0 번 글리프(빈 네모)로 나왔고, 폰트를 만들고 부수기를 되풀이하자 ID 가 겹친 일이 한 실행에 25 ~ 27건 있었습니다.

```csharp
Shaper.ClearCache(font.GetCachedInstanceId());   // 또는 font.ClearDynamicData()
Destroy(font);
```

**폰트 세트를 통째로 바꾸는 자리에서 캐시를 직접 비웁니다.** 캐시 전체를 없애는 `DisposeAll` 은 에디터에서만 등록됩니다. 빌드에서는 네이티브 메모리에 복사한 폰트 바이트가 프로그램이 끝날 때까지 남습니다. 폰트를 자주 바꿔 끼우면 쓰고 버린 폰트의 사본이 쌓입니다.

```csharp
// 언어를 바꿔 폰트 세트가 통째로 바뀌는 자리
Shaper.ClearAllCaches();
```

폰트 에셋을 여러 벌 만들 때는 같은 TTF 를 가리키게 합니다. 해시가 같아 네이티브 쪽 사본이 하나로 합쳐집니다.

## 목록 밖 스크립트는 스크립트 없이 셰이핑된다

buffer 에 문자 체계를 적을 때는 네 글자짜리 국제 표준 이름을 씁니다. 라틴은 `Latn`, 아랍은 `Arab`, 한글은 `Hang` 입니다. HarfBuzz 는 이 이름을 보고 그 문자 체계용 규칙을 고릅니다.

UniText 가 HarfBuzz 에 넘길 수 있는 이름은 스물일곱 개입니다. UniText 안에서 쓰는 문자 체계 값을 이 이름으로 바꾸는 `MapScript` 에 스물일곱 개만 적혀 있고, 목록에 없는 것은 전부 `Zyyy`(정해지지 않음)로 넘어갑니다. 그러면 HarfBuzz 는 그 문자 체계용 규칙을 쓰지 않습니다. Noto Sans Javanese 로 자바 문자 단어를 그려 HarfBuzz 로 뽑은 값과 견줬습니다.

| 단어 | UniText 와 같은 쪽 | 차이 |
|---|---|---|
| aksara jawa | `Zyyy` | `Java` 는 글리프 293 하나, UniText 는 112, 60 둘 |
| sugeng rawuh | `Zyyy` | `Java` 는 96 하나, UniText 는 94, 10 둘 |
| hanacaraka | 둘 다 | 없음 |

표의 숫자는 글리프 번호입니다. `Java` 라고 알려 주면 글자 둘이 글리프 하나로 합쳐져야 맞는데, UniText 에서는 합쳐지지 않은 둘이 나왔습니다. UniText 는 이 글자를 자바 문자로 제대로 판별하지만, 넘기는 단계에서 목록에 없어 `Zyyy` 가 갑니다.

### 이렇게 쓴다

글리프는 나오는데 합쳐지거나 모양이 바뀌어야 할 곳만 빠지기 때문에, 그 언어를 읽지 못하면 틀린 줄도 모릅니다. 지원할 언어의 문자 체계가 `MapScript` 의 목록에 있는지 먼저 확인합니다. 없는 문자 체계는 그 언어를 읽는 사람에게 화면을 확인받습니다.

## 정리

UniText 는 **셰이핑을 HarfBuzz 에 맡기고 C# 쪽에는 부르는 코드와 캐시만** 둡니다.

- 런 하나를 셰이핑해도 문단 전체를 넘겨 이웃 글자를 문맥으로 쓰고, `cluster` 가 문단 기준 절대 번호가 됩니다.
- 캐시는 폰트(프로세스, 데이터 해시 키), 글리프와 폭(폰트), 버퍼(스레드) 세 겹입니다. 부순 폰트의 ID 매핑은 직접 지워야 합니다.
- HarfBuzz 에 넘길 수 있는 문자 체계 이름은 스물일곱 개뿐이고, 목록 밖은 `Zyyy` 로 넘어가 그 문자 체계용 규칙을 받지 못합니다.

그래서 런타임에 만든 폰트는 부수기 전에 캐시에서 지우고, 지원할 언어의 문자 체계가 목록에 있는지 먼저 확인합니다.