작성일 :

GPU가 필요해진 이유

렌더링 기초 시리즈에서 메쉬(정점과 삼각형), 텍스처, 머티리얼과 셰이더를 살펴보았습니다. 메쉬는 3D 오브젝트의 골격이고, 텍스처는 그 위에 입히는 이미지이며, 머티리얼은 텍스처와 셰이더를 묶어 오브젝트의 외형을 결정하는 단위였습니다.

이 중 셰이더는 GPU에서 실행되는 프로그램입니다. 머티리얼이 “이 오브젝트를 어떤 셰이더로 그려라”라고 지정하면, GPU가 해당 셰이더 코드를 수천 번 동시에 실행하여 화면에 픽셀을 채웁니다.


1990년대까지 3D 렌더링은 CPU가 전담했습니다.

정점 좌표를 변환하고, 삼각형을 하나씩 래스터화하고, 픽셀 색상을 계산하는 작업을 CPU가 순차적으로 처리했습니다.

그러나 화면 해상도가 높아지고 3D 장면이 복잡해지면서, 수백만 개의 픽셀에 대해 같은 종류의 연산을 반복하는 작업이 CPU의 처리 능력을 초과하기 시작했습니다.


GPU는 이 한계를 돌파하기 위해 등장한 프로세서입니다.

CPU가 소수의 강력한 코어로 복잡한 작업을 빠르게 처리하는 쪽에 집중한다면, GPU는 수천 개의 작은 코어로 “같은 연산을 대량의 데이터에 동시 적용”하는 쪽에 특화되어 있습니다.


셰이더의 성능은 GPU가 데이터를 처리하는 방식에 직접 의존합니다.

셰이더에 분기문(if)을 넣었을 때 성능이 떨어지는 원인, 프래그먼트 셰이더(픽셀 색상을 계산하는 단계)가 버텍스 셰이더(정점 위치를 계산하는 단계)보다 병목이 되기 쉬운 구조적 이유 — 이런 현상은 모두 GPU 아키텍처에서 비롯됩니다.


이 글에서는 CPU와 GPU의 아키텍처 차이, GPU의 병렬 실행 모델(SIMD/SIMT), 논리적 렌더링 파이프라인, 그리고 데스크톱 GPU의 IMR(Immediate Mode Rendering) 방식을 다룹니다.


CPU와 GPU: 설계 철학의 차이

CPU와 GPU는 모두 연산 장치이지만, 근본적으로 다른 문제를 풀기 위해 설계되었습니다.

CPU: 복잡한 작업을 빠르게

CPU는 소수의 강력한 코어로 구성됩니다. 일반적인 데스크톱 CPU는 6~16개의 코어를 가지며, 각 코어는 독립적으로 복잡한 연산을 수행할 수 있습니다.

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 내부 구조 (간략화)

┌───────────────────────────────────────────────────────────┐
│                         CPU                               │
│                                                           │
│  ┌──────────────────────────────────────────────────────┐ │
│  │                    대용량 캐시                        │ │
│  │              L1 / L2 / L3 (수 MB ~ 수십 MB)          │ │
│  └──────────────────────────────────────────────────────┘ │
│                                                           │
│  ┌────────────────┐   ┌────────────────┐                  │
│  │    코어 0       │   │    코어 1       │                  │
│  │                │   │                │                  │
│  │  ┌───────────┐ │   │  ┌───────────┐ │                  │
│  │  │ 제어 유닛  │ │   │  │ 제어 유닛  │ │                  │
│  │  └───────────┘ │   │  └───────────┘ │                  │
│  │  ┌───────────┐ │   │  ┌───────────┐ │                  │
│  │  │  ALU      │ │   │  │  ALU      │ │                  │
│  │  └───────────┘ │   │  └───────────┘ │                  │
│  │  ┌───────────┐ │   │  ┌───────────┐ │                  │
│  │  │ 분기 예측  │ │   │  │ 분기 예측  │ │                  │
│  │  └───────────┘ │   │  └───────────┘ │                  │
│  │  ┌───────────┐ │   │  ┌───────────┐ │                  │
│  │  │비순서 실행 │ │   │  │비순서 실행 │ │                  │
│  │  └───────────┘ │   │  └───────────┘ │                  │
│  └────────────────┘   └────────────────┘   ...            │
│        (6 ~ 16개 코어)                                    │
└───────────────────────────────────────────────────────────┘


CPU 코어 하나의 내부에는 연산 유닛(ALU, Arithmetic Logic Unit) 외에도 제어 유닛, 분기 예측기(Branch Predictor), 비순서 실행(Out-of-Order Execution) 장치, 그리고 대용량 캐시(L1/L2/L3)가 들어 있습니다. 여기서 L1/L2/L3은 코어에 가까운 순서대로 번호를 매긴 캐시 메모리 계층(Level 1/2/3)입니다.


CPU는 하나의 명령어를 여러 단계(읽기, 해석, 실행, 저장)로 나누어 처리합니다. 이 구조를 명령어 파이프라인이라 합니다.

단계를 나누면, 한 명령어가 “실행” 단계에 있는 동안 다음 명령어를 “해석”하는 식으로 여러 명령어를 겹쳐서 진행할 수 있습니다. 파이프라인이 끊기지 않고 흘러갈수록 단위 시간당 완료되는 명령어 수가 늘어납니다.


하지만 파이프라인은 여러 원인으로 멈출 수 있습니다. if문을 만나면 조건 결과가 나올 때까지 다음 명령어를 넣을 수 없고, 앞 명령어의 결과를 기다려야 하는 명령어가 있으면 파이프라인에 빈 틈이 생기며, 데이터가 메인 메모리에 있으면 수백 사이클 동안 대기해야 합니다. 코어 내부의 장치들은 이 멈춤을 방지합니다.

분기 예측기는 if문의 결과를 미리 추측하여, 조건 판정이 끝나기 전에 다음 명령어를 파이프라인에 넣어 둡니다. 추측이 맞으면 멈춤 없이 파이프라인이 계속 흐릅니다.

비순서 실행 장치는 명령어 간의 의존성을 분석하여, 순서를 바꿔도 결과가 같은 명령어를 먼저 실행함으로써 파이프라인의 빈 틈을 줄입니다.

대용량 캐시는 메인 메모리의 데이터를 코어 가까이에 복사해 두어, 메모리 접근 대기 시간을 줄입니다.

이 장치들은 모두 레이턴시(latency, 하나의 작업을 완료하는 데 걸리는 시간) 를 줄이기 위한 설계입니다.

GPU: 같은 작업을 대량으로

앞에서 CPU는 레이턴시를 줄이기 위해 코어 하나를 정교하게 만든다고 했습니다. GPU는 정반대입니다.

코어 하나의 성능을 높이는 대신, 단순한 코어를 수천 개 배치합니다.

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
GPU 내부 구조 (간략화)

┌───────────────────────────────────────────────────────────┐
│                         GPU                               │
│                                                           │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐        │
│  │   SM 0  │ │   SM 1  │ │   SM 2  │ │   SM 3  │  ...   │
│  │         │ │         │ │         │ │         │        │
│  │ ┌─┬─┬─┐│ │ ┌─┬─┬─┐│ │ ┌─┬─┬─┐│ │ ┌─┬─┬─┐│        │
│  │ │c│c│c││ │ │c│c│c││ │ │c│c│c││ │ │c│c│c││        │
│  │ ├─┼─┼─┤│ │ ├─┼─┼─┤│ │ ├─┼─┼─┤│ │ ├─┼─┼─┤│        │
│  │ │c│c│c││ │ │c│c│c││ │ │c│c│c││ │ │c│c│c││        │
│  │ ├─┼─┼─┤│ │ ├─┼─┼─┤│ │ ├─┼─┼─┤│ │ ├─┼─┼─┤│        │
│  │ │c│c│c││ │ │c│c│c││ │ │c│c│c││ │ │c│c│c││        │
│  │ ├─┼─┼─┤│ │ ├─┼─┼─┤│ │ ├─┼─┼─┤│ │ ├─┼─┼─┤│        │
│  │ │c│c│c││ │ │c│c│c││ │ │c│c│c││ │ │c│c│c││        │
│  │ └─┴─┴─┘│ │ └─┴─┴─┘│ │ └─┴─┴─┘│ │ └─┴─┴─┘│        │
│  │ (128+  ││ │ (128+  ││ │ (128+  ││ │ (128+  ││        │
│  │  코어)  ││ │  코어)  ││ │  코어)  ││ │  코어)  ││        │
│  └─────────┘ └─────────┘ └─────────┘ └─────────┘        │
│                                                           │
│  ┌──────────────────────────────────────────────────────┐ │
│  │                  공유 메모리 / 캐시                    │ │
│  │                   (CPU 대비 소용량)                    │ │
│  └──────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘


