게임 루프의 원리 (2) - CPU-bound와 GPU-bound - soo:bak
작성일 :
병목의 개념
Part 1에서 하나의 프레임이 CPU 단계와 GPU 단계로 이루어져 있고, 이 두 단계가 파이프라이닝을 통해 병렬로 실행됨을 살펴보았습니다. CPU가 프레임 N의 로직을 처리하는 동안 GPU는 프레임 N-1의 렌더링을 처리합니다.
이 병렬 구조에서 프레임 시간은 CPU 시간과 GPU 시간의 단순 합이 아닙니다. 둘 중 더 긴 쪽이 프레임 시간을 결정합니다.
1
프레임 시간 = max(CPU 시간, GPU 시간)
CPU가 8ms 걸리고 GPU가 12ms 걸리면 프레임 시간은 12ms입니다. CPU가 15ms 걸리고 GPU가 10ms 걸리면 프레임 시간은 15ms입니다. 느린 쪽이 전체 속도를 결정하며, 이 느린 쪽을 병목(Bottleneck)이라 부릅니다.
병목이 아닌 쪽을 최적화해도 프레임 시간은 줄어들지 않습니다. GPU가 12ms 걸리는 상황에서 CPU를 8ms에서 5ms로 줄여도 프레임 시간은 여전히 12ms입니다. 최적화는 병목이 어디인지 파악하는 데서 시작합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
병목 판별:
CPU 시간 > GPU 시간 → CPU-bound
GPU 시간 > CPU 시간 → GPU-bound
┌──────────────────────────────────────────────────────┐
│ │
│ CPU-bound 상태: │
│ │
│ CPU ████████████████████████ (15ms) │
│ GPU ██████████████ (10ms) │
│ ^^^^^^^^^^^^ │
│ GPU 대기 │
│ │
│ 프레임 시간 = 15ms │
│ │
├──────────────────────────────────────────────────────┤
│ │
│ GPU-bound 상태: │
│ │
│ CPU ██████████████ (10ms) │
│ GPU ████████████████████████ (15ms) │
│ ^^^^^^^^^^^^ │
│ CPU 대기 │
│ │
│ 프레임 시간 = 15ms │
│ │
└──────────────────────────────────────────────────────┘
병목은 고정된 것이 아닙니다. 화면에 파티클과 이펙트가 많이 겹치면 GPU가 픽셀 계산에 시간을 많이 소모하여 GPU-bound가 되고, AI 에이전트가 대량으로 경로를 탐색하면 CPU가 로직 계산에 시간을 많이 소모하여 CPU-bound가 됩니다. 같은 게임에서도 장면과 상황에 따라 병목이 바뀝니다.
CPU-bound
프레임 시간을 CPU가 결정하고 있다면 CPU-bound 상태입니다. GPU는 이미 렌더링을 마쳤지만, CPU가 아직 로직 처리를 끝내지 못해 GPU가 다음 렌더링 명령을 받지 못하고 유휴 상태에 머무릅니다.
CPU가 처리하는 작업
하나의 프레임에서 CPU가 담당하는 작업은 여러 서브시스템에 걸쳐 있습니다. Unity 프로파일러에서 프레임을 열어보면, 메인 스레드의 타임라인에서 이 서브시스템들이 순차적으로 나열된 것을 확인할 수 있습니다.
1
2
3
4
5
6
7
8
9
CPU 메인 스레드의 한 프레임:
┌──────────────┬──────────┬────────────┬────────────┬───────────┬────────────────┐
│ FixedUpdate │ Update │ 애니메이션 │ LateUpdate │ UI │ 렌더 준비 │
│ + Physics │ │ (Animator │ │ (Canvas │ (Culling, │
│ (0~N회 반복) │ │ 평가) │ │ Rebuild) │ Batching, │
│ │ │ │ │ │ 커맨드 버퍼) │
└──────────────┴──────────┴────────────┴────────────┴───────────┴────────────────┘
◄──────────────────────── CPU 프레임 시간 ──────────────────────────────────────►
FixedUpdate + Physics가 프레임에서 가장 먼저 실행됩니다. FixedUpdate는 고정된 시간 간격(기본 0.02초)마다 호출되므로, 프레임 시간에 따라 한 프레임에서 0회 또는 여러 회 실행될 수 있습니다. 각 FixedUpdate 호출 직후에 PhysX 물리 엔진이 충돌 감지와 강체(Rigidbody) 시뮬레이션을 수행합니다. 강체란 물리 법칙(중력, 힘, 충돌 반응)의 적용을 받는 오브젝트를 말합니다. 물리 오브젝트의 수와 충돌 쌍의 수가 CPU 비용에 직접 영향을 줍니다. 콜라이더가 복잡한 메쉬 형태인 경우 단순한 박스나 구에 비해 충돌 계산량이 크게 늘어납니다. 메쉬 콜라이더는 삼각형 단위로 충돌을 검사하므로, 메쉬의 복잡도에 따라 비용 차이가 수배에서 수십 배에 달할 수 있습니다.
물리 처리가 끝나면 Update가 실행됩니다. MonoBehaviour의 Update에서 실행되는 게임플레이 코드로, AI 의사결정, 입력 처리, 게임 규칙 평가 등이 여기에 해당합니다. 스크립트가 복잡하거나 수천 개의 오브젝트가 매 프레임 Update를 호출하면 이 구간이 길어집니다.
애니메이션 업데이트에서는 Animator 컴포넌트가 각 캐릭터의 애니메이션 상태를 평가하고, 뼈대(Bone)의 위치와 회전을 계산합니다. 캐릭터 수가 많거나 블렌드 트리(Blend Tree)가 복잡하면 이 비용이 커집니다. 블렌드 트리는 여러 애니메이션 클립을 파라미터에 따라 실시간으로 혼합하는 구조입니다. 뼈대의 위치가 결정되면, 그 움직임에 따라 캐릭터 메쉬의 정점을 변형해야 합니다. 이 과정을 스키닝(Skinning)이라고 합니다. 스키닝 연산 자체는 GPU로 넘길 수 있지만, 어떤 애니메이션을 재생할지 결정하는 상태 평가는 CPU에서 수행됩니다.
LateUpdate는 Update와 애니메이션 이후에 실행됩니다. 카메라가 캐릭터를 따라가는 로직처럼, 다른 오브젝트의 최종 위치가 확정된 뒤에 실행해야 하는 코드가 여기에 해당합니다.
LateUpdate 이후, 렌더링이 시작되기 직전에 UI 레이아웃 계산이 수행됩니다. Canvas 시스템이 UI 요소의 위치, 크기, 배치를 결정합니다. UI 요소 하나가 바뀌면 해당 Canvas 전체가 리빌드(Rebuild)될 수 있습니다. Unity의 Canvas는 자식 UI 요소들의 메쉬를 하나로 합쳐서 배칭합니다. 요소 하나가 변경되면 이 합쳐진 메쉬 전체를 다시 생성해야 합니다. 많은 UI 요소가 매 프레임 변하면(예: 체력바, 대미지 숫자) Canvas 리빌드 비용이 프레임 예산의 상당 부분을 차지할 수 있습니다. Unity의 공식 최적화 가이드에서도 Canvas 리빌드를 주요 CPU 병목 원인으로 언급합니다.
렌더 준비 단계에서 CPU는 GPU에 보낼 렌더링 명령을 만듭니다. 이 과정은 크게 세 단계로 나뉩니다.
먼저 컬링(Culling)에서 카메라에 보이지 않는 오브젝트를 걸러냅니다. 씬에 오브젝트가 10,000개 있더라도 카메라 시야에 들어오는 것이 2,000개라면, 나머지 8,000개는 GPU에 보내지 않습니다. Unity는 프러스텀 컬링(Frustum Culling)으로 카메라의 절두체(보이는 영역) 밖에 있는 오브젝트를 제외하고, 오클루전 컬링(Occlusion Culling)으로 다른 오브젝트에 완전히 가려진 오브젝트를 추가로 제외합니다. 오브젝트 수가 많을수록 이 판별 자체에 CPU 시간이 더 소요됩니다.
다음으로 소팅과 배칭(Sorting & Batching)이 수행됩니다. 컬링을 통과한 오브젝트들을 렌더링 순서에 맞게 정렬하고, 같은 머티리얼을 사용하는 오브젝트를 하나의 드로우 콜로 묶습니다. GPU에 “이 오브젝트를 그려라”라는 명령을 보내는 것이 드로우 콜인데, 드로우 콜 하나하나에 CPU 오버헤드가 따릅니다. 100개의 오브젝트가 같은 머티리얼을 쓴다면, 배칭을 통해 드로우 콜 100회를 1회로 줄일 수 있습니다.
마지막으로 이 모든 렌더링 명령을 하나의 목록인 커맨드 버퍼(Command Buffer)로 묶어 GPU에 전달합니다. 커맨드 버퍼에는 “이 셰이더를 사용해라”, “이 메쉬를 그려라”, “렌더 타겟을 전환해라” 같은 GPU 명령이 순서대로 담깁니다. 씬에 렌더링할 오브젝트가 많을수록 컬링과 소팅, 배칭에 드는 CPU 비용이 높아지고, 그만큼 커맨드 버퍼에 기록할 명령도 늘어납니다.
CPU-bound의 특징
CPU-bound 상태에서는 화면의 렌더링 복잡도를 낮춰도 프레임 시간이 개선되지 않습니다. 해상도를 절반으로 줄이거나, 셰이더를 단순화하거나, 후처리를 꺼도 GPU 시간만 줄어들 뿐이고 프레임 시간은 CPU 시간에 의해 여전히 제한됩니다.
반대로, 스크립트 로직을 최적화하거나 물리 오브젝트 수를 줄이면 프레임 시간이 직접적으로 개선됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CPU-bound에서 GPU 최적화의 효과:
최적화 전:
CPU ████████████████████████ (15ms)
GPU ██████████████ (10ms)
프레임 = 15ms
GPU 해상도 절반:
CPU ████████████████████████ (15ms) ← 변화 없음
GPU ████████ (6ms)
프레임 = 15ms ← 변화 없음
CPU 스크립트 최적화:
CPU ████████████ (9ms) ← 개선
GPU ██████████████ (10ms)
프레임 = 10ms ← 개선 (GPU-bound로 전환)
위 예시에서 CPU를 최적화하자 병목이 CPU에서 GPU로 옮겨간 것을 확인할 수 있습니다. 한쪽 병목을 해소하면 다른 쪽이 새로운 병목이 됩니다. 양쪽을 번갈아 최적화하는 과정이 반복됩니다.
GPU-bound
CPU-bound와 반대 상황도 발생합니다. CPU는 다음 프레임의 로직을 이미 끝냈지만, GPU가 아직 이전 프레임의 렌더링을 마치지 못한 상태가 GPU-bound입니다.
GPU가 처리하는 작업
GPU는 CPU가 만든 렌더링 명령을 받아 화면에 그릴 픽셀을 만들어냅니다. 이 과정은 정점 처리, 래스터라이징, 프래그먼트 처리, 출력 병합의 네 단계를 거칩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
GPU 렌더링 파이프라인 (간략화):
CPU가 보낸 명령
│
▼
┌──────────────┐
│ 정점 처리 │ 정점 셰이더 실행
│ (Vertex) │ 좌표 변환, 조명 준비
└──────┬───────┘
│
▼
┌──────────────┐
│ 래스터라이징 │ 삼각형을 픽셀로 변환
│ │
└──────┬───────┘
│
▼
┌──────────────┐
│ 프래그먼트 │ 각 픽셀의 색상 계산
│ 처리(Pixel) │ 텍스처 샘플링, 라이팅
└──────┬───────┘
│
▼
┌──────────────┐
│ 출력 병합 │ 깊이 테스트, 블렌딩
│ (Output) │ 프레임 버퍼에 기록
└──────────────┘
이 파이프라인을 구동하는 데 GPU 시간이 얼마나 걸리는지는 몇 가지 요인에 의해 결정됩니다.
먼저 드로우 콜(Draw Call)입니다. 하나의 드로우 콜은 “이 메쉬를, 이 머티리얼로, 이 셰이더를 사용해서 그려라”라는 명령입니다. 연속된 드로우 콜이 서로 다른 머티리얼을 사용하면, GPU는 렌더 스테이트(Render State)를 전환해야 합니다. 렌더 스테이트란 현재 적용 중인 셰이더, 텍스처, 블렌딩 모드 등 GPU의 그리기 설정입니다. 같은 머티리얼을 쓰는 드로우 콜이 연속되면 상태 전환 없이 처리되지만, 머티리얼이 바뀔 때마다 전환 비용이 발생합니다. 드로우 콜이 많아지면 CPU가 각 명령을 준비하고 제출하는 오버헤드가 커지고, 렌더 스테이트 전환이 잦으면 GPU 쪽에서도 파이프라인 재설정 비용이 발생합니다. 다만 GPU의 렌더링 시간을 좌우하는 주된 요인은 드로우 콜 수보다 처리할 정점과 픽셀의 총량, 셰이더 복잡도입니다.
다음으로 셰이더 연산입니다. 위 파이프라인에서 정점 처리와 프래그먼트 처리 단계가 셰이더가 실행되는 구간입니다. 정점 셰이더는 메쉬의 각 정점 위치를 변환하고, 프래그먼트 셰이더는 화면의 각 픽셀 색상을 계산합니다. 셰이더가 복잡할수록(라이팅 모델, 노멀 맵, 반사 계산 등) GPU 연산량이 늘어납니다.
프래그먼트 셰이더가 픽셀 색상을 계산할 때 가장 빈번하게 수행하는 동작이 텍스처 샘플링입니다. 셰이더가 텍스처에서 색상 값을 읽는 동작으로, GPU의 메모리 대역폭을 소모합니다. 밉맵(Mipmap)은 원본 텍스처를 절반씩 축소한 사본들을 미리 만들어 두는 기법으로, 멀리 있는 오브젝트에는 작은 밉맵을 읽어 대역폭을 절약합니다. 밉맵 없이 항상 원본 해상도를 읽거나, 고해상도 텍스처를 여러 장 동시에 읽으면 대역폭 소모가 급격히 늘어납니다.
마지막으로 후처리(Post-Processing)가 있습니다. 위 파이프라인을 거쳐 완성된 이미지에 추가 효과를 적용하는 단계입니다. 밝은 부분을 번지게 하는 블룸(Bloom), 계단 현상을 줄이는 안티앨리어싱(Anti-Aliasing), 색감을 조정하는 색보정(Color Grading), 틈새에 그림자를 넣는 앰비언트 오클루전(Ambient Occlusion) 등이 여기에 해당합니다. 각 효과는 전체 화면 픽셀을 한 번 이상 읽고 쓰므로 비용이 누적됩니다.
모바일에서 GPU-bound가 흔한 이유
모바일 기기의 GPU는 데스크톱 GPU와 설계 철학이 다릅니다. 데스크톱 GPU는 전력 제한이 크지 않으므로 순수 연산 성능을 극대화하는 방향으로 발전해 왔습니다. 반면 모바일 GPU는 배터리로 동작하므로 와트당 성능(Performance per Watt)을 최우선으로 설계합니다. 이 설계 차이가 두 가지 제약으로 이어집니다.
첫째, 필레이트(Fill Rate) 제한입니다. 필레이트는 GPU가 초당 채울 수 있는 픽셀 수입니다. 모바일 디스플레이의 해상도는 이미 2560x1440(QHD) 이상까지 올라갔지만, 모바일 GPU의 필레이트는 데스크톱 GPU보다 크게 낮습니다. 세대와 등급에 따라 차이가 다르지만, 일반적으로 데스크톱 대비 수분의 1 수준입니다. 화면 해상도에 비해 GPU의 픽셀 처리 능력이 부족한 상황이 쉽게 발생합니다.
이 필레이트 제한에 오버드로우(Overdraw)가 겹치면 문제가 심각해집니다. 오버드로우란 같은 픽셀 위치를 여러 번 그리는 것입니다. 불투명 오브젝트는 깊이 테스트(각 픽셀의 앞뒤 거리를 비교하여 뒤에 가려진 픽셀을 그리지 않는 처리)로 가려진 픽셀을 건너뛸 수 있지만, 반투명 오브젝트는 뒤에 있는 색상과 섞어야 하므로 건너뛸 수 없습니다. 뒤에서 앞으로(Back-to-Front) 순서로 그려야 하고, 이 과정에서 같은 픽셀이 반복적으로 처리됩니다. 반투명 파티클 이펙트가 여러 겹 쌓이면 오버드로우 배수가 3~5배까지 올라갈 수 있습니다. 필레이트가 제한된 모바일에서 오버드로우는 프레임 시간을 직접적으로 늘립니다.
둘째, 메모리 대역폭 제한입니다. 데스크톱 GPU는 자신만 사용하는 전용 메모리인 VRAM을 가지고 있고, GPU와 VRAM 사이를 수백 GB/s 대역폭의 고속 버스로 연결합니다. 텍스처, 메쉬 데이터, 프레임 버퍼 등 렌더링에 필요한 데이터를 GPU가 빠르게 읽고 쓸 수 있습니다.
모바일 기기는 구조가 다릅니다. CPU, GPU, 그리고 메모리가 하나의 칩(SoC) 안에 통합되어 있고, CPU와 GPU가 같은 물리 메모리(LPDDR)를 공유합니다. 이 구조를 통합 메모리(Unified Memory)라고 합니다. 메모리가 하나이므로 CPU와 GPU가 같은 메모리 버스를 나눠 씁니다. 이 공유 버스의 대역폭은 수십 GB/s 수준으로, 데스크톱 GPU의 전용 버스보다 크게 낮습니다. GPU가 텍스처를 읽는 동안 CPU도 같은 버스를 사용하므로 서로 대역폭을 빼앗깁니다. 그 결과 GPU 코어의 연산 능력은 남아 있는데 데이터를 읽어오지 못해 대기하는 상황이 발생합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
데스크톱 GPU:
┌──────────┐ 고속 전용 버스 ┌──────────┐
│ GPU │◄──────────────────────►│ VRAM │
│ │ (수백 GB/s) │ (전용) │
└──────────┘ └──────────┘
모바일 SoC:
┌──────────┐
│ CPU │◄──┐
└──────────┘ │ 공유 버스 ┌──────────┐
├────────────────────►│ 공유 │
┌──────────┐ │ (수십 GB/s) │ 메모리 │
│ GPU │◄──┘ │ (LPDDR) │
└──────────┘ └──────────┘
이런 구조적 제약 때문에 모바일 게임에서는 셰이더 복잡도, 텍스처 해상도, 후처리 수, 오버드로우를 데스크톱보다 엄격하게 관리해야 합니다.
VSync
CPU와 GPU가 병목을 결정하는 방식과 각각이 처리하는 작업을 살펴보았습니다. 하지만 프레임 시간이 16.67ms 안에 끝난다고 해서 바로 60fps로 표시되는 것은 아닙니다. GPU가 프레임을 완성한 시점과 디스플레이가 그 프레임을 표시하는 시점 사이에는 VSync(Vertical Synchronization)라는 제약이 있습니다.
디스플레이의 동작 원리
디스플레이는 화면을 주기적으로 갱신합니다. 60Hz 디스플레이라면 초당 60번, 즉 16.67ms마다 한 번씩 화면을 새로 그립니다. 갱신 방식은 왼쪽 위부터 오른쪽 아래까지 한 줄씩 스캔하는 것이며, 한 화면을 다 그리면 다시 왼쪽 위로 돌아가 다음 갱신을 시작합니다.
이 갱신 주기는 GPU가 프레임을 얼마나 빨리 만드는지와 무관하게, 디스플레이의 하드웨어 클럭에 의해 고정되어 있습니다.
티어링(Tearing)
VSync 없이 GPU가 프레임을 완성하는 즉시 프레임 버퍼에 덮어쓰면 문제가 발생합니다.
디스플레이가 화면의 위쪽 절반을 프레임 N의 데이터로 스캔하고 있는 도중에, GPU가 프레임 N+1의 결과를 프레임 버퍼에 덮어쓸 수 있습니다. 디스플레이는 나머지 아래쪽 절반을 프레임 N+1의 데이터로 스캔합니다. 결과적으로 한 화면에 두 프레임의 이미지가 나뉘어 보입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
티어링(Tearing):
┌──────────────────────┐
│ │
│ 프레임 N의 이미지 │ ← 디스플레이 스캔이 여기까지 진행된 시점에
│ │
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤ ← GPU가 프레임 버퍼를 교체
│ │
│ 프레임 N+1의 이미지 │ ← 아래쪽은 새 프레임으로 표시
│ │
└──────────────────────┘
화면이 수평으로 찢어져 보임
이 현상을 티어링(Tearing)이라 부릅니다. 티어링은 항상 발생할 수 있지만, 카메라가 정지해 있거나 느리게 움직이면 연속된 두 프레임의 이미지가 거의 동일하므로 경계선이 눈에 띄지 않습니다. 반면 카메라가 빠르게 회전하거나 오브젝트가 빠르게 이동하면 프레임 N과 N+1의 이미지 차이가 커지고, 경계선을 기준으로 위아래가 수평으로 어긋나 보입니다.
VSync의 동작
VSync는 GPU의 프레임 제출을 디스플레이의 갱신 주기에 동기화합니다. 디스플레이가 한 화면을 다 스캔하고 다음 스캔을 시작하기 전에는 짧은 빈 시간이 있습니다. 이 구간을 수직 귀선 구간(VBI, Vertical Blanking Interval)이라 부르며, V-BLANK라고도 합니다. VSync가 켜져 있으면 GPU가 프레임을 완성하더라도 VBI까지 기다렸다가 프레임 버퍼를 교체합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
VSync 동작 (60Hz):
0ms 16.67ms 33.33ms 50.00ms
│ │ │ │
화면 ─────┼─── 스캔 ─────┤VB├─── 스캔 ────┤VB├─── 스캔 ────┤VB├──
│ ↕ ↕ ↕
GPU ─────┼─ A(12ms) ─░░░░┼─ B(14ms) ──░░░┼─ C(12ms) ─░░░░┼──
↑ ↑ ↑
버퍼 교체 버퍼 교체 버퍼 교체
░ = VBlank 대기 (프레임 완성 후 교체를 기다리는 시간)
→ 프레임이 일찍 완성되어도 VBlank까지 대기 후 표시
→ 디스플레이 스캔 도중 버퍼가 바뀌지 않으므로 티어링 없음
VSync를 켜면 티어링이 사라집니다. GPU가 완성한 프레임은 VBlank에서만 교체되므로, 디스플레이가 스캔하는 도중에 버퍼가 바뀌는 일이 없습니다. 하지만 대가가 있습니다.
VSync의 프레임 드롭 현상
60Hz 디스플레이에서 VSync가 켜져 있으면, GPU는 프레임을 16.67ms 간격의 VBlank 시점에만 제출할 수 있습니다. 프레임 처리가 16.67ms 안에 끝나면 60fps로 표시됩니다.
프레임 하나가 16.67ms를 초과하면 상황이 달라집니다. 이전 프레임이 VBlank에서 교체된 직후 렌더링을 시작하는데, 완성까지 18ms가 걸리면 다음 VBlank(16.67ms 후)에는 아직 렌더링 중입니다. 그 VBlank을 놓치고 그 다음 VBlank까지 기다려야 하므로, 이전 프레임으로부터 33.33ms 후에야 표시됩니다. 사실상 30fps입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
VSync에서의 프레임 드롭:
0ms 16.67ms 33.33ms 50.00ms 66.67ms
│ │ │ │ │
화면 ─────┼─── 스캔 ─────┤VB├─── 스캔 ────┤VB├─── 스캔 ────┤VB├── 스캔 ───┤VB├──
│ ↕ X ↕ ↕
GPU ─────┼─ A(12ms) ─░░░░┼──── B(18ms) ────────░░░░░░░░░░░┼─ C(10ms) ░░░░┼──
↑ ↑ ↑
A 표시 B 표시 C 표시
A: 12ms 완성 → 16.67ms VBlank에 표시 (60fps)
B: 18ms 완성 → 33.33ms VBlank 놓침(X) → 50.00ms에 표시 (30fps로 드롭)
C: 10ms 완성 → 66.67ms VBlank에 표시 (60fps 복귀)
16.67ms에서 1ms만 초과해도 프레임 표시가 33.33ms 뒤로 밀립니다. 60fps와 30fps 사이에 중간 단계가 없습니다. 대부분의 프레임이 14ms인데 가끔 17ms짜리가 섞이면, 플레이어 눈에는 부드러운 60fps 중간에 갑작스러운 프레임 드롭으로 보입니다.
VSync의 이 계단식 드롭 특성 때문에, 60fps 목표라면 모든 프레임이 16.67ms 안에 끝나야 합니다. “평균 15ms”로는 부족합니다. 최악의 프레임 하나가 16.67ms를 넘는 순간 그 프레임은 30fps로 표시됩니다.
모바일에서는 30fps를 목표로 잡는 경우가 많습니다. 이때 프레임 예산은 33.33ms입니다. 이를 초과하면 20fps(50ms)로 떨어집니다.
1
2
3
4
5
6
7
8
9
10
VSync 프레임 예산:
목표 fps 디스플레이 프레임 예산 초과 시 드롭
─────────────────────────────────────────────────────
60fps 60Hz 16.67ms → 30fps
30fps 60Hz 33.33ms → 20fps (*)
120fps 120Hz 8.33ms → 60fps
(*) 모바일: Application.targetFrameRate = 30
데스크톱: QualitySettings.vSyncCount = 2 (VBlank 하나 건너뛰기)
프레임 페이싱
VSync는 티어링을 방지하지만, 균일한 프레임 간격까지 보장하지는 않습니다.
불균일한 프레임 간격의 문제
프레임마다 처리 시간이 조금씩 다릅니다. 어떤 프레임은 10ms, 다음은 15ms, 그 다음은 11ms가 걸립니다. 모두 16.67ms 안에 들어오면 VBlank마다 한 프레임씩 교체되어 화면 갱신 간격은 균일합니다.
하지만 대부분의 프레임이 14~15ms에 완성되는 게임이라면 여유가 1~2ms밖에 없습니다. 특정 프레임에서 파티클이 한꺼번에 생성되거나 오브젝트가 대량으로 활성화되면, 처리 시간이 16.67ms를 살짝 넘길 수 있습니다. 이 프레임은 직전 VBlank을 놓치고 다음 VBlank(33.33ms 후)까지 기다려야 합니다.
1
2
3
4
5
6
7
8
9
10
불균일한 프레임 페이싱:
VBlank: 0 16.67 33.33 50.00 66.67 83.33
│ │ │ │ │ │
이상적: ├── A ──├── B ──├── C ──├── D ──├── E ──┤
16.67 16.67 16.67 16.67 16.67 (ms 간격)
불균일: ├── A ──├── B ───────── C ──├── D ──├── E ──┤
16.67 33.33(!) 16.67 16.67 (ms 간격)
B의 처리 시간이 17ms로 16.67ms를 살짝 넘기면, B는 직전 VBlank을 놓치고 다음 VBlank에서 표시됩니다. B가 화면에 33.33ms 동안 머무는 동안 C는 바로 다음 VBlank에 표시되므로, 프레임레이트 카운터는 평균 60fps를 보여주는데 화면은 버벅거려 보입니다.
사람의 시각은 평균 프레임레이트보다 프레임 간격의 일관성에 더 민감합니다. 40fps가 일정하게 유지되는 것이, 60fps인데 간간이 프레임이 늦게 표시되는 것보다 부드럽게 느껴집니다.
프레임 페이싱의 역할
프레임 페이싱(Frame Pacing)은 프레임이 균일한 간격으로 표시되도록 GPU의 작업 제출 타이밍을 조절하는 기술입니다.
Android에서는 Choreographer라는 시스템 API가 이 역할을 합니다. Choreographer는 디스플레이의 VSync 신호를 기준으로 “지금 프레임을 준비하라”는 콜백을 발생시킵니다. 엔진은 이 콜백에 맞춰 프레임 작업을 시작함으로써, 각 프레임의 시작 시점을 디스플레이 주기에 정렬합니다.
iOS에서는 CADisplayLink라는 시스템 API가 같은 역할을 합니다. CADisplayLink는 디스플레이 갱신 직전에 콜백을 호출하여, 엔진이 VSync에 맞춰 프레임을 준비하도록 안내합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
프레임 페이싱 없음:
0ms 16.67ms 33.33ms 50.00ms
│ │ │ │
VSync ───┼───────────────┼────────────────┼────────────────┼──
│ │ │ │
GPU ────┼─ A(12ms) ─░░░░┤ B(8ms)░░░░░░░░┤ │
│ └─┐ └──┐ │
│ C(18ms)────────── ─┤ D(6ms) ─░░░░┤
│
→ 프레임 완성 후 바로 다음 프레임을 시작
→ 시작 타이밍이 VSync와 무관하게 흩어짐
→ 표시 간격: 16.67ms, 16.67ms, 33.33ms (불균일)
프레임 페이싱 적용:
0ms 16.67ms 33.33ms 50.00ms
│ │ │ │
VSync ───┼───────────────┼────────────────┼────────────────┼──
│ ↕ ↕ ↕
GPU ────┼─ A(12ms) ─░░░░┼─ B(8ms) ──░░░░┼─ C(14ms) ──░░░┼──
↑ ↑ ↑
A 표시 B 표시 C 표시
→ 매 VSync에 맞춰 프레임 시작
→ 표시 간격: 16.67ms, 16.67ms, 16.67ms (균일)
Unity에서는 Android의 경우 Optimized Frame Pacing 옵션으로 이 기능을 활성화할 수 있습니다. 이 옵션이 켜지면 Unity가 Android Frame Pacing 라이브러리를 사용하여 프레임 제출 타이밍을 디스플레이 주기에 맞춥니다.
목표 프레임레이트와 프레임 페이싱의 관계
60fps가 안정적으로 나오지 않는 모바일 기기에서 무리하게 60fps를 목표로 잡으면, 60fps와 30fps를 오가는 불안정한 상태가 됩니다. 이 경우 처음부터 30fps로 목표를 낮추고 프레임 페이싱을 적용하면, 균일한 33.33ms 간격으로 프레임이 표시됩니다. 프레임레이트 숫자는 낮지만 체감 품질은 더 높아집니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
불안정한 60fps (목표 16.67ms, 일부 프레임 초과):
0ms 16.67 33.33 50.00 66.67 83.33 100.00
│ │ │ │ │ │ │
VSync ───┼────────┼────────┼────────┼────────┼────────┼────────┼──
│ ↕ X ↕ ↕ X ↕
GPU ────┼─ A ─░░░┼── B(18ms) ──░░░┼─ C ─░░░┼─ D(19ms) ───░░░┼──
↑ ↑ ↑ ↑
A 표시 B 표시 C 표시 D 표시
표시 간격: 16.67 → 33.33 → 16.67 → 33.33ms (들쭉날쭉)
안정적인 30fps (목표 33.33ms):
0ms 33.33ms 66.67ms 100.00ms
│ │ │ │
VSync ───┼──────────────────┼───────────────────┼──────────────────┼──
│ ↕ ↕ ↕
GPU ────┼─── A(25ms) ──░░░░┼─── B(28ms) ───░░░┼─── C(22ms) ─░░░░┼──
↑ ↑ ↑
A 표시 B 표시 C 표시
표시 간격: 33.33 → 33.33 → 33.33ms (균일, 부드러운 체감)
모바일 게임에서 프레임레이트 목표를 결정할 때는 “최대 fps”가 아니라 “안정적으로 유지할 수 있는 fps”를 기준으로 삼아야 합니다.
서멀 쓰로틀링
지금까지의 논의는 CPU와 GPU의 성능이 시간이 지나도 변하지 않는다고 가정했습니다. 데스크톱에서는 이 가정이 대체로 성립하지만, 모바일 기기에서는 성립하지 않습니다.
피크 성능과 지속 성능
모바일 SoC(System on Chip)는 CPU, GPU, 메모리 컨트롤러가 손가락 끝만 한 칩 위에 밀집되어 있습니다. 전력을 많이 쓰면 열이 나고, 열을 방출할 수 있는 면적과 방열 구조가 데스크톱에 비해 제한적입니다. 팬이 없고, 방열판도 작거나 없습니다.
기기를 처음 켜거나 가벼운 작업 후에는 칩 온도가 낮아 최대 클럭으로 동작합니다. 이때의 성능을 피크 성능(Peak Performance)이라 부르며, 벤치마크 앱이 측정하는 수치가 바로 이 피크 성능입니다.
하지만 게임처럼 CPU와 GPU를 동시에 고부하로 사용하면 수 분 내에 칩 온도가 올라갑니다. 온도가 임계치에 도달하면 SoC는 스스로 클럭을 낮춥니다. 이 현상이 서멀 쓰로틀링(Thermal Throttling)입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
서멀 쓰로틀링에 의한 성능 저하:
성능
│
│ ████
│ ████
│ ████ ████
│ ████ ████ ████
│ ████ ████ ████ ████
│ ████ ████ ████ ████ ████ ████ ████ ████
│ ████ ████ ████ ████ ████ ████ ████ ████
│─────────────────────────────────────────── 시간
│
0 2 4 6 8 10 12 14 (분)
│ │
▼ ▼
피크 성능 지속 성능
(100%) (50~70%)
피크 성능 대비 지속 성능(Sustained Performance)의 비율은 기기마다 다르지만, 일반적으로 50~70% 수준입니다. 피크 성능 기준으로 60fps가 나오던 게임이 10분 플레이 후 30~40fps로 떨어질 수 있습니다.
쓰로틀링이 프레임에 미치는 영향
서멀 쓰로틀링이 발생하면 CPU와 GPU의 클럭이 모두 낮아집니다. 클럭이 60%로 떨어지면, 이전까지 10ms 걸리던 CPU 작업이 약 17ms로 늘어나고, 12ms 걸리던 GPU 작업이 약 20ms로 늘어납니다. 프레임 시간은 CPU와 GPU 중 더 오래 걸리는 쪽이 결정하므로, GPU의 20ms가 프레임 시간이 됩니다. 16.67ms를 초과하므로 VSync 드롭이 발생합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
쓰로틀링 전후 비교:
쓰로틀링 전 (피크 성능):
0ms 12ms 16.67ms
│ │ │
CPU ─────────┼── 10ms ──┤ │ │
GPU ─────────┼──── 12ms ─────┤░░░░░░░░┤
↑
16.67ms 이내 → 60fps
쓰로틀링 후 (클럭 60%):
0ms 16.67ms 33.33ms
│ │ │
CPU ─────────┼───── 17ms ──────┤│ │
GPU ─────────┼─────── 20ms ────────┤░░░░░░░░░┤
X ↑
16.67ms 초과 → 30fps로 드롭
피크 성능에서 여유 있게 60fps를 달성하던 게임이 쓰로틀링 후 30fps로 떨어질 수 있습니다. 플레이어 입장에서는 게임 시작 직후에는 부드럽다가, 플레이 시간이 길어지면서 점차 버벅거리기 시작하는 경험을 하게 됩니다.
피크 성능이 아닌 지속 성능을 기준으로 설계
모바일 게임의 성능 목표는 피크 성능이 아니라 지속 성능을 기준으로 잡아야 합니다. 피크 성능 기준으로 60fps를 목표하면 10분 후 쓰로틀링으로 30fps까지 떨어집니다. 지속 성능 기준으로 30fps를 목표하면 플레이 시간에 관계없이 일정한 경험을 제공합니다.
서멀 쓰로틀링에 대응하는 방법은 런타임에서 기기 온도를 감지하고, 온도가 높아지면 렌더링 품질이나 물리 연산 범위를 자동으로 낮추는 것입니다. 그림자 해상도를 줄이거나, 후처리 효과를 끄거나, 렌더링 해상도를 낮추는 식으로 GPU 부하를 단계적으로 낮춥니다.
프레임 병목 진단의 흐름
병목 판별, VSync, 프레임 페이싱, 서멀 쓰로틀링을 하나의 진단 흐름으로 연결하면 다음 구조가 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
프레임 성능 진단 흐름:
1. 목표 프레임레이트 설정
└─ 지속 성능 기준 (피크 성능 X)
│
▼
2. 프레임 시간 측정 ◀──────────────────────────┐
└─ 목표 달성? │
│ │
├── 예 ──→ 균일한 간격? │
│ │ │ │
│ 예 → 완료 아니오 │
│ │ │
│ 프레임 페이싱 │
│ 설정 확인 ─────────┘
아니오
│
▼
3. 병목 판별
├─ CPU 시간 > GPU 시간 → CPU-bound ─┐
└─ GPU 시간 > CPU 시간 → GPU-bound ─┤
│
┌─────────────────────────────┘
▼
4. 병목 서브시스템 식별
├─ CPU-bound: 스크립트? 물리? 애니메이션? UI?
└─ GPU-bound: 셰이더? 필레이트? 대역폭? 후처리?
│
▼
5. 해당 서브시스템 최적화
└─ 다시 2번으로 ─────────────────────────────┘
쓰로틀링을 고려하면 진단 흐름의 전제가 달라집니다. 2번(프레임 시간 측정)을 콜드 스타트 직후에 수행하면 피크 성능이 측정되어, 실제 플레이 환경과 다른 결과가 나옵니다. 콜드 스타트(Cold Start)란 기기를 막 켜서 칩 온도가 낮은 상태를 말합니다. 이때는 쓰로틀링이 아직 발생하지 않아 피크 성능이 나옵니다. 측정은 기기를 충분히 예열한 상태(10분 이상 게임 실행 후)에서 수행해야 지속 성능 기준의 결과를 얻을 수 있습니다.
마무리
- 프레임 시간은 CPU 시간과 GPU 시간 중 더 긴 쪽이 결정합니다. 느린 쪽이 병목입니다.
- CPU-bound일 때는 스크립트, 물리, 애니메이션, UI, 렌더 준비 중 원인을 식별합니다. GPU-bound일 때는 셰이더 복잡도, 필레이트, 메모리 대역폭, 후처리를 점검합니다.
- 모바일 GPU는 타일 기반 렌더링, 공유 메모리, 제한된 대역폭이라는 구조적 제약이 있어 GPU-bound가 발생하기 쉽습니다.
- VSync는 티어링을 방지하지만, 프레임 예산을 초과하면 프레임레이트가 계단식으로 떨어집니다(60→30→20fps).
- 프레임 페이싱은 프레임 시작 시점을 VSync에 정렬하여 표시 간격을 균일하게 유지합니다. 불안정한 60fps보다 안정적인 30fps가 체감 품질이 높습니다.
- 서멀 쓰로틀링은 지속 성능을 피크 대비 50~70%로 떨어뜨립니다. 모바일 게임의 성능 목표는 지속 성능 기준으로 설정해야 합니다.
한쪽 병목을 해소하면 다른 쪽이 새로운 병목이 됩니다. 모바일에서는 시간이 지나면서 쓰로틀링까지 겹칩니다. 프레임 성능을 안정적으로 유지하려면, 지속 성능 기준으로 목표를 잡고 측정과 최적화를 반복해야 합니다.
프레임 예산의 대부분은 렌더링에 쓰입니다. GPU가 처리하는 데이터 — 메쉬, 텍스처, 셰이더, 머티리얼 — 가 어떤 구조로 되어 있는지 이해하면, 병목의 원인을 더 정확하게 짚어낼 수 있습니다.
관련 글
시리즈
- 게임 루프의 원리 (1) - 프레임의 구조
- 게임 루프의 원리 (2) - CPU-bound와 GPU-bound (현재 글)