본문으로 바로가기

Vulkan) 4. 디바이스 초기화와 파이프라인 단계

category Graphics/Vulkan 2021. 6. 9. 23:48

모든 내용은 https://vulkan-tutorial.com/Development_environment 에서 발췌합니다.

Windows를 기본으로 합니다. 그 외 플랫폼의 개발 환경 구성은 튜토리얼 사이트를 확인해 주세요.

 

모든 완성된 코드는 github.com/kimduuukbae/VulkanTutorial에 업로드됩니다.


이전에 Vertex Shader Fragment Shader를 작성했습니다.

남은건 고정 파이프라인 단계를 작성하는 것입니다.

디바이스 초기화 부분은 매우 장황하므로, 각 부분에 대하여 설명을 할 때 소스코드를 같이 보시면 아주 좋습니다.

 

이번 장의 디바이스 초기화 순서는 대략 다음과 같습니다.

 

1. vulkan instance를 생성하여 라이브러리 초기화

2. 유효성 검사 레이어 추가

3. 창 (window)의 생성 및 연결

4. 물리 디바이스 생성

5. 논리 디바이스 생성

6. 커맨드 풀(명령 버퍼) 생성

이번 장의 Device 초기화 부분은 매우 장황하고 깁니다.
소스코드를 다운받을 수 있는 URL을 알려드릴테니 다운로드 받아 하시는게 훨씬 낫습니다.
각 단계가 무엇을 하는지 알아두는것이 먼저입니다.

 

1. vulkan instance를 생성하여 라이브러리 초기화

vulkan instance 는 소프트웨어 구조체로 애플리케이션의 상태를 다른 애플리케이션이나 현재 애플리케이션의 문맥에서 수행되는 라이브러리로부터 논리적으로 분리한다. 시스템의 물리 장치는 인스턴스의 구성원으로 표현되며, 각각이 특정 능력을 가지며, 가용한 큐의 선택을 포함한다.

 

Vulkan은 애플리케이션의 하위 시스템으로 볼 수 있다. 애플리케이션을 vulkan 라이브러리에 한 번 연결하고 초기화하면, 일부 상태를 추적한다. vulkan이 애플리케이션의 어떤 전역적 상태도 도입하지 않기에, 모든 추적되는 상태는 반드시 제공된 객체에 저장되어야 한다. 이는 인스턴스 장치이며 VkInstance 객체로 표현된다.

이를 생성하기 위해 첫 vulkan 함수 vkCreateInstance()를 호출하며, 함수 원형은 다음과 같다.

VkResult vkCreateInstance(
const VkInstanceCreateInfo* pCreateInfo,
const VkAllocationCallbacks* pAllocator,
VkInstance* pInstance);

이 선언은 vulkan 함수의 전형이다. vulkan에 전달될 많은 매개변수를 직접 전달하기 보다, 함수는 보통 구조체의 포인터를 받는다.

여기서 pCreateInfoVkInstanceCreateInfo 구조체이며, 이는 새로운 인스턴스를 설명하는 매개변수를 포함한다. 원형은 다음과 같다.

typedef struct VkInstanceCreateInfo {
VkStructureType sType;
const void* pNext;
VkInstanceCreateFlags flags;
const VkApplicationInfo* pApplicationInfo;
uint32_t enabledLayerCount;
const char* const* ppEnabledLayerNames;
uint32_t enabledExtensionCount;
const char* const* ppEnabledExtensionNames;
} VkInstanceCreateInfo;

API에게  매개변수를 넘기기 위한 거의 모든 vulkan 구조체의 첫 멤버는 sType이며, 이는 이 구조체가 어떤 종류의 구조체인지를 vulkan에게 알려주는 것이다. 핵심 API와 어떤 확장의 각 구조체도 할당된 구조체 태그를 가진다.

이 태그를 조사하면 vulkan 도구, 레이어, 드라이버 등이 검증 용도와 확장을 위해 등 구조체의 종류를 알 수 있게 된다.

 

pNext는 함수에 전달되는 확장(Extension) 구조체의 연결 목록이다. 

핵심 인스턴스 생성 구조체를 확장하진 않을것이므로 VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO를 sType에 넣고, pNext는 단순히 nullptr로 설정한다.

 

flags는 향후 사용을 위해 예약되어 있다. 0으로 설정한다.

 

pApplicationInfo는 애플리케이션을 설명하는 다른 구조체에 대한 선택적인 포인터이다. 이를 nullptr로 설정할 수 있지만. 잘 동작하는 애플리케이션은 이를 유용한 것으로 채워 넣는다. VkApplicationInfo 구조체의 인스턴스를 가리키며, 원형은 다음과 같다.

typedef struct VkApplicationInfo{
VkStructureType sType;
const void* pNext;
const char* pApplicationName;
uint32_t applicationVersion;
const char* pEngineName;
uint32_t engineVersion;
uint32_t apiVersion;
} VkApplicationInfo;

다시 이 구조체의 sType과 pNext를 볼 수 있다. sType은 VK_STRUCTURE_TYPE_APPLICATION_INFO로 설정돼야 하며, pNext는 nullptr로 남겨둔다.

pAllicationName은 애플리케이션의 이름을 포함한 '\0' 로 끝나는 문자열이여야 하며, applicationVersion은 애플리케이션의 버전이다.

이는 도구와 드라이버가 어떤 애플리케이션을 어떻게 처리할지 결정하는 것을 가능하게 한다.

비슷하게, pEngineNameengineVersion은 각각 애플리케이션이 기반으로 하는 엔진이나 미들웨어의 이름과 버전을 포함한다.

 

apiVersion은 vulkan의 API버전을 포함한다. 애플리케이션이 실행하는 데 필요한 vulkan의  절대적인 최소 버전으로 설정해야 한다.

 

