Handmade Rust Part 4: Generating Vulkan bindings

Vulkan is a C API so we’ll need some kind of bindings to be able to use it in Rust. The API is defined in a XML file distributed by Khronos. This file describes the structs, enums, constants, and functions for each version of the API, and all published extensions. The functions can then be loaded from the Vulkan dynamic library and other functions from the API.

However using a raw C API isn’t easy in Rust because it requires using a lot of unsafe code. This is why we’ll also generate builders for all structs so we can for instance fill in pointer/size pairs using slices, but we’ll also generate methods that return Rust’s Results and take in Rust-friendly types like references instead of raw C types. Finally we’ll also generate loaders so we don’t have to manually load the function we need.

Parsing the spec

The XML spec for Vulkan is notoriously annoying to use outside of the C/C++ ecosystem because it’s basically C code with tags around it. For instance, good luck parsing this:

<member>const <type>void</type>* <name>pNext</name></member>

For my purpose, I wanted to transform this awkward representation into some kind of AST. For example, given the code above, this is what the parser outputs:

<member name="pNext" optional="False">
    <pointer const="False">
        <type const="True">void</type>
    </pointer>
</member>

This is much easier to use for generating code in any other language than C.

On a higher level, the parser is also able to resolve type dependencies. The goal is to tell it what version of the API and what extensions to use. With that, the parser visits all necessary types and commands, possibly extends some enums, and this is what is then written to the output file.

With that in mind, I won’t go into too much details into how the parser works. Just know that it’s written in Python and that it was both incredibly boring and annoying to do, I never want to do it again! I’ll now show a few examples of the generated XML file.

The source code for the parser and generators is available on GitHub.

Type aliases

<alias name="VkInstanceCreateFlags" type="VkFlags"/>
<base name="VkDeviceSize" type="uint64_t"/>
<handle name="VkBufferView" type="uint64_t"/>

The alias tag is a Vulkan-to-Vulkan type alias while base is a Vulkan-to-native alias. In this instance VkInstanceCreateFlags is an alias for VkFlags because it is an empty enum.

Commands

<command name="vkGetPhysicalDeviceSurfacePresentModesKHR" success-codes="VK_SUCCESS,VK_INCOMPLETE" type="instance">
    <return-type>
        <type const="False">VkResult</type>
    </return-type>
    <arg name="physicalDevice" optional="False">
        <type const="False">VkPhysicalDevice</type>
    </arg>
    <arg name="surface" optional="False">
        <type const="False">VkSurfaceKHR</type>
    </arg>
    <arg name="pPresentModeCount" optional="True">
        <pointer const="False">
            <type const="False">uint32_t</type>
        </pointer>
    </arg>
    <arg length="pPresentModeCount" name="pPresentModes" optional="True">
        <pointer const="False">
            <type const="False">VkPresentModeKHR</type>
        </pointer>
    </arg>
</command>

command define their name of course, but also their success codes for functions that return a VkResult (this will be used for error checking) and their type, which defines how the associated function pointer must be loaded.

The command has one return-type and many args with a type AST inside.

Each argument defines whether it is optional or not (this is useful for pointers) and pointer arguments can indicate what argument defines their length.

  • For instance if length is pPresentModeCount, it means that the pPresentModeCount argument defines the length of the array pointed to by the pointer.
  • If the length is pCreateInfo::somethingCount, it means that the lenth of the array is the somethingCount field from the struct in the pCreateInfo argument.

These info are useful to generate functions that take in slices instead of pointer/length pairs, which is much more idiomatic in Rust. Also, whether the pointer type is const or not indicates whether it is an input argument or not.

Enums

<enum name="VkPresentModeKHR" type="enum">
    <entry name="VK_PRESENT_MODE_IMMEDIATE_KHR">0</entry>
    <entry name="VK_PRESENT_MODE_MAILBOX_KHR">1</entry>
    <entry name="VK_PRESENT_MODE_FIFO_KHR">2</entry>
    <entry name="VK_PRESENT_MODE_FIFO_RELAXED_KHR">3</entry>
</enum>

Not much to say here, everything is pretty obvious. :)

Bit flags