GPU의 기본 단위는 SM(Streaming Multiprocessor) (NVIDIA 기준) 또는 CU(Compute Unit) (AMD 기준)입니다. 하나의 SM 안에 128개 이상의 작은 코어(CUDA 코어)가 있고, SM이 수십 개 모여서 전체적으로 수천 개의 코어를 구성합니다. SM의 개수는 GPU 모델마다 다르므로, 같은 작업이라도 SM이 많은 GPU에서 더 빠르게 처리됩니다.

개별 CUDA 코어는 CPU 코어와 비교하면 단순합니다. 앞에서 살펴본 분기 예측기도 없고, 비순서 실행 장치도 없으며, 캐시도 작습니다. GPU가 처리하는 작업에 이 장치들이 필요 없기 때문입니다.

예를 들어, 1920×1080 해상도의 화면에서 각 픽셀에 조명 계산을 적용한다고 하면, 약 200만 개의 픽셀이 모두 같은 셰이더 프로그램을 실행합니다. 픽셀마다 좌표와 법선 벡터 같은 입력 데이터만 다를 뿐, 연산 자체는 동일합니다. if문으로 분기할 일이 거의 없고, 명령어 순서를 바꿀 필요도 없습니다.

CPU에서 파이프라인 멈춤을 방지하던 장치들이 GPU에는 필요 없고, 그 트랜지스터 예산으로 더 많은 연산 유닛을 넣을 수 있습니다.

하나의 작업을 빠르게 끝내는 능력에서는 CPU 코어에 한참 못 미치지만, 단위 시간당 처리하는 작업의 총량, 즉 스루풋(throughput) 에서는 CPU를 압도합니다.

CPU와 GPU의 비유

CPU는 교수 4명이 각자 복잡한 연구 과제를 순서대로 풀어가는 것에 해당합니다. 각 교수는 논리적으로 복잡한 문제도 빠르게 해결할 수 있지만, 동시에 처리할 수 있는 과제의 수는 4개뿐입니다.

GPU는 학생 4,000명이 같은 유형의 계산 문제를 동시에 풀어가는 것에 해당합니다. 개별 학생이 풀 수 있는 문제의 복잡도는 제한적이지만, 같은 종류의 문제가 4,000개 있다면 모두 한꺼번에 끝낼 수 있습니다.


렌더링은 정확히 GPU의 강점에 들어맞는 작업입니다.

화면에 그려야 하는 정점이 10만 개라면, 10만 개의 정점 각각에 같은 좌표 변환 연산을 적용해야 합니다.

화면의 픽셀이 200만 개라면, 200만 개 각각에 같은 색상 계산을 수행해야 합니다.

데이터는 다르지만 연산은 동일합니다. GPU가 렌더링에 사용되는 이유가 여기에 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
CPU vs GPU 설계 비교

┌──────────────────────────────┬──────────┬──────────┐
│          작업 특성            │   CPU    │   GPU    │
├──────────────────────────────┼──────────┼──────────┤
│ 복잡한 분기 로직              │    ◎     │    △     │
│ 순차 의존성이 높은 연산        │    ◎     │    △     │
│ 동일 연산의 대량 반복          │    △     │    ◎     │
├──────────────────────────────┼──────────┼──────────┤
│ 설계 목표                    │ 레이턴시  │ 스루풋    │
└──────────────────────────────┴──────────┴──────────┘
◎ 강점  △ 약점


Unity에서 게임 로직(스크립트, 물리 시뮬레이션, AI)은 CPU에서 실행되고, 렌더링(정점 변환, 래스터화, 셰이딩)은 GPU에서 실행됩니다. 최적화를 할 때 병목이 CPU에 있는지 GPU에 있는지를 먼저 구분해야 하는 이유가 여기에 있습니다.


SIMD와 SIMT: GPU의 병렬 실행 모델

앞에서 GPU의 코어들이 같은 연산을 동시에 수행한다고 했습니다. 같은 연산을 수행하는 것은 맞지만, 각 코어가 독립적으로 명령어를 읽고 실행하는 것은 아닙니다.

GPU는 코어 여러 개를 하나의 그룹으로 묶고, 그룹 전체가 하나의 명령어를 공유합니다.

SIMD: 하나의 명령어, 여러 데이터

이 원리를 SIMD(Single Instruction, Multiple Data) 라 합니다. 정점 4개의 x좌표를 각각 2배로 만들어야 하는 상황으로 비교하면 차이가 명확합니다.

1
2
3
4
5
6
7
일반 처리 (SISD - Single Instruction, Single Data):

명령 1: x0 = x0 * 2
명령 2: x1 = x1 * 2
명령 3: x2 = x2 * 2
명령 4: x3 = x3 * 2
→ 4개의 명령이 순서대로 실행됨
1
2
3
4
SIMD 처리:

하나의 명령: [x0, x1, x2, x3] = [x0, x1, x2, x3] * 2
→ 1개의 명령이 4개의 데이터를 동시에 처리


SIMD의 핵심은 프로세서 내부의 레지스터입니다.

레지스터란 데이터를 임시로 저장하는 공간인데, SIMD에서는 하나의 넓은 레지스터에 여러 데이터를 나란히 넣고, 하나의 명령으로 모든 데이터에 같은 연산을 적용합니다.

CPU도 SIMD 명령어(SSE, AVX 등)를 지원하지만, 이는 기능의 일부입니다. GPU는 아키텍처 전체가 이 원리 위에 구축되어 있습니다.

셰이더에서 float4(4개의 float를 묶은 벡터)를 사용하면 4개의 값을 하나의 SIMD 연산으로 처리할 수 있는 이유도, GPU의 데이터 경로 자체가 벡터 단위에 맞춰 설계되어 있기 때문입니다.

SIMT: 하나의 명령어, 여러 스레드

앞에서 살펴본 SIMD는 하나의 명령어가 여러 데이터 값을 동시에 처리하는 것이었습니다. float4의 x, y, z, w 네 값에 같은 연산을 한 번에 적용하는 식입니다.

SIMT(Single Instruction, Multiple Threads) 는 여러 스레드가 같은 명령어를 동시에 실행하는 것입니다. 정점 0을 처리하는 스레드와 정점 1을 처리하는 스레드가 같은 셰이더 명령어를 같은 순간에 실행합니다.


셰이더를 작성할 때는 정점 하나, 픽셀 하나의 처리만 작성합니다. GPU가 이 프로그램을 수천 개의 스레드에 배분하여, 각 스레드가 서로 다른 정점이나 프래그먼트를 동시에 처리합니다.

1
2
3
4
5
6
7
8
9
10
11
SIMT 실행 모델 — 모든 스레드가 같은 명령어를 같은 순간에 실행

시간  │  스레드 0   │  스레드 1   │  스레드 2   │  스레드 3   │
──────┼────────────┼────────────┼────────────┼────────────┤
  t0  │  월드 변환  │  월드 변환  │  월드 변환  │  월드 변환  │ ← 같은 명령어
  t1  │ 카메라 변환 │ 카메라 변환 │ 카메라 변환 │ 카메라 변환 │ ← 같은 명령어
  t2  │  클립 변환  │  클립 변환  │  클립 변환  │  클립 변환  │ ← 같은 명령어
