작성일 :

하나의 Canvas가 만드는 문제

Part 1에서 Canvas 리빌드가 UI 성능의 핵심 비용임을 다루었습니다. Unity의 UGUI 시스템은 UI 요소를 메쉬로 변환하여 GPU에 전달합니다. 이 메쉬를 다시 생성하는 과정이 리빌드입니다.

하나의 Canvas 안에서 어떤 요소라도 변경되면 해당 Canvas 전체의 메쉬가 다시 계산됩니다. 텍스트 한 줄이 바뀌어도, 이미지 하나의 색상이 변해도, 같은 Canvas에 속한 모든 요소가 리빌드 대상이 됩니다.

리빌드 비용은 Canvas에 포함된 요소가 많을수록 커집니다. 요소가 100개인 Canvas에서 하나의 텍스트만 변경해도 100개 전체의 메쉬를 다시 계산합니다. 모바일 기기의 CPU 예산은 제한적이므로, 이 불필요한 리빌드를 줄이는 것이 UI 최적화의 출발점입니다.


Canvas 분리 — 정적 요소와 동적 요소

리빌드 비용을 줄이는 가장 기본적인 전략은 Canvas를 분리하는 것입니다. 자주 변하는 요소와 거의 변하지 않는 요소를 별도의 Canvas에 배치하면, 변경이 발생한 Canvas만 리빌드됩니다.

정적 Canvas와 동적 Canvas

UI 요소는 변경 빈도에 따라 두 부류로 나뉩니다.

첫째는 정적 요소입니다. 배경 이미지, 장식 프레임, 고정 아이콘, 타이틀 텍스트 등 한 번 그려지면 거의 변하지 않는 요소입니다. 게임이 실행되는 동안 메쉬가 바뀔 일이 없으므로, 최초 한 번만 리빌드하면 됩니다.

둘째는 동적 요소입니다. 점수, 타이머, HP 바, 쿨다운 표시, 콤보 카운터 등 매 프레임 또는 자주 갱신되는 요소입니다. 값이 바뀔 때마다 리빌드가 발생합니다.

이 두 부류를 하나의 Canvas에 넣으면, 동적 요소가 변할 때마다 정적 요소까지 불필요하게 리빌드됩니다. 별도의 Canvas로 분리하면 동적 Canvas만 리빌드되고, 정적 Canvas는 영향을 받지 않습니다.


Sub-Canvas로 구현

Canvas 분리는 Sub-Canvas(Nested Canvas) 로 구현합니다. 기존 Canvas의 자식 오브젝트에 Canvas 컴포넌트를 추가하면, 해당 오브젝트 이하가 독립된 배칭 단위가 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
Hierarchy 구조

  Root Canvas                     ← 최상위 Canvas
  ├── Static Sub-Canvas           ← Canvas 컴포넌트 추가
  │   ├── Background Image
  │   ├── Frame Image
  │   └── Fixed Icon
  │
  └── Dynamic Sub-Canvas          ← Canvas 컴포넌트 추가
      ├── Score Text
      ├── Timer Text
      └── HP Bar

Sub-Canvas의 핵심 특성은 리빌드 격리입니다. 각 Canvas는 자신에 속한 요소만 독립적으로 배칭하므로, 한 Canvas에서 리빌드가 발생해도 다른 Canvas에 전파되지 않습니다.


Canvas가 늘어나면 드로우콜이 증가할 수 있습니다. 같은 머티리얼의 요소라도 Canvas가 다르면 배칭이 분리되기 때문입니다. 하지만 매 프레임 리빌드되는 비용이 드로우콜 몇 개가 추가되는 비용보다 크므로, 변경 빈도가 확실히 다른 요소들을 분리하는 것이 이득입니다.

분리 기준

Canvas를 몇 개로 분리할지는 프로젝트마다 다르지만, 기본 기준은 변경 빈도입니다.

매 프레임 변하는 요소와 전혀 변하지 않는 요소가 같은 Canvas에 있으면, 정적 요소까지 매 프레임 재배칭되므로 분리 효과가 가장 큽니다.


ScrollRect 풀링 — 긴 리스트의 최적화

Canvas 분리가 리빌드 범위를 줄이는 전략이었다면, ScrollRect 풀링은 리빌드 대상이 되는 UI 요소의 개수를 줄이는 전략입니다.

