yhc509

Vulkan Tutorial (2) - Overview

·23 min read

-Khronos Vulkan Tutorial

이 챕터는 Vulkan과 Vulkan이 다루고 있는 문제들을 소개하면서 시작할 겁니다. 그 다음 첫번째 삼각형을 그리는데 필요한 것들을 알아보겠습니다. 이후 챕터들을 위한 큰 그림을 그리게 해줄 것입니다. Vulkan API의 구조와 일반적인 사용 패턴을 다루면서 끝내겠습니다.

Vulkan의 기원

이전 그래픽스 API와 마찬가지로, Vulkan은 GPU를 통한 cross-platform 추상화로 설계되었습니다. 이러한 API 대부분이 갖는 문제점은 구성 가능한 고정된 기능으로 대부분이 제한되었던 그래픽스 하드웨어가 설계된 시대였다는 점입니다. 프로그래머는 버텍스 데이터를 표준 형식으로 제공해야 했고, 라이팅과 셰이더 옵션을 GPU 제조 업체에게 의존해야 했습니다.

그래픽 카드 아키텍쳐가 성숙하면서, 점점 더 많은 프로그래밍 가능한 기능을 제공하기 시작했습니다. 이러한 모든 새로운 기능들은 어떻게든 기존의 API들에 통합되어야 했습니다. 이 결과로 최신 그래픽스 아키텍쳐에 프로그래머의 의도를 매핑하기 위해 그래픽스 드라이버 관점에서 이상적이지 않은 추상화와 많은 추측들이 생겼습니다. 게임의 퍼포먼스 향상을 위한 드라이버 업데이트가 많고, 때때로 상당히 차이가 나는 이유입니다. 드라이버의 복잡성 때문에, 어플리케이션 개발자는 셰이더에 허용되는 구문과 같은, 제조사 간의 불일치를 해결해야 했습니다. 이러한 새로운 기능들 외에도, 지난 10년 동안 강력한 그래픽 하드웨어를 지닌 모바일 디바이스들이 유입되었습니다. 모바일 GPU는 에너지와 공간 요구 사항에 따라 아키텍쳐가 다릅니다. 하나의 예로 tiled rendering을 보자면, 프로그래머에게 기능에 대한 좀 더 많은 제어 권한을 제공함으로써 성능 향상이라는 이점을 얻을 수 있습니다. 이러한 API의 또다른 한계는 제한된 multi-threading 지원으로 인해 CPU에서 병목 현상이 발생할 수 있다는 것입니다.

Vulkan은 이러한 문제를 최신 그래픽스 아키텍쳐를 위한 설계를 통해 해결했습니다. 프로그래머가 더 자세한 API를 사용하여 그들의 의도를 명백하게 지정하는 것으로 드라이버의 오버헤드를 줄이고, 다중 thread로 병렬적으로 명령어를 생성하고 제출하도록 합니다. 단일 컴파일러를 통해 표준화된 바이트 코드 형태로 전환하여 셰이더 컴파일의 불일치를 줄입니다. 마지막으로, 그래픽과 컴퓨팅 기능을 하나의 API로 통합하여, 최신 그래픽 카드의 범용 처리 기능을 인정합니다.

삼각형을 그리는데 필요한 것

이제 잘 동작하는 Vulkan 프로그램에서 삼각형을 그리는데 필요한 단계를 살펴보겠습니다. 여기서 소개된 모든 개념들은 다음 챕터에서 자세히 나올 것입니다. 개별적인 구성 요소들간의 관계로 큰 그림을 그릴 수 있도록 도와주는 것입니다.

Step 1 - 인스턴스와 물리적 장치 선택

Vulkan 어플리케이션은 VkInstance를 통해 Vulkan API 세팅을 시작합니다. 어플리케이션과 어떤 API 확장을 사용할지 설명하여 Instance가 생성됩니다. Instance가 생성된 후에는 Vulkan을 지원하는 하드웨어를 쿼리하고, 그것들 중에서 작업에 사용할 VkPhysicalDevice를 선택할 수 있습니다. VRAM 크기 및 장치 기능 같은 요소로 원하는 디바이스를 선택할 수 있습니다. 예를들면 “전용 그래픽 카드를 선호”처럼 말이죠.

Step 2 - 논리적 장치와 큐 패밀리