──────┼────────────┼────────────┼────────────┼────────────┤
      │   정점 0   │   정점 1   │   정점 2   │   정점 3   │
      └────────────┴────────────┴────────────┴────────────┘
                      서로 다른 데이터


스레드 수가 수천 개에 달하면 각각을 개별적으로 관리하기는 어렵습니다.

따라서, GPU는 스레드들을 Warp(NVIDIA 용어) 또는 Wavefront(AMD 용어)라는 단위로 묶어서 관리합니다.

NVIDIA GPU에서 하나의 Warp는 32개의 스레드로 구성되며, 이 크기는 모든 NVIDIA GPU에서 동일합니다. AMD GPU에서는 Wavefront라 부르며, 아키텍처에 따라 32개 또는 64개입니다. 동작 원리는 같으므로, 앞으로는 Warp(32스레드)를 기준으로 살펴봅니다.

Warp 안의 32개 스레드는 항상 같은 명령어를 같은 순간에 실행합니다. 스레드 0이 “정점을 월드 좌표로 변환”하는 명령을 실행할 때, 같은 Warp에 속한 스레드 1~31도 동시에 같은 명령을 실행합니다. 다만 각 스레드가 참조하는 정점 데이터가 다를 뿐입니다.

1
2
3
4
5
6
7
8
9
10
11
Warp 구성 — 스레드를 32개씩 묶어 관리

Warp 0                     Warp 1                     Warp 2
┌────────────────────┐    ┌────────────────────┐    ┌────────────────────┐
│ 스레드 0  → 정점 0 │    │ 스레드 32 → 정점 32│    │ 스레드 64 → 정점 64│
│ 스레드 1  → 정점 1 │    │ 스레드 33 → 정점 33│    │ 스레드 65 → 정점 65│
│       ...          │    │       ...          │    │       ...          │
│ 스레드 31 → 정점 31│    │ 스레드 63 → 정점 63│    │ 스레드 95 → 정점 95│
└────────────────────┘    └────────────────────┘    └────────────────────┘
  32개가 항상 같은            32개가 항상 같은            32개가 항상 같은
  명령어를 동시에 실행        명령어를 동시에 실행        명령어를 동시에 실행


셰이더를 작성할 때는 각 정점이나 프래그먼트마다 독립적인 스레드가 실행된다고 생각하면 됩니다. GPU가 알아서 스레드들을 Warp로 묶고, Warp 안에서는 SIMD 하드웨어로 동시에 처리합니다.

분기(if문)가 GPU에서 비용이 큰 이유

Warp 안의 32개 스레드는 항상 같은 명령어를 같은 순간에 실행합니다. if문의 조건 판정 자체는 모든 스레드가 같은 명령어로 수행합니다.

문제는 조건 판정 이후입니다. if 블록 안의 코드와 else 블록 안의 코드는 서로 다른 명령어입니다. 스레드마다 조건 결과가 다르면, 어떤 스레드는 if 블록을, 어떤 스레드는 else 블록을 실행해야 합니다.

하지만 Warp는 한 번에 하나의 명령어만 실행할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
// 프래그먼트 셰이더 예시
if (brightness > 0.5)
{
    // 밝은 영역: 조명 계산 + 반사광
    color = baseColor * lightIntensity;
    color += specular * roughness;
}
else
{
    // 어두운 영역: 그림자 색상만 적용
    color = baseColor * shadowFactor;
}

Warp 안의 32개 스레드는 각자 다른 프래그먼트(화면의 픽셀 후보)를 담당합니다.

프래그먼트마다 밝기 값이 다르므로, 같은 Warp 안에서 어떤 스레드는 if 블록으로, 어떤 스레드는 else 블록으로 갈라질 수 있습니다. 이 상황을 분기 발산(divergent branch) 이라 합니다.

분기 발산이 발생하면 GPU는 양쪽 경로를 순서대로 모두 실행합니다.

먼저 if 블록을 실행하는 동안 else에 해당하는 스레드는 아무 일도 하지 않고 대기합니다.

그 다음 else 블록을 실행하는 동안 if에 해당하는 스레드가 대기합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
분기 발산 시 실행 흐름

Warp 내 32개 스레드 상태:
  스레드 0~19:  brightness > 0.5  → true  (if 블록)
  스레드 20~31: brightness > 0.5  → false (else 블록)

┌─────────────────────────────────────────────────────────┐
│ 1. if 조건 평가 (32개 스레드 모두 실행)                   │
├─────────────────────────────────────────────────────────┤
│ 2. if 블록 실행: color = baseColor * lightIntensity      │
│                  color += specular * roughness           │
│    스레드 0~19:   실행 → 결과 저장                        │
│    스레드 20~31:  대기 (아무 일도 하지 않음)               │
├─────────────────────────────────────────────────────────┤
│ 3. else 블록 실행: color = baseColor * shadowFactor      │
│    스레드 0~19:   대기 (아무 일도 하지 않음)               │
│    스레드 20~31:  실행 → 결과 저장                        │
├─────────────────────────────────────────────────────────┤
│ 4. 합류 — 32개 스레드가 다시 같은 명령어를 실행           │
└─────────────────────────────────────────────────────────┘

→ if와 else를 모두 실행하므로, 총 실행 시간 = if + else


분기 발산이 없었다면 A 또는 B 중 한쪽 경로만 실행하면 됩니다. 그러나 발산이 일어나면 양쪽 경로를 순서대로 모두 실행해야 하므로, 최악의 경우 실행 시간이 2배로 늘어납니다.

단, Warp 안의 32개 스레드가 모두 같은 방향으로 분기하면 성능 저하가 없습니다. 32개 스레드 전부 true라면 true 경로만, 전부 false라면 false 경로만 실행하기 때문입니다. 비용이 발생하는 것은 같은 Warp 안에서 분기 방향이 갈리는 경우뿐입니다.

Unity 셰이더 최적화에서 “셰이더의 if문을 줄여라”라는 조언이 나오는 배경이 바로 이 구조입니다. 모든 if문이 성능에 해로운 것은 아니지만, 같은 Warp 안에서 스레드들이 서로 다른 방향으로 분기하는 if문은 실행 시간을 직접 늘립니다.


논리적 렌더링 파이프라인

GPU의 병렬 처리 방식을 바탕으로, 이제 GPU가 3D 데이터를 화면의 2D 이미지로 변환하는 전체 과정을 살펴봅니다. 이 과정을 렌더링 파이프라인이라 부릅니다.

렌더링 파이프라인에서는 정점 데이터가 여러 단계를 순차적으로 거쳐 최종 이미지가 됩니다.

각 단계는 이전 단계의 출력을 입력으로 받아 처리한 뒤 다음 단계로 넘깁니다.


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
32
33
34
35
36
37
렌더링 파이프라인 전체 흐름

  ┌─────────────────────┐
  │   정점 데이터 입력    │  정점 좌표, UV, 노멀 등
  └─────────┬───────────┘
            │  데이터: 정점
            ▼
  ┌─────────────────────┐
  │   버텍스 셰이더      │  좌표 변환 (로컬 → 클립)       ◀ 프로그래밍 가능
  └─────────┬───────────┘
            │  데이터: 변환된 정점
            ▼
  ┌─────────────────────┐
  │  프리머티브 어셈블리   │  정점 → 삼각형 조립
  │  + 클리핑            │  화면 밖 삼각형 제거            ◁ 고정 기능
  └─────────┬───────────┘
            │  데이터: 삼각형
            ▼
  ┌─────────────────────┐
  │    래스터화          │  삼각형 → 프래그먼트 변환       ◁ 고정 기능
  └─────────┬───────────┘
            │  데이터: 프래그먼트 (수가 급증)
            ▼
  ┌─────────────────────┐
  │  프래그먼트 셰이더    │  최종 색상 계산                ◀ 프로그래밍 가능
  └─────────┬───────────┘
            │  데이터: 색상 + 깊이
            ▼
  ┌─────────────────────┐
  │  깊이/스텐실 테스트   │  가려진 프래그먼트 제거
  │  + 블렌딩           │  반투명 합성                   ◁ 고정 기능
  └─────────┬───────────┘
            │
            ▼
  ┌─────────────────────┐
  │   프레임버퍼 출력     │  최종 이미지 → 화면 표시
  └─────────────────────┘