ScrollRect의 문제

채팅 로그, 인벤토리, 상점 목록, 랭킹 등 게임 UI에서 긴 리스트는 자주 등장합니다. Unity의 ScrollRect 컴포넌트는 스크롤 가능한 영역을 제공하며, 이 영역 안에 아이템을 나열하여 리스트를 구현합니다.

기본 구현에서는 리스트의 모든 아이템을 미리 생성합니다. 인벤토리에 아이템이 1,000개라면 UI 오브젝트도 1,000개 생성됩니다. 각 아이템이 아이콘, 이름, 수량, 등급 표시 등으로 구성되므로 실제 오브젝트 수는 수천 개에 이를 수 있습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
기본 ScrollRect 구현의 문제

  화면에 보이는 영역 (10개):
  ┌────────────────────────────┐
  │  아이템 1  [아이콘][이름]  │
  │  아이템 2  [아이콘][이름]  │
  │  아이템 3  [아이콘][이름]  │
  │  ...                       │
  │  아이템 10 [아이콘][이름]  │
  └────────────────────────────┘
    아이템 11                    ← 화면 밖
    아이템 12                    ← 화면 밖
    ...
    아이템 1000                  ← 화면 밖

  → 1,000개 전체가 Canvas 리빌드 대상이지만, 화면에 보이는 것은 10개

화면 밖의 990개도 Canvas에 속한 UI 요소이므로 메모리를 점유하고 리빌드 비용에 포함됩니다. 초기 생성 시간도 아이템 수에 비례하여 늘어납니다.

풀링의 원리

ScrollRect 풀링은 화면에 보이는 아이템 수에 약간의 여분을 더한 만큼만 실제 UI 오브젝트를 생성하는 방식입니다. 화면에 10개가 보인다면 13~15개 정도의 오브젝트만 만듭니다. 나머지 985~987개의 아이템은 데이터로만 존재합니다.

사용자가 리스트를 아래로 스크롤하면, 화면 위쪽으로 사라진 오브젝트를 화면 아래쪽으로 이동시키고 해당 위치의 데이터로 내용을 교체합니다.

이 방식은 게임 오브젝트 풀링과 동일한 원리입니다. 일반적인 오브젝트 풀링이 “사용 중/대기 중” 상태만 관리하는 반면, ScrollRect 풀링은 오브젝트의 위치 이동과 데이터 교체까지 수행합니다.

풀링의 효과

풀링을 적용하면 세 가지 비용이 줄어듭니다.


리빌드 비용. Canvas가 리빌드할 때 처리해야 하는 UI 요소 수가 1,000개에서 15개 수준으로 줄어듭니다.

메모리 사용량. 1,000개의 GameObject와 그에 딸린 컴포넌트, 메쉬 데이터가 15개 수준으로 줄어듭니다. 각 아이템이 여러 자식 오브젝트를 가지면 절약 효과는 더 커집니다.

초기 생성 시간. 리스트를 처음 열 때 1,000개의 오브젝트를 한꺼번에 생성하면 프레임 끊김이 발생할 수 있습니다. 풀링 방식에서는 15개만 생성하므로 거의 즉시 표시됩니다.


TextMeshPro vs Legacy Text

Canvas 분리와 ScrollRect 풀링이 리빌드의 범위와 대상을 줄이는 전략이었다면, 텍스트 렌더링 방식의 선택은 리빌드 자체의 비용을 줄이는 전략입니다.

Legacy Text의 한계

Unity의 기본 UI Text 컴포넌트(Legacy Text)는 비트맵 기반 텍스트 렌더링을 사용합니다. 폰트의 각 글자를 특정 크기로 래스터화(비트맵 이미지로 변환)하여 텍스처에 저장하고, 텍스트를 표시할 때 이 텍스처에서 글자를 읽어와 메쉬를 구성합니다.


이 방식에는 세 가지 비용 문제가 있습니다.

메쉬 재생성. 텍스트 내용이 바뀌면 메쉬가 전면 재생성됩니다. 글자 수가 변하면 정점 수가 달라지므로, 메쉬 버퍼를 새로 구성해야 합니다.

