Writing

Vulkan Series: 03 - VMA, GLM and Refactoring

Splitting our Vulkan 1.3 sample into multiple files, and bringing in the Vulkan Memory Allocator (VMA) and GLM

graphicsvulkanc++vmaglmcmakerefactoringvulkan 1.3

What we’ll build here

End Result
End Result

Yep, it’s the same triangle as last time! Don’t close the tab just yet though. This post is all about groundwork: nothing new on the screen, but a much better base to build on for the rest of the series.

At the end of the previous post, our single main.cpp was well over a thousand lines long. That was fine for getting a triangle on screen, but it’s only going to grow from here, so in this post we’ll:

  1. Split the code into multiple files, and pull the graphics pipeline out into its own little class
  2. Add a small Result type for functions that can fail
  3. Bring in the Vulkan Memory Allocator (VMA), which will take care of GPU memory allocation for us
  4. Bring in GLM, the go-to maths library for graphics programming

We won’t actually allocate any memory with VMA, or use GLM in our code, just yet. That’s for the next post, where we’ll finally move our vertices out of the shader and into vertex and index buffers. Here, we just get everything set up and ready.

This post builds directly on top of the coloured triangle post (which in turn builds on the coloured window post), so if you haven’t gone through those yet, I’d strongly recommend doing so first.

Just want the source code? Find it on GitHub or at the end of this post.

Setup

Nothing new to install this time! Same as with GLFW, CMake will download and build VMA and GLM for us. Here’s our updated CMakeLists.txt:

cmake_minimum_required(VERSION 3.28)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

project(I_A_S_ME_VulkanSeries)

include(FetchContent)

set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE)

FetchContent_Declare(glfw
    GIT_REPOSITORY https://github.com/glfw/glfw.git
    GIT_TAG 3.5.1
    GIT_SHALLOW TRUE
)

FetchContent_Declare(VulkanMemoryAllocator
    GIT_REPOSITORY https://github.com/GPUOpen-LibrariesAndSDKs/VulkanMemoryAllocator.git
    GIT_TAG v3.5.0
    GIT_SHALLOW TRUE
)

FetchContent_Declare(glm
    GIT_REPOSITORY https://github.com/g-truc/glm.git
    GIT_TAG 1.0.3
    GIT_SHALLOW TRUE
)

FetchContent_MakeAvailable(glfw VulkanMemoryAllocator glm)

add_executable(App
    vma.cpp
    main.cpp
    sample.cpp
    pipeline.cpp
)

target_link_libraries(App PRIVATE 
    glfw
    vulkan
    glm::glm
    GPUOpen::VulkanMemoryAllocator
)

What’s new:

  • Two more FetchContent_Declare blocks, one for VMA (v3.5.0) and one for GLM (1.0.3), and both are handed to FetchContent_MakeAvailable along with GLFW
  • We now link against GPUOpen::VulkanMemoryAllocator and glm::glm. Both libraries are header-only, so “linking” here really just means “add their include directories to our app”
  • Our app is now made of multiple .cpp files. More on those in a second!
  • CMAKE_EXPORT_COMPILE_COMMANDS makes CMake write a compile_commands.json into the build folder. It’s completely optional, but if your editor uses clangd (like mine does), it’s how clangd finds our include paths, and with multiple files and three dependencies, you’ll want that

Building and running is the same as before:

cmake -S. -B./build
cmake --build ./build

The first configure will take a little longer again, since VMA and GLM need to be downloaded too.

Refactoring

The new file layout

Here’s how our code is split up now:

FileWhat’s in it
common.hppIncludes that everyone needs, plus our Result type and Box alias
pipeline.hpp / pipeline.cppThe new Pipeline class
sample.hpp / sample.cppOur SampleVulkan class, along with the embedded shaders
main.cppOur SampleApp class (the GLFW window and main loop), and main
vma.cppThe VMA implementation (more on this later)

The good news: almost all of the code is exactly what we wrote in the previous two posts, just moved into a different file. SampleVulkan’s declaration moved to sample.hpp, its function definitions moved to sample.cpp, and SampleApp stayed behind in main.cpp. So I’ll only be going through what actually changed. As always, the full source code is at the end if you want to see where everything ended up.

Remember the note from the first post about SampleVulkan having to be declared before SampleApp? That’s no longer something we have to keep in mind: main.cpp just includes sample.hpp, and the header takes care of it for us.

common.hpp

common.hpp is included (directly or indirectly) by every other file. It starts with the includes that pretty much everyone needs:

// Released into the public domain.
// Written by I-A-S for use in i-a-s.me vulkan series.

#pragma once

#include <span>
#include <cstdio>
#include <memory>
#include <string>
#include <cstddef>
#include <variant>
#include <cassert>
#include <type_traits>

#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>

#include <vk_mem_alloc.h>

GLFW_INCLUDE_VULKAN and GLFW are here, same as before, and so is vk_mem_alloc.h (VMA), which we’ll get to later.

The Result type

In the previous posts, all our functions that can fail returned a bool, and printed an error message right where things went wrong. That works, but it means the function that failed has to decide how to report the failure. For our new Pipeline class, I wanted something a little nicer: return either the value we asked for, or an error message, and let the caller decide what to do with it. If you’ve used Rust, you’ll recognize this immediately, since it’s Rust’s Result type (in a very, very simplified form):

struct Ok {};

// A very simple Result type to hold either
// an error message or the value. To be used
// mostly as return types in failable functions. 
template<typename T = Ok>
class [[nodiscard]] Result
{
    static_assert(!std::is_same_v<std::decay_t<T>, std::string>, "std::string values are not allowed");
    static_assert(!std::is_same_v<T, void>, "void values are not allowed. Use Result<> or Result<Ok> instead.");

public:
    Result(std::nullptr_t) = delete;

    constexpr Result(const char* error): m_variant(std::in_place_index<1>, error) {}
    constexpr Result(T&& value): m_variant(std::in_place_index<0>, std::move(value)) {}
    constexpr Result(const std::string& error): m_variant(std::in_place_index<1>, error) {}

    constexpr bool is_ok() const { return m_variant.index() == 0; }
    constexpr bool is_error() const { return m_variant.index() == 1; }

    T get_value() && = delete;
    T& get_value() & { assert(is_ok()); return std::get<0>(m_variant); }
    const T& get_value() const& { assert(is_ok()); return std::get<0>(m_variant); }

    T take_value() && { assert(is_ok()); return std::get<0>(std::move(m_variant)); }
    
    const std::string& get_error() const& { assert(is_error()); return std::get<1>(m_variant); }
    std::string get_error() && { assert(is_error()); return std::get<1>(std::move(m_variant)); }

private:
    std::variant<T, std::string> m_variant;
};

Under the hood, it’s just a std::variant that holds either a value of type T (at index 0) or a std::string error message (at index 1). Let’s go through the rest:

  • The constructors: A Result can be constructed from an error message (a const char* or a std::string), or from a value. This lets us write return "Something went wrong"; or return value; from a function returning Result<T>, and the right constructor is picked automatically. Note that the value constructor takes T&&, so values are always moved in.
  • Result(std::nullptr_t) = delete: Without this, return nullptr; would happily pick the const char* constructor, and constructing a std::string from a null pointer is undefined behaviour. Deleting it turns that mistake into a compile error instead.
  • The static_asserts: T can’t be std::string, since then we wouldn’t be able to tell a value from an error message (both constructors would take a string!). And T can’t be void either, since a std::variant can’t hold a void. For functions that don’t return anything besides “it worked”, we use Result<>, which defaults T to our empty Ok struct.
  • is_ok / is_error: Check which one we’ve got.
  • get_value: Returns a reference to the value. Notice the & and const& after the function signature. These are ref-qualifiers: they make the function only callable on lvalues (named objects). The && overload, which would be called on a temporary Result, is deleted, because a reference into a temporary would be left dangling as soon as that temporary is destroyed.
  • take_value: For temporaries, there’s take_value, which moves the value out instead. To use it on a named Result, we have to std::move it first, which also makes it obvious in the code that we’re done with that Result afterwards.
  • get_error: Same idea as above, but for the error message.

The asserts make sure we never try to get a value out of an error (or vice versa) in debug builds.

[[nodiscard]] on the class means the compiler will warn us if we call a function returning a Result and then just ignore it. Which is exactly what we want, since ignoring a possible error is how bugs sneak in!

The Box alias

// Alias std::unique_ptr to Box. Easier to type.
// (Inspired by Rust of course, just like Result above).
template<typename T>
using Box = std::unique_ptr<T>;

Also a Rust-ism, and exactly what the comment says: Box<T> is just a shorter name for std::unique_ptr<T>. We’ll be using it to hold objects that own Vulkan resources, like our new Pipeline class.

The Pipeline class

In the previous post, create_graphics_pipeline created the pipeline and its layout, and stored them as raw handles (m_graphics_pipeline and m_graphics_pipeline_layout) inside SampleVulkan, which also had to remember to destroy them. We’ll be creating more pipelines as the series goes on, so it makes sense to pull all of that out into its own class:

// Released into the public domain.
// Written by I-A-S for use in i-a-s.me vulkan series.

#pragma once

#include "common.hpp"

class Pipeline
{
public:
    static Result<Box<Pipeline>> create(VkDevice device, VkFormat color_attachment_format, 
        std::span<const uint32_t> vertex_shader_source, std::span<const uint32_t> fragment_shader_source);

    Pipeline(const Pipeline&) = delete;
    ~Pipeline();

    void bind(VkCommandBuffer cmd);

protected:
    Pipeline(VkDevice device): m_device(device) {}

private:
    VkDevice m_device{};

    VkPipeline m_handle{};
    VkPipelineLayout m_layout{};
};

The interesting bit is how a Pipeline gets created. The constructor is protected, so the only way to get one from the outside is through the static create function, which returns a Result<Box<Pipeline>>: either a ready to use pipeline, or an error message telling us what went wrong. There’s no way to end up with a half-created Pipeline object.

