Vulkan project
This is a set of samples that demonstrate fundamental differences between Vulkan and OpenGL
Vulkan is not well-suited to simple test applications; neither is it a suitable aid for teaching graphics concepts.

— Graham Sellers, Vulkan Programming Guide

To remain true to the quote, we actually don't want to fully explain how to write a minimal Vulkan application. The main reasons for this decision are, on the one hand, that such a "minimal" project consists of approximately 2000 lines of code. On the other hand these lines are quite verbose and thus boring to read. That's why we are only show code examples where we can compare Vulkan to OpenGL easily.

For the curious, the whole code can, of course, be found on out gitlab page. There are also detailed instructions on how to build the project yourself.

We also want to draw your attention to these excellent Vulkan sample projects by Sascha Willems and this extremely comprehensive Vulkan tutorial.

We encourage you to browse the Vulkan documentation yourself by clicking the underlined types and functions in the code listings.

Record the command queue

From this small sample code, we can already observe some recurring patterns. Pay attention to the sType data member of each Vulkan struct: it must always be filled with an enumeration value that corresponds to the struct's type.

The first code paragraph shows how to initialize the command buffer begin info struct. The flag we are giving is the most flexible one, though it is not really needed here.

In line 6, we actually begin the recording of the command buffer. All vkCmd... functions from now on will put the commands into the currently active command buffer. Jumping to line 42, you see the corresponding vkEndCommandBuffer call, which ends the recording. All errors that occured during the recording are postponed until now. hasError is a simple helper function that checks for generic Vulkan errors [source].

Starting with line 8, we begin our first and only render pass by, again, filling out a struct. The most notable member variables are the area we want to draw on, given by an offset of (0, 0) and the width and height of basically the whole window.

The next paragraph is similar to glClearColor and glClearDepth. Here, we set the clear color for the frame buffer to a dark grey with full opacity and the depth buffer to 1.0f which is also the default value in OpenGL and denotes the maximum depth.

Now, line 22, we record the start of the render pass, which is ended in line 40. The next paragraph is again similar to the OpenGL render loop. However, instead of binding the whole vertex array object, we must manually bind the buffer that holds the vertex data and after that the buffer containing the indices. For performance reasons, all objects are stored inside a single buffer, because allocating many small memory segments is very inefficient in Vulkan. The distinction is simply made by an offset and length, which is stored for each object.

Note: a better approach would be to have vertex buffers for each object which are backed by a single piece of memory. For the sake of simplicity we took this approach.

Inside the for loop, we bind the pipeline, which is similar to the glUseProgram, although there are more things configured inside the Vulkan pipeline – e.g. wireframe mode ...

vkCmdBindDescriptorSets is handled completely transparent in OpenGL. In Vulkan however, we must explicitly describe the uniform data we are about to send during render.

Finally, after calculating the next vertex buffer offset, we record the actual draw call. Note that in Vulkan there is no distinction between an indexed draw call and an individual draw call. Everything is handled with the function arguments.

Hint: variables starting with m_ are member variables of the application class. The exact definition is not important here – the name should make the meaning clear.
 1 VkCommandBufferBeginInfo begin;
 2 begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
 3 begin.flags = VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT;
 4 begin.pInheritanceInfo = nullptr;
 5 
 6 vkBeginCommandBuffer(m_drawCommandBuffer, &begin);
 7 
 8 VkRenderPassBeginInfo render;
 9 render.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
