Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Port from ash to vulkan_rust

Task: Migrate an existing ash-based project to vulkan_rust (published as vulkan-rust on crates.io).

If you already have a working ash project, switching to vulkan_rust is mostly mechanical. The Vulkan concepts are identical, and the API surface maps one-to-one. This guide covers every difference you will encounter.

What stays the same

Before diving into differences, note what does not change:

  • All Vulkan functions are unsafe.
  • You must explicitly destroy every object you create (no RAII/Drop on handles).
  • Handles are lightweight Copy types.
  • The same Vulkan mental model applies: instances, devices, queues, command buffers, pipelines, descriptor sets, synchronization primitives.

Key differences at a glance

Aspectashvulkan_rust
Crate nameashvulkan-rust
Command styleTrait methods (DeviceV1_0, KhrSwapchainFn)Inherent methods on Device / Instance
Trait importsOne per API version + one per extensionNone needed
Raw typesash::vk::*vulkan_rust::vk::*
Builders::builder() returns Builder, call .build()::builder() returns Builder that derefs to inner struct
ExtensionsManual loader structs (ash::khr::swapchain::Device)All loaded automatically, call methods on Device directly
InteropLimited from_raw on some typesInstance::from_raw_parts / Device::from_raw_parts
Error typeash::vk::Result with separate success/error enumsVkResult<T> wrapping vk::Result

Step 1: Replace the Cargo dependency

# Before (ash)
[dependencies]
ash = "0.38"

# After (vulkan_rust)
[dependencies]
vulkan-rust = "0.10"

Step 2: Remove trait imports

This is the single biggest ergonomic difference. In ash, every Vulkan API version and extension requires a trait import:

// ash: you need these traits in scope to call device methods
use ash::vk;
use ash::Device;
// Without this import, device.create_buffer() does not exist:
use ash::version::DeviceV1_0;
// Without this import, device.create_swapchain_khr() does not exist:
use ash::khr::swapchain::Device as SwapchainDevice;

In vulkan_rust, every command is an inherent method on Device or Instance. No trait imports, no extension loader structs:

// vulkan_rust: this is all you need
use vulkan_rust::vk;
use vulkan_rust::Device;
// device.create_buffer() and device.create_swapchain_khr()
// are both available immediately.

Migration action: Delete all use ash::version::* and use ash::extensions::* imports. Replace use ash::vk with use vulkan_rust::vk.

Step 3: Replace Entry, Instance, and Device creation

Entry and Instance

// ── ash ─────────────────────────────────────────────────
let entry = ash::Entry::linked();
let app_info = vk::ApplicationInfo::builder()
    .api_version(vk::make_api_version(0, 1, 3, 0))
    .build();
let create_info = vk::InstanceCreateInfo::builder()
    .application_info(&app_info)
    .build();
let instance = unsafe { entry.create_instance(&create_info, None)? };

// ── vulkan_rust ───────────────────────────────────────────
use vulkan_rust::vk;
use vk::*;

let loader = vulkan_rust::LibloadingLoader::new()
    .expect("Failed to load Vulkan");
let entry = unsafe { vulkan_rust::Entry::new(loader) }
    .expect("Failed to create entry");

let app_info = ApplicationInfo::builder()
    .api_version((1 << 22) | (3 << 12));  // Vulkan 1.3
let create_info = InstanceCreateInfo::builder()
    .application_info(&app_info);
let instance = unsafe { entry.create_instance(&create_info, None) }
    .expect("Failed to create instance");

The main changes: Entry is loaded through LibloadingLoader instead of linked(), make_api_version is replaced with a raw u32 expression, .application_info() stays .application_info(), and .build() calls are removed. The builder derefs to the inner struct, so you can pass &create_info directly where a &InstanceCreateInfo is expected.

Device

// ── ash ─────────────────────────────────────────────────
let queue_info = vk::DeviceQueueCreateInfo::builder()
    .queue_family_index(0)
    .queue_priorities(&[1.0])
    .build();
let device_info = vk::DeviceCreateInfo::builder()
    .queue_create_infos(std::slice::from_ref(&queue_info))
    .build();
let device = unsafe {
    instance.create_device(physical_device, &device_info, None)?
};

// ── vulkan_rust ───────────────────────────────────────────
use vulkan_rust::vk;
use vk::*;

let queue_info = DeviceQueueCreateInfo::builder()
    .queue_family_index(0)
    .queue_priorities(&[1.0]);
let device_info = DeviceCreateInfo::builder()
    .queue_create_infos(std::slice::from_ref(&queue_info));
let device = unsafe {
    instance.create_device(physical_device, &device_info, None)
}
.expect("Failed to create device");

Step 4: Update builders (drop .build())

In ash, builders require .build() to produce the final struct. In vulkan_rust, builders implement Deref<Target = T>, so the conversion is implicit:

// ── ash ─────────────────────────────────────────────────
let info = vk::BufferCreateInfo::builder()
    .size(1024)
    .usage(vk::BufferUsageFlags::VERTEX_BUFFER)
    .sharing_mode(vk::SharingMode::EXCLUSIVE)
    .build();  // <-- required in ash

// ── vulkan_rust ───────────────────────────────────────────
use vulkan_rust::vk;
use vk::*;

