[vk] Vertex buffers - Staging buffer

2021. 8. 14. 03:22그래픽스/vk

1. Introduction

- vertex buffer는 지금 올바르게 작동하지만

- 현재 memory type은 CPU에 접근 가능하게 해주지만,

- 그래픽 카드 자체에서 읽을 수 있는 가장 최적의 memory type이 아닐 수 있다, 

- 가장 최적의 메모리는 VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT  플래그이며

- 이것은 일반적인 방법으론 전용 그래픽카드(dedicated graphics card)를 사용하는 CPU에서 접근불가능하다

- 이번 챕터에서는 두개의 vertex buffer를 만들것이다.

- 하나는 staging buffer로 vertex 배열 데이터를 메모리로 업로드할 수 있는 CPU 접근가능한 메모리이며

- 하나는 vertex buffer로 device local memory안에 있는것이다.

- 그다음 buffer copy command를 사용하여 staging buffer 에서 실제 vertex buffer로 데이터를 옮길것이다.

 

 

 

 

2. Transfer queue

- buffer copy command 는 transfer operation을 지원하는 queue family가 필요하다. (VK_QUEUE_TRANSFER_BIT)

- 좋은 소식은 VK_QUEUE_GRAPHICS_BIT 또는 VK_QUEUE_COMPUTE_BIT 를 가지고 있는 queue family는 

- 암시적으로 VK_QUEUE_TRANSFER_BIT 연산자를 사용할 수 있다.

- 그러므로 queueFlags 안의 리스트를 추가 안해줘도 된다.

 

- transfer operations 를 위한 다른 queue family를 사용하려면

- 아래와 같이 프로그램을 수정할 필요가 있다.

  • Modify QueueFamilyIndices and findQueueFamilies to explicitly look for a queue family with the VK_QUEUE_TRANSFER_BIT bit, but not the VK_QUEUE_GRAPHICS_BIT.
  • Modify createLogicalDevice to request a handle to the transfer queue
  • Create a second command pool for command buffers that are submitted on the transfer queue family
  • Change the sharingMode of resources to be VK_SHARING_MODE_CONCURRENT and specify both the graphics and transfer queue families
  • Submit any transfer commands like vkCmdCopyBuffer (which we'll be using in this chapter) to the transfer queue instead of the graphics queue

- 많은 작업을 해야하지만, queue families 사이간 리소스의 공유하는 방법에 대해 알 수 있는 기회가 될것이다.

 

 

 

 

3. Abstracting buffer creation

- 여러개의 버퍼들을 생성해야하니, 버퍼 생성을 helper function으로 옮기는것이 좋다.

- 새로운 함수인 createBuffer 를 생성하고, createVertexBuffer 의 코드를 매핑부분을 제외하고 옮겨야한다.  

void createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, VkMemoryPropertyFlags properties, VkBuffer& buffer, VkDeviceMemory& bufferMemory) {
    VkBufferCreateInfo bufferInfo{};
    bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
    bufferInfo.size = size;
    bufferInfo.usage = usage;
    bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

    if (vkCreateBuffer(device, &bufferInfo, nullptr, &buffer) != VK_SUCCESS) {
        throw std::runtime_error("failed to create buffer!");
    }

    VkMemoryRequirements memRequirements;
    vkGetBufferMemoryRequirements(device, buffer, &memRequirements);

    VkMemoryAllocateInfo allocInfo{};
    allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
    allocInfo.allocationSize = memRequirements.size;
    allocInfo.memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, properties);

    if (vkAllocateMemory(device, &allocInfo, nullptr, &bufferMemory) != VK_SUCCESS) {
        throw std::runtime_error("failed to allocate buffer memory!");
    }

    vkBindBufferMemory(device, buffer, bufferMemory, 0);
}

- 파라미터를 추가하여 (the buffer size, memory properties and usage)

- 이 함수를 다른 타입의 버퍼 또한 생성 가능하게 만든것이다.

- 마지막 두 파라미터는 핸들로 쓸 출력 변수이다.

 

- 이제 createVertexBuffer 에서 createBuffer 함수를 호출하도록 고쳐야한다.

