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.
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:
- Layers sit between your program and the driver, intercepting every call. The important one is the validation layer, Khronos's checker for Vulkan's usage rules. With it on, mistakes that would otherwise be silent produce a readable message. It costs speed, so this project enables it only in debug builds.
- Extensions add functionality beyond the core API. Some belong to the instance (talking to the window system, receiving debug messages); others belong to a device (the swapchain, for showing images on screen).
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.
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.");
}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());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;
}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)? };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
}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())For each candidate, it applies four tests in turn and moves to the next GPU (continue) on
the first failure:
- 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.
- Does it have the required device extensions?
REQUIRED_DEVICE_EXTENSIONSlists 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); andVK_KHR_maintenance5, which lets pipelines take SPIR-V directly (Chapter 6). - 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
maintenance5does. The GPU must report Vulkan 1.3, and then report all three features as supported, using thepush_nextquery shown in Chapter 2. - 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;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))Reading it in order:
- Features. Multi-draw indirect, indirect first-instance and non-solid fill mode (for wireframe) are requested only when supported. Enabling only what's used keeps device creation working on implementations that lack the rest.
- Queues. One queue from the chosen family. Every queue needs a priority between 0 and
1, passed as a pointer to an array;
queue_priorityis that array, kept in a local variable so the pointer stays valid untilcreate_devicereturns. - Extensions. Built just above (elided): the required device extensions, plus
VK_KHR_portability_subsetif listed. A portability driver uses that one to declare which features it can't provide, and Vulkan requires you to enable it when it's present. - Features, switched on. Supporting a feature isn't enough; it must be
enabled. Dynamic rendering and synchronization2 are both turned on in one
PhysicalDeviceVulkan13Featuresstruct, and maintenance5 in its own struct. Both are chained onto the create info withpush_next, after a struct literal sets the other fields. - The queue.
get_device_queuedoesn't create anything. Queues are made with the device; this just fetches the handle of queue 0 in our family.
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
- The instance declares the API version (1.3), layers and instance extensions. Validation is on in debug builds and reports through a debug-messenger callback.
- Portability enumeration is enabled whenever the loader offers it, so portability drivers stay usable; KosmicKrisp doesn't need it.
- A GPU qualifies if it supports Vulkan 1.3 with dynamic rendering and synchronization2, the swapchain,
push-descriptor and maintenance5 extensions (with the maintenance5 feature), and a queue family that can draw
and present to the window's surface, plus at least 144 bytes of push constants. A discrete GPU is preferred.
Wireframe is optional: supported devices enable non-solid fill mode, while others keep the UI toggle disabled.
It is currently unavailable with KosmicKrisp, pending driver implementation of
fillModeNonSolid. - The logical device enables only the features the renderer uses, and provides the single queue used for all work.