다시 VkInstanceCreateInfo 구조체로 돌아가서, enabledLayerCountppEnabledLayerNames를 확인할 수 있다. 이는 각각 활성화하고 싶은 인스턴스 레이어의 수와 그 이름들이다. 레이어는 vulkan API를 가로채서 로깅, 프로파일링, 디버깅, 혹은 다른 추가적인 기능에 사용된다. 만약 레이어가 필요하지 않으면 enabledLayerCount를 0으로 설정하고 ppEnabledLayerNames를 nullptr로 남겨둔다.

(그리고 2. 유효성 검사 레이어 추가 를 넘겨도 좋다!)

 

비슷하게 enabledExtensionCount는 활성화하고 싶은 확장(extension)의 수이며, ppEnabledExtensionNames는 이름들이다. 똑같이, 어떤 확장도 사용하지 않으면 0과 nullptr로 각각 설정하자.

 

다시 vkCreateInstance() 함수로 돌아가서 pAllocator 매개변수는 vulkan 시스템이 사용하는 주 메모리를 관리하기 위해 제공하는 메모리 할당자에 대한 포인터이다. 이를 nullptr로 사용하면 내부 메모리 할당자를 사용한다. 이 내용은 따로 다룬다.

 

vkCreateInstance() 함수가 성공했다면, VK_SUCCESS를 반환하고 pInstance 매개변수가 가리키는 곳의 새 인스턴스에 핸들을 설정한다.

 

2. 유효성 검사 레이어 추가

레이어는 vulkan의 행태를 변경할 수 있는 기능이다. 레이어는 일반적으로 vulkan의 전체 혹은 부분을 가로채서 로깅, 추적, 진단, 프로파일링 등의 기능을 추가할 수 있다. 레이어는 인스턴스 단계에서 추가할 수 있으며, 이 경우 전체 vulkan 인스턴스와 이가 생성한 모든 장치에 영향을 준다. (위에서 '확장' 이라는 단어는 이러한 레이어 같은 확장 기능들을 말하는 것이다.)

다른 경우, 레이어는 디바이스 단계에서 추가될 수 있으며, 이 경우 활성화된 디바이스에만 영향을 준다.

시스템의 인스턴스에 사용 가능한 레이어를 찾기 위해서 vkEnumerateInstanceLayerProperties()를 호출하며, 원형은 다음과 같다.

VkResult vkEnumerateInstanceLayerProperties(
uint32_t* pPropertyCount,
VkLayerProperties* pProperties);

만약 pProperties가 nullptr이면, pPropertyCount는 불칸에 사용할 레이어의 수로 쓰여질 '변수'를 가리켜야 한다. 

pProperties가 nullptr이 아니면, VkLayerProperties 구조체의 배열을 가리켜야 하며, 시스템에 등록된 레이어에 대한 정보로 채워질 것이다. 이 경우 pPropertyCount가 가리키는 변수의 값은 pProperties가 가리키는 배열의 길이이다. (덮어 씌워짐)

pPropertie 배열의 각 요소는 VkLayerProperties 구조체의 인스턴스로, 정의는 다음과 같다.

typedef struct VkLayerProperties {
char layerName[VK_MAX_EXTENSION_NAME_SIZE];
uint32_t specVersion;
utin32_t implementationVersion;
char description[VK_MAX_DESCRIPTION_SIZE];
} VkLayerProperties;

각 레이어는 VkLayerProperties 구조체의 layerName 배열에 들어있는 이름을 가진다. 

레이어 구현의 버전은 specVersion으로 제공된다.

날이 갈수록 기술은 발전하면서, 구현 또한 발전되어 간다. implementationVersion에 구현부의 버전이 저장되어 있다.

마지막으로 레이어를 설명하는 문자열이 description에 들어있다. 

이 항목의 목적은 단지 로깅이나 유저 인터페이스에 표시하는 것으로 '정보성' 일 뿐이다.

 

다음은 인스턴스 레이어를 설정하는 코드이다.

uint32_t numInstanceLayers{0};
std:vector<VkLayerProperties> instanceLayerProperties;

// 인스턴스 레이어 찾기
vkEnumerateInstanceLayerProperties(&numInstanceExtensions, nullptr);

// 만약 레이어가 있으면, 레이어의 특성을 얻어낸다
if(numInstanceLayers != 0){
    instanceLayerProperties.resize(numInstanceLayers);
    vkEnumerateInstanceLayerProperties(nullptr, 
    &numInstanceLayers,
    instanceLayerProperties.data());
}

앞에서 말한 것과 같이, 인스턴스 단계에서만 레이어를 추가할 수 있는 것은 아니고, 디바이스 단계에서도 가능하다.

vkEnumerateDeviceLayerProperties()를 호출하며, 원형은 다음과 같다.

VkResult vkEnumerateDeviceLayerProperties(
VkPhysicalDevice physicalDevice,
uint32_t* pPropertyCount,
VkLayerProperties* pProperties);

PhysicalDevice 는 '4. 물리 디바이스 생성' 에서 다루겠다. 일단은 각 각의 물리 디바이스에 서로 다른 레이어를 추가할 수 있다는 정도만 알아두자.

 