크기 의존성. 글자를 확대하거나 축소하면 래스터화된 비트맵이 흐려지거나 깨집니다. 다양한 크기에 대응하려면 같은 폰트를 여러 크기로 래스터화하여 저장해야 하며, 폰트 텍스처의 크기가 증가합니다.

런타임 텍스처 갱신. 텍스트에 사용된 글자가 기존 폰트 텍스처에 없으면, 런타임에 래스터화하여 추가하는 과정이 발생합니다. 텍스처 업로드가 일어나고, 같은 폰트를 사용하는 다른 텍스트까지 영향을 받을 수 있습니다.

이 세 가지 문제는 모두 비트맵 기반 렌더링에서 비롯됩니다.

TextMeshPro의 SDF 렌더링

TextMeshPro(TMP)SDF(Signed Distance Field) 기반의 텍스트 렌더링을 사용합니다.

비트맵 방식에서 각 텍셀(텍스처 픽셀)은 글자의 불투명도를 저장합니다. 텍스트를 확대하면 고정된 픽셀 데이터를 늘리는 것이므로 계단 현상이 발생하고, 크기별로 별도의 비트맵을 미리 생성해야 합니다.

SDF 텍스처의 각 텍셀은 색상이 아니라 글자 윤곽선까지의 거리를 저장합니다. 글자 내부의 텍셀은 양수, 외부는 음수, 윤곽선 위는 0입니다. 렌더링 시점에 GPU의 프래그먼트 셰이더가 이 거리 값을 읽어, 특정 임계값을 기준으로 글자 내부와 외부를 구분합니다. 텍스트를 확대해도 텍셀에 저장된 거리 정보는 유지되므로 셰이더가 윤곽선을 정확히 계산할 수 있고, 경계 영역에서 부드러운 보간이 이루어져 선명함이 유지됩니다. 하나의 SDF 텍스처로 모든 크기에 대응할 수 있습니다.

TextMeshPro의 성능 이점

SDF 렌더링은 시각적 품질만 높은 것이 아니라, 성능 면에서도 이점이 있습니다.

메쉬 재생성 비용이 낮습니다. SDF는 크기 변화에 독립적이므로, 표시 크기가 달라져도 글리프 메트릭을 다시 계산할 필요가 없습니다. 내부적으로 메쉬 재생성 경로가 최적화되어 있어, 텍스트 내용이 바뀔 때 재생성 과정 자체가 Legacy Text보다 효율적입니다.

폰트 텍스처 재생성이 줄어듭니다. SDF 텍스처는 크기에 독립적이므로, 같은 폰트를 여러 크기로 표시해도 하나의 텍스처만 사용합니다. Legacy Text처럼 크기별로 폰트 텍스처를 재생성할 필요가 없습니다.

추가 시각 효과의 비용이 낮습니다. SDF의 거리 데이터를 활용하면 외곽선(Outline), 그림자(Shadow), 글로우(Glow) 등의 효과를 셰이더에서 구현할 수 있습니다. Legacy Text에서 이런 효과를 적용하면 정점을 복제하여 메쉬에 추가하므로(Shadow는 2배, Outline은 5배) 재생성 비용이 크게 증가하지만, TextMeshPro에서는 같은 메쉬의 셰이더 안에서 처리됩니다.


1
2
3
4
5
6
7
8
9
10
Legacy Text vs TextMeshPro 비교

                    Legacy Text           TextMeshPro
──────────────────────────────────────────────────────────
렌더링 방식         비트맵                SDF
확대/축소           흐려짐/깨짐           선명 유지
크기별 텍스처       크기마다 별도 필요    하나로 모든 크기
외곽선/그림자       정점 복제(2~5배)      셰이더에서 처리
메쉬 재생성         전면 재생성           최적화된 재생성
텍스처 재생성       빈번                  적음


TextMeshPro는 Unity 2018.1부터 패키지 매니저로 제공되었고, Unity 2018.3부터 모든 프로젝트에 기본 포함됩니다. 신규 프로젝트에서 Legacy Text를 사용할 이유는 없습니다.


폰트 아틀라스 — 한글의 글자 수 문제

TextMeshPro는 SDF 기반으로 텍스트를 렌더링합니다. SDF 렌더링이 동작하려면 각 글자의 SDF 데이터가 폰트 아틀라스(Font Atlas) 에 미리 준비되어 있어야 합니다. 폰트 아틀라스는 폰트의 글자들을 하나의 텍스처에 모아 놓은 것입니다. 스프라이트를 하나의 텍스처에 모으는 텍스처 아틀라스(Sprite Atlas)와 동일한 구조입니다.