We also delete the copy constructor. A Pipeline owns its Vulkan handles, and destroys them in its destructor, so if we could copy one, we’d end up with two objects destroying the same handles! Since we always keep it in a Box, we never need to copy (or move) the Pipeline itself anyway. We just move the Box around.

The pipeline creation code itself is pretty much the same as in the previous post, just moved into Pipeline::create, with m_device replaced with the device parameter, and our class members replaced with pipeline->m_layout and pipeline->m_handle. Here’s the start of it:

Result<Box<Pipeline>> Pipeline::create(VkDevice device, VkFormat color_attachment_format, 
        std::span<const uint32_t> vertex_shader_source, std::span<const uint32_t> fragment_shader_source)
{
  Box<Pipeline> pipeline{new Pipeline(device)};

  VkPipelineLayoutCreateInfo layout_create_info{
    .sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO,
    .setLayoutCount = 0,
    .pushConstantRangeCount = 0,
  };

  if (vkCreatePipelineLayout(device, &layout_create_info, nullptr, &pipeline->m_layout) != VK_SUCCESS)
    return "Failed to create the graphics pipeline layout";

  VkShaderModule shader_modules[2];

  VkShaderModuleCreateInfo shader_module_create_infos[2] = {
    {
      .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO,
      .codeSize = vertex_shader_source.size() << 2,
      .pCode = vertex_shader_source.data(),
    },
    {
      .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO,
      .codeSize = fragment_shader_source.size() << 2,
      .pCode = fragment_shader_source.data(),
    },
  };

  if (vkCreateShaderModule(device, &shader_module_create_infos[0], nullptr, &shader_modules[0]) != VK_SUCCESS)
    return "Failed to create the shader module for vertex shader";

  if (vkCreateShaderModule(device, &shader_module_create_infos[1], nullptr, &shader_modules[1]) != VK_SUCCESS) {
    vkDestroyShaderModule(device, shader_modules[0], nullptr);
    return "Failed to create the shader module for fragment shader";
  }

The very first thing we do is create our (empty) Pipeline object inside a Box. We have to use new here instead of std::make_unique, since std::make_unique can’t access our protected constructor (but Pipeline::create, being a member of the class, can).

Doing this first has a nice side effect. Look at the error paths: we just return an error message, and when we do, pipeline goes out of scope, the Box deletes the Pipeline, and the destructor cleans up whatever was created so far (the layout, in this case). No manual cleanup on every error path! Well, almost no manual cleanup: the shader modules aren’t owned by the Pipeline, so we still have to destroy those ourselves.

The shaders are now passed in as std::span<const uint32_t>, which is just a pointer and a size, without us having to care about where the data actually lives (our embedded arrays here, but it could also be a std::vector loaded from a file, for example). One thing to watch out for: codeSize is in bytes, but size() of our span gives us the number of uint32_t words. So we multiply it by 4 (which is what << 2 does, because shifting n places to the left is the same as multiplying by 2^n). Previously, we used sizeof on the arrays, which already gave us the size in bytes.

The rest of the function (the shader stages, all the fixed function state and the VkGraphicsPipelineCreateInfo) is unchanged, until the very end:


  if (vkCreateGraphicsPipelines(device, nullptr, 1, &create_info, nullptr, &pipeline->m_handle) != VK_SUCCESS) {
    vkDestroyShaderModule(device, shader_modules[0], nullptr);
    vkDestroyShaderModule(device, shader_modules[1], nullptr);

    return "Failed to create the graphics pipeline";
  }

  vkDestroyShaderModule(device, shader_modules[0], nullptr);
  vkDestroyShaderModule(device, shader_modules[1], nullptr);

  return pipeline;

If everything went well, we just return pipeline;. Since C++20, returning a local variable like this automatically moves it into the Result, so no std::move needed here.

And finally, binding and cleanup:


void Pipeline::bind(VkCommandBuffer cmd)
{
  // for now we'll only be using graphics pipeline so we can just hardcode VK_PIPELINE_BIND_POINT_GRAPHICS here
  vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, m_handle);
}

Pipeline::~Pipeline()
{
  if (!m_device)
    return;

  if (m_handle) vkDestroyPipeline(m_device, m_handle, nullptr);
  if (m_layout) vkDestroyPipelineLayout(m_device, m_layout, nullptr);
}

bind just wraps vkCmdBindPipeline. The destructor only destroys the handles that were actually created, which is what makes the “just return on error” trick above work.

Using the Pipeline class

Back in sample.hpp, the two raw handles are gone, and replaced with a single member:

  Box<Pipeline> m_graphics_pipeline{};

And create_graphics_pipeline got a lot shorter:

bool SampleVulkan::create_graphics_pipeline()
{
  auto result = Pipeline::create(m_device, m_swapchain_format.format, SHADER_SPV_VERT, SHADER_SPV_FRAG);

  if (result.is_error()) {
    printf("%s\n", result.get_error().c_str());
    return false;
  }

  m_graphics_pipeline = std::move(result).take_value();

  return true;
}

If creation failed, we print the error message and bail out, same as all our other functions do. Otherwise, we take_value the Box<Pipeline> out of the Result (notice the std::move on result, since take_value only works on rvalues, as we saw above).

In draw_frame, instead of calling vkCmdBindPipeline ourselves, we now do:

  m_graphics_pipeline->bind(cmd);

And in the destructor, instead of destroying the pipeline and its layout by hand, we just reset the Box, which destroys the Pipeline (and its handles) for us:

    m_graphics_pipeline.reset();

We still do this right after vkDeviceWaitIdle, same as before, since the GPU might still be using the pipeline.

Finally, SampleApp in main.cpp now holds SampleVulkan in a Box too, to keep things consistent:

class SampleApp
{
  static constexpr uint32_t SCREEN_WIDTH = 800;
  static constexpr uint32_t SCREEN_HEIGHT = 600;

  public:
  int launch(int argc, char *argv[]);

  ~SampleApp();

  private:
  GLFWwindow *m_window{};
  Box<SampleVulkan> m_vulkan{};
};

Vulkan Memory Allocator (VMA)

THEORY: Memory allocation in Vulkan

So far, we haven’t had to allocate any GPU memory ourselves. The swapchain images were allocated for us by the swapchain, and our vertices lived in the shader. That changes in the next post, when we create our first vertex and index buffers.

In Vulkan, creating a buffer (or an image) doesn’t allocate any memory for it. It just creates the buffer object. Getting memory for it is our job, and it goes something like this:

  1. Ask the buffer what kind of memory it needs (vkGetBufferMemoryRequirements): how big, what alignment, and which memory types it can live in
  2. Pick a suitable memory type. A GPU exposes several memory heaps (for example, the GPU’s own VRAM, and the system RAM), and several memory types on top of them, each with different properties: some are only accessible from the GPU (DEVICE_LOCAL), some can be written from the CPU (HOST_VISIBLE), some are both, and so on. Which ones exist, and how they’re laid out, differs from GPU to GPU
  3. Allocate memory of that type with vkAllocateMemory
  4. Bind the memory to the buffer with vkBindBufferMemory

Doesn’t sound too bad, right? The catch is in step 3. You’re not supposed to call vkAllocateMemory for every single buffer and image. Allocations are slow, and the total number of allocations you can have at once is limited (maxMemoryAllocationCount). The spec only guarantees 4096, and plenty of drivers report exactly that! Instead, you’re supposed to allocate big blocks of memory, and hand out pieces of those to your buffers and images yourself, respecting each one’s alignment requirements, dealing with fragmentation, freeing blocks when they’re no longer needed, and so on.

In other words, you need to write a memory allocator. Which is a whole project in itself, and has very little to do with learning Vulkan!

Enter VMA

The Vulkan Memory Allocator (VMA for short), from AMD’s GPUOpen, is that memory allocator, already written (and battle tested) for us. It picks memory types, allocates blocks and sub-allocates from them, so that creating a buffer with its memory becomes a single function call. It’s used very widely, so it’s well worth getting to know.

The implementation

VMA comes as a single header, vk_mem_alloc.h. Including it normally gives us just the declarations, which is what common.hpp does. The actual implementation only gets compiled in where we #define VMA_IMPLEMENTATION before including it, and that has to happen in exactly one .cpp file. So we give it a file of its own, vma.cpp:

// Released into the public domain.
// Written by I-A-S for use in i-a-s.me vulkan series.

#define VMA_IMPLEMENTATION
#include <vk_mem_alloc.h>

That’s the whole file! Keeping it in its own file also means the (rather large) VMA implementation only gets compiled once, and not every time we change something in sample.cpp.

Creating the allocator

Everything in VMA goes through a VmaAllocator object, which we add as a member of SampleVulkan:

  VmaAllocator m_allocator{};

VMA needs the instance, the physical device and the device to be created, so we create the allocator right after create_device_and_swapchain in initialize:

bool SampleVulkan::initialize(
    std::function<bool(VkInstance, uint32_t &, uint32_t &, VkSurfaceKHR &)> surface_creation_callback)
{
  if (!create_instance())
    return false;

  if (!surface_creation_callback(m_instance, m_pending_swapchain_extent.width, m_pending_swapchain_extent.height,
                                 m_surface))
  {
    printf("Failed to create Vulkan surface\n");
    return false;
  }

  if (!select_physical_device())
    return false;

  if (!create_device_and_swapchain())
    return false;

  VmaAllocatorCreateInfo vma_create_info{
    .physicalDevice = m_physical_device,
    .device = m_device,
    .instance = m_instance,
    .vulkanApiVersion = VK_API_VERSION_1_3,
  };
  if (vmaCreateAllocator(&vma_create_info, &m_allocator) != VK_SUCCESS) {
    printf("Failed to create the VMA allocator\n");
    return false;
  }

  if (!create_frame_resources())
    return false;

  if (!create_graphics_pipeline())
    return false;

  return true;
}
  • physicalDevice, device and instance: The objects VMA will be working with.
  • vulkanApiVersion: Tells VMA which Vulkan version we’re using, so it knows which core functions it can use. It must match the apiVersion we gave our instance, which is VK_API_VERSION_1_3. If we leave it out, VMA assumes Vulkan 1.0.

