렌더링 기초 (3) - 머티리얼과 셰이더 기초 - soo:bak
작성일 :
머티리얼이란
Part 1에서 메쉬가 정점과 삼각형으로 오브젝트의 형태를 정의하는 구조를, Part 2에서 텍스처가 UV 좌표를 통해 표면에 색상과 디테일을 입히는 구조를 살펴보았습니다. 메쉬는 “어떤 모양인가”를 결정하고, 텍스처는 “표면이 어떻게 보이는가”를 담고 있습니다.
하지만 메쉬와 텍스처만으로는 렌더링을 완성할 수 없습니다. 같은 구(sphere) 메쉬에 같은 텍스처를 입히더라도, 표면이 빛을 금속처럼 반사할 수도 있고 천처럼 흡수할 수도 있습니다. 투명도 역시 텍스처가 아니라 렌더링 규칙에서 결정됩니다. 메쉬 위에 텍스처를 어떤 방식으로 입힐 것인지, 빛이 표면에 닿았을 때 어떻게 반응할 것인지, 불투명한 오브젝트인지 투명한 오브젝트인지 등을 결합하고 해석하는 규칙이 필요합니다.
머티리얼(Material)이 이 규칙을 정의합니다. 이 글에서는 머티리얼의 구조, 셰이더의 동작 원리(버텍스/프래그먼트), GPU의 렌더 스테이트 설정, 고정 파이프라인에서 프로그래머블 셰이더로의 발전 과정을 살펴봅니다. Part 1의 메쉬, Part 2의 텍스처와 함께 이 글에서 다루는 머티리얼/셰이더가 렌더링 파이프라인에 입력되는 세 가지 핵심 데이터를 완성합니다.
머티리얼은 셰이더를 참조하면서, 셰이더가 요구하는 파라미터 값을 저장합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
머티리얼의 구성
┌──────────────┐
│ 셰이더 │ ← GPU에서 실행되는 프로그램
└──────▲───────┘
│ 참조
┌──────┴───────────────────────────────────────────┐
│ 머티리얼 │
│ │
│ ┌────────────┐ ┌────────┐ ┌────────┐ │
│ │ 텍스처들 │ │ 색상 │ │ 반사도 │ │
│ └────────────┘ └────────┘ └────────┘ │
│ │
│ ┌────────┐ ┌────────────┐ ┌────────┐ │
│ │ 투명도 │ │ 노멀 강도 │ │ 기타 │ │
│ └────────┘ └────────────┘ └────────┘ │
└──────────────────────────────────────────────────┘
셰이더(Shader)는 GPU에서 실행되는 프로그램입니다. 셰이더가 “표면을 이런 규칙으로 렌더링하라”는 프로그램이라면, 머티리얼은 그 프로그램에 들어가는 구체적인 값을 담는 데이터 묶음입니다.
같은 메쉬에 서로 다른 머티리얼을 적용하면, 동일한 형태의 오브젝트가 완전히 다른 외관을 갖게 됩니다. 예를 들어 구(sphere) 메쉬 하나에 금속 머티리얼을 적용하면 반짝이는 금속 구가 되고, 유리 머티리얼을 적용하면 투명한 유리 구가 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
같은 메쉬 + 같은 셰이더, 다른 파라미터 → 다른 외관
┌──────────┐ 셰이더: Standard
│ │ Rendering Mode: Opaque
│ 구 메쉬 │ + 색상: 회색 → 반짝이는 금속 구
│ │ Metallic: 0.9
│ │ Smoothness: 0.8
└──────────┘
┌──────────┐ 셰이더: Standard
│ │ Rendering Mode: Transparent
│ 구 메쉬 │ + 색상: 흰색 (Alpha 0.2) → 투명한 유리 구
│ │ Metallic: 0.1
│ │ Smoothness: 1.0
└──────────┘
렌더링에서 메쉬는 “형태”, 텍스처는 “표면 데이터”, 머티리얼은 “형태와 데이터를 결합하여 최종 외관을 결정하는 규칙”입니다. 머티리얼의 핵심은 셰이더이며, 셰이더의 동작 구조가 곧 렌더링 성능을 좌우합니다.
셰이더의 역할
머티리얼 구성 요소에서 셰이더가 GPU에서 실행되는 프로그램이라고 했습니다. CPU의 프로그램이 하나의 작업을 순차적으로 처리하는 것과 달리, 셰이더는 수천 개의 정점이나 수백만 개의 픽셀을 동시에 병렬 처리하도록 설계되어 있습니다.
셰이더는 역할에 따라 크게 두 종류로 나뉩니다. 하나는 3D 공간의 정점 좌표를 2D 화면 좌표로 변환하는 버텍스 셰이더(Vertex Shader), 다른 하나는 화면의 각 픽셀 색상을 결정하는 프래그먼트 셰이더(Fragment Shader) 입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
버텍스 셰이더와 프래그먼트 셰이더
3D 정점 데이터 2D 화면 픽셀
(위치, 법선, UV) (최종 색상)
│ ▲
▼ │
┌──────────────┐ ┌──────────────┐
│ 버텍스 │ 래스터 │ 프래그먼트 │
│ 셰이더 │ ──── 라이저 ────► │ 셰이더 │
│ │ (고정 기능) │ │
│ 정점마다 │ │ 픽셀마다 │
│ 한 번 실행 │ │ 한 번 실행 │
└──────────────┘ └──────────────┘
│ │
좌표 변환 텍스처 샘플링
법선 변환 조명 계산
정점 애니메이션 색상 결정
버텍스 셰이더 (Vertex Shader)
버텍스 셰이더는 메쉬를 구성하는 정점(Vertex)마다 한 번씩 실행됩니다. Part 1에서 메쉬가 정점의 집합으로 구성된다고 설명했는데, 버텍스 셰이더는 그 정점들을 하나씩 처리하는 프로그램입니다.
버텍스 셰이더의 핵심 역할은 좌표 변환입니다. 메쉬의 정점은 원래 오브젝트 자체를 기준으로 한 로컬 좌표계(Local Space)에서 정의되어 있습니다. 로컬 좌표계란, 오브젝트의 중심을 원점(0, 0, 0)으로 놓고 각 정점의 위치를 표현한 좌표입니다. 이 로컬 좌표를 월드 좌표로, 월드 좌표를 카메라 기준 좌표(뷰 좌표)로, 뷰 좌표를 투영 좌표(클립 좌표)로 변환하는 과정을 거칩니다. 버텍스 셰이더의 출력은 이 클립 좌표이며, 이후 GPU가 원근 나눗셈과 뷰포트 변환을 거쳐 최종 화면 좌표를 산출합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
좌표 변환 과정 (버텍스 셰이더)
로컬 좌표 월드 좌표 뷰 좌표 클립 좌표
(모델 기준) (씬 기준) (카메라 기준) (투영 기준)
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│ │ Model │ │ View │ │ Projection│ │
│ * │ ───────► │ * │ ───────► │ * │ ────────► │ * │
│ │ Matrix │ │ Matrix │ │ Matrix │ │
└─────┘ └─────┘ └─────┘ └──┬──┘
│
오브젝트 씬 안에서의 카메라에서 버텍스 셰이더
자체 기준 절대 위치 바라본 위치 최종 출력
│
▼
GPU 고정 기능이 이어받음
(원근 나눗셈 → 뷰포트 변환
→ 화면 좌표)
이 변환은 행렬 곱셈으로 수행됩니다. 행렬(Matrix)은 이동, 회전, 스케일 같은 변환을 하나의 수학적 연산으로 표현하는 도구입니다. Model 행렬은 오브젝트의 위치, 회전, 스케일을 반영하여 로컬 좌표를 월드 좌표로 바꾸고, View 행렬은 카메라의 위치와 방향을 반영하여 뷰 좌표로 바꾸며, Projection 행렬은 원근(또는 직교) 투영을 적용하여 클립 좌표로 바꿉니다. Unity에서는 이 세 행렬을 합친 MVP(Model-View-Projection) 행렬을 셰이더에 자동으로 전달합니다.
좌표 변환 외에도 버텍스 셰이더는 다양한 작업을 수행할 수 있습니다. 법선 벡터 변환(조명 계산에 필요), UV 좌표 전달(텍스처 매핑에 필요), 정점 애니메이션(바람에 흔들리는 나뭇잎, 물결 효과 등)이 대표적입니다. 특히 정점 애니메이션은 CPU가 아닌 GPU에서 수행되므로, 대량의 오브젝트에 적용해도 CPU 부하를 줄일 수 있습니다.
프래그먼트 셰이더 (Fragment Shader)
정점의 좌표 변환이 끝나고 화면 좌표가 확정되면, GPU의 래스터라이저(Rasterizer)가 다음 단계를 수행합니다. Part 1에서 삼각형이 GPU의 기본 처리 단위이고 래스터화가 삼각형 내부를 픽셀로 채우는 과정이라고 했는데, 래스터라이저가 이 작업을 담당하는 하드웨어 유닛입니다. 래스터라이저는 정점 사이의 삼각형 영역을 화면 픽셀로 채우며, 이때 채워진 각 픽셀 후보를 프래그먼트(Fragment)라고 부릅니다. 프래그먼트 셰이더는 이 프래그먼트마다 한 번씩 실행되어 최종 색상을 결정합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
래스터라이저와 프래그먼트
래스터라이저 입력 래스터라이저 출력
(화면 좌표의 세 정점) (프래그먼트들)
v0 ■ ■ ■
/ \ ■ ■ ■ ■ ■
/ \ ■ ■ ■ ■ ■ ■ ■
/ \ ■ ■ ■ ■ ■ ■ ■ ■ ■
v1──────v2 ■ ■ ■ ■ ■ ■ ■ ■ ■ ■ ■
세 정점의 화면 좌표 삼각형 내부를 채운 픽셀(프래그먼트)
각 ■마다 프래그먼트 셰이더 실행
프래그먼트 셰이더가 수행하는 작업은 크게 세 단계로 진행됩니다. 먼저 텍스처 샘플링 단계에서, Part 2에서 다룬 텍스처로부터 해당 프래그먼트에 대응하는 텍셀(texel) 값을 읽어옵니다. 버텍스 셰이더가 전달한 UV 좌표를 사용하여 프래그먼트가 텍스처의 어느 위치에 해당하는지를 계산하고 색상을 가져옵니다.
다음으로 조명 계산 단계에서, 표면의 법선 벡터, 광원의 위치와 강도, 카메라의 위치 등을 종합하여 빛이 표면에 어떻게 반사되는지를 계산합니다. 대표적인 조명 성분은 세 가지입니다.
확산 반사(Diffuse)는 빛이 표면에 닿은 뒤 모든 방향으로 균일하게 퍼지는 반사입니다.
Part 1에서 다룬 법선 벡터와 광원 방향 사이의 각도(cos theta)가 이 값을 결정하며, 오브젝트의 기본적인 밝고 어두운 면을 만들어 냅니다.
정반사(Specular)는 빛이 특정 방향으로 집중 반사되어 표면에 밝은 하이라이트를 만드는 성분입니다. 확산 반사는 법선과 광원 방향만으로 계산되므로 카메라가 어디에 있든 결과가 같지만, 정반사는 반사된 빛이 카메라를 향할 때만 밝게 보입니다.
카메라가 이동하면 반사광이 카메라를 향하는 표면 위치도 달라지므로, 하이라이트가 따라 움직입니다.
이처럼 카메라 위치에 따라 결과가 달라지는 특성을 시점 의존(View-Dependent) 이라고 합니다.
주변광(Ambient)은 직접 광원 없이도 환경에서 간접적으로 도달하는 빛입니다. 직사광선이 닿지 않는 그림자 영역이 완전히 검게 보이지 않는 이유가 이 주변광 때문입니다.
프래그먼트 셰이더는 이 세 성분을 합산하여 최종 조명 값을 계산합니다.
마지막으로 최종 색상 결정 단계에서, 텍스처 색상과 조명 결과를 결합하고 머티리얼에 설정된 색상이나 기타 파라미터를 반영하여 프래그먼트의 최종 RGBA 색상을 출력합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
프래그먼트 셰이더의 처리 흐름
──────────────────────────────────────────────────────────
1) 텍스처 샘플링
UV 좌표 ──► 텍스처에서 텍셀 읽기 ──► 텍스처 색상
│
▼
2) 조명 계산
법선 벡터 ──┐
광원 정보 ──┼──► 확산 + 정반사 + 주변광 ──► 조명 값
카메라 위치 ──┘
│
▼
3) 최종 색상 결정
텍스처 색상 ──┐
조명 값 ──┼──► 결합 ──► 최종 RGBA 출력
머티리얼 파라미터 ──┘
(색상, 반사도 등)
프래그먼트 셰이더가 더 비싼 이유
버텍스 셰이더와 프래그먼트 셰이더를 비교하면, 대부분의 장면에서 프래그먼트 셰이더가 GPU 시간을 더 많이 소비합니다. 가장 큰 원인은 실행 횟수의 차이입니다.
화면에 표시되는 삼각형 하나를 예로 들어 봅니다. 이 삼각형은 정점 3개로 구성되므로, 버텍스 셰이더는 3번 실행됩니다. 하지만 이 삼각형이 화면에서 1,000픽셀을 차지한다면, 프래그먼트 셰이더는 1,000번 실행됩니다.
일반적인 3D 오브젝트에서 화면에 그려지는 프래그먼트 수는 정점 수보다 수십~수백 배 많습니다. 1080p 해상도(1920x1080) 기준으로 화면 전체를 덮는 오브젝트는 약 207만 개의 프래그먼트를 생성합니다.
여기에 프래그먼트 셰이더는 텍스처 샘플링과 조명 계산 등 버텍스 셰이더의 행렬 곱셈보다 복잡한 연산을 수행하는 경우가 많으므로, 실행 횟수와 연산 복잡도가 함께 작용하여 비용이 커집니다.
프래그먼트 셰이더에 연산 하나를 추가하면 그 연산이 207만 번 반복되는 셈이므로, 모바일에서는 프래그먼트 셰이더의 복잡도를 줄이는 것이 렌더링 성능 최적화에서 가장 효과가 큰 영역 중 하나입니다.
렌더 스테이트 (Render State)
버텍스 셰이더의 좌표 변환과 프래그먼트 셰이더의 색상 계산까지 살펴보았지만, GPU가 오브젝트를 그리려면 셰이더 프로그램만으로는 충분하지 않습니다.
삼각형의 보이지 않는 뒷면을 제거하고, 여러 오브젝트가 겹칠 때 깊이를 비교하며, 투명한 오브젝트의 색상을 기존 화면과 합성하는 등의 처리도 필요합니다.
셰이더가 각 정점과 픽셀의 값을 계산하는 프로그램이라면, 렌더 스테이트(Render State)는 그 계산 결과를 GPU 파이프라인이 어떻게 처리할지를 제어하는 설정입니다. 블렌딩, 깊이 테스트, 컬링, 스텐실 테스트 등이 렌더 스테이트에 포함됩니다.
블렌딩 (Blending)
블렌딩은 프래그먼트 셰이더가 출력한 새 색상(source)과 이미 프레임버퍼에 있는 기존 색상(destination)을 합성하는 규칙입니다.
불투명한 오브젝트는 새 색상이 기존 색상을 완전히 덮어쓰면 되므로, 블렌딩을 비활성화합니다. 반투명 유리나 연기 같은 투명한 오브젝트는 두 색상을 알파 값에 따라 섞어야 합니다.
가장 일반적인 알파 블렌딩 공식은 결과 = source × α + destination × (1 - α) 입니다. α가 1이면 새 색상만 남고(불투명), α가 0이면 기존 색상만 남습니다(완전 투명).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
블렌딩 예시
──────────────────────────────────────────────────────────
불투명 (Blend Off):
─────────────────────────────
destination(dst): 파란 배경
source(src): 빨간 오브젝트
결과 = src → 빨강 (완전 덮어쓰기)
반투명 (Alpha Blending, α = 0.5):
─────────────────────────────
destination(dst): 파란 배경
source(src): 빨간 유리
결과 = src × α + dst × (1 - α)
= 빨강 × 0.5 + 파랑 × 0.5 → 보라
깊이 테스트 (Depth Test / Z-Buffer)
화면의 같은 위치에 여러 오브젝트가 겹칠 때, 어떤 오브젝트가 앞에 있는지를 판별하는 처리입니다.
GPU는 Z-buffer(깊이 버퍼)라는 별도의 버퍼를 유지하며, 화면의 각 픽셀마다 현재까지 그려진 가장 가까운 프래그먼트의 깊이 값을 저장합니다. 깊이 값은 카메라로부터의 거리를 나타내며, 값이 작을수록 카메라에 가깝습니다.
새 프래그먼트를 그릴 때, GPU는 이 프래그먼트의 깊이 값과 Z-buffer에 저장된 값을 비교합니다.
새 프래그먼트가 더 가까우면(깊이 값이 더 작으면) 색상 버퍼에 기록하고 Z-buffer를 갱신합니다. 더 멀면 이미 앞에 다른 오브젝트가 있다는 뜻이므로 폐기합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
깊이 테스트 동작
──────────────────────────────────────────────────────────
카메라 ◄── 가까움(깊이 값 작음) ────── 멀음(깊이 값 큼) ──►
한 픽셀(P)에 세 오브젝트가 겹치는 경우:
색상 버퍼[P] Z-buffer[P]
─────────────────────────────────────────────────────────
초기 상태 비어 있음 ∞
① 빨간 상자 (깊이 7)
7 < ∞ → 통과 빨강 7
② 파란 구 (깊이 3)
3 < 7 → 통과 (더 가까움) 파랑 3
③ 초록 벽 (깊이 9)
9 < 3 → 실패 (더 멀음) 파랑 3
(변경 없음)
─────────────────────────────────────────────────────────
최종 결과: 파란 구의 색상이 화면에 표시됨
컬링 (Culling)
삼각형에는 앞면과 뒷면이 있습니다.
GPU는 삼각형을 구성하는 세 정점의 나열 순서로 앞면과 뒷면을 구분하는데, 카메라에서 바라볼 때 정점이 시계 방향으로 나열되면 앞면, 반시계 방향이면 뒷면으로 판단하는 것이 Unity의 기본 규칙입니다.
백 페이스 컬링(Back-face Culling)은 카메라를 향하지 않는 뒷면 삼각형을 아예 그리지 않도록 하는 설정입니다.
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
백 페이스 컬링
──────────────────────────────────────────────────────────
카메라에서 바라본 정점 나열 순서로 앞면/뒷면을 구분:
v0 v0
╱ ╲ ╱ ╲
╱ ╲ ╱ ╲
v1─────v2 v2─────v1
시계 방향(CW) 반시계 방향(CCW)
= 앞면 → 렌더링 = 뒷면 → 컬링
닫힌 오브젝트(구, 큐브)에 적용:
카메라
│
▼
┌───────────────┐
│ 앞면 │ → 렌더링
│ (≈ 50%) │
├───────────────┤
│ 뒷면 │ → 컬링(제외)
│ (≈ 50%) │
└───────────────┘
렌더링 대상 삼각형 약 50% 감소
닫힌 오브젝트(큐브, 구 등)에서는 뒷면이 보이지 않으므로, 뒷면을 그리는 것은 GPU 자원의 낭비입니다.
백 페이스 컬링을 활성화하면 그려야 할 삼각형 수가 대략 절반으로 줄어듭니다. 다만, 종이처럼 양면이 모두 보여야 하는 오브젝트에서는 컬링을 비활성화해야 합니다.
스텐실 테스트 (Stencil Test)
깊이 테스트가 카메라로부터의 거리로 프래그먼트를 걸러낸다면, 스텐실 테스트는 미리 기록해 둔 정수 값으로 특정 영역의 렌더링을 허용하거나 제외합니다.
GPU는 스텐실 버퍼(Stencil Buffer)에 화면의 각 픽셀마다 정수 값(보통 0~255)을 저장하고, 이 값을 비교 조건으로 사용합니다.
포털 효과가 대표적인 예시입니다.
먼저 포털 형태의 메쉬를 그리면서 해당 픽셀의 스텐실 값을 1로 기록합니다. 이후 다른 씬을 렌더링할 때 “스텐실 값이 1인 픽셀에만 그려라”는 조건을 설정하면, 포털 영역 안에서만 다른 씬이 보입니다.
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
스텐실 버퍼 활용 예시: 포털 효과
──────────────────────────────────────────────────────────
1) 포털 메쉬를 그리며 스텐실 버퍼에 값 기록:
스텐실 버퍼 상태:
┌──────────────────────────────┐
│ 0 0 0 0 0 0 0 │
│ 0 ┌──────────────┐ 0 0 │
│ 0 │ 1 1 1 1│ 0 0 │
│ 0 │ 1 1 1 1│ 0 0 │
│ 0 └──────────────┘ 0 0 │
│ 0 0 0 0 0 0 0 │
└──────────────────────────────┘
2) "스텐실 = 1인 픽셀만 렌더링" 조건으로 다른 씬을 그림:
화면 결과:
┌──────────────────────────────┐
│ 일반 씬 │
│ ┌──────────────┐ │
│ │ 다른 씬이 │ │
│ │ 포털 안에만 │ │
│ │ 보임 │ │
│ └──────────────┘ │
│ 일반 씬 │
└──────────────────────────────┘
렌더 스테이트 변경의 비용
렌더 스테이트를 변경하는 것도 비용이 발생합니다.
GPU는 현재 설정된 렌더 스테이트에 맞춰 내부 하드웨어를 구성하는데, 스테이트가 바뀌면 이 구성을 다시 설정해야 합니다.
이를 스테이트 변경(State Change)이라고 하며, 변경이 잦을수록 렌더링 성능이 떨어집니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
렌더 스테이트 변경 비용
비효율적인 순서 효율적인 순서
────────────── ─────────────
오브젝트 A: 불투명 불투명 오브젝트 모아서 그리기
오브젝트 B: 투명 ← 변경 ──────────────────────
오브젝트 C: 불투명 ← 변경 오브젝트 A: 불투명
오브젝트 D: 투명 ← 변경 오브젝트 C: 불투명
← 변경 1회
스테이트 변경: 3회 투명 오브젝트 모아서 그리기
──────────────────────
오브젝트 B: 투명
오브젝트 D: 투명
스테이트 변경: 1회
같은 렌더 스테이트를 사용하는 오브젝트끼리 모아서 그리면 스테이트 변경 횟수를 줄일 수 있습니다.
Unity의 렌더링 파이프라인은 이 원칙에 따라 렌더링 순서를 정합니다.
먼저 불투명 오브젝트를 모두 그립니다. 불투명 오브젝트는 블렌딩이 필요 없고 깊이 테스트만으로 앞뒤 판별이 가능하므로, 같은 렌더 스테이트로 일괄 처리할 수 있고 그리는 순서도 자유롭습니다.
이 단계에서 깊이 버퍼가 채워지면, 이후 가려진 프래그먼트를 조기에 폐기할 수 있어 셰이딩 비용도 줄어듭니다.
다음으로 투명 오브젝트를 뒤에서 앞(back-to-front) 순서로 그립니다.
알파 블렌딩은 이미 화면에 있는 색상(destination)과 새 색상(source)을 합성하는 연산이므로, 뒤에 있는 오브젝트의 색상이 먼저 화면에 존재해야 올바른 결과가 나옵니다.
고정 파이프라인에서 프로그래머블 셰이더로
지금까지 설명한 버텍스 셰이더와 프래그먼트 셰이더는 개발자가 직접 프로그래밍할 수 있는 구조입니다.
조명 모델을 자유롭게 구현하고, 정점을 임의로 변형하며, 렌더 스테이트와 조합하여 다양한 시각 효과를 만들 수 있습니다.
하지만 처음부터 이런 구조였던 것은 아닙니다. 초기 GPU에는 렌더링 방식이 하드웨어에 고정되어 있었고, 이 한계가 프로그래머블 셰이더의 등장으로 이어졌습니다.
고정 기능 파이프라인 (Fixed Function Pipeline)
초기 GPU(1990년대)는 고정 기능 파이프라인으로 동작했습니다. 하드웨어에 미리 정해진 조명 모델과 텍스처 합성 방식만 사용할 수 있었습니다. 개발자는 몇 가지 파라미터(광원 수, 텍스처 모드 등)를 설정할 수 있을 뿐, 렌더링 알고리즘 자체를 바꿀 수는 없었습니다.
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
고정 기능 파이프라인
──────────────────────────────────────────────────────────
정점 데이터
│
▼
┌──────────────┐
│ 좌표 변환 │─── 위치, 회전, 스케일 값만 설정 가능
│ (고정) │
└──────┬───────┘
▼
┌──────────────┐
│ 조명 계산 │─── 광원 위치, 색상, 개수만 설정 가능
│ (고정) │
└──────┬───────┘
▼
┌──────────────┐
│ 텍스처 합성 │─── 블렌딩 모드, 안개(Fog)만 설정 가능
│ (고정) │
└──────┬───────┘
▼
화면
모든 단계의 알고리즘이 하드웨어에 고정
→ 파라미터 조절만 가능, 알고리즘 변경 불가
이 구조는 단순하고 하드웨어 최적화에 유리했지만, 표현의 한계가 분명했습니다. 모든 게임이 같은 조명 모델을 사용할 수밖에 없었으므로, 시각적 차별화가 어려웠습니다.
프로그래머블 셰이더의 등장
2000년 말 DirectX 8.0이 발표되고, 2001년 NVIDIA GeForce 3가 출시되면서 프로그래머블 셰이더가 도입되었습니다. 고정되어 있던 정점 처리와 픽셀 처리 단계를 개발자가 직접 프로그래밍할 수 있게 된 것입니다.
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
프로그래머블 파이프라인
──────────────────────────────────────────────────────────
정점 데이터
│
▼
┌──────────────┐
│ 버텍스 │─── 개발자가 작성하는 프로그램
│ 셰이더 │ 좌표 변환, 법선 처리, 정점 변형 등을
│ │ 자유롭게 구현
└──────┬───────┘
▼
┌──────────────┐
│ 래스터라이저 │─── 하드웨어 고정 (변경 불가)
│ (고정 기능) │
└──────┬───────┘
▼
┌──────────────┐
│ 프래그먼트 │─── 개발자가 작성하는 프로그램
│ 셰이더 │ 조명 모델, 텍스처 합성, 색상 계산 등을
│ │ 자유롭게 구현
└──────┬───────┘
▼
화면
고정 파이프라인과의 차이:
파라미터 조절만 가능 → 알고리즘 자체를 교체 가능
이 변화로 개발자가 조명 모델부터 시각 효과까지 직접 구현할 수 있게 되었습니다.
만화 느낌을 내는 툰 셰이딩(Toon Shading), 물리 법칙 기반으로 사실적인 표면을 표현하는 PBR(Physically Based Rendering), 복잡한 형상을 실시간으로 렌더링하는 레이 마칭(Ray Marching) 등이 프로그래머블 셰이더로 가능해진 대표적인 기법입니다.
셰이더 언어의 발전
프로그래머블 셰이더 초기에는 어셈블리에 가까운 저수준 언어로 셰이더를 작성해야 했습니다. 이후 고수준 셰이더 언어가 등장하면서 생산성이 향상되었습니다.
1
2
3
4
5
6
7
8
9
10
셰이더 언어의 발전
시기 언어 / 기술 특징
───── ────────── ────
~2001 어셈블리 셰이더 레지스터 직접 조작
2002~ HLSL (DirectX) C 유사 문법
Cg (NVIDIA) 크로스 API 대응
2004~ GLSL (OpenGL) C 유사 문법
2014~ Metal Shading Language Apple 플랫폼 전용
2015~ SPIR-V (Vulkan) 셰이더 바이트코드 표준
Unity의 셰이더 시스템도 이 흐름을 따라 발전했습니다.
Unity는 셰이더의 구조(패스, 렌더 스테이트, 프로퍼티 등)를 선언하는 ShaderLab이라는 고유의 기술 언어를 사용합니다.
초기에는 ShaderLab 안에서 고정 파이프라인 명령을 나열하는 고정 함수 셰이더(Fixed Function Shader) 방식이었으며, 현재는 레거시로 분류되어 사용이 권장되지 않습니다.
이후 ShaderLab 안에서 Cg 또는 HLSL로 버텍스/프래그먼트 셰이더를 직접 작성하는 방식으로 발전했습니다.
현재는 HLSL이 표준 셰이더 언어이며, 코드를 작성하지 않고 노드를 연결하여 셰이더를 만드는 Shader Graph도 함께 제공됩니다. Shader Graph는 내부적으로 HLSL 코드를 자동 생성하므로, 최종 결과는 코드로 작성한 HLSL 셰이더와 동일합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Unity 셰이더 기술의 변천
──────────────────────────────────────────────────────────
┌───────────────────────────┐
│ 고정 함수 셰이더 (레거시) │ 고정 파이프라인 시절
└──────────┬────────────────┘
▼
┌───────────────────────────┐
│ ShaderLab + Cg │ 프로그래머블 셰이더 초기
└──────────┬────────────────┘
▼
┌───────────────────────────────────────────────────┐
│ ShaderLab + HLSL (현재 표준) │
│ │
│ 작성 방법 1: HLSL 코드 직접 작성 │
│ 작성 방법 2: Shader Graph (HLSL 자동 생성) │
└───────────────────────────────────────────────────┘
Unity의 머티리얼 시스템
머티리얼과 셰이더의 일반적인 구조를 살펴보았으므로, Unity에서 이 구조가 구체적으로 어떻게 구현되는지를 확인합니다.
머티리얼과 셰이더의 관계
Unity에서 머티리얼은 하나의 셰이더를 참조하고, 그 셰이더가 정의한 프로퍼티 값을 저장합니다. Inspector 창에서 텍스처, 색상, 수치 등을 설정하면, 렌더링 시 이 값들이 셰이더에 전달됩니다.
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
Unity 머티리얼 구조
┌─ 머티리얼 A ─────────────────────────┐
│ │
│ 셰이더 참조: Standard Shader │
│ │
│ 프로퍼티: │
│ _MainTex = 벽돌 텍스처 │
│ _Color = (0.8, 0.8, 0.8, 1) │
│ _Metallic = 0.0 │
│ _Smoothness = 0.3 │
│ _BumpMap = 벽돌 노멀 맵 │
└───────────────────────────────────────┘
┌─ 머티리얼 B ─────────────────────────┐
│ │
│ 셰이더 참조: Standard Shader (동일) │
│ │
│ 프로퍼티: │
│ _MainTex = 금속 텍스처 │
│ _Color = (0.9, 0.9, 0.9, 1) │
│ _Metallic = 0.9 │
│ _Smoothness = 0.7 │
│ _BumpMap = 금속 노멀 맵 │
└───────────────────────────────────────┘
같은 셰이더를 참조하지만 프로퍼티 값이 다르면
별도의 머티리얼로 취급됨
프로퍼티 값이 하나라도 다르면 별도의 머티리얼입니다.
같은 셰이더와 같은 프로퍼티를 사용한다면 머티리얼을 하나로 통합할 수 있고, 불필요한 분리는 드로우콜(GPU에 오브젝트를 그리라는 명령)을 늘려 렌더링 효율을 떨어뜨립니다.
머티리얼과 드로우콜
CPU가 GPU에 오브젝트를 그리라는 명령을 보내는 것을 드로우콜(Draw Call)이라고 합니다.
드로우콜 하나에는 메쉬, 머티리얼(셰이더 + 프로퍼티), 변환 정보가 포함되며, CPU가 드로우콜을 발행하면 GPU가 해당 오브젝트를 렌더링합니다.
드로우콜에는 CPU 오버헤드가 따릅니다.
CPU가 렌더링 상태를 준비하고 커맨드 버퍼(Command Buffer)에 명령을 기록해야 하므로, 오브젝트가 수백 ~ 수천 개로 늘어나면 드로우콜 수도 함께 증가하여 CPU 측 병목이 될 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
드로우콜과 머티리얼의 관계
씬에 오브젝트 4개가 있는 경우:
머티리얼이 모두 다를 때
─────────────────────
오브젝트 1: 머티리얼 A → 드로우콜 1
오브젝트 2: 머티리얼 B → 드로우콜 2
오브젝트 3: 머티리얼 C → 드로우콜 3
오브젝트 4: 머티리얼 D → 드로우콜 4
합계: 드로우콜 4회
같은 머티리얼을 공유할 때 (배칭 가능)
─────────────────────────────────────
오브젝트 1: 머티리얼 A ─┐
오브젝트 2: 머티리얼 A ─┤──► 드로우콜 1 (배칭)
오브젝트 3: 머티리얼 A ─┘
오브젝트 4: 머티리얼 B ────── 드로우콜 2
합계: 드로우콜 2회
배칭(Batching)은 동일한 머티리얼을 사용하는 여러 오브젝트의 메쉬를 합쳐 단일 드로우콜로 그리는 기법입니다.
모바일 최적화에서는 가능한 한 머티리얼 수를 줄이는 것을 권장합니다.
텍스처 아틀라스(Texture Atlas)(여러 텍스처를 하나로 합치는 기법)를 사용하거나, 유사한 오브젝트끼리 같은 머티리얼을 공유하도록 설계하면 드로우콜을 줄일 수 있습니다.
셰이더 변형 (Shader Variants)
머티리얼마다 사용하는 기능 조합이 다를 수 있습니다. 어떤 머티리얼은 노멀 맵을 사용하고, 어떤 머티리얼은 사용하지 않는 경우 등입니다.
이 때, Unity에서는 기능 조합마다 별도의 셰이더 파일을 작성하는 대신, 하나의 셰이더 파일이 키워드 조합에 따라 서로 다른 버전으로 컴파일되도록 합니다.
이렇게 생성된 각 버전이 셰이더 변형(Shader Variant)입니다.
1
2
3
4
5
6
7
8
9
10
11
셰이더 변형 예시
Standard Shader
│
├── _NORMALMAP ON + _METALLIC ON → 변형 1
├── _NORMALMAP ON + _METALLIC OFF → 변형 2
├── _NORMALMAP OFF + _METALLIC ON → 변형 3
└── _NORMALMAP OFF + _METALLIC OFF → 변형 4
키워드 2개 × 각 2가지 옵션 = 4개 변형
키워드가 N개이면 최대 2^N개 변형이 생성될 수 있음
키워드가 늘어날수록 변형 수도 급증하여 빌드 시간과 메모리 사용량이 증가합니다.
변형은 빌드 시점에 미리 컴파일해 둘 수도 있고, 런타임에 처음 사용될 때 컴파일될 수도 있습니다.
런타임에 아직 컴파일되지 않은 변형이 처음 사용되면 그 자리에서 셰이더 컴파일이 발생하므로, 해당 프레임에서 수십 밀리초의 지연이 생기고 플레이어에게는 순간적인 끊김으로 느껴집니다.
모바일 프로젝트에서는 사용하지 않는 키워드를 명시적으로 제거하여 변형 수 자체를 줄입니다.
필요한 변형은 Shader Variant Collection으로 미리 컴파일(프리워밍)하여 런타임 끊김을 방지합니다.
셰이더 변형 스트리핑으로 불필요한 변형을 빌드에서 제거하면 빌드 크기도 줄일 수 있습니다.
메쉬, 텍스처, 머티리얼의 관계 — 렌더링의 입력 데이터
Part 1부터 이 글까지 세 편에 걸쳐 렌더링의 입력 데이터를 구성하는 세 가지 요소를 살펴보았습니다.
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
렌더링의 입력 데이터 구조
┌────────────────────────────────────────────────────────────┐
│ 오브젝트 │
│ │
│ ┌───────────┐ ┌───────────────────────────────────┐ │
│ │ │ │ 머티리얼 │ │
│ │ 메쉬 │ │ │ │
│ │ │ │ ┌─────────┐ ┌───────────────┐ │ │
│ │ 정점 │ │ │ 셰이더 │ │ 파라미터 │ │ │
│ │ 인덱스 │ │ │ │ │ │ │ │
│ │ UV │ │ │ 버텍스 │ │ ┌───────────┐ │ │ │
│ │ 법선 │ │ │ 프래그 │ │ │ 텍스처 │ │ │ │
│ │ │ │ │ 먼트 │ │ │ 색상 │ │ │ │
│ │ │ │ │ │ │ │ 반사도 │ │ │ │
│ └───────────┘ │ └─────────┘ │ │ ... │ │ │ │
│ │ │ └───────────┘ │ │ │
│ 형태를 정의 │ └───────────────┘ │ │
│ │ │ │
│ │ 외관을 결정 │ │
│ └───────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
│
▼
GPU로 전달
│
▼
렌더링 파이프라인
메쉬는 정점, 인덱스, UV, 법선으로 오브젝트의 형태를 정의합니다.
텍스처는 표면의 색상, 노멀, 러프니스 등을 2D 이미지로 담는 데이터입니다.
머티리얼은 셰이더와 파라미터(텍스처 포함)를 묶어, 메쉬 표면의 최종 외관을 결정합니다.
이 세 가지는 렌더링 파이프라인에 입력되는 데이터입니다.
이 데이터를 실제로 처리하는 하드웨어가 GPU이며, GPU의 내부 구조에 따라 같은 데이터라도 처리 방식과 성능 특성이 달라집니다.
마무리
머티리얼은 셰이더와 파라미터(텍스처 포함)를 묶어 메쉬 표면의 최종 외관을 결정합니다.
셰이더는 GPU에서 실행되는 프로그램으로, 버텍스 셰이더가 정점의 좌표를 변환하고 프래그먼트 셰이더가 각 픽셀의 색상을 계산합니다.
화면에 그려지는 프래그먼트 수는 정점 수보다 수십 ~ 수백 배 많으므로, 모바일에서 프래그먼트 셰이더의 복잡도를 줄이는 것은 성능에 직접적인 영향을 미칩니다.
GPU는 셰이더 외에도 블렌딩, 깊이 테스트, 컬링 같은 렌더 스테이트 설정이 필요합니다.
같은 렌더 스테이트를 사용하는 오브젝트끼리 모아서 그리면 스테이트 변경 횟수가 줄어들고, 그만큼 렌더링 효율이 올라갑니다.
셰이더 자체도 초기의 고정 기능 파이프라인에서 프로그래머블 셰이더로 발전하면서, 개발자가 조명 모델과 시각 효과를 직접 구현할 수 있게 되었습니다.
Unity에서는 같은 머티리얼을 공유하는 오브젝트끼리 배칭으로 묶어 드로우콜을 줄일 수 있습니다.
머티리얼 수를 최소화하고 셰이더 변형을 관리하면 빌드 크기와 메모리도 절약됩니다.
세 편에 걸쳐 살펴본 메쉬, 텍스처, 머티리얼/셰이더는 렌더링 파이프라인에 입력되는 데이터이며, 이 데이터의 구조를 이해하는 것이 렌더링 최적화의 출발점입니다.
관련 글
시리즈
- 렌더링 기초 (1) - 메쉬의 구조
- 렌더링 기초 (2) - 텍스처와 압축
- 렌더링 기초 (3) - 머티리얼과 셰이더 기초 (현재 글)