1단계: 정점 데이터 입력 (Vertex Input)

렌더링은 CPU가 GPU에 드로우 콜(Draw Call) 을 보내는 것으로 시작됩니다.

드로우 콜 하나는 “이 메쉬를, 이 머티리얼로, 그려라”라는 명령입니다. GPU는 이 명령을 받으면 GPU 메모리(VRAM) 에서 메쉬의 정점 데이터를 읽어옵니다.

메쉬 데이터는 에셋 로딩 시점에 CPU가 디스크에서 읽어 GPU 메모리에 미리 업로드해 둔 것이므로, 드로우 콜 시점에는 GPU가 자신의 메모리에서 바로 읽습니다.

렌더링 기초 (1)에서 다룬 것처럼, 정점 하나에는 위치(position), 텍스처 좌표(UV), 노멀(normal) 등의 속성(attribute)이 포함되어 있습니다.

GPU 메모리에는 이 정점들이 정점 버퍼(Vertex Buffer) 라는 연속된 배열로 저장되어 있습니다.

1
2
3
4
5
6
정점 버퍼 예시 (바닥 평면의 네 꼭짓점)

정점 0: 위치(-1.0, 0.0, -1.0)  UV(0.0, 0.0)  노멀(0.0, 1.0, 0.0)
정점 1: 위치( 1.0, 0.0, -1.0)  UV(1.0, 0.0)  노멀(0.0, 1.0, 0.0)
정점 2: 위치( 1.0, 0.0,  1.0)  UV(1.0, 1.0)  노멀(0.0, 1.0, 0.0)
정점 3: 위치(-1.0, 0.0,  1.0)  UV(0.0, 1.0)  노멀(0.0, 1.0, 0.0)

정점 버퍼에는 정점의 속성만 나열되어 있고, 어떤 정점들을 묶어 삼각형을 만들지는 기록되어 있지 않습니다. 삼각형의 조합을 지정하는 것이 인덱스 버퍼(Index Buffer) 입니다.

위 네 정점으로 사각형을 만들려면 삼각형 두 개가 필요하므로, 인덱스 버퍼는 [0, 1, 2]와 [0, 2, 3]을 지정합니다. 정점 0과 정점 2는 두 삼각형이 공유하지만, 인덱스로 참조하므로 정점 데이터를 중복 저장할 필요가 없습니다.

2단계: 버텍스 셰이더 (Vertex Shader)

버텍스 셰이더는 정점 하나당 하나의 스레드가 실행됩니다.

메쉬에 정점이 10,000개 있으면 GPU는 스레드 10,000개를 생성하고, 앞서 살펴본 SIMT 모델에 따라 32개씩 Warp로 묶어 병렬 처리합니다. 10,000개가 동시에 실행되는 것은 아니고, 313개의 Warp(10,000 ÷ 32)가 GPU의 SM들에 분배되어 스케줄링됩니다.

각 스레드가 수행하는 핵심 작업은 좌표 변환입니다.

1단계에서 읽어온 정점 좌표는 메쉬 자체의 로컬 좌표계로 되어 있어서, 이 정점이 화면의 어디에 찍혀야 하는지 알 수 없습니다. 예를 들어 캐릭터 모델의 코 끝이 (0, 1.7, 0.1)이라면, 이는 캐릭터 모델 내부에서의 위치일 뿐입니다.

버텍스 셰이더가 이 로컬 좌표에 오브젝트의 위치와 회전, 카메라의 시점, 원근감을 차례로 반영하여 최종 화면 좌표로 변환합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
좌표 변환 과정

로컬 공간       (0, 1.7, 0.1)             메쉬 내부 좌표
    │
    │  × 모델 행렬: 위치·회전·크기 반영
    ▼
월드 공간       (10, 1.7, 5.1)            게임 세계 좌표
    │
    │  × 뷰 행렬: 카메라 기준으로 좌표계 변환
    ▼
카메라 공간                                카메라를 원점에 놓고 본 좌표
    │
    │  × 프로젝션 행렬: 원근감 적용
    ▼
클립 공간                                  화면 표시 범위 판정 좌표
                                           범위 밖 → 클리핑에서 제거


다이어그램의 세 변환은 각각 4×4 행렬을 정점 좌표에 곱하는 것으로 수행됩니다.

실제로는 세 행렬(모델, 뷰, 프로젝션)을 미리 하나로 합친 MVP(Model-View-Projection) 행렬을 정점 좌표에 한 번만 곱합니다.

Unity에서는 UnityObjectToClipPos(v.vertex) 함수가 이 MVP 변환을 수행합니다.

행렬 곱셈은 앞에서 살펴본 float4 벡터 단위의 곱셈과 덧셈으로 구성되므로, GPU의 SIMD 유닛이 효율적으로 처리합니다.

좌표 변환이 버텍스 셰이더의 핵심 역할이지만, 프로그래밍 가능한 단계이므로 다른 작업도 수행할 수 있습니다. 바람에 흔들리는 풀처럼 정점 위치를 직접 움직이는 애니메이션이 대표적이고, 비용을 줄이기 위해 프래그먼트 셰이더 대신 정점 단위로 조명을 근사하는 경우도 있습니다.

다만 정점 단위 조명은 삼각형 면 위의 밝기를 정점 값에서 보간하므로, 조명 변화가 부드럽지 않게 보일 수 있습니다. 현대 렌더링에서는 프래그먼트 셰이더에서 픽셀마다 조명을 계산하는 방식이 일반적입니다.

버텍스 셰이더의 핵심 작업은 좌표 변환이며, 나머지는 필요에 따라 추가하는 선택적 작업입니다.

3단계: 프리머티브 어셈블리와 클리핑

2단계에서 변환된 정점들이 1단계의 인덱스 버퍼에 따라 삼각형으로 조립됩니다.

렌더링에서 삼각형 하나를 프리머티브(Primitive) 라 부르며, 이 조립 과정이 프리머티브 어셈블리입니다. GPU 하드웨어가 고정적으로 수행하며, 셰이더로 제어할 수 없습니다.

조립된 삼각형 중 카메라의 시야 영역인 뷰 프러스텀(View Frustum) 밖에 있는 삼각형은 클리핑(Clipping) 이라는 동작으로 걸러냅니다.

클리핑은 완전히 화면 밖에 있는 삼각형을 버리고, 일부만 걸쳐 있는 삼각형은 화면 안의 부분만 남도록 자릅니다.

화면에 보이지 않는 삼각형을 여기서 미리 제거하면, 이후 래스터화와 프래그먼트 셰이딩의 연산량을 줄일 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
클리핑 예시 (위에서 본 시야)

      카메라 시야 (뷰 프러스텀)
     ╱                        ╲
    ╱                          ╲
   ╱    ▲ 삼각형 A              ╲
  ╱    ╱ ╲  (완전히 안에 있음)    ╲
 ╱    ╱___╲                      ╲
╱                                  ╲
╱       ▲ 삼각형 B                  ╲
╱      ╱ ╲                          ╲
──────╱───╲────────────────────────────
     (반만 걸침 → 잘림)

              ▲ 삼각형 C
             ╱ ╲  (완전히 밖 → 제거)
            ╱___╲

결과:
  삼각형 A → 그대로 유지
  삼각형 B → 화면 안의 부분만 남김 (새로운 정점 생성)
  삼각형 C → 완전히 제거


