UniText 는 셰이핑을 HarfBuzz 에 맡기고 캐시만 쥔다
Esc

    UniText 는 셰이핑을 HarfBuzz 에 맡기고 캐시만 쥔다

    2026년 10월 6일 수정–Unity라이브러리UIuGUI
    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).

    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 로 넘어가 그 문자 체계용 규칙을 받지 못합니다.

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