2022. 6. 23. 09:35ㆍVulkan
개요
그래픽스 프로그램에서 텍스처는 모델의 표면 색을 결정하는 데 아주 많이 사용되는 방식입니다. 이걸 하면 사실상 게임 개발에 꼭 필요한 마지막 조각을 맞춘 셈이 되겠죠. GL에서는 텍스처 객체를 생성한 뒤에 glActiveTexture로 바인드할 샘플러를 정하고 텍스처를 바인드, 그리고 샘플러 번호는 uniform 정수로 보냈었습니다. 벌칸에서는 지난 시간에 설명한 자원 기술자를 사용해야 하는데요, 일반 2D 이미지 텍스처와 큐브맵 텍스처를 쓰는 방법을 자세하게 알아봅시다.
이번 글에서는 밉맵을 직접 다루지 않겠습니다. (이후의 글에서 다룰 가능성은 있습니다.) KTX 형식에서 밉맵이 생성되어 있는 텍스처를 불러오는(자주 사용됨) 방법은 여기를, 이미지를 불러온 후 블릿으로 밉맵을 생성하는 방법은 여기나 여기를 참고해 주세요.
목차
3. 샘플러
4. 샘플러 쓰기
본문
1. 이미지 데이터를 준비하기
이 부분은 벌칸과는 관련이 없습니다. 자주 사용되는 압축 형식의 이미지 데이터를 픽셀의 배열로 불러오는 방법을 이미 알고 계신다면 넘어가도 좋습니다.
오늘날 이미지는 대부분 한 픽셀 한 픽셀이 24 혹은 32비트로 표현됩니다. 용량이 제법 되겠죠. 그래서 여러 종류의 압축 방식이 통용됩니다. 대표적으로 PNG라는 압축 방식이 있죠. 그냥 저기 한번 들어가서 사양을 보면 알겠지만 아주 복잡합니다. 이런 녀석이 몇 종류씩이나 있지만, 우리는 응용 개발자를 위한 개발을 하고 있으므로 대놓고 BMP같은 비압축만 받아서 내장 시 기본적으로 메모리를 엄청 먹게 하는 게 아닌 이상(단, 바이너리 내장 방식이 아니면 사실 메모리 사용량은 큰 차이가 없을 것) 한 종류만 지원해도 무리는 없는 게 사실입니다. 하지만 한 종류도 꽤 버겁죠.
그러니 여기로 가서 stb_image.h를 받아 주세요. 함수 하나를 호출하는 것만으로 압축된 이미지를 일련의 픽셀 데이터로 변환해 줍니다. 앞서 말한 것처럼 한 종류의 형식만 쓴다면 더 좋은 성능을 위해 이것도 고려는 해 볼 수 있긴 한데, 여기서 그 사용법을 안내하진 않을 겁니다.
이건 단순히 헤더 파일만 포함하여 사용할 수 있습니다. 아래의 글을 펼쳐서 사용법과 주의사항을 확인해 봅시다.
저의 경우 다른 외부 라이브러리처럼 리포지토리의 externals에 이것을 포함했습니다. VkPlayer.cpp에 이 문장을 추가해 주세요.
// VkPlayer.cpp
#define STB_IMAGE_IMPLEMENTATION
#include "externals/stb_image.h"
#define STB_IMAGE_IMPLEMENTATION 지시문이 있어야 함수의 정의부가 활성화됩니다. 헤더에 함수 정의까지 다 포함되어 있는 라이브러리를 #include 지시문으로 포함해서 쓰는 경우에는, 중복 정의에 의한 링커 오류가 발생하는 것을 막기 위해 다음을 지켜야 합니다.
- 함수 본문이 정의되는 조건에 해당하는, 위의 STB_IMAGE_IMPLEMENTATION과 같은 문장을 #include문보다 위에 써야 합니다.
- 이를 여러 소스에 포함할 경우에는 STB_IMAGE_IMPLEMENTATION은 한 군데에서만 써야 합니다.
- 여러 곳에서 사용되거나 그럴 가능성이 있는 다른 헤더에 이걸 포함할 거라면 저 #define문은 그 헤더에는 쓰면 안 됩니다.
여기 와서 위 3개에 대한 이유를 모르면 곤란합니다. 혹시 모른다면 링킹이랑 #include문의 의미를 다시 알아보고 오세요. 별 차이는 안 나지만 이것 때문에 저는 헤더 하나만 있는 라이브러리보다는 헤더 하나 소스 하나가 있는 라이브러리가 더 쓰기 편하네요. (헤더에 인라인 함수만 있는 라이브러리라면 그냥 써도 됩니다. 그 경우 가능한 컴파일러의 '전역 최적화' 기능을 활성화시켜 주세요)
아니면 위의 코드 내용을 stb_image.cpp라는 파일로 만들어 빌드 대상에 추가하고 다른 데에는 헤더만 포함해도 됩니다.
이제 전역 함수 2개를 만들어 줍니다. 하나는 파일로부터 픽셀 데이터를 읽어오는 거고, 다른 하나는 변수로부터 픽셀 데이터를 읽어오는 겁니다.
// VkPlayer.cpp
inline static unsigned char* readImageData(const unsigned char* data, size_t len, int* width, int* height, int* nChannels) {
unsigned char* pix = stbi_load_from_memory(data, len, width, height, nChannels, 4);
return pix;
}
inline static unsigned char* readImageFile(const char* file, int* width, int* height, int* nChannels) {
unsigned char* pix = stbi_load(file, width, height, nChannels, 4);
return pix;
}
이게 전부입니다. 단, GPU 메모리로 이걸 집어넣고 나면 리턴한 저 포인터에 대한 메모리는 free로 해제해야 합니다. (delete가 아니라 stdlib의 free만 됩니다) 주어진 포인터 width, height, nChannels는 당연히 읽은 이미지의 크기와 색상 채널 수겠죠.
stbi_load의 리턴 픽셀 데이터는 기본적으로 채널당 8비트인 값이 순서대로 배열된 모양새입니다. 요컨대 R8G8B8A8이죠. 매개변수 중 마지막은 리턴을 원하는 채널 수입니다. 샘플러에서 형식을 버퍼에 맞춰야 하므로 4로 해 주세요. 예를 들어 이미지에 RGB만 저장된 경우 저걸 4로 주면 A값으로 0xff를 채워서 주는 식입니다. 0으로 주면 원본에 있는 것만 씁니다.
별로 중요한 건 아니지만 제 글에서는 텍스처로 이 2개의 이미지를 쓰겠습니다. 여러분은 마음에 드는 그림을 알아서 쓰세요.
![]() |
![]() |
2. 장치로 이미지 데이터 올리기
공유 버퍼를 만들 때 했던 것처럼 이번에는 텍스처 데이터를 담아서 그걸 기술해야 합니다. 이번에는 VkBuffer 말고 더 좋은 성능으로 데이터를 맞출 수 있게 하는 VkImage를 씁니다.
멤버를 만들어요. 뷰까지 필요합니다. 생성/해제의 호출 시점은 비슷한 의미의 공유 버퍼 근처로 하면 되겠습니다.
// VkPlayer.h
static VkImage tex0;
static VkDeviceMemory texmem0;
static VkImageView texview0;
static bool createTex0();
static void destroyTex0();
이미지 생성은 전에 깊이 버퍼 할 때 했었죠. 똑같이 하면 됩니다.
// VkPlayer::createTex0
int w, h, ch;
unsigned char* pix = readImageFile("no1.png", &w, &h, &ch);
if (!pix) {
fprintf(stderr, "Failed to read image file\n");
return false;
}
VkImageCreateInfo imgInfo{};
imgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
imgInfo.imageType = VK_IMAGE_TYPE_2D;
imgInfo.extent.width = (uint32_t)w;
imgInfo.extent.height = (uint32_t)h;
imgInfo.extent.depth = 1;
imgInfo.mipLevels = 1;
imgInfo.arrayLayers = 1;
imgInfo.format = VK_FORMAT_R8G8B8A8_SRGB;
imgInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
imgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
imgInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
imgInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
imgInfo.samples = VK_SAMPLE_COUNT_1_BIT;
if (vkCreateImage(device, &imgInfo, nullptr, &tex0) != VK_SUCCESS) {
fprintf(stderr, "Failed to create texture image object\n");
free(pix);
return false;
}
저는 명시적으로 해제하겠지만 여기서 pix를 위한 유니크 포인터를 쓰는 건 좋은 습관이겠죠.
format의 색 공간(주로 SRGB, UNORM 중 하나)은 색 버퍼랑 같은 걸로 입력해 주세요. 눈여겨볼 만한 점은 initialLayout이 UNDEFINED라는 겁니다. 버퍼를 이용하여 저 이미지로 복사하면서 변환할 것이기 때문입니다. (그렇게 하지 않는 경우 이미지 차원에 따라 행 피치가 어긋날 수 있습니다. 원본 데이터의 피치를 장치 사양에 따라 일일이 조절하는 것보다 이 방법이 훨씬 편하고 빠릅니다.)
이제 장치 메모리를 필요한 만큼 할당하고 이미지 객체와 바인드합니다.
VkMemoryRequirements mreq;
vkGetImageMemoryRequirements(device, tex0, &mreq);
VkMemoryAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = mreq.size;
allocInfo.memoryTypeIndex = findMemorytype(mreq.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, physicalDevice.card);
if (vkAllocateMemory(device, &allocInfo, nullptr, &texmem0) != VK_SUCCESS) {
fprintf(stderr, "Failed to allocate memory for texture image\n");
free(pix);
return false;
}
if (vkBindImageMemory(device, tex0, texmem0, 0) != VK_SUCCESS) {
fprintf(stderr, "Failed to bind texture image and device memory\n");
free(pix);
return false;
}
달리 설명할 내용은 없습니다. 이미지 데이터를 실시간으로 바꿀 게 아니니 DEVICE_LOCAL_BIT을 씁니다. 이제 정점 버퍼 때 한 것처럼 아까 받아온 이미지 데이터를 변환(transition)하면서 저기로 옮길 겁니다. 스테이징 버퍼를 만들어 주고 픽셀 데이터를 씁시다.
VkBufferCreateInfo info{};
info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
info.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
info.size = w * h * 4;
info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
VkBuffer temp;
VkDeviceMemory tempMem;
if (vkCreateBuffer(device, &info, nullptr, &temp) != VK_SUCCESS) {
fprintf(stderr, "Failed to create temporary buffer for texture image\n");
free(pix);
return false;
}
vkGetBufferMemoryRequirements(device, temp, &mreq);
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, &tempMem) != VK_SUCCESS) {
fprintf(stderr, "Failed to allocate memory for temporary buffer for texture image\n");
free(pix);
return false;
}
void* data;
if (vkMapMemory(device, tempMem, 0, info.size, 0, &data) != VK_SUCCESS) {
fprintf(stderr, "Failed to map to allocated memory\n");
free(pix);
return false;
}
memcpy(data, pix, info.size);
free(pix);
vkUnmapMemory(device, tempMem);
if (vkBindBufferMemory(device, temp, tempMem, 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, ©Buffer) != VK_SUCCESS) {
fprintf(stderr, "Failed to allocate command buffer for copying texture image\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, ©Begin) != VK_SUCCESS) {
fprintf(stderr, "Failed to begin command buffer for copying texture image\n");
return false;
}
여기부터는 약간 다릅니다. 비슷하게 해도 돌아가게 만들 수는 있습니다만 성능 최적화를 위해서이니 참고 계속해 봅시다. 그러니까 방금 메모리를 올린 스테이징 버퍼로부터 장치 로컬 메모리로 옮기면서 2회의 변환이 필요합니다. 이미지 레이아웃 전환의 경우 이미지 메모리 장벽을 통해서 가능하다고 되어 있지만 사양 문서를 보면 딱히 그 외의 전환 방법이 강조되어 있지 않습니다.
우선 방금 장치 로컬 메모리에 올려 둔 이미지가 이미지 전달의 목적지에 맞게 형식을 변환하기 위한 장벽을 세팅합니다.
VkImageMemoryBarrier barrier{};
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
barrier.image = tex0;
barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
barrier.subresourceRange.baseArrayLayer = 0;
barrier.subresourceRange.baseMipLevel = 0;
barrier.subresourceRange.layerCount = 1;
barrier.subresourceRange.levelCount = 1;
barrier.srcAccessMask = 0;
barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
vkCmdPipelineBarrier(copyBuffer, VK_PIPELINE_STAGE_HOST_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier);
여기서는 접근 마스크의 경우 dstAccessMask의 VK_ACCESS_TRANSFER_WRITE_BIT만 주면 됩니다. 이후의 이 이미지에 대한 GPU 접근 명령이 이 transfer 명령의 write보다 뒤에만 이루어질 수 있다는 뜻입니다. 말 뜻을 모르겠으면 여길 면밀히 읽어야 하는데, 꼭 완전히 이해를 하지 못해도 이 목적을 위해서는 큰 지장은 없을 겁니다. 비등방성 필터링은 나중에 알아보고 subresourceRange는 배열 0번, 밉 수준 기본으로 세팅합시다.
vkCmdPipelineBarrier는 올릴 버퍼, 선수 단계와 후수 단계, 의존성 플래그, 그리고 3쌍의 장벽 수/장벽 배열을 입력받습니다. 장벽 입력 중 이미지 장벽은 3번째에 입력하면 됩니다.
그래, TRANSFER_BIT은 이 이미지 형식 전환을 하는 이미지 장벽 자체의 후수 단계인 걸 알겠는데 선수 단계는 무슨 뜻이지? |
||
CPU측에서 장치 메모리로 읽거나 쓰는 단계로 실제 단계는 아니래. 다만 딱히 전 명령이 없기도 하고 쓸 데이터는 앞에서 이미 쓰기 완료했으니 VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT으로 해도 달라지는 게 없는 것 같다. |
이 명령을 하면 장치에 올려 둔 이미지 객체의 형식이 알맞게 변경됩니다. 그 다음 버퍼에서 이미지로의 복사 명령입니다.
VkBufferImageCopy copyRegion{};
copyRegion.bufferRowLength = 0;
copyRegion.bufferImageHeight = 0;
copyRegion.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
copyRegion.imageSubresource.mipLevel = 0;
copyRegion.imageSubresource.baseArrayLayer = 0;
copyRegion.imageSubresource.layerCount = 1;
copyRegion.imageOffset = { 0,0,0 };
copyRegion.imageExtent = imgInfo.extent;
vkCmdCopyBufferToImage(copyBuffer, temp, tex0, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ©Region);
비등방성 필터링을 만드는 건 나중으로 미뤄 줍시다.
- bufferRowLength와 bufferImageHeight 멤버는 행 혹은 열 간 패딩의 양을 뜻합니다. 버퍼에 올린 것은 stbi_load로부터의 데이터이며 이는 기본적으로 패딩이 없습니다. 0을 주세요.
- imageSubresource의 경우 보이는 바와 같이 복사할 요소(색/깊이/스텐실), 복사할 밉 수준, 배열 길이를 정합니다.
- imageOffset, imageExtent은 양 측의 데이터에서의 x, y, z 오프셋과 크기를 나타냅니다.
이제 이미지의 실제 데이터가 올라갔으니 그것을 셰이더에서 읽기 좋게 변환합니다. 방금 쓴 장벽 정보에서 다음만 수정하여 줍시다. 그리고 제출하면 됩니다.
barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
vkCmdPipelineBarrier(copyBuffer, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier);
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = ©Buffer;
if (vkEndCommandBuffer(copyBuffer) != VK_SUCCESS) {
fprintf(stderr, "Failed to end command buffer for copying texture data\n");
return false;
}
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, ©Buffer);
vkDestroyBuffer(device, temp, nullptr);
vkFreeMemory(device, tempMem, nullptr);
이번에는 각 속성이 무슨 의미인지 코드를 읽어 보면 바로 이해가 되죠? 마지막으로 뷰를 만들고 끝냅니다.
VkImageViewCreateInfo viewInfo{};
viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
viewInfo.image = tex0;
viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
viewInfo.format = VK_FORMAT_B8G8R8A8_SRGB;
viewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
viewInfo.subresourceRange.baseArrayLayer = 0;
viewInfo.subresourceRange.baseMipLevel = 0;
viewInfo.subresourceRange.layerCount = 1;
viewInfo.subresourceRange.levelCount = 1;
viewInfo.components = { VK_COMPONENT_SWIZZLE_IDENTITY,VK_COMPONENT_SWIZZLE_IDENTITY ,VK_COMPONENT_SWIZZLE_IDENTITY ,VK_COMPONENT_SWIZZLE_IDENTITY };
if (vkCreateImageView(device, &viewInfo, nullptr, &texview0) != VK_SUCCESS) {
fprintf(stderr, "Failed to create image view for texture\n");
return false;
}
return true;
이제 해제 함수는 어렵지 않죠?
void VkPlayer::destroyTex0() {
vkDestroyImageView(device, texview0, nullptr);
vkFreeMemory(device, texmem0, nullptr);
vkDestroyImage(device, tex0, nullptr);
}
지금 되어 있는 건 일단 장치에서 사용할 수 있는 메모리에서, 셰이더에서 샘플링하기 최적화된 배열로 여러분의 이미지를 넣은 겁니다. 컴파일하고 실행해 보세요. 변화는 당연히 없겠지만 별다른 문제도 없어야 합니다. 여러분의 이미지를 프로그램 파일 자리에 넣었는지도 꼭 확인해 주세요. 여기까지의 코드입니다(이미지 2개도 동봉되어 있습니다).
3. 샘플러
샘플러는 텍스처 이미지와 좌표를 가지고 그곳의 색상을 얻어오는 역할을 합니다. 그런데 단순히 이미지 배열에서 오프셋을 가지고 값을 읽는 것보다는 많은 일을 한다구요. 직접 구성해야 합니다. 코드로 만들어 봅시다.
샘플러에 관한 멤버를 따로 만들어 줍시다.
// VkPlayer.h
static VkSampler sampler0;
static VkDescriptorSet samplerSet[];
static bool createSampler0();
static void destroySampler0();
생성 함수는 0번 텍스처가 준비된 후에 호출하면 됩니다.
샘플러를 만들려면 먼저 이미지 뷰를 준비해야 합니다.
// VkPlayer::createSampler0
subresourceRange의 경우, 앞의 이미지 생성 시 한 것과 똑같이 넣어 주세요.
샘플러를 만들기 위한 정보를 구성해 봅시다.
VkSamplerCreateInfo samplerInfo{};
samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER;
samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER;
samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER;
samplerInfo.magFilter = VK_FILTER_LINEAR;
samplerInfo.minFilter = VK_FILTER_LINEAR;
samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR;
samplerInfo.mipLodBias = 0.0f;
samplerInfo.compareEnable = VK_FALSE;
samplerInfo.compareOp = VK_COMPARE_OP_NEVER;
samplerInfo.minLod = 0.0f;
samplerInfo.maxLod = 1.0f;
samplerInfo.unnormalizedCoordinates = VK_FALSE;
samplerInfo.anisotropyEnable = VK_FALSE;
samplerInfo.maxAnisotropy = 1.0;
samplerInfo.anisotropyEnable = VK_FALSE;
samplerInfo.borderColor = VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK;
if (vkCreateSampler(device, &samplerInfo, nullptr, &sampler0) != VK_SUCCESS) {
fprintf(stderr, "Failed to create sampler\n");
return false;
}
멤버를 하나씩 살펴봅시다.
- addressMode: 각각 x, y, z 방향 텍스처 좌표의 샘플링 관련된 것입니다. 경계를 넘어가면: 반복, 반전 반복, 경계값으로 컷, 고정값 등 옵션이 있습니다. 여길 참고하세요.
- magFilter, minFilter: GL에서 본 거랑 똑같습니다. 기본적으로 선형보간과 최근접 이웃이 있고 별도의 옵션은 확장을 활성화해야 합니다.
- mipmapMode: 위 2개와 비슷하게 선형, 최근접 이웃이 끝입니다.
- mipLodBias: 기본 밉 수준을 설정합니다. LOD는 level of detail의 약자입니다.
- minLod, maxLod: 계산된 밉 수준을 컷하는 용도입니다.
- borderColor: 색을 컷하는 용도입니다.
- unnormalizedCoordinates: FALSE인 경우 텍스처 좌표 범위는 0~1 범위이며 TRUE인 경우 0~텍스처 크기 범위입니다. 이 용도에서는 false로 고정이겠죠.
- compareEnable, compareOp: 여기를 참고하세요. 이 목적에서는 안 씁니다. 이후 그림자 맵 등에서 사용할 수도 있습니다.
- anisotropyEnable, maxAnisotropy: 비등방성 필터링을 위한 조건입니다. 지금은 넘어갑니다.
여기서 컴파일해서 별 문제가 없어야 합니다. 샘플러 객체는 이미지와 직접 붙어서 생성되는 게 아니라 여러 이미지에 대하여 같이 사용될 수 있는 것입니다. 이를 사용하려면 공유 버퍼처럼 기술자 집합을 만들어야 합니다. createDescriptorSet 함수로 이동해서 새 바인딩을 추가해 줍시다. 기술자 타입이 COMBINED_IMAGE_SAMPLER가 됐죠. 여기선 일단 쓸 샘플러 수만큼만 만들어 줍니다. 버퍼마다 다른 값을 줬던 공유 버퍼랑 달리 여기선 같은 이미지 갖고 읽으면 됩니다.
// VkPlayer::createDescriptorSet
VkDescriptorSetLayoutBinding dsBindings[2] = {};
VkDescriptorSetLayoutBinding& uboBinding=dsBindings[0];
VkDescriptorSetLayoutBinding& samplerBinding = dsBindings[1];
uboBinding.binding = 0;
uboBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
uboBinding.descriptorCount = 1;
uboBinding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; // VK_SHADER_STAGE_ALL_GRAPHICS
samplerBinding.binding = 1;
samplerBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
samplerBinding.descriptorCount = 1;
uboBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
VkDescriptorSetLayoutCreateInfo info{};
info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
info.bindingCount = sizeof(dsBindings) / sizeof(dsBindings[0]);
info.pBindings = dsBindings;
if (vkCreateDescriptorSetLayout(device, &info, nullptr, &ubds) != VK_SUCCESS) {
fprintf(stderr,"Failed to create descriptor set layout for uniform buffer\n");
return false;
}
할당할 풀 사양도 아래와 같이 바꿔야 합니다.
VkDescriptorPoolSize sizes[2] = {};
VkDescriptorPoolSize& ubsize = sizes[0];
VkDescriptorPoolSize& smsize = sizes[1];
ubsize.type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
ubsize.descriptorCount = COMMANDBUFFER_COUNT;
smsize.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
smsize.descriptorCount = 1;
VkDescriptorPoolCreateInfo dpinfo{};
dpinfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
dpinfo.poolSizeCount = sizeof(sizes) / sizeof(sizes[0]);
dpinfo.pPoolSizes = sizes;
dpinfo.maxSets = 0;
for (int i = 0; i < dpinfo.poolSizeCount; i++) {
dpinfo.maxSets += dpinfo.pPoolSizes[i].descriptorCount;
}
이제 기술자 집합을 별도로 만들고 샘플러, 이미지 뷰를 연결해 줍시다.
// return true 바로 위에
std::vector<VkDescriptorSetLayout> samplerLayouts(sizeof(samplerSet) / sizeof(samplerSet[0]), ubds);
setInfo.descriptorSetCount = samplerLayouts.size();
setInfo.pSetLayouts = samplerLayouts.data();
if (vkAllocateDescriptorSets(device, &setInfo, samplerSet) != VK_SUCCESS) {
fprintf(stderr, "Failed to allocate descriptor set for sampler\n");
return false;
}
for (size_t i = 0; i < sizeof(samplerSet) / sizeof(samplerSet[0]); i++) {
VkDescriptorImageInfo imageInfo{};
imageInfo.imageView = texview0;
imageInfo.sampler = sampler0;
imageInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
VkWriteDescriptorSet descriptorWrite{};
descriptorWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descriptorWrite.dstSet = samplerSet[i];
descriptorWrite.dstBinding = 1;
descriptorWrite.dstArrayElement = 0;
descriptorWrite.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
descriptorWrite.descriptorCount = 1;
descriptorWrite.pImageInfo = &imageInfo;
vkUpdateDescriptorSets(device, 1, &descriptorWrite, 0, nullptr);
}
이러면 또 전에 공유 버퍼 때 조잘거린 거랑 다를 바가 없잖아. 여러 종류의 텍스처 쓸 거면 또 CPU에서 넘겨야 하니까 계속 그리고 기다리길 반복하거나 필요한 텍스처마다 디스크립터를 만들라는 거냐? | ||
알아보는 중이야... 일단 기본기만 알았으니 넘어가자. |
사실 이 부분은 딱히 이렇다 할 모범 사례가 없는 모양입니다. 대체로 기술자를 텍스처 수만큼 준비해서 배열의 인덱스를 보내서 쓰는 모습을 보이고 있네요. 푸시 상수처럼 푸시 기술자 역시 확장 상에 존재는 합니다만, 지원하지 않는 장치가 꽤 있는 모양이니 되도록 피하겠습니다.
해제는 그냥 하면 됩니다.
void VkPlayer::destroySampler0() {
vkDestroySampler(device, sampler0, nullptr);
}
컴파일하고 실행해 봅시다. 이상 없어야 합니다. 필요하면 여기까지 코드를 확인해 보세요.
4. 샘플러 쓰기
사용은 공유 버퍼 때와 똑같이 기술자 집합을 바인드하면 됩니다.
일단 텍스처 좌표를 위해 정점 형식을 조금 바꿔 주겠습니다. 셰이더랑 정점 데이터를 수정하면 되겠죠.
struct Vertex { // 임시
float pos[3];
float tc[2]; // 이름을 바꿔서 vertex attribute의 offsetof에 빨간 줄이 생길 테니 수정해 주세요. 포맷도 R32G32로 바꿔 주시고요.
};
// tri.vert
#version 450
// #extension GL_KHR_vulkan_glsl: enable
layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec2 tc;
layout(location = 0) out vec2 texc;
layout(std140, binding = 0) uniform UBO{mat4 rotation;} ubo;
void main() {
gl_Position = ubo.rotation*vec4(inPosition, 1.0);
texc=tc;
}
// tri.frag
#version 450
layout(early_fragment_tests) in;
layout(location = 0) in vec2 texc;
layout(location = 0) out vec4 outColor;
layout(std140, push_constant) uniform ui{
vec4 color;
};
void main() {
outColor = color*vec4(texc, 0.0, 1.0);
}
GL에서는 좌측 하단의 텍스처 정규 좌표가 (0, 0)이었지만 벌칸을 포함한 나머지에서는 좌측 상단이 (0, 0)입니다.
Vertex ar[]{
{{-0.5,0.5,0.99},{0,1}}, // 좌하
{{0.5,0.5,0.99},{1,1}}, // 우하
{{0.5,-0.5,0.99},{1,0}}, // 우상
{{-0.5,-0.5,0.99},{0,0}}, // 좌상
{{-1,1,0.9},{0,1}},
{{1,1,0.9},{1,1}},
{{1,-1,0.9},{1,0}},
{{-1,-1,0.9},{0,0}}
};
지금까지 쭉 따라와서 프로그램을 돌린다면, 가운데 정사각형은 여전히 빨강-검정 그라데이션이, 바깥쪽은 각 모퉁이에 시계방향으로 빨강, 노랑, 초록, 검정이 있어야 합니다.
이제 기술자 집합을 바인드합니다. vkCmdBindDescriptorSets에서 원래 ubset[commandBufferNumber] 하나를 주고 있던 걸, 새 배열을 만들어 2개를 주도록 해 줍시다.
VkDescriptorSet bindDs[] = { ubset[commandBufferNumber],samplerSet[0] };
vkCmdBindDescriptorSets(commandBuffers[commandBufferNumber], VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout0, 0, 2, bindDs, 0, nullptr);
그런데 사실 여러 세트를 바인드하려면 같은 종류라도 기술자 레이아웃 자체를 둘로 늘려야 합니다. 네, 앞에서 잘못 시작한 거 맞습니다. 공유 버퍼랑 달리 샘플러 버퍼를 하나만 쓴다면 기술자 집합 레이아웃 자체를 따로 만들었어야 했는데.. 괜찮습니다. 앞으로 모듈화하면서 싹 간단해질 거고 공유버퍼도 한 개로 줄 것 같아서요.
// VkPlayer::createPipeline0
VkDescriptorSetLayout setlayouts[2] = { ubds,ubds };
VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutInfo.setLayoutCount = 2;
pipelineLayoutInfo.pSetLayouts = setlayouts;
pipelineLayoutInfo.pushConstantRangeCount = 1;
pipelineLayoutInfo.pPushConstantRanges = &pushRange;
vkCreatePipelineLayout(device, &pipelineLayoutInfo, nullptr, &pipelineLayout0);
이제 조각 셰이더에 이렇게 샘플러를 위한 유니폼을 추가하고 쓰게 합니다. 앞의 정점 셰이더에서는 웬만하면 공유 버퍼의 set을 0으로 세팅해 주세요.
layout(set = 1, binding = 1) uniform sampler2D tex;
void main() {
outColor = color*texture(tex, texc);
}
컴파일하고 실행해 봅시다. 일단 아래와 같이 나오면 정상입니다. 시작할 때 사진이 정방향인지 잘 확인해 보세요.
전체 코드도 필요하면 확인해 보세요. (셰이더 코드는 bin에)
5. 비등방성 필터링 확장 기능
앞에서 굳이 설명은 안 하고 지나왔습니다만, 비등방성 필터링은 밉맵과 비슷한 것인데 밉맵은 가로/세로가 반씩 작은 이미지를 저장한다면 비등방성 필터링은 가로/세로 중 하나만 반씩 작은 이미지도 각각 저장하여 길쭉한 메시에 대하여 샘플링할 경우에 거기로부터 여러 샘플을 써서 안티얼라이어싱을 하는 기법입니다.
비등방성 필터링의 경우 위에서 샘플러를 만들 때 옵션을 활성화하기만 하면 되는데요, 카드에서 기능을 얼마나 지원하는지를 확인하는 과정이 필요해서 살짝 뒤로 뺐습니다. 물론 방법은 이전의 기능을 확인한 바와 같습니다만, 비등방성 필터링은 선택적 기능으로 지원해야 맞겠죠. 빠르게 코드 짚고 넘어갑시다.
응용에서 필수적으로 지원해야 하는 걸 지원하지 않으면 그냥 시작 자체를 못하게 하면 되겠지만, 선택적으로 지원할 수 있는 건 미리 조사해서 보유하고 조건을 가르는 게 좋겠죠. 이렇게 해 줍시다.
// VkPlayer.h
enum class OptionalEXT { ANISOTROPIC, OPTIONAL_EXT_MAX_ENUM };
static bool extSupported[]; // 크기는 OPTIONAL_EXT_MAX_ENUM
// VkPlayer.h: 깔끔한 코딩을 위해 아래와 같은 함수를 추가해도 좋습니다.
inline static bool VkPlayer::hasExt(OptionalEXT ext) {
return extSupported[(size_t)ext];
}
그 다음 장치 생성 시 가능하면 확장을 켜 줍시다. 되도록 추가 기능을 많이 지원하는 카드를 고르게 하면 좋겠지만 어차피 대부분 컴퓨터에서는 그래픽카드를 하나만 쓰기도 하고, 모듈화할 때 다시 할 수 있으므로 그냥 지금은 필수 기능을 지원하는 걸 고른 다음에 비등방성 지원 확인만 하게 합니다.
// VkPlayer::findPhysicalDevice
...
if (pd.card) {
physicalDevice = pd;
extSupported[(size_t)OptionalEXT::ANISOTROPIC] = features.samplerAnisotropy;
return true;
}
...
이 값은 가상 장치를 만들 때 쓸 수 있게 명시하면 됩니다.
// VkPlayer::createDevice
VkPhysicalDeviceFeatures features{};
features.samplerAnisotropy = hasExt(OptionalEXT::ANISOTROPIC);
이제 비등방성 필터링을 활성화해서 오류가 안 보이면 됩니다.
// VkPlayer::createSampler0
samplerInfo.anisotropyEnable = VK_TRUE;
samplerInfo.maxAnisotropy = 4.0;
요약
이번에는 널리 사용되는 형식의 이미지 파일을 stb_image로 불러와서, 이미지 메모리 장벽을 이용하여 원하는 형태로 바꾼 뒤 텍스처를 생성하고, 샘플러를 구성하여 이를 COMBINED_IMAGE_SAMPLER라는 기술자 집합을 통해서 사용해 보았습니다. 벌칸에서는 정규화 텍스처 좌표가 이미지 좌측 상단이 0이었던 것도 기억하죠.
맨 처음에 나온 얘기처럼 KTX 형식 파일 같이 텍스처 자체를 위한 형식을 쓰거나 이미지를 불러오고 바로 밉맵을 생성하려면 일단은 위에 걸어 드린 글을 통해 학습해 보세요.
과제
생각나면 추가하겠습니다.
* 다음 글은 원래 모듈화를 할 예정이었는데, 그 전에 동적 공유 버퍼, 파이프라인 동적 상태, Vulkan Memory Allcoator, 그리고 여러 서브 패스의 연결을 먼저 해야 구조상 후회를 안 할 것 같습니다.
'Vulkan' 카테고리의 다른 글
Vulkan - 9. 파이프라인 동적 상태 (0) | 2022.06.28 |
---|---|
Vulkan - 8. 여러 서브패스에 걸친 렌더 패스 (0) | 2022.06.26 |
Vulkan - 6. 깊이와 스텐실 버퍼 (0) | 2022.06.21 |
Vulkan - 5. 인덱스 버퍼와 스테이징 버퍼 (0) | 2022.06.21 |
Vulkan - 4. 공유(uniform) 변수 (0) | 2022.06.16 |