Writing

Vulkan Series: 02 - Coloured Triangle

Drawing the classic coloured triangle with shaders and a graphics pipeline in Vulkan 1.3

graphicsvulkanc++shadersgraphics pipelinedynamic renderingvulkan 1.3

What we’ll build here

End Result
End Result

The classic! Every graphics programmer’s “Hello World”. Three vertices, three colours, and the GPU blending them smoothly across the whole triangle. After the mountain of setup we climbed in the previous post, this one is going to feel much lighter, I promise!

This post builds directly on top of the coloured window post, so if you haven’t gone through that one yet, I’d strongly recommend doing so first. We’ll be adding to that same main.cpp, and I’ll only be showing the new and changed bits here.

To keep things focused, we’re going to hardcode our three vertices (positions and colours) right into the vertex shader itself, and not bother with vertex buffers at all yet. Full credit for this idea goes to the excellent Vulkan Tutorial, which uses this exact same trick to get a triangle on screen before introducing vertex buffers. Vertex buffers mean memory allocation, which is a big enough topic on its own, so this lets us focus on just two new things: shaders and the graphics pipeline.

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

Setup

On top of everything from the previous post, you’ll need:

  • A GLSL to SPIR-V compiler. The Vulkan SDK ships with two: glslc and glslangValidator. I used glslc, but either works.
  • Python 3 (optional), for the small script we’ll use to embed the compiled shaders into our source file. More on that below.

Building and running is exactly the same as last time, so I won’t repeat it here.

THEORY: The Graphics Pipeline

So far, the only thing we’ve asked the GPU to do is clear an image. To actually draw something, our draw commands go through a sequence of steps known as the graphics pipeline. A (simplified) view of it looks like this:

[Vertex Input] -> [Input Assembly] -> [Vertex Shader] -> [Rasterization] -> [Fragment Shader] -> [Colour Blending] -> Colour Attachment
  1. Vertex Input: Fetches the vertex data (positions, colours etc.) from vertex buffers. We’re skipping this one! Our vertices live inside the shader.

  2. Input Assembly: Groups the vertices into primitives. Every 3 vertices make a triangle, in our case.

  3. Vertex Shader (programmable): Runs once per vertex, and outputs its final position on screen, plus any other data we want to pass along (our colour!).

  4. Rasterization: Figures out which pixels each triangle covers, and produces a fragment for each of them. The data our vertex shader outputs gets interpolated across the triangle here.

  5. Fragment Shader (programmable): Runs once per fragment, and outputs its final colour.

  6. Colour Blending: Writes the fragment colour into the colour attachment, optionally blending it with what’s already there.

The stages marked programmable are the ones where we run our own code (shaders). The rest are fixed-function stages: we can’t write code for them, but we can configure how they behave.

Here’s the big difference compared to OpenGL: in OpenGL, all of this state is a giant global state machine, and you could change any bit of it at any time, right before any draw call. The driver would then have to figure out (and possibly recompile shaders!) behind your back at draw time, which is a well known source of random stutters. In Vulkan, we bake almost all of this state, together with the shaders, into a single, immutable VkPipeline object, created up front. If we want to draw with different state, we create another pipeline. Expensive work happens once, at creation time, and binding a pipeline at draw time is cheap.

Dynamic state

“Almost all” because there are some bits of state we can mark as dynamic, which means instead of being baked into the pipeline, they’re set with commands while recording the command buffer. We’ll be using this for the viewport and the scissor. Remember our window can be resized? The viewport and scissor depend on the swapchain size, so if we baked them into the pipeline, we’d have to recreate the pipeline every time the window gets resized. Making them dynamic means our pipeline doesn’t care about the window size at all.

Shaders

Vulkan doesn’t take shader source code (like GLSL) directly like OpenGL does. Instead, it takes shaders in SPIR-V, a binary intermediate format. We still write our shaders in GLSL, and then compile them to SPIR-V ahead of time with glslc. A nice side effect: there’s no driver specific GLSL parsing at runtime, so no more “it compiles on NVIDIA but not on AMD” surprises!

The shaders below are based on the shader code presented in the Vulkan Tutorial’s Shader modules chapter.

Vertex shader

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);
}