사용할 하드웨어를 선택한 후에는 VkDevice (논리적 디바이스)를 생성해야 합니다. 여기서 다중 뷰포트 렌더링 및 64비트 부동 소수점과 같이 사용하려고 하는 것들을 VkPhysicalDeviceFeatures에 더 구체적으로 설명합니다. 그리기 명령이나 메모리 작업과 같이 Vulkan으로 수행되는 대부분의 작업은 VkQueue에 제출하여 비동기적으로 실행됩니다. 큐는 각 큐 패밀리가 해당 큐에서 특정 작업 세트를 지원하는 큐 패밀리에서 할당됩니다. 예를 들어, 그래픽, 컴퓨트, 메모리 전송 작업에 대해 별도의 큐 패밀리가 있을 수 있습니다. 큐 패밀리의 가용성은 물리적 장치 선택의 구별 요소로 사용될 수도 있습니다. Vulkan을 지원하는 장치가 그래픽 기능을 제공하지 않을 수도 있지만, 오늘날 Vulkan을 지원하는 모든 그래픽 카드는 일반적으로 우리가 관심을 가지고 있는 모든 큐 작업을 지원합니다.

Step 3 - 창 표시와 스왑체인

오프스크린 렌더링에만 관심이 있는 것이 아닌 이상, 당신은 렌더링된 이미지를 표시할 창을 만들어야 합니다. Windows는 기본 플랫폼 API나 GLFW, SDL 같은 라이브러리로 만들 수 있습니다. 우리는 이 튜토리얼에서 GLFW를 사용할 것이고 다음 챕터에서 자세히 다루겠습니다.

실제로 창에 렌더링하려면 창 표면 (VkSurfaceKHR)과 스왑체인(VkSwapchainKHR)이 필요합니다. KHR 접미사는 이러한 객체가 Vulkan 확장의 일부임을 나타내는 것입니다. Vulkan API 자체는 완전히 플랫폼에 구애받지 않으므로 창 관리자와 상호작용하기 위해 표준화된 WSI(Window System Interface) 확장을 사용해야 합니다. surface는 렌더링할 창에 대한 플랫폼 간 추상화이며, 일반적으로 기본 창 핸들에 대한 참조를 제공하여 인스턴스화 됩니다. 예를 들면 Windows의 HWND 같은 것입니다. 운 좋게도 GLFW 라이브러리는 이에 대한 플랫폼 별 세부 정보를 처리하는 기능이 내장되어 있습니다.

스왑체인은 렌더타겟의 모음입니다. 기본 목적은 현재 렌더링 중인 이미지가 현재 화면에 있는 이미지와 다른지 확인하는 것입니다. 이는 완전한 이미지만 표시되도록 하는데 중요합니다. 프레임을 그릴 때마다 스왑체인에 렌더링할 이미지를 제공하도록 요청해야 합니다. 프레임 그리기를 마치면 이미지가 스왑체인에서 반환되어 어느 시점엔가 화면에 표시됩니다. 렌더링 대상의 수와 완성된 이미지를 화면에 표시하기 위한 조건은 현재 모드에 따라 다릅니다. 일반적으로 현재 모드는 이중 버퍼링(vsync)나 삼중 버퍼링입니다. 스왑체인 생성 챕터에서 이에 대해 알아보겠습니다.

일부 플랫폼에서는 VK_KHR_displayVK_KHR_display_swapchain 확장을 통해 창 관리자와 상호작용하지 않고 디스플레이에 직접 렌더링할 수 있습니다. 이를 통해 전체화면을 나타내는 surface를 만들 수 있으며, 예를 들면 고유한 창 관리자를 구현할 수도 있습니다.

Step 4 - 이미지 뷰와 프레임 버퍼

스왑체인에서 가져온 이미지를 그리려면, VkImageViewVkFramebuffer로 래핑해야 합니다. 이미지 뷰는 사용할 이미지의 특정 부분을 참조하고, 프레임 버퍼는 색상, 깊이 및 스텐실 대상에 사용할 이미지 뷰를 참조합니다. 스왑체인에는 다양한 이미지가 존재 가능하므로, 각각에 대한 이미지 뷰와 프레임 버퍼를 미리 생성하고 그릴 때 올바른 이미지를 선택합니다.

Step 5 - 렌더패스

Vulkan의 렌더패스는 렌더링 작업 중에 사용되는 이미지 유형, 이미지 사용 방법 및 컨텐츠 처리 방법을 설명합니다. 초기 삼각형 렌더링 어플리케이션에서 Vulkan에 단일 이미지를 색상 대상으로 사용할 것이며, 그리기 작업 직전에 단색으로 지워지기를 원한다고 알릴 것입니다. 렌더패스는 이미지 유형만 설명하지만 VkFramebuffer는 실제로 특정 이미지를 이러한 슬롯에 바인딩합니다.

