[vk] Uniform buffers - Descriptor layout and buffer

2021. 8. 16. 20:05그래픽스/vk

1. Introduction

- 우리는 이제 임의의 속성을 각 정점의 vertex shader로 넘길수있다.

- 그러나 아직 전역 변수에 대해서는 다루지 않았다.

- 우리는 이 챕터에서 3D 그래픽스로 넘어갈것이며, 이에 필요한 model-view-projection 행렬에 대해 다룰것이다.

- 우리는 vertex data로 그것들을 포함시킬 수 있지만,

- transformation이 변경될때마다 vertex buffer를 업데이트 해야할 필요가 있기 때문에 메모리 낭비이다.

- transformation은 모든 단일 프레임마다 쉽게 변경될 수 있어야한다.

 

- 이것을 Vulkan에서 다룰 올바른 방법은 resource descriptors 을 사용하는것이다.

- descriptor는 shader들이 자유롭게 버퍼들과 이미지들같은 리소스에 접근할 수 있게 해주는 방법이다.

- 우리는 변환행렬을 포함한 버퍼를 설정해야하고

- descriptor를 통해 vertex shader가 그것들을 접근할 수 있도록 해야한다.

- descriptor의 사용은 세가지 파트로 구성된다.

  • Specify a descriptor layout during pipeline creation
  • Allocate a descriptor set from a descriptor pool
  • Bind the descriptor set during rendering

- descriptor layout 은 파이프라인에의해 접근되어지는 리소스의 타입을 지정해야한다.

  (render pass에서 접근할 attachments의 타입을 지정한것과 마찬가지로)

- descriptor set은 descriptors에의해 바운딩될  실제 buffer나 image 리소스를 지정한다.

  (framebuffer가 render pass attachments을 바인딩하기위해 실제 image views을 지정하는것과 마찬가지로)

- descriptor set은 vertex buffers 과 framebuffer와 마찬가지로 drawing commands에 바인딩된다.

 

- 많은 descriptors의 타입이 있지만, 이번 챕터에서는 우리는 uniform buffer objects(UBO)로 작업할것이다.

- 향후 챕터에서 다른 타입들을 볼것이고, 기본적인 프로세스는 같다.

                   

 

- vertex shader에 보내길 원하는 데이터가 c언어의 구조체 같이 있다고 가정해보자 

struct UniformBufferObject {
    glm::mat4 model;
    glm::mat4 view;
    glm::mat4 proj;
};

- 그럼  VkBuffer 를 통해 데이터를 옮길 수 있고,

- uniform buffer object descriptor를 통해 아래와 같은 vertex shader에 접근할 수 있다.

layout(binding = 0) uniform UniformBufferObject {
    mat4 model;
    mat4 view;
    mat4 proj;
} ubo;

void main() {
    gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 0.0, 1.0);
    fragColor = inColor;
}

- 우리는 이전 챕터에서 만든 사각형을 3D로 회전시키기 위해

- model, view and projection 행렬을 모든 프레임에 업데이트 할 것이다.

 

 

 

 

2. Vertex shader

- 기존의 vertex shader를 위 처럼 uniform buffer object를 포함하도록 수정해야한다.

- MVP transformations 에 친숙하다고 가정하고 진행할것이다.

  (만일 친숙하지 않다면 the resource 에서 첫번째 챕터에 언급된것을 보면된다.)

#version 450

layout(binding = 0) uniform UniformBufferObject {
    mat4 model;
    mat4 view;
    mat4 proj;
} ubo;

layout(location = 0) in vec2 inPosition;
layout(location = 1) in vec3 inColor;

layout(location = 0) out vec3 fragColor;

void main() {
    gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 0.0, 1.0);
    fragColor = inColor;
}

- uniform in out 선언의 순서는 중요하지 않다.

- binding 지시자는 attribute를 위한 location 지시자와 비슷하다. 

- 우리는 descriptor layout에서 이 binding을 참조할것이다.