<bitmask flags="VkShaderStageFlagBits" name="VkShaderStageFlags"/>
<enum name="VkShaderStageFlagBits" type="bitmask">
    <entry name="VK_SHADER_STAGE_VERTEX_BIT">1</entry>
    <entry name="VK_SHADER_STAGE_TESSELLATION_CONTROL_BIT">2</entry>
    <entry name="VK_SHADER_STAGE_TESSELLATION_EVALUATION_BIT">4</entry>
    <entry name="VK_SHADER_STAGE_GEOMETRY_BIT">8</entry>
    <entry name="VK_SHADER_STAGE_FRAGMENT_BIT">16</entry>
    <entry name="VK_SHADER_STAGE_ALL_GRAPHICS">31</entry>
    <entry name="VK_SHADER_STAGE_COMPUTE_BIT">32</entry>
    <entry name="VK_SHADER_STAGE_ALL">2147483647</entry>
</enum>

For bit flags, we associate a top-level bitmask tag which indicates the association between the enum that defines the flags and the type that combines those flags.

In Vulkan, the flags enum are always called XxxFlagBits and the combined flags XxxFlags.

Function pointers

<function-pointer name="PFN_vkFreeFunction">
    <return-type>
        <type const="False">void</type>
    </return-type>
    <arg name="pUserData" optional="False">
        <pointer const="False">
            <type const="False">void</type>
        </pointer>
    </arg>
    <arg name="pMemory" optional="False">
        <pointer const="False">
            <type const="False">void</type>
        </pointer>
    </arg>
</function-pointer>

Function pointers follow the exact same syntax as commands.

Constants

<integer-constant name="VK_SUBPASS_EXTERNAL" size="32">4294967295</integer-constant>
<real-constant name="VK_LOD_CLAMP_NONE" size="32">1000.0</real-constant>
<string-constant name="VK_KHR_WIN32_SURFACE_EXTENSION_NAME">VK_KHR_win32_surface</string-constant>

Structures and unions

<struct name="VkInstanceCreateInfo">
    <member default_value="VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO" name="sType" optional="False">
        <type const="False">VkStructureType</type>
    </member>
    <member name="pNext" optional="False">
        <pointer const="False">
            <type const="True">void</type>
        </pointer>
    </member>
    <member name="flags" optional="True">
        <type const="False">VkInstanceCreateFlags</type>
    </member>
    <member name="pApplicationInfo" optional="True">
        <pointer const="False">
            <type const="True">VkApplicationInfo</type>
        </pointer>
    </member>
    <member name="enabledLayerCount" optional="True">
        <type const="False">uint32_t</type>
    </member>
    <member length="enabledLayerCount" name="ppEnabledLayerNames" optional="False">
        <pointer const="False">
            <pointer const="True">
                <type const="True">char</type>
            </pointer>
        </pointer>
    </member>
    <member name="enabledExtensionCount" optional="True">
        <type const="False">uint32_t</type>
    </member>
    <member length="enabledExtensionCount" name="ppEnabledExtensionNames" optional="False">
        <pointer const="False">
            <pointer const="True">
                <type const="True">char</type>
            </pointer>
        </pointer>
    </member>
</struct>

On their top-level tag, structs can define an extends attribute which indicates what structures can receive it in the pNext chain.

Another interesting note here is that pointer fields can have a length attribute, which serves the exacts same purpose as on function arguments.

Unions are the exact same thing as structures, so I won’t detail them here.

Generating Rust types

Handles

#[repr(transparent)]
#[derive(Default, Copy, Clone, PartialEq, Eq)]
pub struct VkPhysicalDevice(usize);
impl VkPhysicalDevice
{
    #[inline]
    pub fn null() -> Self
    {
        Self(0)
    }

    #[inline]
    pub fn from_raw(r: usize) -> Self
    {
        Self(r)
    }

    #[inline]
    pub fn as_raw(&self) -> usize
    {
        self.0
    }
}

Handles are generated as a newtypes with the transparent attribute, which guarantees that it has the exact same layout as the underlying type, allowing us to pass it to the API directly. We also generate a few helper methods.

Enums

