Vulkan - 5. 인덱스 버퍼와 스테이징 버퍼

2022. 6. 21. 10:55Vulkan

 

개요

대표적으로 삼각형 단위로 정점을 그릴 땐 인덱스 버퍼를 사용하여 동일한 데이터를 여러 번 사용하도록 할 수 있습니다. 아무래도 정점 셰이더에서 처리할 양도 줄일 수 있고 대부분의 경우 메모리도 크게 아낄 수 있을 테니 그래픽 렌더링을 한다면 인덱스 버퍼링을 기본으로 한다고 봐도 되겠죠. GL에서는 VBO와 IBO를 바인드하여 VAO를 만들고 glDrawElements로 그렸었는데, 벌칸에서는 이들을 어떻게 연관지을까요?

 

또, 지금 프로그램에서는 CPU에서 메모리 맵을 이용하여 GPU가 읽을 수 있는 위치에 값을 씁니다. 하지만 정점 버퍼는 많은 경우에 해제되기 전까지 데이터가 변할 일이 없고, GPU에서만 사용할 수 있는 곳에 데이터를 밀어넣는 방식으로 하여 효율을 올릴 수 있다고 합니다. 이 방법을 먼저 알아보고 인덱스 버퍼를 사용해 봅시다.

 

목차

1. 스테이징 버퍼

2. 인덱스 버퍼

요약

과제

 

본문

1. 스테이징 버퍼

스테이징 버퍼는 알렉산더 튜토리얼의 내용을 거의 그대로 사용합니다. GPU에서 쓰기에 가장 효율적인 메모리는 VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT를 통해 찾을 수 있다고 합니다. 

위에서 GPU만 사용할 수 있는 공간이라고 했지? 그런데 CPU에서 거기에 데이터를 작성하려고 하는 건 고양이 목에 방울 달기 아니냐?
버퍼 복사 명령을 쓴다고 하던데. 내부적인 건 모르겠다만 CPU랑 GPU가 모두 접근할 수 있는 메모리랑 GPU만 접근할 수 있는 메모리니까 GPU 명령을 통해 복사해 간다고 이해하면 되지 않을까?

버퍼 복사 명령은 전송 명령을 지원하는 큐가 필요합니다. 2번 글에서 얘기했듯 VK_QUEUE_TRANSFER_BIT은 그래픽스 명령을 지원하는 모든 큐 계열에서 지원하므로 별도의 검사가 필요하지 않습니다.

 

우선 기존에 쓰던 정점 버퍼 생성 코드는 복사하기 이전에 CPU에서 작성 가능한 버퍼로 사용하죠. 그래서 스테이징이라고 부르나 봅니다. (참고로 제가 들고 있는 스펙 파일은 1.3.204 버전에 대한 것이고 3598페이지짜리입니다만 staging이란 말 자체가 안 나옵니다) 일단 코드로 볼까요?

 

더보기

기존 코드에서 버퍼 포인터의 목적지를 지역 변수로 돌려 버립시다.

// VkPlayer::createFixedVertexBuffer
info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
VkBuffer vb; // 새로 추가
VkDeviceMemory vbmem; // 새로 추가
...

딱히 뒤의 코드를 안 고치고도 이렇게 지역변수로 덮어쓸 수 있습니다. 아시다시피 이렇게 덮어씌워진 경우 멤버변수는 this 포인터로 접근, 정적변수는 클래스 이름공간을 통해 접근하면 됩니다.

그 다음 용도가 이제는 정점 버퍼가 게 아니라 복사 목적이 되었죠. info.usage를 다음과 같이 수정합니다.

info.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;

이름을 읽어 보면 의미를 아시겠죠. 전송 출발지라는 용도입니다. 이제 지금까지의 코드와 비슷하게 실제로 사용할 버퍼를 생성해 줍시다. HOST_VISIBLE이 아닌 위의 DEVICE_LOCAL 속성을 명시하면 되겠죠?

