Vulkan Tutorial in C - 005 - App Initialization Part 1
Before we continue, here's an overview of the application's structure that we'll be building from this point onward:
I've grouped the most important Vulkan structs at the top—these represent the core objects in Vulkan. I refer to them as "core objects" because, while creating each of them often requires filling out multiple helper structs, those helpers are only temporary and serve as intermediaries. I've left those helper structs in place, as they aren't essential to the core structure.
I briefly considered wrapping these core objects in an AppContext struct, but decided to keep it simple for now. Feel free to structure your code however you like if you decide to use this as a foundation for a real project. The following code will go inside WinMain:
Subpasses are subdivisions within a render pass. They allow different stages of rendering to share common attachment settings defined by the render pass, enabling the driver to optimize memory usage and reduce costly layout transitions. Rather than creating separate render passes when only a few details change, you can use subpasses to keep things efficient.
In short, a render pass acts as a container for one or more subpasses, and you must define at least one subpass for the render pass to perform any rendering.
In our minimal app, our render pass will:
When using render passes, Vulkan can automatically transition image layouts between different stages. You define the initialLayout and finalLayout for each attachment in the render pass, and Vulkan takes care of switching them when the render pass begins and ends. This means you don’t have to insert manual barriers for these transitions.
However, for operations outside render passes or for more complex scenarios, you’ll need to use pipeline barriers to explicitly transition image layouts. These barriers ensure that the GPU properly synchronizes access to images when their layout changes between different usages, like going from a shader-read layout to a render-target layout. This fine-grained control, although a bit more work, allows for better performance tuning and efficient memory usage in your applications.
Think of it like this: the render pass is the full ingredient list (with details), and the subpass says, "use ingredient #1 here in this particular way." This separation lets you define attachment details once and then reference them in different subpasses as needed.
In practice, the VkAttachmentDescription struct specifies the properties of each attachment, and the VkAttachmentReference struct holds the index, and it also specifies the image layout the attachment should be in during the subpass.
This automated transition within the render pass is one of Vulkan's conveniences. It handles the layout changes for us based on the specifications in out attachment description, so we don't have to manually insert barriers for these transitions. This helps simplify our code while ensuring optimal memory access and performance during each stage of rendering and presentation.
Preserve attachments are a way to ensure that the content of certain attachments isn’t discarded between subpasses. Even if an attachment isn’t actively used in the current subpass, we can specify it as a preserve attachment so that its data remains intact for later subpasses. This is useful when you need to reuse attachment data without having to re-create or re-upload it. We can also ignore this in our simple case.
Since our render pass contains only a single subpass and doesn't require complex synchronization with external operations, we can safely ignore these dependencies.
For that synchronization, we use a semaphore that we call imageAvailableSemaphore. This semaphore signals when the compositor has finished reading from the swapchain image, allowing us to safely start rendering a new frame into it. Without this synchronization, we might attempt to write to an image that is still being read, leading to visual artifacts or crashes.
While the concept of semaphores might seem complex at first, especially if you're new to low-level graphics programming, creating and using them in Vulkan is straightforward. A semaphore in Vulkan is essentially an opaque handle—a simple object that doesn't require any detailed configuration when created. You just need to pass it to the relevant Vulkan functions that will use it for synchronization.
Next, we use another semaphore called the renderFinishedSemaphore. This semaphore is passed to the queue submission function (e.g., vkQueueSubmit), which signals it once the GPU has finished executing the command buffer. Afterward, we pass this semaphore to the presentation function (e.g., vkQueuePresentKHR), which waits on the semaphore before presenting the frame to the screen.
This ensures that the GPU waits until the command buffer has completed execution before presenting the frame. Without this semaphore, the presentation engine could attempt to display an image that is still being rendered, leading to visual corruption or incomplete frames.
This is why we refer to this process as synchronization: it ensures that operations on the GPU occur in the correct order, preventing race conditions and ensuring that frames are displayed correctly.
For example, if you create a command pool for the graphics queue family, all command buffers allocated from that pool can only be submitted to graphics queues. This design helps Vulkan optimize memory management and ensure that command buffers are properly aligned with the capabilities of their target queues.
Command buffers are used to record a sequence of commands that the GPU will execute. You can think of them as a way to "queue up" work for the GPU. When recording commands, you specify them in a specific order, but the GPU is free to reorder them during execution to optimize performance. This reordering is one of the reasons why explicit synchronization is necessary in Vulkan.
If your recorded commands have dependencies (e.g., one command must finish before another can start), you must use synchronization primitives like semaphores, fences, or barriers to enforce those dependencies. In a simple application, such as a minimal rendering loop, you might not need explicit synchronization between commands, but it becomes critical in more complex scenarios.
Next
// Steps done in previous posts
// Create shader module function
// ...
// WinMain application entry point
int CALLBACK
WinMain(HINSTANCE instance, HINSTANCE prevInstance, LPSTR cmdLine, int showCmd)
{
// Steps that we'll be doing from now on
// ...
return 0;
}
Although the shader creation function should be placed above WinMain, it makes more sense to introduce it later. Instead, we'll begin by setting up the WinMain entry point and implementing its necessary steps first.// Create shader module function
// ...
// WinMain application entry point
int CALLBACK
WinMain(HINSTANCE instance, HINSTANCE prevInstance, LPSTR cmdLine, int showCmd)
{
// Steps that we'll be doing from now on
// ...
return 0;
}
I've grouped the most important Vulkan structs at the top—these represent the core objects in Vulkan. I refer to them as "core objects" because, while creating each of them often requires filling out multiple helper structs, those helpers are only temporary and serve as intermediaries. I've left those helper structs in place, as they aren't essential to the core structure.
I briefly considered wrapping these core objects in an AppContext struct, but decided to keep it simple for now. Feel free to structure your code however you like if you decide to use this as a foundation for a real project. The following code will go inside WinMain:
VkRenderPass renderPass;
VkFramebuffer swapchainFramebuffers[3];
VkSemaphore imageAvailableSemaphore;
VkSemaphore renderFinishedSemaphore;
VkCommandPool commandPool;
VkCommandBuffer commandBuffer;
VkPipelineLayout pipelineLayout;
VkPipeline graphicsPipeline;
VkFence frameFence;
VkFramebuffer swapchainFramebuffers[3];
VkSemaphore imageAvailableSemaphore;
VkSemaphore renderFinishedSemaphore;
VkCommandPool commandPool;
VkCommandBuffer commandBuffer;
VkPipelineLayout pipelineLayout;
VkPipeline graphicsPipeline;
VkFence frameFence;
Render Passes and Subpasses
A render pass in Vulkan is a high-level abstraction that defines how framebuffer attachments (like color, depth, and stencil) are used and transitioned during rendering. Although it doesn't directly mirror a specific GPU hardware unit, it represents a complete "pass" through the rendering pipeline—for example, drawing a triangle on screen.Subpasses are subdivisions within a render pass. They allow different stages of rendering to share common attachment settings defined by the render pass, enabling the driver to optimize memory usage and reduce costly layout transitions. Rather than creating separate render passes when only a few details change, you can use subpasses to keep things efficient.
In short, a render pass acts as a container for one or more subpasses, and you must define at least one subpass for the render pass to perform any rendering.
Framebuffer Attachments
Framebuffer attachments are the image views used as render targets. They define where the GPU writes different types of data (color, depth, etc.) during rendering. For example, you might render color data to one image and depth data to another.In our minimal app, our render pass will:
- Define 1 color attachment: The swapchain image.
- Specify load/store operations: Clear the image before rendering and store the result afterward.
- Use 1 subpass: No extra dependencies or multiple subpasses needed.
Image Layouts, Transitions, and Barriers
In Vulkan, an image layout defines how an image is organized in memory for specific operations. For instance, an image might be in a layout optimized for rendering as a color attachment, or in another layout optimized for shader read access. This explicit management helps the GPU access memory more efficiently.When using render passes, Vulkan can automatically transition image layouts between different stages. You define the initialLayout and finalLayout for each attachment in the render pass, and Vulkan takes care of switching them when the render pass begins and ends. This means you don’t have to insert manual barriers for these transitions.
However, for operations outside render passes or for more complex scenarios, you’ll need to use pipeline barriers to explicitly transition image layouts. These barriers ensure that the GPU properly synchronizes access to images when their layout changes between different usages, like going from a shader-read layout to a render-target layout. This fine-grained control, although a bit more work, allows for better performance tuning and efficient memory usage in your applications.
Attachment Descriptions and References
The render pass describes the properties of each attachment (format, sample count, load/store ops, etc.), while the subpass just tells Vulkan which of those attachments to use (by index) and in what layout during that subpass.Think of it like this: the render pass is the full ingredient list (with details), and the subpass says, "use ingredient #1 here in this particular way." This separation lets you define attachment details once and then reference them in different subpasses as needed.
In practice, the VkAttachmentDescription struct specifies the properties of each attachment, and the VkAttachmentReference struct holds the index, and it also specifies the image layout the attachment should be in during the subpass.
Create the Render Pass
// Describe the color attachment (the swapchain image)
VkAttachmentDescription colorAttachment =
{
0, // flags
vk.swapchainImageFormat,
VK_SAMPLE_COUNT_1_BIT, // no multisampling
VK_ATTACHMENT_LOAD_OP_CLEAR, // load operation (clear the screen)
VK_ATTACHMENT_STORE_OP_STORE, // store op (save the result)
VK_ATTACHMENT_LOAD_OP_DONT_CARE, // stencil load op (ignored)
VK_ATTACHMENT_STORE_OP_DONT_CARE, // stencil store op (ignored)
VK_IMAGE_LAYOUT_UNDEFINED, // initial image layout
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR // final layout (optimal to present)
};
VkAttachmentDescription colorAttachments[] = { colorAttachment };
VkAttachmentReference colorAttachmentRef =
{
0, // index of the attachment in the render pass
// layout during rendering (optimal for rendering color data)
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
};
VkAttachmentReference colorAttachmentRefs[] = { colorAttachmentRef };
// Describe the render subpass
VkSubpassDescription subpass =
{
0, // flags
VK_PIPELINE_BIND_POINT_GRAPHICS, // pipeline bind point
0, // input attachment count (ignored)
NULL, // input attachments (ignored)
array_count(colorAttachmentRefs),
colorAttachmentRefs,
NULL, // resolve attachments (ignored)
NULL, // depth stencil attachment (ignored)
0, // preserve attachment count (ignored)
NULL // preserve attachments (ignored)
};
VkSubpassDescription subpasses[] = { subpass };
VkRenderPassCreateInfo renderPassInfo =
{
VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO,
NULL,
0,
array_count(colorAttachments),
colorAttachments,
array_count(subpasses),
subpasses,
0, // dependency count (ignored)
NULL // dependencies (ignored)
};
if (vkCreateRenderPass(vk.device, &renderPassInfo, NULL,
&renderPass) != VK_SUCCESS)
{
assert(!"Failed to create render pass");
}
In the code above, we set the initial image layout to VK_IMAGE_LAYOUT_UNDEFINED, meaning we don't care about its previous contents. Then, during the render pass, the subpass uses VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, which is optimized for writing color data. Finally, when the render pass completes, the image transitions to VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, which readies it for display on the screen.VkAttachmentDescription colorAttachment =
{
0, // flags
vk.swapchainImageFormat,
VK_SAMPLE_COUNT_1_BIT, // no multisampling
VK_ATTACHMENT_LOAD_OP_CLEAR, // load operation (clear the screen)
VK_ATTACHMENT_STORE_OP_STORE, // store op (save the result)
VK_ATTACHMENT_LOAD_OP_DONT_CARE, // stencil load op (ignored)
VK_ATTACHMENT_STORE_OP_DONT_CARE, // stencil store op (ignored)
VK_IMAGE_LAYOUT_UNDEFINED, // initial image layout
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR // final layout (optimal to present)
};
VkAttachmentDescription colorAttachments[] = { colorAttachment };
VkAttachmentReference colorAttachmentRef =
{
0, // index of the attachment in the render pass
// layout during rendering (optimal for rendering color data)
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
};
VkAttachmentReference colorAttachmentRefs[] = { colorAttachmentRef };
// Describe the render subpass
VkSubpassDescription subpass =
{
0, // flags
VK_PIPELINE_BIND_POINT_GRAPHICS, // pipeline bind point
0, // input attachment count (ignored)
NULL, // input attachments (ignored)
array_count(colorAttachmentRefs),
colorAttachmentRefs,
NULL, // resolve attachments (ignored)
NULL, // depth stencil attachment (ignored)
0, // preserve attachment count (ignored)
NULL // preserve attachments (ignored)
};
VkSubpassDescription subpasses[] = { subpass };
VkRenderPassCreateInfo renderPassInfo =
{
VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO,
NULL,
0,
array_count(colorAttachments),
colorAttachments,
array_count(subpasses),
subpasses,
0, // dependency count (ignored)
NULL // dependencies (ignored)
};
if (vkCreateRenderPass(vk.device, &renderPassInfo, NULL,
&renderPass) != VK_SUCCESS)
{
assert(!"Failed to create render pass");
}
This automated transition within the render pass is one of Vulkan's conveniences. It handles the layout changes for us based on the specifications in out attachment description, so we don't have to manually insert barriers for these transitions. This helps simplify our code while ensuring optimal memory access and performance during each stage of rendering and presentation.
Resolve and Preserve attachments
Resolve attachments come into play with multisampling. When you're using MSAA, you render to a multisampled attachment first, then use a resolve attachment to combine those multiple samples into a single final pixel value. In our subpass, since we're not using multisampling, we can ignore this field.Preserve attachments are a way to ensure that the content of certain attachments isn’t discarded between subpasses. Even if an attachment isn’t actively used in the current subpass, we can specify it as a preserve attachment so that its data remains intact for later subpasses. This is useful when you need to reuse attachment data without having to re-create or re-upload it. We can also ignore this in our simple case.
Subpass Dependencies
In a render pass, the dependencies field lets you define explicit synchronization between subpasses (or between external operations and subpasses). This is useful when you need to ensure that one subpass has fully completed its work before another begins, managing both memory and execution dependencies.Since our render pass contains only a single subpass and doesn't require complex synchronization with external operations, we can safely ignore these dependencies.
Create Swapchain image's Framebuffers
for (u32 i = 0; i < array_count(vk.swapchainImageViews); i++)
{
VkImageView frameBufferAttachments[] = { vk.swapchainImageViews[i] };
// Fill framebuffer create info
VkFramebufferCreateInfo framebufferInfo =
{
VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO,
NULL,
0,
renderPass,
array_count(frameBufferAttachments),
frameBufferAttachments,
vk.swapchainExtents.width,
vk.swapchainExtents.height,
1, // layers
};
// Create the framebuffer
if (vkCreateFramebuffer(vk.device, &framebufferInfo, NULL,
&swapchainFramebuffers[i]) != VK_SUCCESS)
{
assert(!"Failed to create framebuffer");
}
}
{
VkImageView frameBufferAttachments[] = { vk.swapchainImageViews[i] };
// Fill framebuffer create info
VkFramebufferCreateInfo framebufferInfo =
{
VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO,
NULL,
0,
renderPass,
array_count(frameBufferAttachments),
frameBufferAttachments,
vk.swapchainExtents.width,
vk.swapchainExtents.height,
1, // layers
};
// Create the framebuffer
if (vkCreateFramebuffer(vk.device, &framebufferInfo, NULL,
&swapchainFramebuffers[i]) != VK_SUCCESS)
{
assert(!"Failed to create framebuffer");
}
}
Semaphores
Semaphores are crucial for GPU-GPU synchronization, ensuring that operations on the GPU occur in the correct order. First, we need to ensure that the swapchain image we want to render into is ready for use. The operating system includes a component called the compositor, which is responsible for reading from the swapchain image and combining it with the output of other applications to produce the final image displayed on the monitor.For that synchronization, we use a semaphore that we call imageAvailableSemaphore. This semaphore signals when the compositor has finished reading from the swapchain image, allowing us to safely start rendering a new frame into it. Without this synchronization, we might attempt to write to an image that is still being read, leading to visual artifacts or crashes.
While the concept of semaphores might seem complex at first, especially if you're new to low-level graphics programming, creating and using them in Vulkan is straightforward. A semaphore in Vulkan is essentially an opaque handle—a simple object that doesn't require any detailed configuration when created. You just need to pass it to the relevant Vulkan functions that will use it for synchronization.
Next, we use another semaphore called the renderFinishedSemaphore. This semaphore is passed to the queue submission function (e.g., vkQueueSubmit), which signals it once the GPU has finished executing the command buffer. Afterward, we pass this semaphore to the presentation function (e.g., vkQueuePresentKHR), which waits on the semaphore before presenting the frame to the screen.
This ensures that the GPU waits until the command buffer has completed execution before presenting the frame. Without this semaphore, the presentation engine could attempt to display an image that is still being rendered, leading to visual corruption or incomplete frames.
This is why we refer to this process as synchronization: it ensures that operations on the GPU occur in the correct order, preventing race conditions and ensuring that frames are displayed correctly.
Create Semaphores
VkSemaphoreCreateInfo semaphoreInfo =
{
VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO,
NULL,
0
};
vkCreateSemaphore(vk.device, &semaphoreInfo, NULL,
&imageAvailableSemaphore);
vkCreateSemaphore(vk.device, &semaphoreInfo, NULL,
&renderFinishedSemaphore);
{
VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO,
NULL,
0
};
vkCreateSemaphore(vk.device, &semaphoreInfo, NULL,
&imageAvailableSemaphore);
vkCreateSemaphore(vk.device, &semaphoreInfo, NULL,
&renderFinishedSemaphore);
Command Pool and Command Buffers
A command pool in Vulkan is essentially a memory allocator for command buffers. It ensures that the command buffers allocated from it are compatible with a specific queue family. When creating a command pool, we must specify which queue family it is associated with. This means that any command buffer allocated from this pool can only be submitted to queues belonging to that queue family.For example, if you create a command pool for the graphics queue family, all command buffers allocated from that pool can only be submitted to graphics queues. This design helps Vulkan optimize memory management and ensure that command buffers are properly aligned with the capabilities of their target queues.
Command buffers are used to record a sequence of commands that the GPU will execute. You can think of them as a way to "queue up" work for the GPU. When recording commands, you specify them in a specific order, but the GPU is free to reorder them during execution to optimize performance. This reordering is one of the reasons why explicit synchronization is necessary in Vulkan.
If your recorded commands have dependencies (e.g., one command must finish before another can start), you must use synchronization primitives like semaphores, fences, or barriers to enforce those dependencies. In a simple application, such as a minimal rendering loop, you might not need explicit synchronization between commands, but it becomes critical in more complex scenarios.
Create Command Pool and Command Buffer
VkCommandPoolCreateInfo commandPoolCreateInfo =
{
VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO,
NULL,
VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT,
vk.graphicsAndPresentQueueFamily
};
if (vkCreateCommandPool(vk.device, &commandPoolCreateInfo, NULL,
&commandPool) != VK_SUCCESS)
{
assert(!"Failed to create a command pool");
}
VkCommandBufferAllocateInfo allocInfo =
{
VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO,
NULL,
commandPool,
VK_COMMAND_BUFFER_LEVEL_PRIMARY,
1 // commandBufferCount
};
vkAllocateCommandBuffers(vk.device, &allocInfo,
&commandBuffer);
I’ll wrap up this post here. In the next post, we’ll continue with the application initialization, specifically we'll create the shaders and start filling the helper structs necessary to create the graphics pipeline.{
VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO,
NULL,
VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT,
vk.graphicsAndPresentQueueFamily
};
if (vkCreateCommandPool(vk.device, &commandPoolCreateInfo, NULL,
&commandPool) != VK_SUCCESS)
{
assert(!"Failed to create a command pool");
}
VkCommandBufferAllocateInfo allocInfo =
{
VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO,
NULL,
commandPool,
VK_COMMAND_BUFFER_LEVEL_PRIMARY,
1 // commandBufferCount
};
vkAllocateCommandBuffers(vk.device, &allocInfo,
&commandBuffer);
Next
Comments
Post a Comment