Let’s walk through this:

  • VertexData vertices[3]: Our three vertices, hardcoded right into the shader. Each one has a 2D position and an RGB colour: red at the top, green at the bottom right and blue at the bottom left.
  • gl_VertexIndex: A built-in input that tells us which vertex the shader is currently running for. Since we’ll be asking for 3 vertices in our draw call, it’ll be 0, 1 and 2, which we use to index into our array. This is what makes the whole “no vertex buffers” trick work!
  • gl_Position: A built-in output for the final position of the vertex, in clip space. It’s a vec4, so we put our 2D position into it with z = 0.0 and w = 1.0.
  • out_color: Our own output, which passes the vertex’s colour along to the fragment shader. The layout(location = 0) is how it’s matched up with the fragment shader input. More on that in a second.

NOTE: If you’re coming from OpenGL, watch out: in Vulkan, the Y axis of clip space points down. (-1, -1) is the top left corner of the viewport, and (1, 1) is the bottom right. That’s why our top vertex has a negative Y of -0.5.

Fragment shader

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);
}
  • in_color: The colour coming in from the vertex shader. Notice that the name is different (out_color in the vertex shader, in_color here). That’s fine! Vertex shader outputs and fragment shader inputs are matched by their location, not by their name. Both are location = 0, so they’re connected.
  • finalColor: Our output colour. location = 0 here means it goes into colour attachment 0, which is our swapchain image.

But wait, there are 3 vertices, and potentially thousands of fragments. Which vertex’s colour does in_color get? This is where the magic of the rasterizer comes in: it interpolates the vertex shader outputs across the triangle. A fragment right on the top vertex gets pure red, one halfway between the top and the bottom right vertices gets half red, half green, and one right in the middle gets an even mix of all three. That’s where the lovely gradient in the end result comes from, and we didn’t have to do anything for it!

Compiling the shaders

glslc shaders/default.vert -o shaders/default.vert.spv
glslc shaders/default.frag -o shaders/default.frag.spv

Embedding the shaders

Now, we could load these .spv files from disk at runtime. But that means writing file loading code, and dealing with “which directory is the app running from?” kind of issues, none of which have anything to do with Vulkan. So instead, I just embed the compiled SPIR-V bytes directly into main.cpp as arrays. This keeps our sample nice and simple, in a single file.

For this, I wrote a tiny Python script (you can find it at tools/embed_spv.py in the GitHub repo) that reads a .spv file and prints it as a C++ array of 32-bit words:

python3 tools/embed_spv.py shaders/default.vert.spv

which prints something like:

static constexpr uint32_t SHADER_SPV_[] = {
	0x07230203,0x00010000,0x000D000B,0x0000003B,0x00000000,0x00020011,0x00000001,0x0006000B,
	// ... lots more words
};

See that very first word, 0x07230203? That’s the SPIR-V magic number. Every valid SPIR-V binary starts with it, so it’s a handy sanity check that you embedded the right file!

We then paste the output for both shaders into our SampleVulkan class, completing the names as SHADER_SPV_VERT and SHADER_SPV_FRAG:

class SampleVulkan
{
  // ...

  static constexpr uint32_t SHADER_SPV_VERT[] = {
  	// ... words of default.vert.spv
  };

  static constexpr uint32_t SHADER_SPV_FRAG[] = {
  	// ... words of default.frag.spv
  };

  // ...
};

Don’t forget to re-run the compile and embed steps whenever you change a shader! Otherwise, you’ll be scratching your head wondering why your changes aren’t showing up.

NOTE: Why uint32_t and not just plain bytes? SPIR-V is a stream of 32-bit words, and that’s exactly what Vulkan expects: a pointer to uint32_ts (const uint32_t *pCode), which has to be 4 byte aligned. A uint8_t byte array is only guaranteed to be 1 byte aligned, so casting it to a uint32_t pointer would usually work, but “usually” isn’t good enough. By storing our shaders as uint32_t arrays to begin with, they’re guaranteed to be correctly aligned, and we can hand them to Vulkan as is, no casts needed.

Graphics Pipeline Creation

Time to put it all together! Let’s start with the new declarations in our class:

private:
  bool create_graphics_pipeline();

