Vulkan - 2. 안녕 삼각형

2022. 6. 8. 17:35Vulkan

개요

3D 그래픽스 API를 가르칠 때는 삼각형을 먼저 그려 봅니다. 벌칸의 경우 GLFW랑 같이 쓰는 기준으로 삼각형을 그리는 데 약 1000행 정도 필요한데요, 그래픽스 파이프라인을 세팅하고 삼각형을 그리는 데 어떤 것이 필요한지 하나씩 만들어 보고 삼각형을 띄우며, 대충 개발자가 세팅한 것들이 어떤 식으로 돌아가는지 머리 속에 그림을 그려 봅시다.

오른쪽 스크롤 보니까 토 나오겠다.. 그냥 끊어 가면서 하지 왜 한 번에 하려는 건데?
독자가 칼을 뽑았으면 무라도 자르고 싶어할까봐...?

이번 글은 LunarG 튜토리얼이랑 Alexander 튜토리얼을 기초로 했습니다.

 

목차

1. 프로그램의 큰 그림

2. 삼각형을 띄워 보기

  2.1. 인스턴스

  2.2. 확인 계층

  2.3. 물리 장치와 가상(logical) 장치

  2.4. 명령 버퍼

  2.5. 스왑 체인과 창

  2.6. 렌더 패스

  2.7. 파이프라인

  2.8. 정점 버퍼

  2.9. 그리기~!

요약

과제

본문

1. 프로그램의 큰 그림

일단 GL로 프레임워크를 만든 저인 만큼, 이번에 새로이 만들 것도 이런 캡슐이 필요하다고 생각합니다. (여기서는 기본 그래픽스 관련 요소만 포함하고, 오디오, 물리 등 확실히 응용 단으로 분리될 수 있는 것과 디퍼드 렌더링, 레이트레이싱 등과 같은 상대적으로 기초를 벗어나는 내용물은 이후에 고려)

 

참고로 이번에는 계승 프로젝트인 만큼 힘을 조금 더 줄 거라서, 물리는 불릿 같은 외부 라이브러리를 쓸 겁니다.

  • 정점
  • 메시(정점 버퍼 + 인덱스 버퍼)
  • 파이프라인(셰이더 프로그램 + 유니폼 버퍼 등), 더 일반적으로 여러 파이프라인이 이어진 패스
  • 텍스처(이미지 + 샘플러)
  • 표면 재질(산란(diffuse) 텍스처 + 법선 맵)
  • 모델(메시 + 표면 재질) 
  • 애니메이션
  • 카메라
  • 변환

 

애니메이션이 모델에 들어가야 할지 개별 개체에 들어가야 할지는 조금 더 고려해 봐야 할 것 같습니다. 오직 3D만 고려한다면 분명 모델에 들어가는 게 메모리를 조금 더 아낄 것 같습니다만 2D까지 동시에 지원하게 만든다면 또 모르겠네요.

 

위 개체들 중 애니메이션과 변환을 제외한 나머지는 GL에서는 그냥 "만들어져라" 하면 만들어지는 수준인데 벌칸에서는 이것이 조금 어렵다는 말이죠. 아무튼 위의 각 요소에서 필요한 변수들(ex: mip level)은 구현하면서 생각하며 직접 넣도록 하고(이번 글에서는 기본적으로 할 것만 해도 아주 길 거라서 예정에 없습니다), 본격적으로 벌칸 API를 이용한 프로그래밍에 들어갑니다.

2. 삼각형을 띄워 보기

일단 소스를 분리하겠습니다.

 

더보기

 

// VkPlayer.h
#ifndef __VK_PLAYER_H__
#define __VK_PLAYER_H__

#define GLFW_INCLUDE_VULKAN
#include "externals/glfw/glfw3.h"
#include "externals/shaderc/shaderc.hpp"

namespace onart {
    class VkPlayer {
    public:
        // Vulkan을 초기화하고, 시작합니다.
        static void start();
        // 모든 것을 정상적으로 리턴하고 종료합니다. 창을 닫은 것과 같은 동작입니다.
        static void exit();
    private:
        // GLFW, Vulkan을 비롯하여 필요한 모든 것을 초기화합니다.
        static bool init();
        // 업데이트, 렌더링 명령을 수행합니다.
        static void mainLoop();
        // 종료 전 할당한 모든 자원을 해제합니다.
        static void finalize();
    };
}

#endif // !__VK_PLAYER_H__
// VkPlayer.cpp
#include "VkPlayer.h"
#include "externals/shaderc/shaderc.hpp"

#ifdef _MSC_VER
    #pragma comment(lib, "externals/vulkan/vulkan-1.lib")
    #pragma comment(lib, "externals/glfw/glfw3_mt.lib")
    #pragma comment(lib, "externals/shaderc/shaderc_shared.lib")
#endif

namespace onart {
    void VkPlayer::start() {
        if (init()) {
            mainLoop();
        }
        finalize();
    }
    
    void VkPlayer::exit() {
    
    }

    bool VkPlayer::init() {
    
    }

    void VkPlayer::mainLoop() {

    }

    void VkPlayer::finalize() {

    }
}
#include "VkPlayer.h"
#include <filesystem>

int main(int argc, char* argv[]) {
    std::filesystem::current_path(std::filesystem::path(argv[0]).parent_path());
    onart::VkPlayer::start();
    return 0;
}

 

앞서 나온 바와 같이 Vulkan은 전역 상태머신이 없습니다. 때문에 뭐라도 사용하려면 그것을 멤버로 가지고 있어야 하며, 만들었으면 해제 작업 역시 직접 해 주어야 합니다.

 

2.1. 인스턴스

LunarG에 따르면, 벌칸 API는 응용의 상태를 저장하는 vkInstance라는 객체를 사용하여 하드웨어 드라이버의 기능을 불러옵니다. 사이에는 다른 계층이 들어갈 수도 있으며 일단 우리는 확인 계층(validation layer)만 사용할 예정입니다. 벌칸 계층에 대한 내용은 더 알고 싶다면 여기를 참고하세요.

 

인스턴스는 vkCreateInstance 함수를 통해서 만들며, 여기에는 VkInstanceCreateInfo라는 구조체의 형태로 매게변수를 전달합니다. 이 구조체에서 설정할 정보는 사용할 계층 수와 계층 이름 배열, 사용할 확장 수와 확장 배열, 그리고 응용의 이름, 응용 버전, 엔진 이름, 엔진 버전이 있습니다. "그리고" 뒤에 나온 것들은 사실 VkApplicationInfo 구조체의 포인터로 전달됩니다. 예시로 하나 만들어 보겠습니다.

 

더보기

우선 클래스에 선언합니다.

// VkPlayer.h
// vulkan 인스턴스를 생성합니다.
static bool createInstance();
// vulkan 인스턴스를 해제합니다.
static void destroyInstance();

static VkInstance instance;
(VkPlayer.cpp에 instance를 정의하는 것도 잊지 마세요. 안 그러면 링커 오류를 보게 될 겁니다.)

이건 init()과 finalize()에서 호출해야 합니다.

bool VkPlayer::init() {
    return createInstance();
}

void VkPlayer::finalize() {
    destroyInstance();
}

createInstance의 내용물은 다음과 같습니다.

bool VkPlayer::createInstance() {
    VkApplicationInfo ainfo{};
    ainfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;	// 고정값
    ainfo.apiVersion = VK_API_VERSION_1_0;	//VK_HEADER_VERSION_COMPLETE;
    
    // 이하 4줄은 중요한 부분이 아닙니다.
    ainfo.pApplicationName = "OAVKE";
    ainfo.applicationVersion = VK_MAKE_API_VERSION(1, 0, 0, 0);
    ainfo.pEngineName = "OAVKE";
    ainfo.engineVersion = VK_MAKE_API_VERSION(1, 0, 0, 0);
    
    VkInstanceCreateInfo info{};	// 일단 모두 0으로 초기화함
    info.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;	// 고정값
    info.pApplicationInfo = &ainfo;
    if (vkCreateInstance(&info, nullptr, &instance) == VK_SUCCESS) return true;
    fprintf(stderr, "Failed to create Vulkan instance\n");
    return false;
}

void VkPlayer::destroyInstance() {
    vkDestroyInstance(instance, nullptr);
}

생성할 때에는 instance의 주소를 주어야 하며 해제할 때에는 그 자체를 주어야 한다는 점에 주의하세요.

 

앞으로 생성 정보 등을 위한 매개변수가 되는 info 구조체들은 모두 정해진 sType을 주어야 합니다. 앞으로 이 부분은 굳이 짚고 넘어가지 않겠습니다. 그리고 pNext라는 멤버는 void*형인데, 이는 구조체에 주어지는 값에 따라 추후 확장될 가능성을 열어 두는 녀석입니다. 이걸 배우는 입장에서는 그 어떤 구조체에서도 당장에 사용할 일이 없다고 봐도 거의 틀리지 않습니다.

 

VkApplicationInfo에서는 일반적으로 apiVersion 멤버만 주면 됩니다만, LunarG 측에서는 VK_API_VERSION_1_0을 주기를 권장하고 있습니다. 이를 무시하고 여러분이 받은 SDK 버전을 주고자 한다면, VK_API_VERSION_COMPLETE를 주면 됩니다. 나머지의 경우 디버그 등 도구에서 참고할 이름이거나, 드라이버가 최적화하는 데 이름에 따라 적절한 조치를 취할 수도 있습니다. 

 

VkInstanceCreateInfo에는 방금 만든 pApplicationInfo, 그리고 enabledLayerCount와 ppEnabledLayerNames, enabledExtensionCount와 ppEnabledExtensionNames가 필요합니다. 뒤의 4개는 잠시 후에 돌아오도록 합니다.

 

그래서 vkCreateInstance는 방금 만든 VkInstanceCreateInfo 구조체랑 할당 콜백 세트, 그리고 인스턴스 핸들을 받을 주소입니다. 할당 콜백 세트의 경우 nullptr 외의 값을 주면 메모리 할당 시 다른 동작을 취할 수 있습니다. 이 시리즈에서는 전혀 사용할 생각이 없으니 문서를 참고해 주세요.

 

리턴값은 VkResult 열거형으로, 성공하면 VK_SUCCESS 값을 가집니다. 실패한 경우 다양한 오류 코드가 발생하는데, 여기에서는 특별한 경우가 아니면 VK_SUCCESS인지 아닌지만 판별하겠습니다.

 

이제 컴파일하고 실행하면 겉보기에 아무 동작도 하지 않고 정상 종료되어야 합니다.

 

2.2. 확인 계층

확인 계층은 모든 것을 일일이 명시해야 하는 벌칸으로 개발할 때 아주 도움이 많이 되는 계층입니다. 기본적으로 인스턴스 위의 모든 Vulkan 호출을 관찰하다가 필요한 경우 stdout에 오류 내지는 경고를 알립니다. 특히 여러분의 장치에서는 잘 돌아가는데 다른 기계에서는 문제가 발생할 소지를 최소화할 수 있습니다 OpenGL의  glDebugMessageCallback 함수와 유사합니다. dll만 있으면 돌아가는 다른 벌칸의 기능과 다르게 SDK를 가지고 있어야 사용할 수 있다고 하니 참고 바랍니다. 사용을 위한 코드는 아래와 같이 작성할 수 있으며, 딱히 이 부분을 건너뛰어도 여기에 의존하는 부분은 없기는 합니다.

 

더보기

확인 계층은 위에 나온 ppEnabledLayerNames에 정해진 이름을 주는 것으로 활성화를 시도할 수 있습니다. 이 계층은 유용하지만 당연히 성능에 큰 영향을 주므로, 사용할지 말지는 constexpr 상수를 통해 제어하도록 합니다.

// VkPlayer.h
constexpr static bool USE_VALIDATION_LAYER = true;
constexpr static const char* VALIDATION_LAYERS[] = { "VK_LAYER_KHRONOS_validation" };	// 이 정도면 대체로 많은 도움이 됨
constexpr static const int VALIDATION_LAYER_COUNT = sizeof(VALIDATION_LAYERS) / sizeof(const char*);

이것을 여러분이 가진 장치가 지원하는지 확인하려면 vkEnumerateInstanceLayerProperties 함수를 이용하는데, 여기서는 가능한 모든 계층과 계층의 수를 매개변수 포인터를 통해서 리턴합니다. 몇 개가 가능할지 모르기 때문에, 다음과 같은 방식을 이용해야 합니다.

uint32_t count;
vkEnumerateInstanceLayerProperties(&layerCount,nullptr);
std::vector<VkLayerProperties> layers(count); // VkLayerProperties::layerName을 통해 이름에 접근
vkEnumerateInstanceLayerProperties(&layerCount,layers.data());

이들은 layerName으로 식별되며 나머지는 설명 혹은 버전명입니다. 여기서는 이 과정을 생략합니다. 어차피 사용할 수 없으면 그 정보를 주고 인스턴스를 생성하는 데 실패하기 때문입니다. 확인 계층은 배포할 때에는 꺼야 하니, 여러분 컴퓨터에서 사용할 수만 있으면 그만입니다.

 

이제 VkInstanceCreateInfo에 다음 값을 조건부로 주도록 합시다.

// VkPlayer.cpp의 VkPlayer::createInstance()
if constexpr (USE_VALIDATION_LAYER) {
    info.enabledLayerCount = VALIDATION_LAYER_COUNT;
    info.ppEnabledLayerNames = VALIDATION_LAYERS;
}

거의 그럴 일이 없겠지만 실패하면 확인 계층을 사용할 수 없는 겁니다. 그래도 이론상 개발은 가능하겠지만, 그게 불가능한데 다른 게 가능할지는 모르겠습니다.

 

그대로 컴파일했을 때 똑같이 인스턴스 생성에 실패했다는 메시지가 나오지 않는다면 확인 계층은 인스턴스가 해제되기 전까지 벌칸 관련된 호출에 대하여 관찰하게 됩니다. 인스턴스 생성과 해제 자체에 대한 확인도 가능하기는 하지만, 여기에서는 생략하겠습니다. 제가 튜토리얼 번역할 때 확인계층을 저기에 추가로 적용해 봤는데, 저 정도 코드면 오류 안 나더라고요. 그에 대한 내용은 여기를 참고하세요. (제 번역 파일 기준 60p)

 

벌칸의 destroy 계열 함수들은 nullptr를 받아도 되기 때문에 인스턴스 생성에 실패했더라도 해제는 그대로 하면 됩니다.

 

여기까지 대충 이 정도 코드를 가지면 됩니다.

 

2.3. 물리 장치와 가상(logical) 장치

물리 장치란 대부분의 경우 여러분 컴퓨터 혹은 스마트폰의 그래픽 카드를 말합니다. 결국 화면에 그리기 동작을 수행하는 것은 물리 장치이므로, 이런 그림을 상상해 볼 수 있습니다.