클리핑과 함께 뒷면 제거(Back-face Culling) 도 수행됩니다.

삼각형의 정점을 나열하는 방향을 와인딩 오더(Winding Order) 라 합니다. 3D 모델링에서는 앞면의 정점을 반시계 방향으로 나열하는 것이 관례입니다.

1
2
3
4
5
6
7
8
9
10
와인딩 오더에 의한 앞/뒷면 판정

  앞면 (카메라 쪽)               뒷면 (카메라 반대쪽)

      v0                             v0
     ╱  ╲                           ╱  ╲
   v1 ── v2                       v2 ── v1

  순서: v0 → v1 → v2              순서: v0 → v2 → v1
  반시계 방향 (CCW) → 유지         시계 방향 (CW) → 제거

같은 삼각형이라도 뒷면에서 보면 정점의 화면상 순서가 시계 방향으로 뒤집힙니다. GPU는 투영된 정점 순서가 시계 방향이면 뒷면으로 판단하고 제거합니다.

구(sphere) 메쉬를 예로 들면, 카메라를 향한 절반의 삼각형은 반시계 방향(앞면)으로, 반대편 절반은 시계 방향(뒷면)으로 나타납니다. 뒷면 제거만으로 처리할 삼각형 수를 절반으로 줄일 수 있습니다.

4단계: 래스터화 (Rasterization)

3단계를 거친 삼각형은 세 정점의 좌표로 정의된 도형이지만, 화면은 픽셀의 격자입니다.

래스터화(Rasterization) 는 삼각형이 화면에서 덮는 영역을 픽셀 단위의 조각으로 변환하는 과정입니다. 삼각형이 덮는 각 픽셀 위치에 대해, 픽셀 후보인 프래그먼트(Fragment) 가 하나씩 생성됩니다.

프래그먼트는 화면의 특정 픽셀 위치에 대응하지만, 픽셀 자체는 아닙니다.

아직 최종 색상이 결정되지 않은 상태이며, 이후 프래그먼트 셰이더와 깊이 테스트를 거쳐야 실제 픽셀이 됩니다.

하나의 픽셀 위치에 여러 삼각형이 겹치면 프래그먼트도 여러 개 생성되며, 깊이 테스트에서 가장 가까운 것만 남습니다.

1
2
3
4
5
6
7
8
9
10
11
12
래스터화 — 삼각형 → 프래그먼트

삼각형                         픽셀 격자

        ▲                     □ □ □ ■ □ □ □
       ╱ ╲                     □ □ ■ ■ ■ □ □
      ╱   ╲          →         □ ■ ■ ■ ■ ■ □
     ╱     ╲                   ■ ■ ■ ■ ■ ■ ■
    ╱_______╲

                               ■ = 프래그먼트 (픽셀 위치당 하나)
                               □ = 삼각형 밖 → 생략


래스터화 과정에서 GPU는 삼각형 내부의 각 픽셀 위치마다 프래그먼트를 생성합니다.

이때 삼각형의 세 정점이 가지고 있던 속성(UV, 노멀 등)은 프래그먼트 위치에 맞게 보간(Interpolation) 됩니다.

예를 들어 삼각형의 세 정점이 각각 UV 좌표를 가지고 있을 때, 프래그먼트 위치가 세 정점 중 어느 쪽에 가까운지에 비례하여 해당 위치의 UV 좌표가 결정됩니다.

래스터화 과정에서 데이터의 양이 급증합니다.

메쉬의 정점이 1,000개라 해도, 이 메쉬가 화면의 넓은 영역을 차지하면 수십만 개의 프래그먼트가 생성됩니다.

프래그먼트 셰이더는 이 수십만 개 각각에 대해 실행되므로, 프래그먼트 셰이더의 비용이 버텍스 셰이더보다 높아지기 쉽습니다.

5단계: 프래그먼트 셰이더 (Fragment Shader)

프래그먼트 셰이더의 역할은 각 프래그먼트의 최종 색상을 결정하는 것입니다.

프래그먼트 하나당 하나의 스레드가 실행되며, 버텍스 셰이더와 마찬가지로 SIMT 모델에 따라 Warp 단위로 병렬 처리됩니다. 예를 들어 Full HD(1920x1080) 해상도에서 화면을 가득 채우는 오브젝트 하나만으로도 약 200만 개의 프래그먼트가 생성되고, 프래그먼트 셰이더가 200만 개의 스레드로 병렬 실행됩니다.

실제 장면에서는 여러 오브젝트가 같은 픽셀 위치에 겹치므로, 총 프래그먼트 수는 이보다 훨씬 많아집니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
프래그먼트 셰이더의 주요 작업

1. 텍스처 샘플링
   보간된 UV 좌표로 텍스처에서 텍셀(텍스처의 픽셀)을 읽어옴
   (메모리 접근 → 상대적으로 느린 작업)

2. 조명 계산
   법선 벡터, 광원 방향, 카메라 방향 등으로
   표면의 밝기와 색상을 픽셀 단위로 계산
   (수학 연산 → GPU가 잘하는 작업)

3. 추가 효과
   그림자, 반사, 노멀 맵핑(텍스처로 표면 요철 표현),
   이미시브(자체 발광) 등 시각 효과 적용


렌더링 기초 (3)에서 셰이더는 GPU에서 실행되는 프로그램이고, 머티리얼은 그 프로그램에 들어가는 구체적인 값을 담는 데이터 묶음이라고 설명했습니다.

셰이더 프로그램은 버텍스 셰이더와 프래그먼트 셰이더를 포함하며, 그중 머티리얼에 설정된 텍스처, 색상, 수치 파라미터를 읽어서 최종 색상을 계산하는 것이 프래그먼트 셰이더입니다.

프래그먼트 셰이더의 복잡도는 성능에 직접 영향을 미칩니다.

텍스처를 3장 샘플링하면 1장일 때보다 메모리 접근이 3배 늘어나고, 조명 모델이 복잡해지면 수학 연산도 비례하여 늘어납니다.

프래그먼트 셰이더는 프래그먼트마다 실행되므로, 이 비용이 프래그먼트 수만큼 반복됩니다.

Full HD 기준 프래그먼트는 최소 약 200만 개이고, 오버드로우까지 고려하면 그 이상이므로, 셰이더 한 줄의 변경이 전체 프레임 시간에 큰 차이를 만들 수 있습니다.

6단계: 스텐실 테스트, 깊이 테스트, 블렌딩

프래그먼트 셰이더가 색상을 계산한 뒤에도, 그 결과가 바로 화면에 쓰이지는 않습니다.

해당 프래그먼트가 실제로 화면에 보여야 하는지, 다른 오브젝트에 가려지는 것은 아닌지, 반투명이라면 뒤쪽 색상과 어떻게 혼합해야 하는지를 먼저 판단해야 하기 때문입니다.


이 단계에서는 세 가지 처리가 스텐실 테스트 → 깊이 테스트 → 블렌딩 순서로 일어나며, 앞 과정에서 실패한 프래그먼트는 뒤 과정으로 넘어가지 않습니다.


스텐실 테스트(Stencil Test)프레임버퍼(Framebuffer, 최종 이미지를 담는 GPU 메모리 영역) 안의 스텐실 버퍼(Stencil Buffer) 를 사용합니다.

스텐실 버퍼는 각 픽셀마다 정수값을 저장하고 있고, 프래그먼트가 도착하면 해당 픽셀의 스텐실 값과 비교하여 통과 여부를 결정합니다.

예를 들어, 거울 오브젝트를 렌더링할 때 거울이 차지하는 픽셀 영역에만 스텐실 값을 1로 기록해 두면, 반사된 장면을 그릴 때 스텐실 값이 1인 픽셀에만 렌더링을 허용하여 거울 밖으로 반사 장면이 새어 나가지 않게 할 수 있습니다. 다른 공간으로 연결되는 포털 효과도 같은 원리로, 포털 영역에만 스텐실 값을 기록한 뒤 포털 너머의 장면을 해당 영역 안에서만 렌더링합니다.


