RustyVulkan GuideContents

Part 1 · Chapter 02 · ~10 min prose skim

Rust meets a C API

Vulkan is defined in C. This chapter shows how the ash crate presents it to Rust, and why, even in a "memory-safe" language, this codebase has to keep track of who destroys what by hand.

Overview The big picture

The shape of the Vulkan C API

Almost everything in Vulkan follows one pattern. To make an object, you fill in a "create info" struct describing what you want and pass it to a vkCreate… function. You get back a handle: an opaque number that names the object inside the driver. When you're finished, you pass that handle to the matching vkDestroy… function. Every call reports success or failure with a result code.

Objects depend on each other. A buffer is created from a device; a device from an instance. Vulkan's rule is that you must destroy them in the reverse order, children before parents, and never destroy something the GPU is still using. Break the rule and the behaviour is undefined: maybe nothing happens, maybe a crash, maybe a corrupted frame.

What ash adds, and what it doesn't

ash is a deliberately thin layer. Its functions correspond one-to-one with Vulkan's, and its structs have exactly the same fields. What it adds is Rust's vocabulary on top:

What ash does not do is hide anything. Every call is still the raw Vulkan call, with all of its rules, which is why nearly every call is marked unsafe in Rust.

What Rust catches before you run

For a C programmer, the useful change is that ownership becomes part of the language. A vector owns its allocation; moving that vector transfers ownership without copying its elements. This deliberately invalid example demonstrates the check:

let vertices = vec![0.0_f32, 1.0, 2.0];
let upload_data = vertices;          // ownership moves to upload_data
println!("{}", vertices.len());     // compile error: vertices was moved
Illustration · deliberately does not compile

In C, two variables holding the same allocation's address can accidentally become two supposed owners, leading to a double free or use after free. Here the old name cannot be used. When upload_data goes out of scope, its CPU allocation is released once. Shared references let other code read the vector without taking ownership; an exclusive reference lets it change the vector. The compiler checks those relationships too. That removes a great deal of ordinary memory bookkeeping around the Vulkan calls.

Rust's safety net has a GPU-shaped hole

Rust's best-known feature is that it manages memory without a garbage collector. Every value has exactly one owner; when the owner goes away, the value is cleaned up automatically, at a predictable moment. That works perfectly for things Rust understands, such as strings, vectors and files.

A Vulkan handle, though, is just a number to Rust. Rust can't know that the number refers to a texture in GPU memory, that the device must outlive it, or that the GPU might still be reading it. So this codebase adopts a convention, stated in its own DEVELOPMENT.md: every Vulkan resource has exactly one owner in the code, and that owner destroys it explicitly, in the right order. Anywhere else a handle appears, it's merely borrowed.

Shutdown order 1 · Wait until the GPU is idle 2 · App::drop releases what it built on top of the device render passes, sky, grass, materials and textures, meshes, debug UI 3 · VulkanBase::drop releases the device's children render targets, swapchain, sync objects, buffers, … (all explained later) 4 · The GPU memory allocator, and the memory it holds 5 · The logical device 6 · The window surface, debug messenger, then the instance
Teardown runs in reverse dependency order: whatever was created from something is destroyed before it.

Skip to next overview →

Deeper dive

Entry, Instance, Device: three tables of functions

In C, you call free functions like vkCreateBuffer(device, …). ash makes the function lookup explicit with three types, each holding a table of function pointers for one level:

Extensions, optional features outside core Vulkan, get their own small tables, created from the base ones. The window-surface, swapchain and push-descriptor functions are extensions, so VulkanBase::new builds loaders for them:

let entry = Entry::linked();
// ...
let surface_loader = surface::Instance::new(&entry, &instance);
// ...
let swapchain_loader = swapchain::Device::new(&instance, &device);
let push_descriptor = push_descriptor::Device::new(&instance, &device);
src/vulkan/base/setup.rs:460,495,579-580

