UniText 는 폭을 모르는 일을 한 번만 하고 바뀐 층만 다시 돈다
Esc

    UniText 는 폭을 모르는 일을 한 번만 하고 바뀐 층만 다시 돈다

    2026년 10월 6일 수정–Unity라이브러리UIuGUI
    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 한 곳에 모으고, 끝나면 플래그 하나로 “여기까지는 끝났다” 고 표시해 둡니다.

    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 가 바뀐 것에 맞춰 가장 센 플래그 하나를 골라 그 층부터 아래로만 버립니다.

    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[] ...) 는 비교하지 않고 텍스트가 바뀌었다고 표시합니다. 가장 센 표시라 태그 파싱부터 메시까지 전부 다시 돕니다.

    // 매 프레임 전부 다시 돈다. 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 의 파일 이름이나 붙여넣은 글에서 가끔만 생겨 원인을 찾기 어렵습니다.

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

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

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

    정리

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

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

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