Vulkan - 11. 동적 공유(uniform) 버퍼

2022. 7. 2. 16:21Vulkan

 

개요

벌칸은 버퍼에 명령을 기록하고 한 번에 제출할 수 있으며, 제출하면 그래픽카드가 일을 하기 시작하지만 그와 관계 없이 함수는 리턴하며 CPU는 그 동안 다른 일을 할 수 있습니다. 이에 따라 공유 버퍼 하나만 가지고는 여러 draw 명령에 대하여 다른 uniform 값을 줄 수가 없었습니다. 명령 버퍼에서 값을 가지고 가는 푸시 상수라는 방법은 동기화 문제도 없고 효율적이지만 사실상 파이프라인당 128바이트 정도만 사용할 수 있습니다.

 

그렇기 때문에 4번 글에서는 물체별로 다른 값을 가지게 되며 개별 용량이 큰 bone structure 같은 케이스에 대한 공유 버퍼를 해결하지 못했습니다. 결국에는 그려야 할 개별 물체의 행렬들을 모두 기술자 안에 가지고 그것을 가리키는 방식으로 가야 하며, 그 방법으로는 물체마다 기술자를 만들어 그리기 전에 각각 바인드하거나 동적 공유 버퍼를 사용하며 그리기 전에 각각 오프셋을 명시하는 방법이 있습니다. 객체별 텍스처도 같은 이치로 생각할 수는 있습니다. (텍스처의 경우 동적 타입이 없어서 이치는 같아도 사용 방법은 약간 다릅니다. 다른 크기의 이미지 텍스처를 어떻게 그리기 직전에 바인드할지 궁금하면 이런 예시도 있다는 걸 일단은 구경만 해 봅시다.) 여기에서는 동적 공유 버퍼를 소개합니다.

 

여기여기에 적절한 샘플이 있습니다. 이 글도 저것을 참고합니다.

 

목차

1. 동적 공유 버퍼를 위한 기술자 집합

2. 기술자 집합에 오프셋을 주어 바인드하기

요약

 

본문

1. 동적 공유 버퍼를 위한 기술자 집합

일단 미리 적어 두자면 동적 공유 버퍼는 최소 8개를 사용할 수 있습니다. 그 이상을 원한다면 지원 여부를  vkGetPhysicalDeviceProperties에서 얻은 구조체로 properties.limits.maxDescriptorSetUniformBuffersDynamic 값을 확인해야 합니다.

 

이번에는 간단히 동적 공유 버퍼를 이용하여 지금까지 만든 2개 정사각형을 반대 방향으로 회전시키는 걸로 해 보겠습니다. 모델 행렬 정도는 푸시 상수가 더 효율적이라고 하긴 했지만 단지 실습 목적이니까요.

 

더보기

동적 공유 버퍼의 사용은 다음과 같이 이루어집니다.

  • 버퍼를 할당하여 하나의 큰 배열로 쓴다
  • 바이트 단위 오프셋(들)을 주면서 기술자 집합을 바인드

오프셋은 properties.limits.minUniformBufferOffsetAlignment의 배수여야 합니다(참고). 즉, 배열의 개별 원소의 크기는 minUniformBufferOffsetAlignment의 배수로 올림해야 합니다. 지금은 64바이트짜리인 4x4 float 행렬이 2개 필요하고, 저의 장치를 기준으로 해당 값은 256입니다. 그럼 각 원소는 256바이트를 차지하게 해야겠죠. 행렬 하나만 쓸 때는 꽤 낭비가 심하지만 실제 사용은 뼈 행렬 배열을 가지고 할 것이므로 괜찮을 겁니다.

// VkPlayer.h
static VkDeviceSize minUniformBufferOffset;

// VkPlayer::findPhysicalDevice
if (pd.card) { 
    physicalDevice = pd;
    extSupported[(size_t)OptionalEXT::ANISOTROPIC] = features.samplerAnisotropy;
    minUniformBufferOffset = properties.limits.minUniformBufferOffsetAlignment;
    return true;
}

상수가 아니라 alignas를 쓸 수가 없죠. 이런 식을 써 줍니다.