레이어는 공식 SDK에 포함되어 있으며, 대부분은 디버깅, 매개변수 유효 검증, 로깅에 관련되어 있다. 목록은 다음과 같다.

  • VK_LAYER_LUNARG_api_dump : vulkan 호출과 매개변수와 값을 콘솔에 출력한다
  • VK_LAYER_LUNARG_core_validation : 매개변수와 서술자(descriptor) 집합에 사용되는 상태, 파이프라인 상태, 동적 상태에 대한 유효 검증을 수행한다. SPIR-V 모듈과 그래픽 파이프라인의 인터페이스 유효 검증, 후방 객체에서 사용하는 GPU 메모리에 대한 추적과 유효성 검증을 한다.
  • VK_LAYER_LUNARG_device_limits : vulkan 명령어에 인자나 자료 구조들이 지원되는 기능 안에 들어가는지를 검증한다. ( 예를들어 api1.1 에서만 가능한 행동을 api1.0 feature에서 사용하려는 행동 등 )
  • VK_LAYER_LUNARG_image : 이미지 사용 방식을 지원되는 포맷과 일관되도록 검증한다.
  • VK_LAYER_LUNARG_object_tracker : vulkan 객체를 추적하고, 메모리 릭, 해제 후 사용 오류, 유효하지 않은 객체 등을 잡는다.
  • VK_LAYER_LUNARG_parameter_validation : vulkan 함수에 전달되는 모든 매개변수 값의 유효성을 검사한다.
  • VK_LAYER_LUNARG_swapchain : WSI(윈도우 시스템 통합) 확장에서 제공되는 기능에 대한 검증을 제공한다. (추후에 다룸)
  • VK_LAYER_GOOGLE_threading : 멀티스레드에 대응하는 vulkan 명령어에 대한 사용을 보증하고, 동시 접근이 금지되어 있을 때 동시에 같은 객체를 두 스레드가 접근하는 것을 막는다.
  • VK_LAYER_GOOGLE_unique_objects : 애플리케이션이 추적하기 쉽도록 구현이 같은 매개변수를 가진 객체를 표현하는 핸들을 재사용하는 경우에 모든 객체가 고유한 핸들을 가지도록 보장한다.

또한 쉽게 활성화할 수 있도록 수많은 분리된 레이어가 VK_LAYER_LUNARG_standard_validation이라고 불리는 더 큰 단일 레이어에 포함되어 있다.

 

2-1. 확장 ( Extensions )

확장은 vulkan같이 플랫폼 독립적인 API의 경우에 아주 근본적이다. 이미 구현자들이 실험하고, 발전시킨 것 즉, 현업에서 검증된 뒤에 API에 합쳐지는 것이다.

하지만 확장같은 경우 비용이 든다. 그러므로 확장은 사용되기 전에 명시적으로 활성화 되어야 한다.

확장은 인스턴스 확장, 디바이스 확장 두 가지의 확장으로 나뉘는데, 인스턴스 확장은 일반적으로 vulkan 시스템 전체를 확인한다. 디바이스 확장은 시스템의 하나 이상의 장치에 대한 능력을 확장하지만 모든 디바이스에 가용할 순 없다.

인스턴스 확장은 vulkan 인스턴스가 생성될 때 활성화되어야만 하며, 디바이스 확장은 장치가 생성될 때 활성화되어야 한다.

 

지원되는 인스턴스 확장을 검색하는 것은 vkEnumerateInstanceExtensionProperties()를 통해 알 수 있다.

VkResult vkEnumerateInstanceExtensionProperties (
const char* pLayerName,
uint32_t* pPropertyCount,
VkExtensionProperties* pProperties);

pLayerName은 확장을 제공할 수 있는 레이어의 이름이다. 이를 일단은 nullptr로 설정한다.

pPropertyCount는 vulkan에게 검색할 인스턴스 확장의 수를 알려달라는 변수이다.

pProperties는 VkExtensionProperties의 배열에 대한 포인터로써 지원되는 확장에 대한 정보로 채워져 있다.

pProperties가 nullptr이면, pPropertyCount가 가리키는 값은 지원되는 인스턴스 확장의 수로 덮어 씌워진다.

pProperties가 nullptr이 아니라면, pProperties의 항목 수는 pPropertyCount가 가리키는 변수로 가정되며, 이 수 만큼 배열에 생성된다. 

 

모든 지우너되는 인스턴스 확장을 정확하게 찾기 위해 vkEnumerateInstanceExtensionProperties()를 두 번 호출한다.

처음엔 pProperties를 nullptr로 설정하여 최대 인스턴스 확장의 수를 받는다.

그 뒤 배열의 크기를 조절하여 다시 호출하여 pProperties에 있는 배열의 주소를 넘긴다.

uint32_t numInstanceExtensions{0};
std::vector<VkExtensionProperties> instanceExtensionProperties;

vkEnumerateInstanceExtensionProperties(nullptr,
&numInstanceExtensions,
nullptr);

if(numInstanceExtensions != 0){
    instanceExtensionProperties.resize(numInstanceExtensions);
    vkEnumerateInstanceExtensionProperties(nullptr,
    &nuInstanceExtensions,
    instanceExtensionProperties.data());
}

코드가 수행된 뒤, instanceExtensionProperties는 인스턴스가 지원하는 확장의 목록을 포함한다.

배열의 각 요소 VkExtensionProperties는 하나의 확장을 설명한다. 정의는 다음과 같다.

typedef struct VkExtensionProperties {
char extensionName[VK_MAX_EXTENSION_NAME_SIZE];
uint32_t specVersion;
} VkExtensionProperties;

앞의 레이어와 별 다를 바 없이, 이름과 확장의 버전을 설명한다.

 

디바이스 확장 지원을 찾는 것도 같은 과정이다. 이를 위해서 vkEnumerateDeviceExtensionProperties()를 호출한다.

VkResult vkEnumerateDeviceExtensionProperties(
VkPhysicalDevice physicalDevice,
const char* pLayerName,
uint32_t* pPropertyCount,
VkExtensionProperties* pProperties);

다 동일한데, VkPhysicalDevice만 추가되었다. 이는 확장을 찾을 디바이스에 대한 핸들이다.

VkDeviceCreateInfo 구조체의 ppEnabledExtensionNames의 항목은 vkEnumerateDeviceExtensionProperties()로 부터 반한된 문자열의 하나에 대한 포인터를 포함한다.

 

일부 확장은 호출할 수 있는 추가적인 시작 위치의 형태로 새 기능을 제공한다.