당연하다면 당연하지만, 지금까지 배운 게 없다 보니 이 그림은 지나치게 많이 축약되었습니다. 응용에서는 인스턴스(로드된 벌칸 명령)를 통해 직접 뭔가 요청할 수가 없고 그래픽 카드는 드라이버를 통해서 명령을 받아야 합니다(앞으로의 그림에서 '그래픽 카드'하면 그 말은 그래픽 카드 드라이버라는 뜻입니다). 점점 진행하면서 이 그림이 올바른 무언가가 되길 바랄 뿐입니다.

 

물리 장치들은 여러 계열의 큐(Queue)를 가지고 있으며, 큐 계열마다 전달하는 명령의 종류가 다릅니다. 한 큐 계열에서 여러 종류의 명령을 보관하는 것도 가능합니다. 이번에 할 일은 일단 물리 장치, 즉 그래픽 카드 중에서 우리가 원하는 일을 하는 큐를 가진 것을 찾는 과정이라고 보시면 됩니다. 나중에 더 배우시면 여기에서 더 많은 조건을 따질 수도 있겠죠? 다음과 같이 코드를 작성해 봅시다.

 

더보기

우선 물리 장치를 찾는 함수를 선언 및 정의하고, init()에서 인스턴스 생성 이후 호출합니다.
// VkPlayer.h
// 가용 물리 장치를 찾습니다.
static bool findPhysicalDevice();
static VkPhysicalDevice physicalDevice;		// 물리 장치
bool VkPlayer::init() {
    return
        createInstance()
        && findPhysicalDevice();
}

먼저 인스턴스를 통해 가용 카드 목록을 얻어 봅니다.

bool VkPlayer::findPhysicalDevice() {
    uint32_t count;
    vkEnumeratePhysicalDevices(instance, &count, nullptr);
    std::vector<VkPhysicalDevice> cards(count);
    vkEnumeratePhysicalDevices(instance, &count, cards.data());
}

VkPhysicalDevice는 불완전한 형식에 대한 포인터이며, 함수를 통해서나 그 정보를 얻을 수 있습니다. 얻을 수 있는 정보는 속성, 기능 문서 참고하세요. 그래픽카드를 조금 고생시키고자 한다면 아마 속성의 limits 부분을 주시할 필요가 있겠죠.

 

함수(그리고 구조체)에 2가 붙은 버전도 있는데, 이는 더 많은 정보를 얻을 수 있지만 대체로 꼭 필요할 만한 기능에 대한 것은 여기 나오는 함수로 충분합니다. 위 함수에 다음 코드를 이어붙여 보고 컴파일해 봅시다.

VkPhysicalDeviceProperties properties;
VkPhysicalDeviceFeatures features;
for (uint32_t i = 0; i < count; i++) {
    vkGetPhysicalDeviceProperties(cards[i], &properties);
    vkGetPhysicalDeviceFeatures(cards[i], &features);
    printf("%s\n%d\n\n", properties.deviceName, properties.limits.maxImageDimension2D);
    printf("geometry shader: %d\n",features.geometryShader);
}
physicalDevice = cards[0];
return true;

저의 경우 하나의 그래픽 카드를 찾아 다음과 같은 결과가 나왔네요.

NVIDIA GeForce GTX 1050
32768

geometry shader: 1

 

여러분이 쓰는 그래픽카드 이름을 보니까 이제 조금 본격적인 느낌이 드시나요? 앞으로 물리 장치에서 꼭 필요한 기능이 없으면 거르도록 해야 합니다. 선호하는 기능에 대해서도 따질 수가 있죠. 예를 들면 인덱스마다 점수를 부여하여 최대값에 대한 인덱스만 보유하게 하는 식으로 장치를 선별할 수 있습니다. 아무튼 앞에서 얘기한 큐 계열부터 알아보겠습니다.

 

큐 계열이 받아먹을 수 있는 명령에는 그래픽스, 데이터 전송, 계산 등이 있습니다. 화면에 뭔가 그리려면 당장에 앞의 2개가 필요하며, 나머지는 단순 그래픽스 목적으로는 쓸 일이 없을 수도 있습니다. (계산 셰이더가 레이트레이싱 등에 쓰이는 모양이기도 합니다.) 명령의 유형은 문서를 참고하세요. 일단 여기서는 그래픽스와 데이터 전송, 그리고 창에 표시가 가능한 큐 계열의 번호를 알아 두는 과정을 짜 봅시다.

 

더보기

과정은 물리 장치로부터 큐 계열의 속성을 담는 구조체의 배열을 뽑아내고, 몇 번째 큐 계열에서 원하는 명령을 쓸 수 있는지 확인하면 됩니다. 그러니, 물리 장치는 다음과 같이 내부 구조체에 담아 주는 게 좋겠습니다. physicalDevice의 타입을 이렇게 바꿔 줍시다.

// VkPlayer.h, private 부분
static struct PhysicalDevice {
    VkPhysicalDevice card;
    uint32_t graphicsFamily;
}physicalDevice;							// 물리 장치

코드를 깔끔히 유지하기 위해 가용 큐 계열 구조체를 리턴하는 함수를 작성해 보겠습니다. 크로노스 그룹에 따르면, 전송 명령을 지원하는 큐에서 지원하는 모든 명령은 그래픽 명령 또는 계산 명령을 지원하는 큐에서도 지원됩니다. 그러니 전송 명령은 별도로 검사하지 않겠습니다. 표시의 경우, 확장 기능이기 때문에 아래와 같은 방식으로 검사할 수는 없습니다. 일단은 이렇게 두고, 나중에 조건을 초가하겠습니다.

VkPlayer::PhysicalDevice VkPlayer::setQueueFamily(VkPhysicalDevice card) {
    uint32_t qfcount;
    vkGetPhysicalDeviceQueueFamilyProperties(card, &qfcount, nullptr);
    std::vector<VkQueueFamilyProperties> qfs(qfcount);
    vkGetPhysicalDeviceQueueFamilyProperties(card, &qfcount, qfs.data());
    for (uint32_t i = 0; i < qfcount; i++) {
        if (qfs[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) { return { card,i,i }; }
    }
    return {};
}

리턴한 구조체에서 card가 nullptr라면 가능한 큐가 없는 겁니다. 어차피 대부분의 사람은 그래픽 카드를 한 개 정도만 쓰지 않을까요? 저는 큐 조건을 만족하는 카드 중 가장 먼저 오는 것을 선택하겠습니다. 점수 등의 더 복잡한 로직은 앞으로 필요한 기능에 따라 여러분이 입맛대로 만들어 보아요. 선택 이후의 과정은 지금까지의 코드와 독립적입니다. 

for (uint32_t i = 0; i < count; i++) {
    vkGetPhysicalDeviceProperties(cards[i], &properties);	// 현재 미사용
    vkGetPhysicalDeviceFeatures(cards[i], &features);		// 현재 미사용
    PhysicalDevice pd = setQueueFamily(cards[i]);
    if (pd.card) { 
        physicalDevice = pd;
        return true;
    }
}
fprintf(stderr, "Couldn't find adequate graphics device\n");
return false;

이후에 조건을 추가하는 것을 위해 이 선택 과정을 별도의 함수로 떼는 것도 나쁘지 않은 선택입니다. 저의 경우는 약간의 속도를 위해 분리를 안 했지만, 사실 여기서의 속도의 중요도는 0에 수렴합니다.

 

여기까지 대략 이 정도 코드면 됩니다. 컴파일 후 실행하여 오류가 없나 확인해 보세요. 참고로 VkPhysicalDevice는 create한 게 아니라 인스턴스가 찾아온 거기 때문에 해제 함수가 없습니다.

* 저장소의 코드와 위 코드가 다릅니다. 위 코드가 더 신 버전이므로, 달라도 상관 없다고 알고 계셔도 됩니다. 

 

가상(logical) 장치는 물리 장치의 추상화 버전으로 볼 수 있습니다. 즉, 우리가 그리기 등의 명령을 내리는 것은 이 가상 장치가 받아서 물리 장치가 일을 하게 합니다. 가상 장치를 만들기 위해 주는 정보는 방금 선택한 큐 계열에서 몇 개의 큐를 가져와 사용할지, 그리고 장치의 어떤 기능을 사용할지입니다. 코드를 만들어 봅시다.

 

더보기

가상 장치 생성 및 해제 함수를 만들어 주고 호출하게 합니다.

// VkPlayer.h
static VkDevice device;						// 가상 장치
static bool createDevice();
static bool destroyDevice();
// VkPlayer.cpp
bool VkPlayer::init() {
    return 
        createInstance()
        && findPhysicalDevice()
        && createDevice();
}

void VkPlayer::finalize() {
    destroyDevice();
    destroyInstance();
}

위에 나온 큐 생성 정보는 VkDeviceQueueCreateInfo 구조체로 구성합니다. 몇 번째 큐 계열로부터 몇 개의 큐를 가져올지를 선택하면 됩니다. 큐는 한 개면 충분하다고 합니다.

큐를 여러 개 생성하는 경우 우선도를 0과 1 사이의 float로 정할 수 있지만, 지금 이걸 활용할 일은 없으니 아무 값이나 주도록 합니다. 이것은 각 큐마다 정해지므로, queueCount 길이의 배열을 주는 게 맞지만 큐는 한 개에서 더 늘릴 계획이 없으니 그냥 주소를 줘도 됩니다. nullptr는 확인 계층에서 경고를 주므로 가급적 주지 않도록 합니다.

bool VkPlayer::createDevice() {
    float priority=1.0f;
    VkDeviceQueueCreateInfo qInfo[1]{};
    qInfo[0].sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
    qInfo[0].queueFamilyIndex = physicalDevice.graphicsFamily.value();
    qInfo[0].queueCount = 1;
    qInfo[0].pQueuePriorities = &queuePriority;
}

장치 기능 정보는 당장에 추가로 필요한 게 없습니다. 이를 테면 테셀레이션 셰이더 같은 게 필요하면 사용해 보세요.  다시 문서 링크입니다.

VkPhysicalDeviceFeatures features{};

가상 장치는 vkCreateDevice를 통해 생성할 수 있습니다. vkCreateDevice의 경우 코드만 봐도 의미를 알 수 있을 겁니다.

VkDeviceCreateInfo info{};
info.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
info.pQueueCreateInfos = qInfo;
info.queueCreateInfoCount = 1;
info.pEnabledFeatures = &features;

bool result = vkCreateDevice(physicalDevice.card, &info, nullptr, &device) == VK_SUCCESS;

해제는 vkDestroyDevice로 하면 됩니다.

void VkPlayer::destroyDevice() {
    vkDestroyDevice(device, nullptr);
}

 가상 장치의 큐에 명령을 전달하기 위해 보유한 큐를 보여달라고 해야 합니다.

// VkPlayer.h
VkQueue graphicsQueue;

vkGetDeviceQueue 함수가 그걸 하는데, 장치, 계열 번호, 그리고 실제 만든 큐 번호를 받습니다. 때문에 그래픽과 전송 큐가 온 계열이 같든 다르든, 다음 코드로 모두 커버할 수 있습니다.

if (result) {
    vkGetDeviceQueue(device, physicalDevice.graphicsFamily, 0, &graphicsQueue);
}
return result;

가상 장치가 가지고 있던 걸 보여주는 개념이므로 큐는 해제 함수가 없습니다.

 

컴파일 후 실행하여 오류가 없나 확인해 보세요. 지금까지 이 정도 코드면 됩니다.

지금까지 한 것은 대충 이 정도입니다. 이들의 관계는 종료될 때까지 변하지 않을 겁니다. 즉, 이제부터는 가상 장치 위에서나 놀게 됩니다. 이렇게 생각해도 됩니다.

별 건 아니고 위 그림의 가상 장치에 나머지들이 모두 함축되었다고 보면 됩니다. 사용하는 물리 장치 기능이 약간 달라질 수는 있지만, info 구조체의 '값' 말고는 변하는 게  없을 겁니다. 이와는 다르게 고수준으로 갈수록 구조 자체가 다양하게 갈릴 수 있습니다. 그림을 그리는 것은 그 때문입니다. 큐가 있지만 이것만 갖고는 아직 명령을 내릴 수는 없습니다.

 

2.4. 명령 버퍼

명령 버퍼는 이름 그대로 명령을 담는 버퍼입니다. 명령 버퍼에 명령을 기록했다가 큐로 넘긴다고 이해하시면 될 것 같습니다. 예를 들어 파이프라인을 바인드하거나 실제 그리기 명령을 내리는 등, 기존 API들에서 익숙하게 사용해 보았던 명령들은 드디어 이 명령 버퍼에다가 직접적으로 기록할 수 있습니다.

 

명령 버퍼는 초기 상태, 기록 상태, 기록 완료 상태, 큐 대기 상태, 무효 상태 중 하나입니다. 기록 완료 후 큐에 제출되고 큐 대기가 끝나 실제 명령이 실행되고 나면 큐 대기 상태 혹은 무효 상태로 전환됩니다.

 

명령 버퍼를 만들었다가 지웠다가 하는 것은 무거운 작업일 수 있습니다. 때문에 이러한 과정은 명령 풀 위에서 합니다. 명령 풀이 어느 정도 자원을 가지고 있다가 일부를 버퍼로 쓰는 식으로(생성할 때 추가 할당이 필요하면 하고) 생각하시면 될 것 같습니다. 자세한 설명이 알고 싶다면 여기를 참고하세요("Command Buffer Pools" 문단). 아무튼 우선 명령 풀부터 만들어 줍니다.

 

더보기

우선 멤버를 만들고 다음과 같이 호출해 줍니다.

// VkPlayer.h
constexpr static constexpr static int COMMANDBUFFER_COUNT = 4;
// 명령 풀과 버퍼를 생성합니다.
static bool createCommandPool();
// 명령 풀과 버퍼를 제거합니다.
static void destroyCommandPool();
static VkCommandPool commandPool;
static VkCommandBuffer commandBuffers[];	// cpp 파일의 정의문에서는 크기를 COMMANDBUFFER_COUNT로

// VkPlayer.cpp
bool VkPlayer::init() {
    return 
        createInstance()
        && findPhysicalDevice()
        && createDevice()
        && createCommandPool();
}
void VkPlayer::finalize() {
    destroyCommandPool();
    destroyDevice();
    destroyInstance();
}

명령 풀을 만들려면 기본적으로는 어떤 계열의 큐에 관한 것인지가 필요합니다. VkCommandPoolCreateInfo의 queueFamilyIndex를 씁니다.

bool VkPlayer::createCommandPool() {
    VkCommandPoolCreateInfo info{};
    info.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
    info.queueFamilyIndex = physicalDevice.graphicsFamily;
    
    if (vkCreateCommandPool(device, &info, nullptr, &commandPool) != VK_SUCCESS) {
        fprintf(stderr, "Failed to create graphics/transfer command pool\n");
        return false;
    }
}

추가로, flags 속성을 설정할 수 있습니다. 다음 중에 원하는 속성을 조합하면 됩니다. 하나 더 있기는 하지만 확장 기능이므로 생략합니다.

  • VK_COMMAND_POOL_CREATE_TRANSIENT_BIT : 이 풀에서 할당한 명령 버퍼는 금방 리셋(가지고 있는 명령을 버린다)되거나 해제되기에 적합한 구조를 가지게 됩니다.
  • VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT: 이 풀에서 할당한 명령 버퍼는 개별적으로 vkResetCommandBuffer를 통해 초기 상태로 되돌리거나 vkBeginCommandBuffer로 초기 상태를 들를 수 있습니다. 반대로 말하면, 이 플래그를 세팅하지 않으면 버퍼 자체로부터 리셋이 불가능합니다. 같은 명령만 계속 사용하거나, 아니면 일괄적으로 리셋(vkResetCommandPool)하거나라고 보시면 될 것 같습니다.

이 풀을 가지고 명령 버퍼를 할당합니다. 그리기 명령을 하면 그리기가 끝날 때까지 리턴하지 않았던 OpenGL과 달리 벌칸에서는 CPU가 큐에 명령을 제출만 하면 제출 함수는 바로 리턴합니다(동기화가 필요할 성 싶죠?). CPU가 다른 명령을 가져오는 동안 GPU는 자기 일을 할 수 있도록 버퍼는 여러 개 할당해 봅시다.

버퍼 할당에 줄 정보는 명령 풀, 생성 수, 그리고 '수준'입니다. 수준은 기본 수준과 보조 수준이 있는데, 기본 수준은 큐에 명령을 제출할 수 있고 보조 수준은 기본 수준 버퍼에 명령을 제출할 수 있습니다.

VkCommandBufferAllocateInfo bufferInfo{};
bufferInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
bufferInfo.commandPool = commandPool;
bufferInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
bufferInfo.commandBufferCount = COMMANDBUFFER_COUNT;
if (vkAllocateCommandBuffers(device, &bufferInfo, commandBuffers) != VK_SUCCESS) {
    fprintf(stderr, "Failed to create graphics/transfer command buffers\n");
    return false;
}
return true;

vkAllocate... 함수로 할당한 것은 vkFree... 함수를 통해 해제합니다.

void VkPlayer::destroyCommandPool() {
    vkFreeCommandBuffers(device, commandPool, COMMANDBUFFER_COUNT, commandBuffers);
    vkDestroyCommandPool(device, commandPool, nullptr);
}

 

 

컴파일하고 실행하면 별 이상 없이 종료되어야 합니다. 지금까지의 코드는 이렇습니다.

지금까지 만들어진 것은 다음과 같이 표현할 수 있습니다. 상기했듯 명령 버퍼는 명령 풀 위에서 할당되고 돌아가는데, 우리가 해제하기 전까지 명령 풀에 대하여 접근해야 할 때는 버퍼 일괄 리셋할 때밖에 없습니다.

명령을 제출하려면 명령을 작성해야겠죠. 작성할 명령 자체는 GL 때와 비슷하게 각종 버퍼 사용을 명시하고 파이프라인 가지고 최종적으로 '그려라' 하는 겁니다. 그러면 데이터 버퍼도 필요하겠고 파이프라인도 만들어야겠군요. 우선 파이프라인에 대한 내용부터 다루겠습니다.

 

2.5. 스왑 체인과 창

벌칸은 GUI의 창 시스템과는 기본적으로 독립적입니다. 창 시스템과 우리의 프레임버퍼를 통합하려면 확장이 필요합니다. 이는 대부분의 PC 시스템에서는 GLFW를 통해 통일된 명령으로 수행할 수 있습니다. 그 도움 없이 하고 싶다면 여기여기를 참고하세요. (이 경우에는 입력 시스템이나 크기 변화 등에 콜백을 활용하기 위해 X11, Win32를 아는 게 좋습니다.) 하지만 첫 글에서 추후 안드로이드를 포함하기로 했었죠. 그러니 함수를 따로 나누어 확장을 요청해 봅시다.

 

더보기

벌칸에서는 창 시스템과의 인터페이스를 VkSurfaceKHR라는 확장으로 제공합니다. (확장 기능이 필요한 것은 KHR라는 접미사가 붙습니다.) 창 시스템은 많은 PC 플랫폼에 대한 커버를 GLFW에서 GLFWwindow로 제공합니다. 이에 대한 멤버를 추가해 봅시다.

// VkPlayer.h
static GLFWwindow* window;					// 응용 창
static uint32_t width, height;				// 창 크기
static VkSurfaceKHR surface;				// 창 표면

// 응용 창에 벌칸을 통합합니다.
static bool createWindow();
// 창을 제거합니다.
static void destroyWindow();

창 크기의 경우 변할 수 있으면서 여러 곳에서 참조해야 하므로 저렇게 둡니다. 창 시스템과의 통합은 인스턴스 생성 직후에 하면 됩니다(코드 생략). 당연히 해제는 그 반대 순서가 되겠죠.

// VkPlayer.cpp
bool VkPlayer::createWindow() {
    if (glfwInit() != GLFW_TRUE) {
        fprintf(stderr, "Failed to initialize GLFW\n");
        return {};
    }
    if (glfwVulkanSupported() != GLFW_TRUE) {
        fprintf(stderr, "Vulkan not supported with GLFW\n");
        return {};
    }

    glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
    glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE);
    window = glfwCreateWindow(width, height, u8"OAVKE", nullptr, nullptr);
    if (!window) {
        fprintf(stderr, "Failed to create window\n");
        return false;
    }
    if (glfwCreateWindowSurface(instance, window, nullptr, &surface) != VK_SUCCESS) {
        fprintf(stderr, "Failed to create window surface\n");
        return false;
    }
    return true;
}