- gl_Position 라인이 변환을 계산하여 절단 좌표계로 최종적인 위치를 계산한 결과값을 받는것으로 변경되었다.

 

- 2D 삼각형과 같지않게 절단좌표계의 마지막 요소는 아마 1 이 아닐 수 있다. 

- screen위의 normalized device coordinates 로 변환될때 나눗셈이 발생하는데

- 이것은 원근 분할로서 원근 투상(perspective projection)에서 사용되며

- 객체들이 가까울수록 멀리있는것보단 더 크게 보이도록하는 데 필수적이다. (원근감)

(Unlike the 2D triangles, the last component of the clip coordinates may not be 1, which will result in a division when converted to the final normalized device coordinates on the screen. This is used in perspective projection as the perspective division and is essential for making closer objects look larger than objects that are further away.)

 

 

 

 

3. Descriptor set layout

- 다음 스텝은 cpp 코드상에서  UBO를 정의하고, Vulkan에게 vertex shader에서 이 descriptor에 대해 알리는것이다.

struct UniformBufferObject {
    glm::mat4 model;
    glm::mat4 view;
    glm::mat4 proj;
};

- 셰이더에서 사용하는 데이터 타입과 매칭되는 GLM을 사용할 수 있다.

- 이 데이터는 셰이더가 예상하는 방식과 이진 호환가능하므로,

- 나중에 UniformBufferObject 에서 VkBuffermemcpy 를 사용하여 데이터를 복사할 수 있다.

 

- 우리는 파이프라인 생성을 위해 셰이더에서 사용되는 모든 descriptor binding 에 대한 세부정보를 제공해야한다.

  (모든 vertex attribute와 location 인덱스에 대해 한것처럼)

- 우리는 createDescriptorSetLayout 라는 새함수를 만들고 모든 정보를 정의할것이다.

- 이것은 파이프라인 생성에서 필요하기 때문에 파이프라인 생성 직전에생성해야한다. 

void initVulkan() {
    ...
    createDescriptorSetLayout();
    createGraphicsPipeline();
    ...
}

...

void createDescriptorSetLayout() {

}

 

 

 

3.1 VkDescriptorSetLayoutBinding

- 모든 바인딩은 VkDescriptorSetLayoutBinding 구조체를 통해 기술해야한다.

void createDescriptorSetLayout() {
    VkDescriptorSetLayoutBinding uboLayoutBinding{};
    uboLayoutBinding.binding = 0;
    uboLayoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
    uboLayoutBinding.descriptorCount = 1;
}

- binding : shader에서 binding 번호

- descriptorType : 현재는 uniform buffer object 타입을 사용

- 셰이더 변수는 uniform buffer object의 배열을 나타낼 수 있으며 descriptorCount 를 통해 배열의 크기를 명시할 수 있다.

- ex) 이것을 사용하여 skeletal animation의 skeleton에서 각각의 bones들의 변환을 명시할 수 있다.

- 튜토리얼에선 MVP transformation 은 단일 uniform buffer object이므로 1로 세팅한다.

uboLayoutBinding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;

- 우리는 또한 descriptor가 참조할 shader stages을 명시할 필요가 있다.

- 이 stageFlags 필드는 VkShaderStageFlagBits 값의 조합이거나 VK_SHADER_STAGE_ALL_GRAPHICS 값이 올 수 있다.

- 우리의 경우에는 vertex shader를 참조할것이다. 

uboLayoutBinding.pImmutableSamplers = nullptr; // Optional

- pImmutableSamplers 필드는 오직 descriptors과 관련된 image sampling 에만 관련되어있다.

- - - - 향후 챕터에서 보게될것이다. (지금은 기본값으로 설정)

 

 

 

 

3.2 VkDescriptorSetLayout

-  모든 descriptor 바인딩들은 단일 VkDescriptorSetLayout 객체로 결합되어진다.

- 새로운 클래스 멤버로 정의해야한다.