이는 함수 포인터로 노출되며, 확장을 활성화한 뒤에 인스턴스에서나 장치에서 반드시 질의를 해야 하는 값이다.

만약 확장이 인스턴스 단계 기능을 확장하면, 새 기능을 위해서 인스턴스 단계 함수 포인터를 사용해야 한다.

vkGetInstanceProcAddr()를 사용하여 인스턴스 단계 함수 포인터를 받는다.

PFN_vkVoidFunction vkGetInstanceProcAddr(
VkInstance instance,
const char* pName);

3. 창 (window)의 생성 및 연결

window는 이전에 작성하였다.

다음은 glfw로 만든 window와 연결하는 것이다.

결과를 표시하는 vulkan의 기능이다. 

window를 만든 후 glfwCreateWindowSurface() 함수를 호출하여 Surface를 만들며 window와 연결한다.

void Window::CreateWindowSurface(VkInstance instance, VkSurfaceKHR* surface){
	if (glfwCreateWindowSurface(instance, window, nullptr, surface) != VK_SUCCESS)
		throw std::runtime_error{ "Failed to create window surface" };
}

 

4. 물리 디바이스 생성

인스턴스를 한 번 가지게 되면, 시스템에 설치된 vulkan 호환 장치를 찾는 데 사용할 수 있다.

vulkan은 물리 & 논리 두 가지의 디바이스를 가진다.

물리 디바이스는 일반적으로 vulkan의 작업을 할 수 있는 시스템의 일부이다. 그래픽 카드, 가속기, DSP 등이다.

시스템에는 고정된 수의 물리 디바이스가 있으며, 각각은 고정된 능력 집합을 가진다.

논리 디바이스는 물리 장치의 가상화된 소프트웨어 이며, 애플리케이션에 특화된 방식으로 설정된다.

논리 디바이스는 애플리케이션이 대부분의 시간을 처리하는 데 사용한다. 

논리 디바이스를 생성하기 전에 반드시 연결된 물리 디바이스를 찾아야 한다. 이를 위해 vkEnumeratePhysicalDevices()를 호출한다.

VkResult vkEnumeratePhysicalDevices (
VkInstance instance,
uint32_t* pPhysicalDeviceCount,
VkPhysicalDevice* pPhysicalDevices);

 

instance는 VkInstance로 이전에 생성한 인스턴스 객체이다.

pPhysicalDeviceCount는 입력이자 출력인 부호 없는 정수 값이다.

출력 - 시스템이 가지고 있는 물리 디바이스의 수

입력 - 애플리케이션이 처리할 최대 디바이스 수

pPhysicalDevices 매개변수는 이 숫자의 VkPhysicalDevice 핸들의 배열에 대한 포인터이다.

 

만약 시스템에 얼마나 많은 디바이스가 있는지 알고 싶다면, pPhysicalDevices 를 nullptr로 설정하면 pPhysicalDeviceCount로 수를 알아낼 수 있다.

즉, vkEnumeratePhysicalDevices()를 두 번 호출하여 VkPhysicalDevice 배열의 크기를 동적으로 설정할 수 있다.

문제가 없다고 가정하면 VK_SUCCESS를 반환하고, pPHysicalDeviceCount에 인식된 물리 장치의 수를, pPhysicalDevices에 핸들을 저장한다.

다음은 물리 디바이스를 설정하는 예이다.

// 인스턴스는 만들어 졌다고 가정
std::vector<VkPhysicalDevice> physicalDevices;
uint32_t physicalDeviceCount{0};

// 우선 디바이스가 시스템에 얼마나 있는지 확인
vkEnumeratePhysicalDevices(instance, &physicalDeviceCount, nullptr);

if( result == VK_SUCCESS ){
    physicalDevices.resize(physicalDeviceCount);
    vkEnumeratePhysicalDevices(instance,
    &phytsicalDeviceCount,
    &m_physicalDevices[0]);
}

물리 디바이스 핸들은 디바이스의 정보를 찾고, 궁극적으로 논리 디바이스를 생성하는 데 사용된다.

vkGetPhysicalDeviceProperties()를 사용해 물리 디바이스의 정보를 얻는다.

void vkGetPhysicalDeviceProperties (
VkPHysicalDevice physicalDevice,
VkPHysicalDeviceProperties* pProperties);

vkEnumeratePhysicalDevices를 호출해 얻은 디바이스를 vkPhysicalDevice에 넣으면 그에 따른 정보를 받을 수 있다.

내용이 기므로 주석으로 설명만 남겨놓겠다.

typedef struct VkPhysicalDeviceProperties {
uint32_t apiVersion;	// 디바이스가 지원하는 vlukan의 가장 높은 버전
uint32_t driverVersion; // 디바이스의 드라이버 버전
uint32_t vendorID;		// PCI 제작사와 장치 식별자
uint32_t deviceID;		// PCI 제작사와 장치 식별자
VkPhysicalDeviceType deviceType; // 디바이스 타입
char deviceName[VK_MAX_PHYSICAL_DEVICE_NAME_SIZE]; // 디바이스 이름
uint8_t pipelineCacheUUID[VK_UUID_SIZE]; // 파이프라인 캐싱에 사용됨
VkPhysicalDeviceLimits limits; // 물리 디바이스의 최소 ~ 최대 한계
VkPhysicalDeviceSparseProperties sparseProperties; // sparse texture 지원 여부
} VkPhysicalDeviceProperties;

어떤 기능을 물리 디바이스가 지원하는지를 확인하기 위해서 vkGetPhysicalDeviceFeatures()를 호출한다.

vodi vkGetPhysicalDeviceFeatures (
VkPhysicalDevice physicalDevice,
VkPhysicalDeviceFeatures* pFeatures);

다음은 본인의 Tutorial 예제에 사용된 질의들과 기능 확인 함수이다.