#[repr(transparent)]
#[derive(Default, PartialOrd, Copy, Clone, Ord, PartialEq, Eq, Hash)]
pub struct VkSystemAllocationScope(u32);
impl VkSystemAllocationScope
{
    pub const COMMAND: VkSystemAllocationScope = VkSystemAllocationScope(0);
    pub const OBJECT: VkSystemAllocationScope = VkSystemAllocationScope(1);
    pub const CACHE: VkSystemAllocationScope = VkSystemAllocationScope(2);
    pub const DEVICE: VkSystemAllocationScope = VkSystemAllocationScope(3);
    pub const INSTANCE: VkSystemAllocationScope = VkSystemAllocationScope(4);
}

Enums are also generated as newtypes with transparent. Each value is an associated constant. It was not possible to use an enum here because there can be multiple symbols with the same value, which is not allowed in Rust enums.

Bitflags

#[repr(transparent)]
#[derive(Default, Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Hash)]
pub struct VkCompositeAlphaFlagBitsKHR(VkFlags);
impl VkCompositeAlphaFlagBitsKHR
{
    pub const OPAQUE_BIT_KHR: VkCompositeAlphaFlagBitsKHR = VkCompositeAlphaFlagBitsKHR(1);
    pub const PRE_MULTIPLIED_BIT_KHR: VkCompositeAlphaFlagBitsKHR = VkCompositeAlphaFlagBitsKHR(2);
    pub const POST_MULTIPLIED_BIT_KHR: VkCompositeAlphaFlagBitsKHR = VkCompositeAlphaFlagBitsKHR(4);
    pub const INHERIT_BIT_KHR: VkCompositeAlphaFlagBitsKHR = VkCompositeAlphaFlagBitsKHR(8);

    #[inline]
    pub fn contains(&self, other: Self) -> bool
    {
        return (self.0 & other.0) == other.0;
    }
}

Bitflags are implemented in a very similar way to enums, but they contains a very useful contains method for checking that a mask is contained in another one. They also implement additional traits like BitOr, BitAnd, BitXor, and their -Assign variants.

Structures

#[repr(C)]
#[derive(Copy, Clone)]
pub struct VkInstanceCreateInfo
{
    pub s_type: VkStructureType,
    pub p_next: *const core::ffi::c_void,
    pub flags: VkInstanceCreateFlags,
    pub p_application_info: *const VkApplicationInfo,
    pub enabled_layer_count: u32,
    pub pp_enabled_layer_names: *const *const u8,
    pub enabled_extension_count: u32,
    pub pp_enabled_extension_names: *const *const u8,
}

pub trait ExtendsInstanceCreateInfo
{
}

impl Default for VkInstanceCreateInfo
{
    fn default() -> Self
    {
        Self {
            s_type: VkStructureType::INSTANCE_CREATE_INFO,
            p_next: core::ptr::null(),
            flags: VkInstanceCreateFlags::default(),
            p_application_info: core::ptr::null(),
            enabled_layer_count: 0,
            pp_enabled_layer_names: core::ptr::null(),
            enabled_extension_count: 0,
            pp_enabled_extension_names: core::ptr::null(),
        }
    }
}

Structures use the repr(C) attribute for ABI compatibility. They implement the Default trait, and we also define a Extends- trait that must be implemented by all structures that can be added to the pNext chain of this structure. For instance we have:

impl ExtendsInstanceCreateInfo for VkDebugUtilsMessengerCreateInfoEXT {}

Function pointers and commands

pub type PfnVkEnumeratePhysicalDevices = extern "system" fn(
    instance: VkInstance,
    p_physical_device_count: *mut u32,
    p_physical_devices: *mut VkPhysicalDevice,
) -> VkResult;

Both function pointers and commands define associated function pointers that will be used in the loader. They use raw types, but will not be used directly by the user.

Structure builders

pub struct VkInstanceCreateInfoBuilder<'a>
{
    s: VkInstanceCreateInfo,
    _p: core::marker::PhantomData<&'a ()>,
}

impl<'a> VkInstanceCreateInfoBuilder<'a>
{
    pub fn new() -> Self
    {
        Self {
            s: VkInstanceCreateInfo::default(),
            _p: core::marker::PhantomData,
        }
    }

    pub fn build(&self) -> VkInstanceCreateInfo
    {
        self.s.clone()
    }