영문과 한글의 차이

영문 알파벳은 대소문자, 숫자, 기본 기호를 합쳐도 약 100자 내외입니다. 512x512 텍스처 하나면 영문 폰트 아틀라스를 넉넉하게 구성할 수 있습니다.

한글은 글자 수가 다릅니다. 유니코드 완성형 한글은 초성 19개, 중성 21개, 종성 28개(없음 포함)의 조합으로 11,172자입니다. 11,172자를 모두 SDF로 하나의 텍스처에 담으려면 4096x4096 이상의 텍스처가 필요할 수 있으며, 이 크기의 텍스처는 수십 MB의 메모리를 소비합니다.

정적 아틀라스와 동적 아틀라스

이 문제를 해결하는 두 가지 접근법이 있습니다.

정적 아틀라스(Static Atlas). 빌드 시점에 필요한 글자를 미리 아틀라스에 포함시킵니다. 런타임에 SDF를 생성할 필요가 없지만, 어떤 글자가 사용될지 미리 알아야 합니다. 영문처럼 글자 수가 적은 언어에서는 전체 글자를 정적으로 포함해도 부담이 없지만, 한글 11,172자 전체를 포함하면 메모리 비용이 큽니다.

동적 아틀라스(Dynamic Atlas). 런타임에 실제로 사용되는 글자만 아틀라스에 추가합니다. TextMeshPro의 Dynamic SDF 모드가 이 방식입니다. 텍스트에 새로운 글자가 등장하면, 그 글자의 SDF 데이터를 실시간으로 생성하여 아틀라스 텍스처에 추가합니다. 이미 아틀라스에 있는 글자는 다시 생성하지 않습니다.

동적 아틀라스의 비용은 두 곳에서 발생합니다.

새 글자가 처음 등장할 때 SDF 생성 연산이 CPU에서 실행되며, 한 프레임에 많은 새 글자가 한꺼번에 등장하면(예: 채팅 메시지에 처음 보는 글자가 많을 때) 프레임 지연이 발생할 수 있습니다.

또한 아틀라스 텍스처가 가득 차면 더 큰 텍스처로 확장하거나, 사용 빈도가 낮은 글자를 제거하고 재구성해야 하며, 이 과정에서 텍스처 업로드 비용이 발생합니다.

혼합 전략

정적 아틀라스와 동적 아틀라스를 혼합하여 사용합니다. 숫자, 영문, 기본 기호, 고정 UI 텍스트, 그리고 자주 쓰는 한글을 정적 아틀라스에 포함시킵니다.

KS X 1001 완성형 2,350자가 일상적인 한국어 텍스트의 대부분을 커버하므로, 이 범위를 정적으로 포함하면 런타임에 동적으로 추가해야 하는 글자 수가 크게 줄어듭니다.

채팅 메시지, 유저 닉네임 등에서 등장하는 드문 글자만 동적 아틀라스로 처리하면 런타임 비용을 최소화할 수 있습니다.


UI 오버드로우 — 같은 픽셀을 여러 번 그리는 비용

Canvas 분리, ScrollRect 풀링, TextMeshPro는 모두 CPU 측의 리빌드 비용을 줄이는 전략이었습니다. UI 오버드로우는 GPU 측의 비용에 관한 문제입니다.

반투명 레이어의 겹침

UI 요소는 대부분 반투명(Alpha) 을 포함합니다. 버튼의 모서리 라운딩, 패널의 반투명 배경, 그림자 효과, 텍스트의 안티앨리어싱 — 이 모든 것이 알파 채널을 사용합니다.

반투명 요소는 깊이 테스트(Depth Test)로 걸러지지 않습니다. 뒤에 있는 요소가 비쳐 보여야 하므로, 겹치는 모든 요소가 순서대로 그려져야 합니다.

예를 들어 배경 이미지 위에 반투명 패널, 그 위에 버튼, 그 위에 텍스트가 겹치면, 텍스트 위치의 한 픽셀은 4번 그려집니다(오버드로우 4x).