스텐실 테스트를 통과한 프래그먼트는 깊이 테스트(Depth Test) 를 거칩니다.

같은 픽셀 위치에 여러 오브젝트가 겹칠 때, 깊이 테스트 없이 결과를 바로 화면에 쓰면 CPU가 렌더링 명령을 보낸 순서대로 덮어쓰게 됩니다.

CPU의 렌더링 명령 순서는 3D 공간의 깊이와 무관하게 결정되므로, 앞뒤 관계가 올바르게 반영되지 않습니다.

깊이 테스트는 프레임버퍼 안의 깊이 버퍼(Z-buffer) 를 사용하여 이 문제를 해결합니다.

깊이 버퍼에는 각 픽셀 위치마다 현재까지 기록된 가장 가까운 프래그먼트의 깊이값이 저장되어 있고, 새 프래그먼트가 도착하면 이 값과 비교합니다. 기존값보다 가까우면 깊이 테스트를 통과하여 색상이 기록되고, 그렇지 않으면 버려집니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
깊이 테스트 원리

깊이값 범위: 0.0(카메라 바로 앞) ~ 1.0(가장 멀리)

화면의 한 픽셀 위치에 두 개의 삼각형이 겹친 경우:

  삼각형 A의 프래그먼트: 깊이 = 0.3 (가까움)
  삼각형 B의 프래그먼트: 깊이 = 0.7 (멀음)

  깊이 버퍼 현재값: 1.0 (초기값, 가장 멀리)

  1) 삼각형 A 처리:
     0.3 < 1.0 → 깊이 테스트 통과. 색상 기록. 깊이 버퍼를 0.3으로 갱신.

  2) 삼각형 B 처리:
     0.7 > 0.3 → 깊이 테스트 실패. 더 가까운 프래그먼트가 이미 기록됨. 버림.

  결과: 화면에는 삼각형 A의 색상만 표시됨.

깊이 테스트는 내부적으로 두 동작으로 나뉩니다. 깊이 읽기는 깊이 버퍼의 현재값과 새 프래그먼트의 깊이를 비교하는 것이고, 깊이 쓰기는 테스트를 통과한 뒤 깊이 버퍼에 새 값을 기록하는 것입니다. 위 다이어그램에서 “깊이 버퍼를 0.3으로 갱신”이 깊이 쓰기에 해당합니다.

이 두 동작은 독립적으로 켜고 끌 수 있습니다. 불투명 오브젝트는 읽기와 쓰기를 모두 활성화하지만, 반투명 오브젝트는 쓰기를 끄고 읽기만 수행합니다.

불투명 오브젝트는 읽기와 쓰기가 모두 활성화되어 있으므로, 렌더링 순서와 무관하게 카메라에 가까운 것이 먼 것을 자연스럽게 가립니다.


스텐실 테스트, 깊이 테스트, 블렌딩은 모두 셰이더 설정으로 개별적으로 켜고 끌 수 있습니다.

불투명 오브젝트는 일반적으로 스텐실 테스트 OFF, 깊이 테스트 ON(읽기+쓰기), 블렌딩 OFF로 설정됩니다.

블렌딩이 꺼져 있으면 새 프래그먼트의 색상이 프레임버퍼의 기존 색상을 그대로 덮어씁니다.


하지만 유리나 파티클 이펙트처럼 뒤가 비치는 오브젝트는 덮어쓰기가 아니라 색상을 혼합해야 합니다. 이때 블렌딩(Blending) 을 활성화합니다.

블렌딩은 반투명 프래그먼트의 색상과 프레임버퍼에 이미 기록된 색상을 비율에 따라 혼합하여 반투명 효과를 구현합니다.

예를 들어, 알파값이 0.5인 반투명 유리의 프래그먼트가 도착하면, 유리의 색상 50%와 프레임버퍼에 이미 기록된 뒤쪽 오브젝트의 색상 50%를 혼합합니다.


반투명 오브젝트 렌더링

반투명 오브젝트에 깊이 쓰기를 켜 둔 채로 렌더링하면 문제가 생깁니다.

반투명 유리 A(깊이 0.3)가 먼저 렌더링되어 깊이 버퍼에 0.3이 기록되면, 그 뒤에 있는 반투명 유리 B(깊이 0.7)의 프래그먼트는 깊이 테스트에서 실패하여 폐기됩니다. 유리 B는 A를 통해 비쳐 보여야 하지만, 깊이 쓰기가 이를 차단하는 것입니다.

그래서 반투명 오브젝트를 렌더링할 때는 깊이 쓰기를 비활성화하여, 깊이 비교(읽기)만 수행하고 깊이 버퍼에 새 값을 기록하지 않습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
깊이 쓰기 비활성화의 효과

불투명 벽(깊이 0.9)이 먼저 렌더링된 상태:
  깊이 버퍼 = 0.9

반투명 유리 A(깊이 0.3) 렌더링 — 깊이 쓰기 OFF:
  0.3 < 0.9 → 깊이 테스트 통과. 색상 혼합.
  깊이 버퍼 = 0.9 (쓰기가 꺼져 있으므로 변경 없음)

반투명 유리 B(깊이 0.7) 렌더링 — 깊이 쓰기 OFF:
  0.7 < 0.9 → 깊이 테스트 통과. 색상 혼합.
  (비교 대상이 불투명 벽의 0.9이므로 통과.
   만약 쓰기가 켜져 있었다면 유리 A가 깊이 버퍼를 0.3으로
   갱신했을 것이고, 0.7 > 0.3 → 깊이 테스트 실패로 폐기)

깊이 쓰기를 끄면 반투명 오브젝트들은 모두 불투명 오브젝트가 기록해 둔 깊이값과만 비교되고, 서로의 깊이값에는 영향을 주지 않습니다.

깊이 테스트 자체는 활성 상태이므로 불투명 오브젝트 뒤에 있는 반투명 오브젝트는 정상적으로 폐기됩니다.


깊이 쓰기를 비활성화하면 반투명 오브젝트끼리 서로 차단하는 문제는 해결되지만, 렌더링 순서에도 주의가 필요합니다.

블렌딩은 프레임버퍼에 이미 기록된 색상 위에 새 색상을 혼합하므로, 반투명 오브젝트는 카메라에서 먼 것부터 가까운 순서(back-to-front)로 렌더링해야 합니다.

이 정렬은 GPU가 아니라 CPU가 담당합니다.

CPU가 반투명 오브젝트들을 카메라로부터의 거리 기준으로 정렬한 뒤, 먼 것부터 순서대로 드로우 콜을 GPU에 제출합니다.

순서가 뒤바뀌어 가까운 유리가 먼저 기록되면, 뒤쪽 오브젝트의 색상이 유리 위에 혼합되어 앞뒤가 뒤집힌 것처럼 보입니다.


깊이 쓰기 비활성화와 back-to-front 정렬을 적용하려면, 오브젝트가 반투명이라는 정보가 필요합니다. 이 판별 또한 CPU가 수행합니다.

셰이더에는 GPU에서 실행되는 프로그램 코드뿐 아니라, 렌더링 상태 선언도 포함되어 있습니다. ZWrite Off(깊이 쓰기 비활성화), Blend SrcAlpha OneMinusSrcAlpha(알파 기반 블렌딩) 같은 지시문으로, GPU의 고정 기능 하드웨어를 어떻게 설정할지 지정합니다.

렌더링 상태는 GPU 코드가 아니라 메타데이터이며, Unity가 셰이더를 로드할 때 CPU 측에서 파싱하여 보관합니다.

CPU는 이 값을 기준으로 정렬과 상태 설정을 수행한 뒤 드로우 콜을 제출합니다.


Unity에서는 불투명 셰이더가 렌더 큐 2000(Geometry), 반투명 셰이더가 렌더 큐 3000(Transparent)으로 분류되어 있습니다.

