RustyVulkan GuideContents

Part 2 · Chapter 03 · ~9 min prose skim

Instance, devices and queues

Before anything can be drawn, the program has to connect to Vulkan, look at the GPUs on the machine, pick one, switch on the features it needs, and get a queue to send work to.

Overview The big picture

In OpenGL, asking the window system for a context gave you everything at once: a GPU chosen for you, all its features on, and an implicit place to send commands. Vulkan splits that single step into several explicit ones, each a decision you make.

From instance to queue Instance + layers (validation) + instance extensions Surface the window, as Vulkan sees it Physical device e.g. Apple M-series GPU Physical device another GPU, if any Can it present to this surface? Logical device chosen GPU + the features and device extensions you enabled Queue from a family that can draw and present asked of each GPU
The instance lists the GPUs. The surface helps decide which one is usable. The chosen GPU is opened as a logical device, which hands out queues.

The instance, layers and extensions

The instance is the program's connection to Vulkan. Creating it is where you declare which version of the API you're written against (1.3 here), and what optional extras you want:

Physical and logical devices

A physical device is a GPU as Vulkan reports it: its name, its type (built-in or discrete), its limits, and what optional features it supports. You can ask questions of it, but you can't use it yet. A logical device is what you create from it: your own session with that GPU, with exactly the features and extensions you asked for switched on. Nearly every other Vulkan object is created from the logical device, and it's what the codebase means whenever it says device.

RustyVulkan's choice is simple: prefer a discrete graphics card, but only if it can do everything the renderer needs. On an Apple Silicon Mac there is only one GPU, so the question is really "does this one qualify?".

Queues and queue families

Work reaches the GPU through queues. A GPU groups its queues into families by what they can do: graphics, compute, data transfer. Whether a family can show images in a particular window ("present to a surface") is a separate question that depends on the window. That's why the window's surface is created before the GPU is chosen. This renderer looks for one family that can both draw and present, and uses a single queue from it for everything.

Skip to next overview →

Deeper dive