GPU 아키텍처 (2) - 모바일 GPU와 TBDR에서 다룬 것처럼, 오버드로우는 모바일 GPU에서 비용이 큽니다.

불투명 오브젝트는 Mali의 FPK(Forward Pixel Kill), Adreno의 LRZ(Low-Resolution Z), Apple의 HSR(Hidden Surface Removal) 같은 하드웨어 최적화가 불필요한 셰이딩을 제거해 줍니다. 이들은 모두 최종적으로 보이지 않을 픽셀을 미리 판별하여 프래그먼트 셰이더 실행을 건너뛰는 기술입니다. 반투명 오브젝트는 이 최적화의 도움을 받지 못하므로, 겹치는 만큼 프래그먼트 셰이더가 실행되며 모바일 GPU의 제한된 필레이트(GPU가 초당 처리할 수 있는 픽셀 수) 예산을 소모합니다.

UI는 반투명 요소의 비율이 3D 씬보다 높습니다. 버튼, 패널, 텍스트, 아이콘 대부분이 알파 채널을 포함하므로, UI가 모바일에서 오버드로우의 주요 원인이 됩니다.

오버드로우 줄이기

불필요한 배경 이미지 제거. 다른 UI 요소에 의해 완전히 가려지는 배경 이미지가 있다면 제거합니다. 예를 들어 전체 화면 패널 아래에 깔려 있는 전체 화면 배경 이미지는, 패널이 불투명이라면 보이지 않습니다. 하지만 GPU는 여전히 그립니다. UGUI의 UI 요소는 알파 값과 관계없이 Transparent 렌더 큐에서 렌더링되며, 깊이 버퍼에 기록하지 않습니다. 3D 오브젝트는 앞에 불투명 오브젝트가 있으면 깊이 테스트로 걸러지지만, UI 요소는 이 과정이 없으므로 앞에 가려져도 그대로 렌더링됩니다.

투명 영역 최소화. UI 이미지의 실제 콘텐츠가 텍스처의 일부분만 차지하고 나머지가 투명인 경우가 있습니다. UI Image는 사각형 메쉬(쿼드)로 렌더링되므로, GPU는 쿼드 영역 안의 모든 픽셀에 대해 프래그먼트 셰이더를 실행합니다. 투명 픽셀도 예외가 아닙니다. 셰이더가 텍스처를 읽고 알파 값이 0임을 확인한 뒤 블렌딩까지 수행하므로, 시각적으로 보이지 않을 뿐 연산 비용은 동일합니다. 이미지의 투명 영역을 최대한 잘라내어(Trim) 실제 콘텐츠 영역만 남기면 쿼드 자체가 작아져 GPU가 처리하는 픽셀 수가 줄어듭니다.

UI 요소 비활성화. 화면에 보이지 않는 UI 요소(다른 화면에 가려진 팝업, 숨겨진 패널 등)가 활성화 상태로 남아 있으면 여전히 렌더링 대상이 됩니다. Unity의 렌더링 시스템은 UI 요소가 실제로 화면에 보이는지가 아니라, 활성화 상태인지를 기준으로 렌더링 대상에 포함하기 때문입니다. SetActive(false) 또는 Canvas 컴포넌트의 enabled = false로 렌더링에서 제외할 수 있습니다. 이 중 Canvas의 enabled 토글이 더 가볍습니다. SetActive는 하위 모든 오브젝트의 OnEnable/OnDisable 콜백을 유발하지만, Canvas의 enabled는 렌더링만 제어하므로 콜백이 발생하지 않습니다.

전체 화면 UI에서의 3D 씬 렌더링 중단. 인벤토리, 설정 화면 등 전체 화면을 덮는 UI가 활성화되면, 그 뒤의 3D 씬은 보이지 않습니다. 이때 3D 씬을 렌더링하는 카메라를 비활성화하면, 드로우콜, 정점 처리, 조명 계산, 셰도우 맵, 포스트 프로세싱 등 3D 렌더링 파이프라인 전체가 생략됩니다. 오버드로우 감소가 아니라 렌더링 패스 자체를 제거하는 것이므로 절약 효과가 큽니다.


Raycast Target — 불필요한 입력 처리 제거

오버드로우가 GPU 측의 불필요한 작업이라면, 불필요한 Raycast Target은 CPU 측의 불필요한 작업입니다.

