Your First Triangle in Vulkan
You have a window and a surface from the platform layer, and you want anything at all on screen. In Vulkan, a single colored triangle runs to roughly a thousand lines, because you declare every piece yourself: the instance, the device and its queues, the swapchain, the pipeline, the command buffers, and the synchronization that keeps the CPU and GPU out of each other's way. This page builds it in C++ and Rust (ash), with dynamic rendering, and puts the part most people get wrong first (semaphores vs fences) front and center, so your triangle doesn't hang, tear or flood the validation log on the first resize.
- Create and tear down every Vulkan object a triangle needs, in an order validation accepts.
- Pick a GPU, its graphics and present queues, and a swapchain format, extent, image count and present mode that work on any machine.
- Build a pipeline for dynamic rendering, and record the layout barriers a render pass used to do for you.
- Write a render loop that uses fences and semaphores correctly with two frames in flight.
- Survive window resizes and minimizes without a hang or a validation error.
01Why ~1000 lines
If you've drawn a triangle in OpenGL, it took far less code. OpenGL hid the instance, the device, the framebuffer, and the synchronization behind global state and the driver. Vulkan is explicit by design: you declare all of it. That's why a from-scratch colored triangle runs on the order of a thousand lines: a little over 900 in the original C-API Vulkan Tutorial[1], about 1,000 (heavily commented) in Sascha Willems' Vulkan 1.3 triangle sample[14]. The payoff is control over CPU-side submission and multi-threaded command recording, not a higher frame rate for one triangle.
vk-bootstrap collapses instance, physical-device, device, queue, and swapchain setup; its README shows "500 lines slimmed down to about 50"[12], and vkguide.dev builds on it[2]. VMA handles memory allocation[13]. We write the longhand here so the machine is visible, then point at the shortcut. And if you want portability and safety over control, wgpu is a higher-level Rust API that runs on Vulkan, Metal, D3D12, and OpenGL backends[15].
Vulkan object lifetimes are the dependency order: an object has to exist before anything built from it, and has to outlive everything built from it. So the order you build in, reversed, is the teardown order.
Worked example
This page creates, in order: the instance, the surface, the device (after picking a physical device, which you never create or destroy), the swapchain and its image views, the shader modules, the pipeline layout and pipeline, the command pool and its command buffers, and the semaphores and fences. Shutdown runs it backwards: fences and semaphores, command pool (which frees its buffers), pipeline, pipeline layout, image views, swapchain, device, surface, instance. The shader modules are the exception: you can destroy them right after the pipeline is created.
The first widget is that dependency graph; come back to it as each object appears.
02Instance & validation
Vulkan does almost no error checking by default: pass a wrong value and you get a crash, a black screen or, worse, something that works on your GPU and breaks on someone else's. The fix is set up in the very first object you create. The VkInstance is your application's connection to Vulkan. You give it an app info (with the API version you target), the instance extensions (the surface extensions came from the Platform & Window tutorial), and the validation layer, a debug layer that checks every call against the spec and reports misuse.
Worked example
apiVersion packs four numbers into one 32-bit integer: variant in the top 3 bits, then major, minor and patch. VK_API_VERSION_1_3 is (1 << 22) | (3 << 12) = 4,194,304 + 12,288 = 4,206,592, which is what you'll see in a debugger. It says "this app is written against 1.3". Whether the GPU can actually do the 1.3 features you use is a separate question, answered when you create the device.
Turn on the single meta-layer VK_LAYER_KHRONOS_validation (from the Vulkan SDK) in debug builds and off in release[1]. Wire a VK_EXT_debug_utils messenger, a callback that receives validation messages, so they reach your log. Two traps: validation is a separate layer that must be installed, and requesting it on a machine without it makes vkCreateInstance fail with VK_ERROR_LAYER_NOT_PRESENT, so check vkEnumerateInstanceLayerProperties first and drop it if it's missing. And to catch errors during instance creation itself, chain a bootstrap messenger via the instance's pNext, the pointer most Vulkan structs use to chain extra structs onto them. Also: apiVersion is the highest version you target; it does not enable newer features (those are gated at device creation). And the surface is an instance extension while the swapchain is a device extension, a classic mix-up.
VkApplicationInfo appInfo{ VK_STRUCTURE_TYPE_APPLICATION_INFO };
appInfo.apiVersion = VK_API_VERSION_1_3; // highest version targeted, not a feature switch
const char* layers[] = { "VK_LAYER_KHRONOS_validation" }; // debug builds only
// extensionNames = surface extensions from the platform layer + VK_EXT_debug_utils
VkInstanceCreateInfo createInfo{ VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO };
createInfo.pApplicationInfo = &appInfo;
createInfo.enabledLayerCount = 1; createInfo.ppEnabledLayerNames = layers;
createInfo.enabledExtensionCount = (uint32_t)extensionNames.size();
createInfo.ppEnabledExtensionNames = extensionNames.data();
VkResult result = vkCreateInstance(&createInfo, nullptr, &instance);
// VK_ERROR_LAYER_NOT_PRESENT: the validation layer isn't installed on this machine
// ash 0.38: ::default() + consuming setters (no more ::builder()).
let app_info = vk::ApplicationInfo::default()
.api_version(vk::make_api_version(0, 1, 3, 0)); // targeted version
let layers = [c"VK_LAYER_KHRONOS_validation".as_ptr()]; // debug only
let create_info = vk::InstanceCreateInfo::default()
.application_info(&app_info)
.enabled_layer_names(&layers)
.enabled_extension_names(&extension_names); // surface exts + debug_utils
// Err(ERROR_LAYER_NOT_PRESENT): the validation layer isn't installed on this machine
let instance = unsafe { entry.create_instance(&create_info, None)? };
03Devices & queues
A laptop can have two GPUs, a weak integrated one and a fast discrete one, and your program has to pick one and say which of its queues it will use. Enumerate the physical devices (the GPUs the instance can see), score them, and pick one that supports the swapchain extension. Then find the queue families you need. A queue family is a group of the GPU's work queues with the same abilities. You need a graphics family (a capability bit) and a present family (a surface-specific query: can this family show images on this window?). Create the logical VkDevice, your handle to the chosen GPU with exactly the features and queues you enable, from one queue-create-info per unique family index.
Worked example
Say the GPU reports family 0 (graphics, compute and transfer), family 1 (transfer only) and family 2 (compute only), and the present query says yes for family 0. Then graphics = 0 and present = 0, the unique set is {0}, and you pass one queue-create-info and get both queues from it. If present were only supported on family 2, the set would be {0, 2}: two create-infos, two different queues, and a swapchain that has to be shared between them.
They are not guaranteed distinct and not guaranteed the same[1]. Store each as an optional index; if they differ, create two queues (and the swapchain uses concurrent sharing or an explicit ownership transfer). The official tutorial simplifies to "one family that does both"; a real engine handles both cases. Two more: dedupe the unique indices (passing the same index twice in the queue array is invalid), and a feature must be enabled, not just available: dynamicRendering is a field in VkPhysicalDeviceVulkan13Features (core in 1.3)[2], or the VK_KHR_dynamic_rendering extension before that.
// present support is a SEPARATE, surface-specific query:
VkBool32 presentSupport = VK_FALSE;
vkGetPhysicalDeviceSurfaceSupportKHR(physicalDevice, familyIndex, surface, &presentSupport);
std::set<uint32_t> uniqueFamilies = { graphicsFamily, presentFamily }; // dedupe: the same index twice is invalid
std::vector<VkDeviceQueueCreateInfo> queueInfos;
float queuePriority = 1.0f;
for (uint32_t family : uniqueFamilies)
queueInfos.push_back({ VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO, nullptr, 0, family, 1, &queuePriority });
VkPhysicalDeviceVulkan13Features features13{ VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES };
features13.dynamicRendering = VK_TRUE; // enable the feature, not just check it
const char* deviceExtensions[] = { VK_KHR_SWAPCHAIN_EXTENSION_NAME }; // DEVICE extension
VkDeviceCreateInfo deviceInfo{ VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO, &features13 }; // feature struct rides on pNext
deviceInfo.queueCreateInfoCount = (uint32_t)queueInfos.size(); deviceInfo.pQueueCreateInfos = queueInfos.data();
deviceInfo.enabledExtensionCount = 1; deviceInfo.ppEnabledExtensionNames = deviceExtensions;
vkCreateDevice(physicalDevice, &deviceInfo, nullptr, &device);
vkGetDeviceQueue(device, graphicsFamily, 0, &graphicsQueue);
vkGetDeviceQueue(device, presentFamily, 0, &presentQueue);
let surface_loader = ash::khr::surface::Instance::new(&entry, &instance);
let present_support = unsafe {
surface_loader.get_physical_device_surface_support(physical_device, family_index, surface)?
};
use std::collections::HashSet;
let unique_families: HashSet<u32> = [graphics_family, present_family].into_iter().collect(); // dedupe
let queue_priority = [1.0f32];
let queue_infos: Vec<_> = unique_families.iter().map(|&family| vk::DeviceQueueCreateInfo::default()
.queue_family_index(family).queue_priorities(&queue_priority)).collect();
let mut features13 = vk::PhysicalDeviceVulkan13Features::default().dynamic_rendering(true);
let device_extensions = [ash::khr::swapchain::NAME.as_ptr()]; // DEVICE extension
let device_info = vk::DeviceCreateInfo::default()
.queue_create_infos(&queue_infos)
.enabled_extension_names(&device_extensions)
.push_next(&mut features13); // feature struct rides on p_next
let device = unsafe { instance.create_device(physical_device, &device_info, None)? };
let graphics_queue = unsafe { device.get_device_queue(graphics_family, 0) };
let present_queue = unsafe { device.get_device_queue(present_family, 0) };
04The swapchain
The GPU can't draw straight onto the window: while the display shows one image, you need another to draw into. The (VK_KHR_swapchain) is that rotating set of presentable images, tied to the surface. You choose a surface format (prefer B8G8R8A8_SRGB with the sRGB color space, but enumerate vkGetPhysicalDeviceSurfaceFormatsKHR and fall back: the pair is not guaranteed), a present mode, an image count, and an extent (the images' size in pixels), then create one VkImageView per image to render into. An image view says how to read an image: which format, which mip levels, which layers.
Worked example
A 1280 × 720 window on a display at 150% scaling has a 1920 × 1080 framebuffer, so that's the extent you want (when currentExtent isn't fixed for you). Say the surface reports minImageCount 2 and maxImageCount 0. You request 2 + 1 = 3, and 0 means there's no upper limit, so 3 stands. At 4 bytes per pixel each image is 1920 × 1080 × 4 = 8,294,400 bytes, so the swapchain holds about 24.9 MB of images.
- FIFO is the only present mode guaranteed available (v-synced, no tearing). The present mode is the rule for how finished images reach the display. MAILBOX and IMMEDIATE are optional; query
vkGetPhysicalDeviceSurfacePresentModesKHRand fall back to FIFO[6]. - Extent has a special case. If
currentExtentis a concrete value, use it (on WindowsminImageExtentandmaxImageExtentboth equal it, so nothing else is valid); if it's the sentinel0xFFFFFFFF(Wayland, for one), you pick the size, clamped tomin/maxImageExtent[5]. Use the framebuffer pixel size, not the logical window size. - Image count: request
minImageCount + 1, and clamp tomaxImageCountonly when it's nonzero (0 means no limit). The number of swapchain images is not the same as frames in flight (§9).
05Shaders → SPIR-V
In OpenGL you hand the driver shader source text at runtime, and every vendor's driver compiles it with its own front end. Vulkan doesn't take GLSL (OpenGL's C-like shading language). It consumes SPIR-V bytecode, a binary intermediate format, which you precompile offline with glslc (from the SDK); HLSL and Slang are also valid SPIR-V sources[7]. For the hardcoded triangle there's no vertex buffer at all: the vertex shader indexes a constant array with the built-in gl_VertexIndex, and the draw is vkCmdDraw(cmd, 3, 1, 0, 0).
// triangle.vert, compiled with: glslc triangle.vert -o triangle.vert.spv
#version 450
layout(location = 0) out vec3 fragColor;
vec2 positions[3] = vec2[](vec2(0.0, -0.5), vec2(0.5, 0.5), vec2(-0.5, 0.5));
vec3 colors[3] = vec3[](vec3(1,0,0), vec3(0,1,0), vec3(0,0,1));
void main() {
gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0); // gl_VertexIndex, not gl_VertexID
fragColor = colors[gl_VertexIndex];
}
Read the .spv as uint32-aligned bytes (ash has ash::util::read_spv[3]) and wrap it in a VkShaderModule. Note: Vulkan's clip space, the space gl_Position is written in, is Y-down with a 0..1 depth range, so a triangle that looks right in OpenGL renders upside down here. For the hardcoded triangle the positions are simply picked to look right, and the camera tutorial handles the projection properly.
Worked example
In an 800 × 600 window, the viewport maps NDC x from −1..1 to 0..800 and y from −1..1 to 0..600, with y growing downward. The shader's first corner, (0.0, −0.5), lands at ((0 + 1) ÷ 2 × 800, (−0.5 + 1) ÷ 2 × 600) = (400, 150), the top middle. The other two, (0.5, 0.5) and (−0.5, 0.5), land at (600, 450) and (200, 450). A negative y means up the screen here, which is why the apex has y = −0.5. As for the bytes: a .spv file is a sequence of 32-bit words starting with the magic number 0x07230203, so on a little-endian machine its first four bytes read 03 02 23 07.
06The graphics pipeline
Every draw needs the GPU to know which shaders run and how each fixed stage is set up, and Vulkan wants all of that decided and compiled before the frame starts. The is the concrete form of the "baked state object" from The GPU & the Graphics Pipeline: it freezes the shader stages, the fixed-function state (input assembly, rasterizer, multisample, depth/stencil, color blend: the stages you configure but don't program), and the pipeline layout into one object[8].
If you bake the viewport into the pipeline, every window resize forces a new pipeline, and dragging a window edge can deliver a new size every frame. Instead declare VK_DYNAMIC_STATE_VIEWPORT and VK_DYNAMIC_STATE_SCISSOR and set them at record time[8]. Also: the VkPipelineLayout (the descriptor and push-constant interface: which buffers, textures and small constants the shaders can see, all empty for this triangle) is a separate object from the pipeline; beginners conflate them.
07Dynamic rendering
Older Vulkan code makes you declare every image you'll draw into, and how, in two extra objects before you can draw at all. This page uses (core in Vulkan 1.3), which removes those two objects, VkRenderPass and VkFramebuffer, entirely. At pipeline creation you chain a VkPipelineRenderingCreateInfo (with the color attachment format) into the pipeline's pNext and set renderPass = VK_NULL_HANDLE; at record time you bracket drawing with vkCmdBeginRendering / vkCmdEndRendering[9].
An image's layout is how its pixels are arranged in memory for the job at hand, and the GPU needs a different one for drawing than for display. A render pass transitions the swapchain image for you through its attachment's initial and final layouts (UNDEFINED → COLOR_ATTACHMENT_OPTIMAL before drawing, → PRESENT_SRC_KHR after). Without a render pass, you insert those with a pipeline barrier each frame[9], and leaving them out is a common dynamic-rendering bug. And the attachment format in VkPipelineRenderingCreateInfo must match the format of the view you pass to vkCmdBeginRendering. On tile-based mobile GPUs, render-pass subpasses (or dynamic rendering's local reads, core in Vulkan 1.4) are still how one pass reads another's output without leaving the tile, so dynamic rendering is simpler rather than universally better.
Worked example
Each frame the swapchain image needs two barriers. Before drawing: old layout UNDEFINED, new layout COLOR_ATTACHMENT_OPTIMAL, making the color-attachment-output stage's writes wait for it. UNDEFINED tells the driver it may throw away the old contents, which is fine because the frame clears the whole image. After drawing: COLOR_ATTACHMENT_OPTIMAL to PRESENT_SRC_KHR, waiting on those color writes, so the display never reads a half-written image. Leave the first one out and the validation layer reports the image in the wrong layout; leave the second out and you present an image in the wrong layout, which validation also flags and which can show garbage.
// No render pass: declare the attachment format the pipeline renders to.
// The array needs a name: the builder borrows it until the pipeline is created.
let color_formats = [swapchain_format]; // must match the view given to begin_rendering
let mut rendering = vk::PipelineRenderingCreateInfo::default()
.color_attachment_formats(&color_formats);
let pipeline_info = vk::GraphicsPipelineCreateInfo::default()
.stages(&stages)
.vertex_input_state(&vertex_input) // empty: corners come from gl_VertexIndex
.input_assembly_state(&input_assembly)
.viewport_state(&viewport_state) // counts only; the rectangles are dynamic
.rasterization_state(&rasterizer)
.multisample_state(&multisample)
.color_blend_state(&color_blend)
.dynamic_state(&dynamic_state) // VIEWPORT + SCISSOR
.layout(pipeline_layout)
.push_next(&mut rendering); // render_pass stays VK_NULL_HANDLE
let pipeline = unsafe {
device.create_graphics_pipelines(vk::PipelineCache::null(), &[pipeline_info], None)
}.map_err(|(_, error)| error)?[0]; // Err carries any partial results too
08Command buffers
The GPU doesn't run your API calls as you make them. You write the frame's commands into a list on the CPU and hand the whole list over. That list is a command buffer, and a VkCommandPool (created for the graphics queue family) is the allocator it comes from. Each frame you record: transition the image to color-attachment, vkCmdBeginRendering, bind the pipeline, set the dynamic viewport and scissor, vkCmdDraw(3, 1, 0, 0), vkCmdEndRendering, transition to present.
A command buffer can only be submitted to a queue of the family its pool was created for; that binding is fixed. And command pools are not internally synchronized: you can't record into buffers from one pool on two threads at once, so the standard pattern is one pool per thread and per frame-in-flight. With 2 frames in flight that's 2 pools for a single-threaded renderer, and 8 if 4 threads record. The three triangle vertices come from gl_VertexIndex 0/1/2, not a bound buffer.
09The render loop & sync
The CPU and GPU run at the same time, and without coordination the CPU will overwrite a command buffer the GPU is still reading, or the GPU will draw into an image the display is still showing. Per frame: wait the in-flight fence, acquire the next swapchain image, reset the fence, record, submit, present. (The loop below is shown in Rust/ash; the C++ is the identical vk* calls in the same order.) The synchronization is where most first renderers go wrong, so be precise about the two primitives[4]:
- order GPU work, queue to queue: one operation signals, a later one waits. A binary semaphore is not waited on by the CPU.
- Fences are the CPU-GPU sync: the CPU calls
vkWaitForFencesto know the GPU finished.
Trying to vkWaitForFences on a semaphore, or expecting a binary semaphore to block the CPU, is the classic mistake: semaphores order GPU work, only fences (or timeline semaphores) are CPU-waitable[4]. The canonical pair is an image-available semaphore (signaled by acquire, waited by the submit) and a render-finished semaphore (signaled by the submit, waited by present, so present never shows a half-drawn frame). Frames in flight (usually 2) duplicates the command buffer and sync objects per frame so the CPU can record frame N+1 while the GPU works on N, and is not the same as the swapchain image count[10]. (One simplification here: a fully robust renderer ties the render-finished semaphore to the swapchain image, not the frame-in-flight, so it never re-signals a semaphore a prior present is still waiting on.)
Frames in flight change how the CPU's and GPU's times combine into the frame time:
In words: with one frame in flight the CPU waits for the GPU to finish before it records the next frame, so the two take turns and their times add. With two, the CPU records the next frame while the GPU draws this one, so the slower of the two sets the pace. Hover any term to see what it stands for.
Worked example
The CPU takes 4 ms to record and submit a frame and the GPU takes 10 ms to draw it. With 1 frame in flight they take turns: 4 + 10 = 14 ms a frame, about 71 fps, with the GPU idle 4 ms of every 14. With 2 frames in flight the CPU records frame N+1 during the GPU's 10 ms on frame N, so a frame comes out every 10 ms, 100 fps. A third frame in flight adds nothing here, because the GPU is already busy all the time, and each extra frame queued adds up to one more frame of input latency.
// in_flight_fence, image_available, render_finished, cmd: this frame-in-flight's copies
unsafe {
device.wait_for_fences(&[in_flight_fence], true, u64::MAX)?; // CPU waits the FENCE
let image_index = match swapchain_loader
.acquire_next_image(swapchain, u64::MAX, image_available, vk::Fence::null())
{
Ok((index, _suboptimal)) => index, // suboptimal still presents; handled below
Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => {
recreate_swapchain()?; // fence untouched, so the next wait returns at once
return Ok(());
}
Err(error) => return Err(error),
};
// Reset only once a submit is certain: resetting before that early return
// would leave the fence unsignaled and deadlock the next frame's wait.
device.reset_fences(&[in_flight_fence])?;
// ... record cmd: barrier→begin_rendering→bind→draw(3,1,0,0)→end_rendering→barrier ...
// Named arrays: the builders borrow them until the call that consumes the struct.
let wait_semaphores = [image_available];
let wait_stages = [vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT]; // only the color write waits
let command_buffers = [cmd];
let signal_semaphores = [render_finished];
let submit = vk::SubmitInfo::default()
.wait_semaphores(&wait_semaphores) // GPU waits the SEMAPHORE
.wait_dst_stage_mask(&wait_stages)
.command_buffers(&command_buffers)
.signal_semaphores(&signal_semaphores);
device.queue_submit(graphics_queue, &[submit], in_flight_fence)?; // fence signaled when the GPU is done
let swapchains = [swapchain];
let image_indices = [image_index];
let present = vk::PresentInfoKHR::default()
.wait_semaphores(&signal_semaphores) // present waits render-finished
.swapchains(&swapchains)
.image_indices(&image_indices);
let stale = match swapchain_loader.queue_present(present_queue, &present) {
Ok(suboptimal) => suboptimal,
Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => true,
Err(error) => return Err(error),
};
if stale || framebuffer_resized { recreate_swapchain()?; } // §10
}
The widget animates the CPU and GPU lanes across seven frames:
Worked example
At real speeds: a 60 Hz display and a renderer that could do 100 fps. FIFO holds the renderer to 60 frames a second, waiting for a free image. MAILBOX lets it render all 100 but shows 60 and throws away 40 every second, with each shown frame as fresh as possible. IMMEDIATE shows all 100 as they finish, about 1.7 swaps per refresh, and each swap mid-scan is a tear line.
Wrong answers, and why: the CPU can only wait on a fence (not a binary semaphore or a barrier); and a missing present mode is a capability you must query, not a VRAM or validation issue.
10Resize & shutdown
The player drags the window to a new size, and the swapchain's images are now the wrong size for it. The swapchain has gone stale, and the acquire/present calls report it: VK_ERROR_OUT_OF_DATE_KHR means you must recreate; VK_SUBOPTIMAL_KHR still presents but should be recreated soon[11].
VK_SUBOPTIMAL_KHRis a success code (≥ 0); a blanketresult != VK_SUCCESScheck mishandles it. Its value is 1,000,001,003, whileVK_ERROR_OUT_OF_DATE_KHRis −1,000,001,004: test for negative to catch errors, and handle the suboptimal case separately.- Don't rely only on the error codes; some platforms don't report them, so also keep an explicit
framebufferResizedflag from the window callback[11]. - On minimize the extent is
(0,0), and a swapchain can't have zero-size images; pause (wait for events) until it's nonzero before recreating. - Recreate after vkDeviceWaitIdle, which blocks until the GPU has finished everything you submitted; destroy in reverse creation order. In Rust/ash, handles have no
Drop: you calldestroy_*yourself, in order. The C API doesn't either; Vulkan-Hpp'svk::raiiRAII wrappers, whose destructors free the handle, destroy automatically (declaration order then matters instead).
11Pitfalls
12What's next
There's a triangle on screen, and the entire scaffold (instance, device, swapchain, pipeline, sync) is reusable. Next, Textures, Samplers & Materials adds images, samplers, and descriptor sets so the triangle can be textured, then the 2D Renderer turns this into a sprite batcher for the 2D-game capstone. The full path is on the series hub.
- Khronos. Vulkan Tutorial, adapted from Alexander Overvoorde's original. docs.vulkan.org/tutorial; original at vulkan-tutorial.com. Validation layers, physical-device/queue-family selection, and the explicit-API model; the original C-API version reaches its first triangle after "a little over 900 lines of code".
- Victor Blanco. Vulkan Guide. vkguide.dev. Vulkan 1.3 with dynamic rendering, structured for an engine; logical-device feature enablement.
- ash (Rust). docs.rs/ash and the README. Version 0.38:
::default()+ consuming setters,push_next,ash::khr/ash::extloader modules. - The Khronos Group. Vulkan Specification, "Synchronization and Cache Control." docs.vulkan.org. Semaphores order GPU queue work (not CPU-waited); fences are the device-to-host sync.
- The Khronos Group. Vulkan Specification, WSI /
VkSurfaceCapabilitiesKHR. docs.vulkan.org. ThecurrentExtent == 0xFFFFFFFFsentinel and the min/max extent clamp. - The Khronos Group. Vulkan Specification,
VkPresentModeKHR. docs.vulkan.org. FIFO is the only present mode required to be supported; MAILBOX/IMMEDIATE are optional. - Khronos. Vulkan Tutorial, "Shader modules." docs.vulkan.org. Vulkan consumes SPIR-V bytecode (via glslc), not GLSL source; the hardcoded-triangle shader and
gl_VertexIndex. - Khronos. Vulkan Tutorial, "Fixed functions." docs.vulkan.org. The baked pipeline state and dynamic viewport/scissor so resize needn't recreate it.
- Lesley Lai. "Vulkan dynamic rendering." lesleylai.info.
VkPipelineRenderingCreateInfowiring and the manual image-layout barriers dynamic rendering requires. - Khronos. Vulkan Tutorial, "Frames in flight." docs.vulkan.org. Per-frame command buffers and sync objects; frames in flight is not the swapchain image count.
- Khronos. Vulkan Tutorial, "Swap chain recreation." docs.vulkan.org.
OUT_OF_DATEvsSUBOPTIMAL, the resize flag, and the minimize (0,0) case. - Charles Giessen / LunarG. vk-bootstrap. github.com/charles-lunarg/vk-bootstrap. Instance, device, queue, and swapchain setup; the README's example shows "500 lines slimmed down to about 50".
- AMD GPUOpen. Vulkan Memory Allocator. gpuopen.com. A widely used sub-allocator that replaces hand-rolled
vkAllocateMemorycalls. - Sascha Willems. Vulkan C++ examples. github.com/SaschaWillems/Vulkan. A large Vulkan example corpus; its
trianglevulkan13sample is a single-file, heavily commented Vulkan 1.3 dynamic-rendering triangle of about 1,000 lines. - gfx-rs. wgpu. wgpu.rs. A safe, portable Rust graphics API implementing WebGPU on top of Vulkan, Metal, D3D12, and OpenGL.