UniText 는 줄을 그리디로 끊고 폰트 크기를 폭으로 바꿔 푼다
label.FontSize = Mathf.Lerp(36, 72, t); // 매 프레임 크기가 바뀐다
크기가 바뀌면 글자 폭이 바뀌고, 폭이 바뀌면 줄이 바뀝니다. 크기마다 셰이핑부터 다시 하면 버튼이 커지는 연출 하나가 매 프레임 HarfBuzz 를 부릅니다.
UniText 는 줄을 되감기 지점 하나만 기억하는 그리디로 끊고, 폰트 크기 변경을 “상자를 줄이는 것” 으로 바꿔 같은 셰이핑 결과 위에서 줄만 다시 끊습니다. 기준은 1.0.0 브랜치의 커밋 2087eff 입니다.
줄나누기에 들어오는 재료는 둘이다
줄을 나누는 일은 둘로 갈립니다. “어디서 끊어도 되나” 와 “그중 어디서 끊을까” 입니다.
어디서 끊어도 되나 는 글자만 보고 정해집니다. 유니코드는 글자마다 줄바꿈 성질을 정해 두었고, 그 성질로 글자 사이마다 셋 중 하나를 매기는 규칙(UAX #14)을 둡니다.
| 판정 | 뜻 | 예 |
|---|---|---|
| 반드시 끊는다 | 여기서 줄이 바뀐다 | 줄바꿈 문자 뒤 |
| 끊어도 된다 | 폭이 모자라면 여기서 바꿔도 된다 | 공백 뒤, 한글과 한자의 글자 사이 |
| 끊으면 안 된다 | 여기서는 줄을 바꾸지 않는다 | 영어 단어의 글자 사이, 닫는 괄호 앞 |
이 판정은 상자 폭과 상관없어서 첫 패스가 미리 해 둡니다. 글자마다의 폭도 셰이핑 결과에서 뽑아 cpWidths 배열에 모아 둡니다.
그래서 줄나누기에 들어오는 것은 글자 사이마다의 판정과 글자마다의 폭, 둘입니다. 줄나누기는 이 둘과 상자 폭으로 그중 어디서 끊을까 만 정합니다. HarfBuzz 를 부르지 않습니다.
되감기 지점 하나만 기억하고 끊는다
LineBreaker.WrapLines 는 앞에서부터 글자 폭을 더하다가 상자 폭을 넘치면 마지막으로 본 끊어도 되는 자리로 되감습니다. 되감을 곳이 없으면 지금 글자 앞에서 강제로 자르고, 한 글자도 안 들어가면 넘친 채 둡니다. 들어갈 수 있는 데까지 채우고 끊는 이 방식을 그리디라고 부릅니다.
for (cp = 0; cp < cpCount; cp++)
{
lineWidth += cpWidths[cp]; // 이 글자의 폭을 더한다
while (lineWidth > maxWidth) // 넘쳤다
{
if (lastBreak >= lineStart)
{
// 기억해 둔 끊어도 되는 자리로 되감아 줄을 만든다
// 줄 = [lineStart..lastBreak]
lineStart = lastBreak + 1;
lineWidth -= widthAtLastBreak; // 남은 폭을 다음 줄로 넘긴다
lastBreak = -1;
}
else if (cp > lineStart)
{
// 되감을 곳이 없다. 지금 글자 앞에서 강제로 자른다
// 줄 = [lineStart..cp-1]
lineStart = cp;
lineWidth = cpWidths[cp];
}
else
{
break; // 한 글자도 안 들어간다. 넘친 채 둔다
}
}
if (breakAfter(cp) == Mandatory)
{
// 줄바꿈 문자다. 줄 = [lineStart..cp]
lineWidth = 0;
}
else if (breakAfter(cp) == Optional)
{
lastBreak = cp; // 끊어도 되는 자리와
widthAtLastBreak = lineWidth; // 그때까지의 폭을 기억한다
}
}
글자 폭이 모두 1 이고 상자 폭이 4 라고 하면 이렇게 끊깁니다.
ab cd. 공백(2번) 뒤가 끊어도 되는 자리라 기억해 둡니다.c까지 폭이 4 라 딱 맞고,d에서 5 가 되어 넘칩니다. 공백 뒤로 되감아 첫 줄이ab가 되고,cd는 다음 줄로 갑니다.abcdef. 끊어도 되는 자리가 없습니다.e에서 넘치면 되감을 곳이 없어e앞에서 강제로 자릅니다. 첫 줄이abcd가 되고 단어 중간에서 잘립니다.
기억하는 되감기 자리는 늘 하나입니다. 줄이 만들어지면 지우고 다음 끊어도 되는 자리를 기다립니다.
줄마다 채울 수 있는 만큼 채우므로 줄 길이가 들쭉날쭉할 수 있습니다. 문단 전체를 보고 줄 길이를 고르게 맞추는 방식도 있지만(조판 프로그램이 쓰는 Knuth-Plass), UniText 는 한 번 훑고 끝나고 할당이 없는 쪽을 골랐습니다. 비용이 숨은 곳이 하나 있습니다. 줄을 만들 때 런의 글리프를 처음부터 훑어 그 줄의 글리프를 찾아서, 런 하나가 여러 줄에 걸치면 줄 수만큼 다시 훑습니다. 2,000자를 줄나누기 한 번 한 시간입니다.
| 문자열 | 런 수 | 줄 수 | 줄나누기 한 번 |
|---|---|---|---|
| 라틴만 | 1 | 20 | 0.143 ms |
| 라틴만 | 1 | 91 | 0.617 ms |
| 한국어와 라틴 번갈아 | 345 | 104 | 0.054 ms |
단일 런은 줄당 0.007 ms 로 시간이 줄 수에 정비례하고, 잘게 쪼개진 문자열은 줄이 늘어도 거의 늘지 않았습니다.
이렇게 쓴다
넘치는 글은 직접 처리합니다. 줄나누기는 한 글자도 안 들어가는 줄을 넘친 채 두고, 배치는 상자 밖으로 나간 줄도 그대로 그립니다. 번역으로 문장이 길어진 언어에서 글자가 버튼 밖으로 삐져나옵니다. 잘라야 하는 자리에는 말줄임을 넣는 EllipsisModifier 를 등록합니다. 크기를 줄여 맞출 자리에는 AutoSize 를 쓰되 말줄임을 같이 둡니다.
공백 없이 쓰는 언어에는 끊을 자리를 넣어 줍니다. 태국어, 라오어, 크메르어, 미얀마어는 단어 사이에 공백이 없습니다. 규칙은 이런 글을 사전으로 단어를 찾아 끊으라고 정하지만, 이 판에는 사전도 사전을 읽는 코드도 없습니다. 공백 없는 태국어 문장을 폭 300 에 넣자 네 줄 가운데 첫 줄 말고는 모두 단어 중간에서 시작했습니다. 번역 문자열의 단어 경계마다 폭 없는 공백(U+200B)을 넣으면 네 줄 모두 단어 경계에서 시작했습니다.
폰트 크기는 폭으로 바꿔 푼다
cpWidths 에 든 폭은 첫 패스에서 셰이핑한 크기 기준입니다. 폰트 크기가 두 배가 되면 글자 폭도 두 배가 됩니다. 이때 글자 폭을 전부 다시 구하는 대신 상자 폭을 반으로 줄여도 줄이 끊기는 자리는 같습니다. “글자가 두 배로 커졌다” 와 “상자가 반으로 줄었다” 는 줄나누기에게 같은 문제이기 때문입니다.
UniText 는 이렇게 풉니다. cpWidths 는 그대로 두고, 상자 폭을 목표 크기 / 셰이핑 크기 로 나눠 줄만 다시 끊습니다.
그래서 FontSize 는 전체 재구성이 아니라 레이아웃 재구성 플래그에 속하고, 크기를 매 프레임 바꿔도 HarfBuzz 호출은 0번입니다. 결과도 같습니다. 크기 72 로 셰이핑한 뒤 12 로 바꾼 컴포넌트와 처음부터 12 인 컴포넌트는 글리프 x 좌표 차가 최대 1.9e-6 이고 결과 크기(322.92 x 16.34)가 같았습니다. 대가가 하나 있습니다. 일부 폰트는 작은 크기에서 자간을 넓히는 것처럼 크기에 따라 글리프나 간격을 다르게 고릅니다(광학 크기). 셰이핑을 한 번만 하니 이런 기능은 반영되지 않습니다.
이렇게 쓴다
AutoSize 와 줄바꿈을 함께 켠 텍스트는 매 프레임 바꾸지 않습니다. 넘치면 맞는 크기를 찾느라 줄나누기를 최대 열 번 돌립니다. 300자 문단 한 번이 IL2CPP 에서 0.176 ms 였습니다.
- 타이머처럼 자주 바뀌는 숫자는 크기를 고정합니다.
AutoSize가 필요하면 최소와 최대 사이를 좁힙니다. 최대를 72 에서 40 으로 좁히자 열 번이 여덟 번, 0.108 ms 가 됐습니다.- 한 줄이면 되는 텍스트는 줄바꿈을 끕니다. 크기를 계산 한 번으로 구합니다.
정렬은 문단의 시작과 끝을 기준으로 한다
줄이 정해지면 배치 단계(TextLayout.Layout)가 줄마다 글리프의 좌표를 매깁니다. 가로로 어디서 시작할지는 정렬 값과 그 줄이 속한 문단의 방향(paragraphBaseLevel & 1)으로 정합니다.
Left 와 Right 는 물리적 좌우가 아니라 문단의 시작과 끝입니다. 패키지의 샘플 씬에서도 정렬이 Left 인 아랍어 문장이 오른쪽 여백 0 px 로 오른쪽에 붙어 그려졌습니다. 문장의 첫 글자가 아랍 문자라 문단 방향이 오른쪽에서 왼쪽이 됐기 때문입니다.
이렇게 쓴다
정렬 값은 “문단의 시작 쪽”, “문단의 끝 쪽” 으로 읽습니다.
- 언어를 바꿀 때 정렬을 손으로 뒤집지 않습니다. 아랍어 UI 를 좌우로 뒤집으면서 정렬도 뒤집으면 두 번 뒤집혀 원래대로 돌아갑니다.
- 반드시 왼쪽에 붙어야 하는 숫자나 코드는
BaseDirection을 왼쪽에서 오른쪽으로 고정합니다.Auto로 두면 첫 글자에 따라 붙는 쪽이 바뀝니다.
정리
UniText 는 줄을 그리디로 끊고, 폰트 크기 변경을 폭 변경으로 바꿔 셰이핑 없이 풉니다.
WrapLines는 되감기 지점 하나만 기억합니다. 한 번 훑고 할당이 없지만, 긴 단일 런은 줄마다 글리프를 다시 훑습니다.- 크기 변경은
폭 / (목표 크기 / 셰이핑 크기)로 줄만 다시 끊고 HarfBuzz 를 부르지 않습니다. - 정렬은 문단 방향 기준이라 오른쪽에서 왼쪽 줄의
Left는 오른쪽에 붙습니다. 공백 없는 태국어 계열은 사전 없이 아무 데서나 끊깁니다.
그래서 넘치는 글에는 말줄임을 등록하고, 공백 없는 언어에는 폭 없는 공백을 넣고, 정렬은 언어를 바꿔도 손으로 뒤집지 않습니다.