bool Device::IsDeviceSuitable(VkPhysicalDevice device){
    QueueFamilyIndices indices = FindQueueFamilies(device);

    bool extensionsSupported{ CheckDeviceExtensionSupport(device) };

    bool swapChainAdequate{ false };
    if (extensionsSupported) {
	    SwapChainSupportDetails swapChainSupport = QuerySwapChainSupport(device);
	    swapChainAdequate = !swapChainSupport.formats.empty() && !swapChainSupport.presentModes.empty();
    }

    VkPhysicalDeviceFeatures supportedFeatures;
    vkGetPhysicalDeviceFeatures(device, &supportedFeatures);

    return indices.IsComplete() && extensionsSupported && swapChainAdequate &&
        supportedFeatures.samplerAnisotropy;
}

다음은 장치 메모리를 접근하여 텍스처와 다른 데이터를 위한 저장소를 얻어야 한다.

메모리는 타입으로 정의되며, 주 시스템과 디바이스 간의 캐싱 설정이나 일관성 행태 같은 각각의 특성 집합으로 정의된다.

메모리의 각 종류는 그 뒤 디바이스의 힙(여러개가 있을 수 있음) 중 하나로 돌아간다.

디바이스가 지원하는 힙의 설정과 메모리 종류를 찾기 위해서 다음을 호출한다.

void vkGetPhysicalDeviceMemoryProperties(
VkPHysicalDevice phytsicalDevice,
VkPhysicalDeviceMemoryProperties* pMemoryProperties);

메모리의 정보는 pMemoryProperties에 저장된다.

VkPhysicalDeviceMemoryProperties는 디바이스의 힙과 지원하는 메모리 종류에 대한 특성을 가지고 있다.

typedef struct VkPhysicalDeviceMemoryProperties {
uint32_t memoryTypeCount;
VkMemoryType memoryTypes[VK_MAX_MEMORY_TYPES];
uint32_t memoryHeapCount;
VkMemoryHeap memoryHeaps[VK_MAX_MEMORY_HEAPS];
} VkPhysicalDeviceMemoryProperties;

메모리 종류의 수는 memoryTypeCount에서 알 수 있으며, 메모리 종류의 최대 수는 32로 정의되어 있다.

memoryTypes 배열은 각각의 메모리 타입을 설명하는 memoryTypeCount개의 vkMemoryType 구조체를 가진다

typedef struct VkMemoryType {
VkMemoryPropertyFlags propertyFlags;
uint32_t heapIndex;
} VKMemoryType;

이는 단지 플래그의 집합과 메모리 종류의 힙 인덱스로만 이루어진 단순한 구조체이다.

flags는 메모리 종류를 설명하고 VkMemoryPropertyFlagBits의 조합으로 이루어 진다. 의미는 다음과 같다.

  • VK_MEMORY_PROPERTY_DEVICE_VISIBLE_BIT : 메모리가 디바이스에 지역적(물리적으로 연결된)이라는 것을 의미한다. 만약 이 비트가 설정돼 있지 않다면 메모리는 주 시스템에 지역적으로 가정할 수 있다.
  • VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT : 이 종류의 메모리 할당이 바로 연결되어 주 시스템에 의해서 읽거나 쓸 수 있다는 것을 의미한다. 만약 이 비트가 설정돼 있지 않다면 이 종류의 메모리는 주 시스템이 직접 접근이 불가능하며 디바이스가 배타적으로 사용한다.
  • VK_MEMORY_PROPERTY_HOST_COHERENT_BIT : 이 메모리가 동시에 주 시스템과 디바이스에 의해서 접근되면 해답 접근은 일관성(Consistency)있다는 이야기다. 이 비트가 설정되지 않으면, 주 시스템이나 디바이스는 캐시가 명시적으로 교체되기 전엔 쓰기의 결과를 볼 수 없을 수 있다.
  • VK_MEMORY_PROPERTY_HOST_CACHED_BIT : 이 메모리의 자료가 주 시스템에서 캐시되었다는 것을 의미한다. 이 메모리의 읽기 접근은 일반적으로 설정이 안된 경우보다 빠르다. 하지만 디바이스의 접근은 지연이 생길 수 있다.
  • VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT : 이 메모리는 연결된 힙에서 즉시 공간을 소모할 필요가 없어 드라이버가 메모리 객체가 자원에서 사용될 때까지 물리 메모리 할당을 미룰 수 있다는 것을 의미한다.

각 메모리는 VkMemoryType구조체의 heapIndex를 사용해 어디서 공간을 사용했는지를 알 수 있다.

이는 memoryHeaps 배열에 들어가는 인덱스이다.

VkMemoryHeap 구조체는 다음과 같다.

typedef struct VkMemoryHeap {
VkDeviceSize size;
VkMemoryHeapFlags flags;
} VkMemoryHeap;

힙의 바이트 수와, 힙을 설명하는 몇 몇 플래그만 포함된다.

 

4-1. 디바이스 큐 ( Queue family )

디바이스는 큐에 전송된 작업을 수행한다. 각 디바이스는 하나 이상의 큐를 가지며, 각각의 큐는 디바이스 큐 가족 ( Queue family ... 한국어는 좀.. )은 동일한 능력을 지닌 큐의 모음으로 병렬로 수행이 가능하다.

큐 가족의 수, 각 가족의 능력, 각 가족의 큐의 수는 모두 물리 디바이스의 특성이다. 큐 가족에 대해 장치에 질의하려면

vkGetPhysicalDeviceQueueFamilyProperties()를 호출한다.

void vkGetPhysicalDeviceQueueFamilyProperties (
VkPhysicalDevice physicalDevice,
uint32_t* pQueueFamilyPropertyCount,
VkQueueFamilyProperties* pQueueFamilyProperties);

이 것도 다른 함수들과 마찬가지로 2번 호출을 기대한다.