Step 6 - 그래픽 파이프라인

기존 API와 비교했을 때 Vulkan의 가장 큰 특징 중 하나는 그래픽 파이프라인의 거의 모든 구성을 미리 설정해야 한다는 것입니다. 즉, 다른 셰이더로 전환하거나 정점 레이아웃을 약간 변경하려면 그래픽 파이프라인을 완전히 다시 만들어야 합니다. 즉, 렌더링 작업에 필요한 모든 다양한 조합에 대해 미리 많은 VkPipeline 객체를 만들어야 합니다. 뷰 포트 크기 및 선명한 색상 같은 일부 기본 구성만 동적으로 변경할 수 있습니다. 모든 상태도 명시적으로 설명해야 합니다. 예를 들면 색상 혼합 상태는 기본값이 없습니다.

좋은 소식은 ahead-of-time 컴파일과 just-in-time 컴파일에 상응하는 작업을 수행하기 때문에 드라이버에 대한 최적화 기회가 더 많고 런타임 성능이 더 예측 가능하다는 것입니다. 다른 그래픽 파이프라인은 매우 명시적으로 만들어집니다.

Step 7 - 커멘드 풀과 커멘드 버퍼

앞서 언급했듯이, Vulkan에는 그리기 작업같이 우리가 실행하고자 하는 많은 작업들은 큐에 제출되어야 합니다. 이러한 작업은 제출하기 전에 먼저 VkCommandBuffer에 기록해야 합니다. 커멘드 버퍼는 특정 큐 패밀리와 연결된 VkCommandPool에서 할당됩니다. 간단한 삼각형을 그리려면 다음 작업에 따라 커멘드 버퍼를 기록해야 합니다.

  • 렌더패스 시작하기
  • 그래픽 파이프라인 바인딩
  • 정점 3개 그리기
  • 렌더패스 종료하기

프레임 버퍼의 이미지는 스왑체인이 제공하는 특정 이미지에 따라 다르기 때문에, 가능한 각 이미지에 대한 커멘드 버퍼를 기록하고 그릴 때 올바른 이미지를 선택해야 합니다. 대안은 매 프레임마다 커멘드 버퍼를 다시 기록하는 것인데, 이는 그다지 효율적이지 않습니다.

Step 8 - 메인 루프

이제 그리기 명령이 커멘드 버퍼에 래핑되었으므로, 메인 루프는 매우 간단합니다. 먼저 vkAcquireNextImageKHR을 사용하여 스왑체인에서 이미지를 가져옵니다. 그 다음 해당 이미지에 적절한 커멘드 버퍼를 선택하고 vkQueueSubmit을 사용하여 실행할 수 있습니다. 마지막으로 vkQueuePresentKHR을 사용하여 화면에 표시하기 위해 이미지를 스왑체인으로 반환합니다.

대기열에 제출된 작업은 비동기적으로 실행됩니다. 따라서 올바른 실행 순서를 보장하기 위해 세마포어 같은 동기와 객체를 사용해야 합니다. 그리기 명령 버퍼를 실행하는 것은 이미지 획득이 완료될 때까지 기다리도록 해야 합니다. 그렇지 않으면 화면에 표시하기 위해 여전히 읽고 있는 이미지로 렌더링을 시작할 수 있습니다. vkQueuepresentKHR을 호출할 때는 차례로 렌더링이 완료될 때까지 기다려야 하며, 렌더링이 완료된 후 신호를 받는 두번째 세마포어를 사용합니다.

요약

이 회오리바람 투어는 첫번째 삼각형을 그리는 작업에 대한 기본적인 이해를 제공해야 합니다. 실제 프로그램에는 정점 버퍼 할당, 균일한 버퍼 생성 및 다음 챕터에서 다룰 텍스쳐 이미지 업로드와 같은 더 많은 단계가 포함되어 있지만, Vulkan에는 가파른 학습 곡선이 충분히 있기 때문에 간단하게 시작하겠습니다. 정점 버퍼를 사용하는 대신 정점 셰이더에 정점 좌표를 처음에 포함하여 약간의 속임수를 사용합니다. 정점 버퍼를 관리하려면 먼저 커멘드 버퍼에 익숙해져야 하기 때문입니다.

