모던 Vulkan (C++ 버전) 시리즈의 여섯 번째 글입니다. 지난 글(#5)에서는 메모리 관리와 버퍼 생성 과정을 Vulkan-HPP, RAII, 예외 처리를 활용해 재정립했습니다. 이제 GPGPU 연산을 수행하기 위해 필요한 Compute 파이프라인(Compute Pipeline)과 디스크립터(Descriptor) 관련 설정을 Modern C++ 스타일로 다시 구현해보겠습니다.
입문 시리즈에서 SPIR-V 셰이더 모듈을 로드하고, 파이프라인 레이아웃과 컴퓨트 파이프라인을 생성한 뒤, 디스크립터 풀과 디스크립터 셋을 통해 버퍼를 셰이더에 바인딩하는 과정을 다뤘습니다. 이번에는 Vulkan-HPP를 활용해 RAII 기반의 vk::UniqueShaderModule, vk::UniquePipeline, vk::UniqueDescriptorSetLayout, vk::UniqueDescriptorPool 등을 사용하고, 예외 처리 모드를 통해 코드 가독성과 안전성을 크게 개선합니다.
목표
- SPIR-V 셰이더 로딩 후 vk::UniqueShaderModule 생성
- vk::UniquePipelineLayout, vk::UniquePipeline로 Compute 파이프라인 RAII 관리
- vk::UniqueDescriptorSetLayout, vk::UniqueDescriptorPool 사용해 디스크립터 셋 RAII 관리
- 디스크립터 업데이트 시 std::vector, 예외 처리 등을 통해 코드 단순화
기본 개념 복습
C 스타일 Vulkan에서는 다음 과정을 거쳤습니다.
vkCreateShaderModule
로 SPIR-V 로드- 디스크립터 레이아웃 생성 후 파이프라인 레이아웃 생성
vkCreateComputePipelines
로 컴퓨트 파이프라인 생성- 디스크립터 풀, 디스크립터 셋 할당 후
vkUpdateDescriptorSets
로 버퍼 바인딩
이제 Vulkan-HPP를 사용하면:
- vk::UniqueShaderModule, vk::UniquePipelineLayout, vk::UniquePipeline으로 소멸자 자동 처리
- 디스크립터 레이아웃, 디스크립터 풀, 디스크립터 셋 할당 역시 RAII 또는 간단한 벡터 관리로 처리
- 예외 모드에서 파이프라인 생성 실패 시 예외 던지므로 에러 처리 단순화
코드 예제
아래 예제는 단순히 1개의 스토리지 버퍼를 binding=0으로 바인딩하는 Compute 파이프라인을 만들고 디스크립터 셋을 업데이트하는 과정을 보여줍니다. SPIR-V 셰이더(add.comp.spv
)는 이전에 벡터 덧셈 예제에서 사용한 것과 유사한 것을 가정합니다.
디렉토리 구조
my_vulkan_hpp_compute_pipeline/
├─ CMakeLists.txt
├─ shaders/
│ └─ add.comp.spv
├─ src/
│ └─ main.cpp
└─ build/
SPIR-V 로딩 함수 예제
#include <fstream>
#include <vector>
#include <stdexcept>
std::vector<char> readFile(const std::string& filename) {
std::ifstream file(filename, std::ios::ate | std::ios::binary);
if (!file.is_open()) throw std::runtime_error("Failed to open file!");
size_t fileSize = (size_t)file.tellg();
std::vector<char> buffer(fileSize);
file.seekg(0);
file.read(buffer.data(), fileSize);
return buffer;
}
main.cpp 코드
#include <iostream>
#include <vector>
#include <vulkan/vulkan.hpp>
#include <stdexcept>
std::vector<char> readFile(const std::string& filename);
int main() {
// 인스턴스, 디바이스, 큐, 메모리, 버퍼 준비 (이전 글 로직 재사용 가정)
// 여기서는 device, chosenDevice, computeQueueFamilyIndex, device, buffer, bufferMemory 등이 이미 준비되어 있다고 가정
// 실전에서는 이전 글 코드와 결합해서 완성
// 예: Compute 파이프라인에 사용할 SPIR-V 셰이더 로드
std::vector<char> code = readFile("shaders/add.comp.spv");
vk::ShaderModuleCreateInfo shaderModuleInfo({}, code.size(), reinterpret_cast<const uint32_t*>(code.data()));
vk::UniqueShaderModule shaderModule = device->createShaderModuleUnique(shaderModuleInfo);
// 디스크립터 레이아웃 (binding=0, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER)
vk::DescriptorSetLayoutBinding binding(
0, // binding=0
vk::DescriptorType::eStorageBuffer,
1, // descriptorCount
vk::ShaderStageFlagBits::eCompute
);
vk::DescriptorSetLayoutCreateInfo layoutInfo({}, 1, &binding);
vk::UniqueDescriptorSetLayout descriptorSetLayout = device->createDescriptorSetLayoutUnique(layoutInfo);
// 파이프라인 레이아웃
vk::PipelineLayoutCreateInfo pipelineLayoutInfo({}, 1, &(*descriptorSetLayout));
vk::UniquePipelineLayout pipelineLayout = device->createPipelineLayoutUnique(pipelineLayoutInfo);
// Compute 파이프라인 생성
vk::PipelineShaderStageCreateInfo stageInfo({}, vk::ShaderStageFlagBits::eCompute, *shaderModule, "main");
vk::ComputePipelineCreateInfo computePipelineInfo({}, stageInfo, *pipelineLayout);
vk::UniquePipeline computePipeline = device->createComputePipelineUnique(nullptr, computePipelineInfo);
// 디스크립터 풀 생성
vk::DescriptorPoolSize poolSize(vk::DescriptorType::eStorageBuffer, 1);
vk::DescriptorPoolCreateInfo poolInfo({}, 1, 1, &poolSize);
vk::UniqueDescriptorPool descriptorPool = device->createDescriptorPoolUnique(poolInfo);
// 디스크립터 셋 할당
vk::DescriptorSetAllocateInfo allocInfo(*descriptorPool, 1, &(*descriptorSetLayout));
std::vector<vk::DescriptorSet> descriptorSets = device->allocateDescriptorSets(allocInfo);
// 버퍼를 디스크립터에 바인딩 (이전 글에서 만든 buffer, bufferMemory 가정)
vk::DescriptorBufferInfo bufferInfo(*buffer, 0, VK_WHOLE_SIZE);
vk::WriteDescriptorSet write(
descriptorSets[0],
0, // dstBinding
0, // dstArrayElement
1, // descriptorCount
vk::DescriptorType::eStorageBuffer,
nullptr, // pImageInfo
&bufferInfo, // pBufferInfo
nullptr // pTexelBufferView
);
device->updateDescriptorSets(write, {});
std::cout << "Compute pipeline & descriptor set successfully created with Vulkan-HPP!\n";
// 여기까지 RAII로 shaderModule, descriptorSetLayout, pipelineLayout, computePipeline, descriptorPool 모두 자동 해제
return 0;
}
CMakeLists.txt 예제
cmake_minimum_required(VERSION 3.10)
project(vulkan_hpp_compute_pipeline)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Vulkan REQUIRED)
add_executable(vulkan_hpp_compute_pipeline src/main.cpp)
target_include_directories(vulkan_hpp_compute_pipeline PRIVATE ${Vulkan_INCLUDE_DIRS})
target_link_libraries(vulkan_hpp_compute_pipeline Vulkan::Vulkan)
빌드 및 실행
mkdir build
cd build
cmake ..
make
./vulkan_hpp_compute_pipeline
정상적으로 파이프라인과 디스크립터 셋이 생성되었다면 “Compute pipeline & descriptor set successfully created with Vulkan-HPP!”가 출력됩니다.
주요 포인트
- vk::UniqueShaderModule, vk::UniquePipelineLayout, vk::UniquePipeline 등 RAII로 소멸자 자동 호출
- DescriptorSetLayout, DescriptorPool도 RAII (Unique 핸들)
- std::vector와 Vulkan-HPP API 결합으로 할당 및 업데이트 과정 간결화
- 예외 발생 시 std::runtime_error나 vk::SystemError 예외 처리로 에러 처리 단순화
- C 스타일 대비 훨씬 깔끔하고 유지보수 쉬운 코드
정리 및 다음 글 예고
이번 글에서는 Compute 파이프라인 및 디스크립터 셋 구성을 Modern C++ 스타일로 재작성했습니다. 이제 파이프라인, 디스크립터 셋, 메모리, 버퍼, 커맨드 버퍼 등이 모두 Modern C++와 RAII를 활용해 재정비되었습니다.
다음 글(#7)에서는 실제로 벡터 덧셈 예제를 Modern C++ 스타일로 완성하여, 인스턴스부터 파이프라인, 커맨드 버퍼 제출, 결과 회수까지 한 번에 체험하는 과정을 살펴보겠습니다.
'개발 이야기 > Vulkan' 카테고리의 다른 글
[모던 Vulkan (C++ 버전)] #8: 디버깅, Validation Layer, 성능 프로파일링 재점검 (0) | 2024.12.19 |
---|---|
[모던 Vulkan (C++ 버전)] #7: 벡터 덧셈 예제 완성 (0) | 2024.12.19 |
[모던 Vulkan (C++ 버전)] #5: 메모리 관리 & 버퍼 생성 (RAII 기반 Modern C++) (0) | 2024.12.19 |
[모던 Vulkan (C++ 버전)] #4: 커맨드 버퍼, 커맨드 풀, 큐 제출 (RAII 기반) (0) | 2024.12.19 |
[모던 Vulkan (C++ 버전)] #3: 물리 디바이스 선택 및 로지컬 디바이스, 큐 확보 (RAII 적용) (0) | 2024.12.19 |