UniText 는 폭을 모르는 일을 한 번만 하고 바뀐 층만 다시 돈다
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 로 다시 넣지 않고, 바깥에서 온 문자열은 정규화해서 넣습니다.