if (vkBindBufferMemory(device, vb, vbmem, 0) != VK_SUCCESS) {
    fprintf(stderr, "Failed to bind buffer object and memory\n");
    return false;
}

// 여기부터 추가
info.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT;
if (vkCreateBuffer(device, &info, nullptr, &VkPlayer::vb) != VK_SUCCESS) {
    fprintf(stderr, "Failed to create fixed vertex buffer\n");
    return false;
}
vkGetBufferMemoryRequirements(device, VkPlayer::vb, &mreq);
allocInfo.allocationSize = mreq.size;
allocInfo.memoryTypeIndex = findMemorytype(mreq.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, physicalDevice.card);
if (vkAllocateMemory(device, &allocInfo, nullptr, &VkPlayer::vbmem) != VK_SUCCESS) {
    fprintf(stderr, "Failed to allocate memory for fixed vb\n");
    return false;
}
if (vkBindBufferMemory(device, VkPlayer::vb, VkPlayer::vbmem, 0) != VK_SUCCESS) {
    fprintf(stderr, "Failed to bind buffer object and memory\n");
    return false;
}

이제 로컬 vb에서 정적 vb로 버퍼를 복사만 하면 됩니다. 복사는 명령 버퍼에서 하면 됩니다. 만들어 둔 그리기용 명령 버퍼 중 아무 거나 써도 무리가 없지만 일회용에 맞춰 하나 새로 할당하고 쓴 다음 없애 봅시다. 위 코드에 이어 쓰면 되겠습니다.

VkCommandBufferAllocateInfo cmdInfo{};
cmdInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
cmdInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
cmdInfo.commandBufferCount = 1;
cmdInfo.commandPool = commandPool;

VkCommandBuffer copyBuffer;
if (vkAllocateCommandBuffers(device, &cmdInfo, &copyBuffer) != VK_SUCCESS) {
    fprintf(stderr,"Failed to allocate command buffer for copying vertex buffer\n");
    return false;
}
VkCommandBufferBeginInfo copyBegin{};
copyBegin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
copyBegin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
if (vkBeginCommandBuffer(copyBuffer, &copyBegin) != VK_SUCCESS) {
    fprintf(stderr, "Failed to begin command buffer for copying vertex buffer\n");
    return false;
}
VkBufferCopy copyRegion{};
copyRegion.size = sizeof(ar); // 오프셋 설정 가능
vkCmdCopyBuffer(copyBuffer, vb, VkPlayer::vb, 1, &copyRegion);
if (vkEndCommandBuffer(copyBuffer) != VK_SUCCESS) {
    fprintf(stderr, "Failed to end command buffer for copying vertex buffer\n");
    return false;
}
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &copyBuffer;
if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE) != VK_SUCCESS) {
    fprintf(stderr, "Failed to submit command buffer for copying vertex buffer\n");
    return false;
}
vkQueueWaitIdle(graphicsQueue);
vkFreeCommandBuffers(device, commandPool, 1, &copyBuffer);
vkDestroyBuffer(device, vb, nullptr);
vkFreeMemory(device, vbmem, nullptr);
return true;

복사하는 건 버퍼이기 때문에, 할당된 메모리의 양만큼 복사하는 게 프로그램이 깨질 정도의 오류를 보이진 않겠습니다만 buffer creation info에 명시한 만큼을 복사하는 게 맞다는 점에 주의하세요.

VkBufferCopy라는 정보 구조체는 출발지 복사 시작점 오프셋과 목적지 붙여넣기 시작점 오프셋을 받을 수 있습니다. 메모리 공간 하나를 여러 목적으로 사용한다면 이 값을 꼭 이용해야겠죠.

 

여기까지의 코드를 컴파일하고 실행하여 봅시다(알림: 2번째 vkGetBufferMemoryRequirements 호출의 인수는 vb가 아니라 VkPlayer::vb여야 합니다. 결과가 다를 일은 없겠지만 의미가 통하는 게 더 나은 코드겠죠). 오류 없이 기존과 같은 결과를 보여야 합니다. 

