콘텐츠로 이동

GPU 렌더링 파이프라인 심화

GPU 렌더링 파이프라인은 3D 장면 데이터를 2D 픽셀로 변환하는 일련의 처리 단계입니다. 각 단계를 깊이 이해하면 셰이더 최적화, 드로우콜 감소, 메모리 대역폭 절약 등 실질적인 성능 향상을 이끌어낼 수 있습니다.


CPU GPU
| |
| DrawCall ──────────▶ |
| Input Assembler
| ↓
| Vertex Shader (프로그래머블)
| ↓
| Hull Shader (테셀레이션, 선택)
| ↓
| Domain Shader (선택)
| ↓
| Geometry Shader (선택)
| ↓
| Rasterizer (고정 기능)
| ↓
| Pixel/Fragment Shader (프로그래머블)
| ↓
| Output Merger (깊이 테스트, 블렌딩)
| ↓
| Framebuffer → 화면 출력

GPU는 SIMT(Single Instruction, Multiple Threads) 방식으로 동작합니다. NVIDIA에서는 32개 스레드 묶음을 워프(Warp), AMD에서는 64개를 **웨이브프론트(Wavefront)**라고 합니다.

워프 내 32개 스레드 → 동일한 명령어 동시 실행
┌──────────────────────────────────────────┐
│ Thread 0 | Thread 1 | ... | Thread 31 │
│ 같은 셰이더 코드, 다른 데이터 │
└──────────────────────────────────────────┘
// 이 코드는 워프 내 스레드가 다른 경로를 실행해 성능 저하
if (uv.x > 0.5) {
color = texture(albedo, uv); // 절반 스레드 실행
} else {
color = vec4(1.0, 0.0, 0.0, 1.0); // 나머지 절반 실행
}
// 워프는 두 경로를 모두 순차 실행 → 실질적으로 절반 성능

분기 발산을 줄이려면 mix(), step(), smoothstep() 같은 수학 함수로 분기를 없애거나, 텍스처 룩업으로 대체합니다.


// 기본 버텍스 셰이더
layout(location = 0) in vec3 aPosition;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
uniform mat4 uMVP; // Model-View-Projection
uniform mat4 uModel;
uniform mat3 uNormalMatrix; // transpose(inverse(Model))의 3x3
out vec3 vWorldPos;
out vec3 vNormal;
out vec2 vTexCoord;
void main() {
vWorldPos = vec3(uModel * vec4(aPosition, 1.0));
vNormal = normalize(uNormalMatrix * aNormal);
vTexCoord = aTexCoord;
gl_Position = uMVP * vec4(aPosition, 1.0);
}

주의: Normal을 변환할 때 Model 행렬이 아닌 transpose(inverse(Model))의 3x3을 사용해야 비균등 스케일에서도 올바릅니다.


고정 기능 하드웨어가 삼각형을 픽셀 그리드에 매핑합니다.

삼각형 버텍스 → 픽셀 커버리지 계산 → 보간 속성 생성
(0,0)──────────────(W,0)
| ╱╲ |
| ╱ ╲ 픽셀 |
| ╱ ╲ 샘플링 |
|╱──────╲ |
(0,H) (W,H)

Perspective-Correct Interpolation: UV, 법선 등 버텍스 속성을 W로 나눠 원근 보정을 적용합니다. noperspective 키워드로 선형 보간만 할 수도 있습니다.


// 좋음 — 연속된 UV, 캐시 히트 가능성 높음
vec4 c = texture(sampler, vTexCoord);
// 나쁨 — 랜덤 UV 접근, 캐시 미스 다발
vec2 randomUV = hash(vTexCoord) * vec2(1.0);
vec4 c = texture(sampler, randomUV);
기존: 픽셀 셰이더 실행 → 깊이 테스트 → 실패 시 결과 버림
Early-Z: 깊이 테스트 먼저 → 통과한 픽셀만 셰이더 실행

Early-Z가 활성화되려면 픽셀 셰이더에서 gl_FragDepth를 수정하거나 discard를 사용하지 않아야 합니다.

// Early-Z 비활성화 원인
void main() {
if (texture(albedo, vTexCoord).a < 0.5)
discard; // Early-Z 비활성화! → 오버드로우 발생
// ...
}
// 해결책: Alpha Test 전용 셰이더 분리 + Sort by depth

픽셀 셰이더 출력 → 깊이/스텐실 테스트 → 블렌딩 → Framebuffer
블렌딩 공식:
Result = Src * SrcFactor + Dst * DstFactor
알파 블렌딩:
Result = Src * Src.a + Dst * (1 - Src.a)
// OpenGL 블렌딩 설정 예시
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
// 반투명 오브젝트는 뒤에서 앞 순서(Painter's Algorithm)로 렌더링 필요

모바일 GPU(Mali, Adreno, Apple GPU)는 화면을 타일(16×16~32×32 픽셀)로 나눠 처리합니다.

화면 → 타일 분할 → 각 타일을 온칩 메모리에서 처리 → 메인 메모리에 기록
장점: 메인 메모리 대역폭 절약
단점: Framebuffer Read(glBlendFunc 일부, 포스트 프로세싱) 시 타일 데이터 강제 flush

모바일 최적화 원칙:

  • glInvalidateFramebuffer / MTLRenderPassDescriptor loadAction = .clear로 이전 타일 데이터 불필요 시 명시
  • 중간 렌더 타겟(render-to-texture)을 최소화
  • AlphaTest 대신 AlphaBlend를 선호 (Early-Z 활용)

전통적인 Vertex/Geometry Shader를 대체하는 모던 파이프라인입니다.

전통: IA → VS → GS → Rasterizer
Mesh: Task Shader → Mesh Shader → Rasterizer
장점:
- Meshlet 기반 GPU-driven 렌더링
- Culling을 GPU에서 직접 수행
- 동적 LOD 전환 용이
// HLSL Mesh Shader 개요 (DirectX 12)
[numthreads(32, 1, 1)]
[outputtopology("triangle")]
void MeshShader(
in uint gid : SV_GroupID,
in uint tid : SV_GroupThreadID,
out vertices MyVertex verts[64],
out indices uint3 tris[128])
{
SetMeshOutputCounts(vertCount, triCount);
// Meshlet 데이터로 verts, tris 채우기
}

원인증상해결책
오버드로우GPU 이용률 높고 프레임 낮음Occlusion Culling, Front-to-back 정렬
메모리 대역폭 부족텍스처 많고 느림밉맵, 텍스처 압축(BC7, ASTC)
Warp Divergence셰이더 복잡하고 분기 많음분기를 math로 대체
CPU-GPU 동기화GPU 대기멀티 프레임 버퍼링, 비동기 컴퓨트
State Change 과다드로우콜 많음인스턴싱, 배치 렌더링, GPU-Driven

  • SIMT: 32개(NVIDIA) 스레드가 동일 명령 실행 → 분기 최소화
  • Early-Z: discard/gl_FragDepth 수정 시 비활성화 → 오버드로우 증가
  • 모바일 Tile-Based: Framebuffer Read 최소화, 명시적 load/store action 설정
  • Mesh Shader: GPU-Driven 렌더링의 핵심, Meshlet 단위 컬링
  • 최적화 우선순위: 드로우콜 감소 → 오버드로우 감소 → 셰이더 단순화 → 대역폭 절약