래스터화 파이프라인 (2) - 출력 병합 - soo:bak
작성일 :
프래그먼트 이후의 처리
래스터화 파이프라인 (1)에서 삼각형이 화면 격자 위의 프래그먼트로 변환되는 과정을 다루었습니다. Edge Function으로 삼각형 내부를 판별하고, 무게중심 좌표로 정점 속성을 보간하여 각 픽셀 위치마다 프래그먼트 데이터가 만들어졌습니다.
프래그먼트 셰이더가 색상을 계산한 뒤에도 그 결과가 곧바로 화면에 표시되지는 않습니다.
겹치는 오브젝트의 앞뒤를 가리는 깊이 테스트, 특정 영역에만 렌더링을 제한하는 스텐실 테스트, 반투명 오브젝트의 색상을 합성하는 블렌딩처럼 — 프래그먼트가 최종 픽셀이 되기까지 거쳐야 할 처리가 남아 있습니다.
이 처리들을 통칭하여 출력 병합(Output Merger) 단계라 하며, 깊이 버퍼·스텐실 버퍼·프레임 버퍼·렌더 타겟·MRT가 이 단계의 핵심 구성 요소입니다.
프레임 버퍼
출력 병합 단계의 출발점은 프레임 버퍼(Frame Buffer)입니다.
프레임 버퍼는 최종 렌더링 결과가 기록되는 메모리 영역으로, 화면에 표시될 이미지의 각 픽셀 색상이 이곳에 저장됩니다.
한 프레임의 모든 드로우 콜이 처리되면, 프레임 버퍼의 내용이 디스플레이로 전송됩니다.
프레임 버퍼의 각 픽셀은 RGBA 4개 채널로 구성됩니다.
R(Red)·G(Green)·B(Blue)는 색상을, A(Alpha)는 불투명도를 나타냅니다.
가장 일반적인 형식인 RGBA8은 채널당 8비트(0~255), 픽셀당 총 4바이트를 사용합니다.
HDR(High Dynamic Range) 렌더링에서는 채널당 8비트로는 밝기 범위가 부족하므로, RGBA16F(채널당 16비트 부동소수점, 픽셀당 8바이트)나 R11G11B10F(픽셀당 4바이트, 알파 채널 없음) 등의 형식을 사용합니다.
형식이 바뀌면 픽셀당 크기가 달라지고, 프레임 버퍼 전체의 메모리 크기도 비례하여 변합니다.
GPU는 렌더링 과정에서 프레임 버퍼에 픽셀을 쓰고, 블렌딩 시에는 기존 값을 다시 읽어야 하므로, 버퍼가 클수록 매 프레임의 대역폭 소비가 증가합니다.
여기까지 설명한 프레임 버퍼는 색상 데이터만 담는 색상 버퍼(Color Buffer)에 해당합니다.
실제 렌더링에서는 색상 버퍼 외에도 깊이 버퍼, 스텐실 버퍼 등 보조 버퍼가 함께 사용됩니다.
OpenGL의 프레임 버퍼 오브젝트(FBO)처럼, 그래픽스 API에서 “프레임 버퍼”라고 부르는 것은 이 보조 버퍼들까지 포함한 버퍼 집합 전체를 가리킵니다.
깊이 버퍼 (Z-Buffer)
3D 장면에서는 여러 오브젝트가 화면의 같은 픽셀 위치에 겹칠 수 있습니다.
현실 세계에서 가까운 물체가 먼 물체를 가리듯이, 렌더링에서도 카메라에 가까운 오브젝트가 먼 오브젝트를 가려야 자연스러운 3D 장면이 됩니다.
이 가시성 판별(Visibility Determination)을 수행하는 것이 깊이 버퍼(Depth Buffer), 또는 Z-Buffer입니다.
깊이 버퍼는 프레임 버퍼와 동일한 해상도를 가지며, 각 픽셀 위치마다 현재까지 기록된 가장 가까운 프래그먼트의 깊이 값을 저장합니다.
깊이 값은 그래픽스 수학 (3)에서 다룬 NDC(Normalized Device Coordinates)에서의 z 값을 기반으로 합니다.
DirectX, Metal, Vulkan에서 NDC z 범위는 [0, 1]이고, OpenGL에서는 [-1, 1]입니다. 이 절에서는 [0, 1] 범위를 기준으로 하며, 0.0이 카메라에 가장 가깝고 1.0이 가장 먼 값입니다.
깊이 테스트 덕분에 불투명 오브젝트는 렌더링 순서와 무관하게, 3D 공간에서 앞에 있는 것이 뒤에 있는 것을 자연스럽게 가립니다.
삼각형 A를 먼저 그리든 삼각형 C를 먼저 그리든, 최종 결과에서는 가장 가까운 삼각형의 색상만 남습니다.
반투명 오브젝트에서는 사정이 달라지는데, 이는 블렌딩 절에서 다룹니다.
깊이 버퍼의 일반적인 형식은 D24S8(깊이 24비트 + 스텐실 8비트, 합계 32비트)이며, 픽셀당 4바이트입니다. 여기서 스텐실 8비트가 무엇인지는 스텐실 버퍼 절에서 다룹니다.
D24S8은 깊이 테스트와 스텐실 테스트를 모두 지원하므로 대부분의 3D 렌더링 시나리오에서 기본값으로 사용되며, Unity도 대부분의 플랫폼에서 이 형식을 기본으로 채택합니다.
깊이 전용 형식으로 D32F(32비트 부동소수점)를 사용하는 경우도 있습니다.
스텐실이 필요 없고 깊이 정밀도가 특별히 중요한 상황에서 선택됩니다.
비행 시뮬레이터처럼 콕핏(0.1m)과 지형(수십~수백 km)을 동시에 렌더링하는 경우, 24비트 정수로는 정밀도가 부족하여 z-fighting이 발생할 수 있습니다.
부동소수점의 정밀도 분포(0 근처에 밀집)를 활용하는 Reversed-Z 기법과 D32F를 조합하면 원거리 z-fighting 감소 효과가 극대화됩니다.
섀도우 맵에서도 깊이 정밀도 부족이 shadow acne의 직접적 원인이 되므로 D32F를 선택하기도 합니다.
높은 깊이 정밀도와 스텐실을 동시에 필요로 하는 경우를 위해 D32F_S8(32비트 부동소수점 깊이 + 8비트 스텐실, 픽셀당 8바이트)도 존재하지만, 메모리·대역폭 비용이 두 배이므로 흔하지는 않습니다.
깊이 값의 비선형성
깊이 버퍼에 저장되는 값은 3D 공간의 실제 거리와 선형 관계가 아닙니다.
만약 선형이라면 near = 1m, far = 1000m일 때 거리 500m 지점이 버퍼 값 약 0.5에 대응하겠지만, 실제로는 그렇지 않습니다.
버텍스 셰이더가 출력한 클립 좌표 (x, y, z, w)에서 GPU는 모든 성분을 w로 나눕니다.
이것이 원근 나눗셈(perspective divide)입니다.
원근 투영 행렬은 w에 뷰 공간의 z값을 넣도록 설계되어 있으므로, NDC z = z’/w = z’/z가 되어 결과가 자연스럽게 1/z에 비례하는 형태가 됩니다.
이 비선형 변환의 결과, near = 1, far = 1000일 때 깊이 값 분포는 다음과 같습니다.
| 거리(z) | NDC 깊이 값 |
|---|---|
| 1 (near) | 0.000 |
| 2 | ≈ 0.501 |
| 10 | ≈ 0.901 |
| 100 | ≈ 0.991 |
| 500 | ≈ 0.999 |
| 1000 (far) | 1.000 |
버퍼 범위의 절반(약 0.0~0.5)이 거리 1~2 구간(단 1m)에 사용되고, 나머지 절반(약 0.5~1.0)이 2~1000 구간(998m)에 사용됩니다.
near plane 근처에서는 깊이 값이 촘촘하게 변하여 정밀도가 높고, far plane 근처에서는 깊이 값이 거의 변하지 않아 정밀도가 낮습니다.
정밀도가 낮은 영역에서는 3D 공간에서 서로 다른 거리에 있는 두 오브젝트가 깊이 버퍼에서 같은 값으로 기록될 수 있습니다.
이 상황에서 두 오브젝트의 표면이 매 프레임마다 번갈아 앞으로 나타나는 Z-fighting 현상이 발생합니다.
이것이 앞서 D32F + Reversed-Z 조합을 언급한 이유이기도 합니다. 이 조합을 이해하려면 먼저 부동소수점의 정밀도 분포를 알아야 합니다.
IEEE 754 부동소수점은 지수(exponent) 구조 때문에 0에 가까울수록 표현 가능한 값이 촘촘합니다. 0.000001과 0.000002는 쉽게 구분하지만, 0.999998과 0.999999는 구분하기 어렵습니다.
깊이 버퍼가 부동소수점(D32F)일 때, 전통적 매핑(near=0, far=1)에서는 1/z 분포와 float 정밀도 분포가 같은 방향으로 겹칩니다.
| near(가까운 곳) | far(먼 곳) | |
|---|---|---|
| 1/z 분포 | 깊이 값이 촘촘 (정밀도 높음) | 깊이 값이 성김 (정밀도 낮음) |
| float 정밀도 | 0 근처 → 촘촘 (정밀도 높음) | 1 근처 → 성김 (정밀도 낮음) |
| 합산 | 이미 충분한데 더 정밀 | 이미 부족한데 더 부족 |
가까운 곳에 정밀도가 과잉으로 몰리고, 먼 곳은 이중으로 부족해집니다.
원근 투영의 비선형성(1/z) 자체는 바꿀 수 없지만, 깊이 값의 매핑 방향은 바꿀 수 있습니다. Reversed-Z는 near=1, far=0으로 뒤집어 float의 0 근처 정밀도를 먼 거리 쪽에 배분합니다.
| near(가까운 곳) | far(먼 곳) | |
|---|---|---|
| 1/z 분포 | 깊이 값이 촘촘 (변함없음) | 깊이 값이 성김 (변함없음) |
| float 정밀도 | 1 근처 → 성김 | 0 근처 → 촘촘 |
| 합산 | 1/z의 높은 정밀도를 float이 적당히 깎음 | 1/z의 낮은 정밀도를 float이 보충 |
두 분포가 서로 상쇄되어, 전체 깊이 범위에 걸쳐 정밀도가 훨씬 균일해집니다. 이 효과는 float 깊이 버퍼(D32F)에서 뚜렷하며, 값 간격이 균일한 정수 깊이 버퍼(D24S8)에서는 효과가 없습니다.
Z-fighting을 줄이는 가장 직접적인 방법은 near plane과 far plane의 비율을 줄이는 것입니다.
깊이 값이 1/z에 비례하므로, 이 비율이 클수록(예: near=0.01, far=1000 → 1:100,000) 가까운 쪽에 정밀도가 과도하게 집중되고, 먼 거리의 정밀도는 더욱 부족해집니다.
near=0.3, far=1000이면 비율은 1:3,333으로, 대부분의 장면에서 충분한 정밀도를 확보할 수 있습니다.
Unity 카메라의 Near Clipping Plane 기본값이 0.3인 것도 이 이유입니다.
현대 렌더링 엔진에서는 Reversed-Z라는 기법으로 깊이 정밀도를 개선합니다.
이 절의 깊이 테스트 설명은 가까울수록 0.0, 멀수록 1.0인 전통적 매핑을 기준으로 했지만, Reversed-Z에서는 방향을 뒤집어 near plane이 1.0, far plane이 0.0에 대응합니다.
이 차이가 Reversed-Z의 효과를 결정합니다.
| 전통적 매핑 | Reversed-Z | |
|---|---|---|
| D32F (불균일) | 1/z 편향 + float 편향 = 이중 편향 | 1/z 편향 ↔ float 편향 = 상쇄 |
| D24S8 (균일) | 1/z 편향만 존재 | 1/z 편향만 존재 — 변화 없음 |
Unity는 DirectX, Metal, Vulkan 플랫폼에서 Reversed-Z를 기본으로 사용합니다. OpenGL/OpenGL ES에서는 NDC z 범위가 [-1, 1]이므로, near를 1에, far를 0에 매핑하는 Reversed-Z 기법을 그대로 적용할 수 없어 Reversed-Z가 사용되지 않습니다.
Reversed-Z를 사용하는 플랫폼에서는 깊이 버퍼의 초기화 값이 1.0이 아닌 0.0이 되며, 깊이 비교 함수도 Less 대신 Greater로 바뀝니다.
Unity가 이 설정을 자동으로 처리하므로 셰이더 작성 시 별도 대응은 불필요하지만, 깊이 버퍼를 직접 읽거나 커스텀 깊이 연산을 수행할 때는 깊이 방향이 플랫폼에 따라 다르다는 점을 인지해야 합니다.
Unity는 이를 위해 셰이더에서 UNITY_REVERSED_Z 매크로를 제공합니다.
Early-Z vs Late-Z
렌더링 파이프라인의 논리적 순서에서 깊이 테스트는 프래그먼트 셰이더 이후에 위치합니다.
색상을 계산한 뒤에야 폐기 여부가 결정되므로, 가려질 프래그먼트에도 셰이더 비용이 그대로 소모됩니다.
Early-Z는 이 순서를 뒤집어, 프래그먼트 셰이더 이전에 깊이 테스트를 먼저 수행합니다.
가려질 프래그먼트는 셰이딩 자체를 건너뛰므로 연산 비용이 절감됩니다.
Late-Z (논리적 순서)
Late-Z는 논리적 순서 그대로, 프래그먼트 셰이더가 실행된 이후 깊이 테스트를 수행하는 방식입니다.
Early-Z (최적화)
Early-Z는 프래그먼트 셰이더 이전에 깊이 테스트를 수행합니다.
해당 픽셀 위치에 이미 더 가까운 깊이 값이 기록되어 있으면, 셰이더를 실행하지 않고 프래그먼트를 바로 폐기합니다.
Early-Z가 효과적으로 작동하려면, 불투명 오브젝트를 카메라 기준으로 앞에서 뒤(front-to-back) 순서로 렌더링해야 합니다.
가까운 오브젝트를 먼저 그리면 깊이 버퍼에 작은 값이 먼저 기록되고, 이후 도착하는 먼 오브젝트의 프래그먼트는 Early-Z에서 바로 폐기됩니다.
Unity의 렌더링 파이프라인(URP, Built-in, HDRP)은 Early-Z 효율을 위해 불투명 오브젝트를 자동으로 카메라 기준 앞→뒤 순서로 정렬합니다.
Unity는 렌더링 순서를 렌더 큐(Render Queue)라는 번호 체계로 관리하는데, 불투명 오브젝트가 속하는 Opaque 큐에 이 앞→뒤 정렬이 적용됩니다.
Early-Z가 작동하지 않는 경우
Early-Z가 비활성화되거나 효과가 제한되는 경우가 있습니다.
첫째, Alpha Test(discard/clip) — 셰이더에서 discard 명령으로 프래그먼트를 폐기하는 경우, 셰이더를 실행해봐야 해당 프래그먼트가 살아남는지 알 수 있습니다.
Early-Z는 깊이 비교뿐 아니라 통과한 프래그먼트의 깊이를 버퍼에 미리 기록하므로, discard로 사라질 프래그먼트의 깊이까지 버퍼에 남아 뒤의 오브젝트를 잘못 가릴 수 있습니다.
GPU는 이를 방지하기 위해 해당 드로우 콜의 Early-Z를 비활성화하거나 크게 제한합니다. GPU 아키텍처 (2)에서 다룬 TBDR 환경의 alpha test 비용도 이 비활성화에서 비롯됩니다.
둘째, 셰이더에서 깊이 값을 수정하는 경우 — 프래그먼트 셰이더가 출력 깊이를 직접 변경하면(SV_Depth 출력 등), 래스터화 단계에서 보간된 깊이와 최종 출력 깊이가 달라집니다.
Early-Z는 래스터화 단계의 깊이를 기준으로 판단하므로, 셰이더가 깊이를 바꾸면 판단이 틀어집니다. 이 경우에도 GPU는 Early-Z를 비활성화합니다.
셋째, 깊이 쓰기 비활성화 — ZWrite Off로 깊이 쓰기를 끄면 깊이 버퍼가 갱신되지 않습니다.
Early-Z 테스트 자체는 여전히 수행되므로, 이미 깊이 버퍼에 기록된 불투명 오브젝트 뒤에 있는 프래그먼트는 걸러집니다.
다만 깊이를 새로 기록하지 않으므로, 이 오브젝트 자체가 이후 드로우 콜의 Early-Z 판단에 기여하지 못합니다.
반투명 오브젝트가 ZWrite Off를 사용하는 것도 같은 이유입니다.
반투명 오브젝트가 깊이를 기록하면, 그 뒤에 있는 오브젝트가 깊이 테스트에서 탈락하여 투명한 면 너머로 보여야 할 장면이 사라집니다.
따라서 반투명 오브젝트는 깊이를 읽기만 하고 쓰지 않으므로, Early-Z 최적화 효과가 제한됩니다.
스텐실 버퍼
깊이 버퍼가 “어떤 프래그먼트가 앞에 있는가”를 판별한다면, 스텐실 버퍼(Stencil Buffer)는 “이 픽셀에 그릴 것인가, 말 것인가”를 판별합니다.
각 픽셀에 8비트 정수 값(0~255)을 저장하며, 앞서 다룬 D24S8 형식에서 깊이 24비트 옆의 나머지 8비트가 스텐실 버퍼입니다.
프래그먼트가 도착하면, 해당 픽셀에 기록된 스텐실 값과 미리 설정한 참조 값을 비교합니다.
조건을 만족하면 그리고, 만족하지 않으면 건너뜁니다.
이런 조건부 렌더링을 활용하면, 특정 영역에 스텐실 값을 먼저 기록해 둔 뒤 그 위치에만 그리거나 피해서 그릴 수 있습니다.
스텐실 버퍼의 활용 예시
거울 효과.
거울 표면의 영역에 스텐실 값을 기록합니다.
반사된 장면을 렌더링할 때, 스텐실 값이 존재하는 영역에만 그리도록 설정합니다.
거울 영역 밖에서는 반사 장면이 보이지 않습니다.
아웃라인 효과.
오브젝트를 정상적으로 렌더링하면서 스텐실에 1을 기록합니다.
이후 오브젝트를 약간 크게 확대하여 외곽선 색상으로 렌더링하되, 스텐실이 1인 영역은 제외합니다.
결과적으로 오브젝트의 가장자리에만 외곽선이 남습니다.
포털 효과.
포털 프레임 영역을 렌더링하면서 해당 픽셀에 스텐실 값을 기록합니다.
이후 포털 너머의 장면(별도 카메라로 촬영한 다른 공간)을 렌더링할 때, 스텐실 값이 존재하는 영역에만 그리도록 설정합니다.
포털 프레임 밖으로 다른 공간의 장면이 삐져나오지 않으므로, 프레임 안쪽에서만 다른 세계가 보이는 효과가 만들어집니다.
Unity에서 스텐실 테스트는 셰이더의 Stencil 블록에서 설정합니다.
앞서 설명한 참조 값(Ref), 비교 함수(Comp), 통과·실패 시 동작(Pass, Fail, ZFail)을 이 블록에서 지정합니다.
UI 시스템의 Mask 컴포넌트도 내부적으로 스텐실 버퍼를 사용합니다.
Mask 오브젝트를 렌더링할 때 해당 영역에 스텐실 값을 기록하고, 자식 UI 요소는 스텐실 값이 일치하는 픽셀에만 렌더링됩니다.
Mask를 중첩하면 비트 단위로 마스크 값을 누적하므로(1 → 3 → 7 → 15 …), 8비트 범위에서 최대 8단계까지 중첩이 가능합니다.
다만 Mask 자체가 별도의 드로우 콜을 발생시키고, 자식 요소의 머티리얼에 스텐실 설정이 추가되어 배칭이 깨질 수 있습니다.
또한 커스텀 셰이더를 사용하는 UI 요소는 기본 UI/Default 셰이더에 내장된 스텐실 프로퍼티(_StencilComp, _Stencil, _StencilOp 등)가 빠져 있으므로, 이를 명시적으로 추가하지 않으면 Mask 하위에 있어도 마스킹이 작동하지 않습니다.
2D 스프라이트 영역에서는 SpriteMask 컴포넌트가 같은 역할을 합니다.
SpriteMask도 내부적으로 스텐실 버퍼를 사용하며, SpriteRenderer의 Mask Interaction 속성을 Visible Inside Mask 또는 Visible Outside Mask로 설정하여 마스킹 여부를 제어합니다.
기본 Sprites-Default 셰이더에 스텐실 프로퍼티가 내장되어 있으므로 기본 셰이더를 사용하면 바로 작동하지만, 커스텀 셰이더를 쓸 때 스텐실 프로퍼티를 추가해야 하는 점은 UI Mask와 동일합니다.
반면 RectMask2D는 스텐실 버퍼를 사용하지 않습니다.
CPU에서 RectTransform 영역을 기준으로 영역 밖의 UI 요소를 렌더링 대상에서 제외하는 방식이므로, 드로우 콜 추가나 배칭 영향 없이 마스킹이 이루어집니다.
대신 직사각형 영역만 마스킹할 수 있어, 원형이나 불규칙한 형태의 마스킹에는 Mask를 사용해야 합니다.
블렌딩
지금까지 다룬 깊이 테스트와 스텐실 테스트는 프래그먼트를 “통과시키거나 버리는” 이진 판정이었습니다.
불투명 오브젝트는 뒤에 있는 것을 완전히 가리므로 가장 가까운 프래그먼트 하나만 남기면 되고, 이진 판정만으로 충분합니다.
반면 유리, 물, 파티클 이펙트처럼 뒤에 있는 것이 비쳐 보여야 하는 반투명 오브젝트에서는, 새로운 프래그먼트의 색상과 기존 프레임 버퍼의 색상을 혼합(Blend)해야 합니다.
블렌딩 공식
블렌딩은 소스 색상(새 프래그먼트)과 대상 색상(프레임 버퍼의 기존 값)을 각각의 팩터(factor)로 곱한 뒤 더하는 연산입니다.
\[\text{최종 색상} = \text{Src} \times \text{SrcFactor} + \text{Dst} \times \text{DstFactor}\]- 소스 색상(Src): 프래그먼트 셰이더가 출력한 색상
- 대상 색상(Dst): 프레임 버퍼에 이미 기록된 색상
- 소스 팩터(SrcFactor): 소스 색상에 곱하는 계수
- 대상 팩터(DstFactor): 대상 색상에 곱하는 계수
Alpha Blending
Alpha Blending은 소스의 알파 값(투명도)을 팩터로 사용하여 소스와 대상을 혼합합니다. 유리, 물 등 반투명 표면의 렌더링에 사용됩니다.
- 소스 팩터: $\text{SrcAlpha}$ $(= \text{src.a})$
- 대상 팩터: $\text{OneMinusSrcAlpha}$ $(= 1 - \text{src.a})$
예시 — 소스: 빨강 $(1,\,0,\,0)$, $\text{alpha} = 0.3$ (30% 불투명) / 대상: 파랑 $(0,\,0,\,1)$
\[\begin{aligned} \text{최종} &= (1,\,0,\,0) \times 0.3 + (0,\,0,\,1) \times 0.7 \\\\ &= (0.3,\,0,\,0) + (0,\,0,\,0.7) \\\\ &= (0.3,\,0,\,0.7) \quad \rightarrow \text{30\% 빨강 + 70\% 파랑} \end{aligned}\]Additive Blending
Additive Blending은 소스 색상을 대상 색상에 단순히 더합니다. 파티클 이펙트(불꽃, 빛줄기 등)에서 주로 사용됩니다.
- 소스 팩터: $\text{One}$ $(= 1)$
- 대상 팩터: $\text{One}$ $(= 1)$
여러 파티클이 겹칠수록 색상이 밝아지므로, 밝은 빛 효과에 적합합니다.
반투명 오브젝트의 정렬 문제
반투명 오브젝트를 올바르게 렌더링하려면 뒤에서 앞(back-to-front) 순서로 그려야 합니다.
블렌딩은 프레임 버퍼에 이미 기록된 색상 위에 새 색상을 겹치는 연산이므로, 가장 뒤에 있는 오브젝트부터 프레임 버퍼에 깔아야 올바른 결과가 나옵니다.
이 뒤→앞 정렬 순서는 앞서 Early-Z 절에서 다룬 불투명 오브젝트의 앞→뒤 정렬과 정반대입니다.
Unity는 불투명 오브젝트(Opaque 큐, 2000번대)를 앞→뒤로, 반투명 오브젝트(Transparent 큐, 3000번대)를 뒤→앞으로 그리며, 이 정렬 방향의 차이 때문에 두 단계가 분리되어 있습니다.
반투명 오브젝트의 뒤→앞 정렬은 완벽하지 않습니다.
정렬은 픽셀 단위가 아니라 오브젝트의 중심점(pivot) 기준이므로, 두 반투명 오브젝트가 서로 관통하면 하나의 중심점만으로는 정확한 순서를 결정할 수 없습니다.
이때 시각적 아티팩트가 발생하며, 래스터화 기반 렌더링의 근본적인 한계입니다.
렌더 타겟과 MRT
지금까지 다룬 버퍼와 블렌딩은 모두 렌더링 결과를 화면용 프레임 버퍼에 기록하는 것을 전제로 했습니다.
하지만 출력 대상이 항상 화면일 필요는 없습니다. 예를 들어 거울에 비친 장면을 표현하려면, 거울 시점의 렌더링 결과를 화면이 아닌 텍스처에 먼저 기록해야 합니다.
이처럼 렌더링 결과가 기록되는 대상을 렌더 타겟(Render Target)이라 하며, 화면용 프레임 버퍼도, 텍스처도 모두 렌더 타겟입니다.
렌더 텍스처
렌더 타겟으로 지정된 텍스처를 렌더 텍스처(Render Texture)라고 합니다.
앞의 거울 예시에서 거울 시점으로 렌더링한 결과가 기록된 텍스처가 곧 렌더 텍스처이며, 이 텍스처를 거울 표면의 머티리얼에 입히면 반사 효과가 완성됩니다.
이처럼 렌더 텍스처는 이후 다른 렌더링 패스에서 입력 텍스처로 사용됩니다.
Unity에서는 RenderTexture 클래스가 렌더 텍스처에 대응합니다.
카메라의 Target Texture에 RenderTexture를 할당하면, 해당 카메라의 렌더링 결과가 화면 대신 렌더 텍스처에 기록됩니다.
MRT (Multiple Render Targets)
일반적인 렌더링에서는 프래그먼트 셰이더가 하나의 색상 값을 출력하여 하나의 렌더 타겟에 기록합니다.
MRT(Multiple Render Targets)를 사용하면 프래그먼트 셰이더가 여러 색상 값을 동시에 출력하여, 각각을 서로 다른 렌더 타겟에 한 번의 패스로 기록할 수 있습니다.
Deferred Rendering의 G-Buffer
MRT가 가장 활발히 쓰이는 곳이 Deferred Rendering(디퍼드 렌더링)입니다.
Forward Rendering에서는 각 오브젝트를 렌더링할 때 조명 계산까지 함께 수행합니다.
광원이 N개이고 오브젝트가 M개이면 조명 계산이 최대 N × M번 반복되므로, 광원 수가 늘어날수록 비용이 급증합니다.
Deferred Rendering은 이 문제를 피하기 위해 렌더링을 두 단계로 나눕니다.
첫 번째 단계(Geometry Pass)에서는 조명 계산 없이, 모든 오브젝트의 기하 정보(색상, 법선, 스페큘러(표면의 정반사 특성) 등)만 MRT를 통해 여러 렌더 텍스처에 기록합니다.
이 텍스처 집합이 G-Buffer입니다.
두 번째 단계(Lighting Pass)에서는 G-Buffer의 데이터를 읽어 조명 계산을 수행합니다.
조명 비용이 오브젝트 수(M)와 무관하게 화면의 픽셀 수와 광원 수(N)에만 비례하므로, 광원이 많은 장면에서 Forward Rendering보다 효율적입니다.
G-Buffer는 여러 렌더 텍스처로 구성되므로 메모리 사용량이 큽니다.
Full HD 기준으로 G-Buffer 하나의 텍스처가 약 8MB이고, 3~4개를 사용하면 24~32MB에 달합니다.
매 프레임마다 이 데이터를 읽고 써야 하므로 대역폭 부담도 그만큼 늘어납니다.
Deferred Rendering에는 메모리·대역폭 외에도 구조적 한계가 있습니다.
G-Buffer의 각 픽셀에는 가장 가까운 표면 하나의 속성만 저장됩니다.
예를 들어 빨간 벽 앞에 반투명 유리가 있으면, 유리의 색상·법선을 G-Buffer에 기록하는 순간 벽의 정보가 덮어쓰여 사라집니다.
반투명 렌더링은 두 표면의 색상을 블렌딩해야 하므로 양쪽 정보가 모두 필요하지만, G-Buffer 구조에서는 한쪽만 남습니다.
따라서 반투명 오브젝트는 G-Buffer를 거치지 않고 별도의 Forward 패스로 렌더링됩니다.
또한 G-Buffer는 픽셀 단위로 데이터를 저장하므로, 서브 샘플 단위의 데이터가 필요한 MSAA와 직접 호환되지 않습니다.
그래서 Deferred Rendering에서 안티앨리어싱이 필요하면, FXAA나 TAA 같은 후처리 기반 기법을 사용합니다.
메모리와 대역폭이 제한적인 모바일에서는 이 부담이 크기 때문에, Unity URP의 모바일 설정에서는 Forward Rendering이 기본으로 사용됩니다.
다만 GPU 아키텍처 (2)에서 다룬 TBDR 아키텍처에서는 타일 단위로 G-Buffer를 온칩 메모리에서 처리할 수 있어, 모바일에서도 대역폭 부담이 줄어듭니다.
반면 HDRP(High Definition Render Pipeline)는 데스크톱·콘솔을 대상으로 하며, HDRP Asset의 Lit Shader Mode 설정을 통해 Forward와 Deferred를 모두 지원합니다.
출력 병합의 전체 흐름
앞에서 다룬 깊이 테스트, 스텐실 테스트, 블렌딩, 렌더 타겟이 출력 병합 단계에서 어떻게 연결되는지 아래 다이어그램으로 정리했습니다.
Early-Z가 활성화된 드로우 콜에서는 프래그먼트 셰이더 이전에 깊이 테스트가 수행되고, 비활성화된 경우에는 Late-Z 경로만 사용되며, 두 경로가 동시에 실행되지는 않습니다.
각 버퍼의 역할과 크기는 다음과 같습니다.
| 버퍼 | 크기/형식 | 역할 |
|---|---|---|
| 프레임 버퍼 (색상 버퍼) | RGBA8/16F, 4~8 B/pixel | 최종 색상 저장 |
| 깊이+스텐실 버퍼 | D24S8 (4 B/pixel) 또는 D32F_S8 (8 B/pixel) | 가시성 판별 (깊이 테스트) + 조건부 렌더링 (마스킹) |
| 렌더 텍스처 | 다양 | 오프스크린 렌더링, MRT, G-Buffer, 포스트 프로세싱 |
해상도 1920x1080, D24S8 + RGBA8 기준: 색상 버퍼 ~8 MB, 깊이+스텐실 ~8 MB, 합계 ~16 MB (프레임당)
마무리
- 프레임 버퍼는 최종 색상이 기록되는 메모리 영역이며, RGBA 채널로 구성됩니다.
- 깊이 버퍼(Z-Buffer)는 각 픽셀의 가장 가까운 프래그먼트 깊이를 저장하여 가시성을 판별합니다.
- Early-Z는 프래그먼트 셰이더 이전에 깊이 테스트를 수행하여 가려진 프래그먼트의 셰이딩 비용을 절약하지만, alpha test나 깊이 수정 시에는 비활성화됩니다.
- 스텐실 버퍼는 8비트 정수 값으로 조건부 렌더링(거울, 포털, 아웃라인 등)을 구현합니다.
- 블렌딩은 반투명 오브젝트의 색상을 렌더 타겟의 기존 값(Dst)과 혼합하며, 올바른 결과를 위해 뒤에서 앞(back-to-front) 순서로 렌더링해야 합니다.
- 렌더 타겟은 렌더링 결과가 기록되는 대상이며, 프레임 버퍼뿐 아니라 렌더 텍스처도 렌더 타겟이 됩니다. MRT는 한 번의 패스로 여러 렌더 타겟에 동시에 기록하여 Deferred Rendering의 G-Buffer 구성에 사용됩니다.
이 버퍼들은 모두 GPU 메모리에 위치하며, GPU 아키텍처 (2)에서 다룬 TBDR의 타일 메모리에서는 깊이 테스트, 블렌딩, MSAA가 온칩에서 수행되어 대역폭을 절약합니다.
프레임 버퍼에 기록된 이미지는 아직 화면에 표시된 것이 아닙니다. 프레임 버퍼의 데이터가 디스플레이에 전달되는 과정에서 GPU와 디스플레이의 타이밍이 어긋나면 화면이 찢어지는 현상이 발생합니다.
다음 글에서는 스캔아웃, 티어링, VSync, 더블/트리플 버퍼링과 함께, 래스터화의 근본적 한계인 앨리어싱과 안티앨리어싱 기법들을 다룹니다.
관련 글
시리즈
- 래스터화 파이프라인 (1) - 삼각형에서 프래그먼트까지
- 래스터화 파이프라인 (2) - 출력 병합 (현재 글)
- 래스터화 파이프라인 (3) - 디스플레이와 안티앨리어싱
전체 시리즈
- 하드웨어 기초 (1) - CPU 아키텍처와 파이프라인
- 하드웨어 기초 (2) - 메모리 계층 구조
- 하드웨어 기초 (3) - GPU의 탄생과 발전
- 하드웨어 기초 (4) - 모바일 SoC
- 그래픽스 수학 (1) - 벡터와 벡터 연산
- 그래픽스 수학 (2) - 행렬과 변환
- 그래픽스 수학 (3) - 좌표 공간의 전환
- 그래픽스 수학 (4) - 투영
- C# 런타임 기초 (1) - 값 타입과 참조 타입
- C# 런타임 기초 (2) - .NET 런타임과 IL2CPP
- C# 런타임 기초 (3) - 가비지 컬렉션의 기초
- C# 런타임 기초 (4) - 스레딩과 비동기
- 색과 빛 (1) - 빛의 물리적 원리
- 색과 빛 (2) - 색 표현과 색공간
- 색과 빛 (3) - 셰이딩 모델
- 래스터화 파이프라인 (1) - 삼각형에서 프래그먼트까지
- 래스터화 파이프라인 (2) - 출력 병합 (현재 글)
- 래스터화 파이프라인 (3) - 디스플레이와 안티앨리어싱
- Unity 엔진 핵심 (1) - GameObject와 Component
- Unity 엔진 핵심 (2) - Transform 계층과 씬 그래프
- Unity 엔진 핵심 (3) - Unity 실행 순서
- Unity 엔진 핵심 (4) - Unity의 스레딩 모델
- Unity 에셋 시스템 (1) - Asset Import Pipeline
- Unity 에셋 시스템 (2) - Serialization과 Instantiation
- Unity 에셋 시스템 (3) - Scene Management
- Unity 렌더링 (1) - Camera와 Rendering Layer
- Unity 렌더링 (2) - Render Target과 Frame Buffer
- Unity 렌더링 (3) - Render Pipeline 개요