CPU는 렌더 큐 번호가 낮은 순서대로 드로우 콜을 제출하므로, 불투명 큐의 드로우 콜이 먼저(깊이 쓰기 활성화, front-to-back 정렬), 반투명 큐의 드로우 콜이 나중에(깊이 쓰기 비활성화, back-to-front 정렬) 제출됩니다.

불투명 오브젝트가 먼저 렌더링되어 깊이 버퍼를 채워야, 반투명 오브젝트가 깊이 읽기로 불투명 오브젝트 뒤에 있는지를 판정할 수 있기 때문입니다.

7단계: 프레임버퍼 출력

모든 테스트를 통과한 프래그먼트의 색상이 프레임버퍼의 색상 버퍼에 기록됩니다.

프레임버퍼에는 색상 버퍼 외에도 깊이 테스트에 사용한 깊이 버퍼와 스텐실 테스트에 사용한 스텐실 버퍼가 함께 들어 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
프레임버퍼 구성

┌─────────────────────────────────────────────┐
│  프레임버퍼                                  │
│                                             │
│  ┌──────────────┐  각 픽셀의 최종 색상       │
│  │  색상 버퍼    │  (RGBA: 빨강, 초록,       │
│  │  (Color)     │   파랑, 알파)              │
│  └──────────────┘                           │
│                                             │
│  ┌──────────────┐  각 픽셀의 깊이 값         │
│  │  깊이 버퍼    │  (깊이 테스트에 사용)      │
│  │  (Depth)     │                           │
│  └──────────────┘                           │
│                                             │
│  ┌──────────────┐  각 픽셀의 스텐실 값       │
│  │  스텐실 버퍼  │  (마스킹에 사용)           │
│  │  (Stencil)   │                           │
│  └──────────────┘                           │
└─────────────────────────────────────────────┘


GPU는 프레임버퍼 두 벌을 사용합니다. 하나는 GPU가 현재 렌더링하는 백 버퍼(Back Buffer), 다른 하나는 화면에 표시 중인 프론트 버퍼(Front Buffer) 입니다.

지금까지 파이프라인에서 색상, 깊이, 스텐실을 기록한 프레임버퍼가 바로 백 버퍼입니다.

한 프레임의 모든 드로우 콜이 처리되면 백 버퍼와 프론트 버퍼의 역할을 교체합니다.


화면 표시는 GPU 칩 안에 있지만 렌더링과는 별도로 동작하는 디스플레이 컨트롤러가 담당합니다.

디스플레이 컨트롤러는 프론트 버퍼의 픽셀 데이터를 모니터의 주사율에 맞춰 지속적으로 읽어 HDMI나 DisplayPort 같은 인터페이스를 통해 모니터에 전송합니다.

버퍼가 교체되면 디스플레이 컨트롤러가 읽는 대상이 바뀌므로, 완성된 프레임이 화면에 나타납니다.

이 교체 없이 렌더링 중인 버퍼를 직접 표시하면, 디스플레이 컨트롤러가 화면을 스캔하는 도중에 내용이 바뀌어 상반부는 이전 프레임, 하반부는 현재 프레임이 표시되는 티어링(Tearing) 현상이 발생합니다.


렌더링 파이프라인은 정점 데이터에서 시작하여 프레임버퍼의 픽셀까지 데이터가 흘러가는 과정입니다.

개발자가 직접 프로그래밍하는 단계는 버텍스 셰이더와 프래그먼트 셰이더 두 곳이고, 래스터화나 깊이 테스트 같은 나머지 단계는 GPU의 고정 기능 하드웨어가 처리합니다.

Unity 셰이더는 이 두 프로그래머블 단계의 코드와 함께, 고정 기능 하드웨어의 렌더링 상태를 정의합니다.


데스크톱 GPU의 IMR 방식

렌더링 파이프라인의 논리적 흐름 — 정점 입력, 버텍스 셰이더, 클리핑, 래스터화, 프래그먼트 셰이더, 프래그먼트 테스트와 블렌딩, 프레임버퍼 출력 — 은 모든 GPU에서 동일합니다. 하지만 이 파이프라인을 물리적으로 어떻게 구현하는가는 GPU마다 다릅니다.

데스크톱 GPU(NVIDIA GeForce, AMD Radeon 등)는 대부분 IMR(Immediate Mode Rendering) 방식을 사용합니다.

IMR의 동작 원리

IMR에서 “Immediate”는 “즉시”라는 뜻입니다. CPU가 드로우 콜을 제출하면, GPU는 이를 모아두지 않고 즉시 렌더링 파이프라인 전체를 실행합니다. 버텍스 셰이더 → 클리핑 → 래스터화 → 프래그먼트 셰이더 → 테스트와 블렌딩을 순차적으로 거쳐 프레임버퍼에 기록됩니다.

다만 GPU 전체로 보면, 파이프라인의 여러 단계가 동시에 작동합니다. 삼각형 A가 프래그먼트 셰이딩 단계에 있을 때, 삼각형 B는 래스터화 단계에, 삼각형 C는 버텍스 셰이더 단계에 있을 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
IMR 처리 흐름

시간 →

삼각형 1: [VS] → [클리핑] → [래스터화] → [FS] → [테스트] → [FB 쓰기]
삼각형 2:        [VS] → [클리핑] → [래스터화] → [FS] → [테스트] → [FB 쓰기]
삼각형 3:               [VS] → [클리핑] → [래스터화] → [FS] → [테스트] → ...

VS = 버텍스 셰이더
FS = 프래그먼트 셰이더
FB = 프레임버퍼


IMR에서는 프래그먼트 하나가 셰이딩, 테스트, 블렌딩을 거쳐 프레임버퍼에 기록되기까지의 전체 과정이 즉시 실행됩니다.

이 과정에서 프래그먼트 셰이더의 텍스처 샘플링, 깊이 테스트를 위한 깊이 버퍼 읽기/쓰기, 최종 색상의 프레임버퍼 기록이 모두 외부 메모리(VRAM) 접근입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
IMR의 메모리 접근 패턴

GPU 코어 (온칩)                      VRAM (외부 메모리)
┌────────────────┐                   ┌────────────────────┐
│                │                   │                    │
│                │  텍스처 읽기 ←─────│  텍스처 데이터      │
│                │                   │                    │
│                │  스텐실 읽기 ←─────│  스텐실 버퍼        │
│  프래그먼트    │  스텐실 쓰기 ─────→│                    │
│  처리         │                   │                    │
│                │  깊이 읽기 ←──────│  깊이 버퍼          │
│                │  깊이 쓰기 ──────→│                    │
│                │                   │                    │
│                │  색상 읽기 ←──────│  색상 버퍼          │
│                │  색상 쓰기 ──────→│                    │
│                │                   │                    │
└────────────────┘                   └────────────────────┘

        ←─────── 메모리 버스 ─────────→
              (128 ~ 512 bit)
          대역폭: 수백 GB/s

대역폭과 IMR

GPU 코어와 VRAM 사이의 데이터 전송에는 메모리 대역폭(Memory Bandwidth) 이라는 물리적 한계가 존재합니다. 대역폭은 단위 시간당 전송할 수 있는 데이터의 양이며, 외부 메모리 접근이 빈번할수록 이 한계에 가까워집니다.

앞의 다이어그램에서 보았듯이, IMR 방식에서는 프래그먼트 하나를 처리할 때마다 텍스처 읽기, 스텐실/깊이 버퍼 읽기/쓰기, 색상 버퍼 읽기/쓰기가 발생합니다.

Full HD 해상도(1920x1080 = 약 200만 픽셀)에서, 화면의 같은 위치에 여러 오브젝트가 겹쳐 그려지는 경우까지 고려하면 프래그먼트 수는 수백만에 달합니다. 이 모든 프래그먼트에 대해 VRAM 접근이 일어나므로, 메모리 대역폭 소모가 큽니다.