By default, VMA calls the Vulkan functions directly (it’s set to use “static” Vulkan functions), which works out of the box for us, since we link against the Vulkan loader.

Destroying the allocator

The allocator has to be destroyed after everything allocated with it is freed (there’s nothing yet, but there will be!), but before the device. So it goes right before vkDestroyDevice in our destructor:

SampleVulkan::~SampleVulkan()
{
  if (m_device)
  {
    vkDeviceWaitIdle(m_device);

    m_graphics_pipeline.reset();

    for (uint32_t i = 0; i < NUM_CONCURRENT_FRAMES; i++)
    {
      vkDestroyFence(m_device, m_frames[i].fen_in_flight, nullptr);
      vkDestroySemaphore(m_device, m_frames[i].sm_image_available, nullptr);
    }

    for (size_t i = 0; i < m_render_done_semaphores.size(); i++)
      vkDestroySemaphore(m_device, m_render_done_semaphores[i], nullptr);
    m_render_done_semaphores.clear();

    vkDestroyCommandPool(m_device, m_frame_resource_command_pool, nullptr);

    for (const auto &view : m_swapchain_image_views)
      vkDestroyImageView(m_device, view, nullptr);
    vkDestroySwapchainKHR(m_device, m_swapchain, nullptr);

    vmaDestroyAllocator(m_allocator);

    vkDestroyDevice(m_device, nullptr);
  }

  if (m_instance)
  {
    if (m_surface)
      vkDestroySurfaceKHR(m_instance, m_surface, nullptr);

    {
      const auto debug_messenger_destroy_func =
          (PFN_vkDestroyDebugUtilsMessengerEXT) vkGetInstanceProcAddr(m_instance, "vkDestroyDebugUtilsMessengerEXT");
      if (debug_messenger_destroy_func && m_debug_messenger)
        debug_messenger_destroy_func(m_instance, m_debug_messenger, nullptr);
    }

    vkDestroyInstance(m_instance, nullptr);
  }
}

vmaDestroyAllocator does nothing if it’s given a null allocator, so we’re also fine in the case where initialization failed before the allocator got created.

GLM

Last but not least, GLM (OpenGL Mathematics). It’s a header-only maths library, and the de facto standard one in graphics programming. Its types and functions are modelled after GLSL’s, so it’ll feel very familiar after the previous post: glm::vec2, glm::vec3, glm::vec4, glm::mat4 and so on, along with functions like glm::dot, glm::cross, glm::normalize and glm::mix.

We’re not using GLM anywhere in our code just yet. CMake is already making it available to us, so all we’ll need is an #include <glm/glm.hpp> once we do. Just to give you a taste (this isn’t part of this post’s code), here’s what the data for our triangle’s top vertex looks like with GLM:

#include <glm/glm.hpp>

glm::vec2 position{0.0f, -0.5f};
glm::vec3 color{1.0f, 0.0f, 0.0f};

Look familiar? It’s exactly what we had in our vertex shader, only now on the C++ side. And since a glm::vec3 is laid out in memory just like a vec3 in our shader (three tightly packed floats), we’ll be able to copy our vertex data straight into a buffer, and have our pipeline read it as vertex input as is. That’s exactly how we’ll be using it in the next post: to hold the positions and colours of our vertices.

NOTE: GLM started out as a maths library for OpenGL, and a few of its defaults (especially the ones related to projection matrices) assume OpenGL conventions, which differ from Vulkan’s. We won’t need any of those for a while, so we’ll deal with them once we get there.

Wrapping up

And that’s it! Build it, run it, and you should see… the exact same triangle as last time, and no validation errors. Which is exactly what we want from a refactor!

Let’s recap what we did:

  1. Split our single main.cpp into multiple files
  2. Added a small Rust-inspired Result type, and a Box alias for std::unique_ptr
  3. Pulled the graphics pipeline out into its own Pipeline class, which cleans up after itself, even on error paths
  4. Brought in VMA through CMake, compiled its implementation in vma.cpp, and created and destroyed a VmaAllocator
  5. Brought in GLM through CMake, ready to be used in the next post

Not the most exciting post visually, I know. But with all of this in place, we’re now ready for the fun part.

Next up: Vertex & Index Buffers

Time to finally get our vertices out of the vertex shader! We’ll define a vertex type using GLM to hold the positions and colours of our vertices, create vertex and index buffers for them using VMA, and tell our graphics pipeline how to read from them.

References

  1. Vulkan Memory Allocator, and its documentation
  2. GLM
  3. VkGuide
  4. The Vulkan specification (version 1.4.364 at the time of writing)

Full Source Code

The complete sample is now spread over a few files, around 1300 lines in total (a good chunk of which is still the embedded shader code!).

Show CMakeLists.txt
cmake_minimum_required(VERSION 3.28)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

project(I_A_S_ME_VulkanSeries)

include(FetchContent)

set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE)

FetchContent_Declare(glfw
    GIT_REPOSITORY https://github.com/glfw/glfw.git
    GIT_TAG 3.5.1
    GIT_SHALLOW TRUE
)

FetchContent_Declare(VulkanMemoryAllocator
    GIT_REPOSITORY https://github.com/GPUOpen-LibrariesAndSDKs/VulkanMemoryAllocator.git
    GIT_TAG v3.5.0
    GIT_SHALLOW TRUE
)

FetchContent_Declare(glm
    GIT_REPOSITORY https://github.com/g-truc/glm.git
    GIT_TAG 1.0.3
    GIT_SHALLOW TRUE
)

FetchContent_MakeAvailable(glfw VulkanMemoryAllocator glm)

add_executable(App
    vma.cpp
    main.cpp
    sample.cpp
    pipeline.cpp
)

target_link_libraries(App PRIVATE 
    glfw
    vulkan
    glm::glm
    GPUOpen::VulkanMemoryAllocator
)
Show common.hpp
// Released into the public domain.
// Written by I-A-S for use in i-a-s.me vulkan series.

#pragma once

#include <span>
#include <cstdio>
#include <memory>
#include <string>
#include <cstddef>
#include <variant>
#include <cassert>
#include <type_traits>

#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>

#include <vk_mem_alloc.h>

struct Ok {};

// A very simple Result type to hold either
// an error message or the value. To be used
// mostly as return types in failable functions. 
template<typename T = Ok>
class [[nodiscard]] Result
{
    static_assert(!std::is_same_v<std::decay_t<T>, std::string>, "std::string values are not allowed");
    static_assert(!std::is_same_v<T, void>, "void values are not allowed. Use Result<> or Result<Ok> instead.");

public:
    Result(std::nullptr_t) = delete;

    constexpr Result(const char* error): m_variant(std::in_place_index<1>, error) {}
    constexpr Result(T&& value): m_variant(std::in_place_index<0>, std::move(value)) {}
    constexpr Result(const std::string& error): m_variant(std::in_place_index<1>, error) {}

    constexpr bool is_ok() const { return m_variant.index() == 0; }
    constexpr bool is_error() const { return m_variant.index() == 1; }

    T get_value() && = delete;
    T& get_value() & { assert(is_ok()); return std::get<0>(m_variant); }
    const T& get_value() const& { assert(is_ok()); return std::get<0>(m_variant); }

    T take_value() && { assert(is_ok()); return std::get<0>(std::move(m_variant)); }
    
    const std::string& get_error() const& { assert(is_error()); return std::get<1>(m_variant); }
    std::string get_error() && { assert(is_error()); return std::get<1>(std::move(m_variant)); }

private:
    std::variant<T, std::string> m_variant;
};

// Alias std::unique_ptr to Box. Easier to type.
// (Inspired by Rust of course, just like Result above).
template<typename T>
using Box = std::unique_ptr<T>;
Show pipeline.hpp
// Released into the public domain.
// Written by I-A-S for use in i-a-s.me vulkan series.

#pragma once

#include "common.hpp"

class Pipeline
{
public:
    static Result<Box<Pipeline>> create(VkDevice device, VkFormat color_attachment_format, 
        std::span<const uint32_t> vertex_shader_source, std::span<const uint32_t> fragment_shader_source);

    Pipeline(const Pipeline&) = delete;
    ~Pipeline();

    void bind(VkCommandBuffer cmd);

protected:
    Pipeline(VkDevice device): m_device(device) {}

private:
    VkDevice m_device{};

    VkPipeline m_handle{};
    VkPipelineLayout m_layout{};
};
Show pipeline.cpp
// Released into the public domain.
// Written by I-A-S for use in i-a-s.me vulkan series.

#include "pipeline.hpp"