    pub fn s_type(mut self, value: VkStructureType) -> VkInstanceCreateInfoBuilder<'a>
    {
        self.s.s_type = value;
        self
    }

    pub fn push_next<T: ExtendsInstanceCreateInfo>(
        mut self,
        next: &'a mut T,
    ) -> VkInstanceCreateInfoBuilder<'a>
    {
        unsafe {
            let last = get_last_base_out_struct_chain(next as *mut T as *mut VkBaseOutStructure);
            (*last).p_next = self.s.p_next as _;
            self.s.p_next = core::mem::transmute(next);
        }
        self
    }

    pub fn p_application_info(
        mut self,
        value: Option<&'a VkApplicationInfo>,
    ) -> VkInstanceCreateInfoBuilder<'a>
    {
        self.s.p_application_info = match value
        {
            Some(r) => r,
            None => core::ptr::null(),
        };
        self
    }

    pub fn enabled_layer_count(mut self, value: u32) -> VkInstanceCreateInfoBuilder<'a>
    {
        self.s.enabled_layer_count = value;
        self
    }

    pub fn pp_enabled_layer_names(
        mut self,
        values: &'a [*const u8],
    ) -> VkInstanceCreateInfoBuilder<'a>
    {
        self.s.enabled_layer_count = values.len() as _;
        self.s.pp_enabled_layer_names = values.as_ptr();
        self
    }

    // ...
}

impl<'a> core::ops::Deref for VkInstanceCreateInfoBuilder<'a>
{
    type Target = VkInstanceCreateInfo;

    fn deref(&self) -> &Self::Target
    {
        &self.s
    }
}

Raw structures can be annoying to fill in because they use pointers, amongst other reasons. To prevent this pain, we define a builder for each structure.

Each field from the struct has an associated method to fill it in the raw struct. These methods use Rust-friendly types:

  • References instead of pointers.
  • Option<&[mut] T> instead of optional pointers.
  • Fields with associated lengths take in a slice and fill in the associated length field (see pp_enabled_layer_names above).
  • When taking in a reference, its lifetime is bound to the builder, allowing some sweet borrow-checking goodness.

For structs that include a pNext chain, the associated push_next method checks the compatibility of the structure we want to push using the Extends- trait at compile time (oh yeah!), and then automagically pushes it to the front of the chain.

In addition to all this, the builder also has a build method that returns the underlying struct. The only caveat is that all lifetime information is then lost.

Deref and DerefMut are also implemented in order to be able to pass a builder as its underlying struct without having to call build.

Note that most of this was inspired by Ash.

Generating a loader

Now that we have the types sorted out, we need to load the functions pointers to all commands. This is done in several steps:

  • Retrieve the vkGetInstanceProcAddr function from the Vulkan library (vulkan-1.dll on Windows). This is the “static” category.
  • Retrieve the commands that don’t depend on a Vulkan instance using vkGetInstanceProcAddr with VK_NULL_HANDLE as first parameter. This is the “entry” category.
  • After an instance has been created, retrieve its commands using vkGetInstanceProcAddr with the instance handle as first parameter. This is the “instance” category.
  • After a device has been created, retrieve its commands using vkGetDeviceProcAddr with the device handle as first parameter. This is the “device” category.

Each category has a struct containing the function pointers to all its commands. It has a new method that takes in a closure used to load a command given its name as a null-terminated byte string. Each command also has a method to call it.

For example, to create the StaticCommands struct, the user just needs to pass to it a closure that calls GetProcAddress with the handle to the DLL previously loaded.

#[derive(Clone)]
pub struct EntryCommands
{
    pfn_enumerate_instance_layer_properties: PfnVkEnumerateInstanceLayerProperties,
    pfn_enumerate_instance_extension_properties: PfnVkEnumerateInstanceExtensionProperties,
    pfn_create_instance: PfnVkCreateInstance,
}

impl EntryCommands
{
    pub fn load(load_fn: impl Fn(&[u8]) -> PfnVkVoidFunction) -> Self
    {
        EntryCommands {
            pfn_enumerate_instance_layer_properties: unsafe {
                core::mem::transmute(load_fn(b"vkEnumerateInstanceLayerProperties\0"))
            },
            pfn_enumerate_instance_extension_properties: unsafe {
                core::mem::transmute(load_fn(b"vkEnumerateInstanceExtensionProperties\0"))
            },
            pfn_create_instance: unsafe { core::mem::transmute(load_fn(b"vkCreateInstance\0")) },
        }
    }

