UniText 는 이모지 폰트를 빌드에 넣지 않고 OS 에서 빌려 쓴다
Esc

    UniText 는 이모지 폰트를 빌드에 넣지 않고 OS 에서 빌려 쓴다

    2026년 10월 6일 수정–Unity라이브러리UIuGUI
    uniText.Text = "좋아요 \U0001F600";   // 패키지에 이모지 폰트가 없는데 컬러 그림이 나온다

    UniText 패키지에 든 폰트는 라틴, 아랍어, 히브리어뿐입니다. 컬러 이모지 폰트는 하나가 10 MB 를 넘기 때문입니다.

    UniText 는 이모지 폰트를 빌드에 넣지 않고, 실행 중에 그 기기의 OS 가 가진 이모지 폰트를 찾아 그립니다. Windows 는 Segoe UI Emoji, macOS 는 Apple Color Emoji, 안드로이드는 그 기기의 폰트입니다. 빌드 크기를 아끼는 대신 플랫폼마다 그림이 다르고, 실행 중 메모리를 씁니다. 기준은 1.0.0 브랜치의 커밋 2087eff 입니다.

    이모지가 보통 글자와 다른 점 셋

    이모지를 그리는 일이 따로 떼어 다뤄지는 이유는 셋입니다.

    글자 번호 여러 개가 그림 하나입니다. 가족 이모지 👨‍👩‍👧‍👦 는 사람 넷과 그 사이를 잇는 보이지 않는 글자(ZWJ, U+200D) 셋, 모두 일곱 개의 글자 번호로 적습니다. 국기는 지역 문자 둘, 피부색은 사람 뒤에 색 글자 하나가 붙습니다. 이렇게 화면에서 한 글자로 보이는 묶음을 그래핌 클러스터라고 부르고, 이모지인지는 묶음 전체를 보고 판정해야 합니다.

    색이 있습니다. 보통 글리프는 윤곽선 하나라 거리값(SDF)으로 굽고 색은 셰이더가 칠합니다. 이모지는 그림 자체에 색이 있어서 이 방식을 쓸 수 없고, 색을 담는 아틀라스가 따로 필요합니다.

    폰트 안에 그림을 넣는 방식이 여럿입니다. 표준이 하나로 모이지 못했습니다.

    방식 폰트 안에 든 것 쓰는 곳
    비트맵 (CBDT, sbix) 정해진 크기로 미리 그려 둔 PNG 그림 Android 의 Noto Color Emoji, Apple Color Emoji
    벡터 (COLR) 색마다 나눈 윤곽선 여러 겹과 색 정보 Windows 의 Segoe UI Emoji, 새 Noto Color Emoji

    비트맵 방식은 그림을 꺼내 오면 끝입니다. 벡터 방식은 윤곽선마다 색이나 그라디언트를 채워 직접 그려야 합니다. COLR 의 새 판(COLRv1)은 “이 윤곽선을 이 그라디언트로 칠하고, 그 위에 이것을 돌려서 얹는다” 는 식의 그리기 지시가 나무 모양으로 들어 있습니다.

    이모지는 폰트를 찾기 전에 -1 로 표시된다

    셰이핑하기 전에 텍스트를 폰트가 같은 구간(런)으로 잘라야 하고, 그러려면 글자마다 어느 폰트로 그릴지 정해야 합니다. 보통 글자는 폰트 스택을 앞에서부터 뒤져 그 글자가 든 폰트를 찾습니다.

    이모지는 이 과정을 건너뜁니다. 이모지로 판정된 묶음은 폰트를 찾아보지 않고 “이모지 폰트” 를 뜻하는 상수 -1 을 받습니다. 실제 OS 폰트로 바뀌는 것은 나중에 한 번입니다. 폰트 스택에 이모지 폰트가 없어도 되는 이유입니다.

    var len = end - start;
    
    if (len == 1)
    {
        // U+2000 미만은 이모지일 수 없다. 검사 없이 폰트 스택으로 간다
        if ((uint)cp < 0x2000)
            return fp.FindFontForCodepoint(cp);
    
        // 코드포인트 하나짜리 이모지면 폰트를 찾지 않고 -1 을 준다
        if (EmojiFont.IsAvailable && IsSingleCodepointEmoji(cp))
            return EmojiFont.FontId;
    
        return fp.FindFontForCodepoint(cp);
    }
    
    // 코드포인트 여러 개가 한 덩어리면 시퀀스 전체로 판정한다
    if (EmojiFont.IsAvailable && EmojiSequenceClassifier.IsEmojiCluster(cluster))
        return EmojiFont.FontId;
    
    return fp.FindFontForCodepoint(cluster[0]);
    클러스터 지나는 길 받는 폰트 ID
    A (U+0041) U+2000 미만이라 첫 분기에서 끝 라틴 폰트의 해시
    가 (U+AC00) 문턱보다 커서 이모지인지 한 번 묻고, 아님 한글 폰트의 해시
    웃는 얼굴 (U+1F600) 코드포인트 하나짜리 이모지로 판정 -1
    가족 (코드포인트 7개) 시퀀스 판정기가 이모지로 판정 -1
    국기 문자 하나 (U+1F1F0) 둘이 짝을 지어야 국기라 이모지가 아님 일반 폰트 조회

    번호가 U+2000 보다 작은 글자(라틴, 키릴, 아랍, 히브리)는 이모지일 수 없어서 비교 한 번으로 빠져나갑니다. 가족 이모지처럼 여러 번호가 이어진 것은 먼저 한 묶음으로 묶인 뒤 묶음 전체로 판정되고, 국기 문자는 둘씩 짝지어집니다.

    안드로이드는 네 단계로 고르고 하나만 검증한다

    -1 을 실제 폰트로 바꾸려면 그 기기에서 이모지 폰트 파일을 찾아야 합니다. 플랫폼마다 길이 다릅니다.

    플랫폼 이모지 폰트를 얻는 길
    Windows, macOS, Linux 정해진 경로의 폰트 파일을 FreeType 으로 연다
    Android 아래 네 단계로 파일을 찾아 FreeType 으로 연다
    iOS 파일을 열지 못한다. OS 의 글자 그리기 API(Core Text)에 그려 달라고 한다
    WebGL 파일 시스템이 없다. 브라우저의 캔버스에 그려 달라고 한다

    iOS 가 다른 이유는 둘입니다. 앱이 시스템 폰트 파일을 직접 읽지 못하게 막혀 있고, Apple 의 이모지 폰트는 FreeType 이 풀지 못하는 방식으로 압축돼 있습니다. 그래서 글리프를 고르는 것부터 그리는 것까지 OS 에 맡기고 결과 그림만 받아 옵니다. WebGL 도 같은 이유로 브라우저에 맡기고, HarfBuzz 셰이핑도 건너뜁니다.

    안드로이드는 제조사마다 폰트와 경로가 달라 네 단계를 차례로 내려갑니다.

    1. OS 의 폰트 설정 파일(fonts.xml)에서 이모지용으로 표시된 폰트(und-Zsye)를 찾습니다.
    2. API 29 이상이면 OS 에 폰트 목록을 물어 삼성, 화웨이, Noto 순으로 고릅니다.
    3. 알려진 제조사 경로를 하나씩 확인합니다.
    4. 폰트 폴더를 뒤집니다.

    그리고 고른 파일을 FreeType 으로 열어 색이 있는 폰트인지, 웃는 얼굴(U+1F600) 글리프가 있는지 확인합니다.

    // 1. OS 폰트 설정 파일의 und-Zsye 패밀리
    var xml = ParseAndroidFontsXml();
    if (xml != null)
        return xml;
    
    // 2. SystemFonts 열거. API 29 미만이면 null
    var api = GetEmojiFromSystemFontsApi();
    if (api != null)
        return api;
    
    // 3. 하드코딩한 제조사 경로
    foreach (var p in vendorPaths)
    {
        if (File.Exists(p))
            return p;
    }
    
    // 4. 폰트 폴더 스캔
    return Scan("/system/fonts/") ?? Scan("/product/fonts/");
    
    // GetDefaultEmojiFont: 돌려받은 경로 하나만 검증한다
    if (!ValidateEmojiFont(path))
        return null;                       // 실패해도 차순위로 돌아가지 않는다
    
    return path;
    기기 경로를 찾은 단계 검증 결과
    설정 파일에 und-Zsye 가 있음 1 통과 NotoColorEmoji.ttf
    API 28, 설정 파일에 없음 3 통과 NotoColorEmoji.ttf
    2 에서 고른 제조사 파일에 컬러 플래그가 없음 2 실패 이모지 폰트 없음

    네 단계는 후보의 순위를 매길 뿐이고, 검증은 마지막에 한 번만 합니다.

    검증은 최종 후보 하나에만 걸리고, 실패하면 다음 후보로 돌아가지 않습니다. 같은 기기에 멀쩡한 Noto 폰트가 있어도 이모지가 꺼집니다.

    이렇게 쓴다

    안드로이드에서 이모지가 통째로 빠지면 기기 로그에서 아래 줄을 찾습니다. 함께 찍힌 경로가 검증에 실패한 파일입니다.

    [SystemEmojiFont] Font validation failed: ...

    앱 쪽에서 경로를 바로잡을 공개 API 는 없습니다. 꼭 보여야 하는 자리는 스프라이트로 그리고, 나머지는 EmojiFont.Disabled = true 로 이모지 처리를 끄고 일반 폰트 스택에 맡깁니다.

    어느 기기에서나 같은 그림이 필요한 자리에도 시스템 이모지를 쓰지 않습니다. OS 와 제조사마다, OS 를 올릴 때마다 그림이 달라집니다. EmojiFont.CreateFromPath 로 폰트를 직접 만들어도 -1 은 언제나 시스템 폰트로 바뀌어서, 직접 만든 폰트는 쓰이지 않았습니다. 그림이 고정돼야 하면 스프라이트로 그립니다.

    비트맵이 있으면 비트맵, 없을 때만 COLRv1 로 그린다

    폰트를 열었으면 글리프를 그림으로 만들어야 합니다. 앞에서 본 두 방식에 따라 길이 갈립니다.

    • 폰트에 비트맵이 들어 있으면 FreeType 으로 가장 가까운 크기의 그림을 꺼내 옵니다.
    • 벡터뿐이면 그리기 지시를 하나씩 따라가며 직접 칠합니다. FreeType 은 지시를 읽어 주기만 하고 칠하지는 않아서, 칠하는 일은 2D 벡터 그리기 라이브러리 Blend2D 가 합니다. UniText 의 네이티브 바이너리에 Blend2D 가 함께 묶여 있는 이유입니다.

    세 폰트에 모두 있는 이모지 200개를 구운 값입니다.

    폰트 (경로) 렌더 패킹 합계 아틀라스 장 수
    Noto Color Emoji COLRv1 판 11.8 ms 15.1 ms 39.8 ms 2
    Noto Color Emoji CBDT 판 (비트맵) 10.4 ms 2.0 ms 25.1 ms 1
    Apple Color Emoji (sbix 비트맵) 10.3 ms 3.1 ms 36.5 ms 1

    그리는 시간은 벡터 쪽이 글리프당 15% 안팎 비쌀 뿐이고, 합계를 벌린 것은 아틀라스 두 장에 걸쳐 올린 시간입니다.

    아틀라스는 색을 담아야 하니 거리값 1바이트가 아니라 픽셀당 4바이트(RGBA32)입니다. 2048 × 2048 한 장이 16 MB 입니다. 굽는 크기는 128 이고, 비트맵 폰트는 폰트에 든 크기 가운데 가장 가까운 것을 씁니다(Apple Color Emoji 는 126). 서로 다른 이모지 300개가 한 장에 들어갔습니다.

    같은 폰트가 셰이퍼 캐시에 두 번 들어간다

    HarfBuzz 로 셰이핑하려면 폰트 파일 바이트를 네이티브 메모리에 통째로 복사해 두어야 하고, UniText 는 그 사본을 캐시에 넣어 둡니다. 이모지 폰트는 이 캐시에 두 번 들어갑니다. 셰이핑하는 코드는 -1 이라는 열쇠로 찾고, 글리프 폭을 묻는 코드는 폰트 해시(이모지 폰트는 파일 길이)로 찾기 때문입니다. 열쇠가 다르니 같은 폰트인 줄 모르고 사본을 두 벌 만듭니다.

    이모지 없는 문장에서 1 이던 캐시 항목이 이모지 문장 뒤 3 이 됐고, 늘어난 열쇠는 -1 과 192123488(Apple Color Emoji 의 파일 길이)이었습니다. macOS 에서 프로세스 메모리는 183 MB 인 폰트의 약 두 배, 386 ~ 399 MB 늘었습니다.

    이렇게 쓴다

    이모지를 처음 쓰는 순간의 메모리를 예산에 넣습니다. 폰트 사본 두 벌에 아틀라스 한 장 16 MB 가 더해집니다. 이모지 선택 창처럼 많이 보여 주는 화면은 장이 늘고, 구운 글리프는 그대로 남습니다.

    • 그런 화면은 열기 전후로 프로파일러의 텍스처 메모리와 프로세스 메모리를 확인합니다.
    • 폰트 사본은 Shaper.ClearAllCaches() 로 비울 수 있습니다. 이모지를 다시 셰이핑하면 다시 만들어집니다.

    빌드에서는 셰이더도 확인합니다. 이모지 머티리얼은 실행 중에 Shader.Find("UI/Default") 로 셰이더를 이름으로 찾습니다. Project Settings 의 Graphics 에 있는 Always Included Shaders 에서 UI/Default 가 빠지면 머티리얼을 만들다 ArgumentNullException 이 나고, 그 예외가 Text 세터를 끊어 이모지가 아닌 글자까지 텍스트 전체가 사라졌습니다. 로그에 Parameter name: shader 가 찍히고 텍스트가 통째로 사라지면 이 목록부터 봅니다.

    정리

    UniText 는 이모지 폰트를 빌드에 넣지 않고 OS 에서 빌려 씁니다.

    • 이모지로 판정된 묶음은 폰트를 찾지 않고 -1 을 받고, 나중에 OS 의 이모지 폰트로 바뀝니다.
    • 안드로이드는 네 단계로 후보를 고르고 하나만 검증합니다. iOS 와 WebGL 은 폰트 파일을 열지 못해 OS 와 브라우저에 그려 달라고 합니다.
    • 폰트에 비트맵이 있으면 꺼내 쓰고, 벡터뿐일 때만 Blend2D 로 직접 칠합니다.
    • 대가는 플랫폼마다 다른 그림, 한 장 16 MB 인 아틀라스, 네이티브 메모리의 폰트 사본 두 벌, 이름으로 찾는 UI/Default 셰이더입니다.

    그래서 같은 그림이 필요한 자리는 스프라이트로 그리고, 이모지를 많이 쓰는 화면은 메모리를 재 보고, 빌드에 UI/Default 셰이더가 들어가는지 확인합니다.