UniText 는 Graphic 을 상속하지만 그리기는 숨은 자식 렌더러가 한다
public partial class UniText : MaskableGraphic // 평범한 uGUI 그래픽처럼 생겼다
uGUI 의 Graphic 은 CanvasRenderer 하나에 메시 하나, 텍스처 하나를 전제합니다. 그런데 한글, 라틴, 아랍어가 섞인 한 줄은 폰트마다 아틀라스가 달라 텍스처가 여럿 필요합니다. 이 계약 안에서는 그릴 수 없습니다.
UniText 는 Graphic 을 상속하되 그 갱신 경로를 비워 두고, 캔버스 훅 두 개에서 바뀐 텍스트를 모아 처리한 뒤 폰트마다 숨은 자식 렌더러로 그립니다. 기준은 1.0.0 브랜치의 커밋 2087eff 입니다.
Graphic 의 길을 비우고 옆에 자기 길을 깔았다
OnPopulateMesh 를 재정의하지 않고, 갱신 입구인 Rebuild 와 UpdateMaterial 은 본문이 비어 있습니다. 런타임에 SetVerticesDirty 를 부르는 코드도 없습니다.
모든 폰트를 아틀라스 한 장에 넣을 수도 없습니다. 렌더 모드가 폰트 속성이라 SDF 본문과 컬러 이모지를 한 텍스처, 한 셰이더로 그릴 수 없기 때문입니다. 그래서 메시는 (폰트, 아틀라스 장) 조합마다 한 조각으로 갈립니다.
바뀐 텍스트는 캔버스 훅 두 개에서 한꺼번에 처리된다
유니티의 Canvas 는 화면을 그리기 직전에 정적 이벤트 둘을 차례로 부릅니다. Canvas.preWillRenderCanvases 와 Canvas.willRenderCanvases 입니다. 여기에 메서드를 걸어 두면 프레임마다 캔버스가 그려지기 직전에 불립니다. 이 글에서는 이것을 캔버스 훅이라고 부릅니다.
Text 에 값을 넣으면 SetDirty 가 버릴 캐시를 정하고 컴포넌트를 정적 목록에 올리는 데서 끝납니다. 실제 처리는 캔버스 훅에서 합니다. 훅은 첫 SetDirty 때 프로젝트 전체에 한 번 걸립니다.
앞 이벤트는 워커 스레드로 보낼 수 있는 분석과 셰이핑만 하고, 텍스처와 메시처럼 메인 스레드에서만 만질 수 있는 일은 뒤 이벤트로 미룹니다. 목록에 모아 두었으니 컴포넌트 열 개가 같은 폰트에 새 글자를 요청해도 텍스처는 폰트당 한 번만 올라갑니다.
조각마다 숨은 자식이 하나씩 그린다
메시 조각마다 -_UTSM_- 라는 자식 오브젝트가 생기고 CanvasRenderer 하나만 붙습니다. HideFlags.HideAndDontSave 라 하이어라키에 보이지 않고 씬에 저장되지 않습니다. 조각 수가 줄면 지우지 않고 재웠다가 다시 켭니다. 기본 하이라이터도 링크 강조용 ClickHighlight, HoverHighlight 자식을 같은 방식으로 붙입니다.
기본 스택 뒤에 NotoSansKR 을 붙이고 안녕 hello مرحبا 를 넣은 결과입니다.
| 외형 | 자식 | 그중 -_UTSM_- |
드로우 콜 |
|---|---|---|---|
| 기본 외형 (아랍어에 외곽선, 글자면 2패스) | 5 | 3 | 4 |
| 폰트별 머티리얼을 비운 복제본 | 5 | 3 | 3 |
한글, 라틴, 아랍어 조각마다 하나씩 셋입니다. 기본 외형은 아랍어 폰트에 외곽선과 글자면을 따로 그리는 머티리얼(2패스)을 겁니다. 이어 쓰는 글자의 외곽선이 글자 사이 이음매에 보이지 않게 하려는 것입니다. 2패스 머티리얼이 걸린 조각은 같은 삼각형을 두 번 그려 드로우 콜이 하나 늡니다.
uGUI 가 해 주던 일을 손으로 넘긴다
렌더러를 자식으로 흩어 놓았으니 RectMask2D 의 클리핑을 uGUI 가 알아서 걸어 주지 못합니다. UniText 는 클리핑 호출을 재정의해 자식 전부에 다시 뿌리고, 값을 필드에 적어 두어 나중에 생길 자식도 같은 상태로 시작하게 합니다.
public override void SetClipRect(Rect clipRect, bool validRect)
{
base.SetClipRect(clipRect, validRect); // 자기 CanvasRenderer. uGUI 가 원래 하던 일은 여기까지다
// 값을 적어 둔다. 나중에 새로 생기는 자식이 이 값으로 시작한다
cachedClipRect = clipRect;
cachedValidClip = validRect;
// 자식 렌더러 전부에 같은 값을 다시 뿌린다
for (var i = 0; i < subMeshRenderers.Count; i++)
{
var r = subMeshRenderers[i].renderer;
if (validRect)
{
r.EnableRectClipping(clipRect); // 마스크 안: 같은 영역으로 자른다
}
else
{
r.DisableRectClipping(); // 마스크 밖: 클리핑을 끄고
r.cull = false; // 컬링도 푼다
}
}
}
라틴 조각과 아랍어 조각, 자식이 둘인 텍스트가 RectMask2D 안으로 들어오면 부모와 자식 둘, CanvasRenderer 셋이 모두 같은 영역으로 잘립니다. 루프에서 자식이 하나라도 빠지면 그 폰트의 글자만 마스크 밖에서 보입니다.
값을 적어 두는 두 줄이 없으면, 마스크 안에서 텍스트가 바뀌어 새로 생긴 자식만 잘리지 않습니다. CreateSubMeshRenderer 가 이 값으로 새 자식의 시작 상태를 맞춥니다.
SetClipSoftness, Cull 도 같은 모양이고, 스텐실 마스크는 머티리얼마다 스텐실 변종을 만들어 참조 수를 맞춰 반납합니다. 버튼의 CrossFadeColor 가 부모 색만 바꾸는 것도 Update 에서 자식에게 복사해 맞춥니다.
주의할 점
1. UniText 오브젝트의 자식을 코드로 훑거나 지울 때 숨은 렌더러를 셈에 넣는다
안 지키면 생기는 문제. 숨은 자식도 트랜스폼의 자식입니다. foreach (Transform c in uniText.transform) Destroy(c.gameObject); 로 지우면 다음 프레임부터 글자가 보이지 않고 로그도 없습니다. Text 를 바꾸면 -_UTSM_- 셋은 다시 생기지만 강조용 자식 둘은 돌아오지 않았습니다.
이렇게 쓴다. UniText 오브젝트 밑에는 다른 자식을 두지 않고 붙일 UI 는 형제나 부모 쪽에 둡니다. 자식을 훑는 코드는 -_UTSM_- 로 시작하는 것과 ClickHighlight, HoverHighlight 를 건너뜁니다.
2. Text 를 넣은 직후 같은 코드에서 크기를 읽지 않는다
안 지키면 생기는 문제. 선호 폭은 첫 패스 결과에 기대는데, 첫 패스는 캔버스 훅에서 돕니다. Start 에서 Text 를 넣고 바로 읽으면 폭 0 이고, ForceRebuildLayoutImmediate 를 불러도 0, 첫 LateUpdate 까지도 0 입니다. 같은 프레임 WaitForEndOfFrame 에서야 524 가 됐습니다(에디터, Mono, IL2CPP 개발 빌드 모두 같음). 말풍선 크기를 텍스트에 맞추는 코드가 첫 말풍선만 납작하게 만듭니다.
이렇게 쓴다. 크기는 ContentSizeFitter 나 레이아웃 그룹에 맡깁니다. 코드에서 크기가 꼭 필요하면 한 프레임 뒤나 Canvas.willRenderCanvases 이후에 읽습니다.
3. 텍스트 하나에 섞는 폰트 수를 렌더러 수로 계산한다
안 지키면 생기는 문제. 폰트 셋이 섞인 텍스트 하나가 렌더러 셋이 되고, 2패스 머티리얼이 걸리면 드로우 콜이 더 늡니다. 목록 아이템 수십 개에 이런 텍스트가 들어가면 렌더러와 드로우 콜이 그 배수로 늘어납니다.
이렇게 쓴다. 주 폰트 하나가 대부분의 글자를 덮도록 스택 순서를 잡고, 이모지나 기호처럼 드문 것만 폴백으로 둡니다.
정리
UniText 는 Graphic 을 상속하지만 그리기는 캔버스 훅과 숨은 자식 렌더러가 맡습니다.
Rebuild와UpdateMaterial이 비어 있고, 메시는 (폰트, 아틀라스 장)마다 갈립니다.Text대입은 목록에 올리기만 하고,preWillRenderCanvases에서 분석과 셰이핑,willRenderCanvases에서 굽기와 메시가 한꺼번에 돕니다. 그래서 대입 직후에는 크기가 0 입니다.- 조각마다
HideAndDontSave자식-_UTSM_-가 그리고, 클리핑과 마스킹과 색 전환은 부모가 손으로 넘깁니다.