이 글은 Vulkan을 학습하는 개발자의 관점에서, 커맨드 풀 설계를 위한 두 가지 접근 방식(스왑체인 이미지 기반과 프레임 인 플라이트 기반)을 비교하고 정리한 기록입니다.
[Vulkan] 커맨드 풀 설계: 스왑체인 기반과 프레임 기반 구조 비교 분석
Vulkan 튜토리얼을 따라 애플리케이션을 만들다 보면 자연스럽게 특정 구조를 사용하게 됩니다. 저 또한 스왑체인 이미지 개수에 맞추어 커맨드 버퍼를 생성하는 일반적인 방식으로 학습을 시작했습니다. 하지만 이 설계가 더 복잡한 상황에서는 비효율적이라는 점을 배우게 되어, 이를 개선하는 프레임 인 플라이트(Frame-in-Flight) 기반 설계와 비교하며 학습한 내용을 정리하고자 합니다.
1. 일반적인 학습 구조: 스왑체인 이미지 기반
대부분의 튜토리얼은 아래와 같은 리소스 구성을 기반으로 렌더링 파이프라인을 구축합니다.
| 항목 | 개수 | 설명 |
|---|---|---|
| 스왑체인 이미지 | 3개 | 화면에 표시하기 위한 이미지 버퍼 |
| 프레임 인 플라이트 (FIF) | 2개 | CPU가 GPU의 작업 완료를 기다리지 않고 미리 준비할 수 있는 프레임의 최대 개수 |
| 그래픽스 큐 패밀리 | 1개 | 모든 렌더링 명령을 제출하는 통로 |
| 커맨드 풀 | 1개 | 그래픽스 큐 패밀리에 종속된 커맨드 버퍼 할당자 |
| 커맨드 버퍼 | 3개 | 스왑체인 이미지 개수와 동일하게 생성 |
이 구조에서는 vkAcquireNextImageKHR을 통해 얻은 스왑체인 이미지의 인덱스를 사용하여, 해당 인덱스에 맞는 커맨드 버퍼에 렌더링 명령을 기록합니다.
2. 스왑체인 이미지 기반 구조의 특징
장점
- 단순한 구현: 스왑체인 이미지 인덱스와 커맨드 버퍼 인덱스가 1:1로 매칭되어 코드를 이해하고 작성하기가 직관적입니다.
- 낮은 진입 장벽: 검증된 튜토리얼 패턴을 그대로 따르므로, Vulkan의 기본 동기화 객체(
Fence,Semaphore)의 역할을 이해하는 데 집중할 수 있습니다.
단점
- 리소스 개수 불일치: CPU의 작업 단위인
MAX_FRAMES_IN_FLIGHT(2개)와 GPU가 사용할 커맨드 버퍼(3개)의 생명 주기가 동기화되지 않는 근본적인 문제가 있습니다. - 복잡한 동기화:
vkAcquireNextImageKHR가 반환하는imageIndex는 순차적이지 않습니다. 이 때문에 현재 CPU가 작업 중인 프레임(currentFrame)과imageIndex사이의 관계를 관리하기 위한 추가 로직이 필요하며, 이는 구조를 복잡하게 만듭니다. - 메모리 낭비:
MAX_FRAMES_IN_FLIGHT값과 무관하게 항상 스왑체인 이미지 개수만큼 커맨드 버퍼를 유지해야 하므로 불필요한 메모리 오버헤드가 발생합니다. - 낮은 확장성: 렌더 타겟이 스왑체인 외부(예: G-Buffer, 섀도우맵)로 확장될 경우, 이미지 인덱스에 종속된 현재 구조는 큰 변경을 필요로 합니다.
3. 개선된 구조: 프레임 인 플라이트 기반
이러한 문제들을 해결하기 위해, 각 프레임 인 플라이트(Frame-in-Flight)가 자신만의 리소스 집합을 소유하는 구조를 적용합니다.
struct FrameResources {
VkCommandPool commandPool;
VkCommandBuffer commandBuffer;
VkFence renderFence;
VkSemaphore imageAvailable;
VkSemaphore renderFinished;
};
// FIF 개수만큼만 리소스 묶음을 생성
FrameResources frames[MAX_FRAMES_IN_FLIGHT]; // 보통 2개
이 구조에서는 더 이상 스왑체인 이미지 인덱스를 커맨드 버퍼 선택에 사용하지 않습니다. 대신, 현재 CPU가 작업할 프레임(currentFrame)의 FrameResources를 가져와 사용합니다.
4. 프레임 인 플라이트 기반 구조의 장점
- 메모리 효율성:
MAX_FRAMES_IN_FLIGHT개수만큼의 커맨드 풀과 커맨드 버퍼만 생성하므로 메모리 사용량을 예측하고 관리하기 용이합니다. - 단순한 재사용: 특정 프레임에 대한 렌더링 명령 기록을 다시 시작할 때, 개별 커맨드 버퍼가 아닌 풀 전체를
vkResetCommandPool()으로 초기화합니다. 이는 드라이버에 더 효율적인 최적화 힌트를 제공합니다. - 명확한 동기화 모델: 각 프레임 슬롯은 자신만의 동기화 객체(
Fence,Semaphore)를 가집니다.frames[i]의 작업은 오직frames[i].renderFence에만 의존하므로 다른 프레임의 상태와 얽히지 않아 동기화 관리가 명확해집니다. - 멀티스레드 확장성: 각 프레임이 독립적인 커맨드 풀을 가지므로, 추후 멀티스레드 렌더링으로 확장하기 위한 기반이 됩니다. 예를 들어, 여러 스레드가 각자의 커맨드 풀에서
secondary command buffer를 생성하여 병렬로 명령을 기록하고, 메인 스레드에서 이를 취합합니다.
5. 동기화 과정의 이해
프레임 인 플라이트 기반 구조의 주된 동기화 흐름은 다음과 같습니다.
vkAcquireNextImageKHR(): 렌더링할 스왑체인 이미지를 GPU로부터 받아옵니다.MAX_FRAMES_IN_FLIGHT가 스왑체인 이미지 개수보다 작다면, GPU가 사용 중이지 않은 가용 이미지가 확보될 가능성이 커 CPU의 대기 시간이 줄어듭니다.vkWaitForFences(): 현재 프레임(currentFrame)에 대한 작업을 시작하기 전, 이 프레임 슬롯을 사용했던 이전 렌더링(예: 2 프레임 전)이 GPU에서 완전히 끝났는지Fence를 통해 확인하고 기다립니다. 이 과정이 CPU와 GPU의 파이프라인을 동기화하는 핵심적인 역할을 합니다.vkResetCommandPool(): 펜스를 통해 안전을 확인한 후, 이 프레임의 커맨드 풀을 리셋하고 새로운 명령 기록을 시작합니다.vkQueueSubmit(): 명령 기록이 끝난 커맨드 버퍼를 큐에 제출하고, 작업 완료를 추적할Fence를 함께 전달합니다.
MAX_FRAMES_IN_FLIGHT = 2, 스왑체인 이미지 개수 = 3 구성은 CPU가 한 프레임을 준비하는 동안 GPU는 이전 프레임을 렌더링하고, 디스플레이는 그 이전 프레임을 보여주는 안정적인 파이프라인을 구축하는 표준적인 방식입니다.
6. 정리하며
스왑체인 이미지 기반 구조는 Vulkan의 기본 개념을 익히는 데 훌륭한 시작점입니다. 하지만 데이터가 복잡해지고 동적인 변화가 많은 애플리케이션을 목표로 한다면, 프레임 인 플라이트 기반 설계로 전환하는 것이 필수적이라는 결론을 내렸습니다. 이 구조는 단순히 메모리 효율성을 높이는 것을 넘어, 동기화 모델을 명확하게 하고 향후 멀티스레딩과 같은 고급 기법으로 확장할 수 있는 견고한 토대를 마련해 줍니다.
이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.