VKDriven #2 - Project structure

Abstracting away

September 30, 2026

So I finished up https://www.howtovulkan.com which left me with a ~1000 line main.cpp file with everything I needed. The next step was to abstract away all the logic, this blog will talk about the different parts of that process. Here’s a snapshot of the repo as it stood when I wrote this post.

VKDriven viewport showing a colorful monkey head render and rendering statistics

app.cpp

Handles the initialization of the entire program, as well as the render loop. It owns all the various components and coordinates initialization and rendering. You’ll see the various parts it contains talked about below.

Utils

  • check.h
    • Contains helper functions for printing out errors when they occur, uses std::println which is an incredible addition to C++ in the year 2023 😂.
  • handle.h
    • When using Vulkan you do have the option to use Vulkan-Hpp which gives you a C++ API with RAII.
    • In my case, all of the tutorials use the C API, so I stuck with it. handle.h introduces my own RAII wrappers for the common Vulkan objects.

window.cpp

We handle creating the SDL window here with the intent to use SDL w/ Vulkan. There’s some other functionality here such as listening to various SDL window events.

instance.cpp

We begin to actually initialize Vulkan here (connecting our application to Vulkan). We define what version of Vulkan we plan to use VK_API_VERSION_1_3 (1.3) as well as enabling Vulkan Validation Layers (VVL) when we build in debug mode. These help us figure out if we are calling Vulkan APIs correctly.

surface.cpp

The link between the Vulkan instance and the SDL window.

device.cpp

  • The actual physical GPU is found and stored here.
  • We figure out what queue family we want to select.
  • We enable various Vulkan 1.3 features such as dynamic rendering and synchronization2.
  • We create the Vulkan device here

allocator.cpp

  • A wrapper for VulkanMemoryAllocator, we pass this around whenever we need to allocate memory, VMA handles the allocation and managing of Vulkan device memory.
  • Reminds me of my NVIDIA days where the library I was working on allowed people to pass in whatever type of memory allocation method they wanted to use.

buffer.cpp

Wraps a Vulkan buffer and its VMA allocation. Buffers store data such as vertices, indices, and shader parameters.

image.cpp

Wrapper for VkImage and VkImageView, used to store textures (or other things), the image represents the resource, whereas the image view describes how rendering or shaders will access it.

command_pool.cpp

Owns a Vulkan command pool, from which we allocate command buffers to record GPU work. It also provides a helper for one-off submissions, which submits the work to a queue and waits for it to finish before returning.

swapchain.cpp

Creates and manages the Vulkan swapchain.

  • We ask for the minimum number of images the surface supports (capabilities.minImageCount), but the driver is allowed to give us more, so we query how many it actually created. The present mode is FIFO, which is vsync.
  • We also have three different types of swapchain statuses.
    • VK_ERROR_OUT_OF_DATE_KHR the swapchain needs to be recreated (i.e screen resized).
    • VK_SUBOPTIMAL_KHR the swapchain can proceed but is performing in suboptimal standards.
    • VK_SUCCESS. let’s render away.

frame_resources.cpp

Handles the “frames in flight”. We define two frames in flight, which means that the CPU can begin recording frame N+1 while the GPU is still working on frame N. For each frame we need to have two copies of each data.

sync.h

Helpers for creating fences and semaphores, plus pipelineBarrier, which records a vkCmdPipelineBarrier2 (the synchronization2 version of barriers) into a command buffer. Read about how they work in my previous blog.

shader_compiler.cpp

Handles the compilation of the slang shaders, see file_watcher.cpp for how hot-reloading works.

graphics_pipeline.cpp

A pipeline bakes almost all GPU state into one object up front: shaders, vertex layout, depth testing, blending and so on.

  • The vertex layout comes from Vertex (position, normal, UV).
  • Viewport and scissor are dynamic, so resizing doesn’t need a new pipeline.
  • Depth compare is GREATER_OR_EQUAL because I use reverse-Z, which spreads depth precision much more evenly than standard Z.
  • With dynamic rendering there’s no VkRenderPass. The pipeline only needs to know the formats of the images it will draw into (VkPipelineRenderingCreateInfo). Which images is decided later, when recording.

viewport_target.cpp

The scene doesn’t render straight to the screen. It renders into its own colour and depth images, which ImGui then shows inside a dockable “Viewport” window, a bit like a game engine editor. When the window is resized, the images are recreated at the new size.

Putting it together: one frame

Back in app.cpp, each loop does the following…

  1. Polls window events, reloads shaders if they changed, and recreates the swapchain if the window was resized.
  2. Builds the UI and updates the camera.
  3. drawFrame():
    • Waits on this frame-in-flight’s fence, so the GPU is done with its command buffer and data from last time.
    • Acquires a swapchain image. The image might still be in use by the screen, so acquiring hands us a semaphore that’s signalled when it’s actually free.
    • Writes this frame’s view/projection matrices.
    • Records one command buffer: the scene pass, then the UI pass (draw ImGui onto the swapchain image, then move it into the layout for presenting).
    • Submits it. The GPU waits for the “image acquired” semaphore before writing to the swapchain image, signals a “render complete” semaphore when done, and signals the fence for the CPU.
    • Presents, which waits on “render complete” before the image goes to the screen.

Conclusion

I imagine a lot of this will stay the same (with additions), but, I guess we’ll see as we get further into this project.