Create-info structs

Here is how the code creates its Vulkan instance (app_info and the two name lists are built just above). It's nearly a literal translation:

let create_info = vk::InstanceCreateInfo {
    flags: instance_flags,
    p_application_info: &app_info,
    enabled_layer_count: layers.len() as u32,
    pp_enabled_layer_names: layers.as_ptr(),
    enabled_extension_count: extension_ptrs.len() as u32,
    pp_enabled_extension_names: extension_ptrs.as_ptr(),
    ..Default::default()
};

Ok(unsafe { entry.create_instance(&create_info, None)? })
src/vulkan/base/setup.rs:55-65

In C you'd write VkInstanceCreateInfo info = {0};, set info.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, and fill in the fields. The Rust version does the same, but ..Default::default() fills every field not mentioned, and ash's default sets s_type correctly for you. Pointer-and-count pairs are filled from Rust vectors with .as_ptr() and .len().

Vulkan extends structs through a pNext pointer chain: a linked list of extra structs hanging off the main one, used for features added after the original struct was defined. The builder push_next links one in safely, and calls can be chained to add several. Here it's used to ask a GPU whether it supports the features this renderer depends on: dynamic rendering and synchronization2 from Vulkan 1.3 (Chapter 7, Chapter 10), and maintenance5 (Chapter 6):

let supports_required_features = unsafe {
    let mut vulkan13 = vk::PhysicalDeviceVulkan13Features::default();
    let mut maintenance5 = vk::PhysicalDeviceMaintenance5FeaturesKHR::default();
    let mut features2 = vk::PhysicalDeviceFeatures2::default()
        .push_next(&mut vulkan13)
        .push_next(&mut maintenance5);
    instance.get_physical_device_features2(physical_device, &mut features2);
    vulkan13.dynamic_rendering == vk::TRUE
        && vulkan13.synchronization2 == vk::TRUE
        && maintenance5.maintenance5 == vk::TRUE
};
src/vulkan/base/setup.rs:134-144

Why it's all unsafe

That's why the codebase is dotted with blocks like this, often covering a whole function body:

let device = unsafe { instance.create_device(physical_device, &device_create_info, None)? };
let queue = unsafe { device.get_device_queue(queue_family_index, 0) };
src/vulkan/base/setup.rs:258-259

Errors: Result and ?