10 render.renderPass = m_renderPass;
11 render.framebuffer = m_framebuffer;
12 render.renderArea.offset = {0, 0};
13 render.renderArea.extent = m_swapchain.extent;
14 
15 std::array<VkClearValue, 2> clear;
16 clear[0].color = {0.1f, 0.1f, 0.1f, 1.0f};
17 clear[1].depthStencil = {1.0f, 0};
18 
19 render.clearValueCount = (uint32_t)clear.size();
20 render.pClearValues = clear.data();
21 
22 vkCmdBeginRenderPass(m_drawCommandBuffer, &render, VK_SUBPASS_CONTENTS_INLINE);
23 
24 VkDeviceSize offsets[] = {0};
25 vkCmdBindVertexBuffers(m_drawCommandBuffer, 0, 1, m_vertexBuffer, offsets);
26 vkCmdBindIndexBuffer(m_drawCommandBuffer, m_indexBuffer, 0, VK_INDEX_TYPE_UINT32);
27 
28 uint32_t dynamicOffset = 0;
29 for (const auto &object : m_scene.objects)
30 {
31     vkCmdBindPipeline(m_drawCommandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, object.pipeline);
32 
33     vkCmdBindDescriptorSets(m_drawCommandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, object.pipeline.layout, 0, 1,
34                             &object.texture.descriptorSet, 1, &dynamicOffset);
35 
36     dynamicOffset += object.pipeline.uniform.localAlignment;
37     vkCmdDrawIndexed(m_drawCommandBuffer, object.model.size, object.instanceCount, object.model.offset, 0, object.firstInstance);
38 }
39 
40 vkCmdEndRenderPass(m_drawCommandBuffer);
41 
42 if(hasError(vkEndCommandBuffer(m_drawCommandBuffer)))
43 {
44     throw std::runtime_error("Failed to record command buffers");
45 }

Draw the scene

In contrast to OpenGL, we must explicitly take care of the synchronization when rendering and operating the swap chain. Let's start with acquiring an image from the swapchain so we can start drawing a frame. At first, we must make sure that the command queue that handles presentation is not busy anymore (line 1). Next, we actually request the image, passing a reference to a semaphore (m_imageAvailableSemaphore) that will be signaled once the image is, indeed, available. There are some things that can go wrong, but we won't discuss the error handling here.

 1 vkQueueWaitIdle(m_presentationQueue);
 2 
 3 uint32_t image;
 4 VkResult result = vkAcquireNextImageKHR(m_device, m_swapchain, std::numeric_limits<uint64_t>::max(), m_imageAvailableSemaphore, VK_NULL_HANDLE, &image);
 5 
 6 if(result == VK_ERROR_OUT_OF_DATE_KHR)
 7 {
 8     recreateSwapChain();
 9     return;
10 }
11 else if(result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR)
12 {
13     throw std::runtime_error("Failed to acquire swap chain image");
14 }

Now, we must fill a struct to handle additional synchronizations. In line 6 we specify that the rendering can only start when the swapchain image is available. Actually only the stages that output color information on this image are required to wait – which is what we configure with lines 4 and 7. Last, we pass the command queue responsible for rendering and a semaphore that is signaled once the rendering is done. Finally we submit the commands and, again, handle the possible errors.

 1 VkSubmitInfo submit;
 2 submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
 3 
 4 VkPipelineStageFlags wait[] = {VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT};
 5 submit.waitSemaphoreCount = 1;
 6 submit.pWaitSemaphores = &m_imageAvailableSemaphore;
 7 submit.pWaitDstStageMask = wait;
 8 submit.commandBufferCount = 1;
 9 submit.pCommandBuffers = &m_drawCommandBuffer;
10 submit.signalSemaphoreCount = 1;
11 submit.pSignalSemaphores = &m_renderFinishedSemaphore;
12 
13 if(hasError(vkQueueSubmit(m_graphicsQueue, 1, &submit, VK_NULL_HANDLE)))
14 {
15     throw std::runtime_error("Failed to submit draw command buffer");
16 }

In the last step, we submit the fresh image back to the swapchain so it can be displayed. We want the display step to wait for the rendering which is why we pass the appropriate semaphore here. In the end we actually present the image and handle the errors.

Note that the struct with the KHR suffix are actually extensions as Vulkan itself does not handle the presentation in order to keep the core simple.
 1 VkPresentInfoKHR present;
 2 present.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
 3 present.waitSemaphoreCount = 1;
 4 present.pWaitSemaphores = &m_renderFinishedSemaphore;
 5 present.swapchainCount = 1;
 6 present.pSwapchains = m_swapchain;
 7 present.pImageIndices = &image;
 8 present.pResults = nullptr;
 9 
10 result = vkQueuePresentKHR(m_presentationQueue, &present);
11 
12 if(result == VK_ERROR_OUT_OF_DATE_KHR || result == VK_SUBOPTIMAL_KHR)
13 {
14     recreateSwapChain();
15 }
16 else if(hasError(result))
17 {
18     throw std::runtime_error("Failed to present swap chain image");
19 }