데스크톱 GPU는 이 문제를 넓은 메모리 버스높은 대역폭으로 감당합니다.

메모리 버스(Memory Bus)는 GPU 코어와 VRAM을 연결하는 물리적 데이터 통로입니다. 버스 폭이 256bit이면 한 번의 전송에 256bit를 동시에 보낼 수 있고, 512bit이면 그 두 배입니다. 버스가 넓을수록 대역폭이 높아집니다.

넓은 메모리 버스는 그만큼 전력을 많이 소모하지만, 데스크톱 환경에서는 전원 콘센트에서 수백 와트를 공급받을 수 있으므로 이를 감당할 수 있습니다. 초당 500GB 이상의 대역폭이면, IMR 방식의 빈번한 VRAM 접근도 병목 없이 처리됩니다.

IMR의 오버드로우 문제

IMR에는 구조적인 비효율이 하나 있습니다. 오버드로우(Overdraw) 문제입니다.

삼각형이 제출되는 순서대로 파이프라인을 통과하므로, 화면의 같은 픽셀 위치에 여러 삼각형이 그려질 수 있습니다.

예를 들어, 뒤에 있는 배경 벽이 먼저 제출되면 해당 프래그먼트의 셰이딩이 실행되고 결과가 프레임버퍼에 기록됩니다.

이후 앞에 있는 캐릭터가 같은 위치에 그려지면, 앞서 기록한 배경 벽의 결과를 덮어씁니다. 배경 벽에 소모한 셰이딩 비용은 결과적으로 낭비된 셈입니다.

이처럼 같은 픽셀 위치를 여러 번 덮어쓰는 현상을 오버드로우라고 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
오버드로우 예시 — 같은 픽셀 위치 (x, y)

렌더링 순서: 뒤에 있는 오브젝트부터

1) 배경 벽 (깊이 0.9)
   → 셰이딩 실행
   → 깊이 테스트: 0.9 < 1.0(초기값) → 통과
   → 색상/깊이 기록

2) 캐릭터 (깊이 0.3)
   → 셰이딩 실행
   → 깊이 테스트: 0.3 < 0.9 → 통과
   → 색상/깊이 덮어쓰기

배경 벽의 셰이딩 비용은 낭비됨


이 낭비가 발생하는 근본 원인은 파이프라인의 단계 순서에 있습니다.

앞서 렌더링 파이프라인에서 살펴보았듯이, 깊이 테스트는 프래그먼트 셰이더 이후에 수행됩니다.

깊이 테스트 시점에서 “이 프래그먼트는 가려지므로 버려야 한다”고 판정하더라도, 그때는 이미 프래그먼트 셰이더가 실행을 마친 뒤입니다.


데스크톱 GPU에서는 이 문제를 Early-Z 테스트(Early Depth Test) 로 완화합니다.

Early-Z는 프래그먼트 셰이더 이전에 깊이 테스트를 먼저 수행하여, 이미 더 가까운 프래그먼트가 기록된 위치라면 셰이딩 자체를 건너뜁니다.

다만, Early-Z가 효과를 발휘하려면 가까운 오브젝트의 깊이값이 깊이 버퍼에 먼저 기록되어 있어야 합니다. 그래야 뒤에 제출되는 먼 오브젝트가 Early-Z에서 걸러집니다.

앞서 CPU가 불투명 오브젝트를 front-to-back으로 정렬한다고 했는데, 이 정렬은 깊이 테스트의 탈락률을 높여 불필요한 프레임버퍼 쓰기를 줄이고, Early-Z가 있으면 셰이딩까지 건너뛸 수 있게 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Early-Z 최적화 — 같은 픽셀 위치 (x, y)

렌더링 순서: 앞에 있는 오브젝트부터 (front-to-back)
깊이 버퍼 초기값: 1.0 (가장 먼 거리)

1) 캐릭터 (깊이 0.3)
   → Early-Z: 0.3 < 1.0(초기값) → 통과
   → 셰이딩 실행 → 색상/깊이 기록 (깊이 버퍼: 1.0 → 0.3)

2) 배경 벽 (깊이 0.9)
   → Early-Z: 0.9 > 0.3 → 탈락
   → 셰이딩 건너뜀

배경 벽의 가려진 부분은 셰이딩 비용이 발생하지 않음


앞에서 Unity의 불투명 오브젝트가 렌더 큐 2000(Geometry)에 속하고, CPU가 카메라와의 거리 기준으로 가까운 것부터(front-to-back) 정렬하여 드로우 콜을 제출한다고 했습니다.

URP와 Built-in 렌더링 파이프라인은 이 Geometry 큐를 포함하여 렌더 큐 2500 이하의 오브젝트를 Opaque 패스에서 렌더링합니다.

Unity의 기본 설정(OpaqueSortMode.Default)은 GPU 종류에 따라 동작이 달라집니다. 대부분의 데스크톱 GPU에서는 front-to-back 정렬을 적용하고, PowerVR/Apple GPU처럼 타일 기반 아키텍처에서는 이 정렬을 적용하지 않습니다.

데스크톱 GPU에서 이 정렬이 적용되는 이유는, 깊이 테스트 탈락률을 높여 불필요한 프레임버퍼 쓰기를 줄이고, Early-Z가 있으면 셰이딩까지 건너뛸 수 있기 때문입니다.


마무리

  • CPU는 레이턴시 중심, GPU는 스루풋 중심으로 설계되었습니다. GPU는 SIMT 모델에 따라 같은 셰이더를 Warp(32스레드) 단위로 묶어 병렬 실행하며, Warp 내에서 분기 방향이 갈리면 양쪽 경로를 모두 실행해야 합니다.
  • 렌더링 파이프라인은 정점 입력 → 버텍스 셰이더 → 클리핑 → 래스터화 → 프래그먼트 셰이더 → 테스트와 블렌딩 → 프레임버퍼 출력 순서로 진행됩니다. 래스터화 이후 프래그먼트 수가 급증하므로, 프래그먼트 셰이더의 복잡도가 전체 성능에 미치는 영향이 큽니다.
  • 데스크톱 GPU의 IMR 방식은 드로우 콜을 모아두지 않고 즉시 파이프라인 전체를 실행하며, 셰이딩부터 테스트, 프레임버퍼 기록까지 모든 단계가 VRAM에 접근합니다. 데스크톱 GPU는 넓은 메모리 버스와 수백 GB/s의 대역폭으로 이를 감당합니다. 같은 픽셀을 여러 삼각형이 덮어쓰는 오버드로우가 발생할 수 있으며, front-to-back 정렬로 깊이 테스트 탈락률을 높이고, Early-Z로 셰이딩까지 건너뛸 수 있습니다.

셰이더에서 if문을 줄이라는 조언, 프래그먼트 셰이더의 복잡도를 경계하라는 조언, 엔진이 불투명 오브젝트를 front-to-back으로 정렬하여 불필요한 셰이딩과 프레임버퍼 쓰기를 줄이는 동작은 모두 이 GPU 아키텍처와 파이프라인 구조에서 비롯됩니다.


그런데 모바일 환경에서는 사정이 다릅니다.

스마트폰의 배터리는 수 와트 수준의 전력만 공급할 수 있고, 메모리 버스 폭도 64~128bit로 좁으며, 대역폭은 데스크톱의 1/10 이하입니다.

이 조건에서 IMR 방식의 빈번한 VRAM 접근은 한정된 대역폭을 압박하고, 전력 소모를 급증시킵니다.

다음 글에서는 모바일 GPU가 이 한계를 극복하기 위해 채택한 TBDR(Tile-Based Deferred Rendering) 아키텍처의 동작 원리와, 이 아키텍처가 Unity 모바일 최적화에 미치는 구체적인 영향을 다룹니다.



관련 글

시리즈

Tags: GPU, Unity, 렌더링파이프라인, 모바일, 최적화

Categories: ,