첫 호출에는 pQueueFamilyProperties를 nullptr로 넘겨, pQueueFamilyPropertyCount에 디바이스에서 지원하는 큐 가족의 수를 얻어낸다. 이 수를 VkQeueFamilyProperties의 배열의 크기를 적절히 조절하는 데 사용한다.

그 뒤 두 번째 홏울에서 pQueueFamilyProperties에 넘기면, 큐의 특성으로 vulkan이 채워준다.

VkQueueFamilyProperties의 정의는 다음과 같다.

typedef struct VkQueueFamilyProperties {
VkQueueFlags queueFlags;
uint32_t queueCount;
uint32_t timestampValidBits;
VkExtent3D minImageTransferGranularity;
} VkQueueFamilyProperties;

queueFlags는 큐의 전체적인 능력을 설명한다. 이 항목은 VkQueueFlagBits의 조합으로 이루어지며, 각각의 의미는 다음과 같다.

  • VK_QUEUE_GRAPHICS_BIT : 이 가족의 큐는 점, 선, 삼각형을 그리는 등의 그래픽 연산을 지원한다.
  • VK_QUEUE_COMPUTE_BIT : 이 가족의 큐는 계산 셰이더를 배치하는 등의 계산 연산을 지원한다.
  • VK_QUEUE_TRANSFER_BIT : 이 가족의 큐는 버퍼와 이미지 내용을 복사하는 등의 전송 연산을 지원한다.
  • VK_QUEUE_SPARSE_BIT : 이 가족의 큐는 sparse resource를 갱신하는 데 사용되는 메모리 결속 연산을 지원한다.

queueCount는 가족 안의 큐의 수를 알려준다. 이는 1로 설정되거나 그 이상이 될 수 있다.

timestampValidBits는 큐에서 타임스태프를 가져올 때 얼마나 많은 비트가 유효한지 알려준다. 만약 이 값이 0이면, 큐는 타임스탬프를 지원하지 않는다. 만약 0이 아니면 이는 최소 36비트가 보장된다. 만약 VkPhysicalDeviceLimits 구조체의 timestampComputeAndGraphics 항목이 VK_TRUE이면, VK_QUEUE_GRAPHICS_BIT나 VK_QUEUE_COMPUTE_BIT를 가진 모든 큐는 최소 36비트의 크기를 지원하도록 보장한다.

minImageTimestampGranularity는 이미지 전송을 지원할 경우 최소 단위를 명시한다.

 

가족 안의 큐는 본질적으로 동일하다는 것을 알아두자.

다른 가족의 큐는 vulkan API에서 쉽게 표현될 수 없는 다른 내부 능력을 가지고 있을 수 있다.

이 이유로 인해, 구현은 비슷한 큐를 다른 가족의 구성원으로 추가하도록 선택할 수 있다.

이는 이 큐들 사이에서 자원이 공유되는 데 추가적인 제한을 생성하며, 이를 통해 이 차이점을 구현이 수용하는 것을 가능하게 한다.

 

다음은 물리 디바이스의 메모리 특성과 큐 가족의 특성을 어떻게 질의하는지를 보여준다.

다음 절에서 말하는 것처럼 논리 디바이스를 생성하기 전에 큐 가족 특성을 얻어야 한다.

uint32_t queueFamilyPropertyCount;
std::vector<VkQueueFamilyProperties> queueFamilyProperties;
VkPhysicalDeviceMemoryProperties physicalDeviceMemoryProperties;

//물리 디바이스의 메모리 특성을 가져온다.
vkGetPHysicalDeviceMemoryProperties(physicalDevices[deviceIndex],
&physicalDeviceMemoryProperties);

// 물리 장치에서 지원되는 큐 가족의 수를 결정한다.
vkGetPhysicalDeviceQueueFamilyProperties(physicalDevices[0],
&queueFamilyPropertyCount,
nullptr);

// 큐 특성 구조체를 위한 충분한 공간을 할당한다.
queueFamilyProperties.resize(queueFamilyPropertyCount);

//큐 가족의 실제 특성을 질의한다.
vkGetPhysicalDeviceQueueFamilyProperties(physicalDevices[0],
&queueFamilyPropertyCount,
queueFamilyProperties.data());

 

5. 논리 디바이스 생성

시스템의 모든 물리 디바이스를 나열한 뒤에 애플리케이션은 장치를 선택하여 이에 대응하는 논리 디바이스를 생성해야 한다.

논리 디바이스는 디바이스의 초기 상태를 표현한다.

논리 디바이스 생성 동안, 선택 기능과 사용할 확장 등에 대해서 사전 동의를 해야한다.

논리 장치를 생성하는 것은 vkCreateDevice()를 호출하면 된다.

VkResult vkCreateDevice (
VkPhysicalDevice physicalDevice,
const VkDeviceCreateInfo* pCreateInfo,
const VkAllocationCallbacks* pAllocator,
VkDevice* pDevice);

논리 디바이스에 대응하는 물리 디바이스는 physicalDevice로 전달한다.

새 논리 디바이스에 대한 정보는 pCreateInfo 를 통해 전달한다. VkDeviceCreateInfo의 정의는 다음과 같다.

typedef struct VkDeviceCreateInfo {
VkStructureType sType;	//VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO
const void* pNext;		// 확장
VkDeviceCreateFlags flags;	// 예약 플래그
uint32_t queueCreateInfoCount;	// 큐의 갯수	
const VkDeviceQueueCreateInfo* pQueueCreateInfos; // 큐의 정보
uint32_t enabledLayerCount; // 레이어 갯수
const char* const* ppEnabledLayerNames; // 레이어 이름
uint32_t enabledExtensionCount; // 확장 갯수
const char& const* ppEnabledExtensionNames; // 확장 이름
const VkPhysicalDeviceFeatures* pEnabledFeatures; // 애플리케이션이 사용할 기능 명시
} VkDeviceCreateInfo