All of this happens in VulkanBase::new in src/vulkan/base/setup.rs, which builds the whole backend in one long function. It reads top to bottom in the order of the diagram. (Patterns from Chapter 2 aren't re-explained.)

Checking for the validation layer

let entry = Entry::linked();

#[cfg(debug_assertions)]
let layer_names = [c"VK_LAYER_KHRONOS_validation"];
#[cfg(debug_assertions)]
let layer_name_ptrs: Vec<*const i8> = layer_names.iter().map(|s| s.as_ptr()).collect();

#[cfg(not(debug_assertions))]
let layer_name_ptrs: Vec<*const i8> = Vec::new();

#[cfg(debug_assertions)]
{
    unsafe {
        let available_layers = entry.enumerate_instance_layer_properties()?;
        let validation_layer_name = c"VK_LAYER_KHRONOS_validation";
        let is_layer_available = available_layers.iter().any(|layer| {
            let name = CStr::from_ptr(layer.layer_name.as_ptr());
            name == validation_layer_name
        });
        if !is_layer_available {
            return Err("Validation layers requested, but not available.".into());
        }
    }
    println!("✅ Validation layers available and requested.");
}
src/vulkan/base/setup.rs:460-484

Entry::linked() gives the function table for the loader linked in at build time. Then, in a debug build, the code asks the loader which layers are installed. If the validation layer is missing, it fails with a clear message rather than silently running unchecked (so debug runs need the layer installed, as DEVELOPMENT.md says). Every item marked #[cfg(debug_assertions)], even the bare { … } block, is compiled only into debug builds (Chapter 1). In a release build the layer list is simply empty.

Creating the instance

create_instance starts by describing the application and collecting the instance extensions it needs:

let app_name = std::ffi::CString::new("RustyVulkan")?;

let app_info = vk::ApplicationInfo {
    p_application_name: app_name.as_ptr(),
    application_version: vk::make_api_version(0, 1, 0, 0),
    p_engine_name: app_name.as_ptr(),
    engine_version: vk::make_api_version(0, 1, 0, 0),
    api_version: vk::API_VERSION_1_3,
    ..Default::default()
};

let ext_names =
    enumerate_required_extensions(event_loop.display_handle().unwrap().as_raw())?;
let mut extension_ptrs: Vec<*const i8> = ext_names.to_vec();

#[cfg(debug_assertions)]
extension_ptrs.push(ash::ext::debug_utils::NAME.as_ptr());
src/vulkan/base/setup.rs:20-36

enumerate_required_extensions comes from the ash-window crate. It asks the windowing system which surface extensions this platform needs (on macOS, the generic surface extension plus a Metal-specific one), so the code never names them. In debug builds it adds VK_EXT_debug_utils, which lets the validation layer report back to us.

Then comes a small piece of driver diplomacy:

// Enable portability enumeration only when the active loader exposes it.
// KosmicKrisp does not need it, while MoltenVK and some other drivers do.
let mut instance_flags = vk::InstanceCreateFlags::empty();
if supports_portability_enum {
    extension_ptrs.push(vk::KHR_PORTABILITY_ENUMERATION_NAME.as_ptr());
    instance_flags |= vk::InstanceCreateFlags::ENUMERATE_PORTABILITY_KHR;
}
src/vulkan/base/setup.rs:46-52

Some Vulkan implementations, MoltenVK being the best known, run on top of another API and can't support every part of the specification. Vulkan calls these portability drivers. To stop programs accidentally using one, the loader hides them unless the instance opts in with this flag and extension. KosmicKrisp doesn't need it, but rather than hard-coding a driver, the code opts in whenever the loader offers the extension. After that, the create call shown in Chapter 2 makes the instance.

Hearing from the validation layer

With the debug extension enabled, the code registers a debug messenger: a callback that the validation layer calls whenever it has something to say.

let loader = ash::ext::debug_utils::Instance::new(entry, instance);
let create_info = vk::DebugUtilsMessengerCreateInfoEXT {
    message_severity: vk::DebugUtilsMessageSeverityFlagsEXT::VERBOSE
        | vk::DebugUtilsMessageSeverityFlagsEXT::WARNING
        | vk::DebugUtilsMessageSeverityFlagsEXT::ERROR,
    message_type: vk::DebugUtilsMessageTypeFlagsEXT::GENERAL
        | vk::DebugUtilsMessageTypeFlagsEXT::VALIDATION
        | vk::DebugUtilsMessageTypeFlagsEXT::PERFORMANCE,
    pfn_user_callback: Some(vulkan_debug_callback),
    ..Default::default()
};
let messenger = unsafe { loader.create_debug_utils_messenger(&create_info, None)? };
src/vulkan/base/setup.rs:705-716

The callback itself just formats the message and prints it to standard error. Returning vk::FALSE tells the layer not to abort the Vulkan call that triggered the message:

#[cfg(debug_assertions)]
unsafe extern "system" fn vulkan_debug_callback(
    message_severity: vk::DebugUtilsMessageSeverityFlagsEXT,
    message_type: vk::DebugUtilsMessageTypeFlagsEXT,
    p_callback_data: *const vk::DebugUtilsMessengerCallbackDataEXT,
    _p_user_data: *mut std::ffi::c_void,
) -> vk::Bool32 {
    unsafe {
        let message = CStr::from_ptr((*p_callback_data).p_message);
// ...
        eprintln!("{} {} {:?}", severity, ty, message);
    }
    vk::FALSE
}
src/vulkan/base.rs:85-93,107-110

Choosing a GPU

The window surface is created next (Chapter 4), because presentation support is needed to judge GPUs. Then choose_device_and_queue_family tries each GPU, first looking only at discrete ones and then at everything else:

for prefer_discrete in [true, false] {
    for &physical_device in physical_devices {
        let props = unsafe { instance.get_physical_device_properties(physical_device) };
        if (props.device_type == vk::PhysicalDeviceType::DISCRETE_GPU) != prefer_discrete {
            continue;
        }

        // Scene and grass shaders share a 144-byte push-constant ABI.
        // Vulkan 1.3 only guarantees 128 bytes, so check before creating resources.
        if props.limits.max_push_constants_size
            < crate::graphics::pipeline::PUSH_CONSTANT_BYTES
        {
            eprintln!(
                "Skipping GPU: maxPushConstantsSize={} bytes; renderer requires {}",
                props.limits.max_push_constants_size,
                crate::graphics::pipeline::PUSH_CONSTANT_BYTES,
            );
            continue;
        }

        let extensions =
            unsafe { instance.enumerate_device_extension_properties(physical_device)? };
        let has_extension = |required: &CStr| {
            extensions.iter().any(|extension| {
                let name = unsafe { CStr::from_ptr(extension.extension_name.as_ptr()) };
                name == required
            })
        };
        if !REQUIRED_DEVICE_EXTENSIONS
            .iter()
            .all(|&name| has_extension(name))
        {
            continue;
        }

        if props.api_version < vk::API_VERSION_1_3 {
            continue;
        }
        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
        };
        if !supports_required_features {
            continue;
        }

        let queue_families = unsafe {
            instance.get_physical_device_queue_family_properties(physical_device)
        };
        for (index, family) in queue_families.iter().enumerate() {
            if !family.queue_flags.contains(vk::QueueFlags::GRAPHICS) {
                continue;
            }
            if unsafe {
                surface_loader.get_physical_device_surface_support(
                    physical_device,
                    index as u32,
                    surface,
                )?
            } {
                println!("🎯 Found graphics/present queue family at index {}", index);
                return Ok((physical_device, index as u32));
            }
        }
    }
}

Err("No Vulkan 1.3 device supports graphics, presentation, dynamic rendering, synchronization2, push descriptors, maintenance5, and at least 144 bytes of push constants".into())
src/vulkan/base/setup.rs:96-170