Result<Box<Pipeline>> Pipeline::create(VkDevice device, VkFormat color_attachment_format, 
        std::span<const uint32_t> vertex_shader_source, std::span<const uint32_t> fragment_shader_source)
{
  Box<Pipeline> pipeline{new Pipeline(device)};

  VkPipelineLayoutCreateInfo layout_create_info{
    .sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO,
    .setLayoutCount = 0,
    .pushConstantRangeCount = 0,
  };

  if (vkCreatePipelineLayout(device, &layout_create_info, nullptr, &pipeline->m_layout) != VK_SUCCESS)
    return "Failed to create the graphics pipeline layout";

  VkShaderModule shader_modules[2];

  VkShaderModuleCreateInfo shader_module_create_infos[2] = {
    {
      .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO,
      .codeSize = vertex_shader_source.size() << 2,
      .pCode = vertex_shader_source.data(),
    },
    {
      .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO,
      .codeSize = fragment_shader_source.size() << 2,
      .pCode = fragment_shader_source.data(),
    },
  };

  if (vkCreateShaderModule(device, &shader_module_create_infos[0], nullptr, &shader_modules[0]) != VK_SUCCESS)
    return "Failed to create the shader module for vertex shader";

  if (vkCreateShaderModule(device, &shader_module_create_infos[1], nullptr, &shader_modules[1]) != VK_SUCCESS) {
    vkDestroyShaderModule(device, shader_modules[0], nullptr);
    return "Failed to create the shader module for fragment shader";
  }

  VkPipelineShaderStageCreateInfo shader_stage_create_infos[2] = {
    {
      .sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO,
      .stage = VK_SHADER_STAGE_VERTEX_BIT,
      .module = shader_modules[0],
      .pName = "main",
    },
    {
      .sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO,
      .stage = VK_SHADER_STAGE_FRAGMENT_BIT,
      .module = shader_modules[1],
      .pName = "main",
    },
  };

  VkPipelineRenderingCreateInfo rendering_create_info {
    .sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO,
    .colorAttachmentCount = 1,
    .pColorAttachmentFormats = &color_attachment_format,
    .depthAttachmentFormat = VK_FORMAT_UNDEFINED,
    .stencilAttachmentFormat = VK_FORMAT_UNDEFINED,
  };

  VkPipelineVertexInputStateCreateInfo vertex_input_state_create_info{
    .sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO,
    .vertexBindingDescriptionCount = 0,
    .vertexAttributeDescriptionCount = 0,
  };

  VkPipelineInputAssemblyStateCreateInfo ia_state_create_info{
    .sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO,
    .topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST,
  };

  VkPipelineViewportStateCreateInfo viewport_state_create_info{
    .sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO,
    .viewportCount = 1,
    .scissorCount = 1,
  };

  VkPipelineRasterizationStateCreateInfo rs_create_info{
    .sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO,
    .polygonMode = VK_POLYGON_MODE_FILL,
    .cullMode = VK_CULL_MODE_NONE,
    .lineWidth = 1.0,
  };

  VkPipelineMultisampleStateCreateInfo multisample_state_create_info{
    .sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO,
    .rasterizationSamples = VK_SAMPLE_COUNT_1_BIT,
  };

  VkPipelineColorBlendAttachmentState color_blend_attachment_state{
    .blendEnable = VK_FALSE,
    .colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT |
                  VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT,
  };

  VkPipelineColorBlendStateCreateInfo color_blend_state_create_info{
    .sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO,
    .attachmentCount = 1,
    .pAttachments = &color_blend_attachment_state,
  };

  VkDynamicState dynamic_states[2] = {
    VK_DYNAMIC_STATE_VIEWPORT,
    VK_DYNAMIC_STATE_SCISSOR,
  };

  VkPipelineDynamicStateCreateInfo dynamic_state_create_info{
    .sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO,
    .dynamicStateCount = (sizeof(dynamic_states)/sizeof(dynamic_states[0])),
    .pDynamicStates = dynamic_states,
  };

  VkGraphicsPipelineCreateInfo create_info{
    .sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO,
    .pNext = &rendering_create_info,

    .stageCount = (sizeof(shader_stage_create_infos)/sizeof(shader_stage_create_infos[0])),
    .pStages = shader_stage_create_infos,

    .pVertexInputState = &vertex_input_state_create_info,
    .pInputAssemblyState = &ia_state_create_info,
    .pViewportState = &viewport_state_create_info,
    .pRasterizationState = &rs_create_info,
    .pMultisampleState = &multisample_state_create_info,
    .pColorBlendState = &color_blend_state_create_info,
    .pDynamicState = &dynamic_state_create_info,

    .layout = pipeline->m_layout,
  };

  if (vkCreateGraphicsPipelines(device, nullptr, 1, &create_info, nullptr, &pipeline->m_handle) != VK_SUCCESS) {
    vkDestroyShaderModule(device, shader_modules[0], nullptr);
    vkDestroyShaderModule(device, shader_modules[1], nullptr);

    return "Failed to create the graphics pipeline";
  }

  vkDestroyShaderModule(device, shader_modules[0], nullptr);
  vkDestroyShaderModule(device, shader_modules[1], nullptr);

  return pipeline;
}

void Pipeline::bind(VkCommandBuffer cmd)
{
  // for now we'll only be using graphics pipeline so we can just hardcode VK_PIPELINE_BIND_POINT_GRAPHICS here
  vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, m_handle);
}

Pipeline::~Pipeline()
{
  if (!m_device)
    return;

  if (m_handle) vkDestroyPipeline(m_device, m_handle, nullptr);
  if (m_layout) vkDestroyPipelineLayout(m_device, m_layout, nullptr);
}
Show sample.hpp
// Released into the public domain.
// Written by I-A-S for use in i-a-s.me vulkan series.

#pragma once

#include "pipeline.hpp"

#include <vector>
#include <string>
#include <cassert>
#include <stdint.h>
#include <functional>

class SampleVulkan
{
  static constexpr uint32_t NUM_CONCURRENT_FRAMES = 3;
  static constexpr const char *VALIDATION_LAYER_NAME = "VK_LAYER_KHRONOS_validation";
  static constexpr const char *SWAPCHAIN_DEVICE_EXTENSION_NAME = "VK_KHR_swapchain";
  static constexpr const char *PORTABILITY_SUBSET_DEVICE_EXTENSION_NAME = "VK_KHR_portability_subset";

  static constexpr uint32_t SHADER_SPV_VERT[] = {
  	0x07230203,0x00010000,0x000D000B,0x0000003B,0x00000000,0x00020011,0x00000001,0x0006000B,
    0x00000001,0x4C534C47,0x6474732E,0x3035342E,0x00000000,0x0003000E,0x00000000,0x00000001,
    0x0008000F,0x00000000,0x00000004,0x6E69616D,0x00000000,0x00000021,0x00000027,0x00000031,
    0x00030003,0x00000002,0x000001C2,0x000A0004,0x475F4C47,0x4C474F4F,0x70635F45,0x74735F70,
    0x5F656C79,0x656E696C,0x7269645F,0x69746365,0x00006576,0x00080004,0x475F4C47,0x4C474F4F,
    0x6E695F45,0x64756C63,0x69645F65,0x74636572,0x00657669,0x00040005,0x00000004,0x6E69616D,
    0x00000000,0x00050005,0x00000009,0x74726556,0x61447865,0x00006174,0x00040006,0x00000009,
    0x00000000,0x00736F70,0x00050006,0x00000009,0x00000001,0x6F6C6F63,0x00000072,0x00050005,
    0x0000000E,0x74726576,0x73656369,0x00000000,0x00040005,0x0000001E,0x74726576,0x00007865,
    0x00060005,0x00000021,0x565F6C67,0x65747265,0x646E4978,0x00007865,0x00050005,0x00000027,
    0x5F74756F,0x6F6C6F63,0x00000072,0x00060005,0x0000002F,0x505F6C67,0x65567265,0x78657472,
    0x00000000,0x00060006,0x0000002F,0x00000000,0x505F6C67,0x7469736F,0x006E6F69,0x00070006,
    0x0000002F,0x00000001,0x505F6C67,0x746E696F,0x657A6953,0x00000000,0x00070006,0x0000002F,
    0x00000002,0x435F6C67,0x4470696C,0x61747369,0x0065636E,0x00070006,0x0000002F,0x00000003,
    0x435F6C67,0x446C6C75,0x61747369,0x0065636E,0x00030005,0x00000031,0x00000000,0x00040047,
    0x00000021,0x0000000B,0x0000002A,0x00040047,0x00000027,0x0000001E,0x00000000,0x00030047,
    0x0000002F,0x00000002,0x00050048,0x0000002F,0x00000000,0x0000000B,0x00000000,0x00050048,
    0x0000002F,0x00000001,0x0000000B,0x00000001,0x00050048,0x0000002F,0x00000002,0x0000000B,
    0x00000003,0x00050048,0x0000002F,0x00000003,0x0000000B,0x00000004,0x00020013,0x00000002,
    0x00030021,0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,
    0x00000006,0x00000002,0x00040017,0x00000008,0x00000006,0x00000003,0x0004001E,0x00000009,
    0x00000007,0x00000008,0x00040015,0x0000000A,0x00000020,0x00000000,0x0004002B,0x0000000A,
    0x0000000B,0x00000003,0x0004001C,0x0000000C,0x00000009,0x0000000B,0x00040020,0x0000000D,
    0x00000006,0x0000000C,0x0004003B,0x0000000D,0x0000000E,0x00000006,0x0004002B,0x00000006,
    0x0000000F,0x00000000,0x0004002B,0x00000006,0x00000010,0xBF000000,0x0005002C,0x00000007,
    0x00000011,0x0000000F,0x00000010,0x0004002B,0x00000006,0x00000012,0x3F800000,0x0006002C,
    0x00000008,0x00000013,0x00000012,0x0000000F,0x0000000F,0x0005002C,0x00000009,0x00000014,
    0x00000011,0x00000013,0x0004002B,0x00000006,0x00000015,0x3F000000,0x0005002C,0x00000007,
    0x00000016,0x00000015,0x00000015,0x0006002C,0x00000008,0x00000017,0x0000000F,0x00000012,
    0x0000000F,0x0005002C,0x00000009,0x00000018,0x00000016,0x00000017,0x0005002C,0x00000007,
    0x00000019,0x00000010,0x00000015,0x0006002C,0x00000008,0x0000001A,0x0000000F,0x0000000F,
    0x00000012,0x0005002C,0x00000009,0x0000001B,0x00000019,0x0000001A,0x0006002C,0x0000000C,
    0x0000001C,0x00000014,0x00000018,0x0000001B,0x00040020,0x0000001D,0x00000007,0x00000009,
    0x00040015,0x0000001F,0x00000020,0x00000001,0x00040020,0x00000020,0x00000001,0x0000001F,
    0x0004003B,0x00000020,0x00000021,0x00000001,0x00040020,0x00000023,0x00000006,0x00000009,
    0x00040020,0x00000026,0x00000003,0x00000008,0x0004003B,0x00000026,0x00000027,0x00000003,
    0x0004002B,0x0000001F,0x00000028,0x00000001,0x00040020,0x00000029,0x00000007,0x00000008,
    0x00040017,0x0000002C,0x00000006,0x00000004,0x0004002B,0x0000000A,0x0000002D,0x00000001,
    0x0004001C,0x0000002E,0x00000006,0x0000002D,0x0006001E,0x0000002F,0x0000002C,0x00000006,
    0x0000002E,0x0000002E,0x00040020,0x00000030,0x00000003,0x0000002F,0x0004003B,0x00000030,
    0x00000031,0x00000003,0x0004002B,0x0000001F,0x00000032,0x00000000,0x00040020,0x00000033,
    0x00000007,0x00000007,0x00040020,0x00000039,0x00000003,0x0000002C,0x00050036,0x00000002,
    0x00000004,0x00000000,0x00000003,0x000200F8,0x00000005,0x0004003B,0x0000001D,0x0000001E,
    0x00000007,0x0003003E,0x0000000E,0x0000001C,0x0004003D,0x0000001F,0x00000022,0x00000021,
    0x00050041,0x00000023,0x00000024,0x0000000E,0x00000022,0x0004003D,0x00000009,0x00000025,
    0x00000024,0x0003003E,0x0000001E,0x00000025,0x00050041,0x00000029,0x0000002A,0x0000001E,
    0x00000028,0x0004003D,0x00000008,0x0000002B,0x0000002A,0x0003003E,0x00000027,0x0000002B,
    0x00050041,0x00000033,0x00000034,0x0000001E,0x00000032,0x0004003D,0x00000007,0x00000035,
    0x00000034,0x00050051,0x00000006,0x00000036,0x00000035,0x00000000,0x00050051,0x00000006,
    0x00000037,0x00000035,0x00000001,0x00070050,0x0000002C,0x00000038,0x00000036,0x00000037,
    0x0000000F,0x00000012,0x00050041,0x00000039,0x0000003A,0x00000031,0x00000032,0x0003003E,
    0x0000003A,0x00000038,0x000100FD,0x00010038,
  };
  