void createVertexBuffer() {
    VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();
    createBuffer(bufferSize, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, vertexBuffer, vertexBufferMemory);

    void* data;
    vkMapMemory(device, vertexBufferMemory, 0, bufferSize, 0, &data);
        memcpy(data, vertices.data(), (size_t) bufferSize);
    vkUnmapMemory(device, vertexBufferMemory);

- 프로그램을 실행하여 vertex buffer가 확실히 작동하는지 체크해보자/

 

 

 

 

4. Using a staging buffer

- 우리는 이제 createVertexBuffer 를 변경하여, host visible buffer를 임시 버퍼로 사용할것이다. 

- 그리고 device local 을 실제 vertex buffer로 사용할것이다.

void createVertexBuffer() {
    VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();

    VkBuffer stagingBuffer;
    VkDeviceMemory stagingBufferMemory;
    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer, stagingBufferMemory);

    void* data;
    vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0, &data);
        memcpy(data, vertices.data(), (size_t) bufferSize);
    vkUnmapMemory(device, stagingBufferMemory);

    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, vertexBuffer, vertexBufferMemory);
}

- 우리는 이제 매핑을 위한 stagingBufferMemory 과 함께 새로운 stagingBuffer 를 사용할것이다.

- 그리고 vertex data를 복사할것이다.

- 그리고 다음과같은 두개의 새로운 buffer usage flags를 사용할것이다.

  • VK_BUFFER_USAGE_TRANSFER_SRC_BIT: Buffer can be used as source in a memory transfer operation.
  • VK_BUFFER_USAGE_TRANSFER_DST_BIT: Buffer can be used as destination in a memory transfer operation.

- vertexBuffer 는 이제 device local 의 메모리타입으로 할당되어진다.

- 이것의 일반적인 의미는 vkMapMemory 을 사용할 수 없다는것이다.

- 그러나 stagingBuffer 에서 vertexBuffer 로 데이터를 복사할 수 있다.

- transfer source 플래그로 stagingBuffer를 설정하고, transfer destination flag를 vertexBuffer로 설정하는것으로

- 전송한다는것을 알려야한다.

 

 

 

 

4.1 copyBuffer

- 이제 하나의 버퍼에서 다른 버퍼로 내용물을 복사하는 함수 copyBuffer 를 만들어보자

void copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize size) {

}

 

- 메모리 전송 연산은 commandbuffers를 사용함으로서 실행되어진다. (drawing commands처럼)

- 그러므로 우리는 첫번째로 임시 command buffer를 생성해야한다.

- 구현에서 메모리 할당 최적화를 적용할 수 있기 때문에 이러한 단기 버퍼에 대해 별도의 command pool을 생성하고싶을 수 도 있다.
- 이런 경우엔 command pool 생성동안에 VK_COMMAND_POOL_CREATE_TRANSIENT_BIT 플래그를 사용해야한다.
 (튜토리얼에서는 별도로 생성하지 않음)
void copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize size) {
    VkCommandBufferAllocateInfo allocInfo{};
    allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
    allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
    allocInfo.commandPool = commandPool;
    allocInfo.commandBufferCount = 1;

    VkCommandBuffer commandBuffer;
    vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
    
}
if (vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer) != VK_SUCCESS) {
    throw std::runtime_error("failed to allocate command buffers!");
}

-  즉시 command buffer로 기록을 시작한다.

VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;

vkBeginCommandBuffer(commandBuffer, &beginInfo);
if (vkBeginCommandBuffer(commandBuffers[i], &beginInfo) != VK_SUCCESS) {
    throw std::runtime_error("failed to begin recording command buffer!");
}

- command buffer를 한번만 사용하고, 복사 작업이 끝날때까지 기다려야한다.

- VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT를 사용한것은 드라이버한테 우리의 의도를 말하는 것이다. 

VkBufferCopy copyRegion{};
copyRegion.srcOffset = 0; // Optional
copyRegion.dstOffset = 0; // Optional
copyRegion.size = size;
vkCmdCopyBuffer(commandBuffer, srcBuffer, dstBuffer, 1, &copyRegion);

- 버퍼들의 내용물들은  vkCmdCopyBuffer 명령어를 사용하여 옮길수있다.

- 이것은 source 와 destination 버퍼들을 인자로 받아 처리하며, VkBufferCopy 에 정의된 영역을 복사한다.

- VkBufferCopy 구조체는source buffer offset과 destination buffer offset 그리고 size로 구성되어있다.

- 여기서는 vkMapMemory 와는 다르게  VK_WHOLE_SIZE 를 사용하지 못한다. 

vkEndCommandBuffer(commandBuffer);
if (vkEndCommandBuffer(commandBuffers[i]) != VK_SUCCESS) {
    throw std::runtime_error("failed to record command buffer!");
}

- 이러한 command buffer는 오직 copy command 만 있기 때문에 바로 종료해야한다.

- 이제 command buffer가 실행하여 전송을 완수하게 해야한다.

VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;

vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE);
vkQueueWaitIdle(graphicsQueue);

 

if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE) != VK_SUCCESS) {
    throw std::runtime_error("failed to submit draw command buffer!");
}

- draw commands과 같지않게 여기에서는 대기할 이벤트가 없다.

- 그저 버퍼에서 즉시 전송을 실행되기를 원한다.

- 전송이 완료될때까지 대기시킬 방법이 두개가 있다.

- 1) fence를 사용하여  vkWaitForFences 를 사용하여 대기하는 방법

- 2) vkQueueWaitIdle를 사용하여 transfer queue 가 idle 상태가 될때까지 기다리는것

- fence를 사용하면 한번에 하나씩 실행하는 대신

- 여러 전송을 동시에 스케쥴링할 수 있으며 전부 완료될때까지 대기해야한다.

- 이런 방식을 사용하여 드라이버에게 좀더 많은 최적화 기회를 줄 수 있다.

vkFreeCommandBuffers(device, commandPool, 1, &commandBuffer);

- 그리고 transfer operation을 마치고 command buffer를 해제하는것을 잊지말아야한다.

 

 

 

 

4.2 createVertexBuffer

- 이제 createVertexBuffer 함수에서 copyBuffer 를 호출하여 vertex data를 device local buffer로 옮길 수 있다. 

createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, vertexBuffer, vertexBufferMemory);

copyBuffer(stagingBuffer, vertexBuffer, bufferSize);

- 데이터를 staging buffer에서 device buffer까지 복사를 한 후에 버퍼를 제거하고 메모리를 해제해줘야한다.

    ...

    copyBuffer(stagingBuffer, vertexBuffer, bufferSize);

    vkDestroyBuffer(device, stagingBuffer, nullptr);
    vkFreeMemory(device, stagingBufferMemory, nullptr);
}

- 프로그램을 실행하면, 친숙한 삼각형을 다시 볼 수 있을것이다.

- 이러한 성능향상은 지금당장 눈으로 보이지 않지만

- vertex data는 이제 높은 성능의 메모리를 사용하여 로드되어지고 있다.

- 이러한 고성능 메모리의 사용은 복잡한 도형을 렌더링을 시작할 때 더 중요해진다.

 

 

 

 

5. Conclusion

- 실제 응용프로그램에서는, 튜토리얼에서  vkAllocateMemory 를 모든 개별 버퍼에서 사용한것처럼, 버퍼를 할당하면 안된다.

- 최대 동시에 메모리를 할당할 수 있는 개수는 physical device limit인 maxMemoryAllocationCount 에 의해 제한되어져있다. (대부분 4096 보다 작지만 하이엔드 하드웨어는 이보다 크다)

- 많은 객체에 동시에 메모리를 할당하는 올바른 방법은 custom allocator를 만들어서 하나의 할당을 많은 다른 객체들한테 offset 파라미터를 사용하여 여러개로 쪼개어 나눠주는것이다.

 

- 이러한 allocator는 직접 구현하거나 the GPUOpen initiative 에서 제공하는 VulkanMemoryAllocator 라이브러리를 사용할 수 도 있다.

- 하지만 튜토리얼에서는 모든 리소스를 개별적으로 할당해도 상관은 없다.

  (제한된 개수만큼 리소스를 할당하지 않기때문)

 

 

 

C++ code / Vertex shader / Fragment shader

 

https://vulkan-tutorial.com/Vertex_buffers/Staging_buffer

 

 

댓글들
1) command buffer는 자동으로 해제되는데 명시한 이유
- 이후에 사용하지 않기 때문에 바로 삭제하는것이 좋음
- command pool은 프로그램 종료시 해제되니 이 버퍼는 계속 유지됨

2) "In a real-world application, you're not supposed to actually call vkAllocateMemory for every individual buffer." 에 대한 물음
- 최대 할당 가능한 개수만큼 graphical objects을 생성할 수 있기때문에, 사용할때 주의해야함.


3) 
Antoine Morrier의 댓글
Hello
I disagree with "Change the sharingMode of resources to be VK_SHARING_MODE_CONCURRENT and specify both the graphics and transfer queue families"
In our case, the buffer will be used only by the graphicQueue, or the transferQueue, not both at the same time.
So, instead of using VK_SHARING_MODE_CONCURRENT, it should be better to use a memory barrier to release ownership from the graphicQueue to the transferQueue, make the copy, and release ownership from the transferQueue to the graphicQueue.

Am I correct?

- Yes, that is an alternative solution. I just didn't want to introduce memory barriers until the texture mapping tutorial.