dynamicAlignment = 64; // 원소의 실제 크기
if(minUniformBufferOffset > 0){
    dynamicAlignment = (dynamicAlignment + minUniformBufferOffset - 1);
    dynamicAlignment -= dynamicAlignment % minUniformBufferOffset;
}
어지럽잖아. 심지어 위 링크에서 본 식이랑 약간 다르게 생겼네.
설명을 위해 dynamicAlignment를 D, minUniformBufferOffset을 M이라고 부르자.
M이 0이 아니면 D=aM+b (D, a, M, b는 음이 아닌 정수, b<M)라고 나타낼 수 있지. 이때 b=0이면 정렬은 aM(=D)이 될 거고 그 외에는 (a+1)M이 될 거겠지? (a+1)M+b-1은 b가 0이라면 (a+1)M보다 작을 거고 그 외에는 (a+1)M 이상일 거야. 이걸 M의 배수로 내리면 우리가 원하는 결과를 얻을 수 있겠지.
그럼 위 링크의 식은 minUniformBufferOffset이 2의 거듭제곱인 경우에만 맞겠네?
맞아. 사실 2의 거듭제곱이 아닌 경우는 없을 테지만 어차피 호출은 동적 버퍼당 한 번씩일 테니 성능에 영향이 거의 없어서 조금이나마 알아먹기 쉽게 작성한 거지.

만약 통째로 메모리 맵에 복사를 원하는 경우 정렬에 맞춘 동적 할당을 해야 하는데, 이는 플랫폼마다 쓸 수 있는 함수가 다릅니다. 그런데 마침 VMA에 aligned_alloc이 있네요? 그걸 쓴다면, 메모리를 복사하기 위한 기반은 이런 식으로 코드가 나오겠네요. 이걸 기반으로 다른 코드로 커버하는 게 가장 깔끔하겠죠. aligned alloc 없이 그냥 매핑된 메모리의 특정 오프셋에 접근해도 되겠지만 조금 지저분할 수 있습니다. 그런 부분은 나중으로 미루고 우리는 그냥 오프셋 접근으로 하겠습니다.

unsigned char* map1 = (unsigned char*)vma_aligned_alloc(dynamicAlignment, dynamicAlignment * BUFFER_LENGTH);
// vma_aligned_free(map1);

이 정보를 이용하여 버퍼와 기술자를 만들 수 있습니다. 기존의 모델 행렬을 가지고 있던 기술자를 동적 공유 버퍼로 바꿔 보겠습니다. 가장 먼저 버퍼 생성 부분에 저기 위의 코드를 추가해 줍니다.

// VkPlayer::createUniformBuffer
// 맨 위
VkDeviceSize dynamicAlignment = sizeof(float) * 16;
if (minUniformBufferOffset > 0) {
    dynamicAlignment = dynamicAlignment + minUniformBufferOffset - 1;
    dynamicAlignment -= dynamicAlignment % minUniformBufferOffset;
}

VkBufferCreateInfo info{};
info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
info.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT;
info.size = dynamicAlignment * 2; // 일단 사각형이 2개이므로
info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
...
결국 동적 공유 버퍼도 고정된 크기로 생성하는구나. 차이점은 바인드할 때 오프셋을 바꾸는 데 쓸 수 있다는 것뿐이겠네?
어쩔 수 없지. 내가 가장 권장하는건 std::vector를 쓰듯이 적절한 양을 예약해 두고 혹시나 넘어간다면 또 그만큼 충분한 양을 추가 할당하도록 해서 재생성하고 VkWriteDescriptorSet를 새로 작성하여 업데이트하는 거야. 그러니 최대한 아끼는 게 좋잖아. 예를 들자면, 우리가 이걸 하고 있는 주된 이유인 관절 애니메이션의 경우 모든 모델이 사용하지는 않지. 그 경우 그 모델은 저 배열에 관여하지 않는 게 좋겠지?

아무튼 이제 기술자 생성 부분을 수정해 보죠.

// VkPlayer::createDescriptorSet
...
uboBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC;
...
ubsize.type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC;
...
bufferInfo.range = dynamicAlignment;
...
descriptorWrite.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC;
...

bufferInfo.range는 한 개의 요소의 범위(크기)를 지정하는 겁니다. 즉 dynamicAlignment를 그대로 넘깁니다. 저도 이 스코프에 지금 dynamicAlignment가 없는 건 알고 있는데, 위에서 한 거랑 똑같이 만들어 주시면 됩니다. 그저 크기 맞추고 타입을 dynamic이라고 바꾼 게 끝입니다.

 

 

2. 기술자 집합에 오프셋을 주어 바인드하기