private:
  VkPipeline m_graphics_pipeline{};
  VkPipelineLayout m_graphics_pipeline_layout{};

The graphics pipeline creation is one long function, with a lot of structures to fill out (one for pretty much each stage of the pipeline we talked about). So let’s go through it one piece at a time.

Pipeline layout

bool SampleVulkan::create_graphics_pipeline()
{
  VkPipelineLayoutCreateInfo layout_create_info{
    .sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO,
    .setLayoutCount = 0,
    .pushConstantRangeCount = 0,
  };

  if (vkCreatePipelineLayout(m_device, &layout_create_info, nullptr, &m_graphics_pipeline_layout) != VK_SUCCESS) {
    printf("Failed to create the graphics pipeline layout\n");
    return false;
  }

The pipeline layout describes the external resources our shaders can access: descriptor sets (for things like uniform buffers and textures) and push constants (small bits of data we can push straight into a command buffer). Our shaders don’t use any of those, so our layout is empty. But we still need one, as every pipeline must have a layout. We’ll make good use of it later on in the series.

Shader modules

  VkShaderModule shader_modules[2];

  VkShaderModuleCreateInfo shader_module_create_infos[2] = {
    {
      .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO,
      .codeSize = sizeof(SHADER_SPV_VERT),
      .pCode = SHADER_SPV_VERT,
    },
    {
      .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO,
      .codeSize = sizeof(SHADER_SPV_FRAG),
      .pCode = SHADER_SPV_FRAG,
    },
  };
  
  if (vkCreateShaderModule(m_device, &shader_module_create_infos[0], nullptr, &shader_modules[0]) != VK_SUCCESS) {
    printf("Failed to create the shader module for vertex shader\n");
    return false;
  }

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

A shader module (VkShaderModule) is simply a thin wrapper around our SPIR-V code. Note that codeSize is in bytes, even though pCode is a pointer to uint32_ts. sizeof on our arrays gives us exactly that, the size of the whole array in bytes, so don’t go dividing it by 4!

Shader stages

  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",
    },
  };

Here, we tell the pipeline which shader module to use for which stage. pName is the name of the entry point function in the shader, which is main for both of ours. A single SPIR-V module can actually contain multiple entry points, which is why we have to specify it.

NOTE: Do not forget to set pName! I did, and on my machine, it didn’t give a nice validation error, it just straight up crashed the driver inside vkCreateGraphicsPipelines.

Dynamic rendering info

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

In Vulkan 1.0, a pipeline had to be created against a specific VkRenderPass. Thanks to dynamic rendering, we don’t have any render passes, so instead we just tell the pipeline the formats of the attachments it will be rendering into. We have one colour attachment, our swapchain image, and no depth or stencil attachments (yet!).

These formats must match the attachments we pass to vkCmdBeginRendering later. Since our swapchain format is picked once, and never changes on swapchain recreation, our pipeline stays valid across window resizes.

Vertex input & input assembly

  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,
  };
  • Vertex input: Describes the layout of our vertex buffers. We have none, so: nothing! Zero bindings, zero attributes. This is where the “vertices in the shader” trick pays off.
  • Input assembly: VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST means every 3 vertices form a separate triangle. Other options include points, lines and triangle strips.

Viewport state

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

The viewport describes the region of the attachment that clip space gets mapped onto, and the scissor is a rectangle outside of which all fragments are discarded. Since we’re making both of them dynamic (see below), we don’t give the actual viewport and scissor here, only how many of them we’ll be using.

Rasterization & multisampling

  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,
  };
  • polygonMode: FILL fills in our triangles. (LINE would give us a wireframe, but that requires enabling a GPU feature.)
  • cullMode: Normally, we’d tell the rasterizer to throw away triangles that face away from the camera (back-face culling), which is decided by the order (winding) of their vertices. We don’t have to care about that yet, so we just turn culling off.
  • lineWidth: Must be 1.0, unless we enable the wideLines feature. Yes, even though we’re drawing triangles, not lines!
  • rasterizationSamples: We don’t do any multisampling (MSAA) for now, so one sample per pixel.

Colour blending

  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,
  };

