UniText 는 셰이핑을 HarfBuzz 에 맡기고 캐시만 쥔다
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 | 글자를 넣고 글리프를 받는 통 | 셰이핑할 때마다 씀 |
부르는 순서는 이렇습니다.
- 폰트 파일 바이트를 네이티브 메모리로 복사합니다. HarfBuzz 가 그 주소를 폰트를 쓰는 내내 붙들고 있어서, GC 가 옮길 수 있는 C# 배열을 그대로 넘길 수 없습니다.
- 그 주소로 blob, face, font 를 차례로 만듭니다. 여기서 폰트 파일을 해석합니다.
- buffer 에 글자 번호들을 넣고, 방향과 문자 체계를 적습니다. HarfBuzz 는 이 둘을 스스로 정하지 않습니다.
shape(font, buffer)를 부릅니다. HarfBuzz 가 폰트의 규칙 표를 실행해 buffer 안의 글자를 글리프로 바꿉니다.- buffer 에서 글리프 번호, 이동량,
cluster를 읽어 C# 배열로 옮깁니다.
1번과 2번은 비싸고 폰트마다 한 번이면 됩니다. 3번부터 5번은 런마다 합니다. 이 차이가 뒤에 나오는 캐시의 모양을 정합니다.
런 하나를 셰이핑할 때도 문단 전체를 넘긴다
셰이핑은 런 단위로 부릅니다. 런은 방향, 문자 체계, 폰트가 모두 같은 구간이고, 셋 중 하나만 바뀌어도 끊깁니다. 한 단어 안에서 폰트가 바뀌면 단어 중간에서도 끊깁니다.
그런데 아랍어 글자는 앞뒤 글자와 이어지는지에 따라 모양이 달라집니다. 런만 잘라서 넘기면 HarfBuzz 는 런의 첫 글자 앞에 무엇이 있는지 모릅니다. 그래서 buffer 에는 문단 전체를 넣고, 셰이핑할 구간만 시작 위치와 길이로 알려 줍니다(hb_buffer_add_codepoints 의 item_offset, item_length).
cluster 는 글리프마다 붙는 번호로, 그 글리프가 원문의 몇 번째 글자에서 나왔는지를 가리킵니다. 문단 전체를 넣었으니 이 번호가 곧 문단 기준 번호입니다. 런마다 시작 위치를 더할 필요 없이 줄나누기와 클릭 위치 판정이 이 번호 하나로 원래 글자까지 돌아갑니다.
캐시는 수명이 다른 세 겹이다
앞에서 본 대로 blob, face, font 는 폰트마다 한 번 만들면 되고 buffer 는 호출마다 씁니다. UniText 는 이 차이를 그대로 캐시로 옮겼습니다.
| 층 | 무엇 | 수명 |
|---|---|---|
fontCache |
폰트마다 네이티브 메모리의 폰트 사본과 blob, face, font | 프로그램이 끝날 때까지. 여러 스레드가 같이 써서 lock 으로 지킨다 |
glyphCache, advanceCache |
글자 → 글리프, 글리프 → 폭 | 폰트와 같이 |
reusableBuffer, outputBuffer |
buffer 와 결과 배열 | 스레드마다 하나씩 ([ThreadStatic]) |
buffer 를 스레드마다 따로 두는 것은 buffer 가 셰이핑하는 동안 안이 바뀌는 통이라서입니다. 워커 스레드 여럿이 동시에 셰이핑해도 서로의 통을 건드리지 않습니다.
폰트 캐시에서 폰트를 찾는 열쇠는 폰트 에셋이 아니라 폰트 파일 바이트의 해시(FontDataHash)입니다. 같은 TTF 를 가리키는 폰트 에셋이 여럿이어도 HarfBuzz 의 font 는 하나만 만들어 나눠 씁니다. 이모지 폰트만 상수 -1 을 열쇠로 씁니다.
앞의 다섯 순서가 코드에서는 이렇게 보입니다.
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건 있었습니다.
Shaper.ClearCache(font.GetCachedInstanceId()); // 또는 font.ClearDynamicData()
Destroy(font);
폰트 세트를 통째로 바꾸는 자리에서 캐시를 직접 비웁니다. 캐시 전체를 없애는 DisposeAll 은 에디터에서만 등록됩니다. 빌드에서는 네이티브 메모리에 복사한 폰트 바이트가 프로그램이 끝날 때까지 남습니다. 폰트를 자주 바꿔 끼우면 쓰고 버린 폰트의 사본이 쌓입니다.
// 언어를 바꿔 폰트 세트가 통째로 바뀌는 자리
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로 넘어가 그 문자 체계용 규칙을 받지 못합니다.
그래서 런타임에 만든 폰트는 부수기 전에 캐시에서 지우고, 지원할 언어의 문자 체계가 목록에 있는지 먼저 확인합니다.