void VkPlayer::destroyWindow() {
    vkDestroySurfaceKHR(instance, surface, nullptr);
    glfwDestroyWindow(window);
}

glfwWindowHint 함수는 창을 생성하기 전에 glfw에 어떻게 해야 하는지를 알려 둡니다. 자세한 설명은 여기서는 다루지 않겠습니다. 여기를 참고하세요. 생성하기 위해 줘야 할 값이 플랫폼마다 다르지만, glfwCreateWindowSurface 함수는 이것을 플랫폼마다 구분해서 알아서 한다는 점이 좋습니다. 해제 방법은 플랫폼과 관계가 없으므로 vkDestroySurfaceKHR로 하면 됩니다. 하지만 이대로 컴파일하면 glfwCreateWindowSurface에서 막힙니다. 확장을 사용하도록 인스턴스에 명시하지 않았기 때문입니다.

확장을 사용하는 것도 GLFW 의존적이므로 함수를 분리하고, GLFW를 통해 필요한 확장의 이름을 얻어옵니다.

std::vector<std::string> VkPlayer::getNeededInstanceExtensions() {
    if (glfwInit() != GLFW_TRUE) {
        fprintf(stderr, "Failed to initialize GLFW\n");
        return {};
    }
    if (glfwVulkanSupported() != GLFW_TRUE) {
        fprintf(stderr, "Vulkan not supported with GLFW\n");
        return {};
    }
    uint32_t count;
    const char** names = glfwGetRequiredInstanceExtensions(&count);
    std::vector<std::string> strs(count);
    for (uint32_t i = 0; i < count; i++) {
        strs[i] = names[i];
    }
    return strs;
}

이것은 꼭 멤버 함수일 필요는 없습니다. GLFW를 사용하려면 glfwInit()을 써야 하는데, 이것은 인스턴스 생성 전에 호출해야 하므로 glfwInit()의 호출은 여기로 앞당겨 줍니다. glfwGetRequiredInstanceExtensions 함수가 리턴하는 이름 문자열의 경우 메모리 해제를 GLFW가 직접 관리합니다. 따라서 복사하여 사용하는 것이 안전합니다.

무슨 말이야, 그게. 바로 info.ppEnabledExtensionNames = glfwGetRequiredInstanceExtensions(&count); 하면 되는 거 아냐?
그 말고 다른 확장을 원한다면 어쩔 거야? 이미 char**가 할당되어 있는데 그 다음에 이어붙인다고 realloc()이라도 하려고?

이 함수는 createInstance()에서 이런 식으로 호출합니다.

// VkPlayer.cpp의 VkPlayer::createInstance()
VkInstanceCreateInfo info{};	// 일단 모두 0으로 초기화함
info.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;	// 고정값
info.pApplicationInfo = &ainfo;

std::vector<std::string> names = getNeededInstanceExtensions();
std::vector<const char*> pnames(names.size());
for (size_t i = 0; i < pnames.size(); i++) { pnames[i] = names[i].c_str(); }
info.enabledExtensionCount = pnames.size();
info.ppEnabledExtensionNames = pnames.data();

이제 컴파일하고 실행하면 정상적으로 종료됩니다. 창 표면에 올바르게 표시하려면 그에 맞는 큐가 필요합니다. 물리 장치 큐 계열의 역량 검사를 이렇게 고쳐 봅시다. 2개 모두를 지원하는 큐 계열을 최우선으로 선택하고, card를 null로 두는 겁니다.