    #[inline]
    pub unsafe fn enumerate_instance_layer_properties(
        &self,
        p_property_count: *mut u32,
        p_properties: *mut VkLayerProperties,
    ) -> VkResult
    {
        (self.pfn_enumerate_instance_layer_properties)(p_property_count, p_properties)
    }
}

The high level entry point, instance, and device wrappers

The API is divided in three parts:

  • The EntryPoint stores static and entry commands. It is created by giving it a closure, just like in the loader. This one is responsible for loading vkGetInstanceProcAddr from the OS. After loading the static commands, it also loads the entry commands.
  • Instance wraps a VkInstance and the instance commands. It is created by giving it a VkInstance handle and an EntryPoint struct.
  • Device wraps a VkDevice and the device commands. It is created by giving it a VkDevice handle and an Instance struct.

Those structs contain a method for each of their command. Those methods are high level: they translate from idiomatic Rust to raw API calls. The next part will detail how they are generated.

#[derive(Clone)]
pub struct Device
{
    handle: VkDevice,
    d: DeviceCommands,
}

impl Device
{
    pub fn new(device: VkDevice, instance: &Instance) -> Self
    {
        let commands = DeviceCommands::load(|fn_name| unsafe {
            instance.i.get_device_proc_addr(device, fn_name.as_ptr())
        });
        Self {
            handle: device,
            d: commands,
        }
    }
}

Generating idiomatic Rust commands

The first step to making the Vulkan API more ergonomic in this context is not requiring to pass in the device or instance handle as first argument of the commands. For this purpose, the first VkInstance argument of all instance commands is ommited and the one contained in the Instance is given to the raw call. Same thing for VkDevice in Device.

Next, commands that return a VkResult now return a Result<VkResult, VkResult>. Whether it is Ok or Err depend on the success codes listed in the specs.

Commands that return a single value via a pointer now return it naturally. If such a command returns a VkResult, the high level method returns a Result<(VkResult, T), VkResult> instead, where T is the returned value.

impl Device
{
    pub fn create_fence(
        &self,
        p_create_info: &VkFenceCreateInfo,
        p_allocator: Option<&VkAllocationCallbacks>,
    ) -> Result<(VkResult, VkFence), VkResult>
    {
        // VkResult vkCreateFence(
        //     VkDevice                                    device,
        //     const VkFenceCreateInfo*                    pCreateInfo,
        //     const VkAllocationCallbacks*                pAllocator,
        //     VkFence*                                    pFence);

        // ...
    }
}

The argument type ergonomics are the same used in structure builders so I won’t list them again. This includes optional pointers, slices, etc.

Finally commands are categorized to generate the most ergonomic version possible.

Commands that return a single value

This is detected by finding a non-const pointer argument that doesn’t have a length attribute. See the above example.

Commands that return an array or take in arrays of known length

This is detected by finding pointer arguments that have a length attribute pointing to an integer value.

  • If the length is an argument we replace the length and pointer arguments by a slice. The length is that of the slice.
pub fn wait_for_fences(
    &self,
    p_fences: &[VkFence],
    wait_all: bool,
    timeout: u64,
) -> Result<VkResult, VkResult>
{
    let fence_count = p_fences.len() as _;

    // ...

    // VkResult vkWaitForFences(
    //     VkDevice                                    device,
    //     uint32_t                                    fenceCount,
    //     const VkFence*                              pFences,
    //     VkBool32                                    waitAll,
    //     uint64_t                                    timeout);
}
  • If the length is a field in a structure given as argument, we replace the pointer argument by a slice and assert that its length is correct.
pub fn allocate_command_buffers(
    &self,
    p_allocate_info: &VkCommandBufferAllocateInfo,
    p_command_buffers: &mut [VkCommandBuffer],
) -> Result<VkResult, VkResult>
{
    assert!(p_allocate_info.command_buffer_count as usize == p_command_buffers.len());

    // ...

    // VkResult vkAllocateCommandBuffers(
    //     VkDevice                                    device,
    //     const VkCommandBufferAllocateInfo*          pAllocateInfo,
    //     VkCommandBuffer*                            pCommandBuffers);
}
  • If multiple pointer arguments point to the same length argument, one of the slices set the length and we assert the length of the following ones.