let info = BufferCreateInfo::builder()
    .size(1024)
    .usage(BufferUsageFlags::VERTEX_BUFFER)
    .sharing_mode(SharingMode::EXCLUSIVE);
    // No .build(), pass &info directly to create_buffer()

Migration action: Search your codebase for .build() and remove every occurrence on Vulkan builder types.

Step 5: Command buffer recording

The pattern is identical, just without trait imports:

// ── ash ─────────────────────────────────────────────────
use ash::version::DeviceV1_0;  // required for begin/end

let begin_info = vk::CommandBufferBeginInfo::builder()
    .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT)
    .build();
unsafe {
    device.begin_command_buffer(cmd, &begin_info)?;
    device.cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, pipeline);
    device.cmd_draw(cmd, 3, 1, 0, 0);
    device.end_command_buffer(cmd)?;
}

// ── vulkan_rust ───────────────────────────────────────────
use vulkan_rust::vk;
use vk::*;

let begin_info = CommandBufferBeginInfo::builder()
    .flags(CommandBufferUsageFlags::ONE_TIME_SUBMIT);
unsafe {
    device.begin_command_buffer(cmd, &begin_info)
        .expect("Failed to begin command buffer");
    device.cmd_bind_pipeline(cmd, PipelineBindPoint::GRAPHICS, pipeline);
    device.cmd_draw(cmd, 3, 1, 0, 0);
    device.end_command_buffer(cmd)
        .expect("Failed to end command buffer");
}

Step 6: Queue submission

// ── ash ─────────────────────────────────────────────────
let submit_info = vk::SubmitInfo::builder()
    .command_buffers(&[cmd])
    .wait_semaphores(&[image_available])
    .wait_dst_stage_mask(&[vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT])
    .signal_semaphores(&[render_finished])
    .build();
unsafe { device.queue_submit(queue, &[submit_info.build()], fence)? };

// ── vulkan_rust ───────────────────────────────────────────
use vulkan_rust::vk;
use vk::*;

let wait_stages = [PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT];
let cmd_bufs = [cmd];
let wait_sems = [image_available];
let signal_sems = [render_finished];
let submit_info = SubmitInfo::builder()
    .command_buffers(&cmd_bufs)
    .wait_semaphores(&wait_sems)
    .wait_dst_stage_mask(&wait_stages)
    .signal_semaphores(&signal_sems);
unsafe {
    device.queue_submit(queue, &[*submit_info], fence)
        .expect("Failed to submit");
};

Step 7: Error handling

ash splits Vulkan results into success codes and error codes. vulkan_rust uses a single VkResult<T> type:

// ── ash ─────────────────────────────────────────────────
match unsafe { device.create_buffer(&info, None) } {
    Ok(buffer) => { /* ... */ }
    Err(vk::Result::ERROR_OUT_OF_DEVICE_MEMORY) => { /* ... */ }
    Err(e) => panic!("Unexpected: {:?}", e),
}

// ── vulkan_rust ───────────────────────────────────────────
use vulkan_rust::vk;
use vk::Result as VkError;

match unsafe { device.create_buffer(&info, None) } {
    Ok(buffer) => { /* ... */ }
    Err(VkError::ERROR_OUT_OF_DEVICE_MEMORY) => { /* ... */ }
    Err(e) => panic!("Unexpected: {e:?}"),
}

The match arms look the same. The difference is that VkResult<T> implements std::error::Error, so it works with anyhow, eyre, and the ? operator out of the box.

Step 8: Extensions

In ash, extensions require separate loader structs:

// ash: manual extension loading
let swapchain_loader = ash::khr::swapchain::Device::new(&instance, &device);
let swapchain = unsafe {
    swapchain_loader.create_swapchain(&create_info, None)?
};

In vulkan_rust, all extension functions are loaded automatically when the Device or Instance is created. You call them as regular methods:

// vulkan_rust: no loader, just call the method
let swapchain = unsafe {
    device.create_swapchain_khr(&create_info, None)
}
.expect("Failed to create swapchain");

Migration action: Delete all extension loader struct construction. Replace loader.method() with device.method() or instance.method().

Step 9: Interop with from_raw_parts

If another library (OpenXR, a C plugin, a test harness) gives you raw Vulkan handles, vulkan_rust provides from_raw_parts to wrap them:

// Wrap an externally-created VkInstance
let instance = unsafe {
    vulkan_rust::Instance::from_raw_parts(raw_instance, get_instance_proc_addr)
};

// Wrap an externally-created VkDevice
let device = unsafe {
    vulkan_rust::Device::from_raw_parts(raw_device, get_device_proc_addr)
};

This loads all function pointers from the provided get_*_proc_addr, so the wrapped object works identically to one created through Entry.

Quick-reference migration checklist

  • Replace ash with vulkan-rust in Cargo.toml
  • Replace use ash::vk with use vulkan_rust::vk
  • Delete all use ash::version::* trait imports
  • Delete all extension loader struct construction
  • Remove every .build() on Vulkan builder types
  • Replace ash::Entry / ash::Instance / ash::Device with vulkan_rust::*
  • Replace extension loader method calls with direct device.method() calls
  • Update error handling if you matched on ash-specific error types
  • Compile and fix any remaining type mismatches