바인드하는 함수인 vkCmdBindDescriptorSets에서는 집합의 각 동적 기술자마다 동적 데이터의 오프셋을 요구합니다. 예를 들어 동적 기술자가 2개고 배열을 {0, 256}을 주었다면 동적 기술자 중 첫 번째의 경우 버퍼의 0바이트 위치, 두 번째는 256바이트 위치를 참조하게 됩니다.

 

더보기

일단 메모리 매핑을 해 둔 만큼 버퍼 업데이트는 쉽게 할 수 있습니다.

// VkPlayer::fixedDraw
...
float st = sinf(tp), ct = cosf(tp);
float asp = (float)rheight / rwid;
float rotation[16] = {
    ct*asp,st,0,0,
    -st*asp,ct,0,0,
    0,0,1,0,
    0,0,0,1
};
memcpy(ubmap[commandBufferNumber], rotation, sizeof(rotation));
rotation[1] *= -1;
rotation[4] *= -1;
memcpy((char*)ubmap[commandBufferNumber] + dynamicAlignment, rotation, sizeof(rotation));
...

그 다음 기술자 집합 바인드에서 오프셋을 넘기면 됩니다.

...
uint32_t dynamicOffs[] = { 0,0 };
vkCmdBindDescriptorSets(commandBuffers[commandBufferNumber], VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout0, 0, 2, bindDs, sizeof(dynamicOffs) / sizeof(dynamicOffs[0]), dynamicOffs);
float clr[4] = { 1.0f,0,0,1 };
vkCmdPushConstants(commandBuffers[commandBufferNumber], pipelineLayout0, VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, clr);
vkCmdDrawIndexed(commandBuffers[commandBufferNumber], 6, 1, 0, 0, 0);
dynamicOffs[0] = dynamicAlignment;
vkCmdBindDescriptorSets(commandBuffers[commandBufferNumber], VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout0, 0, 2, bindDs, sizeof(dynamicOffs) / sizeof(dynamicOffs[0]), dynamicOffs);
vkCmdPushConstants(commandBuffers[commandBufferNumber], pipelineLayout0, VK_SHADER_STAGE_FRAGMENT_BIT, 4, 4, clr);
vkCmdDrawIndexed(commandBuffers[commandBufferNumber], 6, 1, 0, 4, 0);
...
동적 디스크립터 하나당 오프셋 한 개라고 하지 않았던가? 동적 디스크립터는 둘 중 하나인데 왜 2개를 넘긴 거지?
(22.8.8 수정)지금 프로그램 상에 있는 동적 디스크립터는 2개가 맞다. 바인드한 2개 기술자 집합의 바인딩에 모두 동적 디스크립터 집합이 하나씩 있기 때문이지. 명령 버퍼 1개당 유니폼버퍼 한 개를 쓸 거고 텍스처 디스크립터는 전체에서 한 개만 쓴다면 두 집합을 한 레이아웃에 묶지 않는 게 당연히 효율적이겠지만, 전에 말한 것처럼 그저 잘못 만든 거야.

셰이더 코드는 바꿀 필요 없습니다.

 

제대로 했다면 오류 없이 이렇게 반대로 회전하고 있을 겁니다. 원하면 전체 코드를 참고하세요.

 

 

요약

동적 공유 버퍼는 하나의 큰 버퍼를 만들어 기술자 집합에서 동적 공유 버퍼임을 명시하고 그것을 참조한 뒤, 그리기 전마다 바인드할 때 오프셋을 주면 됩니다. 셰이더에서의 사용은 단일 공유 버퍼와 동일합니다. 주의할 사항은 동적 공유 버퍼의 각 요소 크기(정확히 말하자면 정렬)는 어떤 값의 배수일 필요가 있다는 것이고, 이 값은 그래픽 카드를 찾았을 때 볼 수 있는 limits에서 확인할 수 있습니다.

 

과제

생각나면 추가하겠습니다.

 

다음 글부터는 모듈화 및 설계를 하려고 했는데, 이 글을 쓰다 보니 역시 여러 텍스처는 따로 다루는 게 좋겠단 생각이 듭니다. 12번 글은 여러 텍스처를 그리기마다 따로/한 번의 그리기에 동시에 쓰기, 13번부터는 모듈화를 하려고 했는데, 앞으로 프로젝트는 새로운 카테고리를 쓰고 기본(현재) 카테고리는 기본 정보만 넣도록 하겠습니다.