UniText 는 글리프를 처음 쓰일 때 SDF 로 굽는다
// Runtime/FontCore/UniTextFont.cs (클래스 주석)
/// Glyphs are rendered to the atlas at runtime when first needed.
셰이핑이 끝나면 손에 남는 것은 “몇 번 글리프를 어디에” 라는 숫자뿐입니다. 화면에 그리려면 그 글리프의 그림이 텍스처에 있어야 합니다.
UniText 의 폰트 에셋에는 그 그림이 들어 있지 않습니다. 저장되는 것은 폰트 파일의 바이트와 줄 높이 같은 수치뿐입니다. 처음 보는 대사가 뜨면 그 글자들의 그림을 그 프레임에 만들어야 합니다.
UniText 는 글리프를 처음 쓰일 때 거리값(SDF)으로 구워 텍스처에 쌓고, 차면 새 텍스처를 엽니다. 굽는 일은 비싼 가운데 단계만 워커 스레드에서 돌 수 있게 세 단계로 나눴습니다. 기준은 1.0.0 브랜치의 커밋 2087eff 입니다.
글리프 하나가 텍스처에 들어가기까지
먼저 말 셋을 정리합니다.
- 아틀라스 는 글리프 그림 여러 장을 모아 담은 텍스처 한 장입니다. 글자마다 텍스처를 따로 두면 글자 수만큼 드로우 콜이 나오니, 한 장에 모아 두고 글자마다 그 안의 자기 칸만 가리킵니다.
- 래스터화 는 윤곽선을 픽셀로 바꾸는 일입니다. 폰트 파일 안의 글리프는 픽셀이 아니라 곡선의 좌표로 들어 있어서, 원하는 크기의 픽셀 그림으로 바꿔야 합니다.
- 굽는다 는 글리프를 래스터화해 아틀라스에 넣는 일 전체를 가리킵니다.
글리프 하나를 굽는 과정은 이렇습니다. 가운데 세 줄이 네이티브 라이브러리 FreeType 이 하는 일입니다.
| 순서 | 하는 일 | 누가 |
|---|---|---|
| 1 | 이 글리프가 이미 아틀라스에 있는지 확인한다. 있으면 끝 | C# |
| 2 | 폰트 파일에서 글리프의 윤곽선을 읽는다 | FreeType |
| 3 | 윤곽선을 굽는 크기의 픽셀 그림으로 바꾼다 | FreeType |
| 4 | 픽셀 그림을 거리값 그림으로 바꾼다 | 네이티브 |
| 5 | 아틀라스에서 빈자리를 찾아 그림을 복사한다 | C# |
| 6 | 바뀐 텍스처를 GPU 로 올린다 | C# (유니티 API) |
2번에서 4번이 시간의 대부분을 씁니다. 이 순서를 알고 있으면 뒤의 절들이 어디를 다루는지 보입니다.
픽셀 대신 윤곽까지의 거리를 굽는다
4번에서 픽셀 그림을 왜 다시 바꾸는지부터 봅니다.
같은 폰트로 작은 본문과 큰 제목을 같이 씁니다. 3번의 픽셀 그림을 그대로 아틀라스에 넣으면 길이 둘뿐입니다. 크기마다 따로 굽거나(메모리가 듭니다), 한 크기로 구운 것을 늘려 쓰거나(경계가 뭉개집니다).
SDF(Signed Distance Field)는 픽셀에 “칠한다, 안 칠한다” 대신 그 픽셀에서 글자 윤곽선까지의 거리를 적습니다. 윤곽선 위가 0.5, 글자 안쪽으로 갈수록 1, 바깥으로 갈수록 0 에 가까워집니다. 셰이더는 값이 0.5 보다 큰 곳만 칠합니다.
이렇게 하면 텍스처를 늘려도 경계가 날카롭습니다. 색을 늘리면 중간색이 생겨 흐려지지만, 거리를 늘리면 중간 거리가 나올 뿐이고 그 값도 여전히 맞는 거리이기 때문입니다. 같은 텍스처로 효과도 얻습니다.
| 셰이더가 하는 일 | 얻는 것 |
|---|---|
| 0.5 대신 0.4 에서 자른다 | 굵은 글자 |
| 0.4 와 0.5 사이를 다른 색으로 칠한다 | 외곽선 |
| 옆으로 어긋난 자리에서 한 번 더 읽어 칠한다 | 그림자 |
거리값 하나만 담으면 되니 텍스처는 픽셀당 1바이트(Alpha8)입니다. 1024 × 1024 한 장이 1 MB 입니다.
거리는 윤곽선 바깥으로도 적어야 외곽선과 그림자를 그릴 수 있습니다. 그래서 글리프마다 둘레에 여백을 붙여 굽습니다. 이 여백의 비율이 폰트 에셋의 Spread 설정입니다. 기본값은 굽는 크기 90, Spread 0.25 이고, 여백은 22 픽셀입니다.
대가는 날카로운 모서리가 조금 둥글어지는 것입니다. 이를 푸는 방식(MSDF)은 무료판에 없습니다.
굽기는 가운데만 병렬로 도는 세 단계다
앞의 여섯 순서를 UniText 는 메서드 셋으로 묶었습니다.
| 메서드 | 앞의 순서 | 도는 곳 |
|---|---|---|
PrepareGlyphBatch |
1. 이미 있는 글리프를 거른다 | 메인 스레드 |
RenderPreparedBatch |
2 ~ 4. 윤곽선을 읽고, 픽셀로 바꾸고, 거리값으로 바꾼다 | 아무 스레드 |
PackRenderedBatch |
5 ~ 6. 자리를 잡아 복사하고 GPU 로 올린다 | 메인 스레드 |
앞뒤 둘은 유니티의 텍스처와 사전을 만지므로 메인 스레드에서만 돌 수 있습니다. 가운데는 네이티브 호출만 하고 결과를 바이트 배열로 돌려줄 뿐이라 어느 스레드에서 돌아도 됩니다. 시간의 대부분이 여기 있어서 이 단계만 떼어 낸 것입니다.
여러 스레드가 같은 폰트를 동시에 구우려면 하나 더 필요합니다. FreeType 에서 폰트 하나를 연 객체(FT_Face)는 “지금 작업 중인 글리프” 를 담는 칸이 하나뿐입니다. 글리프를 읽으면 그 칸에 들어가고, 다음 글리프를 읽으면 덮어씁니다. 두 스레드가 같은 객체로 동시에 구우면 서로의 글리프를 덮어씁니다. 그래서 FreeTypeFacePool 이 같은 폰트를 스레드 수만큼 따로 열어 두고 하나씩 빌려줍니다.
한 폰트 안에서는 글리프가 16개 이상일 때만 워커로 나눕니다. 처음 보는 한글 N 자를 Noto Sans KR 빈 아틀라스에 구운 시간입니다(IL2CPP, 5번 중앙값).
| 글자 수 | 준비 | 렌더(SDF) | 패킹 | 합계 |
|---|---|---|---|---|
| 10 | 0.45 ms | 19.2 ms | 0.38 ms | 20.4 ms |
| 40 | 0.39 ms | 14.4 ms | 1.32 ms | 16.1 ms |
| 200 | 0.34 ms | 62.1 ms | 3.50 ms | 66.3 ms |
16개 문턱 아래인 10자는 한 스레드에서 차례로 구워서 40자보다 합계가 큽니다. 같은 글자를 다시 부르면 준비 단계에서 걸러져 0.004 ~ 0.015 ms 로 끝납니다.
이렇게 쓴다
어떤 글자든 처음 그려지는 프레임에 굽는 비용을 냅니다. 켜진 컴포넌트에 처음 보는 한글을 넣자 다음 프레임의 메시 단계가 10자에 19.2 ms, 200자에 64.9 ms 였습니다. 같은 문자열을 다시 처리한 프레임은 0.06 ~ 0.16 ms 였습니다. 대사나 채팅에 새 글자가 들어올 때마다 그 프레임이 튑니다.
로딩 화면에서 그 장면에 나올 문자열을 미리 한 번 그려 둡니다.
// 로딩 중. 화면 밖에 둔, 켜져 있는 UniText 에 넣는다
warmupText.Text = allDialogueOfThisScene;
- 꺼진 컴포넌트에 넣으면 켜진 다음 프레임에야 굽습니다. 켜진 상태로 넣습니다.
- 폰트 에셋의 설정을 바꾸면 아틀라스를 버립니다. 그 뒤에는 다시 그려 둡니다.
차면 텍스처를 키우지 않고 새 페이지를 연다
5번, 아틀라스에서 빈자리를 찾는 방법입니다. 책장에 책을 꽂듯이 왼쪽에서 오른쪽으로 채우다가, 오른쪽 끝에 닿으면 그 줄에서 가장 높은 글리프만큼 아래로 내려가 새 줄을 시작합니다. 이 한 줄을 선반이라고 부릅니다. 기억하는 것은 지금 선반의 x 위치, y 위치, 높이 셋뿐이고, 지나온 빈틈으로 되돌아가지 않습니다.
bool TryPackGlyphShelf(int w, int h, out GlyphRect result)
{
int pw = w + PackingSpacing; // 간격을 더한 폭
int ph = h + PackingSpacing; // 간격을 더한 높이
// 오른쪽이 넘치면 지금 선반 높이만큼 내려가 새 선반을 연다
if (shelfX + pw > atlasSize)
{
shelfY += shelfHeight + PackingSpacing;
shelfX = 0;
shelfHeight = 0;
}
// 아래가 넘치면 이 페이지에는 자리가 없다. 빈틈을 되돌아가 찾지 않는다
if (shelfY + ph > atlasSize)
return false;
result = new GlyphRect(shelfX, shelfY, w, h);
shelfX += pw; // 다음 글리프는 그 오른쪽
if (ph > shelfHeight)
shelfHeight = ph; // 선반 높이는 가장 높은 글리프를 따른다
return true;
}
// 호출하는 쪽 (PackRenderedBatch)
if (!TryPackGlyphShelf(...))
{
atlasTextures[^1].Apply(false, false); // 지금 페이지를 GPU 에 올린다
CreateNewAtlasTexture(); // 같은 크기의 새 페이지를 연다
TryPackGlyphShelf(...); // 새 페이지에서 다시 잡는다
}
한 변이 10 이고 간격이 1 인 아틀라스에, 선반이 x = 5, y = 0, 높이 = 4 인 상태에서 글리프 셋을 차례로 넣으면 이렇게 됩니다.
| 글리프 | 판정 | 잡힌 자리 | 그 뒤 선반 |
|---|---|---|---|
| 3 × 3 | 5 + 4 = 9, 넘치지 않음 | (5, 0) | x 9, y 0 |
| 2 × 4 | 9 + 3 = 12, 오른쪽이 넘쳐 새 선반 | (0, 5) | x 3, y 5 |
| 8 × 3 | 새 선반을 열어도 11 + 4 = 15, 아래가 넘침 | 없음 | 새 페이지 |
첫 선반의 오른쪽 끝과 둘째 선반의 오른쪽에 빈자리가 남았지만 다시 쓰지 않습니다. 글자 높이가 대체로 비슷해 낭비가 크지 않다는 판단입니다.
한 장이 다 차면 길이 둘입니다. 텍스처를 키우거나, 새 텍스처를 한 장 더 만들거나입니다. 키우면 기존 픽셀을 복사해야 하고, 이미 만든 메시가 가리키던 칸의 위치(UV)가 전부 달라져 메시를 다시 만들어야 합니다. UniText 는 기존 것을 건드리지 않는 새 장(페이지)을 엽니다.
대가는 드로우 콜입니다. 텍스처가 다르면 한 번에 그릴 수 없어서 메시가 (폰트, 페이지)마다 갈리고, 페이지가 늘면 드로우 콜이 늡니다. Noto Sans KR 기본 설정으로 KS X 1001 한글 2,350자를 그려 장 수를 셌습니다.
| Atlas Size | 한 장에 든 글리프 | 2,350자에 쓴 장 수 |
|---|---|---|
| 512 | 12 ~ 16 | 176 |
| 1024 | 56 ~ 65 | 41 |
여백 22 를 양쪽에 붙인 한글 한 글자가 한 변 130 픽셀 안팎이라 1024 한 장에 수십 자입니다. 한 줄이 쓰는 장이 하나 늘 때마다 드로우 콜이 하나 늘었습니다.
이렇게 쓴다
한글, 한자처럼 글자가 많은 폰트는 폰트 에셋의 설정을 이렇게 잡습니다.
| 설정 | 잡는 법 |
|---|---|
| Atlas Size | 키운다. 512 는 한 장에 한글 12 ~ 16자뿐 |
| 굽는 크기 | 실제로 쓰는 가장 큰 글자에 맞는 만큼만 |
| Spread | 실제로 쓰는 외곽선 굵기에 맞는 만큼만 |
굽는 크기나 Spread 를 키우면 글리프가 여백만큼 부풀어 한 장에 더 적게 들어갑니다. 플레이한 뒤 폰트 에셋 인스펙터의 아틀라스 미리보기에서 장 수를 확인합니다.
셰이더는 UV1 의 여백 비율에 기댄다
셰이더(UniText/Mobile/SDF)가 거리값을 화면의 굵기로 바꾸려면 그 글리프를 구울 때 여백을 얼마로 잡았는지 알아야 합니다. 같은 거리값 0.4 라도 여백이 넓게 구워졌으면 윤곽선에서 멀리 떨어진 곳이고, 좁게 구워졌으면 가까운 곳이기 때문입니다.
여백은 폰트마다 다릅니다. 한 텍스트에 폰트가 여럿 섞이니 머티리얼에 값 하나로 넣을 수 없어서, UniText 는 이 값(spreadRatio, 여백 ÷ 굽는 크기)을 정점마다 둘째 UV 채널(UV1)에 실어 보냅니다.
그런데 uGUI 캔버스는 기본으로 UV1 을 GPU 에 보내지 않습니다. 캔버스의 additionalShaderChannels 에서 켜야 갑니다.
그래서 컴포넌트가 Update 마다 자기가 속한 캔버스의 추가 채널에 TexCoord1 과 Normal 을 켭니다. 소스 주석도 이유를 적어 두었습니다. UV1 이 없으면 여백 비율이 0 이 되고 효과 강도가 100배쯤 커져 외곽선과 그림자가 심하게 일그러진다는 내용입니다.
이렇게 쓴다
외곽선이나 그림자가 글자를 뒤덮으면 캔버스의 채널부터 봅니다. 텍스트를 실행 중에 다른 캔버스로 옮기거나 캔버스를 새로 만들면, 컴포넌트가 다시 켜 주기 전까지 이렇게 보일 수 있습니다. 텍스트를 담을 캔버스는 처음부터 켜 둡니다.
canvas.additionalShaderChannels |=
AdditionalCanvasShaderChannels.TexCoord1 | AdditionalCanvasShaderChannels.Normal;
모서리가 또렷해야 하는 픽셀 폰트는 SDF 로 굽지 않습니다. 폰트 에셋의 Render Mode 를 Smooth 나 Mono 로 둡니다.
정리
UniText 는 글리프를 처음 쓰일 때 SDF 로 구워 아틀라스에 쌓습니다.
- 에셋에는 폰트 바이트와 메트릭만 저장되고, 처음 나온 글자는 그 프레임에 굽습니다. 한글 200자가 IL2CPP 에서 66 ms 였습니다.
- 굽기는 거르기(메인), 윤곽선을 거리값 그림으로 바꾸기(아무 스레드, FreeType), 자리 잡아 올리기(메인) 세 단계이고, 한 폰트 안에서는 16개 이상일 때만 병렬입니다.
- 선반 패킹으로 쌓다 차면 새 페이지를 열어 드로우 콜이 늡니다. 셰이더는 UV1 의
spreadRatio가 있어야 외곽선이 제대로 나옵니다.
그래서 장면에 나올 글자는 로딩 중에 미리 그려 두고, 글자가 많은 폰트는 Atlas Size 를 키우고, 캔버스에는 TexCoord1 채널을 켜 둡니다.