  static constexpr uint32_t SHADER_SPV_FRAG[] = {
    0x07230203,0x00010000,0x000D000B,0x00000013,0x00000000,0x00020011,0x00000001,0x0006000B,
    0x00000001,0x4C534C47,0x6474732E,0x3035342E,0x00000000,0x0003000E,0x00000000,0x00000001,
    0x0007000F,0x00000004,0x00000004,0x6E69616D,0x00000000,0x00000009,0x0000000C,0x00030010,
    0x00000004,0x00000007,0x00030003,0x00000002,0x000001C2,0x000A0004,0x475F4C47,0x4C474F4F,
    0x70635F45,0x74735F70,0x5F656C79,0x656E696C,0x7269645F,0x69746365,0x00006576,0x00080004,
    0x475F4C47,0x4C474F4F,0x6E695F45,0x64756C63,0x69645F65,0x74636572,0x00657669,0x00040005,
    0x00000004,0x6E69616D,0x00000000,0x00050005,0x00000009,0x616E6966,0x6C6F436C,0x0000726F,
    0x00050005,0x0000000C,0x635F6E69,0x726F6C6F,0x00000000,0x00040047,0x00000009,0x0000001E,
    0x00000000,0x00040047,0x0000000C,0x0000001E,0x00000000,0x00020013,0x00000002,0x00030021,
    0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,0x00000006,
    0x00000004,0x00040020,0x00000008,0x00000003,0x00000007,0x0004003B,0x00000008,0x00000009,
    0x00000003,0x00040017,0x0000000A,0x00000006,0x00000003,0x00040020,0x0000000B,0x00000001,
    0x0000000A,0x0004003B,0x0000000B,0x0000000C,0x00000001,0x0004002B,0x00000006,0x0000000E,
    0x3F800000,0x00050036,0x00000002,0x00000004,0x00000000,0x00000003,0x000200F8,0x00000005,
    0x0004003D,0x0000000A,0x0000000D,0x0000000C,0x00050051,0x00000006,0x0000000F,0x0000000D,
    0x00000000,0x00050051,0x00000006,0x00000010,0x0000000D,0x00000001,0x00050051,0x00000006,
    0x00000011,0x0000000D,0x00000002,0x00070050,0x00000007,0x00000012,0x0000000F,0x00000010,
    0x00000011,0x0000000E,0x0003003E,0x00000009,0x00000012,0x000100FD,0x00010038,
  };

  struct FrameResourceSet
  {
    VkFence fen_in_flight{};
    VkSemaphore sm_image_available{};
    VkCommandBuffer cmd_buffer{};
  };

public:
  SampleVulkan() = default;
  SampleVulkan(const SampleVulkan &) = delete;
  SampleVulkan &operator=(const SampleVulkan &) = delete;

  ~SampleVulkan();

  bool initialize(std::function<bool(VkInstance, uint32_t &, uint32_t &, VkSurfaceKHR &)> surface_creation_callback);

  bool render();

  void resize(uint32_t fb_width, uint32_t fb_height);

private:
  bool create_instance();
  bool select_physical_device();
  bool create_device_and_swapchain();
  bool create_frame_resources();

  bool create_graphics_pipeline();

  void draw_frame(VkCommandBuffer cmd, VkImageView color_attachment);

  bool recreate_swapchain();

  bool transition_image_layout(VkCommandBuffer cmd, VkImage image, VkImageLayout old_layout, VkImageLayout new_layout,
                               uint32_t base_mip_level = 0, uint32_t level_count = 1, uint32_t base_array_layer = 0,
                               uint32_t layer_count = 1);

private:
  VkInstance m_instance{};
  VkDebugUtilsMessengerEXT m_debug_messenger{};

  VkSurfaceKHR m_surface{};
  std::string m_physical_device_name{};
  VkPhysicalDevice m_physical_device{};
  uint32_t m_graphics_queue_family_index{};
  uint32_t m_present_queue_family_index{};
  uint32_t m_swapchain_image_count{};
  std::vector<VkSemaphore> m_render_done_semaphores;
  VkSurfaceFormatKHR m_swapchain_format{};
  VkPresentModeKHR m_swapchain_present_mode{};
  VkExtent2D m_swapchain_extent{1, 1};
  VkExtent2D m_pending_swapchain_extent{1, 1};
  bool m_swapchain_is_out_of_date{};
  VkSurfaceTransformFlagBitsKHR m_swapchain_pre_transform{};

  VkDevice m_device{};
  VkQueue m_graphics_queue{};
  VkQueue m_present_queue{};
  VkSwapchainKHR m_swapchain{};
  std::vector<VkImage> m_swapchain_images{};
  std::vector<VkImageView> m_swapchain_image_views{};

  uint32_t m_current_frame_index{};
  VkCommandPool m_frame_resource_command_pool{};
  FrameResourceSet m_frames[NUM_CONCURRENT_FRAMES];

  Box<Pipeline> m_graphics_pipeline{};

  VmaAllocator m_allocator{};
};
Show sample.cpp
// Released into the public domain.
// Written by I-A-S for use in i-a-s.me vulkan series.

#include "sample.hpp"

#include <set>
#include <stdio.h>
#include <string.h>
#include <utility>
#include <algorithm>

template<typename ElementT, typename FuncT, typename... Args> std::vector<ElementT> vkEnumCall(FuncT f, Args... args)
{
  uint32_t _count{};
  f(args..., &_count, nullptr);

  std::vector<ElementT> result(_count);
  f(args..., &_count, result.data());

  return result;
}

SampleVulkan::~SampleVulkan()
{
  if (m_device)
  {
    vkDeviceWaitIdle(m_device);

    m_graphics_pipeline.reset();

    for (uint32_t i = 0; i < NUM_CONCURRENT_FRAMES; i++)
    {
      vkDestroyFence(m_device, m_frames[i].fen_in_flight, nullptr);
      vkDestroySemaphore(m_device, m_frames[i].sm_image_available, nullptr);
    }

    for (size_t i = 0; i < m_render_done_semaphores.size(); i++)
      vkDestroySemaphore(m_device, m_render_done_semaphores[i], nullptr);
    m_render_done_semaphores.clear();

    vkDestroyCommandPool(m_device, m_frame_resource_command_pool, nullptr);

    for (const auto &view : m_swapchain_image_views)
      vkDestroyImageView(m_device, view, nullptr);
    vkDestroySwapchainKHR(m_device, m_swapchain, nullptr);

    vmaDestroyAllocator(m_allocator);

    vkDestroyDevice(m_device, nullptr);
  }

  if (m_instance)
  {
    if (m_surface)
      vkDestroySurfaceKHR(m_instance, m_surface, nullptr);

    {
      const auto debug_messenger_destroy_func =
          (PFN_vkDestroyDebugUtilsMessengerEXT) vkGetInstanceProcAddr(m_instance, "vkDestroyDebugUtilsMessengerEXT");
      if (debug_messenger_destroy_func && m_debug_messenger)
        debug_messenger_destroy_func(m_instance, m_debug_messenger, nullptr);
    }

    vkDestroyInstance(m_instance, nullptr);
  }
}