fn create_instance(
    entry: &Entry,
    event_loop: &ActiveEventLoop,
    layers: &[*const i8],
) -> Result<Instance, Box<dyn Error>> {
    let app_name = std::ffi::CString::new("RustyVulkan")?;
src/vulkan/base/setup.rs:15-20

Below, CString::new can fail with yet another error type (a string containing a zero byte), and the ? on the last line handles it the same way.

Sometimes a specific error is expected and handled rather than passed up. Acquiring a swapchain image can return ERROR_OUT_OF_DATE_KHR when the window has been resized. That isn't a failure, just a signal to rebuild, so begin_frame pattern-matches on it:

let (image_index, is_suboptimal) = match self.swapchain_loader.acquire_next_image(
    self.swapchain.handle,
    u64::MAX,
    self.image_available_semaphores[slot],
    vk::Fence::null(),
) {
    Ok(result) => result,
    Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => {
        self.swapchain_recreation_needed = true;
        return Ok(None);
    }
    Err(e) => return Err(e.into()),
};
src/vulkan/base.rs:271-283

match is like a switch that must cover every case and can look inside values: Ok(result) pulls out the success value; the second arm catches one particular error; the last converts anything else with .into() and returns it.

Borrowing

Handles are just numbers

You can see this in shutdown code. for &sem in &self.… borrows the vector and the &sem pattern copies each handle out of it, then destroys the object it names:

for &sem in &self.image_available_semaphores {
    self.device.destroy_semaphore(sem, None);
}
src/vulkan/base.rs:768-770

Drop: automatic clean-up, and its limits

Vulkan objects here aren't wrapped in individual Drop types. A wrapper would have to keep a copy of the ash::Device to call its destroy function, and cloning that copies only the function table; it doesn't keep the real Vulkan device alive, so nothing would stop the device dying first. Instead each owner has an explicit clean-up method, and Drop appears on the two owners at the top, which call those methods in the right order. VulkanBase owns the device and everything hanging off it, and its Drop waits for the GPU and then destroys things in reverse order:

fn drop(&mut self) {
    println!("💧 Dropping VulkanBase");
    unsafe {
        // `Drop` gives us `&mut self`, but Vulkan does not know Rust's
        // ownership graph.  Destroy resources in reverse dependency order.
// ...
        if let Err(error) = self.device.device_wait_idle() {
            eprintln!("Failed to wait for device during shutdown: {error}");
        }
// ...
        self.device.destroy_command_pool(self.command_pool, None);
        // `Option::take` moves the allocator out while we only have `&mut self`.
        // Dropping it here frees VMA memory before its Vulkan device disappears.
        if let Some(alloc) = self.allocator.take() {
// ...
            drop(alloc);
        }
        self.device.destroy_device(None);
        self.surface_loader.destroy_surface(self.surface, None);
        #[cfg(debug_assertions)]
        self.debug_utils_loader
            .destroy_debug_utils_messenger(self.debug_messenger, None);
        self.instance.destroy_instance(None);
src/vulkan/base.rs:746-750,753-755,792-795,804-811

Note that it logs the wait-idle error rather than returning it with ?: a destructor has no caller to report to, and panicking in a destructor while another panic is unwinding aborts the program. The allocator is stored as an Option so it can be destroyed in a chosen place. Option::take() moves the value out and leaves None behind, which is how you take ownership of a field when you only have &mut self.

VulkanBase itself lives inside App, which owns the renderers and resource managers built on top of it. After a drop body finishes, Rust drops the struct's fields in declaration order, which isn't the order Vulkan needs. So App::drop takes the backend out first, cleans up everything that depends on it, and only then lets it go:

impl Drop for App {
    fn drop(&mut self) {
        // `take` moves VulkanBase out of the Option.  Its own Drop runs only
        // after the managers below release resources that depend on its device.
        if let Some(vb) = self.vulkan_base.take() {
            unsafe {
                let _ = vb.device.device_wait_idle();
            }

            // Rust drops fields automatically, but Vulkan handles need this
            // explicit dependency order: passes -> managers -> renderer -> device.
            self.render_graph.cleanup(&vb.device);
// ...
            self.mesh_manager.cleanup(vb.allocator.as_ref().unwrap());

            if let Some(mut renderer) = self.imgui_renderer.take() {
                println!("🗑️ Cleaning up ImGui renderer");
                renderer.cleanup(vb.allocator.as_ref().unwrap());
            }
        }
    }
}
src/app/app.rs:554-565,580-588

At the end of the if let block, vb goes out of scope and VulkanBase's own Drop, above, runs last.

When creation fails halfway

The harder case is a function that creates several objects and fails on, say, the third: the first two now need destroying, or they leak. The project's rule is to record ownership after each step and release what's been made if a later step fails. Here it is in the code that creates one uniform buffer per swapchain image:

    let (buffer, allocation) =
        match unsafe { allocator.create_buffer(&buffer_info, &alloc_info) } {
            Ok(resource) => resource,
            Err(error) => {
                for (buffer, mut allocation) in buffers.into_iter().zip(allocations) {
                    unsafe { allocator.destroy_buffer(buffer, &mut allocation) };
                }
                return Err(error);
            }
        };
    buffers.push(buffer);
    allocations.push(allocation);
}
src/vulkan/base/setup.rs:359-371

If creating buffer n fails, the error arm destroys buffers 0 to n−1 before returning (zip pairs the two vectors; into_iter consumes them).

Recap