요약하면, 삼각형을 그리려면 다음 작업을 해야 합니다:

  • VkInstance 생성
  • 지원되는 그래픽 카드 선택 (VkPhysicalDevice)
  • 그리기와 표시를 위한 VkDevice 와 VkQueue 생성
  • 창 생성, 창 표시 및 스왑체인 만들기
  • 스왑체인 이미지를 VkImageView로 래핑
  • 렌더 대상 및 사용법을 지정하는 렌더패스 생성
  • 렌더패스용 프레임 버퍼 생성
  • 그래픽 파이프라인 세팅
  • 가능한 모든 스왑체인 이미지에 대해 그리기 명령으로 커멘드 버퍼 할당 및 기록
  • 이미지를 획득하고 올바른 그리기 명령 버퍼를 제출하고 이미지를 스왑체인으로 다시 반환하여 프레임 그리기

많은 단계가 있지만, 각 단계의 목적은 다음 챕터에서 매우 간단하고 명확해질 것입니다. 전체 프로그램과 비교하여 각 단계의 관계에 대해 혼란스럽다면 이 챕터를 다시 살펴보세요.

API 개념

이 챕터는 Vulkan API가 하위 수준에서 어떻게 구성되는지에 대한 간략한 개요로 마무리됩니다.

코딩 규칙

모든 Vulkan 함수, 열거형, 구조체는 LunarG에서 개발한 VulkanSDK에 포함된 vulkan.h 헤더에 정의되어 있습니다. 다음 챕터에서 이 SDK를 설치하는 방법을 보겠습니다.

함수에는 소문자 vk 접두사가 있고, 열거형과 구조체에는 Vk 접두사가 있습니다. 열거값에는 VK_ 접두사가 있습니다. API는 구조체를 많이 사용하여 함수에 매개변수를 넘깁니다. 예를 들어, 객체 생성은 일반적으로 다음 패턴을 따릅니다.

VkXXXCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_XXX_CREATE_INFO;
createInfo.pNext = nullptr;
createInfo.foo = ...;
createInfo.bar = ...;

VkXXX object;
if (vkCreateXXX(&createInfo, nullptr, &object) != VK_SUCCESS) {
	std::cerr << "failed to create object" << std::endl;
	return false;
}

Vulkan의 많은 구조에서는 sType 멤버에서 구조 타입을 명시적으로 지정해야 합니다. pNext 멤버는 확장 구조를 가리킬 수 있으며, 이 튜토리얼에서는 항상 nullptr입니다. 객체를 생성하거나 파괴하는 함수에는 드라이버 메모리에 대한 맞춤 할당자를 사용할 수 있는 VkAllocationCallbacks 매개변수가 있으며, 이 매개변수도 이 튜토리얼에서는 nullptr일 것입니다.

거의 모든 함수는 VkResult를 반환하며, VK_SUCCESS이거나 오류 코드일 수 있습니다. 사양은 각 함수가 반환할 수 있는 오류 코드와 의미를 설명합니다.

레이어 검증

앞서 언급했듯이, Vulkan은 고성능과 낮은 드라이버 오버헤드를 위해 설계되었습니다. 따라서 기본적으로 매우 제한된 오류 검사 및 디버깅 기능이 포함됩니다. 드라이버가 잘못되면 오류 코드를 반환하는 대신 충돌하는 경우가 많습니다. 그게 아니라면 당신의 그래픽 카드에서 작동하는 것처럼 보이지만 다른 카드에서는 완전히 실패합니다.

Vulkan은 유효성 검사 레이어라는 기능을 사용하여 광범위한 검사를 활성화할 수 있습니다.

유효성 검사 레이어는 API와 그래픽 드라이버 사이에 삽입하여 함수 매개변수에 대한 추가 검사를 실행하고 메모리 관리 문제를 추적하는 것과 같은 작업을 수행할 수 있는 코드 조각입니다. 장점은 개발 중에 활성화한 다음, 오버헤드 없이 어플리케이션을 릴리즈할 때 완전히 비활성화 할 수 있다는 것입니다. 누구나 자신의 검증 레이어를 작성할 수 있지만 LunarG의 VulkanSDK는 이 튜토리얼에서 사용할 표준 검증 레이어 세트를 제공합니다. 또한 레이어에서 디버그 메세지를 수신하려면 콜백 함수를 등록해야 합니다.

Vulkan은 모든 작업에 대해 매우 명확하고 유효성 검사 레이어가 광범위하기 때문에 실제로 OpenGL, Direct3D에 비하면 화면이 검은색인 이유를 찾는 것이 훨씬 수월할 것입니다!

코드 작성을 시작하기 전에 개발환경을 설정하는 단계만 남았습니다.