bool SampleVulkan::initialize(
    std::function<bool(VkInstance, uint32_t &, uint32_t &, VkSurfaceKHR &)> surface_creation_callback)
{
  if (!create_instance())
    return false;

  if (!surface_creation_callback(m_instance, m_pending_swapchain_extent.width, m_pending_swapchain_extent.height,
                                 m_surface))
  {
    printf("Failed to create Vulkan surface\n");
    return false;
  }

  if (!select_physical_device())
    return false;

  if (!create_device_and_swapchain())
    return false;

  VmaAllocatorCreateInfo vma_create_info{
    .physicalDevice = m_physical_device,
    .device = m_device,
    .instance = m_instance,
    .vulkanApiVersion = VK_API_VERSION_1_3,
  };
  if (vmaCreateAllocator(&vma_create_info, &m_allocator) != VK_SUCCESS) {
    printf("Failed to create the VMA allocator\n");
    return false;
  }

  if (!create_frame_resources())
    return false;

  if (!create_graphics_pipeline())
    return false;

  return true;
}

bool SampleVulkan::render()
{
  const auto &current_frame = m_frames[m_current_frame_index];

  vkWaitForFences(m_device, 1, &current_frame.fen_in_flight, VK_TRUE, UINT64_MAX);

  uint32_t image_index{};
  switch (vkAcquireNextImageKHR(m_device, m_swapchain, UINT64_MAX, current_frame.sm_image_available, VK_NULL_HANDLE,
                                &image_index))
  {
  case VK_ERROR_OUT_OF_DATE_KHR:
    if (!recreate_swapchain())
      return false;
    return true;

  case VK_SUCCESS:
  case VK_SUBOPTIMAL_KHR:
    break;

  default:
    printf("Failed to acquire swapchain image\n");
    return false;
  }

  VkCommandBufferBeginInfo cmd_begin_info{
      .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
  };
  vkResetCommandBuffer(current_frame.cmd_buffer, 0);
  if (vkBeginCommandBuffer(current_frame.cmd_buffer, &cmd_begin_info) != VK_SUCCESS)
  {
    printf("Failed to start recording command buffer\n");
    return false;
  }

  if (!transition_image_layout(current_frame.cmd_buffer, m_swapchain_images[image_index], VK_IMAGE_LAYOUT_UNDEFINED,
                               VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL))
  {
    printf("Failed to transition swapchain image\n");
    return false;
  }

  draw_frame(current_frame.cmd_buffer, m_swapchain_image_views[image_index]);

  if (!transition_image_layout(current_frame.cmd_buffer, m_swapchain_images[image_index],
                               VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR))
  {
    printf("Failed to transition swapchain image\n");
    return false;
  }

  if (vkEndCommandBuffer(current_frame.cmd_buffer) != VK_SUCCESS)
  {
    printf("Failed to finish recording command buffer\n");
    return false;
  }

  VkPipelineStageFlags submit_pipeline_wait_stage{VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT};
  VkSubmitInfo submit_info{
      .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO,
      .waitSemaphoreCount = 1,
      .pWaitSemaphores = &current_frame.sm_image_available,
      .pWaitDstStageMask = &submit_pipeline_wait_stage,
      .commandBufferCount = 1,
      .pCommandBuffers = &current_frame.cmd_buffer,
      .signalSemaphoreCount = 1,
      .pSignalSemaphores = &m_render_done_semaphores[image_index],
  };

  vkResetFences(m_device, 1, &current_frame.fen_in_flight);
  if (vkQueueSubmit(m_graphics_queue, 1, &submit_info, current_frame.fen_in_flight) != VK_SUCCESS)
  {
    printf("Failed to submit frame command buffer\n");
    return false;
  }

  VkPresentInfoKHR present_info{
      .sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR,
      .waitSemaphoreCount = 1,
      .pWaitSemaphores = &m_render_done_semaphores[image_index],
      .swapchainCount = 1,
      .pSwapchains = &m_swapchain,
      .pImageIndices = &image_index,
  };

  switch (vkQueuePresentKHR(m_present_queue, &present_info))
  {
  case VK_ERROR_OUT_OF_DATE_KHR:
  case VK_SUBOPTIMAL_KHR:
    if (!recreate_swapchain())
      return false;
    break;

  case VK_SUCCESS:
    break;

  default:
    printf("Failed to present swapchain\n");
    return false;
  }

  if (m_swapchain_is_out_of_date && !recreate_swapchain())
    return false;

  m_current_frame_index = (m_current_frame_index + 1) % NUM_CONCURRENT_FRAMES;

  return true;
}

void SampleVulkan::draw_frame(VkCommandBuffer cmd, VkImageView color_attachment)
{
  VkRenderingAttachmentInfo color_attachment_info{
      .sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO,
      .imageView = color_attachment,
      .imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
      .loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR,
      .storeOp = VK_ATTACHMENT_STORE_OP_STORE,
      .clearValue =
          {
              .color =
                  {
                      0.1f,
                      0.1f,
                      0.1f,
                      1.0f,
                  },
          },
  };

  VkRenderingInfo rendering_info{
      .sType = VK_STRUCTURE_TYPE_RENDERING_INFO,
      .renderArea =
          {
              {},
              m_swapchain_extent,
          },
      .layerCount = 1,
      .viewMask = 0,
      .colorAttachmentCount = 1,
      .pColorAttachments = &color_attachment_info,
  };

  vkCmdBeginRendering(cmd, &rendering_info);

  VkViewport viewport = {
    .width = (float)m_swapchain_extent.width,
    .height = (float)m_swapchain_extent.height,
    .minDepth = 0.0f,
    .maxDepth = 1.0f,
  };
  vkCmdSetViewport(cmd, 0, 1, &viewport);

  VkRect2D scissor{
    .extent = m_swapchain_extent,
  };
  vkCmdSetScissor(cmd, 0, 1, &scissor);

  m_graphics_pipeline->bind(cmd);

  vkCmdDraw(cmd, 3, 1, 0, 0);

  vkCmdEndRendering(cmd);
}

bool SampleVulkan::transition_image_layout(VkCommandBuffer cmd, VkImage image, VkImageLayout old_layout,
                                           VkImageLayout new_layout, uint32_t base_mip_level, uint32_t level_count,
                                           uint32_t base_array_layer, uint32_t layer_count)
{
  VkPipelineStageFlags2 src_stage{}, dst_stage{};
  VkAccessFlags2 src_access{}, dst_access{};
  VkImageAspectFlags aspect{VK_IMAGE_ASPECT_COLOR_BIT};

#define _is_layout_pair(o, n) ((old_layout == o) && (new_layout == n))

  if (_is_layout_pair(VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL))
  {
    src_stage = VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT;
    src_access = VK_ACCESS_2_NONE;

    dst_stage = VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT;
    dst_access = VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT;
  }
  else if (_is_layout_pair(VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL))
  {
    // Wait for any depth writes from a previous use of this image (e.g. the previous frame)
    src_stage = VK_PIPELINE_STAGE_2_EARLY_FRAGMENT_TESTS_BIT | VK_PIPELINE_STAGE_2_LATE_FRAGMENT_TESTS_BIT;
    src_access = VK_ACCESS_2_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;

    dst_stage = VK_PIPELINE_STAGE_2_EARLY_FRAGMENT_TESTS_BIT | VK_PIPELINE_STAGE_2_LATE_FRAGMENT_TESTS_BIT;
    dst_access = VK_ACCESS_2_DEPTH_STENCIL_ATTACHMENT_READ_BIT | VK_ACCESS_2_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;

    aspect = VK_IMAGE_ASPECT_DEPTH_BIT;
  }
  else if (_is_layout_pair(VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR))
  {
    src_stage = VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT;
    src_access = VK_ACCESS_2_COLOR_ATTACHMENT_WRITE_BIT;

    dst_stage = VK_PIPELINE_STAGE_2_NONE;
    dst_access = VK_ACCESS_2_NONE;
  }
  else
  {
    printf("Unknown layout transition\n");
    return false;
  }

#undef _is_layout_pair

  VkImageMemoryBarrier2 barrier{
      .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2,

      .srcStageMask = src_stage,
      .srcAccessMask = src_access,

      .dstStageMask = dst_stage,
      .dstAccessMask = dst_access,

      .oldLayout = old_layout,
      .newLayout = new_layout,

      .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
      .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,

      .image = image,

      .subresourceRange =
          {
              .aspectMask = aspect,
              .baseMipLevel = base_mip_level,
              .levelCount = level_count,
              .baseArrayLayer = base_array_layer,
              .layerCount = layer_count,
          },
  };

  VkDependencyInfo dep_info{
      .sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO,
      .imageMemoryBarrierCount = 1,
      .pImageMemoryBarriers = &barrier,
  };

  vkCmdPipelineBarrier2(cmd, &dep_info);

  return true;
}