For each candidate, it applies four tests in turn and moves to the next GPU (continue) on the first failure:

  1. Can it hold this renderer's per-draw data? The scene and grass use 144 bytes of push constants (Chapter 7). Vulkan 1.3 guarantees only 128, so the device's reported limit must be checked. A GPU below 144 is skipped with a diagnostic before any pipeline is created.
  2. Does it have the required device extensions? REQUIRED_DEVICE_EXTENSIONS lists them: the swapchain extension, without which there's nothing to display; VK_KHR_push_descriptor, which lets draws write texture descriptors straight into the command buffer (Chapter 8); and VK_KHR_maintenance5, which lets pipelines take SPIR-V directly (Chapter 6).
  3. Does it support the features this renderer uses? It draws with dynamic rendering (Chapter 7) and records its barriers with the clearer synchronization2 commands (Chapter 10), both Vulkan 1.3 features. An extension can also come with features of its own that must be checked separately, as maintenance5 does. The GPU must report Vulkan 1.3, and then report all three features as supported, using the push_next query shown in Chapter 2.
  4. Is there a queue family that can both draw and present to our surface? The first one found wins, and the function returns the GPU and that family's index together.

has_extension is a closure that captures the GPU's extension list, and .iter().all(…) asks it about each required name in turn.

Once a GPU is chosen, new records a few of its capabilities for later:

let sample_count_flags_supported = chosen_props.limits.framebuffer_color_sample_counts
    & chosen_props.limits.framebuffer_depth_sample_counts;
let supported_features = unsafe { instance.get_physical_device_features(physical_device) };
let supports_wireframe = supported_features.fill_mode_non_solid == vk::TRUE;
let supports_multi_draw_indirect = supported_features.multi_draw_indirect == vk::TRUE;
let supports_draw_indirect_first_instance =
    supported_features.draw_indirect_first_instance == vk::TRUE;
src/vulkan/base/setup.rs:514-520

The supported multisample counts for colour and depth are intersected, giving the anti-aliasing options the UI can offer (Chapter 10). Multi-draw indirect, which lets one command issue many draws, and indirect "first instance", which lets those commands pick out a range of instances, are both used by the grass if available (Chapter 14). The & between two flag sets is a bitwise AND, just as in C.

Opening the logical device

create_logical_device_and_queue asks for exactly what the renderer uses and nothing more:

let device_features = vk::PhysicalDeviceFeatures {
    // LINE polygon mode requires this feature to be enabled, not just supported.
    fill_mode_non_solid: if enable_wireframe {
        vk::TRUE
    } else {
        vk::FALSE
    },
    multi_draw_indirect: if enable_multi_draw_indirect {
        vk::TRUE
    } else {
        vk::FALSE
    },
    draw_indirect_first_instance: if enable_draw_indirect_first_instance {
        vk::TRUE
    } else {
        vk::FALSE
    },
    ..Default::default()
};

let queue_info = vk::DeviceQueueCreateInfo {
    s_type: vk::StructureType::DEVICE_QUEUE_CREATE_INFO,
    queue_family_index,
    queue_count: 1,
    p_queue_priorities: queue_priority.as_ptr(),
    ..Default::default()
};
// ...
let mut vulkan13_features = vk::PhysicalDeviceVulkan13Features {
    dynamic_rendering: vk::TRUE,
    synchronization2: vk::TRUE,
    ..Default::default()
};
let mut maintenance5_features = vk::PhysicalDeviceMaintenance5FeaturesKHR {
    maintenance5: vk::TRUE,
    ..Default::default()
};

let device_create_info = vk::DeviceCreateInfo {
    p_queue_create_infos: &queue_info,
    queue_create_info_count: 1,
    pp_enabled_extension_names: device_extensions.as_ptr(),
    enabled_extension_count: device_extensions.len() as u32,
    p_enabled_features: &device_features,
    ..Default::default()
}
.push_next(&mut vulkan13_features)
.push_next(&mut maintenance5_features);

let device = unsafe { instance.create_device(physical_device, &device_create_info, None)? };
let queue = unsafe { device.get_device_queue(queue_family_index, 0) };
println!("✅ Logical device and graphics queue ready");
Ok((device, queue))
src/vulkan/base/setup.rs:191-217,237-261

Reading it in order:

What else new sets up

After the device, VulkanBase::new carries on creating the objects the rest of the guide is about. In order: a pipeline cache shared by all pipelines (Chapter 7); a command pool (Chapter 9); the VMA memory allocator (Chapter 5); the global descriptor layout (Chapter 8); the swapchain (Chapter 4); uniform buffers, descriptor sets and the shadow sampler; per-frame fences and semaphores and the command buffers (Chapter 9); and a timestamp query pool used to measure GPU time per pass, which is where the "GPU: 11.05 ms" in the screenshot's menu bar comes from. Everything ends up as a field of the VulkanBase struct, which is the single owner from Chapter 2.

Recap