Vulkan Tutorial in C - 002 - Preparation
Introducing the VulkanContext Struct
Before we dive into the details of initializing Vulkan, let’s first take a look at the VulkanContext struct that holds all the necessary Vulkan objects and resources. This struct will help us keep track of everything we need to interact with Vulkan in a clean and organized way.
When I ported this code to Linux (Ubuntu using XLib), I encountered an issue where the Vulkan driver wouldn't allow me to create a swapchain with only two images. As a result, I had to use three images instead. On Windows, this wasn’t an issue—at least on my machine—but it may vary depending on the driver.
To ensure compatibility, I’m using three swapchain images. However, I haven’t updated the code beyond this point, so you may still see references to two images in the tutorial. Make sure to use three images instead to avoid issues.
Here’s the full definition of the struct:
To ensure compatibility, I’m using three swapchain images. However, I haven’t updated the code beyond this point, so you may still see references to two images in the tutorial. Make sure to use three images instead to avoid issues.
typedef struct
{
HWND window;
VkInstance instance;
VkSurfaceKHR surface;
VkPhysicalDevice physicalDevice;
VkDevice device;
u32 graphicsAndPresentQueueFamily;
VkQueue graphicsAndPresentQueue;
VkSwapchainKHR swapchain;
VkFormat swapchainImageFormat;
VkImage swapchainImages[3];
VkImageView swapchainImageViews[3];
VkExtent2D swapchainExtents;
} VulkanContext;
This struct holds all the Vulkan objects and properties we need for creating and managing our Vulkan application. Let’s go over each field and what it represents:
{
HWND window;
VkInstance instance;
VkSurfaceKHR surface;
VkPhysicalDevice physicalDevice;
VkDevice device;
u32 graphicsAndPresentQueueFamily;
VkQueue graphicsAndPresentQueue;
VkSwapchainKHR swapchain;
VkFormat swapchainImageFormat;
VkImage swapchainImages[3];
VkImageView swapchainImageViews[3];
VkExtent2D swapchainExtents;
} VulkanContext;
HWND window
The window handle for the application. It's used to associate the Vulkan surface with the actual window on the screen. Without it, Vulkan wouldn't know where to render the graphics.
The window handle for the application. It's used to associate the Vulkan surface with the actual window on the screen. Without it, Vulkan wouldn't know where to render the graphics.
VkInstance instance
The core Vulkan object that initializes the Vulkan API. It’s required before you can use any Vulkan features and acts as a connection to the Vulkan driver on the system.
The core Vulkan object that initializes the Vulkan API. It’s required before you can use any Vulkan features and acts as a connection to the Vulkan driver on the system.
VkSurfaceKHR surface
Represents the area where Vulkan will render its images to be displayed. In Vulkan, the term surface doesn't refer to the physical surface like a screen or window directly, but rather a Vulkan-specific abstraction that connects Vulkan's rendering system to the windowing system. It's like a bridge between Vulkan's graphics pipeline and the system’s native window for displaying images.
Represents the area where Vulkan will render its images to be displayed. In Vulkan, the term surface doesn't refer to the physical surface like a screen or window directly, but rather a Vulkan-specific abstraction that connects Vulkan's rendering system to the windowing system. It's like a bridge between Vulkan's graphics pipeline and the system’s native window for displaying images.
VkPhysicalDevice physicalDevice
The actual GPU or graphics hardware that Vulkan will use. Vulkan applications often query this to gather information about available devices and choose the one best suited for the task (e.g., graphics or compute capabilities).
The actual GPU or graphics hardware that Vulkan will use. Vulkan applications often query this to gather information about available devices and choose the one best suited for the task (e.g., graphics or compute capabilities).
VkDevice device
Represents the logical device used to interact with the physical GPU. It's essentially the interface between the Vulkan application and the GPU, providing functions for rendering, memory management, and more.
Represents the logical device used to interact with the physical GPU. It's essentially the interface between the Vulkan application and the GPU, providing functions for rendering, memory management, and more.
u32 graphicsAndPresentQueueFamily;
This stores the index of the queue family that supports both graphics rendering and presenting images to the surface. Vulkan separates different types of operations into "queue families", and this field helps identify the one that can handle both.
This stores the index of the queue family that supports both graphics rendering and presenting images to the surface. Vulkan separates different types of operations into "queue families", and this field helps identify the one that can handle both.
VkQueue graphicsAndPresentQueue
This represents the queue that will handle both graphics commands and presentation commands (i.e., showing the rendered images on the screen). Vulkan requires explicit control over the submission of tasks to the GPU, and queues handle these tasks.
This represents the queue that will handle both graphics commands and presentation commands (i.e., showing the rendered images on the screen). Vulkan requires explicit control over the submission of tasks to the GPU, and queues handle these tasks.
VkSwapchainKHR swapchain
Represents the collection of images used for presenting rendered frames to the screen. It’s the primary mechanism for displaying content in Vulkan, managing the images that get shown on the screen in sync with the GPU’s rendering pipeline.
Represents the collection of images used for presenting rendered frames to the screen. It’s the primary mechanism for displaying content in Vulkan, managing the images that get shown on the screen in sync with the GPU’s rendering pipeline.
VkFormat swapchainImageFormat
The format of the images that will be rendered to the swapchain. It’s important because the format determines how the GPU stores the image data (e.g., color depth, transparency).
The format of the images that will be rendered to the swapchain. It’s important because the format determines how the GPU stores the image data (e.g., color depth, transparency).
VkImage swapchainImages[2]
This is an array holding the images of the swapchain. These images are the ones we will render to. The [2] size indicates a double-buffered setup, where one image is displayed while the other is being rendered to, allowing for smooth frame transitions.
This is an array holding the images of the swapchain. These images are the ones we will render to. The [2] size indicates a double-buffered setup, where one image is displayed while the other is being rendered to, allowing for smooth frame transitions.
VkImageView swapchainImageViews[2]
These are views into the swapchain images. They are used to define how Vulkan should interpret and use the swapchain images (e.g., for rendering or presentation).
These are views into the swapchain images. They are used to define how Vulkan should interpret and use the swapchain images (e.g., for rendering or presentation).
VkExtent2D swapchainExtents
This defines the dimensions (width and height) of the swapchain images. It ensures that the swapchain is created with the correct size for rendering, matching the window size or the intended resolution.
You’ll notice that the VulkanContext struct doesn’t contain every Vulkan-related field we’ll need for the application. There are plenty of additional fields that could be added, and we’ll see some of them later in the tutorial. However, I decided not to include those in this struct, as many of them are app-specific.This defines the dimensions (width and height) of the swapchain images. It ensures that the swapchain is created with the correct size for rendering, matching the window size or the intended resolution.
The fields I’ve included here are mostly app-agnostic (with the exception of the number of swapchain images), meaning they define core Vulkan resources that are common across different applications. This design choice allows us to eventually move the VulkanContext struct and all its initialization code into a generic file that could be reused across multiple projects.
While it could be argued that the graphicsAndPresentQueueFamily and graphicsAndPresentQueue fields are specific to the current app’s setup, I’ve included them in the VulkanContext struct for simplicity. Since this tutorial is geared toward beginners and focused on game development, it’s beneficial to have these fields there for easier reference and use.
Error Checking in This Tutorial
Throughout this tutorial, I've chosen to handle error checking in the simplest way possible: by using assertions. Whenever something could fail, I assert that it worked as expected. You’ll notice these assertions at various points in the code.In a production-ready application, this approach isn’t ideal. Typically, you'd handle errors in a more robust way, such as by returning error codes or using error handling mechanisms. However, keep in mind that this is just a beginner’s tutorial, and none of the code here is intended for a shippable application.
That said, eliminating error checks entirely would be a mistake. Assertions are included so you can quickly identify when something goes wrong and figure out exactly what caused the issue. So, while you’ll see these assertions in the code, know that they serve a purpose: they’ll help you catch mistakes early, making it easier to fix problems and understand how things work.
About Resource Management
In this tutorial, I’ve made the decision not to clean up resources at the end of the app. In a production environment, this is generally not a good practice, but for the sake of brevity and simplicity, we are intentionally omitting cleanup for both the Windows API and Vulkan API.The only exceptions to this rule are cases where a resource can be freed immediately because it will no longer be used. However, for resources that remain in use throughout the entire lifetime of the app, we will not be explicitly freeing them.
Be aware of this decision as you follow along—proper resource management is essential in a real-world application, but for a tutorial, keeping the code straightforward is the priority.
Loading Files
Later in the tutorial, we'll need to load files into memory, specifically shaders. Since shaders are one of the last things we’ll tackle (and you’ll probably be pretty tired by then), let’s take care of this now while we're still fresh and get the file loading utility out of the way.
typedef struct
{
void *data;
size_t size;
} LoadedFile;
LoadedFile
load_entire_file(char *fileName)
{
LoadedFile result = {0};
FILE *handle;
fopen_s(&handle, fileName, "rb");
assert(handle);
fseek(handle, 0, SEEK_END);
result.size = ftell(handle);
fseek(handle, 0, SEEK_SET);
assert(result.size > 0);
result.data = malloc(result.size);
assert(result.data);
size_t bytesRead = fread(result.data, 1, result.size, handle);
assert(bytesRead == result.size);
fclose(handle);
return result;
}
Global Running Variable and Window Procedure
Next, as the final step in this post, we define a globalRunning variable to keep track of whether our app is still running. Alongside that, we create the WindowProc, which is a Win32 API concept that allows our app to respond to window events like mouse movements, keyboard inputs, resizing, and closing. This is a core part of the event-driven nature of Win32 applications.
static bool globalRunning;
LRESULT CALLBACK
vulkan_window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam)
{
switch (message)
{
case WM_CREATE:
{
OutputDebugString("Window created\n");
} break;
case WM_SIZE:
{
OutputDebugString("Window resized\n");
} break;
case WM_CLOSE:
case WM_DESTROY:
{
globalRunning = false;
} break;
default:
{
return DefWindowProc(window, message, wparam, lparam);
} break;
}
return 0;
}
A Word of Caution
The WindowProc function can be a bit finicky. For instance, in the default case, we return the result of calling DefWindowProc. If we were to omit this return statement, the window creation would silently fail without any error messages, which can be frustrating to debug. I made this mistake myself when writing the code, and it took me a while to figure out what went wrong. Another potential pitfall is handling certain messages, like WM_PAINT. If we were to handle that incorrectly, it could cause issues with the app, again without providing any error messages to guide us. Additionally, notice that in the WM_CLOSE and WM_DESTROY cases, we set globalRunning = false. This is crucial—without it, our app’s process would continue running indefinitely (at full speed) after the window is closed. The app would only terminate when you manually close it in Task Manager or shut down the computer.Wrapping Up
At this point, we've set up our VulkanContext struct, added a simple file-loading utility, and established our WindowProc to handle window events. These preparations lay the foundation for what’s coming next. In the next post, we'll dive deeper into initializing Vulkan itself—creating the Vulkan instance, setting up the debug messenger, and getting things ready to start interacting with the GPU.Next
Comments
Post a Comment