VkDescriptorSetLayout descriptorSetLayout;
VkPipelineLayout pipelineLayout;

- vkCreateDescriptorSetLayout 을 사용함으로써 생성할 수 있다.

- 이 함수는 간단하게 바인딩 배열을 참조하는 VkDescriptorSetLayoutCreateInfo 를 받는다.    

VkDescriptorSetLayoutCreateInfo layoutInfo{};
layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
layoutInfo.bindingCount = 1;
layoutInfo.pBindings = &uboLayoutBinding;

if (vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &descriptorSetLayout) != VK_SUCCESS) {
    throw std::runtime_error("failed to create descriptor set layout!");
}
    void createDescriptorSetLayout() {
        VkDescriptorSetLayoutBinding uboLayoutBinding{};
        uboLayoutBinding.binding = 0;
        uboLayoutBinding.descriptorCount = 1;
        uboLayoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
        uboLayoutBinding.pImmutableSamplers = nullptr;
        uboLayoutBinding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;

        VkDescriptorSetLayoutCreateInfo layoutInfo{};
        layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
        layoutInfo.bindingCount = 1;
        layoutInfo.pBindings = &uboLayoutBinding;

        if (vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &descriptorSetLayout) != VK_SUCCESS) {
            throw std::runtime_error("failed to create descriptor set layout!");
        }
    }

 

 

 

3.3 createGraphicsPipeline  

- 우리는 Vulkan에게 셰이더가 사용할 descriptors이 어느것인지 알려주기위해

- 파이프라인 생성동안 descriptor set layout을 기술하는것이 필요하다.

- 따라서 createGraphicsPipeline에 있는 VkPipelineLayoutCreateInfo

- 아래와 같이 layout object를 참조하도록 수정해야한다.

VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutInfo.setLayoutCount = 1;
pipelineLayoutInfo.pSetLayouts = &descriptorSetLayout;

- 하나의 descriptor set layouts이 이미  모든 바인딩들을 포함되었기 때문에

  (descriptorLayout에서 다수의 바인딩을 포함할 수 있기 때문에)

- 다수의 descriptor set layouts을 명시할 수 있는 이유가 궁금할 수 있을것이다.

- 그 이유는 다음 챕터에서, descriptor pools 과 descriptor sets에 대해 더 자세히 살펴볼것이다.

(이유는 sets을 여러개 만들고 한번에 바인딩하여 그림을 그릴 수 있기 때문이다)

 

 

- descriptor layout은 프로그램이 끝날때까지 새 그래픽 파이프라인이 생성될 수 있는동안 계속 유지해야하므로

- 프로그램 끝에서 제거해야한다.

void cleanup() {
    cleanupSwapChain();

    vkDestroyDescriptorSetLayout(device, descriptorSetLayout, nullptr);

    ...
}

 

 

 

 

4. Uniform buffer

- 다음 챕터에서 셰이더에 대한 UBO 데이터가 포함된 버퍼를 지정할것이다.

- 그이전에 먼저 이 버퍼를 생성해야한다.

- 우리는 새로운데이터를 uniform 버퍼로 매프레임마다 복사할것이다.

- 따라서 스테이징 버퍼를 생성하는것은 무의미하다. 

- 스테이징 버퍼를 사용하는것은 이 경우에서는 오버헤드를 야기시키고, 성능을 저하시킨다.

 

- 우리는 다수의 버퍼를 사용헤야한다.

- 왜냐하면 다수의 프레임들은 아마 동시에 in flight 상태일 수 있고,