이제 GPU가 더 읽기 좋은 방식과 CPU에서 지속적으로 작성할 수 있는 방식으로 버퍼를 굴릴 수 있게 된 만큼 적절한 사용이 꼭 필요합니다.

 

2. 인덱스 버퍼

벌칸에서 정점 버퍼와 인덱스 버퍼를 만들 때의 차이는 GL에서 정점 버퍼와 인덱스 버퍼를 만들 때의 차이와 같이, 용도만 달리 명시하는 겁니다. 실제 사용할 때에는 정점 버퍼와 인덱스 버퍼를 직접 바인드하고 별개의 명령을 통해 그립니다. 그럼 다시 코드로 들어가 봅시다.

 

더보기

인덱스 버퍼 데이터는 16비트 비부호형 정수 혹은 32비트 비부호형 정수로 취급할 수 있습니다. 이것에 대한 명시는 바인드할 당시에 합니다. 일단 멤버부터 만들어 줍니다.

// VkPlayer.h
static VkBuffer vb, ib;
static VkDeviceMemory vbmem, ibmem;

static bool createFixedIndexBuffer();
static void destroyFixedIndexBuffer();

생성 및 해제 함수 호출은 정점 버퍼 전후에 해 주면 됩니다.

해제 함수는 똑같습니다.

// VkPlayer.cpp
void VkPlayer::destroyFixedIndexBuffer() {
    vkFreeMemory(device, ibmem, nullptr);
    vkDestroyBuffer(device, ib, nullptr);
}

인덱스 버퍼를 쓰는 김에 정점을 재활용하게 하는 모양을 만들어 보죠. 정점 4개로 삼각형 2개를 만들어 직사각형을 만들어 줄 겁니다.

// VkPlayer::createFixedVertexBuffer
Vertex ar[]{
    {{-0.5,0.5,0},{1,0,0}}, // 좌하: 기본 적색
    {{0.5,0.5,0},{0,1,0}},  // 우하: 기본 녹색
    {{0.5,-0.5,0},{0,0,1}}, // 우상: 기본 청색
    {{-0.5,-0.5,0},{1,1,1}} // 좌상: 기본 백색
};

여기서 반시계 방향으로 인덱스를 만들려면 인덱스는 [0, 1, 2, 0, 2, 3]이면 되겠군요. 다른 방법도 많지만 성능이라거나 하는 차이는 없다고 봐도 되겠죠. 버퍼 생성은 코드를 복사해 두고 차이점만 집어 보겠습니다. 결론부터 말하면 실 사용 메모리 상 버퍼의 usage를 VERTEX_BUFFER에서 INDEX_BUFFER로, 그리고 데이터 자체를 바꾸면 됩니다.