We need one VkPipelineColorBlendAttachmentState per colour attachment. We don’t want any blending, our fragment colour should simply replace what’s in the image, so blendEnable is VK_FALSE.

Now pay close attention to colorWriteMask: it controls which of the R, G, B and A channels actually get written into the attachment. If you just zero initialize this structure (like I initially did…), the write mask is 0, which means no channels get written at all! The pipeline runs, the fragment shader runs, and then the results are just thrown away. And since that’s a perfectly legal thing to do, the validation layers won’t say a word about it. You’ll just be staring at an empty window, wondering where your triangle went!

Dynamic 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,
  };

Here, we tell the pipeline that the viewport and scissor will be set dynamically, while recording our command buffers.

Creating the pipeline

Finally, we tie all of the above together:

  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 = m_graphics_pipeline_layout,
  };

  if (vkCreateGraphicsPipelines(m_device, nullptr, 1, &create_info, nullptr, &m_graphics_pipeline) != VK_SUCCESS) {
    printf("Failed to create the graphics pipeline\n");

    vkDestroyShaderModule(m_device, shader_modules[0], nullptr);
    vkDestroyShaderModule(m_device, shader_modules[1], nullptr);
    return false;
  }

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

  return true;
}

A few things to note:

  • We chain our dynamic rendering info through pNext, just like we did with the debug messenger create info when creating the instance.
  • The second parameter of vkCreateGraphicsPipelines is a pipeline cache, which can be used to speed up pipeline creation (for example, across runs of the app). We don’t use one, so we pass nullptr.
  • vkCreateGraphicsPipelines can create multiple pipelines in one go, which is why it takes a count (1) and an array of create infos.
  • Once the pipeline is created, it no longer needs the shader modules, so we can destroy them right away.

And let’s call it from initialize, after creating our frame resources:

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;

  if (!create_frame_resources())
    return false;

  if (!create_graphics_pipeline())
    return false;

  return true;
}

Don’t forget to destroy the pipeline and its layout in our destructor, right after waiting for the device to be idle:

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

    vkDestroyPipeline(m_device, m_graphics_pipeline, nullptr);
    vkDestroyPipelineLayout(m_device, m_graphics_pipeline_layout, nullptr);

    // ... rest of the cleanup from the previous post
  }

  // ...
}

Drawing the Triangle

Remember that empty space between vkCmdBeginRendering and vkCmdEndRendering from the last post? It’s finally getting some company! Here’s our updated draw_frame:

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);

  vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, m_graphics_pipeline);
  vkCmdDraw(cmd, 3, 1, 0, 0);

  vkCmdEndRendering(cmd);
}
  1. The clear colour: Changed from our lovely blue to a dark grey, so that our colourful triangle stands out.

  2. vkCmdSetViewport and vkCmdSetScissor: Since we made the viewport and the scissor dynamic, we have to set them before drawing. Both cover the whole swapchain image. minDepth and maxDepth define the depth range the viewport maps to, and 0.0 to 1.0 is the standard range. We use m_swapchain_extent here, so whenever the window is resized (and the swapchain is recreated), the viewport and the scissor automatically follow!

  3. vkCmdBindPipeline: Binds our graphics pipeline, so that the draw commands that follow use it.

  4. vkCmdDraw: The moment we’ve all been waiting for! The parameters are:

  • vertexCount = 3: Run the vertex shader for 3 vertices. These become our gl_VertexIndex values 0, 1 and 2.
  • instanceCount = 1: We’re drawing a single instance (instancing lets us draw the same geometry many times in one call).
  • firstVertex = 0 and firstInstance = 0: Start counting from zero.

Notice how we didn’t bind any vertex buffers before drawing. We didn’t need to, our vertices are in the shader!

Wrapping up

And that’s it! Run it, and you should be greeted by our beautiful coloured triangle. Resize the window, and it stretches along with it, thanks to our dynamic viewport and scissor.

Let’s recap what it took this time:

  1. Wrote our first vertex and fragment shaders in GLSL, with the vertices hardcoded right into the vertex shader
  2. Compiled them to SPIR-V with glslc, and embedded the result into our source
  3. Created shader modules from that SPIR-V
  4. Created a pipeline layout (empty, for now)
  5. Created a graphics pipeline, describing every stage our draw goes through, with dynamic rendering and a dynamic viewport and scissor
  6. Every frame: set the viewport and scissor, bound our pipeline and drew 3 vertices