- 이전 프레임이 여전히 읽고있는동안 다음 프레임을 준비하기 위해 버퍼를 업데이트하고 싶지 않기 때문

  (We should have multiple buffers, because multiple frames may be in flight at the same time and

  we don't want to update the buffer in preparation of the next frame while a previous one is still reading from it!)

- 우리는 frame 마다 또는 swap chain image마다 uniform buffer를 가질 수 있다

- 하지만 우리는 swap chain image 마다 가지고 있는 command buffer에서 uniform buffer를 참조해야하므로

- swapchain image마다 uniform buffer를 갖는것이 가장 합리적이다.

 

- 이를위해, 새로운 클래스 멤버인 uniformBuffers 와 uniformBuffersMemory 를 추가해야한다.

VkBuffer indexBuffer;
VkDeviceMemory indexBufferMemory;

std::vector<VkBuffer> uniformBuffers;
std::vector<VkDeviceMemory> uniformBuffersMemory;

- 비슷하게 새로운 함수 createUniformBuffers 를 추가하고, createIndexBuffer 이후에 호출하도록해야한다.  

void initVulkan() {
    ...
    createVertexBuffer();
    createIndexBuffer();
    createUniformBuffers();
    ...
}

...

void createUniformBuffers() {
    VkDeviceSize bufferSize = sizeof(UniformBufferObject);

    uniformBuffers.resize(swapChainImages.size());
    uniformBuffersMemory.resize(swapChainImages.size());

    for (size_t i = 0; i < swapChainImages.size(); i++) {
        createBuffer(bufferSize, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, uniformBuffers[i], uniformBuffersMemory[i]);
    }
}

- 매 프레임마다 새로운 transformation으로 uniform buffer를 업데이트하는 별도의 함수를 작성할것이다.

- 그래서 이 함수에서는 vkMapMemory 함수를 사용하지 않을것이다. (매번 매핑해야하니)

(We're going to write a separate function that updates the uniform buffer with a new transformation every frame, so there will be no vkMapMemory here.)

- uniform 데이터는 모든 draw call에서 사용되어져야한다.

- 그래서 이것을 포함하는 버퍼는 렌더링이 중단되었을 때 파괴되어져야한다.

- 이것은 swapchain images의 수에 의존하므로, recreation후 변경되어져야하기 때문에,

- cleanupSwapChain 함수에서 정리해줘야한다.

void cleanupSwapChain() {
    ...

    for (size_t i = 0; i < swapChainImages.size(); i++) {
        vkDestroyBuffer(device, uniformBuffers[i], nullptr);
        vkFreeMemory(device, uniformBuffersMemory[i], nullptr);
    }
}

- 또한 스왑체인을 재생성할때  recreateSwapChain 에서 uniform buffer또한 재생성해야함을 의미한다.

void recreateSwapChain() {
    ...

    createFramebuffers();
    createUniformBuffers();
    createCommandBuffers();
}

 

 

 

 

5. Updating uniform data

- 새로운 함수 updateUniformBuffer 를 만들고 drawFrame 에서 swapchain image를 획득한 직후 호출해줘야한다. 

void drawFrame() {
    ...

    uint32_t imageIndex;
    VkResult result = vkAcquireNextImageKHR(device, swapChain, UINT64_MAX, imageAvailableSemaphores[currentFrame], VK_NULL_HANDLE, &imageIndex);

    ...

    updateUniformBuffer(imageIndex);

    VkSubmitInfo submitInfo{};
    submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;

    ...
}

...

void updateUniformBuffer(uint32_t currentImage) {

}

- 이 함수는 매 프레임마다 도형을 회전시키도록 새로운 tranformation을 생성하게 만들것이다.

- 우리는 이런 함수를 구현하기 위해 두개의 새로운 헤더를 추가해야한다.

#define GLM_FORCE_RADIANS
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>

#include <chrono>

- glm/gtc/matrix_transform.hpp 헤더는 변환관련 함수를 사용할 수 있게 해준다

- - - - model : glm::rotate view : glm::lookAt  / projection: glm::perspective 등등

- GLM_FORCE_RADIANS 의 정의는 라디안을 매개변수로 받는다고 명시하는것이며, 혼란을 피하게해준다.

 

- chrono 는 standard library header로 정확한 time을 기록해주는 함수들을 제공해준다.

- 이를 사용하여 프레임 속도에 관계없이 도형이 초당 90도 회전하도록 할 수 있다.

void updateUniformBuffer(uint32_t currentImage) {
    static auto startTime = std::chrono::high_resolution_clock::now();

    auto currentTime = std::chrono::high_resolution_clock::now();
    float time = std::chrono::duration<float, std::chrono::seconds::period>(currentTime - startTime).count();
}

- updateUniformBuffer 함수는 렌더링이 floating point 정확도인 시간에서 시작하기 때문에

- 초단위로 게산하는 몇가지 로직으로 시작해야한다.

 

- 우리는 이제 uniform buffer object안에서 model, view, projection 변환을 정의해야한다.

- 모델 rotation은 간단히 z축 을 기준으로 time 변수를 사용하여 회전시킬것이다.

UniformBufferObject ubo{};
ubo.model = glm::rotate(glm::mat4(1.0f), time * glm::radians(90.0f), glm::vec3(0.0f, 0.0f, 1.0f));

- glm::rotate 은 기존의 transformation 과 회전할 각도, 그리고 회전축을 파라미터로 받는다.

- glm::mat4(1.0f) 는 항등행렬을 리턴하는 생성자이다.

- time * glm::radians(90.0f) 를 사용함으로써 초당 90도 회전하도록 하였다.

ubo.view = glm::lookAt(glm::vec3(2.0f, 2.0f, 2.0f), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f));

- view transformation 으로 45도 각도 위에서 사각형을 바라보도록한것

- glm::lookAt 은 eye position과 center position 그리고 up axis를 파라미터로 받는다. 

ubo.proj = glm::perspective(glm::radians(45.0f), swapChainExtent.width / (float) swapChainExtent.height, 0.1f, 10.0f);

-  perspective projection 은 45도의 수직시야로 설정하였고,

- 종횡비 (aspect ratio) 그리고 전방, 후방 뷰평면 (near and far view planes) 를 파라미터로 받는다.

- 종횡비를 계산하는것은 리사이즈 후에 새로운 크기에도 대응할 수 있으므로 current swap chain extent 를 사용해야함.

ubo.proj[1][1] *= -1;

- 또한 GLM은 OpenGL를 목적으로 설계되었으므로, 절단좌표계의 y축을 반전시켜야한다.

- 가장 쉬은 방법은 projection의 y축의 부호를 바꿔주는것이다.

- 그렇지 않으면, 이미지를 렌더링할때 상하반전해야한다.

 

- 모든 변환은 이제 정의되었으므로 uniform buffer object 의 데이터를 current uniform buffer에 복사해야한다.

- 이것은 staging 버퍼를 제외한 vertex buffers 와 같은 방식이다.

void* data;
vkMapMemory(device, uniformBuffersMemory[currentImage], 0, sizeof(ubo), 0, &data);
    memcpy(data, &ubo, sizeof(ubo));
vkUnmapMemory(device, uniformBuffersMemory[currentImage]);

- UBO를 사용하여 shader의 값들을 자주 변경하는 이러한 방식은 가장 효율적인 방법은 아니다.

- 조금더 효율적인 방법으론 작은 데이터 버퍼를 셰이더에 넘겨주는 push constants 가 있다.

- 향후 챕터에 볼 수 있을것이다.

 

- 다음 챕터에선 우리는 descriptor sets 을 살펴볼것이며, 실제  VkBuffer를  uniform buffer descriptors에 바인딩할것이다.

- 이를통해 셰이더는 transformation data에 접근할 수 있다.

 

 

- 아래는 perspective volume 에 대한 그림

https://www.youtube.com/watch?v=rvJHkYnAR3w&list=PL8327DO66nu9qYVKLDmdLW_84-yE4auCR&index=18

 

C++ code / Vertex shader / Fragment shader

 

https://vulkan-tutorial.com/Uniform_buffers/Descriptor_layout_and_buffer