Raycast Target의 역할

Unity UGUI에서 터치나 클릭 입력이 발생하면, GraphicRaycasterRaycast Target 속성이 켜져 있는 모든 UI 요소를 순회합니다. 각 요소에 대해 입력 위치와 겹치는지 검사하여 터치 대상을 결정합니다.

배경 이미지, 프레임, 장식 아이콘, 텍스트 라벨, 버튼, 스크롤바가 모두 Raycast Target이 켜져 있다면 6개 전부가 겹침 검사 대상이 되지만, 실제로 상호작용이 필요한 것은 버튼과 스크롤바뿐입니다.

기본값의 문제

Unity의 Image, Text(Legacy), TextMeshPro 등 Graphic 컴포넌트는 생성 시 Raycast Target이 기본으로 켜져 있습니다. 버튼이나 슬라이더처럼 터치 입력을 받아야 하는 요소에는 필요하지만, 장식 이미지, 배경, 텍스트 라벨에는 불필요합니다.

UI 요소가 수십~수백 개인 화면에서 대부분의 요소에 Raycast Target이 켜져 있으면, 매 터치 이벤트마다 불필요한 겹침 검사가 수백 회 실행됩니다.

모바일에서 한 번의 터치는 다운, 무브, 업 등 여러 이벤트로 분할되어 처리되므로, 요소 수가 많아지면 누적 비용이 무시할 수 없는 수준이 됩니다.

Raycast Target을 꺼야 하는 요소

일반적인 UI 화면에서 상호작용이 필요한 요소는 전체의 10~20% 정도입니다. 버튼, 슬라이더, 토글, 입력 필드, ScrollRect, 드래그 가능한 요소 등 실제로 입력을 받는 요소만 Raycast Target을 유지하고, 나머지 배경 이미지, 장식 프레임, 텍스트 라벨, 표시용 아이콘 등은 끄면 겹침 검사 대상이 그만큼 줄어듭니다.

버튼의 자식 텍스트도 꺼도 됩니다. 버튼 이미지가 터치 영역을 담당하므로, 그 위에 있는 텍스트는 검사 대상에서 제외해도 동작에 영향이 없습니다.


전략의 조합

지금까지 다룬 각 전략은 최적화하는 영역이 다릅니다. CPU 측에서는 Canvas 분리와 ScrollRect 풀링이 리빌드 비용을, TextMeshPro가 텍스트 메쉬 재생성 비용을, Raycast Target 관리가 입력 처리 비용을 줄입니다. GPU 측에서는 오버드로우 감소가 프래그먼트 셰이딩 횟수를 줄입니다. 메모리 측면에서는 ScrollRect 풀링이 오브젝트 수를, 폰트 아틀라스 혼합 전략이 텍스처 크기를 관리합니다. 영역이 겹치지 않으므로 함께 적용할 수 있습니다.


마무리

  • Canvas 분리는 자주 변하는 요소와 변하지 않는 요소를 별도 Sub-Canvas에 배치하여, 동적 요소의 변경이 정적 요소의 리빌드를 유발하지 않도록 합니다.
  • ScrollRect 풀링은 화면에 보이는 만큼의 오브젝트만 생성하고, 스크롤 시 사라진 오브젝트를 반대편에 재배치하여 재활용합니다.
  • TextMeshPro는 SDF 기반 렌더링으로 확대/축소에도 선명하며, 메쉬 재생성 비용과 폰트 텍스처 비용이 Legacy Text보다 낮습니다.
  • 한글의 11,172자 문제는 자주 쓰는 글자를 정적 아틀라스에 포함하고 나머지를 동적으로 처리하는 혼합 전략으로 해결합니다.
  • UI의 반투명 요소는 깊이 테스트로 걸러지지 않으므로, 불필요한 배경 제거와 투명 영역 최소화로 오버드로우를 줄입니다.
  • 상호작용이 필요 없는 UI 요소의 Raycast Target을 끄면 터치 입력 처리의 겹침 검사 대상이 줄어듭니다.

UI는 모바일 게임에서 항상 화면에 존재하므로, 이 비용 절감은 프레임마다 누적됩니다.



관련 글

시리즈

전체 시리즈

Tags: UGUI, UI, Unity, 모바일, 최적화

Categories: ,