//VkPlayer::setQueueFamily의 루프 부분
    for (uint32_t i = 0; i < qfcount; i++) {
        VkBool32 supported;
        vkGetPhysicalDeviceSurfaceSupportKHR(card, i, surface, &supported);
        if (qfs[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) { 
            if (supported) return { card,i,i };
            ret.graphicsFamily = i; gq = true;
        }
        if (supported) { ret.presentFamily = i; pq = true; }
    }
    if (gq && pq) {
        ret.card = card;
        return ret;
    }
    return {};

선택한 큐를 장치 만들 때 추가하여 멤버로 담아 두면 됩니다.

bool VkPlayer::createDevice() {
    VkDeviceQueueCreateInfo qInfo[2]{};
    float queuePriority = 1.0f;
    qInfo[0].sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
    qInfo[0].queueFamilyIndex = physicalDevice.graphicsFamily;
    qInfo[0].queueCount = 1;
    qInfo[0].pQueuePriorities = &queuePriority;
    
    qInfo[1].sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
    qInfo[1].queueFamilyIndex = physicalDevice.presentFamily;
    qInfo[1].queueCount = 1;
    qInfo[1].pQueuePriorities = &queuePriority;
    
    VkPhysicalDeviceFeatures features{};
    
    VkDeviceCreateInfo info{};
    info.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
    info.pQueueCreateInfos = qInfo;
    info.queueCreateInfoCount = 1 + physicalDevice.graphicsFamily != physicalDevice.presentFamily;
    info.pEnabledFeatures = &features;
    info.ppEnabledExtensionNames = DEVICE_EXT;
    info.enabledExtensionCount = DEVICE_EXT_COUNT;
    
    bool result = vkCreateDevice(physicalDevice.card, &info, nullptr, &device) == VK_SUCCESS;
    if (result) { 
        vkGetDeviceQueue(device, physicalDevice.graphicsFamily, 0, &graphicsQueue);
        vkGetDeviceQueue(device, physicalDevice.presentFamily, 0, &presentQueue);
    }
    else { fprintf(stderr, "Failed to create logical device\n"); }
    return result;
}

내친김에 메인 루프도 구성해 봅시다. 일단 다음 멤버를 넣어 줍니다.

static int frame;					// 프레임 번호(1부터 시작)
// 현재 프레임과 이전 프레임 사이의 간격(초) / 프레임 시작 시점(초) / dt의 역수
static float dt, tp, idt;

frame이 0도 아니고 1부터 시작하는 것은 입력 시스템을 위해 쓸만하기 때문입니다. 144fps를 기준으로 오버플로가 발생하려면 172일을 꼬박 켜 둬도 부족하므로 그냥 int를 쓰면 됩니다. 입력 시스템까지 여기서 얘기하면 너무 길어지므로 일단 루프만 만들어 줍니다. dt의 역수는 이곳저곳에서 여러 번 사용할 가능성이 있으므로 프레임 당 한 번만 구해 줍니다.

for (frame = 1; glfwWindowShouldClose(window) != GLFW_TRUE; frame++) {
   glfwPollEvents();
   static float prev = 0;
   tp = (float)glfwGetTime();
   dt = tp - prev;
   idt = 1.0f / dt;
   if((frame&15)==0)printf("%f\r", idt);	// 대략적으로 속도를 확인하기 위해 일단 출력해 봅시다. 리얼 프로그램에선 필요 없습니다.
   prev = tp;
   // loop body (씬 업데이트, 오디오 동기화 등)
}

코드 자체에 의미가 다 보입니다. 저 같은 경우 초당 70만~100만 번 정도 루프하네요.

여기서 잠깐 glfwPollEvents()를 빼고 컴파일해 볼까요? 창은 뜨는데 아무런 반응을 안 하고 있을 겁니다. 즉, GLFW가 창의 이벤트를 주기적으로 저 함수를 이용하여 폴링해야 상호작용이 가능합니다. 이후에 이벤트 콜백 함수를 이용할 필요가 있는데 이는 지금은 다루지 않겠지만 결국 게임 프레임워크를 만들 것이므로 꼭 필요합니다. 당장에 알고 싶다면 문서를 참고하세요.

 

창을 만들었기 때문에 이제 exit() 함수를 쓸 수 있습니다. stdlib의 그 exit 말고, 멤버 함수입니다. 

void VkPlayer::exit() {
    glfwSetWindowShouldClose(window, GLFW_TRUE);
}

 

이를 응용에서 호출하면 자연스럽게 자원을 해제하고 종료할 수 있습니다.

 

여기 부분은 딱 한 가지만 더 짚고 마칩니다. 게임을 비롯한 그래픽스 응용들은 결국 사용자에게 보여주는 것이 목적이며, 따라서 모니터 주사율을 넘어가는 업데이트는 꼭 필요하지 않습니다. 그래서 오히려 주사율에 맞춘 화면 업데이트를 하고 남는 시간에는 쉬는 것이 전력을 더 아끼는 길입니다. 품질은 변하지 않으면서요. 그러려면 주사율을 알아야 하는데, 역시 GLFW가 해 줍니다. (참고로 glfwSwapBuffers 함수에 의한 수직 동기화는 벌칸에선 쓸 수 없습니다.)

// VkPlayer.cpp의 VkPlayer::getNeededInstanceExtensions()
const GLFWvidmode* mode = glfwGetVideoMode(glfwGetPrimaryMonitor());
idt = (float)mode->refreshRate;	// 지금 쓰는 모니터의 주사율, desiredFps 등 변수를 만들어 넣는다는 아이디어도 있겠죠.
dt = 1.0f / idt;

딱히 여기서 해야 할 작업은 아닙니다만 그냥 GLFW를 초기화한 직후에 한 겁니다. 성능 측정을 위해, 아직은 프레임 타임을 고정하는 걸 하지 않겠습니다. sleep_until이나 sleep_for 같은 경우 오차가 꽤 나는 편이으로 Sascha Willams의 말에 따라 VK_PRESENT_MODE_FIFO_KHR를 이용하는 게 기본이지 싶습니다. 더 낮은 FPS를 원한다면 std::chrono의 sleep_until, now의 오버헤드를 임의의 장치로부터 평균적으로 잘 계산한 후에 그것을 빼는 식으로 하는 게 좋을 것 같네요.

 

여기까지 이 정도 코드가 나올 겁니다. 이제 창 시스템과 벌칸을 연결했으니 이런 모습이 됩니다.

surface - 창 시스템 - 화면으로 이어지는 전달은 사실상 우리가 중간에 낄 데가 없습니다. 앞으로는 이렇게 생각해도 크게 틀리지 않을 겁니다. (극단적으로 명령 버퍼 뒤에 가상 장치(화면)라고만 남겨도 흐름 상 완전 틀리지는 않지만, 그러면 지금까지 한 게 너무 없어 보이므로(실제로 없긴 함) 이대로 두겠습니다.)

그런데 소제목에 있는 스왑 체인이란 게 뭘까요? 창 표면에 연계되는 스케치북입니다. 스왑 체인에서 종이(이미지)를 받아 와서 그리고 큐에 제출하는 것이 우리가 명령하는 방식입니다. 스왑 체인에서 이미지 2개를 돌리면 더블 버퍼링, 3개를 돌리면 트리플 버퍼링인 겁니다. 자세한 설명은 문서를 참고하세요. (이미지를 담는 곳이기 때문에, 창 크기가 변하면 당연히 스왑 체인은 다시 만들어 줘야 합니다.) 바로 스왑 체인을 만들어 봅시다.

 

더보기

스왑 체인은 VkSwapchainKHR로 접근할 수 있으며, 역시 확장 기능입니다. 그래픽스 연산을 제공하지 않는 장치도 있다 보니 확장인 것이지만, 이전 글에서 vkcube를 돌려 보고 오셨다면 앞으로도 확장이 없어 실행이 불가능한 사태는 일어날 리 없으니 걱정하지 않아도 됩니다. 일단 멤버를 생성합니다.

// VkPlayer.h
static VkSwapchainKHR swapchain; // 스왑 체인
static VkExtend2D swapchainExtent; // 스왑체인 이미지 크기는 갖고 있다가 프레임버퍼 첨부물 설명에 씁니다.

// 스왑 체인을 생성합니다.
static bool createSwapchain();
// 스왑 체인을 해제합니다.
static void destroySwapchain();

스왑 체인 생성도 info 계열 구조체를 이용하여 구성해야 합니다. 하지만 그 전에 확장 지원 여부를 먼저 확인해 봅시다. 이것은 인스턴스가 아닌 물리 장치에서 확인합니다.

// VkPlayer.h
constexpr static const char* DEVICE_EXT[] = { VK_KHR_SWAPCHAIN_EXTENSION_NAME };
constexpr static int DEVICE_EXT_COUNT = sizeof(DEVICE_EXT) / sizeof(const char*);

// 물리 장치의 원하는 확장 지원 여부를 확인합니다.
static bool checkDeviceExtension(VkPhysicalDevice);

checkDeviceExtension 함수는 단일 bool을 리턴하도록 했습니다만, 특정 기능 지원을 안 해도 프로그램이 돌아는 가게 할 수 있도록 만들고자 한다면 bool 배열과 enum class를 같이 사용하는 방법이 있습니다. 단, 스왑체인을 지원하지 않으면 화면에 그릴 수 없으므로 이건 필수가 되겠죠. 이 과정은 인스턴스를 이용하여 물리 장치를 찾을 때와 매우 유사합니다. std::set을 이용하여 복잡도를 내리는 방법도 있지만 여기서 시간을 많이 잡아먹을 일은 없다고 보셔도 됩니다.

bool VkPlayer::checkDeviceExtension(VkPhysicalDevice device) {
    uint32_t count;
    vkEnumerateDeviceExtensionProperties(device, nullptr, &count, nullptr);
    std::vector<VkExtensionProperties> exts(count);
    vkEnumerateDeviceExtensionProperties(device, nullptr, &count, exts.data());
    for (int i = 0; i < DEVICE_EXT_COUNT; i++) {
        bool flag = false;
        for (VkExtensionProperties& pr : exts) {
            if (strcmp(DEVICE_EXT[i], pr.extensionName) == 0) {
                flag = true;
                break;
            }
        }
        if (!flag)return false;
    }
    return true;
}

이제 물리 장치를 선택할 때 이 조건을 추가하면 됩니다.

// VkPlayer.cpp의  VkPlayer::findPhysicalDevice
for (uint32_t i = 0; i < count; i++) {
    vkGetPhysicalDeviceProperties(cards[i], &properties);
    vkGetPhysicalDeviceFeatures(cards[i], &features);
    if (!checkDeviceExtension(cards[i])) continue;	// 새로 추가된 행
    PhysicalDevice pd = setQueueFamily(cards[i]);
    if (pd.card) { 
        physicalDevice = pd;
        return true;
    }
}

위에 나온 바와 같이 물리 장치를 실제로 쓴다고 명시하는 건 그걸 추상화하는 가상 장치 생성 단계에서입니다. 여기서 원하는 확장을 사용하도록 명시하면 됩니다.

// VkPlayer::createDevice
VkDeviceCreateInfo info{};
info.ppEnabledExtensionNames = DEVICE_EXT;
info.enabledExtensionCount = DEVICE_EXT_COUNT;

이제 스왑 체인을 만들면 됩니다. VkSwapcahinCreateInfoKHR 구조체를 채워야 하는데, 스왑 체인이 가질 수 있는 이미지의 수, 각 이미지의 크기(xyz), 픽셀 형식, 그리고 표시 모드를 기반으로 해야 합니다. (표시 모드란 싱글 버퍼링, 더블 버퍼링 등을 말합니다.) 

// VkPlayer.cpp의 VkPlayer::createSwapchain
uint32_t count;
VkSurfaceCapabilitiesKHR caps;
vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physicalDevice.card, surface, &caps);
vkGetPhysicalDeviceSurfaceFormatsKHR(physicalDevice.card, surface, &count, nullptr);
std::vector<VkSurfaceFormatKHR> formats(count);
vkGetPhysicalDeviceSurfaceFormatsKHR(physicalDevice.card, surface, &count, formats.data());
vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice.card, surface, &count, nullptr);
std::vector<VkPresentModeKHR> modes(count);
vkGetPhysicalDeviceSurfacePresentModesKHR(physicalDevice.card, surface, &count, modes.data());

이 정도로 원하는 정보를 다 얻어올 수 있습니다. 이제 차례대로 채워 봅시다. 설명이 다 들어가면 길어지므로, 주석에 대략적으로만 표시하는 걸로 하고 자세한 사항은 사양에서 참고하세요.

uint32_t idx[2] = { physicalDevice.graphicsFamily,physicalDevice.presentFamily };

VkSwapchainCreateInfoKHR info{};
info.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
info.surface = surface; // 이미지를 보여줄 창
info.pQueueFamilyIndices = &physicalDevice.graphicsFamily; // 스왑체인에 접근할 큐 계열 인덱스의 배열
info.minImageCount = caps.minImageCount > caps.maxImageCount - 1 ? caps.minImageCount + 1 : caps.maxImageCount; // 스왑 체인의 최소 이미지 수

VkSurfaceFormatKHR sf = formats[0];
for (VkSurfaceFormatKHR& form : formats) {
    if (form.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR && form.format == VK_FORMAT_B8G8R8A8_SRGB) sf = form;
}

info.imageFormat = sf.format; // 이미지의 픽셀 형식
info.imageColorSpace = sf.colorSpace; // 색 공간(하단 설명 참조)
info.imageExtent.width = std::clamp(width, caps.minImageExtent.width, caps.maxImageExtent.width); // 스왑 체인에서 만들 실제 이미지 크기
info.imageExtent.height = std::clamp(height, caps.minImageExtent.height, caps.maxImageExtent.height); // 스왑 체인에서 만들 실제 이미지 크기
info.presentMode = std::find(modes.begin(), modes.end(), VK_PRESENT_MODE_MAILBOX_KHR) != modes.end() ? VK_PRESENT_MODE_MAILBOX_KHR : VK_PRESENT_MODE_FIFO_KHR; // 상기한 표시 모드
info.imageArrayLayers = 1; // 멀티 뷰에 의한 입체 응용을 만들지 않는다면 1입니다. 그 외에는 뷰의 수만큼.
info.imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; // 색 데이터가 들어가는 이미지
info.preTransform = caps.currentTransform; // 응용의 변환에 추가로 가할 변환으로 보면 되는데, 90도 단위 회전과 수평 반전만 있습니다. 그냥 기존 걸 줍시다.
info.compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR; // 다른 창 표면과 겹쳤을 때 알파 채널을 어떻게 블렌딩할지
info.clipped = VK_TRUE; // true라면 창의 일부가 가려진 경우 그 부분의 픽셀은 무시합니다.
info.oldSwapchain = VK_NULL_HANDLE; // 기존 스왑 체인입니다. 기존 스왑 체인을 준다면 해제하기 전에 여기 줘야 합니다.

swapchainExtent = info.imageExtent;

if (physicalDevice.graphicsFamily == physicalDevice.presentFamily) {
    info.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE;
}
else {
    info.imageSharingMode = VK_SHARING_MODE_CONCURRENT; // 여러 큐 계열에서 동시 접근 가능
    info.queueFamilyIndexCount = 2; // 접근 수(배열 크기)
    info.pQueueFamilyIndices = &physicalDevice.graphicsFamily; // 큐 인덱스 배열
}

 

info.minImageCount는 왜 저런 식으로 정했지? min(caps.minImageCount, caps.maxImageCount) 이렇게 하면 안 되나?
maxImageCount가 0인 경우도 있는데, 딱히 제한이 없다는 의미라는군. 그래서 그런 식으로 하면 장치에 따라 0이 들어가 버릴 수도 있어.
그리고 위에선 VK_PRESENT_MODE_FIFO_KHR로 수직동기화할 수 있다더니, 저 메일박스 모드도 수직 동기화가 되는 건가?
아닌 걸로 알고 있어. 이건 성능 측정을 위해 지금은 가능한 다른 걸 쓰고 나중에 바꾸자.

해제는 지금까지와 같은 방식으로 하면 됩니다.

void VkPlayer::destroySwapchain() {
    vkDestroySwapchainKHR(device, swapchain, nullptr);
}

 

 

컴파일하고 실행해 보세요. 하얀 창을 닫았을 때 경고 없이 리턴 코드 0으로 종료되어야 합니다. 지금까지의 코드 전체는 이렇습니다. (이 코드에는 실수로 modes에 옵션을 받아오는 과정을 빼먹었습니다.)

 

이 스왑 체인에서 이미지를 받아올 때에는, '지금 그릴 수 있는 인덱스'로 받아옵니다. 그럼 그 인덱스를 통해 이미지에 접근해야겠죠. 우리가 무언가 그리라고 시킬 수 있는 곳은 프레임버퍼이며, 스왑 체인에서 받아올 수 있는 이미지는 다목적 데이터 그 자체이며, 그것을 참조하며 메타데이터를 가지고 있는 이미지 뷰가 '첨부물(attachment)'로 사용됩니다. GL 프레임버퍼의 그 첨부물이 맞습니다. 위에서는 색 첨부물로 사용하도록 스왑 체인을 구성했었죠. 깊이/스텐실 첨부물 역시 이미지 뷰의 형태로 프레임버퍼에 붙입니다. 그럼 이미지 뷰를 받아 옵시다.

 

더보기

우선 멤버를 만들어 줍니다. 지금 이미지는 핸들을 갖고 있어도 할 수 있는 일이 없으므로 그걸 가리키는 이미지 뷰만 갖고 있으면 될 것 같습니다.

// VkPlayer.h
static std::vector<VkImageView> swapchainImageViews; // 스왑 체인 이미지 뷰
static VkFormat swapchainImageFormat; // 스왑 체인 이미지의 형식

// 스왑 체인에서 이미지 뷰를 생성합니다.
static bool createSwapchainImageViews();
// 스왑 체인의 이미지 뷰를 해제합니다.
static void destroySwapchainImageViews();

스왑 체인의 이미지의 참조를 가져온 다음, 이미지 뷰로 넘기면 됩니다. info 계열 구조체를 채우며, 역시 주석에 짤막하게만 작성합니다. 자세한 사항은 문서를 참고하세요. 

bool VkPlayer::createSwapchainImageViews() {
    uint32_t count;
    vkGetSwapchainImagesKHR(device, swapchain, &count, nullptr);
    std::vector<VkImage> images(count);
    vkGetSwapchainImagesKHR(device, swapchain, &count, images.data());
    swapchainImageViews.resize(count);
    VkImageViewCreateInfo info{};
    info.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
    info.viewType = VK_IMAGE_VIEW_TYPE_2D; // 이미지 유형으로 거의 대부분 이 값. 나중에 큐브맵을 쓰고 싶다면 2D 자리에 CUBE라고 쓰면 됩니다.
    info.format = swapchainImageFormat; // 스왑 체인을 만들 때 주었던 이미지 형식을 따로 갖고 있다가 여기서 똑같이 써야 합니다.
    info.components.r = info.components.g = info.components.b = info.components.a = VK_COMPONENT_SWIZZLE_IDENTITY;
    // 위의 속성은 색상의 채널이 다른 채널을 표현하게 하고 싶을 때 사용합니다. 0을 주면 그대로 나옵니다.
    info.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; // 
    info.subresourceRange.levelCount = 1; // 밉 수준의 수입니다. 그림의 목적지라서 필요 없으니 1을 줍니다.
    info.subresourceRange.baseMipLevel = 0;
    info.subresourceRange.layerCount = 1; // 기반 레이어 수를 지정합니다. 2D 이미지에서는 1로 주면 됩니다.
    info.subresourceRange.baseArrayLayer = 0;
    for (size_t i = 0; i < swapchainImageViews.size(); i++) {
        info.image = images[i];
        if (vkCreateImageView(device, &info, nullptr, &swapchainImageViews[i]) != VK_SUCCESS) {
            fprintf(stderr,"Failed to create image views\n");
            return false;
        }
    }
    return true;
}

 

 

컴파일하고 프로그램을 돌렸을 때 오류가 안 나야 합니다. (전체 코드) 지금까지 한 일은 이 정도입니다.