Much shorter list than last time, right? That’s the payoff of all that setup in the previous post. Most of our time was spent on the graphics pipeline, which you’ll find yourself creating (and tweaking) again and again from here on.

Go ahead and play around with it! Change the colours or the positions of the vertices in the vertex shader (and remember to recompile and re-embed!), or try outputting a single colour from the fragment shader, to see the interpolation at work (or rather, not at work).

Next up: VMA, GLM and Refactoring

Hardcoding vertices into the shader got us a triangle, but it obviously doesn’t scale. You can’t exactly ship a game with its whole world baked into a vertex shader! We’ll fix that with proper vertex and index buffers, but first, some groundwork. Our single main.cpp is getting a bit long (well over a thousand lines!), so in the next post, we’ll tidy things up and split the code into multiple files, to keep it maintainable as the series grows. We’ll also bring in two libraries we’ll be leaning on from then on: the Vulkan Memory Allocator (VMA), to take care of memory allocation in Vulkan for us, and GLM, the go-to maths library for graphics programming.

With all that in place, the post after that will finally move our vertices out of the shader and into vertex and index buffers, using GLM types to hold their positions and colours.

References

  1. Vulkan Tutorial (the idea of hardcoding the vertices into the vertex shader comes from here)
  2. Vulkan Tutorial: Shader modules (our shaders are based on the shader code presented here)
  3. VkGuide
  4. The Vulkan specification (version 1.4.364 at the time of writing)

Full Source Code

The complete sample in a single file, around 1150 lines (a good chunk of which is the embedded shader code!).

Show full source (main.cpp)
// Released into the public domain.
// Written by I-A-S for use in i-a-s.me vulkan series.

#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <cassert>

#include <algorithm>
#include <functional>
#include <memory>
#include <set>
#include <string>
#include <utility>
#include <vector>

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

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];

  VkPipeline m_graphics_pipeline{};
  VkPipelineLayout m_graphics_pipeline_layout{};
};

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{};
  std::unique_ptr<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 = std::make_unique<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();
}

// -----------------------------------------------------------------------------

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);

    vkDestroyPipeline(m_device, m_graphics_pipeline, nullptr);
    vkDestroyPipelineLayout(m_device, m_graphics_pipeline_layout, nullptr);

    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);

    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;

  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);

  vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, m_graphics_pipeline);
  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()
{
  VkPipelineLayoutCreateInfo layout_create_info{
    .sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO,
    .setLayoutCount = 0,
    .pushConstantRangeCount = 0,
  };

  if (vkCreatePipelineLayout(m_device, &layout_create_info, nullptr, &m_graphics_pipeline_layout) != VK_SUCCESS) {
    printf("Failed to create the graphics pipeline layout\n");
    return false;
  }

  VkShaderModule shader_modules[2];

  VkShaderModuleCreateInfo shader_module_create_infos[2] = {
    {
      .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO,
      .codeSize = sizeof(SHADER_SPV_VERT),
      .pCode = SHADER_SPV_VERT,
    },
    {
      .sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO,
      .codeSize = sizeof(SHADER_SPV_FRAG),
      .pCode = SHADER_SPV_FRAG,
    },
  };
  
  if (vkCreateShaderModule(m_device, &shader_module_create_infos[0], nullptr, &shader_modules[0]) != VK_SUCCESS) {
    printf("Failed to create the shader module for vertex shader\n");
    return false;
  }

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

  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 = &m_swapchain_format.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 = m_graphics_pipeline_layout,
  };

  if (vkCreateGraphicsPipelines(m_device, nullptr, 1, &create_info, nullptr, &m_graphics_pipeline) != VK_SUCCESS) {
    printf("Failed to create the graphics pipeline\n");

    vkDestroyShaderModule(m_device, shader_modules[0], nullptr);
    vkDestroyShaderModule(m_device, shader_modules[1], nullptr);
    return false;
  }

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

  return true;
}
Show shaders (default.vert & default.frag)

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);
}

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);
}