pub fn cmd_bind_vertex_buffers(
    &self,
    command_buffer: VkCommandBuffer,
    first_binding: u32,
    p_buffers: &[VkBuffer],
    p_offsets: &[VkDeviceSize],
)
{
    let binding_count = p_buffers.len() as _;
    assert!(binding_count as usize == p_offsets.len());

    // ...

    // void vkCmdBindVertexBuffers(
    //     VkCommandBuffer                             commandBuffer,
    //     uint32_t                                    firstBinding,
    //     uint32_t                                    bindingCount,
    //     const VkBuffer*                             pBuffers,
    //     const VkDeviceSize*                         pOffsets);
}

Commands that return an array of unknown length

This is detected by finding a non-const pointer argument with a length attribute pointing to a non-const integer pointer. In this case we generate two methods: one that sets the data pointer to null and return only the returned length, and one that take in a slice and return values through it.

// VkResult vkEnumerateDeviceLayerProperties(
//     VkPhysicalDevice                            physicalDevice,
//     uint32_t*                                   pPropertyCount,
//     VkLayerProperties*                          pProperties);

pub fn enumerate_device_layer_properties_count(
    &self,
    physical_device: VkPhysicalDevice,
) -> Result<(VkResult, usize), VkResult>
{
    // Here we set pProperties to null and return the value of pPropertyCount

    // ...
}

pub fn enumerate_device_layer_properties(
    &self,
    physical_device: VkPhysicalDevice,
    p_properties: &mut [VkLayerProperties],
) -> Result<VkResult, VkResult>
{
    let mut p_property_count = p_properties.len() as _;

    // ...
}

Any command that doesn’t fall into any of those categories is generated without any special ergonomic apart from the one listed at the beginning of this part.

Wrap up

And that’s it for the bindings generator. It was definitely a challenge to be able to handle everything the Vulkan API throws at us but in the end it makes is possible to write Vulkan code in a somewhat Rusty way. However I can’t really say that the generator code is elegant… Below is an example of initializing Vulkan.

The code for all of this is available in the VkXml repository. You can contact me on Twitter for any question or remark.

let hinstance = unsafe { GetModuleHandleA(0 as _) as HINSTANCE };

let vk_module = unsafe { LoadLibraryA(b"vulkan-1.dll\0".as_ptr()) };
let vk_entry = vk::EntryPoint::new(|fn_name| unsafe {
    transmute(GetProcAddress(vk_module, fn_name.as_ptr()))
});

let instance_extensions = &[
    VK_EXT_DEBUG_UTILS_EXTENSION_NAME__C.as_ptr(),
    VK_KHR_SURFACE_EXTENSION_NAME__C.as_ptr(),
    VK_KHR_WIN32_SURFACE_EXTENSION_NAME__C.as_ptr(),
];

let layers = &[b"VK_LAYER_LUNARG_standard_validation\0".as_ptr()];

let create_info = VkInstanceCreateInfoBuilder::new()
    .pp_enabled_extension_names(instance_extensions)
    .pp_enabled_layer_names(layers);

let vk_instance = vk_entry.create_instance(&create_info, None).unwrap().1;
let vk_instance = vk::Instance::new(vk_instance, &vk_entry);

let gpu_count = vk_instance.enumerate_physical_devices_count().unwrap().1;
println!("{} GPU(s)", gpu_count);

let gpus = {
    let mut gpus = Array::new();
    gpus.resize(gpu_count, VkPhysicalDevice::null());
    vk_instance.enumerate_physical_devices(&mut gpus).unwrap();
    gpus
};

for (index, gpu) in gpus.iter().enumerate()
{
    let prps = vk_instance.get_physical_device_properties(*gpu);
    let name = unsafe { CStr::from_bytes_null_terminated_unchecked(prps.device_name.as_ptr()) };
    println!("    {}: {}", index, name.as_str().unwrap());
}

vk_instance.destroy_instance(None);

Contents