pQueueCreateInfos는 하나 이상의 VKDeviceQueueCreateInfo 구조체의 배열이다. 각각은 하나 이상의 큐에 대한 설명을 가진다.

VkDeviceQueueCreateInfo {
VkStructureType sType; // VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO
const void* pNext;	// 확장
VkDeviceQueueCreateFlags flags; // 예약
uint32_t queueFamilyIndex; // 큐 가족 배열의 인덱스
uint32_t queueCount; // 큐 갯수
const float* pQueuePriorities; // 큐에 전송되는 작업의 상대적인 우선순위 0.0 ~ 1.0
} VkDeviceQueueCreateInfo;

queueFamilyIndex 항목은 생성하고 싶은 큐 가족을 지정한다. 이는 vkGetPhysicalDeviceQueueFamilyProperties() 에서 얻어낸 인덱스이다. 이 큐 가족을 생성하기 위해서, queueCount를 설정하면 된다.

pQueuePriorities는 각 큐에 전송되는 작업의 상대적인 우선순위이다. 0.0 ~1.0의 우선순위 이며, 높은 우선순위 큐는 낮은 순위의 큐보다 더 많은 처리를 한다. nullptr로 설정하면 모두 같은 우선순위를 가진다.

 

VkDeviceCreateInfo 구조체로 돌아와서, pEnabledFeatures는 애플리케이션이 사용할 기능을 명시하는 VkPhysicalDeviceFeatures의 포인터이다. 만약 사용하고 싶지 않다면 nullptr를 해도 되지만 제공되던 기능들이 비활성화 될 수 있다.

 

디바이스가 지원하는 기능을 알아보기 위해서 vkGetPhysicalDeviceFeatures()를 호출한다.

이는 디바이스가 지원하는 기능의 집합을 얻어낼 수 있다. 단순히 물리 디바이스의 기능을 검색하고 얻어낸 VkPhysicalDeviceFeatures 구조체를 vkVreateDevice()로 전달하여 모든 기능을 활성화 할 수 있지만, 모든 기능을 지원하는 것은 오버헤드가 따르기 마련이다.

자신이 필요한 기능만 체크해서 활성화 하도록 하자.

다음은 디바이스의 기능을 검색하고, 목록을 설정하는 코드이다.

VkResult result;
VkPhysicalDeviceFeatures supportedFeatures;
VkPhysicalDeviceFeatures requiredFeatures{};

vkGetPHysicalDeviceFeatures(physicalDevices[0],
    &supportedFeatures);

requiredFeatures.multiDrawIndirect = supportedFeatures.multiDrawIndirect;
requiredFeaturees.tessellationShader = VK_TRUE;
requiredFeatures.geometryShader = VK_TRUE;

const VkDeviceQueueCreateInfo deviceQueueCreateInfo {
    .sType {VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO},
    .pNext {nullptr},
    .flags {0},
    .queueFamilyIndex {0},
    .queueCount {1},
    pQueuePriorities {nullptr}
};

const VkDeviceCreateInfo deviceCreateInfo{
    .sType {VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO},
    .pNext {nullptr},
    .flags {0},
    .queueCreateInfoCount {1},
    .pQueueCreateInfos {&deviceQueueCreateInfo},
    .enabledLayerCount {0},
    .ppEnabledLayerNames {nullptr},
    .enabledExtensionCount {0},
    .ppEnabledExtensionNames {nullptr},
    .pEnabledFeatures {&requiredFeatures}
};

result = vkCreateDevice(physicalDevices[0],
    &deviceCreateInfo,
    nullptr,
    &logicalDevice);

이 코드가 수행되고 성공적으로 논리 디바이스를 생성하면, 활성화된 기능들의 집합이 requiredFeatures에 저장된다. 이는 나중에 어떤 기능이 활성화되었는지 확인하고 처리하는데 사용된다.

 

5-1. 객체 종류와 함수 규약

vulkan의 실질적인 모든 것은 핸들이 참조하는 객체로 표현된다. 핸들은 두 개의 큰 항목으로 나뉘는데, 실행 가능한 객체와 불가능한 객체로 나뉜다.

대부분은 애플리케이션과는 관련없고 단지 API가 어떻게 구성되었고 로더나 레이어 같은 시스템 단계의 요소들이 객체들과 어떻게 상호작용하는지에만 관련된다.

실행 가는 객체는 내부적으로 실행 표를 포함한다. 이는 애플리케이션이 vulkan을 호출할 때 코드의 어떤 부분을 수행할지 결정하기 위해 사용하는 함수 표이다.

이 종류의 객체는 일반적으로 더 무거운 구조체 이며 인스턴스(VkInstance), 물리 디바이스(VkPhysicalDevice), 논리 디바이스(VkDevice), 커맨드 버퍼(VkCommandBuffer), 큐(VkQueue)로 구성되어 있다. 다른 모든 객체는 실행 불가능 객체로 간주한다.

vulkan 함수들의 첫 인자는 항상 '실행 가능한 객체' 임을 잊지말자. 유일한 예외는 인스턴스 생성하고 초기화 할때 뿐이다.

 

5-2. 메모리 관리

vulkan은 주 메모리(RAM)디바이스 메모리(GPU memory) 두 가지의 메모리를 제공한다.

API가 생성한 객체는 일반적으로 일정량의 주 메모리를 요구한다. 이는 vulkan구현이 객체의 상태를 저장하고 API를 구현하는 데 필요한 자료를 저장하는 곳이다.

버퍼나 이미지 같은 자원 객체는 디바이스 메모리의 일부를 요구한다. 이는 자원 자료를 저장하는 메모리이다.

애플리케이션이 vulkan구현을 위한 주 메모리를 관리하는 것이 가능하며, 또한 디바이스 메모리를 관리하도록 요구된다.

