Vulkan은 매우 명시적이고 직접적인 제어를 제공하는 저수준 graphics 및 compute API로, 개발자가 하드웨어 동작의 거의 모든 단계를 직접 지정해야 하는 것이 특징입니다. OpenGL과 달리, 드라이버나 런타임이 자동으로 많은 일을 처리하지 않으며, 개발자는 hardware capability, device feature, 확장 지원 여부 등을 직접 설정하고 각 함수에 전달해야 합니다.

Instance

Vulkan API의 첫 진입점은 Instance입니다. 애플리케이션이 Vulkan 기능을 사용하려면 가장 먼저 Instance 객체를 생성해야 하며, 이는 Vulkan 시스템 전체의 전역 문맥으로 작동합니다. Instance를 만들 때, 원하는 경우 검증 레이어를 활성화하거나 필요한 확장(VK_KHR_surface 등)을 등록할 수 있습니다. 또 드라이버 에러 발생 시 사용할 각종 로깅 기능도 Instance에 연결합니다. 일반적으로 하나의 애플리케이션당 단일 Instance를 만들어서 사용하고, 이 Instance가 전체 프로세스 동안 Vulkan 관련 모든 객체의 기반이 됩니다.

다음 코드는 InstanceCreateFlags::ENUMERATE_PORTABILITY 확장이 설정된 Instance를 생성하는 작업을 수행합니다.

use vulkano::VulkanLibrary;
use vulkano::instance::{Instance, InstanceCreateFlags, InstanceCreateInfo};

let library = VulkanLibrary::new().expect("no local Vulkan library/DLL");
let instance = Instance::new(
    library,
    InstanceCreateInfo {
        flags: InstanceCreateFlags::ENUMERATE_PORTABILITY,
        ..Default::default()
    },
)
.expect("failed to create instance");

PhysicalDevice

Vulkan에서는 시스템에 존재하는 GPU와 해당 GPU 기능 목록을 가져올 수 있으며, 이 때 사용되는 객체가 PhysicalDevice입니다. PhysicalDevice는 시스템에 존재하는 실제 GPU를 의미하며, 하나의 PC에는 한 개 혹은 여러 개의 GPU가 존재할 수 있습니다. 예를 들어, 게이밍 PC에는 고성능 d-GPU가 단독으로 있거나, 노트북처럼 저전력 i-GPU와 고성능 d-GPU가 동시에 구성될 수도 있습니다. 만약 여러 GPU가 존재한다면, 애플리케이션이 어느 GPU를 사용할지 사용자가 직접 선택할 수 있습니다. 사용 가능한 VRAM 크기, 지원 확장, 고급 기능 지원 여부 등 GPU의 세부 사양 역시 PhysicalDevice 객체를 통해 질의할 수 있습니다. 고급 엔진에서는 이런 정보를 바탕으로 최적의 GPU를 동적으로 선정하기도 합니다.

다음 코드는 사용 가능한 PhysicalDevice 목록 중 가장 첫 번째 장치를 반환합니다.

let physical_device = instance
    .enumerate_physical_devices()
    .expect("could not enumerate devices")
    .next()
    .expect("no devices available");

Queue

CPU에서 실행되는 프로그램에서 여러 스레드를 사용할 수 있는 것처럼, GPU에서도 여러 작업을 병렬로 실행할 수 있습니다. Vulkan에서 CPU 스레드에 해당하는 개념은 Queue이며, PhysicalDevice는 어떠한 종류의 Queue가 사용 가능한지에 대한 정보를 QueueFamily를 통해 제공합니다.

for family in physical_device.queue_family_properties() {
    println!("Found a queue family with {:?} queue(s)", family.queue_count);
}

어떠한 장치가 작업을 수행하도록 하려면 해당 작업을 특정 Queue에 제출해야 합니다. Queue에 따라 graphics, compute, transfer 등의 작업을 모두 지원하거나 일부를 지원하므로, 이를 잘 고려해야 합니다.

Device

마지막으로 Device 객체는 PhysicalDevice를 실제로 제어할 수 있도록 만들어주는 논리적 장치입니다. 대부분의 Vulkan 명령은 Device 핸들을 요구하며, 디버깅·초기화와 관련된 소수의 기능만이 Instance/PhysicalDevice 단계에서 실행됩니다.

Device를 생성하기 위해서는 해당 장치로 어떤 작업을 수행할 것인지를 나타내는 Queue가 필요합니다. 다음 코드는 graphics 작업을 지원하는 QueueFamily를 찾아 index를 반환합니다.

use vulkano::device::QueueFlags;

let queue_family_index = physical_device
    .queue_family_properties()
    .iter()
    .enumerate()
    .position(|(_queue_family_index, queue_family_properties)| {
        queue_family_properties.queue_flags.contains(QueueFlags::GRAPHICS)
    })
    .expect("couldn't find a graphical queue family") as u32;

반환된 index를 사용해 Device를 생성합니다.

use vulkano::device::{Device, DeviceCreateInfo, QueueCreateInfo};

let (device, mut queues) = Device::new(
    physical_device,
    DeviceCreateInfo {
        // here we pass the desired queue family to use by index
        queue_create_infos: vec![QueueCreateInfo {
            queue_family_index,
            ..Default::default()
        }],
        ..Default::default()
    }
)
.expect("failed to create device");

Device::newDevice 객체와 실제 명령을 제출하게 될 Queue 객체에 대한 iterator를 반환합니다. 위의 코드는 QueueCreateInfo에 한 가지 종류의 Queue를 요청했기 때문에 바로 unwrap할 수 있습니다.

let queue = queues.next().unwrap();
#version 460
layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in;

layout(set = 0, binding = 0) buffer Data {
    uint data[];
} buf;

void main() {
    uint idx = gl_GlobalInvocationID.x;
    buf.data[idx] *= 12;
}