bool VkPlayer::createFixedIndexBuffer() {
    uint16_t ar[] = { 0,1,2,0,2,3 }; // 데이터
    
    VkBufferCreateInfo info{};
    info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
    info.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
    info.size = sizeof(ar);
    info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
    
    VkBuffer ib;
    VkDeviceMemory ibmem;
    
    if (vkCreateBuffer(device, &info, nullptr, &ib) != VK_SUCCESS) {
        fprintf(stderr, "Failed to create fixed index buffer\n");
        return false;
    }
    
    VkMemoryRequirements mreq;
    vkGetBufferMemoryRequirements(device, ib, &mreq);
    
    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, &ibmem) != VK_SUCCESS) {
        fprintf(stderr, "Failed to allocate memory for fixed ib\n");
        return false;
    }
    void* data;
    if (vkMapMemory(device, ibmem, 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, ibmem);
    if (vkBindBufferMemory(device, ib, ibmem, 0) != VK_SUCCESS) {
        fprintf(stderr, "Failed to bind buffer object and memory\n");
        return false;
    }
    
    info.usage = VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT; // 용도!!
    if (vkCreateBuffer(device, &info, nullptr, &VkPlayer::ib) != VK_SUCCESS) {
        fprintf(stderr, "Failed to create fixed index buffer\n");
        return false;
    }
    vkGetBufferMemoryRequirements(device, VkPlayer::ib, &mreq);
    allocInfo.allocationSize = mreq.size;
    allocInfo.memoryTypeIndex = findMemorytype(mreq.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, physicalDevice.card);
    if (vkAllocateMemory(device, &allocInfo, nullptr, &VkPlayer::ibmem) != VK_SUCCESS) {
        fprintf(stderr, "Failed to allocate memory for fixed ib\n");
        return false;
    }
    if (vkBindBufferMemory(device, VkPlayer::ib, VkPlayer::ibmem, 0) != VK_SUCCESS) {
        fprintf(stderr, "Failed to bind buffer object and memory\n");
        return false;
    }
    
    VkCommandBufferAllocateInfo cmdInfo{};
    cmdInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
    cmdInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
    cmdInfo.commandBufferCount = 1;
    cmdInfo.commandPool = commandPool;

    VkCommandBuffer copyBuffer;
    if (vkAllocateCommandBuffers(device, &cmdInfo, &copyBuffer) != VK_SUCCESS) {
        fprintf(stderr, "Failed to allocate command buffer for copying index buffer\n");
        return false;
    }
    VkCommandBufferBeginInfo copyBegin{};
    copyBegin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
    copyBegin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
    if (vkBeginCommandBuffer(copyBuffer, &copyBegin) != VK_SUCCESS) {
        fprintf(stderr, "Failed to begin command buffer for copying index buffer\n");
        return false;
    }
    VkBufferCopy copyRegion{};
    copyRegion.size = sizeof(ar);
    vkCmdCopyBuffer(copyBuffer, ib, VkPlayer::ib, 1, &copyRegion);
    if (vkEndCommandBuffer(copyBuffer) != VK_SUCCESS) {
        fprintf(stderr, "Failed to end command buffer for copying index buffer\n");
        return false;
    }
    VkSubmitInfo submitInfo{};
    submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
    submitInfo.commandBufferCount = 1;
    submitInfo.pCommandBuffers = &copyBuffer;
    if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE) != VK_SUCCESS) {
        fprintf(stderr, "Failed to submit command buffer for copying index buffer\n");
        return false;
    }
    vkQueueWaitIdle(graphicsQueue);
    vkFreeCommandBuffers(device, commandPool, 1, &copyBuffer);
    vkDestroyBuffer(device, ib, nullptr);
    vkFreeMemory(device, ibmem, nullptr);
    return true;
}

이제 그릴 때 바인드하면 됩니다.

// VkPlayer::fixedDraw
...
vkCmdBindVertexBuffers(commandBuffers[commandBufferNumber], 0, 1, &vb, offsets);
vkCmdBindIndexBuffer(commandBuffers[commandBufferNumber], ib, 0, VK_INDEX_TYPE_UINT16);
vkCmdBindDescriptorSets(commandBuffers[commandBufferNumber], VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout0, 0, 1, &ubset[commandBufferNumber], 0, nullptr);
vkCmdDrawIndexed(commandBuffers[commandBufferNumber], 6, 1, 0, 0, 0);
...

vkCmdBindVertexBuffers는 정점 버퍼의 배열을 바인드했지만(요컨대 여러 배열을 바인드하면 정점 속성을 같은 길이의 메시에서 돌려 쓸 수 있음..) 인덱스 버퍼는 한 개만을 바인드합니다. 명령 버퍼, 인덱스 버퍼, 버퍼 내의 바이트 오프셋, 인덱스 버퍼 데이터를 읽을 형식입니다.