그림에 문제가 있다면, 스왑 체인에 대한 명령이 아니라 렌더 패스에 대한 명령이라고 해야 맞다는 겁니다. 단순히 아직 안  다룬 내용이기 때문에 저기에 연결해 둔 겁니다. GL을 배울 때에는 프레임버퍼에 한 번 그리는 과정을 렌더패스라고 불렀었는데, 여기서는 렌더패스라는 객체가 존재합니다. 앞으로 그림자 등을 자유롭게 표현하기 위해서는 렌더패스, 서브패스, 프레임버퍼, 파이프라인에 대한 제대로 된 이해가 꼭 필요합니다. 미리 짧게나마 설명하면, 파이프라인은 이미 알고 계신 대로 그저 그리는 과정입니다. 서브패스는 그리는 단계이며, 입/출력 첨부물, 상호 의존성을 정의하며 파이프라인이 여기에 들어가게 됩니다. 렌더패스는 하나 이상의 서브패스가 이어진 것입니다. 프레임버퍼는 렌더패스에서 사용할 첨부물을 모아 둡니다.

그것들을 모두 세팅하고 버퍼까지 세팅하면 삼각형을 그릴 수 있게 됩니다.

 

2.6. 렌더패스

벌칸의 객체들은 만들면 변경할 수 있는 바가 매우 한정적입니다(처음 만들어진 형태에 크게 최적화된 형식을 가집니다). 따라서 여러 객체를 만들어 놓고 기기가 지원하거나 혹은 사용자가 원하는 바에 따라 다른 렌더패스를 따로 만들어 두었다가 사용할 수 있겠죠.

 

위 4개 중에 가장 먼저 만들어야 할 건 렌더패스입니다(서브패스 정의와 동시에).  이제부터 슬슬 응용에 가까워지기 시작합니다. 때문에 단순히 VkRenderPass 객체를 만들 게 아니라 응용 수준에서 렌더패스를 선택할 수 있도록 해야겠죠? 이를 테면 기본 안티얼라이어싱 on/off, 그림자 등 고급 효과 on/off 등의 옵션을 몇몇 게임에서 보신 적 있을 겁니다. 그럼 코드로 들어가 봅시다.

 

더보기

렌더패스는 여러 개를 만들고 인덱스 등으로 접근할 걸 상정하겠습니다. 다만 지금은 서브패스 하나짜리 한 개만 만들 겁니다. 이 함수는 스왑체인 이미지 뷰 생성 이후에 호출하며, 당연히 createRenderPass0 함수는 createRenderPasses에서 호출합니다. 삭제도 마찬가지고요.

// VkPlayer.h
static VkRenderPass renderPass0;
// 렌더패스를 생성합니다.
static bool createRenderPasses();
// 렌더패스를 해제합니다.
static void destroyRenderPasses();
// 단순 렌더링을 위한 패스를 생성합니다.
static bool createRenderPass0();
// 단순 렌더링을 위한 패스를 제거합니다.
static void destroyRenderPass0();

단순 렌더패스를 만들 때는 색 첨부물과 깊이 첨부물을 줘야 합니다만 이미 너무 길어졌으므로 일단 색 첨부물만 정의하여 줍니다. 첨부물 자체를 만드는 게 아니라 첨부물 데이터를 어떻게 바라보아야 하는지를 명시하는 첨부물 기술자(descriptor)로 일러 두는 겁니다. 첨부물은 아까 말했듯 프레임버퍼에서 참조합니다. 이미지 형식, 조각당 샘플수(여기서는 1로 합니다), 서브패스 사용 시작 시 첨부물을 불러올 때 해야 할 행동, 끝낼 때 첨부물에 가할 행동, 스텐실 버퍼일 경우에 대한 앞의 2개 행동, 그리고 들어오는 레이아웃과 나가는 레이아웃입니다.

// VkPlayer.cpp
bool VkPlayer::createRenderPass0() {
    VkAttachmentDescription colorAttachment{};
    colorAttachment.format = swapchainImageFormat;
    colorAttachment.samples = VK_SAMPLE_COUNT_1_BIT;
    colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; // 새로 그림
    colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; // 표시
    colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; // 스텐실 버퍼 아님
    colorAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
    colorAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; // 들어올 이미지 데이터는 비울 예정이므로 신경 x
    colorAttachment.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; // 표시에 최적화된 배치
}

그리고 서브패스는 이것을 참조해야 하는데, VkAttachmentReference를 통해서입니다. 여기의 레이아웃은 '사용 중'일 때의 형식을 정합니다. 서브패스 기술자는 실제로 훨씬 많은 속성을 받을 수 있습니다. 깊이 첨부물이나 입력 첨부물 같은 것 말이죠. 그건 나중에 알아봅시다.

VkAttachmentReference colorAttachmentRef{};
colorAttachmentRef.attachment = 0; // 이후 렌더패스 info에 들어갈 VkAttachmentDescription 배열의 인덱스
colorAttachmentRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; // 색 첨부물에 적합한 구조

VkSubpassDescription subpass{};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
subpass.colorAttachmentCount = 1;
subpass.pColorAttachments = &colorAttachmentRef;

이제 렌더패스 생성 info를 구성하고 create 함수를 부릅니다. 여기에 들어가는 attachment 배열은, 프레임버퍼에 실제로 들어가는 첨부물 배열에 대한 기술자입니다.  

VkRenderPassCreateInfo info{};
info.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
info.attachmentCount = 1;
info.pAttachments = &colorAttachment;
info.pSubpasses = &subpass;
info.subpassCount = 1;
if (vkCreateRenderPass(device, &info, nullptr, &renderPass0) != VK_SUCCESS) {
    fprintf(stderr, "Failed to create render pass\n");
    return false;
}
return true;

컴파일 후 실행해 보세요.

 

몇 줄 추가된 게 없죠. 여기까지 소스는 이렇습니다.

여기까지는 렌더링을 위한 틀을 구성한 거라 보면 됩니다. 실제 작동하는 본체는 파이프라인과 프레임버퍼입니다. 파이프라인을 만들 땐 그 자체의 내용물과 함께, 어떤 렌더패스의 어떤 서브패스로 사용될지를 알려야 합니다. 프레임버퍼는 렌더패스를 명시하기는 하는데, 꼭 지켜야 하는 건 아닙니다. 거기에 준 렌더패스와 호환되는 렌더패스라면 여러 개의 렌더패스와 하나의 프레임버퍼를 사용할 수 있습니다. (렌더패스를 시작하는 명령에다가 줄 수 있습니다.)

이제 아래와 같은 그림을 상상할 수 있습니다.

대충 '장치'가 렌더패스를 실행한다고 보면 됩니다. 한 가지 주의할 점은, 프레임버퍼의 첨부물 이미지는 꼭 스왑 체인의 것만이 아니라는 점입니다. 스왑 체인의 이미지를 첨부물로 사용하는 것은 화면에 그리기 직전에만 필요합니다(그리고 스왑체인 이미지는 색만 표시하면 충분합니다). 그리고 더블 버퍼링 이상부터는, 스왑체인의 이미지 하나를 그리는 동안 기존 것은 화면에 남아 있어야 하므로 최종 단계의 프레임버퍼는 스왑체인 이미지 수만큼 필요합니다.

 

그리고 앞서 주황색으로 설명했듯, 프레임버퍼는 렌더패스 안에 들어가는 건 아닙니다. 렌더패스 사용을 시작할 때 이 프레임버퍼를 사용하겠다고 얘기하는 겁니다.

 

그럼 이제 프레임버퍼를 만들어 봅시다.

 

더보기

프레임버퍼의 첨부물 이미지는 꼭 스왑 체인의 것만이 아니라고 했으니, 중간 단계가 필요하다면 별도의 이미지와 이미지 뷰를 생성해야 합니다. 그런 과정은 별도로 만들어질 것이니, 멤버를 이렇게 만들어 줍니다.

// VkPlayer.h
static std::vector<VkFramebuffer> endFramebuffers; // 스왑체인 이미지를 참조하는 프레임버퍼

// 필요한 프레임버퍼를 생성합니다.
static bool createFramebuffers();
// 프레임버퍼를 해제합니다.
static void destroyFramebuffers();
// 최종 단계(스왑 체인에 연결된)의 프레임버퍼를 생성합니다.
static bool createEndFramebuffers();
// 최종 단계(스왑 체인에 연결된)의 프레임버퍼를 해제합니다.
static void destroyEndFramebuffers();

지금까지와 마찬가지로 init에서 create를, finalize에서 destroy를, createFramebuffers 에서 createEndFramebuffers를 부르고, 해제도 똑같이 하면 됩니다.

이제 프레임버퍼 생성 info 구조체를 구성하고 생성합니다. 꽤 직관적으로, 첨부물 자체랑 크기, 그리고 레이어 수(VR 같이 여러 뷰를 쓰는 게 아니면 1이라고 했습니다), 렌더패스(형식)를 입력합니다. 여기의 attachments 배열에서의 순서는, 당연히 렌더패스를 만들 때의 첨부물 기술자와 같은 순서여야 합니다. 

// VkPlayer.cpp
bool VkPlayer::createEndFramebuffers() {
    endFramebuffers.resize(swapchainImageViews.size());
    VkFramebufferCreateInfo info{};
    info.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
    info.width = swapchainExtent.width;
    info.height = swapchainExtent.height;
    info.layers = 1;
    info.renderPass = renderPass0;
    // flags는 1.0버전 기준 줄 수 있는 값이 없습니다. 이후 버전도 딱히 당장 써야 할 값은 없어 보입니다.
    
    for (size_t i = 0; i < swapchainImageViews.size(); i++) {
        VkImageView attachments[] = { swapchainImageViews[i] };
        info.attachmentCount = sizeof(attachments) / sizeof(attachments[0]);
        info.pAttachments = attachments;
        if (vkCreateFramebuffer(device, &info, nullptr, &endFramebuffers[i]) != VK_SUCCESS) {
            fprintf(stderr, "Failed to create framebuffer\n");
            return false;
        }
    }
    return true;
}

 해제는 그냥 하던 대로 하면 됩니다.

void VkPlayer::destroyEndFramebuffers() {
    for (VkFramebuffer fb : endFramebuffers) {
        vkDestroyFramebuffer(device, fb, nullptr);
    }
}

 

 

당연하다면 당연하게도 이 endFramebuffers는 화면에 표시하기 위한 렌더패스라면 종착점으로 계속 쓸 수 있습니다. 그리고 스왑체인 이미지 변형이 일어나면 이것까진 다시 만들어야겠죠. (이번 글에 들어갈 내용은 아닙니다.)

 

지금까지의 코드는 이 정도입니다. 컴파일하고 실행해서 아무 문제 없이 돌아가고, 창을 닫았을 때 정상적으로 종료 코드 0이 나와야 합니다.

서브패스는 하나뿐이니까 의존성은 필요 없어서 안 한 거지?
아 맞다... 해야지?
어어 왜?
Alexander Overvoorde에 따르면, 렌더패스는 기본적으로 파이프라인이 시작하자마자  필요한 형식으로 이미지가 전환된다는데, 그걸 실제 준비된 이미지 뷰를 받아올 때까지 기다려야 한다네. 세마포어를 이용한 동기화를 해도 되고 의존성으로 해도 된대.

방법은 이렇습니다. 설명은 제가 더 잘 알게 되면 하겠습니다. 조금 더 일반적인 용법은 문서를 확인해 주세요. 아마 링크 들어가서 아래에 있는 설명에 있는 링크까지는 들어가 봐야 이해가 되실 겁니다. 참고로 이 코드와 비슷한 설명이 LunarG 튜토리얼에도 그대로 있으므로, 알렉산더님 말대로 필요한 건 맞는 것 같습니다.

 

더보기

// VkPlayer.cpp의 VkPlayer::createRenderPass0
VkSubpassDependency dependency{};
dependency.srcSubpass = VK_SUBPASS_EXTERNAL;
dependency.dstSubpass = 0;	// 0번 서브패스가 외부에 대한 의존성이 존재
/* (설명 그대로 번역)
그 다음 2개 필드는 기다릴 동작과 그 동작이 발생할 단계입니다.
스왑체인에서 이미지를 읽어오는 걸 기다린 후 접근해야 하니,
색 첨부물 출력 단계에서 기다리게 하면 됩니다.
*/
dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependency.srcAccessMask = 0;
/* (설명 그대로 번역)
이걸 위해 기다려야 할 동작은 색 첨부물 단계에 있는 색 첨부물 쓰기 동작입니다.
*/
dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; 
dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;

이걸 방금 그 info 구조체에 넣습니다.

info.pDependencies = &dependency;
info.dependencyCount = 1;

 

2.7. 파이프라인

익숙한 말이 나와서 기분이 좋나요? 그렇습니다. 우리가 아는, 셰이더 코드를 컴파일해서 만드는 그 파이프라인이 맞습니다. GL에서 파이프라인은 GL 컨텍스트가 되면 셰이더를 컴파일, 다른 단계를 링크하고 쓰면 됐었죠. 벌칸은 방금까지 만들어진 렌더패스를 시작하고 나서 파이프라인을 바인드한 후 명령을 보내는 식으로 사용합니다. (파이프라인은 나머지와는 독립적으로 쓸 수 있습니다.)

 

차이점은, 프로그래밍 가능한 셰이더 단계뿐 아니라 고정 단계 파이프라인의 정보, 이를 테면 깊이/스텐실 테스트 활성화 같은 걸 모두 직접 정해야 하며 이러한 속성들 중 대부분은 변경이 불가능하다는 겁니다. 구성할 설정은 우리가 아는 파이프라인 단계 전부로, 프로그래밍 가능(셰이더) 단계, 정점 데이터 형식, 기초 도형 어셈블리, 뷰포트와 시저, 래스터화, 멀티샘플링, 깊이/스텐실 테스트, 색 블렌딩, 동적으로 설정할 수 있는 여지, 파이프라인 레이아웃(공유 및 푸시 변수에 대한 배치)입니다. 그래서 코드는 참 길지만, 간만에 익숙한 걸 보게 되었으니 그리 어렵게 느껴지지 않을 겁니다. 일단 틀은 아래와 같습니다.

 

더보기

멤버를 만들어 줍시다. 렌더 패스 때랑 같은 식입니다. init()에서 다 생성하고 finalize()에서 다 해제하세요. 파이프라인은 렌더패스와 무관하므로 device 생성보다 뒤에 배치 순서를 적당히 정하시면 됩니다. 

// VkPlayer.h
static VkPipeline pipeline0;

// 필요한 파이프라인을 생성합니다.
static bool createPipelines();
// 파이프라인을 해제합니다.
static void destroyPipelines();
// 단순 파이프라인을 생성합니다.
static bool createPipeline0();
// 단순 파이프라인을 해제합니다.
static void destroyPipeline0();

 pipeline0은, 당장에는 단순히 메시에 변환을 적용하고 텍스처를 씌우는 것이 역할의 전부입니다. 다만, 파이프라인은 익숙한 만큼 응용에 가까운 존재라서 삼각형을 그려 보고 나면 별도의 함수가 아닌, 매개변수를 받아 생성하는 방식으로 수정할 예정입니다. 여기서는 어떤 것에 결정권을 줄 만 할지 대충 찍어 두세요.

 