bool SampleVulkan::create_instance()
{
  // This is a sample, not production code, so we always enable validation layers,
  // unless they're unavailable
  const auto instance_layer_props = vkEnumCall<VkLayerProperties>(vkEnumerateInstanceLayerProperties);
  const bool enable_validation_layers =
      std::any_of(instance_layer_props.begin(), instance_layer_props.end(),
                  [](VkLayerProperties p) { return strcmp(p.layerName, VALIDATION_LAYER_NAME) == 0; });

  VkDebugUtilsMessengerCreateInfoEXT debug_messenger_create_info{
      .sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT,
      .messageSeverity =
          VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT,
      .messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT |
                     VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT,
      .pfnUserCallback = [](VkDebugUtilsMessageSeverityFlagBitsEXT severity, VkDebugUtilsMessageTypeFlagsEXT type,
                            const VkDebugUtilsMessengerCallbackDataEXT *callback_data, void *user_data) -> VkBool32 {
        printf("[VK_VALIDATION_LAYER]: %s\n", callback_data->pMessage);
        return VK_FALSE;
      },
  };

  std::vector<const char *> extensions;
  { // GLFW Extensions
    uint32_t glfw_ext_count{};
    const auto glfw_ext = glfwGetRequiredInstanceExtensions(&glfw_ext_count);
    extensions.insert(extensions.end(), glfw_ext, glfw_ext + glfw_ext_count);
  }
  if (enable_validation_layers)
    extensions.push_back(VK_EXT_DEBUG_UTILS_EXTENSION_NAME);

  // macOS support: MoltenVK is a "portability" implementation (it doesn't support all
  // of Vulkan), so the loader only exposes it to us if we explicitly opt in.
  const auto instance_ext_props = vkEnumCall<VkExtensionProperties>(vkEnumerateInstanceExtensionProperties, nullptr);
  const bool enable_portability =
      std::any_of(instance_ext_props.begin(), instance_ext_props.end(), [](VkExtensionProperties p) {
        return strcmp(p.extensionName, VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME) == 0;
      });
  if (enable_portability)
    extensions.push_back(VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME);

  VkApplicationInfo app_info{
      .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO,
      .pApplicationName = "i-a-s.me Vulkan 1.3 Sample",
      .applicationVersion = VK_MAKE_VERSION(1, 0, 0),
      .pEngineName = "i-a-s.me Vulkan 1.3 Sample",
      .engineVersion = VK_MAKE_VERSION(1, 0, 0),
      .apiVersion = VK_API_VERSION_1_3,
  };
  VkInstanceCreateInfo create_info{
      .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,
      .pNext = enable_validation_layers ? &debug_messenger_create_info : nullptr,
      .flags = enable_portability ? (VkInstanceCreateFlags) VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR : 0u,
      .pApplicationInfo = &app_info,
      .enabledLayerCount = enable_validation_layers ? 1u : 0u,
      .ppEnabledLayerNames = enable_validation_layers ? &VALIDATION_LAYER_NAME : nullptr,
      .enabledExtensionCount = (uint32_t) extensions.size(),
      .ppEnabledExtensionNames = extensions.data(),
  };

  if (vkCreateInstance(&create_info, nullptr, &m_instance) != VK_SUCCESS)
  {
    printf("Failed to create Vulkan instance\n");
    return false;
  }

  const auto debug_messenger_create_func =
      (PFN_vkCreateDebugUtilsMessengerEXT) vkGetInstanceProcAddr(m_instance, "vkCreateDebugUtilsMessengerEXT");
  if (enable_validation_layers && debug_messenger_create_func)
  {
    if (debug_messenger_create_func(m_instance, &debug_messenger_create_info, nullptr, &m_debug_messenger) !=
        VK_SUCCESS)
    {
      printf("Failed to create Vulkan debug messenger\n");
      return false;
    }
  }

  return true;
}

bool SampleVulkan::select_physical_device()
{
  const auto find_queue_families = [&](VkPhysicalDevice p) -> std::pair<uint32_t, uint32_t> {
    uint32_t graphics_family_index{UINT32_MAX}, present_family_index{UINT32_MAX};

    const auto queue_families = vkEnumCall<VkQueueFamilyProperties>(vkGetPhysicalDeviceQueueFamilyProperties, p);
    for (uint32_t i = 0; i < (uint32_t) queue_families.size(); i++)
    {
      const auto &f = queue_families[i];

      if (f.queueFlags & VK_QUEUE_GRAPHICS_BIT)
        graphics_family_index = i;

      VkBool32 has_present_support{};
      if ((vkGetPhysicalDeviceSurfaceSupportKHR(p, i, m_surface, &has_present_support) == VK_SUCCESS) &&
          has_present_support)
        present_family_index = i;

      if ((graphics_family_index != UINT32_MAX) && (present_family_index != UINT32_MAX))
        break;
    }

    return {graphics_family_index, present_family_index};
  };

  m_physical_device = VK_NULL_HANDLE;

  const auto physical_devices = vkEnumCall<VkPhysicalDevice>(vkEnumeratePhysicalDevices, m_instance);

  for (const auto &p : physical_devices)
  {
    VkPhysicalDeviceProperties2 props{
        .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2,
    };
    vkGetPhysicalDeviceProperties2(p, &props);

    if (props.properties.apiVersion < VK_API_VERSION_1_3)
      continue; // If this physical device does not support at least Vulkan 1.3, we cannot use it. We use Vulkan 1.3
                // core features in this sample.

    const auto queue_family_indices = find_queue_families(p);

    if ((queue_family_indices.first == UINT32_MAX) || (queue_family_indices.second == UINT32_MAX))
      continue; // This physical device isn't what we want, if it lacks either graphics or present queue families

    // We could (and should) do more suitability checks in a real application,
    // but for this sample, this is just ok.

    m_physical_device = p;
    m_graphics_queue_family_index = queue_family_indices.first;
    m_present_queue_family_index = queue_family_indices.second;

    m_physical_device_name = props.properties.deviceName;

    if (props.properties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU)
      break; // If we find a discrete GPU, then prefer it
  }

  if (m_physical_device == VK_NULL_HANDLE)
  {
    printf("Failed to find a good physical device\n");
    return false;
  }

  printf("Using physical device \"%s\"\n", m_physical_device_name.c_str());

  return true;
}

void SampleVulkan::resize(uint32_t fb_width, uint32_t fb_height)
{
  m_pending_swapchain_extent = {fb_width, fb_height};
  m_swapchain_is_out_of_date = true;
}

bool SampleVulkan::create_device_and_swapchain()
{
  const float priority = 1.0f;
  std::vector<VkDeviceQueueCreateInfo> queue_create_infos;
  // passing through std::set effectively dedups family indices.
  // not really necessary to use std::set here as we only have to consider
  // 2 families (graphics and present), so we could have just checked (family_index_1 == family_index_2),
  // but this std::set approach generalizes to higher orders (imagine doing if/else checks for combinations of 3 or 4)
  for (const auto index : std::set<uint32_t>{m_graphics_queue_family_index, m_present_queue_family_index})
    queue_create_infos.push_back(VkDeviceQueueCreateInfo{
        .sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO,
        .queueFamilyIndex = index,
        .queueCount = 1,
        .pQueuePriorities = &priority,
    });

  std::vector<const char *> device_extensions{SWAPCHAIN_DEVICE_EXTENSION_NAME};
  // macOS support: if the device is a portability implementation (i.e. MoltenVK),
  // it exposes VK_KHR_portability_subset, and the spec requires us to enable it.
  const auto device_ext_props =
      vkEnumCall<VkExtensionProperties>(vkEnumerateDeviceExtensionProperties, m_physical_device, nullptr);
  if (std::any_of(device_ext_props.begin(), device_ext_props.end(), [](VkExtensionProperties p) {
        return strcmp(p.extensionName, PORTABILITY_SUBSET_DEVICE_EXTENSION_NAME) == 0;
      }))
    device_extensions.push_back(PORTABILITY_SUBSET_DEVICE_EXTENSION_NAME);

  VkPhysicalDeviceFeatures enabled_features{};
  VkPhysicalDeviceVulkan13Features vk_13_features{
      .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES,
      .synchronization2 = VK_TRUE,
      .dynamicRendering = VK_TRUE,
  };

  VkDeviceCreateInfo device_create_info{
      .sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO,
      .pNext = &vk_13_features,
      .queueCreateInfoCount = (uint32_t) queue_create_infos.size(),
      .pQueueCreateInfos = queue_create_infos.data(),
      .enabledLayerCount = 0,
      .ppEnabledLayerNames = nullptr,
      .enabledExtensionCount = (uint32_t) device_extensions.size(),
      .ppEnabledExtensionNames = device_extensions.data(),
      .pEnabledFeatures = &enabled_features,
  };

  if (vkCreateDevice(m_physical_device, &device_create_info, nullptr, &m_device) != VK_SUCCESS)
  {
    printf("Failed to create Vulkan device\n");
    return false;
  }

  vkGetDeviceQueue(m_device, m_graphics_queue_family_index, 0, &m_graphics_queue);
  vkGetDeviceQueue(m_device, m_present_queue_family_index, 0, &m_present_queue);

  const auto surface_formats =
      vkEnumCall<VkSurfaceFormatKHR>(vkGetPhysicalDeviceSurfaceFormatsKHR, m_physical_device, m_surface);
  const auto surface_present_modes =
      vkEnumCall<VkPresentModeKHR>(vkGetPhysicalDeviceSurfacePresentModesKHR, m_physical_device, m_surface);
  assert(surface_formats.size());
  assert(surface_present_modes.size());
  m_swapchain_format = surface_formats[0];
  m_swapchain_present_mode = (std::find(surface_present_modes.begin(), surface_present_modes.end(),
                                        VK_PRESENT_MODE_MAILBOX_KHR) != surface_present_modes.end())
                                 ? VK_PRESENT_MODE_MAILBOX_KHR
                                 : VK_PRESENT_MODE_FIFO_KHR;

  return recreate_swapchain();
}

bool SampleVulkan::create_frame_resources()
{
  VkCommandPoolCreateInfo commmand_pool_create_info{
      .sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO,
      .flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT,
      .queueFamilyIndex = m_graphics_queue_family_index,
  };
  if (vkCreateCommandPool(m_device, &commmand_pool_create_info, nullptr, &m_frame_resource_command_pool) != VK_SUCCESS)
  {
    printf("Failed to create command pool\n");
    return false;
  }

  VkCommandBufferAllocateInfo command_buffer_alloc_info{
      .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO,
      .commandPool = m_frame_resource_command_pool,
      .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY,
      .commandBufferCount = 1,
  };
  for (uint32_t i = 0; i < NUM_CONCURRENT_FRAMES; i++)
  {
    if (vkAllocateCommandBuffers(m_device, &command_buffer_alloc_info, &m_frames[i].cmd_buffer) != VK_SUCCESS)
    {
      printf("Failed to allocate frame command buffer\n");
      return false;
    }

    VkSemaphoreCreateInfo semaphore_create_info{
        .sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO,
    };
    if (vkCreateSemaphore(m_device, &semaphore_create_info, nullptr, &m_frames[i].sm_image_available) != VK_SUCCESS)
    {
      printf("Failed to create a semaphore\n");
      return false;
    }

    VkFenceCreateInfo fence_create_info{
        .sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO,
        .flags = VK_FENCE_CREATE_SIGNALED_BIT,
    };
    if (vkCreateFence(m_device, &fence_create_info, nullptr, &m_frames[i].fen_in_flight) != VK_SUCCESS)
    {
      printf("Failed to create a fence\n");
      return false;
    }
  }

  return true;
}