인덱스 버퍼를 이용하여 어셈블할 경우 vkCmdDrawIndexed 명령을 사용합니다. 매개변수는 각각 명령 버퍼, 인덱스 수, 인스턴스 수, 인덱스 버퍼라는 배열 내의 시작 인덱스, 정점 배열에서 사용할 인덱스 번호, 인스턴스 번호입니다.

 

여기까지 했으면 실행했을 때 다음과 같은 결과를 보일 겁니다. 여기까지의 코드도 참고하세요.

 

야, 야, 뭐 잘못 그린 거 아니야? 중간에 저렇게 또렷하게 선이 보이는데..
그 부분은 감마, SRGB 공간이라는 개념을 알아야 설명할 수 있는 부분이야. 본론이 아닌데 아주 짧은 내용은 아니니까 잘 설명된 다른 자료로 대체할게. VkPlayer::createSwapchain()에서 VkSurfaceFormatKHR을 선택할 때 format을 VK_FORMAT_B8G8R8A8_UNORM 같은 걸 우선 선택하게 바꾸면 색 보간이 선형 공간 상에서 되는 걸 볼 수 있을 거야. 링크의 자료를 안 읽고 왔다면 아래 그림을 참고하자. 말단 색은 같지만 중간이 다르지? 비선형 보간을 쓰는 이유를 요약하자면 사람이 인식하는 빛 현실성 차이 때문이래.

 

왼쪽이 SRGB 공간 상의 보간 처리, 오른쪽이 선형 공간 상의 보간 처리. 자세히 보면 배경 색도 설정값은 같지만 보기에는 다릅니다.

참고로 진짜로 그래프가 밝기를 가리키는 건 아니고 채널 농도라고 표현하는 게 더 적절하겠습니다만 밝기라고 표현하면 그림이 바로 이해될 수 있지 않을까 하는 생각에 그렇게 했습니다. 어차피 녹색이랑 백색이 적색이랑 청색보다 밝게 느껴지잖아요.

 

요약

이번엔 한 게 몇 개 없습니다. 장치에게 있어 가장 효율적인 메모리 공간으로 버퍼를 복사하도록 하여 변할 일이 (거의) 없는 버퍼에 대하여 더 나은 실행 성능을 꾀했고, 혹자는 거기에 스테이징 버퍼라는 이름을 붙였습니다. 인덱스 버퍼를 만드는 것은 버퍼의 용도를 다르게 명시하고 16비트 혹은 32비트 비부호 정수를 넘긴 뒤, 정점 데이터와 함께 바인드하여 vkCmdDrawIndexed를 통해 그릴 수 있었습니다.

 

과제

인덱스 버퍼도 배웠겠다, createFixed... 함수에서 원 데이터를 만들어 그리게 해 봅시다. 웬만하면 triangle strip이나 triangle fan 같은 설정은 쓰지 말고 그냥 triangle로 만들어 보세요.

엥? 그럼 그냥 smoothstep 해 버리면 되는 거 아닌가?

과제에 누가 점수 주는 것도 아니긴 하지만 이번에 배운 걸 쓰세요..

 

감사합니다. 앞으로의 과정 중 순서가 정해진 것만 치자면 깊이/스텐실 버퍼 -> 텍스처 -> 파이프라인의 동적 요소 -> 모듈화 되겠습니다. (좋은 사용성을 위해, 모듈화를 하더라도 헤더 하나/소스 하나는 유지할 겁니다. 엔진 자체를 그렇게 끌고 갈 건 아니고 그래픽스에 직접 관련된 것만)

 

 

'Vulkan' 카테고리의 다른 글

Vulkan - 7. 텍스처  (0) 2022.06.23
Vulkan - 6. 깊이와 스텐실 버퍼  (0) 2022.06.21
Vulkan - 4. 공유(uniform) 변수  (0) 2022.06.16
Vulkan - 3. 세마포어와 펜스  (0) 2022.06.15
Vulkan - 2. 안녕 삼각형  (0) 2022.06.08