렌더링 기초 (1) - 메쉬의 구조 - soo:bak
작성일 :
렌더링이란
게임 루프의 원리 (1) - 프레임의 구조에서 하나의 프레임이 만들어지기까지 CPU와 GPU가 각자의 작업을 수행한다고 했습니다. 그리고 게임 루프의 원리 (2) - CPU-bound와 GPU-bound에서 프레임 예산의 대부분이 렌더링에 쓰인다는 것을 확인했습니다.
렌더링은 데이터를 화면의 픽셀로 변환하는 과정입니다. 2D 스프라이트든 3D 모델이든, 화면에 보이는 모든 것은 렌더링을 거칩니다. 이 변환을 수행하는 것이 GPU의 역할입니다.
이 변환 과정을 이해하려면, 먼저 GPU에 전달되는 입력 데이터의 구조를 알아야 합니다. 게임 화면에 보이는 캐릭터, 지형, 건물은 3D 공간에 수치로 정의된 데이터입니다.
이 형태 데이터를 담는 구조가 메쉬(Mesh)입니다. 메쉬는 오브젝트의 형태를 점(정점)과 면(삼각형)의 집합으로 표현합니다.
이 글에서는 메쉬가 어떤 요소로 구성되어 있고, 각 요소가 렌더링에서 어떤 역할을 하며, 메모리를 얼마나 사용하는지 살펴봅니다.
1
2
3
4
5
6
7
8
3D 공간의 데이터 2D 화면 이미지
┌────────────────────┐ ┌──────────────────┐
│ 정점, 삼각형 │ │ │
│ ← 메쉬 (이 글) │ ═══ 렌더링 ═══► │ 픽셀로 된 │
│ │ │ 최종 이미지 │
│ 텍스처, 머티리얼 │ │ │
│ ← 이후 글 │ │ │
└────────────────────┘ └──────────────────┘
위 다이어그램에서 왼쪽이 렌더링의 입력, 오른쪽이 출력입니다. 입력 중 가장 기본이 되는 메쉬부터 살펴봅니다.
정점(Vertex)과 삼각형
메쉬는 정점(Vertex)으로 구성됩니다. 정점은 공간에서의 한 점으로, 좌표(x, y, z)를 가집니다. 정점을 찍는 것만으로는 점의 집합일 뿐이고, 채워진 표면을 만들려면 정점들을 이어서 면을 구성해야 합니다. 이때 GPU가 처리하는 면의 단위가 삼각형(Triangle)입니다. 정점 3개가 하나의 삼각형을 이룹니다.
1
2
3
4
5
6
7
v0
/\
/ \
/ \
/ \
/________\
v1 v2
삼각형이 면의 기본 단위로 쓰이는 이유는 세 가지입니다.
첫째, 삼각형은 항상 평면입니다. 3D 공간에서 점 3개는 반드시 하나의 평면 위에 놓입니다. 점이 4개 이상이면 같은 평면에 있지 않을 수 있습니다. 사각형을 구성하는 4개의 점 중 하나가 평면에서 벗어나면 면이 뒤틀리고, 렌더링 결과가 예측 불가능해집니다. 삼각형은 이런 문제가 원천적으로 발생하지 않습니다.
둘째, 삼각형은 항상 볼록(Convex)합니다. 다각형이 오목(Concave)하다는 것은 내각이 180°를 넘는 꼭짓점이 있다는 뜻입니다. 윤곽선이 그 꼭짓점에서 안쪽으로 꺾이는 형태입니다. 삼각형의 세 내각의 합은 180°이고, 각 내각은 0°보다 커야 하므로 어떤 내각도 180°에 도달할 수 없습니다. 따라서 삼각형은 오목해질 수 없습니다.
셋째, 이 두 성질 덕분에 래스터화(Rasterization)가 단순해집니다. 래스터화는 면을 픽셀로 채우는 과정이며, GPU의 래스터라이저(Rasterizer)가 이 작업을 수행합니다. 삼각형은 평면이고 볼록하므로, 세 변 각각에 대해 “이 픽셀이 변의 안쪽에 있는가?”만 확인하면 됩니다. 판정 3번으로 내부 여부가 결정되고, GPU는 이 판정을 모든 픽셀에 대해 병렬로 수행합니다. 오목한 다각형은 이런 단순한 판정이 통하지 않아 더 복잡한 알고리즘이 필요하고, 사각형이나 다각형을 렌더링하려면 먼저 삼각형으로 분할하는 단계도 추가됩니다. GPU의 래스터라이저가 삼각형 단위로 설계된 이유입니다.
이 세 가지 성질 덕분에 GPU는 어떤 형태의 메쉬든 삼각형의 집합으로 처리할 수 있습니다.
사각형과 삼각형의 관계
사각형 하나를 예시로 들어 봅니다. 화면에 평평한 사각형 하나를 그리려면, 정점 4개와 삼각형 2개가 필요합니다.
1
2
3
4
5
6
7
v0 _________ v1
| /|
| / |
| / |
| / |
|/________|
v2 v3
정점 4개의 좌표는 다음과 같습니다.
1
2
3
4
v0 = (0, 1, 0)
v1 = (1, 1, 0)
v2 = (0, 0, 0)
v3 = (1, 0, 0)
이 4개의 정점으로 삼각형 2개를 구성합니다.
1
2
삼각형 1: v0, v2, v1
삼각형 2: v1, v2, v3
사각형처럼 단순한 형태도 삼각형 2개로 분해됩니다. 캐릭터나 건물 같은 복잡한 모델은 수천에서 수만 개의 삼각형으로 구성됩니다. 삼각형 수가 많을수록 표면이 매끄럽게 표현되지만, 그만큼 GPU가 처리해야 할 연산량도 증가합니다.
인덱스 버퍼
삼각형으로 면을 구성하는 방식은 간단하지만, 정점 데이터를 그대로 나열하면 메모리 낭비가 발생합니다. 앞의 사각형 예시를 다시 살펴봅니다. 삼각형 2개의 정점 목록을 그대로 나열하면 다음과 같습니다.
1
2
삼각형 1: v0, v2, v1
삼각형 2: v1, v2, v3
v1과 v2가 두 삼각형에 모두 포함되어 있습니다. 대각선을 공유하는 두 삼각형이 같은 정점을 사용하기 때문입니다. 만약 각 삼각형마다 정점 데이터를 독립적으로 저장하면, 총 6개의 정점이 필요하지만 실제 고유한 정점은 4개뿐이므로 2개가 중복 저장됩니다.
사각형 하나에서 2개 중복은 큰 문제가 아닙니다. 하지만 실제 모델에서는 하나의 정점이 평균 4~6개의 삼각형에 공유됩니다. 하나의 정점이 4개 삼각형에 공유된다면, 그 정점의 데이터가 4번 중복 저장됩니다. 정점 10,000개짜리 모델에서 이런 중복 저장을 하면 40,000~60,000개의 정점 데이터가 필요해집니다.
이 문제를 해결하는 구조가 정점 버퍼(Vertex Buffer)와 인덱스 버퍼(Index Buffer)의 분리입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
인덱스 버퍼 없이 (정점 데이터를 그대로 나열):
삼각형 1: (0,1,0) (0,0,0) (1,1,0)
삼각형 2: (1,1,0) (0,0,0) (1,0,0)
→ 정점 6개 저장 (2개 중복)
인덱스 버퍼 사용:
정점 버퍼 (고유한 정점만 저장)
┌─────────────────────────────┐
│ [0] (0, 1, 0) │
│ [1] (1, 1, 0) │
│ [2] (0, 0, 0) │
│ [3] (1, 0, 0) │
└─────────────────────────────┘
↑ 번호로 참조
인덱스 버퍼 (정점 번호만 저장)
┌─────────────────────────────┐
│ 0, 2, 1, 1, 2, 3 │
│ ├──────┤ ├──────┤ │
│ 삼각형1 삼각형2 │
└─────────────────────────────┘
→ 정점 4개 + 인덱스 6개
정점 버퍼에는 고유한 정점 데이터만 저장하고, 인덱스 버퍼에는 삼각형을 구성하는 정점의 번호(인덱스)만 저장합니다. GPU는 인덱스 버퍼를 읽어서 정점 버퍼의 해당 위치를 참조합니다. 삼각형 1의 인덱스 0, 2, 1은 “정점 버퍼의 0번, 2번, 1번 데이터를 사용하라”는 뜻입니다.
인덱스 버퍼를 사용하면 두 가지 측면에서 효율이 높아집니다.
메모리 절약. 정점 하나의 데이터는 위치, 법선, UV 좌표 등을 포함하여 수십 바이트에 달합니다. 반면 인덱스 하나는 16비트(2바이트) 또는 32비트(4바이트) 정수입니다.
앞서 나온 정점 10,000개짜리 모델을 예로 들면, 정점 하나가 40바이트일 때 중복 저장 시 60,000개 × 40바이트 = 약 2.3MB가 필요합니다.
인덱스 버퍼를 사용하면 정점 10,000개 × 40바이트 + 인덱스 60,000개 × 2바이트 = 약 0.5MB로 줄어듭니다.
캐시 효율. GPU는 정점 셰이더(정점의 좌표를 변환하는 프로그램)를 실행한 결과를 캐시에 보관합니다. 이를 포스트 트랜스폼 캐시(Post-Transform Cache)라 합니다.
인접한 삼각형이 같은 인덱스를 참조하면, GPU는 캐시에서 이전 결과를 가져와 정점 셰이더 실행을 건너뜁니다.
인덱스 버퍼 없이 정점을 그대로 나열하는 방식에서는 같은 좌표의 정점이라도 버퍼의 다른 위치에 있으므로, GPU가 같은 정점인지 알 수 없어 매번 정점 셰이더를 다시 실행합니다.
Unity에서 메쉬를 구성할 때도 이 구조를 사용합니다. Mesh.vertices(위치), Mesh.normals(법선), Mesh.uv(UV 좌표) 등의 속성 배열이 합쳐져 GPU의 정점 버퍼가 되고, Mesh.triangles가 인덱스 버퍼가 됩니다.
GPU 메모리에서의 배치
정점 버퍼와 인덱스 버퍼는 GPU가 직접 접근할 수 있는 메모리 영역에 배치됩니다. 데스크톱 GPU는 자체 메모리(VRAM)를 갖고 있어 CPU 메모리와 물리적으로 분리되어 있습니다. 모바일 GPU는 CPU와 같은 물리 메모리(RAM)를 공유하지만, GPU 전용 영역으로 할당하여 GPU가 최적화된 경로로 접근할 수 있게 합니다.
1
2
3
4
5
6
7
CPU 영역 GPU 영역
┌─────────────────────┐ 업로드 ┌─────────────────────┐
│ 메쉬 데이터 │ ──────────► │ 정점 버퍼 │
│ (vertices, normals, │ │ │
│ uvs, triangles) │ │ 인덱스 버퍼 │
└─────────────────────┘ └─────────────────────┘
Unity는 메쉬를 로드할 때 CPU 측에서 데이터를 구성한 뒤 GPU 메모리 영역으로 업로드합니다. 업로드가 완료된 후에도 Unity는 기본적으로 CPU 측에 같은 데이터의 복사본을 유지합니다. 런타임에서 메쉬 콜라이더의 충돌 판정을 하거나, 스크립트에서 정점 데이터를 읽거나, 메쉬를 변형하려면 CPU가 메쉬 데이터에 접근할 수 있어야 하기 때문입니다.
하지만 대부분의 메쉬는 로드 후 변형하지 않으므로, CPU 측 복사본이 불필요하게 메모리를 차지합니다. 모델 임포트 설정에서 Read/Write Enabled를 끄면 GPU 업로드 완료 후 CPU 측 복사본이 해제되어 메모리를 절약할 수 있습니다. 스크립트에서 동적으로 생성한 메쉬는 임포트 설정이 없으므로, Mesh.UploadMeshData(true)를 호출하여 같은 방식으로 CPU 측 복사본을 해제할 수 있습니다.
정점 속성(Vertex Attributes)
앞에서 정점을 위치(x, y, z) 좌표로만 설명했지만, 위치만으로는 빛이 표면에서 어떻게 반사되는지, 텍스처의 어느 부분을 입혀야 하는지 알 수 없습니다. 실제 정점에는 이런 정보를 담는 여러 속성이 위치와 함께 저장됩니다.
Position (위치)
앞에서 다룬 정점의 공간 좌표(x, y, z)입니다. float(32비트 부동소수점) 3개로 구성되며, 12바이트를 차지합니다. 다른 속성은 용도에 따라 생략할 수 있지만, 위치는 모든 정점에 반드시 포함됩니다.
Normal (법선)
표면에 수직으로 바깥을 향하는 방향 벡터입니다. x, y, z 세 개의 float 값으로 구성되며, 12바이트를 차지합니다. 길이가 1인 단위 벡터(Unit Vector)로 저장됩니다.
1
2
3
4
5
법선 (Normal)
↑
│
│
─────────┼───────── 표면
빛이 표면에 닿았을 때 얼마나 밝게 보이는지는 빛이 오는 방향과 법선 사이의 각도로 결정됩니다. 빛이 법선과 나란하게(표면에 수직으로) 들어오면 가장 밝고, 비스듬할수록 어두워지며, 표면과 평행하게 들어오면 빛이 닿지 않습니다.
1
2
3
4
5
6
7
8
9
10
빛 방향 법선
↘ ↑
↘ │
↘ θ │
─────────────────┼───────── 표면
밝기 = cos(θ)
θ = 0° → cos(0°) = 1.0 (가장 밝음)
θ = 60° → cos(60°) = 0.5 (중간)
θ = 90° → cos(90°) = 0.0 (빛이 닿지 않음)
법선이 없으면 빛의 각도에 따른 밝기 차이를 계산할 수 없어, 모든 표면이 동일한 밝기로 표시됩니다. 게임에서 캐릭터 얼굴의 음영이나 지형의 굴곡이 자연스럽게 표현되는 것은 법선을 이용한 조명 계산 덕분입니다.
삼각형은 평면이므로 기하학적 법선이 하나뿐입니다. 삼각형 전체에 이 법선 하나로 조명을 계산하면, 삼각형 안의 모든 픽셀이 같은 밝기가 됩니다. 이것이 플랫 셰이딩(Flat Shading)입니다. 인접한 삼각형의 법선 방향이 다르면 경계에서 밝기가 급격히 바뀌어, 면이 각져 보입니다.
정점마다 법선을 별도로 지정하면 이 문제를 해결할 수 있습니다. 삼각형 내부의 각 픽셀에서 세 정점의 법선을 위치에 따라 섞어서(보간, Interpolation) 그 픽셀만의 법선 방향을 계산합니다. 정점 A 근처의 픽셀은 A의 법선에 가깝고, 정점 B 근처의 픽셀은 B의 법선에 가까운 값을 갖습니다. 삼각형 내부에서 법선이 부드럽게 변하므로 인접 삼각형 경계에서도 밝기가 자연스럽게 이어집니다. 이것이 스무스 셰이딩(Smooth Shading)입니다.
1
2
3
4
5
6
7
8
9
플랫 셰이딩 vs 스무스 셰이딩:
정점A 정점B 정점C 정점D
↑ ↗ ↗ → ← 정점 법선
─────┼───────┼───────┼───────┼─────
삼각형1 삼각형2 삼각형3
플랫: │ 밝음 │ 중간 │ 어두움 │ ← 삼각형마다 균일, 경계에서 끊김
스무스: 밝음 ─── 점진 ─── 점진 ─── 어두움 ← 내부에서 부드럽게 변화
UV (텍스처 좌표)
메쉬의 표면에 2D 이미지(텍스처)를 입히기 위한 좌표입니다. u, v 두 개의 float 값으로 구성되며, 8바이트를 차지합니다.
UV 좌표계에서 (0, 0)은 텍스처 이미지의 왼쪽 아래, (1, 1)은 오른쪽 위입니다. 각 정점에 UV 좌표를 지정하면, 그 정점이 텍스처 이미지의 어느 위치에 대응하는지 결정됩니다. 앞에서 다룬 사각형 메쉬에 텍스처를 입히는 경우를 예로 들면 다음과 같습니다.
1
2
3
4
5
6
7
8
9
텍스처 이미지 메쉬의 정점에 UV 지정
v ↑
1 ┌──────────────────┐ v0 (u=0, v=1) ──── v1 (u=1, v=1)
│ │ │ │
│ 텍스처 │ │ │
│ │ │ │
0 └──────────────────┘ v2 (u=0, v=0) ──── v3 (u=1, v=0)
0 1 → u
v0에 (0, 1)을 지정하면 텍스처의 왼쪽 위, v3에 (1, 0)을 지정하면 텍스처의 오른쪽 아래가 대응됩니다. GPU는 법선의 보간과 같은 방식으로 삼각형 내부의 각 픽셀에 대해 UV를 보간하고, 해당 좌표에서 텍스처 이미지의 색상을 가져옵니다. 이 과정을 텍스처 매핑(Texture Mapping)이라 합니다.
UV 좌표가 없으면 텍스처를 표면에 어떻게 대응시킬지 알 수 없으므로, 텍스처를 사용하는 모든 메쉬에 UV가 필요합니다.
용도에 따라 UV 채널이 여러 개 존재할 수 있습니다. 기본 텍스처용 UV0, 라이트맵(Lightmap)(미리 계산된 조명 정보를 저장한 텍스처)용 UV1이 대표적입니다. UV 채널이 추가될 때마다 정점당 8바이트가 더 사용됩니다.
Tangent (접선)
법선에 수직이면서 표면 위에 놓인 방향 벡터입니다. x, y, z, w 네 개의 float 값으로 구성되며, 16바이트를 차지합니다.
접선은 노멀맵(Normal Map)을 사용할 때 필요합니다.
앞에서 스무스 셰이딩이 정점 법선을 보간하여 매끄러운 표면을 표현한다고 했습니다. 스무스 셰이딩은 표면을 매끄럽게 만들지만, 실제 사물에는 미세한 요철이 있습니다. 벽돌 벽의 홈, 캐릭터 피부의 주름, 금속 표면의 스크래치 같은 디테일입니다. 이런 디테일을 삼각형으로 직접 표현하려면 삼각형 수가 크게 늘어납니다.
노멀맵은 삼각형을 늘리지 않고 이 문제를 해결합니다. 노멀맵은 일반 텍스처와 같은 2D 이미지이지만, 색상 대신 법선 방향을 저장합니다. 각 픽셀의 RGB 값이 법선의 x, y, z 방향에 대응합니다. 접선 공간에서 수정이 없는 기본 법선은 (0, 0, 1), 즉 표면에서 수직으로 바깥을 가리키는 방향이며, 이 값이 RGB로 (128, 128, 255)에 해당하기 때문에 노멀맵은 전체적으로 푸른 보라색을 띕니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
노멀맵의 구조:
┌─────────────────────────────────┐
│ 각 픽셀의 RGB = 법선 방향 │
│ │
│ R (빨강) → x 방향 기울기 │
│ G (초록) → y 방향 기울기 │
│ B (파랑) → z 방향 (표면 수직) │
│ │
│ 기본값 (128, 128, 255) = 푸른 │
│ 보라색 → 수정 없음 (평평) │
│ 값이 128에서 벗어날수록 해당 │
│ 방향으로 법선이 기울어짐 │
└─────────────────────────────────┘
렌더링 시 GPU는 픽셀마다 노멀맵에서 법선 방향을 읽어, 스무스 셰이딩으로 보간된 법선을 수정합니다. 수정된 법선으로 조명을 계산하면, 실제 표면은 평평하지만 빛이 요철에 반응하는 것처럼 보입니다.
1
2
3
4
5
6
7
8
9
10
노멀맵 적용 전후:
적용 전 (평평한 면) 적용 후 (같은 면 + 노멀맵)
───────────────────── ─────────────────────
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↗ ↑ ↖ ↑ ↗ ↑
법선이 모두 같은 방향 픽셀마다 법선이 다른 방향
→ 균일한 밝기 → 밝고 어두운 부분이 생겨
요철이 있는 것처럼 보임
다만 노멀맵은 법선 방향만 수정할 뿐 실제 메쉬의 형태를 바꾸지는 않으므로, 오브젝트의 윤곽선(실루엣)은 여전히 평평합니다. 정면에서 보면 디테일이 잘 표현되지만, 옆에서 비스듬하게 보면 표면이 평평한 것이 드러납니다.
노멀맵에 법선 수정 방향을 저장할 때, 기준이 되는 좌표계가 필요합니다. 장면 전체의 절대 좌표인 월드 공간을 기준으로 저장하면, 오브젝트를 회전시켰을 때 문제가 생깁니다. 벽돌 벽을 90° 회전시키면 요철의 방향도 함께 회전해야 하는데, 월드 공간 기준의 법선은 회전 전 방향을 그대로 가리키기 때문입니다.
이 문제를 해결하기 위해 노멀맵은 접선 공간(Tangent Space)이라는 표면 기준의 로컬 좌표계를 사용합니다. 접선 공간은 법선(Normal), 접선(Tangent), 바이탄젠트(Bitangent) 세 벡터가 각각 한 축을 담당하는 좌표계입니다. 이 좌표계는 표면에 붙어 있으므로, 오브젝트가 어떻게 회전하든 법선 수정 방향이 표면에 대해 일관됩니다.
1
2
3
4
5
6
7
8
9
법선 (Normal)
↑
│
│
───────┼────────→ 접선 (Tangent)
/
/
↙
바이탄젠트 (Bitangent)
노멀맵에서 읽은 법선 방향을 실제 조명 계산에 사용하려면, 접선 공간의 좌표를 월드 공간으로 변환해야 합니다. 이 변환에 법선(N), 접선(T), 바이탄젠트(B) 세 벡터가 모두 필요하며, 이 세 벡터를 열로 묶은 행렬을 TBN 행렬이라 합니다. GPU는 픽셀마다 TBN 행렬을 사용하여 노멀맵의 값을 월드 공간 법선으로 변환하고, 그 결과로 조명을 계산합니다. 정점 속성에는 법선과 접선만 저장되어 있고, 바이탄젠트는 이 두 벡터에 수직인 방향으로 계산할 수 있으므로 별도로 저장하지 않습니다. 접선의 w 성분은 이 계산에서 방향(+1 또는 -1)을 결정하는 부호입니다.
노멀맵을 사용하지 않는 메쉬에서는 접선이 필요 없으므로, 이 속성을 생략하여 정점당 16바이트를 절약할 수 있습니다.
Color (정점 색상)
정점에 직접 색상을 지정하는 속성입니다. r, g, b, a 네 개의 값으로 구성됩니다. 일반적으로 각 채널을 8비트로 저장하여 정점당 4바이트를 차지합니다. HDR 등 더 넓은 범위가 필요한 경우 32비트 float으로 저장하며, 이 경우 16바이트가 됩니다.
텍스처 없이 정점 색상만으로 오브젝트에 색을 입힐 수 있습니다. 법선이나 UV와 마찬가지로 삼각형 내부에서 정점 색상도 보간되므로, 인접한 정점의 색상이 다르면 그 사이에서 색이 자연스럽게 섞입니다. 로우폴리(Low-Poly) 아트 스타일에서 텍스처 없이 정점 색상만으로 색을 표현하는 것이 대표적인 활용입니다.
텍스처와 정점 색상을 함께 사용하는 경우도 있습니다. 넓은 지형에서 풀, 흙, 바위 텍스처를 섞어야 할 때, 정점 색상의 각 채널을 가중치로 활용합니다. 이것을 버텍스 컬러 블렌딩(Vertex Color Blending)이라 합니다. 지형의 삼각형 하나를 예로 들면 다음과 같습니다.
1
2
3
4
5
6
7
8
9
10
정점 A 정점 A
R=1.0, G=0.0 풀 100%
/\ /\
/ \ / \
/ 보간 \ / 풀→흙 \
/ \ / 서서히 \
/________\ /___전환____\
정점 B 정점 C 정점 B 정점 C
R=0.0 R=0.0 흙 100% 흙 100%
G=1.0 G=1.0
정점 A에는 R=1.0을 지정하고, 정점 B와 C에는 G=1.0을 지정합니다. 셰이더에서 R 값을 풀 텍스처의 가중치로, G 값을 흙 텍스처의 가중치로 사용하면, 정점 A 근처는 풀 텍스처만 보이고 정점 B, C 근처는 흙 텍스처만 보입니다. 삼각형 내부에서는 R과 G가 보간되므로, 풀에서 흙으로 자연스럽게 전환됩니다.
속성별 크기 정리
각 정점 속성의 크기를 정리하면 다음과 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
┌──────────────┬──────────────┬─────────────────────────────┐
│ 속성 │ 크기 │ 용도 │
├──────────────┼──────────────┼─────────────────────────────┤
│ Position │ 12 바이트 │ 3D 공간 좌표 │
│ Normal │ 12 바이트 │ 표면 방향 (빛 계산) │
│ UV0 │ 8 바이트 │ 텍스처 매핑 좌표 │
│ UV1 │ 8 바이트 │ 라이트맵 좌표 │
│ Tangent │ 16 바이트 │ 노멀맵 좌표계 │
│ Color │ 4 바이트 │ 정점 색상/블렌딩 가중치 │
├──────────────┼──────────────┼─────────────────────────────┤
│ 전체 합계 │ 60 바이트 │ (모든 속성을 사용할 경우) │
└──────────────┴──────────────┴─────────────────────────────┘
모든 속성을 사용하면 정점 하나가 60바이트를 차지합니다. 속성이 많을수록 정점당 메모리가 증가하고, GPU가 처리해야 할 데이터량도 늘어납니다. 렌더링에 실제로 필요한 속성만 포함하는 것이 모바일 환경에서 특히 중요합니다.
메쉬의 메모리 구조
앞에서 정점 속성이 정점당 바이트 수를 결정하고, 인덱스 버퍼가 삼각형의 정점 참조를 저장한다고 했습니다. 이 두 요소를 종합하면, 하나의 메쉬가 GPU 메모리에서 차지하는 크기를 계산할 수 있습니다.
1
메쉬 메모리 = (정점 수 × 정점당 바이트) + (인덱스 수 × 인덱스 크기)
정점당 바이트는 사용하는 속성의 조합에 따라 달라집니다. 앞의 속성별 크기 표에서 Position + Normal + UV0만 사용하면 32바이트, 모든 속성을 사용하면 60바이트가 됩니다.
인덱스 수는 삼각형 수의 3배입니다. 삼각형 하나가 인덱스 3개를 사용하기 때문입니다.
인덱스 크기는 정점 수에 따라 결정됩니다. 16비트 정수가 표현할 수 있는 최대값이 65,535(2^16 - 1)이므로, 정점이 65,535개 이하이면 16비트(2바이트) 인덱스를 사용하고, 그 이상이면 32비트(4바이트) 인덱스가 필요합니다. 모바일에서는 16비트 인덱스 범위 안에 들도록 메쉬를 관리하는 것이 메모리와 성능 양쪽에 유리합니다.
메모리 계산 예시
앞의 공식에 구체적인 수치를 대입해 봅니다. 인덱스 버퍼 섹션에서 사용한 정점 10,000개짜리 모델을 기준으로, 삼각형 18,000개인 캐릭터 메쉬를 가정합니다. 노멀맵을 사용하는 캐릭터이므로 Position, Normal, UV0, Tangent를 포함합니다.
정점 버퍼
1
2
3
4
5
6
7
8
9
사용 속성:
Position = 12 바이트
Normal = 12 바이트
UV0 = 8 바이트
Tangent = 16 바이트
───────────────────────
합계 = 48 바이트/정점
정점 버퍼 크기 = 10,000 × 48 = 480,000 바이트 ≈ 469 KB
인덱스 버퍼
1
2
3
4
인덱스 수 = 18,000 × 3 = 54,000
인덱스 크기 = 2 바이트 (정점 10,000개 < 65,535이므로 16비트)
인덱스 버퍼 크기 = 54,000 × 2 = 108,000 바이트 ≈ 105 KB
합계
1
메쉬 메모리 = 469 KB + 105 KB ≈ 574 KB
하나의 캐릭터 메쉬가 약 574KB의 GPU 메모리를 차지합니다.
모바일 메쉬 예산
574KB는 PC 환경에서는 부담이 적지만, 모바일에서는 무시할 수 없는 크기입니다. 앞의 GPU 메모리 배치에서 설명한 것처럼, 모바일은 통합 메모리 구조를 사용하므로 메쉬가 차지하는 메모리가 늘어나면 텍스처, 오디오 등 다른 리소스에 쓸 수 있는 메모리가 줄어듭니다.
모바일 게임에서 일반적으로 권장되는 오브젝트 유형별 메쉬 복잡도 기준입니다.
1
2
3
4
5
6
7
8
9
┌────────────────────┬────────────────────────────┐
│ 오브젝트 유형 │ 삼각형 수 (권장 범위) │
├────────────────────┼────────────────────────────┤
│ 주인공 캐릭터 │ 5,000 ~ 15,000 │
│ NPC / 적 │ 2,000 ~ 7,000 │
│ 배경 소품 (작은) │ 100 ~ 500 │
│ 배경 건물 (중간) │ 1,000 ~ 3,000 │
│ 지형 청크 │ 5,000 ~ 10,000 │
└────────────────────┴────────────────────────────┘
이 수치는 절대적인 기준이 아니며, 동시 표시 오브젝트 수, 대상 기기의 성능, 텍스처와 셰이더 복잡도에 따라 달라집니다. 개별 오브젝트의 삼각형 수보다 화면에 동시에 보이는 전체 삼각형 수의 총합이 성능을 결정합니다. 모바일에서는 프레임당 총 삼각형 수를 10만~30만 개 이내로 유지하는 것이 일반적인 목표입니다.
앞에서 본 정점 속성의 선택도 메모리 관리의 일부입니다. 노멀맵을 사용하지 않는 오브젝트에서 Tangent를 제거하면, 정점 10,000개 기준으로 약 156KB(10,000 × 16바이트)를 절약할 수 있습니다. UV1(라이트맵)이 필요 없는 동적 오브젝트에서 UV1을 제거하면 약 78KB(10,000 × 8바이트)를 추가로 절약합니다. 하나의 메쉬에서 두 속성을 제거하면 234KB가 줄어들어, 574KB의 약 40%를 절감하는 셈입니다.
LOD (Level of Detail)
메쉬 예산을 지키는 방법 중 하나는 불필요한 정점 속성을 제거하는 것이고, 또 다른 방법은 상황에 따라 메쉬 자체의 복잡도를 낮추는 것입니다. 카메라에서 멀리 있는 오브젝트는 화면에서 차지하는 픽셀 수가 적습니다. 5,000개의 삼각형으로 이루어진 오브젝트가 화면에서 50×50 픽셀 정도로 보인다면, 대부분의 삼각형은 1픽셀보다 작아 디테일이 보이지 않습니다. 이런 오브젝트에 원본과 같은 삼각형 수를 유지하는 것은 GPU 연산의 낭비입니다.
LOD(Level of Detail)는 카메라와 오브젝트 사이의 거리에 따라 메쉬의 복잡도를 단계적으로 바꾸는 기법입니다. 카메라가 가까울 때는 원본 메쉬(LOD 0)를 사용하고, 거리가 멀어질수록 삼각형 수가 적은 메쉬로 전환합니다.
1
2
3
4
5
6
7
8
9
10
11
카메라에서 가까움 카메라에서 멀어짐
◄──────────────────────────────────────────────────────►
┌─────────┐ ┌─────────┐ ┌─────────┐
│ │ │ │ │ │
│ LOD 0 │ │ LOD 1 │ │ LOD 2 │
│ │ │ │ │ │
│ 5,000 │ │ 2,000 │ │ 500 │
│ 삼각형 │ │ 삼각형 │ │ 삼각형 │
└─────────┘ └─────────┘ └─────────┘
원본 (가장 정밀) 중간 정밀 가장 단순
LOD의 효과
삼각형 수가 줄면 GPU가 처리해야 할 정점과 래스터화 대상이 함께 줄어듭니다. GPU는 정점 셰이더(Vertex Shader)에서 각 정점의 위치를 변환하고 속성을 처리하는데, 이 연산은 정점 수에 비례합니다. LOD 0(5,000 삼각형)에서 LOD 2(500 삼각형)로 전환하면 처리량이 약 1/10이 됩니다.
화면에 나무 100그루가 보이는 장면을 가정합니다. 가까운 나무 10그루만 LOD 0(5,000 삼각형)을 사용하고, 중간 거리 30그루는 LOD 1(2,000 삼각형), 먼 거리 60그루는 LOD 2(500 삼각형)를 사용하면:
1
2
3
4
5
6
7
8
9
LOD 없이 전부 LOD 0:
100 × 5,000 = 500,000 삼각형
LOD 적용:
10 × 5,000 = 50,000 삼각형
30 × 2,000 = 60,000 삼각형
60 × 500 = 30,000 삼각형
────────────────────────────
합계 140,000 삼각형
총 삼각형 수가 500,000에서 140,000으로, 72% 감소합니다. 앞에서 본 모바일 프레임당 삼각형 예산(10만~30만 개)을 고려하면, LOD 없이는 나무 100그루만으로 예산을 초과합니다.
LOD 전환 시 주의점
LOD 단계가 전환될 때, 삼각형 수가 갑자기 바뀌면서 모델의 실루엣이 순간적으로 변하는 현상을 팝핑(Popping)이라 합니다. 특히 카메라가 앞뒤로 움직여 LOD 경계를 반복적으로 넘나들면, 오브젝트가 계속 변형되어 눈에 띕니다.
이를 완화하는 방법으로 LOD 크로스페이드(Crossfade)가 있습니다. 전환 시점에서 이전 LOD와 다음 LOD를 짧은 시간 동안 동시에 렌더링하면서 투명도를 전환합니다. Unity의 LOD Group 컴포넌트에서 Fade Mode를 설정하면 이 기능을 사용할 수 있습니다.
다만 크로스페이드는 전환 구간에서 두 메쉬를 동시에 렌더링하므로, 해당 오브젝트의 드로우 콜(Draw Call)(GPU에 렌더링을 요청하는 호출)이 일시적으로 2배가 됩니다. 동시에 전환되는 오브젝트가 많으면 부담이 커지므로, LOD 간 거리 간격을 충분히 두어 한 프레임에 전환이 집중되지 않도록 배치하는 것이 중요합니다.
LOD 단계 설계
LOD 단계를 몇 개로 구성하고, 각 단계에서 삼각형을 얼마나 줄일지는 오브젝트의 특성에 따라 달라집니다. 일반적인 가이드라인은 다음과 같습니다.
1
2
3
4
5
6
7
8
┌─────────┬────────────────────────┐
│ LOD │ 원본 대비 삼각형 비율 │
├─────────┼────────────────────────┤
│ LOD 0 │ 100% │
│ LOD 1 │ 50% │
│ LOD 2 │ 25% │
│ Culled │ 0% (비표시) │
└─────────┴────────────────────────┘
Unity의 LOD Group은 단순 거리가 아니라, 오브젝트가 화면에서 차지하는 비율(Screen Height Ratio)로 전환 시점을 결정합니다. 같은 거리에 있어도 큰 오브젝트는 화면을 많이 차지하므로 LOD 0을 유지하고, 작은 오브젝트는 더 일찍 LOD 1로 전환됩니다.
마지막 단계인 Culled는 오브젝트를 아예 렌더링하지 않는 것입니다. 화면 차지 비율이 설정한 임계값 이하로 떨어지면 적용되어, GPU 비용이 0이 됩니다. 이와 별도로, 카메라의 시야 영역(Frustum) 바깥에 있는 오브젝트를 렌더링에서 제외하는 프러스텀 컬링(Frustum Culling)은 Unity 엔진이 자동으로 수행합니다.
LOD 단계별 메쉬는 3D 모델링 도구에서 미리 만들거나, Unity의 LOD 생성 도구나 서드파티 플러그인으로 원본 메쉬에서 자동 생성할 수 있습니다. 자동 생성한 메쉬는 삼각형 감소로 실루엣이 과도하게 변형되지 않았는지 확인해야 합니다.
LOD와 메쉬 메모리의 관계
LOD를 사용하면 프레임당 처리하는 삼각형 수는 줄어들지만, GPU 메모리에는 모든 LOD 단계의 메쉬가 동시에 올라가 있어야 합니다. LOD 0, LOD 1, LOD 2를 모두 로드하면, 원본 메쉬 하나만 있을 때보다 메모리 사용량이 늘어납니다.
앞의 LOD 단계 설계 표에서 삼각형 비율이 100%, 50%, 25%였으므로, 메쉬 메모리도 대략 그 비율로 추가됩니다. 원본이 100KB라면 LOD 1이 약 50KB, LOD 2가 약 25KB로, 총 175KB가 되는 셈입니다. 원본만 있을 때보다 75%의 메모리가 추가됩니다. 다만 삼각형 수 50% 감소가 메모리 50% 감소와 정확히 일치하지는 않습니다. 정점 버퍼의 크기는 삼각형 수가 아니라 고유 정점 수에 비례하고, 인덱스 버퍼의 크기만 삼각형 수에 정비례하기 때문입니다.
이 75%의 메모리 추가와 앞에서 본 72%의 삼각형 감소를 비교하면, LOD의 트레이드오프가 드러납니다. 오브젝트 종류가 많아 LOD 단계를 전부 3단계로 구성하면 메모리 부담이 커지므로, 화면에 자주 등장하고 거리 변화가 큰 오브젝트에 우선적으로 LOD를 적용하는 것이 효율적입니다.
마무리
렌더링의 입력 데이터인 메쉬는 정점과 삼각형이라는 단순한 요소로 구성됩니다. 이 단순한 구조 위에 인덱스 버퍼, 정점 속성, LOD 같은 개념이 쌓이면서, GPU가 효율적으로 처리할 수 있는 형태가 완성됩니다.
메쉬는 정점과 삼각형의 집합입니다. 삼각형은 항상 평면이고 항상 볼록하여 래스터화가 단순하므로, GPU의 기본 처리 단위로 쓰입니다. 인덱스 버퍼는 정점의 중복 저장을 제거하여 메모리와 포스트 트랜스폼 캐시 효율을 동시에 높입니다.
정점에는 위치 외에 법선, UV, 접선, 색상 등의 속성이 포함되며, 모든 속성을 사용하면 정점 하나가 60바이트를 차지합니다. 메쉬 메모리는 (정점 수 × 정점당 바이트) + (인덱스 수 × 인덱스 크기)로 계산되고, 모바일에서는 프레임당 총 삼각형 수를 10만~30만 개 이내로 유지하면서 불필요한 정점 속성을 제거하는 것이 중요합니다.
LOD는 카메라와의 거리에 따라 메쉬 복잡도를 줄여 처리 비용을 절약하지만, 모든 LOD 단계의 메쉬가 GPU 메모리에 동시에 올라갑니다. 메모리 증가(약 75%)와 삼각형 감소(약 72%)의 균형을 오브젝트별로 판단해야 합니다.
메쉬가 오브젝트의 형태를 정의한다면, 텍스처는 그 형태에 색과 질감을 입힙니다. 모바일에서 텍스처는 메쉬보다 더 많은 메모리를 차지하는 경우가 대부분이며, 렌더링 기초 (2) - 텍스처와 압축에서 텍스처의 메모리 구조와 모바일 압축 포맷을 다룹니다.
관련 글
시리즈
- 렌더링 기초 (1) - 메쉬의 구조 (현재 글)
- 렌더링 기초 (2) - 텍스처와 압축
- 렌더링 기초 (3) - 머티리얼과 셰이더 기초