bool SampleVulkan::recreate_swapchain()
{
  vkDeviceWaitIdle(m_device);

  VkSurfaceCapabilitiesKHR surface_caps{};
  if (vkGetPhysicalDeviceSurfaceCapabilitiesKHR(m_physical_device, m_surface, &surface_caps) != VK_SUCCESS)
  {
    printf("Failed to query physical device surface caps\n");
    return false;
  }

  // A minimized window has a 0x0 surface, and a 0x0 swapchain is not allowed. So we
  // keep the current swapchain, and try again later (once the window is restored).
  if ((surface_caps.maxImageExtent.width == 0) || (surface_caps.maxImageExtent.height == 0))
  {
    m_swapchain_is_out_of_date = true;
    return true;
  }

  m_swapchain_image_count = std::min(surface_caps.minImageCount + 1,
                                     (surface_caps.maxImageCount == 0) ? UINT32_MAX : surface_caps.maxImageCount);

  m_swapchain_pre_transform = surface_caps.currentTransform;

  m_pending_swapchain_extent.width = std::clamp(m_pending_swapchain_extent.width, surface_caps.minImageExtent.width,
                                                surface_caps.maxImageExtent.width);
  m_pending_swapchain_extent.height = std::clamp(m_pending_swapchain_extent.height, surface_caps.minImageExtent.height,
                                                 surface_caps.maxImageExtent.height);
  m_swapchain_extent = m_pending_swapchain_extent;

  for (const auto &view : m_swapchain_image_views)
    vkDestroyImageView(m_device, view, nullptr);
  if (m_swapchain)
    vkDestroySwapchainKHR(m_device, m_swapchain, nullptr);
  m_swapchain_image_views.clear();
  m_swapchain = VK_NULL_HANDLE;

  VkSwapchainCreateInfoKHR swapchain_create_info{
      .sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR,
      .surface = m_surface,
      .minImageCount = m_swapchain_image_count,
      .imageFormat = m_swapchain_format.format,
      .imageColorSpace = m_swapchain_format.colorSpace,
      .imageExtent = m_swapchain_extent,
      .imageArrayLayers = 1,
      .imageUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT,
      .imageSharingMode = VK_SHARING_MODE_EXCLUSIVE,
      .preTransform = m_swapchain_pre_transform,
      .compositeAlpha = VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR,
      .presentMode = m_swapchain_present_mode,
      .clipped = VK_TRUE,
  };

  uint32_t queue_family_indices[] = {m_graphics_queue_family_index, m_present_queue_family_index};
  if (m_graphics_queue_family_index != m_present_queue_family_index)
  {
    swapchain_create_info.imageSharingMode = VK_SHARING_MODE_CONCURRENT;
    swapchain_create_info.queueFamilyIndexCount = 2;
    swapchain_create_info.pQueueFamilyIndices = queue_family_indices;
  }

  if (vkCreateSwapchainKHR(m_device, &swapchain_create_info, nullptr, &m_swapchain) != VK_SUCCESS)
  {
    printf("Failed to create swapchain\n");
    return false;
  }

  m_swapchain_images = vkEnumCall<VkImage>(vkGetSwapchainImagesKHR, m_device, m_swapchain);

  // Note that we use the *actual* number of images here, which can be
  // more than the minImageCount we asked for.
  for (auto semaphore : m_render_done_semaphores)
    vkDestroySemaphore(m_device, semaphore, nullptr);
  m_render_done_semaphores.assign(m_swapchain_images.size(), VK_NULL_HANDLE);

  VkSemaphoreCreateInfo semaphore_create_info{
      .sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO,
  };
  for (auto &semaphore : m_render_done_semaphores)
  {
    if (vkCreateSemaphore(m_device, &semaphore_create_info, nullptr, &semaphore) != VK_SUCCESS)
    {
      printf("Failed to create a semaphore\n");
      return false;
    }
  }

  m_swapchain_image_views.reserve(m_swapchain_images.size());
  for (const auto &image : m_swapchain_images)
  {
    VkImageViewCreateInfo create_info{
        .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
        .image = image,
        .viewType = VK_IMAGE_VIEW_TYPE_2D,
        .format = m_swapchain_format.format,
        .components =
            {
                .r = VK_COMPONENT_SWIZZLE_IDENTITY,
                .g = VK_COMPONENT_SWIZZLE_IDENTITY,
                .b = VK_COMPONENT_SWIZZLE_IDENTITY,
                .a = VK_COMPONENT_SWIZZLE_IDENTITY,
            },
        .subresourceRange =
            {
                .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
                .baseMipLevel = 0,
                .levelCount = 1,
                .baseArrayLayer = 0,
                .layerCount = 1,
            },
    };

    VkImageView view{};
    if (vkCreateImageView(m_device, &create_info, nullptr, &view) != VK_SUCCESS)
    {
      printf("Failed to create a swapchain image view\n");
      return false;
    }

    m_swapchain_image_views.push_back(view);
  }

  m_swapchain_is_out_of_date = false;

  return true;
}

bool SampleVulkan::create_graphics_pipeline()
{
  auto result = Pipeline::create(m_device, m_swapchain_format.format, SHADER_SPV_VERT, SHADER_SPV_FRAG);

  if (result.is_error()) {
    printf("%s\n", result.get_error().c_str());
    return false;
  }

  m_graphics_pipeline = std::move(result).take_value();

  return true;
}
Show main.cpp
// Released into the public domain.
// Written by I-A-S for use in i-a-s.me vulkan series.

#include "sample.hpp"

class SampleApp
{
  static constexpr uint32_t SCREEN_WIDTH = 800;
  static constexpr uint32_t SCREEN_HEIGHT = 600;

  public:
  int launch(int argc, char *argv[]);

  ~SampleApp();

  private:
  GLFWwindow *m_window{};
  Box<SampleVulkan> m_vulkan{};
};

int main(int argc, char *argv[])
{
  SampleApp app;
  return app.launch(argc, argv);
}

int SampleApp::launch(int argc, char *argv[])
{
  if (!glfwInit())
  {
    printf("Failed to initialize GLFW\n");
    return -1;
  }

  glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
  m_window = glfwCreateWindow(SCREEN_WIDTH, SCREEN_HEIGHT, "i-a-s.me Vulkan Sample", nullptr, nullptr);
  if (!m_window)
  {
    printf("Failed to create GLFW window\n");
    return -1;
  }

  m_vulkan = Box<SampleVulkan>(new SampleVulkan());
  if (!m_vulkan->initialize([window = m_window](VkInstance instance, uint32_t &out_width, uint32_t &out_height,
                                                VkSurfaceKHR &out_surface) -> bool {
        if (glfwCreateWindowSurface(instance, window, nullptr, &out_surface) != VK_SUCCESS)
        {
          printf("Failed to create Vulkan surface\n");
          return false;
        }

        int32_t fb_width{}, fb_height{};
        glfwGetFramebufferSize(window, &fb_width, &fb_height);
        while ((fb_width == 0) || (fb_height == 0))
        {
          glfwWaitEvents();
          glfwGetFramebufferSize(window, &fb_width, &fb_height);
        }

        out_width = (uint32_t) fb_width;
        out_height = (uint32_t) fb_height;

        return true;
      }))
  {
    printf("Failed to initialize Vulkan\n");
    return -1;
  }

  glfwSetWindowUserPointer(m_window, m_vulkan.get());

  glfwSetFramebufferSizeCallback(m_window, [](GLFWwindow *window, int width, int height) {
    int32_t fb_width{}, fb_height{};
    glfwGetFramebufferSize(window, &fb_width, &fb_height);
    if ((fb_width == 0) || (fb_height == 0))
      return;
    static_cast<SampleVulkan *>(glfwGetWindowUserPointer(window))->resize(fb_width, fb_height);
  });

  while (!glfwWindowShouldClose(m_window))
  {
    if (glfwGetKey(m_window, GLFW_KEY_ESCAPE))
      glfwSetWindowShouldClose(m_window, true);

    glfwPollEvents();

    // Don't render while the window is minimized (its framebuffer is 0x0 then)
    int32_t fb_width{}, fb_height{};
    glfwGetFramebufferSize(m_window, &fb_width, &fb_height);
    if ((fb_width == 0) || (fb_height == 0))
    {
      glfwWaitEvents();
      continue;
    }

    if (!m_vulkan->render())
      break;
  }

  return 0;
}

SampleApp::~SampleApp()
{
  m_vulkan.reset();

  if (m_window)
    glfwDestroyWindow(m_window);

  glfwTerminate();
}
Show vma.cpp
// Released into the public domain.
// Written by I-A-S for use in i-a-s.me vulkan series.

#define VMA_IMPLEMENTATION
#include <vk_mem_alloc.h>
Show shaders/default.vert
#version 450

struct VertexData {
    vec2 pos;
    vec3 color;
};

VertexData vertices[3] = VertexData[](
    VertexData(vec2(0.0, -0.5), vec3(1.0, 0.0, 0.0)),
    VertexData(vec2(0.5, 0.5), vec3(0.0, 1.0, 0.0)),
    VertexData(vec2(-0.5, 0.5), vec3(0.0, 0.0, 1.0))
);

layout(location = 0) out vec3 out_color;

void main() {
    VertexData vertex = vertices[gl_VertexIndex];

    out_color = vertex.color;
    gl_Position = vec4(vertex.pos, 0.0, 1.0);
}
Show shaders/default.frag
#version 450

layout(location = 0) in vec3 in_color;

layout(location = 0) out vec4 finalColor;

void main() {
    finalColor = vec4(in_color, 1.0);
}