이 글은 Vulkan을 학습하는 개발자의 관점에서, Vulkan 좌표계의 Y축이 아래를 향하는 이유와 원리를 사실 위주로 정리한 기록입니다.
1. 정점의 여정: 그래픽스 파이프라인의 좌표 변환
하나의 정점이 3D 모델 파일에서부터 최종 화면의 픽셀이 되기까지, 여러 단계의 좌표계 변환을 거칩니다. 이 전체 과정에서 어떤 부분은 개발자가 직접 제어하고, 어떤 부분은 GPU가 자동으로 처리하는지 이해하는 것이 중요했습니다.
| 단계 | 담당 주체 | 결과 공간 | 설명 |
|---|---|---|---|
| ① 모델 변환 | 셰이더 or CPU | 모델 공간 → 월드 공간 | 모델 매트릭스로 변환 |
| ② 뷰 변환 | 셰이더 or CPU | 월드 공간 → 뷰(카메라) 공간 | 카메라 기준으로 변환 |
| ③ 투영 변환 | 버텍스 셰이더 | 뷰 공간 → 클립 공간 | gl_Position에 투영 행렬 곱셈 |
| ④ 원근 나누기 | GPU 자동 | 클립 공간 → NDC 공간 | (x, y, z) / w 연산 |
| ⑤ 뷰포트 변환 | GPU 자동 | NDC 공간 → 프레임버퍼 좌표 | NDC를 실제 픽셀 위치로 매핑 |
제가 가졌던 핵심 질문, “Y축은 언제 뒤집히는가?”에 대한 답은 GPU가 자동으로 처리하는 마지막 두 단계, 즉 NDC 와 뷰포트 변환 에 있었습니다.
2. 명세에서 찾은 좌표계의 단서
Vulkan 명세 29.7장과 29.9장에서 이 핵심 과정을 찾을 수 있었습니다.
클립 공간(Clip Space)과 NDC: 수학적 Y-up 공간
버텍스 셰이더의 최종 출력(gl_Position)이 존재하는 클립 공간과, 이후 GPU에 의해 자동으로 w로 나누어져 생성되는 정규화된 장치 좌표(NDC) 는 물리적인 화면과 분리된 순수한 수학적 공간입니다. 명세 29.7장에 따르면 NDC의 범위는 다음과 같습니다.
이 정의를 통해 NDC 공간 자체는 Y값이 증가할수록 위로 향하는 전통적인 Y-up 좌표계의 형태라는 것을 알 수 있습니다.
뷰포트 변환: 결정적인 좌표 매핑
제가 파악한 문제의 핵심은, 이 추상적인 Y-up NDC 공간을 실제 픽셀로 이루어진 구체적인 창(Window) 공간 으로 변환하는 뷰포트 변환(Viewport Transform) 단계였습니다.
Vulkan 명세 공식과 그 의미
Vulkan 명세 29.9장 “Controlling the Viewport”는 변환 공식을 다음과 같이 정의합니다.
- X축 변환:
- Y축 변환:
여기서 이고, 입니다. (VkViewport의 x, width 필드에 해당)
이 공식이 제가 흔히 사용하던 형태와 동일한지 증명해 보았습니다.
- 명세 공식 에 각 항목을 대입합니다.
- 항의 순서를 바꾸고 로 묶어줍니다.
- 이제 변수 이름을 제가 알아보기 쉬운 형태로 바꾸면 (, , ), 제가 알던 공식과 완벽히 일치함을 알 수 있습니다.
Y축 또한 동일한 방식으로 유도됩니다. 이처럼 두 공식은 표현 방식만 다를 뿐, 수학적으로는 100% 동일합니다. 이제 이 공식을 바탕으로 변환 과정을 분석해 보았습니다.
- X축 변환
- 설명: NDC의 X축 범위
[-1, 1]을[x_offset, x_offset + width]범위의 픽셀 좌표로 선형 변환합니다.
- 설명: NDC의 X축 범위
- Y축 변환 (Y축 반전 발생)
- 설명: 이 공식은 NDC의 Y축 범위
[-1, 1]을 프레임버퍼의[y_offset, y_offset + height]범위로 단순히 선형 변환 합니다. 하지만 NDC(Y=1이 위)와 프레임버퍼(Y=0이 위)의 원점 규칙이 다르기 때문에, 이 변환의 ‘결과’ 로 NDC의 위쪽()이 프레임버퍼의 아래쪽()에 매핑되어 효과적으로 Y축이 반전 되는 것입니다.
- 설명: 이 공식은 NDC의 Y축 범위
- Z축(깊이) 변환
- 설명: NDC의 Z축(깊이) 범위
[0, 1]을 개발자가 지정한[minDepth, maxDepth]범위로 변환하여 깊이 버퍼(depth buffer)에 기록될 값을 결정합니다.
- 설명: NDC의 Z축(깊이) 범위
예시: 1920x1080 뷰포트의 기본 변환
가로 1920, 세로 1080 픽셀 크기의 전체 화면을 사용하는 뷰포트가 있다고 가정해 보았습니다. (width=1920, height=1080, x_offset=0, y_offset=0)
- NDC의 최상단 중앙 (0, 1) 지점은 어디로 갈까요?
-> NDC의 맨 위 중앙 이 프레임버퍼의 맨 아래 중앙 (
960, 1080) 에 매핑됩니다.
대응법: 음수 뷰포트 높이(Negative Viewport Height)
Vulkan 1.1부터는 이 Y축 반전에 대응할 수 있는 흥미로운 방법이 핵심 기능으로 포함된 것을 알게 되었습니다. 바로 뷰포트의 높이를 음수로 설정 하는 것입니다.
VkViewport viewport{};
viewport.x = 0.0f;
viewport.y = (float)swapchainExtent.height; // y 오프셋을 높이로 설정
viewport.width = (float)swapchainExtent.width;
viewport.height = -(float)swapchainExtent.height; // 높이를 음수로 설정
// ...
이 설정은 뷰포트 변환 공식의 Y축 반전을 수학적으로 상쇄시킵니다.
예시: 음수 높이를 적용한 1920x1080 뷰포트 변환
음수 뷰포트 설정을 적용해 보았습니다. (height=-1080, y_offset=1080)
- NDC의 최상단 (
y_ndc = 1) 지점: -> NDC의 맨 위 가 프레임버퍼의 맨 위 (y=0) 에 매핑됩니다.
3. 앞면 판별 규칙: 부호 있는 면적과 외적
Vulkan 명세 30.12.1장 “Basic Polygon Rasterization”은 폴리곤의 앞면(front-face)을 판별하는 규칙을 정의합니다. 그 기준은 프레임버퍼 좌표계(framebuffer coordinates) 에서 계산된 폴리곤의 부호 있는 면적(signed area)입니다.
GPU는 프레임버퍼 좌표계의 정점들을 순회하는 방향을 고려하여 면적을 계산하고, 그 결과의 부호(양수/음수) 를 통해 와인딩 순서를 판별합니다.
- 면적이 양수일 경우: 정점들이 반시계 방향(CCW) 으로 감겨있습니다.
- 면적이 음수일 경우: 정점들이 시계 방향(CW) 으로 감겨있습니다.
vkCmdSetFrontFace 설정은 이 계산 결과를 어떻게 해석할지 GPU에게 알려주는 규칙입니다. 이 모든 과정은 Y축이 이미 뒤집힌 창 좌표계에서 일어나므로, NDC에서 CCW였던 삼각형은 CW로, CW였던 삼각형은 CCW로 최종 판별됩니다.
학습을 마치며
이번 학습을 통해 “Vulkan은 Y-down이다”라는 말이 단순히 외워야 할 규칙이 아니라, “Vulkan의 좌표계 시스템과 뷰포트/프레임버퍼 좌표계 시스템 사이의 상세한 매커니즘의 결과” 라는 것을 이해하게 되었습니다. 이 글은 제가 Vulkan 명세를 찾아보며 그 과정을 수학적으로 따라가 본 기록입니다.
이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.