셰이더 단계는 컴파일한 셰이더를 가지고 초기화하는데, 벌칸에서는 SPIR-V라는 중간 언어를 받습니다. GLSL이나 HLSL 코드를 가지고 shaderc를 이용하여 컴파일하게 되는데, 실전에서는 컴파일한 셰이더를 같이 뿌릴 수도 있지만 셰이더 코드를 빈번히 수정하게 되기 때문에, 일단은 런타임에 컴파일하는 형식으로 갑니다. 우선 정점 셰이더와 조각 셰이더부터 만들어 봅시다.

 

더보기

정점 셰이더 코드입니다. '안녕 삼각형' 하듯이 언제나처럼 위치와 색을 받아옵니다.

#version 450

layout(location = 0) in vec3 position;
layout(location = 1) in vec3 color;

layout(location = 0) out vec3 fragColor;

void main() {
    gl_Position = vec4(position, 1.0);
    fragColor = color;
}

조각 셰이더 코드도 마찬가지로, 그냥 색만 주어진 대로 칠합니다. 여기서 GLSL 4.5버전으로 빌트인 변수가 아닌 outColor를 0번으로 내보내고 있죠. 이것은 아까 서브패스에서 참조하도록 명시한 첨부물 중 0번으로 vec4를 내보내겠다는 뜻입니다.

#version 450

layout(location = 0) in vec3 fragColor;

layout(location = 0) out vec4 outColor;

void main() {
    outColor = vec4(fragColor, 1.0);
}

이들 코드는 실행 파일이 있는 디렉토리에 저장해 주세요. 맨 위의 main.cpp에서는 이런 걸 본 적 있었죠.

std::filesystem::current_path(std::filesystem::path(argv[0]).parent_path());

이 코드는 실행 파일(argv[0])이 있는 곳으로 작업 디렉토리를 옮깁니다. 이건 단순히 VS에서 아래버튼으로

테스트 실행할 때 작업 디렉토리가 기본적으로 프로젝트 파일이 있는 디렉토리로 설정되기 때문에 그냥은 파일을 읽을 수 없으니 넣은 겁니다. 구성 속성 > 디버깅 > 작업 디렉터리를 바꿀 수도 있지만, 저는 코드 상에서 해결하는 것이 더 범용적이라 생각하기 때문에 그렇게 했습니다.

이 셰이더 코드는 기본적으로 여러분이 받은 SDK에서 Bin 폴더의 glslc를 이용하여 SPIR-V로 컴파일할 수 있습니다. 하지만 아까 얘기한 것처럼 셰이더를 자주 수정할 것을 위해 런타임에 컴파일하는 방법을 소개합니다. 필요 없으면 넘어가세요.

#include "externals/shaderc/shaderc.hpp"

static std::vector<uint32_t> compileShader(const char* code, size_t size, shaderc_shader_kind kind, const char* name) {
    shaderc::Compiler compiler;
    shaderc::CompileOptions opts;
    opts.SetOptimizationLevel(shaderc_optimization_level::shaderc_optimization_level_performance);
    shaderc::SpvCompilationResult result = compiler.CompileGlslToSpv(code, size, kind, name, opts);
    if (result.GetCompilationStatus() != shaderc_compilation_status::shaderc_compilation_status_success) {
        fprintf(stderr, "compiler : %s\n", result.GetErrorMessage().c_str());
    }
    return { result.cbegin(),result.cend() };
}

static std::vector<uint32_t> compileShader(const char* fileName, shaderc_shader_kind kind) {
    FILE* fp;
    fopen_s(&fp, fileName, "rb");
    if (!fp) {
        perror("fopen_s");
        return {};
    }
    fseek(fp, 0, SEEK_END);
    size_t sz = (size_t)ftell(fp);
    fseek(fp, 0, SEEK_SET);
    std::string code;
    code.resize(sz);
    fread_s(code.data(), sz, 1, sz, fp);
    std::vector<uint32_t> result = compileShader(code.c_str(), code.size(), kind, fileName);
    fclose(fp);
    return result;
}

static std::vector<uint32_t> loadShader(const char* fileName) {
    FILE* fp;
    fopen_s(&fp, fileName, "rb");
    if (!fp) { 
        perror("fopen_s");
        return {};
    }
    fseek(fp, 0, SEEK_END);
    size_t sz = (size_t)ftell(fp);
    fseek(fp, 0, SEEK_SET);
    std::vector<uint32_t> bcode(sz / sizeof(uint32_t));
    fread_s(bcode.data(), sz, sizeof(uint32_t), sz / sizeof(uint32_t), fp);
    fclose(fp);
    return bcode;
}

위 3개의 함수는 (멤버함수일 필요가 없습니다) 모두 SPIR-V 코드를 리턴합니다. 앞에서부터 순서대로 메모리 상의 GLSL 코드, 파일 상의 GLSL 코드를 컴파일하며 마지막 하나는 그냥 원래 SPIR-V인 파일을 읽어만 옵니다. shaderc를 사용하는 방법은 첫 번째 함수에서 알 수 있는데, 간단하니까 설명은 생략합니다. HLSL 코드를 컴파일하려면 옵션이 달라져야 합니다.

 

이제 이걸로 파이프라인 생성 구조체에 전달할 셰이더 모듈을 만들고 그것의 배열을 단계로써 전달해야 합니다. 예상이 가시죠. 바이트코드만 있으면 모르니 필요한 데이터를 더 주면 되는 겁니다.

 

더보기

셰이더 모듈은 단순히 정보 구조체가 아니라 생성해야 할 객체입니다. 이렇게 만들고 나면 파이프라인을 다 만들고 나서 해제하면 됩니다.

VkShaderModule VKPlayer::createShaderModuleFromSpv(const std::vector<uint32_t>& bcode) {
    VkShaderModule ret;
    VkShaderModuleCreateInfo info{};
    info.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
    info.pCode = bcode.data();
    info.codeSize = bcode.size() * sizeof(uint32_t);
    VkResult res = vkCreateShaderModule(device, &info, nullptr, &ret);
    if (res == VK_SUCCESS) return ret;
    else return nullptr;
}

VkShaderModule VKPlayer::createShaderModule(const char* fileName) { return createShaderModuleFromSpv(loadShader(fileName)); }
VkShaderModule VKPlayer::createShaderModule(const char* fileName, shaderc_shader_kind kind) { return createShaderModuleFromSpv(compileShader(fileName, kind)); }
VkShaderModule VKPlayer::createShaderModule(const char* code, size_t size, shaderc_shader_kind kind, const char* name) { return createShaderModuleFromSpv(compileShader(code, size, kind, name)); }

이제 이 함수를 갖고 구조체를 생성합니다. 간단하죠. 다른 옵션을 줄 거리가 없지 않은데, 문서를 보면 알겠지만 대체로 흥미가 안 가실 겁니다. pName은 진입점 이름을 utf8로 주시면 됩니다. (대체로 거의 모든 인코딩에서 아스키 범위는 일치한다고 봐도 됩니다.)

