[모던 Vulkan (C++ 버전)] #6: Compute 파이프라인 & 디스크립터 구성 (RAII 기반 Modern C++)

모던 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에서는 다음 과정을 거쳤습니다.

  1. vkCreateShaderModule로 SPIR-V 로드
  2. 디스크립터 레이아웃 생성 후 파이프라인 레이아웃 생성
  3. vkCreateComputePipelines로 컴퓨트 파이프라인 생성
  4. 디스크립터 풀, 디스크립터 셋 할당 후 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++ 스타일로 완성하여, 인스턴스부터 파이프라인, 커맨드 버퍼 제출, 결과 회수까지 한 번에 체험하는 과정을 살펴보겠습니다.

반응형