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:
- Result codes become Rust
Resultvalues, so a failed call is hard to ignore by accident. - Create-info structs come with sensible defaults, and optional "builder" methods that let the compiler check that the data a struct points at lives long enough.
- Functions are grouped onto the object they belong to: device functions are methods on a
Device, and so on.
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
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.
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:
ash::Entry: functions that need no Vulkan object yet, such as listing layers and creating an instance.Entry::linked()uses the loader linked in at build time (thelinkedfeature from Chapter 1).ash::Instance: the instance handle plus functions that act on it (enumerating GPUs, creating a device).ash::Device: the device handle plus every device-level function (create buffers, record commands, submit work).
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);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)? })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
};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) };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")?;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()),
};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);
}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);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());
}
}
}
}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);
}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
ashmirrors Vulkan one-to-one, organised intoEntry,InstanceandDevicefunction tables plus per-extension loaders.- Nearly every call is
unsafebecause Vulkan's rules can't be checked at compile time; validation layers detect many mistakes at run time, but correctness remains the programmer's responsibility. - Errors are
Results, propagated with?or matched when expected. - Handles are
Copynumbers, so ownership is a convention: one owner per resource, destroyed in reverse dependency order.