bool VkPlayer::createPipeline0() {
    // programmable function: 세이더 종류는 수십 가지쯤 돼 보이므로, 오버로드로 수용할 것
    VkShaderModule vertModule = createShaderModule("tri.vert", shaderc_shader_kind::shaderc_glsl_vertex_shader);
    VkShaderModule fragModule = createShaderModule("tri.frag", shaderc_shader_kind::shaderc_glsl_fragment_shader);
    VkPipelineShaderStageCreateInfo shaderStagesInfo[2] = {};
    shaderStagesInfo[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
    shaderStagesInfo[0].stage = VK_SHADER_STAGE_VERTEX_BIT;
    shaderStagesInfo[0].module = vertModule;
    shaderStagesInfo[0].pName = "main";
    
    shaderStagesInfo[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
    shaderStagesInfo[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT;
    shaderStagesInfo[1].module = fragModule;
    shaderStagesInfo[1].pName = "main";

 

 

 그 다음은 정점 입력 데이터 형식입니다. 일단 위 셰이더에서 한 것처럼, 정점은 대충 이 정도로 정의합니다. 이건 자유로운 프로그래밍을 위해 나중에 템플릿으로 대체합니다. 이쯤에서, 벌칸의 표준 뷰 볼륨은 [-1, 1] x [-1, 1] x [0, 1]임을 알아 둡시다. 게다가 벌칸에서는 왼쪽 위 좌표가 (-1, -1)입니다. (GL에서는 왼쪽 아래가 (-1, -1)이었죠) z좌표의 경우 기본적으로 GL과 비슷하게 0이 가장 겉을 나타냅니다.

struct Vertex{
    float pos[3];
    float color[3];
};

역시 info 구조체를 구성해 봅시다.

 

더보기

정점 입력 info 구조체를 위해서는 정점 바인딩 기술자와 정점 속성 기술자가 필요합니다. GL에서 정점 버퍼를 구성한 걸 생각해 보시면 이해가 되시죠? GL에서도 정점과 파이프라인은 호환이 되어야 했습니다.  

VkVertexInputBindingDescription vbind{};
vbind.binding = 0;
vbind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
vbind.stride = sizeof(Vertex);

binding 멤버는 앞으로 이 형식의 바인딩이 몇 번인지를 알리는 겁니다. inputRate는 정점당 전달일지 인스턴스당 전달일지 정하는 거고, stride는 다음 것을 읽을 때까지의 거리를 나타냅니다. 인스턴스드 렌더링은 보통 파티클 효과 같은 데 사용하기 좋지 않나요? 이건 나중에 알아보겠습니다. 정점 버퍼에 데이터를 배열 외의 수단으로 전달할 경우는 없다고 봐도 무방하기 때문에, sizeof(Vertex)로 충분합니다. GL에서 이미 해 본 바죠, 아마.

 

그 다음은 속성 구조체입니다. 속성 한 개당 기술자가 하나씩 필요합니다.

VkVertexInputAttributeDescription vattrs[2]{};
vattrs[0].binding = 0;
vattrs[0].format = VK_FORMAT_R32G32B32_SFLOAT;
vattrs[0].location = 0;
vattrs[0].offset = offsetof(Vertex, pos);

vattrs[1].binding = 0;
vattrs[1].format = VK_FORMAT_R32G32B32_SFLOAT;
vattrs[1].location = 1;
vattrs[1].offset = offsetof(Vertex, color);

binding은 위의 것과 의미가 같습니다. 쉽게 말해 0번 유형의 정점은 이렇게 생겼다고 정해 주는 겁니다. location의 경우 방금 정점 셰이더 코드에서 나타낸 거기서 접근할 수 있는 번호를 말하는 겁니다. format은 이 데이터를 가져가서 어떻게 쓰는지를 말하는데, 자세한 사항은 문서를 확인하세요. 사실 이것만 봐도 뻔하죠? 성분 수는 R, G, B, A순으로 확장되고 그 옆의 각각의 숫자는 비트 수일 거고, S, U는 부호(당연히 UFLOAT는 없습니다)입니다.

이걸로 VkPipelineVertexInputStateCreateInfo를 채우면 됩니다.

VkPipelineVertexInputStateCreateInfo vertexInputInfo{};
vertexInputInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
vertexInputInfo.vertexBindingDescriptionCount = 1;
vertexInputInfo.pVertexBindingDescriptions = &vbind;
vertexInputInfo.vertexAttributeDescriptionCount = 2;
vertexInputInfo.pVertexAttributeDescriptions = vattrs;
// 가능한 플래그는 현재 없음

위의 2개 함수는 나중에 정점 구조체의 멤버 함수로 자동으로 나오게 할 겁니다.

 

그 다음은 기초 도형 어셈블러입니다.

 

더보기

여기서 정할 수 있는 건 여러 정점을 어떤 식으로 모으냐죠. 모르면 파이프라인 다시 공부하고 오세요. 가능한 유형은 그대로 점으로 쓰기, 2개씩 끊어 선분으로 쓰기, 모두 선분으로 잇기, 3개씩 끊어 삼각형으로 쓰기, 삼각형을 이어 그리기 등이 있습니다. 더 많은 선택지는 문서에서 확인할 수 있지만, 아마 대부분 쓸 일은 없을 겁니다. 범용적인 삼각형 리스트로 정해줍니다.

VkPipelineInputAssemblyStateCreateInfo inputAssemblyInfo{};
inputAssemblyInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
inputAssemblyInfo.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;	// 3개씩 끊어 삼각형
inputAssemblyInfo.primitiveRestartEnable = VK_FALSE;	// 0xffff 혹은 0xffffffff 인덱스로 스트립 끊기 가능 여부

restartEnable의 경우는 인덱스의 최대치(16비트면 0xffff, 32비트면 0xffffffff)를 가지고 이어 그리는 걸 끊고 그 다음 것부터 다시 시작할 수 있는 겁니다. 이건 우리랑 상관 없다고 봐도 되겠죠.

 

그 다음은 뷰포트입니다.

 

더보기

뷰포트 info는 뷰포트 정보와 시저(가위) 정보를 표현합니다.

 

뷰포트는 우리가 알던 그것으로, 표준 뷰 볼륨(클립 좌표계)에 올라온 폴리곤을 래스터화해서 보내질 목적지 영역입니다. 이렇게 구성합니다. x, y는 왼쪽 위를 나타내며 이미지 상 가장 왼쪽 위는 (0, 0)으로 하면 됩니다.

VkViewport viewport{};
viewport.x = 0.0f;
viewport.y = 0.0f;
viewport.width = (float)swapchainExtent.width;
viewport.height = (float)swapchainExtent.height;
viewport.minDepth = 0.0f;
viewport.maxDepth = 1.0f;

여기서는 단순히 주어진 스왑 체인 이미지에 가득 채우도록 했습니다. 여러분이 한 게임 중에서는 해상도를 변경할 수 있다면 주어진 해상도 중에서 고르는 방식이 가장 많았을 겁니다. 위의 구성은 그런 목적에 적합합니다. 저의 OAGLE의 경우는 종횡비만 세팅되어 있고 창의 크기를 임의로 변경하면 종횡비를 유지한 채로 그에 맞출 수 있는 최대 크기로 맞춥니다. 그런 목적으로 쓴다면, 이것 말고 후술할 동적 변경에 등록하는 게 좋습니다. 뷰포트는 파이프라인 속성 중 동적으로 변할 수 있게 설정할 수 있는 몇 안 되는 속성입니다.

잠깐! minDepth랑 maxDepth를 설정할 수 있는 거면 아까 말한 벌칸의 깊이 [0,1] 클리핑을 바꿀 수 있는 거 아니야? 그럼 GL이랑 같은 원근 행렬을 쓸 수 있는 거고!
좋은 질문이야. 크로노스 문서에 따르면, 저 값은 원래 둘 다 0과 1 사이여야 해. 하지만 장치 확장 중에서 VK_EXT_detph_range_unrestricted 확장을 켜면 0과 1 범위를 넘어가는 것이 허용되지. 하지만 그런다고 다가 아니야. 잠시 후 나올 깊이 클램핑, 즉 범위를 넘어간 도형 자체를 자르지 않고 좌표값만 자르는 작업을 활성화할 경우 비로소 [0,1] 제한이 없어지지. 그러니 나 같으면 그런 옵션 안 쓰고 그냥 0,1에 맞춘다.

그 다음은 시저입니다. 시저는 스왑체인 이미지에서 그려지는 것을 허용할 부분입니다. 쉽게 말해 뷰포트로 래스터화한 다음에 시저로 자르는 겁니다. 정할 속성은 직사각형 범위가 전부입니다.

VkRect2D scissor{};
scissor.offset = { 0,0 };
scissor.extent = swapchainExtent;

이제 이걸로 만들어 줍니다.

VkPipelineViewportStateCreateInfo viewportStateInfo{};
viewportStateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
viewportStateInfo.viewportCount = 1;
viewportStateInfo.pViewports = &viewport;
viewportStateInfo.scissorCount = 1;
viewportStateInfo.pScissors = &scissor;

 

 

거의 다 왔습니다. 이제 래스터화 구성입니다.

 

더보기

래스터화에서는 면 컬링, 다각형 채우기 모드 등을 결정합니다.

VkPipelineRasterizationStateCreateInfo rasterizerInfo{};
rasterizerInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
rasterizerInfo.depthClampEnable = VK_FALSE;
rasterizerInfo.rasterizerDiscardEnable = VK_FALSE;
rasterizerInfo.polygonMode = VK_POLYGON_MODE_FILL;
rasterizerInfo.lineWidth = 1.0f;
rasterizerInfo.cullMode = VK_CULL_MODE_BACK_BIT;
rasterizerInfo.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
rasterizerInfo.depthBiasEnable = VK_FALSE;
rasterizerInfo.depthBiasConstantFactor = 0.0f;
rasterizerInfo.depthBiasClamp = 0.0f;
rasterizerInfo.depthBiasSlopeFactor = 0.0f;
  • depthClampEnable은 위에 잠깐 나왔었죠. z좌표가 범위를 넘어가면 도형을 자르지 않고 좌표값만 자르게 합니다. 쓸 일이 언제인지는 모르겠네요.
  • rasterizerDiscardEnable의 경우는 TRUE를 주면 그냥 아무것도 렌더링 안 된답니다.
  • polygonMode는 채움, 선만 그림, 점만 그림, 그리고 한 확장 옵션이 또 있는데, 우리 목적에 대해서는 당연히 채움을 씁니다.
  • lineWidth는 픽셀 단위로 1 외의 값을 쓰려면 확장이 필요한데, 아마 쓸 일 없을 것 같네요.
  • cullMode와 frontFace의 경우 GL의 그것과 같은 용도입니다. 한 가지 주의할 점이 있다면 아까 얘기한 것처럼 벌칸은 GL과 Y좌표가 반대입니다. 그러니 거기 -1을 곱하는 순간, 앞면과 뒷면이 서로 바뀌게 되겠죠? 보통 모델을 불러오면, 안팎이 모두 정의된 녀석이 아닌 이상 반시계방향 배열일 것 같은데 이걸 고려해서 불러오거나 여기서 앞면을 거르게 하면 됩니다.
  • 나머지 옵션은 깊이값을 직접 건드리는 건데, 우리는 당장 안 쓸 겁니다.

 

 

그 다음은 멀티샘플링 옵션입니다. 이건 나중에 선택지를 줄 필요가 있겠죠. 여기선 일단 안 쓰는 걸로 하고, 설명도 미룹니다. 하지만 구조체는 필요하니 rasterizationSamples랑 minSampleShading만 1로 설정해 주세요.

 

더보기

VkPipelineMultisampleStateCreateInfo msInfo{};
msInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
msInfo.sampleShadingEnable = VK_FALSE;
msInfo.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
msInfo.sampleShadingEnable = VK_FALSE;
msInfo.minSampleShading = 1.0f;
msInfo.alphaToCoverageEnable = VK_FALSE;
msInfo.alphaToOneEnable = VK_FALSE;
msInfo.pSampleMask = nullptr;

 

깊이, 스텐실 연산 설정도 GL과 유사한 방식인데, 이것도 다음 글들로 미룹니다. 몸만 만들어 주세요.

VkPipelineDepthStencilStateCreateInfo depthStencilInfo{};

 

색 블렌딩입니다. 일단 단일 패스이니 소스알파 - (1-소스알파)로 고정하면 되겠죠.

 

더보기

여기서는 개별 프레임버퍼를 위한 구성인 attachment state와 전체를 위한 color blend info 2종류를 만들어야 합니다.

VkPipelineColorBlendAttachmentState colorBlendAttachmentState{};
colorBlendAttachmentState.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
colorBlendAttachmentState.blendEnable = VK_TRUE;
colorBlendAttachmentState.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA;
colorBlendAttachmentState.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;
colorBlendAttachmentState.srcAlphaBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA;
colorBlendAttachmentState.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;
colorBlendAttachmentState.colorBlendOp = VK_BLEND_OP_ADD;
colorBlendAttachmentState.alphaBlendOp = VK_BLEND_OP_ADD;

 colorWriteMasks는 정말 특수한 경우가 아니면 RGBA 모든 요소를 쓸 수 있어야겠고, blendEnable도 켜야겠죠. BlendOp의 경우 ADD로 하면 됩니다. 다른 옵션도 다양하게 있는데, 다른 효과가 필요하면 확인해 보세요. 여기서는 선택지를 제공하지 않을 예정입니다.

2번째 구조체를 구성합시다.

VkPipelineColorBlendStateCreateInfo colorBlendInfo{};
colorBlendInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
colorBlendInfo.logicOpEnable = VK_FALSE;
colorBlendInfo.logicOp = VK_LOGIC_OP_COPY;
colorBlendInfo.attachmentCount = 1;
colorBlendInfo.pAttachments = &colorBlendAttachmentState;
colorBlendInfo.blendConstants[0] = 0.0f;
colorBlendInfo.blendConstants[1] = 0.0f;
colorBlendInfo.blendConstants[2] = 0.0f;
colorBlendInfo.blendConstants[3] = 0.0f;

이것은 전체 프레임버퍼에 대한 것을 설정할 수 있고 비트연산을 쓸 수 있게 하는데, 일단은 attachment만 명시하고 넘어갑니다. 이건 나중에 확실히 알게 되면 설명을 추가할게요.

 

2개 남았습니다. 이건 동적으로 뭘 설정할 수 있게 하는지를 명시합니다. 가능한 것이 몇 가지 있는데, 대부분 그대로 두도록 합시다. 위에 얘기한 대로 나중에 뷰포트를 동적으로 조정할 수 있게 하겠습니다. 지금은 이대로 놔둡니다.

 

더보기

VkDynamicState dynamicStates[1] = { /*VK_DYNAMIC_STATE_VIEWPORT*/ };
VkPipelineDynamicStateCreateInfo dynamics{};
dynamics.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
dynamics.dynamicStateCount = sizeof(dynamicStates) / sizeof(dynamicStates[0]);
dynamics.pDynamicStates = dynamicStates;

 

마지막으로 공유 버퍼 등을 위한 파이프라인 레이아웃입니다. 약간 복잡해지므로 일단 비워 두고, 이후 글에서 다루겠습니다. 이건 멤버를 만들고 저장해 둡니다.

 

더보기
VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutInfo.setLayoutCount = 0;
pipelineLayoutInfo.pSetLayouts = nullptr;
pipelineLayoutInfo.pushConstantRangeCount = 0;
pipelineLayoutInfo.pPushConstantRanges = nullptr;
vkCreatePipelineLayout(device, &pipelineLayoutInfo, nullptr, &pipelineLayout0);

 

이게 파이프라인에 필요한 모든 구성이었습니다. 이 구조체 주소를 모두 넘겨 생성합니다.

 

더보기

지금까지 만든 것 외에, 렌더패스, 그 안의 서브패스 인덱스를 줍니다. 실제 사용하는 렌더패스 내 서브패스는 여기서 준 것과 호환되어야 합니다.

VkGraphicsPipelineCreateInfo pipelineInfo{};
pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
pipelineInfo.stageCount = sizeof(shaderStagesInfo) / sizeof(shaderStagesInfo[0]);
pipelineInfo.pStages = shaderStagesInfo;
pipelineInfo.pVertexInputState = &vertexInputInfo;
pipelineInfo.pInputAssemblyState = &inputAssemblyInfo;
pipelineInfo.pViewportState = &viewportStateInfo;
pipelineInfo.pRasterizationState = &rasterizerInfo;
pipelineInfo.pMultisampleState = &msInfo;
pipelineInfo.pDepthStencilState = nullptr;	// 보류
pipelineInfo.pColorBlendState = &colorBlendInfo;
pipelineInfo.pDynamicState = nullptr;	// 보류
pipelineInfo.layout = pipelineLayout0;
pipelineInfo.renderPass = renderPass0;
pipelineInfo.subpass = 0;
// 아래 2개: 기존 파이프라인을 기반으로 비슷하게 생성하기 위함
pipelineInfo.basePipelineHandle = VK_NULL_HANDLE;
pipelineInfo.basePipelineIndex = -1;

VkResult result = vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &pipeline0);

vkDestroyShaderModule(device, vertModule, nullptr);
vkDestroyShaderModule(device, fragModule, nullptr);
if (result != VK_SUCCESS) {
    fprintf(stderr, "Failed to create pipeline 0\n");
    return false;
}
return true;
basePipelineHandle과 basePipelineIndex는 기존 파이프라인 구조와 비슷하게 만들 때 주는 것이 좋다고 하는데, 일단 넘어갑니다. 

이것 말고 테셀레이션 상태도 줄 수 있는데, 이건 상대적으로 고급 주제이니 일단 넘어갑시다. 다시 돌아올지는 모르겠네요.

 

이 구조체가 꽤 복잡하네요. 일단 문서 링크만 남겨 두고, 삼각형으로 직진합니다. 다만 벌칸이라는 기술에 뜻이 있다면, 나중에라도 description 부분은 읽기를 추천합니다.

 

종료 시 해제 코드는 지금까지랑 똑같습니다.

void VkPlayer::destroyPipeline0() {
    vkDestroyPipelineLayout(device, pipelineLayout0, nullptr);
    vkDestroyPipeline(device, pipeline0, nullptr);
}

 

대충 2~300행 정도 추가했는데요, 컴파일 후 실행해 보세요. 정상적으로 되어야 합니다. 지금까지의 전체 코드는 여기에 있습니다. 셰이더 코드의 경우 링크에서 상위 폴더로 나가 bin 폴더를 찾으면 되겠습니다. 

 

아직 그림은 딱히 바꿀 거리가 없습니다. 서브패스가 사실 "파이프라인을 실행한다"가 되기 때문입니다.

 

2.8. 정점 버퍼

정점 버퍼는 GPU가 읽을 수 있는 메모리 공간에 보낸 데이터죠. 벌칸에서는 이것들 관리도 일일이 다 해야 해서, GL 할 때 glBufferdata() 한 번에 됐던 거랑은 차원이 다릅니다. 일단은 배열 하나만 메모리 맵으로 적도록 하고요, 이후에 인덱스 버퍼랑, 고정 메시에 대하여 더 나은 방법인, 스테이징 후 아주 보내버리는(?) 기술을 다루겠습니다. 코드를 확인하죠.

 

더보기

벌칸에서 각종 버퍼는 메모리를 가리키는 버퍼와 메모리 자체에 대한 핸들을 갖고 있어야 합니다. 일단 임시로 멤버로 주도록 합니다.

 

// VkPlayer.h
static VkBuffer vb; // 일시적인 고정 정점 버퍼
static VkDeviceMemory vbmem; // vb 메모리 핸들

static bool VkPlayer::createFixedVertexBuffer();
static void VkPlayer::destroyFixedVertexBuffer();

이것들은 조만간 일반화하면서 없앨 거예요. 일단 init()에서 생성, finalize()에서 해제를 하게 해 주세요.

일단 정점 데이터를 만들고,

Vertex ar[3]{
    {{-0.5,0.5,0},{1,0,0}},
    {{0.5,0.5,0},{0,1,0}},
    {{0,-0.5,0},{0,0,1}}
    };

버퍼 생성을 위한 info 구조체를 구성합니다. sharingMode를 빼면 GL과 닮은 면이 있죠. 그래픽스 큐 계열에서만 접근하면 되니까 VK_SHARING_MODE_EXCLUSIVE를 주면 됩니다.

VkBufferCreateInfo info{};
info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
info.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;
info.size = sizeof(ar);
info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

if (vkCreateBuffer(device, &info, nullptr, &vb) != VK_SUCCESS) {
    fprintf(stderr,"Failed to create fixed vertex buffer\n");
    return false;
}

그 다음 메모리 요구 사항을 조사해야 합니다. 장치에서 필요한 유형의 메모리를 할당한다고 보면 됩니다. 이는 다음 코드로 수행할 수 있습니다.

VkMemoryRequirements mreq;
vkGetBufferMemoryRequirements(device, vb, &mreq);

여기서 리턴한 값은 메모리 정렬(=시작 주소가 몇의 배수여야 한다), 실제로 필요한 크기(당연히 info.size 이상), 그리고 memoryTypeBits입니다. 이 memoryTypeBits는 그 자원에서 쓸 수 있는 메모리 유형을 비트로 나타낸 겁니다. 그러니까 저게 1인 비트 중에서 하나라도 지원하는 게 있으면 그걸 고르면 되겠습니다. 대체 i번 비트가 무엇인고 하면, 실제 물리 장치에서 지원하는 메모리 타입의 인덱스에 대응하는 겁니다. (참고)

장치에서 지원하는 메모리 타입은 vkGetPhysicalDeviceMemoryProperties 함수로 얻어낼 수 있습니다. 이건 여러 종류의 버퍼에 대하여 사용할 수 있으므로 별개의 함수로 분리합니다.

static uint32_t findMemorytype(uint32_t typeFilter, VkMemoryPropertyFlags props, VkPhysicalDevice card) {
    VkPhysicalDeviceMemoryProperties mprops;
    vkGetPhysicalDeviceMemoryProperties(card, &mprops);
    for (uint32_t i = 0; i < mprops.memoryTypeCount; i++) {
        if ((typeFilter & (1 << i)) && ((mprops.memoryTypes[i].propertyFlags & props) == props)) {
            return i;
        }
    }
    return -1;
}

이 함수를 이해하려면 VkPhysicalDeviceMemoryProperties 구조체를 알아야 하는데, 여기서 쓰고 있는 VkMemoryTypes에 집중해 봅시다. 저 멤버에서는 가능한 속성 플래그가 역시 비트로 명시되어 있습니다. VkMemoryPropertyFlags는 uint32_t와 같은 타입이므로, 주어진 모든 속성이 있어야 그 유형을 쓴다는 겁니다.

한편 VkMemoryTypes 말고 VkMemoryHeaps 멤버도 있는데, 이건 기회가 되면 돌아보겠습니다. 문서를 참고하세요.

이제 메모리를 할당해 봅시다.

VkMemoryAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = mreq.size;
allocInfo.memoryTypeIndex = findMemorytype(mreq.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, physicalDevice.card);

if (vkAllocateMemory(device, &allocInfo, nullptr, &vbmem) != VK_SUCCESS) {
    fprintf(stderr, "Failed to allocate memory for fixed vb\n");
    return false;
}

기본적으로 크기와 메모리 유형을 주면 됩니다. 우리가 원하는 메모리의 속성은, 여기 있는 값들을 통해 명시할 수 있습니다. HOST_VISIBLE의 경우 매핑이 가능하다는 거고, 또 매핑한 데이터를 버퍼 메모리로 바로 복사했음을 보장하기 위해 HOST_COHERENT_BIT를 줍니다. 후자의 경우 명시적으로 vkFlushMappedMemoryRanges와 vkInvalidateMappedMemoryRanges를 부르지 않아도 매핑할 때 캐시에서 메모리로 바로 넘겨 버리도록 해 준다고 합니다. 만약 여기까지 컴퓨터가 성공했다면, 할당한 메모리에 대하여 매핑을 시도해 볼 수 있습니다. GL도 이런 과정이 없진 않죠?

void* data;
if (vkMapMemory(device, vbmem, 0, info.size, 0, &data) != VK_SUCCESS) {
    fprintf(stderr, "Failed to map to allocated memory\n");
    return false;
}
memcpy(data, ar, info.size);
vkUnmapMemory(device, vbmem);
vkBindBufferMemory(device, vb, vbmem, 0);
return true;

코드를 잘 보면 딱히 크기 정보를 전달한 것 말고는 vb랑 vbmem이 관계가 없습니다. 그래서 vkBindBufferMemory를 부르면 됩니다. 이 함수는 마지막 매개변수로 오프셋을 받는데, 할당한 메모리에서 이 정점 버퍼가 가리키는 부분을 말합니다. 이거 하나 쓰니까 당연히 0이겠죠.

이제 정점 버퍼를 바인드하면 저쪽으로 넘겼던 배열 ar의 메모리를 셰이더에서 접근할 수 있을 겁니다.

해제는 둘 다 지금까지처럼 하면 되겠습니다.
void VkPlayer::destroyFixedVertexBuffer() {
    vkFreeMemory(device, vbmem, nullptr);
    vkDestroyBuffer(device, vb, nullptr);
}

 

자, 이제 그리는 것만 남았네요. 컴파일, 실행도 해 보고, 코드를 점검해 봅시다. (저 링크에는 지금 vkBindBufferMemory가 실수로 빠졌습니다.)

 

2.9. 그리기~!

하악 하악
헛소리해서 죄송합니다. 그냥 제가 튜토리얼에서 삼각형 그릴 때쯤 볼 때 기분을 과장해서 표현해 보고 싶었어요.

이젠 진짜 그리기 명령만 하면 삼각형한테 인사할 수 있습니다. 위의 상호작용 그림을 떠올려 보고, 코드로 다시 들어가 보자구요.

 

더보기

역시 일반화하기엔 너무 길어졌으니, 전용 함수를 또 마련해 줍시다.

// VkPlayer.h
static void fixedDraw();

// VkPlayer.cpp
void VkPlayer::mainLoop() {
    for (frame = 1; glfwWindowShouldClose(window) != GLFW_TRUE; frame++) {
        glfwPollEvents();
        static float prev = 0;
        tp = (float)glfwGetTime();
        dt = tp - prev;
        idt = 1.0f / dt;
        if ((frame & 15) == 0) printf("%f\r",idt);
        prev = tp;
        // loop body
        fixedDraw();
    }
}

정말 미안하지만, API는 동기화를 필수적으로 해야 해서 먼저 세마포어를 만들겠습니다. 괜찮습니다. 얘는 그냥 만들어져라~ 하면 만들어지는 앱니다. 멤버 주는 코드 같은 건 알아서 하실 수 있으니(아래 코드에서, fixedSp가 세마포어 객체 이름입니다.) 생략하고 진짜 이렇게만 하면 만들어집니다. 가상 장치보다는 뒤에 만들어야 합니다.

bool VkPlayer::createSemaphore() {
    VkSemaphoreCreateInfo info{};
    info.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
    if (vkCreateSemaphore(device, &info, nullptr, &fixedSp) != VK_SUCCESS) {
        fprintf(stderr,"Failed to create semaphore\n");
        return false;
    }
    return true;
}

일단 세마포어는 지금 통과할 수 없으면 재우고 통과할 수 있게 되면 깨우는 녀석이었죠. 그런 동작은 각종 명령을 통해서 시킬 수가 있습니다. 방법은 지금 만들 fixedDraw 함수를 만들어 가면서 알아봅시다. 동기화의 필요성은 앞서 잠깐 언급되었다시피 CPU가 명령을 주고 실행 끝나기까지 기다렸던 GL과 달리 넘기기만 하고 바로 리턴하기 때문이라고 했었죠. 이보다 더 자세한 설명은 이후의 글로 미룹니다.

다시, 위의 상호작용 그림을 떠올려 봅시다.

먼저 스왑체인에서 지금 작성할 수 있는 그림을 가져옵니다.

void VkPlayer::fixedDraw() {
    uint32_t imgIndex;
    VkResult result;
    if (vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, fixedSp, nullptr, &imgIndex) != VK_SUCCESS) {
        fprintf(stderr, "Fail 1\n");
        return;
    }

vkAcquireNextImageKHR의 3번째 매개변수는 최대 대기 시간(나노초)를 말하며 UINT64_MAX는 무제한 대기를 의미합니다. 4, 5번째 변수는 각각 이게 완료되면 신호를 줄 세마포어와 펜스입니다. 펜스도 동기화 객체인데, 역시 다른 글에서 설명하겠습니다. 저 2개가 모두 nullptr면 안 됩니다. 마지막 매개변수가 스왑 체인에서 가능하다고 알림받을 이미지의 번호가 들어갈 주소입니다.

그건 좀 이상하군. 세마포어 혹은 펜스를 통해 이미지를 얻었다는 신호를 준다고 하는데, 이미지 번호는 받아야 하잖아? 그럼 받아질 때까지 함수에서 블로킹되는 거야? 만약 그렇다면 명령을 먼저 올릴 수도 있으니 세마포어는 그렇다 치고 펜스는 받을 이유가 없잖아?
그래, 거기서 헷갈릴 수도 있지. 이미지 번호는 이미지가 준비되기 전에 받아올 수 있다는 모양이야. 때문에 저 함수 역시, 논블로킹에 가까운 것 같아. 여기를 참고해 봐.

 

그 다음 명령을 새로이 기록하기 위해 명령 버퍼를 비워야 합니다. 일단은 명령 풀을 리셋하는 걸로 하겠습니다. 리셋하면 큐에서 대기 중이 아닌 버퍼는 모두 새로 명령을 기록할 준비가 되며, 시작을 또 해야 합니다.

VkCommandBufferBeginInfo buffbegin{};
buffbegin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
buffbegin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
if (vkBeginCommandBuffer(commandBuffers[0], &buffbegin) != VK_SUCCESS) {
    fprintf(stderr, "Fail 2\n");
    return;
}

저 플래그는 꼭 줘야 할 건 아닙니다만 주면 아마 명령을 한 번 쓰고 비울 때 내부적 관리가 용이해질 겁니다. 플래그는 문서에서 더 확인할 수 있습니다.

다음은 렌더 패스를 시작하는 명령을 기록해야 합니다. 간단히 쓸 렌더 패스와 프레임버퍼, 그리고 직사각형 영역과 클리어 색상(쓴다고 했을 경우)을 주면 됩니다. 

VkRenderPassBeginInfo rpbegin{};
rpbegin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
rpbegin.renderPass = renderPass0;
rpbegin.framebuffer = endFramebuffers[imgIndex];
rpbegin.renderArea.offset = { 0,0 };
rpbegin.renderArea.extent = swapchainExtent;
VkClearValue clearColor = { 0.03f,0.03f,0.03f,1.0f };
rpbegin.clearValueCount = 1;
rpbegin.pClearValues = &clearColor;
vkCmdBeginRenderPass(commandBuffers[0], &rpbegin, VK_SUBPASS_CONTENTS_INLINE);

버퍼에다가 필요한 명령을 이어서 기록합니다. 생김새가 GL에서 시키던 거랑 비슷합니다. use로 셰이더 프로그램을 쓴다고 하고 정점 버퍼를 바인드하고 그리는 겁니다.

vkCmdBindPipeline(commandBuffers[0], VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline0);
const VkDeviceSize offsets[1] = { 0 };
vkCmdBindVertexBuffers(commandBuffers[0], 0, 1, &vb, offsets);
vkCmdDraw(commandBuffers[0], 3, 1, 0, 0);
vkCmdEndRenderPass(commandBuffers[0]);
if (vkEndCommandBuffer(commandBuffers[0]) != VK_SUCCESS) {
    fprintf(stderr, "Fail 3\n");
    return;
}

하지만 아직은 기록만 했습니다. 실제로 시키려면 큐에 내야 한다고 했었죠. VkSubmitInfo를 작성하면 됩니다.

VkPipelineStageFlags waitStages[] = { VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT };
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffers[0];
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = &fixedSp;
submitInfo.pWaitDstStageMask = waitStages;
if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, nullptr) != VK_SUCCESS) {
    fprintf(stderr, "Fail 4\n");
    return;
}

일단 첫 명령 버퍼에 싹 기록했으니 명령 버퍼에 관한 정보는 그대로 주면 됩니다. 세마포어 관련 변수는 이제 이것들을 실행하기 위해 기다릴 녀석으로, 위에서 스왑 체인 이미지 획득이 끝나면 쓸 수 있게 하기 위해 아까 그 세마포어를 넘기면 됩니다. 이 제출이 끝나면 다른 세마포어에 신호를 주는 것도 가능한데, 그것은 이후의 글에서 설명하겠습니다. waitStages는 같은 인덱스의 세마포어에서 어떤 동작이 대기해야 하는지를 명시합니다. 실제로 값을 쓰는 조각 셰이더에서 기다리면 되니 VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT만 주면 됩니다. 역시 더 자세한 부분은 가능하면 이후의 글에서 설명합니다.

일단은 그리기 명령이 끝나면 스왑체인에 제출하도록 합시다.

vkQueueWaitIdle(graphicsQueue); // 그리기 큐가 놀 때까지 기다리자..
VkPresentInfoKHR presentInfo{};
presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
presentInfo.pSwapchains = &swapchain;
presentInfo.swapchainCount = 1;
presentInfo.pImageIndices = &imgIndex;

if (vkQueuePresentKHR(presentQueue, &presentInfo) != VK_SUCCESS) {
    fprintf(stderr, "Fail 5\n");
    return;
}

vkQueuePresentKHR은 스왑 체인에서 창 표면으로 가진 주어진 인덱스의 이미지를 넘기란 말을 전달하고 있습니다.

마지막으로 vkQueueWaitIdle(presentQueue);를 써서 방금 시킨 제출이 끝날 때까지 기다리게 합니다.

 

제대로 따라온 게 맞다면 컴파일, 실행이 정상적으로 되고, 이렇게 보일 겁니다. 전체 소스는 여기 있습니다.

이제 코드만 똑바로 정리한다면 이후 과정의 난이도는 OpenGL보다 아주 조금 높은 수준으로 떨어질 겁니다. 칼을 뽑아서 무를 자르는 데 성공하셨나요? 사진에서는 FIFO 옵션이 적용되어 속도가 60fps로 제한이 걸리고 있습니다. MAILBOX를 적용하면 2800 정도 나오고 있네요.

 

요약

지금까지 벌칸 API를 이용하여 삼각형을 그리기 위해 필요한 것들을 알아보고 구현했습니다. 말이 C++ 프로그래밍이고 사실상 대부분 C식으로 짰습니다.

벌칸에서 삼각형을 그리려면 라이브러리로부터 인스턴스를 생성하여 그래픽 카드를 찾아 원하는 기능을 지원하는지 확인하고, 그것을 추상화한 가상 장치를 만든 다음 그 위에서 명령 풀과 버퍼, 그리고 렌더 패스와 파이프라인을 일일이 구성하여 만들었습니다. 추가로 프레임버퍼와 창 시스템을 연결하기 위해 스왑 체인을 구성하고 GLFW로 창 시스템에 연결했고, 프레임버퍼는 거기서 나오는 이미지 뷰를 참조하게 만들었습니다. GPU가 읽을 수 있는 곳으로 정점 데이터를 보내고 버퍼에 명령을 기록하고 큐에 올려 그려 보았습니다. 또 확인 계층이라는 것을 활성화하여 정상이 아닌 동작들을 일부 잡아줄 도우미를 얻었습니다.

 

앞으로의 글에는 깊이+스텐실 버퍼, 공유 버퍼(uniform buffer), 이들을 재사용하기 좋도록 모듈화/캡슐화하기, 인덱스 버퍼, 리사이즈 대응, 동기화 등 다양한 주제가 남았습니다. 현재 예상하기로, 개중에 이만큼 긴 글은 없을 겁니다. 이 글이 길었던 건(대충 58p 정도 분량이네요) 오직 칼을 뽑아서 무를 자르기를 원할 독자를 헤아린 행위였을 뿐입니다. 글이 하나면 조금 길어도 만만해 보이잖아요.

 

코드가 대체로 포인터를 주고, 함수를 통해 전역 혹은 힙 상의 모든 자원을 관리하게 되어 있죠. 실전 레벨의 C 라이브러리 결과물은 이런 식이 아주 많습니다. 익숙해진다면 수많은 고성능의 C 오픈 소스를 아주 자유자재로 쓸 수 있을 겁니다. 제가 딱히 경험이 많지는 않지만, 써 본 C 라이브러리, API들 중 이게 가장 어려웠네요. 이걸 쓰면서 뒤에서 돌아가는 과정이 대충 상상이 간다면, 다른 C 라이브러리는 진짜 무난히 쓸 수 있을 거라 봐요. (대체로 영어는 잘 해야 됨)

 

과제

일단은 성공한 기분을 만끽하며, 이것저것 시각적으로 영향이 있을 부분을 건드려 봅시다. 정점 데이터파이프라인 생성 시의 뷰포트/시저 구성, 렌더패스 시작 시의 렌더링 영역 설정, 조각 셰이더 등 프로그램을 깨지 않고 보이는 걸 다르게 할 수 있는 요소가 아주 많습니다.

 

혹시 보신 분들 중 질문이나 이해가 어려운 부분, 혹은 오류가 있으면 알려주시기 바랍니다. 감사합니다.