이를 위해서 디바이스 메모리 관리 하위시스템을 생성해야 한다.

생성하는 각 자원은 메모리 양과 반환하기 위해 필요한 메모리의 종류를 질의할 수 있다.

사용하기 전에 정확한 메모리의 양을 할당하고 자원 객체에 붙이는 것은 애플리케이션의 몫이다.

 

6. 커맨드 풀 생성 (명령 버퍼)

큐의 주요 목적은 애플리케이션을 위해서 작업을 처리하는 것이다. 작업은 명령 버퍼에 저장되는 명령어의 연속으로 표현된다.

애플리케이션은 처리할 작업을 포함하는 명령 버퍼를 생성하고 이를 큐 중 하나에 제출한다.

명령 버퍼는 직접 생성할 수 없으며, 풀을 통해서 할당할 수 있다. 풀을 생성하기 위해서 vkCreateCommandPool()을 호출한다.

VkResult vkCreateCommandPool (
VkDevice device,
const VkCommandPoolCreateInfo* pCreateInfo,
const VkAllocationCallbacks* pAllocator,
VkCommandPool* pCommandPool);

대부분의 vulkan 객체 생성 함수와 같이, 첫 매개변수 device는 새 풀 객체를 가질 디바이스에 대한 핸들이며, 풀의 정보는 구조체를 통해서 pCreateInfo로 전달된다. VkCommandPoolCreateInfo는 다음과 같다.

typedef struct VkCommandPoolCreateInfo {
VkStructureType sType; // VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFOO
const void* pNext;
VkCommandPoolCreateFlags flags; 
uint32_t queueFamilyIndex;
} VkCommandPoolCreateInfo;

VkCommandPoolCreateFlags를 위한 두 개의 플래그가 정의되어 있다.

  • VK_COMMAND_POOL_CREATE_TRANSIENT_BIT : 풀에서 가져온 명령어 버퍼가 사용한 되에 풀로 다시 돌아간다는 것을 알려준다. 명령어 버퍼를 일정 시간 유지하려면 설정하지 않아야 한다.
  • VK_COMMAND_POOL_RESET_COMMAND_BUFFER_BIT : 명령어 버퍼를 리셋시켜 재사용될 수 있게 한다. 만약 이 비트가 설정되지 않으면, 풀 자체만 재설정이 가능하고, 풀이 할당한 모든 명령어 버퍼를 재활용한다.

이 비트 각각은 vulkan이 처리하는 작업에 자원의 추적이나 할당 전략을 변경하는 등으로 인해 일부 부하를 추가한다.

예를 들어, VK_COMMAND_POOL_CREATE_TRANSIENT_BIT는 명령어 버퍼가 빈번히 할당되고 해제되는 것으로 인한 파편화를 방지하기 위해 풀에 대해 추가 연산을 한다.

VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT는 각각의 명령어 버퍼의 재사용 상태를 추적한다.

queueFamilyIndex는 풀에서 할당된 명령어 버퍼가 제출될 큐 가족을 설정한다. 이는 디바이스의 두 큐가 같은 능력을 지니고 똑같은 명령어를 지원하더라도, 특정 명령어를 한 큐에 요청하는 것은 다른 큐에 같은 명령어를 요청하는 것과 다르게 동작할 수 있기 때문이다.

 

한번 풀을 가지게 되면, 새 명령어 버퍼를 vkAllocateCommandBuffers()를 호출하여 얻어낼 수 있다.

VkResult vkAllocateCommandBuffers (
VkDevice device,
const VkCommandBufferAllocateInfo* pAllocateInfo,
VkCommandBuffer* pCommandBuffers);

명령어 버퍼가 할당하는 데 사용한 디바이스는 device에 전달되며, 할당할 명령어 버퍼를 설명하는 VkCommandBufferAllocateInfo이다.

typedef struct VkCommandBufferAllocateInfo {
VkStructureType sType;	// VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO
const void* pNext;
VkCommandPool commandPool;
VkCommandBufferLevel level;
uint32_t commandBufferCount;
} VkCommandBufferAllocateInfo;

level 매개변수는 할당하려는 명령어 버퍼의 단계를 설정한다. 이는 VK_COMMAND_BUFFER_LEVEL_PRIMARY 거나 VK_COMMAND_BUFFER_LEVEL_SECONDARY로 설정한다. vulkan은 주 명령어 버퍼가 부 명령어 버퍼를 호출하는 것을 허용한다. 이때 사용되는 단계이다.

commandBufferCount는 풀에서 할당하려 하는 명령어 버퍼의 수를 설정한다.

 

다음은 명령 풀을 만드는 코드이다. ( 명령어 버퍼는 추후에 다시 작성한다. )

QueueFamilyIndices queueFamilyIndices {FindPhysicalQueueFamilies()};

VkCommandPoolCreateInfo poolInfo {};
poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
poolInfo.queueFamilyIndex = queueFamilyIndices.graphicsFamily;
poolInfo.flags =
    VK_COMMAND_POOL_CREATE_TRANSIENT_BIT | VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
// 두 가지 다 설정하는 것이 가장 유연하다. 성능 저하는 어쩔 수 없다..

if (vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool) != VK_SUCCESS) 
		throw std::runtime_error{"failed to create command pool!"};

 


마지막으로,

Device 초기화 부분은 엄청 깁니다. 작성하는데도 몇십시간 걸렸습니다.

글을 하나하나 읽는 것 보단, 추후에 궁금한게 생겼을 때 검색으로 찾아보시는게 훨씬 낫습니다.

다음엔 삼각형을 실제로 띄워보겠습니다.

 

4에 대한 소스코드는 이곳을 확인해주세요.

감사합니다.

 

틀린점이나, 오타, 그 외 정보는 댓글을 남겨주세요.