Chapter 4: Adding User Interaction and Productivity Tools

In this chapter, we will learn how to implement basic helpers to drastically simplify the debugging of graphical applications. There are a couple of demos implemented in OpenGL. However, a significant part of our code in this chapter is Vulkan-based. In Chapter 3, Getting Started with OpenGL and Vulkan, we demonstrated how to implement numerous helper functions to create and maintain basic Vulkan states and objects. In this chapter, we will show how to start organizing Vulkan frame rendering in a way that is easily extensible and adaptable for different applications.

We will cover the following recipes:

  • Organizing Vulkan frame rendering code
  • Organizing mesh rendering in Vulkan
  • Implementing an immediate mode drawing canvas
  • Rendering a Dear ImGui user interface with Vulkan
  • Working with a 3D camera and basic user interaction
  • Adding a frames-per-second counter
  • Adding camera animations and motion
  • Integrating EasyProfiler and Optick into C++ applications
  • Using cube map textures in Vulkan
  • Rendering onscreen charts
  • Putting it all together into a Vulkan application

Technical requirements

Here is what it takes to run the code from this chapter on your Linux or Windows PC. You will need a GPU with recent drivers supporting OpenGL 4.6 and Vulkan 1.1. The source code can be downloaded from https://github.com/PacktPublishing/3D-Graphics-Rendering-Cookbook.

Organizing Vulkan frame rendering code

As we learned from the previous chapter, the Vulkan API is pretty verbose. We need something to conceal the API's verbosity and organize our frame rendering code in a manageable way. Let's assume that each frame is composed of multiple layers, just like an image in a graphics editor. The first, and rather formal, layer is the solid background color. The next layer might be a 3D scene with models and lights. On top of a beautifully rendered 3D scene, we could optionally add some wireframe meshes to draw useful debugging information. These wireframe objects belong in another layer. Next, we add a 2D user interface, for example, using the ImGui library. There might be some additional layers, such as fullscreen charts with performance statistics or frames-per-second (FPS) counters. Finally, the finishing layer transitions the swapchain image to the VK_LAYOUT_PRESENT_SRC_KHR layout.

In this recipe, we define an interface to render a single layer and implement this interface for screen-clearing and finishing layers.

Getting ready

The source code for this recipe is a part of the utility code located in shared/vkRenderers/. Take a look at these files: VulkanModelRenderer.h, VulkanMultiRenderer.h, and VulkanQuadRenderer.h.

How to do it...

In terms of programming, each layer is an object that contains a Vulkan pipeline object, framebuffers, a Vulkan rendering pass, all descriptor sets, and all kinds of buffers necessary for rendering. This object provides an interface that can fill Vulkan command buffers for the current frame and update GPU buffers with CPU data, for example, per-frame uniforms such as the camera transformation.

Let's look at how to define the base class for our layer rendering interface, RendererBase:

  1. The first function this interface provides is fillCommandBuffer(). This function injects a stream of Vulkan commands into the passed command buffer. The second parameter to this function, currentImage, is required to use appropriate uniform and data buffers associated with one of the swapchain images:

    class RendererBase {

    public:

      explicit RendererBase(    const VulkanRenderDevice& vkDev,    VulkanImage depthTexture)

      : device_(vkDev.device)

      , framebufferWidth_(vkDev.framebufferWidth)

      , framebufferHeight_(vkDev.framebufferHeight)

      , depthTexture_(depthTexture)

      {}

      virtual ~RendererBase();

      virtual void fillCommandBuffer(    VkCommandBuffer commandBuffer,    size_t currentImage) = 0;

  2. The getDepthTexture() method gives access to the internally managed depth buffer, which can be shared between layers:

      inline VulkanImage getDepthTexture() const

      { return depthTexture_; }

  3. The state of the object consists of the fields, most of which we have already seen in the VulkanState class from the Chapter 3 demo application. Two member functions, commonly used by the derived classes, are the render pass starter and the uniform buffer allocator. The first one emits the vkCmdBeginRenderPass, vkCmdBindPipeline, and vkCmdBindDescriptorSet commands to begin rendering. The second function allocates a list of GPU buffers that contain uniform data, with one buffer per swapchain image:

    protected:

      void beginRenderPass(VkCommandBuffer commandBuffer,    size_t currentImage);

      bool createUniformBuffers(VulkanRenderDevice& vkDev,    size_t uniformDataSize);

  4. Each layer renderer internally uses the size of the framebuffer to start a rendering pass:

      uint32_t framebufferWidth_;

      uint32_t framebufferHeight_;

  5. All textures and buffers are bound to the shader modules by descriptor sets. We maintain one descriptor set per swapchain image. To define these descriptor sets, we also need the descriptor set layout and the descriptor pool:

      VkDescriptorSetLayout descriptorSetLayout_;

      VkDescriptorPool descriptorPool_;

      std::vector<VkDescriptorSet> descriptorSets_;

  6. Each command buffer operates on a dedicated framebuffer object:

      std::vector<VkFramebuffer> swapchainFramebuffers_;

  7. We store the depth buffer reference, which is passed here during the initialization phase. The render pass, pipeline layout, and the pipeline itself are also taken from the VulkanState object:

      VulkanImage depthTexture_;

      VkRenderPass renderPass_;

      VkPipelineLayout pipelineLayout_;

      VkPipeline graphicsPipeline_;

  8. Each swapchain image has an associated uniform buffer. We declare these buffers here:

      std::vector<VkBuffer> uniformBuffers;

      std::vector<VkDeviceMemory> uniformBuffersMemory;

    };

Let's look into the implementation code of the previously mentioned RenderBase interface, which is reused by all its subclasses:

  1. The implementation of createUniformBuffers() is a simple loop over all swapchain images that calls createUniformBuffer() for each of them:

    bool RendererBase::createUniformBuffers(  VulkanRenderDevice& vkDev, size_t uniformDataSize)

    {

      uniformBuffers_.resize(    vkDev.swapchainImages.size());

      uniformBuffersMemory_.resize(    vkDev.swapchainImages.size());

      for (size_t i = 0;       i < vkDev.swapchainImages.size(); i++) {

        if (!createUniformBuffer(vkDev,           uniformBuffers_[i],           uniformBuffersMemory_[i],           uniformDataSize)) {

          printf("Cannot create uniform buffer ");

          return false;

        }

      }

      return true;

    }

  2. The beginRenderPass() routine marks the start of a rendering pass and does graphics pipeline binding and descriptor set binding for the current image in the swapchain:

    void RendererBase::beginRenderPass(  VkCommandBuffer commandBuffer, size_t currentImage)

    {

      const VkRect2D screenRect = {    .offset = { 0, 0 },    .extent = {      .width = framebufferWidth_,      .height = framebufferHeight_    }  };

      const VkRenderPassBeginInfo renderPassInfo = {    .sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO,    .pNext = nullptr,    .renderPass = renderPass_,    .framebuffer =      swapchainFramebuffers_[currentImage],    .renderArea = screenRect   };

      vkCmdBeginRenderPass(commandBuffer,     &renderPassInfo,    VK_SUBPASS_CONTENTS_INLINE);

      vkCmdBindPipeline(commandBuffer,    VK_PIPELINE_BIND_POINT_GRAPHICS,    graphicsPipeline_);

      vkCmdBindDescriptorSets(commandBuffer,    VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_,    0, 1, &descriptorSets_[currentImage], 0, nullptr);

    }

  3. Finally, the destructor cleans up each allocated object. In a bigger application, you might want to write an RAII wrapper for each of these resources to ensure automatic deallocation:

    RendererBase::~RendererBase() {

      for (auto buf : uniformBuffers_)    vkDestroyBuffer(device_, buf, nullptr);

      for (auto mem : uniformBuffersMemory_)    vkFreeMemory(device_, mem, nullptr);

      vkDestroyDescriptorSetLayout(    device_, descriptorSetLayout_, nullptr);

      vkDestroyDescriptorPool(    device_, descriptorPool_, nullptr);

      for (auto framebuffer : swapchainFramebuffers_)

        vkDestroyFramebuffer(      device_, framebuffer, nullptr);

      vkDestroyRenderPass(device_, renderPass_, nullptr);

      vkDestroyPipelineLayout(    device_, pipelineLayout_, nullptr);

      vkDestroyPipeline(    device_, graphicsPipeline_, nullptr);

    }

    Note that this assumes all these objects were actually allocated in the initialization routines of the derived classes.

Now that we have our basic layer rendering interface, we can define the first layer. The VulkanClear object initializes and starts an empty rendering pass whose only purpose is to clear the color and depth buffers. Let's look at the steps:

  1. The initialization routine takes a reference to our rendering device object and a possibly empty depth buffer handle:

    class VulkanClear: public RendererBase {

    public:

      VulkanClear(VulkanRenderDevice& vkDev,    VulkanImage depthTexture);

  2. The only function we implement here starts and finishes our single rendering pass. The private part of the class contains a Boolean flag that tells it to clear the depth buffer:

      virtual void fillCommandBuffer(    VkCommandBuffer commandBuffer,    size_t currentImage) override;

    private:

      bool shouldClearDepth;

    };

  3. The constructor creates framebuffers, the rendering pass, and the graphics pipeline:

    VulkanClear::VulkanClear(

      VulkanRenderDevice& vkDev, VulkanImage depthTexture)

    : RendererBase(vkDev, depthTexture)

    , shouldClearDepth(    depthTexture.image != VK_NULL_HANDLE)

    {

  4. The RenderPassCreateInfo structure defines how this rendering pass should be created. We introduce a set of flags to simplify this process. The eRenderPassBit_First flag defines a rendering pass as the first pass. What this means for Vulkan is that before this rendering pass, our swapchain image should be in the VK_LAYOUT_UNDEFINED state and, after this pass, it is not yet suitable for presentation but only for rendering:

      if (!createColorAndDepthRenderPass(        vkDev, shouldClearDepth, &renderPass_,

            RenderPassCreateInfo {               .clearColor_ = true, .clearDepth_ = true,          .flags_ = eRenderPassBit_First})) {

        printf(      "VulkanClear: failed to create render pass ");

        exit(EXIT_FAILURE);

      }

      createColorAndDepthFramebuffers(vkDev, renderPass_,    depthTexture.imageView, swapchainFramebuffers_);

    }

  5. The fillCommandBuffer() function starts and finishes the render pass, also filling the clearValues member field in the VkBeginRenderPassInfo structure:

    void VulkanClear::fillCommandBuffer(  VkCommandBuffer commandBuffer,  size_t swapFramebuffer)

    {

      const VkClearValue clearValues[2] = {    VkClearValue {.color = {1.0f, 1.0f, 1.0f, 1.0f} },    VkClearValue {.depthStencil = { 1.0f, 0.0f } }  };

      const VkRect2D screenRect = {    .offset = { 0, 0 },    .extent = { .width  = framebufferWidth_,                .height = framebufferHeight_ }

      };

  6. If we need to clear the depth buffer in this render pass, we should use both clear values from the clearValues array. Otherwise, use only the first value to clear the color buffer:

      const VkRenderPassBeginInfo renderPassInfo = {    .sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO,    .renderPass = renderPass_,    .framebuffer =       swapchainFramebuffers_[swapFramebuffer],    .renderArea = screenRect,    .clearValueCount = shouldClearDepth ? 2u : 1u,    .pClearValues = &clearValues[0]  };

      vkCmdBeginRenderPass(commandBuffer, &renderPassInfo,    VK_SUBPASS_CONTENTS_INLINE);

      vkCmdEndRenderPass(commandBuffer);

    }

Now we are able to start rendering our frame, but it is equally important to finish the frame. The following VulkanFinish object helps us to create another empty rendering pass that transitions the swapchain image to the VK_IMAGE_LAYOUT_PRESENT_SRC_KHR format:

  1. Let's take a look at the VulkanFinish class declaration:

    class VulkanFinish: public RendererBase {

    public:

      VulkanFinish(VulkanRenderDevice& vkDev,    VulkanImage depthTexture);

      virtual void fillCommandBuffer(    VkCommandBuffer commandBuffer,    size_t currentImage) override;

    };

    There are no additional members introduced and all resource management is done in the base class.

  2. The class constructor creates one empty rendering pass:

    VulkanFinish::VulkanFinish(

      VulkanRenderDevice& vkDev, VulkanImage depthTexture)

      : RendererBase(vkDev, depthTexture)

    {

      if (!createColorAndDepthRenderPass(        vkDev, (depthTexture.image != VK_NULL_HANDLE),        &renderPass_,        RenderPassCreateInfo{          .clearColor_ = false,          .clearDepth_ = false,          .flags_ = eRenderPassBit_Last       })) {

        printf(      "VulkanFinish: failed to create render pass ");

        exit(EXIT_FAILURE);

      }

      createColorAndDepthFramebuffers(vkDev,    renderPass_, depthTexture.imageView,    swapchainFramebuffers_);

    }

    This rendering pass is the last one and should not clear any buffers.

  3. Starting and finishing the rendering pass is rather similar to the functionality of VulkanClear described previously, except that here we do not clear any buffers:

    void VulkanFinish::fillCommandBuffer(  VkCommandBuffer commandBuffer, size_t currentImage)

    {

      const VkRect2D screenRect = {    .offset = { 0, 0 },    .extent = { .width = screenWidth,                .height = screenHeight }  };

      const VkRenderPassBeginInfo renderPassInfo = {    .sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO,    .renderPass = renderPass,    .framebuffer = swapchainFramebuffers[currentImage],    .renderArea = screenRect   };

      vkCmdBeginRenderPass(commandBuffer, &renderPassInfo,    VK_SUBPASS_CONTENTS_INLINE);

      vkCmdEndRenderPass(commandBuffer);

    }

This class essentially hides all the hassle necessary to finalize frame rendering in Vulkan. We will use it in our demos together with VulkanClear.

There's more…

We have only started our frame rendering reorganization. The complete Vulkan usage example, which will put together the functionality of all the recipes of this chapter, is postponed until we go through and discuss all the recipes here. The next recipe will show how to simplify static 3D mesh rendering with Vulkan.

Organizing mesh rendering in Vulkan

In Chapter 3, Getting Started with OpenGL and Vulkan, we learned how to render a textured 3D model on the screen using Vulkan in a direct and pretty ad hoc way. Now we will show how to move one step closer to creating a more scalable 3D model renderer with Vulkan. In the subsequent chapters, we will further generalize the rendering approach so that we can build a minimalistic Vulkan rendering engine from scratch, step by step.

Getting ready

Revisit the Organizing Vulkan frame rendering code recipe and recall how the RendererBase interface works. The code we will discuss in this recipe can be found in shared/vkRenderers/VulkanModelRenderer.cpp and the corresponding header file.

How to do it...

Let's declare the ModelRenderer class, which contains a texture and combined vertex and index buffers:

  1. The declaration looks as follows:

    class ModelRenderer: public RendererBase {

    public:

      ModelRenderer(VulkanRenderDevice& vkDev,    const char* modelFile,    const char* textureFile,    uint32_t uniformDataSize);

      virtual ~ModelRenderer();

      virtual void fillCommandBuffer(    VkCommandBuffer commandBuffer,    size_t currentImage) override;

      void updateUniformBuffer(VulkanRenderDevice& vkDev,    uint32_t currentImage,    const void* data, size_t dataSize);

    We add a new function to the interface, which allows us to modify per-frame uniform constants. In this case, we pass only the premultiplied model-view matrix to the shaders. The fillCommandBuffer() function emits the appropriate vkCmdDraw command into a Vulkan command buffer.

  2. The storage buffer holds index and vertex data combined. Besides that, we should store the sizes of vertex and index data separately:

    private:

      size_t vertexBufferSize_;

      size_t indexBufferSize_;

      VkBuffer storageBuffer_;

      VkDeviceMemory storageBufferMemory_;

  3. Add a single texture sampler and a texture:

      VkSampler textureSampler_;

      VulkanImage texture_;

      bool createDescriptorSet(    VulkanRenderDevice& vkDev,    uint32_t uniformDataSize);

    };

    In this recipe, we limit our 3D model to containing only one texture. This is a pretty strong constraint but it will help us to keep our code structure for this chapter reasonably simple without adding too much complexity here at once. Materials will be covered in Chapter 7, Graphics Rendering Pipeline.

Now, let's switch to the implementation part of this class:

  1. Everything starts with loading a texture and a mesh. To that end, we reuse the mesh loading code from Chapter 3, Getting Started with OpenGL and Vulkan, like so:

    ModelRenderer::ModelRenderer(  VulkanRenderDevice& vkDev,  const char* modelFile, const char* textureFile,  uint32_t uniformDataSize)

    : RendererBase(vkDev, VulkanImage()) {

      if (!createTexturedVertexBuffer(vkDev, modelFile,        &storageBuffer_, &storageBufferMemory_,        &vertexBufferSize_, &indexBufferSize_)) {

        printf("ModelRenderer:      createTexturedVertexBuffer failed ");

        exit(EXIT_FAILURE);

      }

  2. The same applies to the texture image, image view, and sampler:

      createTextureImage(    vkDev, textureFile, texture_.image,    texture_.imageMemory);

      createImageView(vkDev.device, texture_.image,    VK_FORMAT_R8G8B8A8_UNORM,    VK_IMAGE_ASPECT_COLOR_BIT,    &texture_.imageView);

      createTextureSampler(vkDev.device, &textureSampler_);

      ...

    }

    They are created using the code from Chapter 3, Getting Started with OpenGL and Vulkan. The pipeline creation code contains only calls to various create*() functions and it is skipped here for the sake of brevity. Take a look at UtilsVulkanModelRenderer.cpp for all the details.

The longest member function is createDescriptorSet() whose code is mostly similar to the demo application from Chapter 3, Getting Started with OpenGL and Vulkan. This code differs only in the number and type of used Vulkan buffers. We will go through its content here once because in the remaining part of this chapter, similar code is reused to create descriptor sets for different rendering purposes:

  1. The first part of this method declares usage types, binding points, and accessibility flags for buffers used in all shader stages:

    bool ModelRenderer::createDescriptorSet(  VulkanRenderDevice& vkDev, uint32_t uniformDataSize)

    {

      const std::array<VkDescriptorSetLayoutBinding, 4>    bindings = {

        descriptorSetLayoutBinding(0,      VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,      VK_SHADER_STAGE_VERTEX_BIT),

        descriptorSetLayoutBinding(1,      VK_DESCRIPTOR_TYPE_STORAGE_BUFFER,      VK_SHADER_STAGE_VERTEX_BIT),

        descriptorSetLayoutBinding(2,      VK_DESCRIPTOR_TYPE_STORAGE_BUFFER,      VK_SHADER_STAGE_VERTEX_BIT),

        descriptorSetLayoutBinding(3,      VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,      VK_SHADER_STAGE_FRAGMENT_BIT)  };

  2. This LayoutBinding description is used to create an immutable descriptor set layout that does not change later:

      const VkDescriptorSetLayoutCreateInfo layoutInfo = {    .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET              _LAYOUT_CREATE_INFO,    .pNext = nullptr,    .flags = 0,    .bindingCount =      static_cast<uint32_t>(bindings.size()),    .pBindings = bindings.data()

      };

      VK_CHECK(vkCreateDescriptorSetLayout(vkDev.device,    &layoutInfo, nullptr, &descriptorSetLayout_));

  3. Once we have descriptorSetLayout, we should proceed with the actual descriptorSet creation:

      std::vector<VkDescriptorSetLayout>

      layouts(vkDev.swapchainImages.size(),    descriptorSetLayout_);

      const VkDescriptorSetAllocateInfo allocInfo = {    .sType = VK_STRUCTURE_TYPE_DESCRIPTOR_             SET_ALLOCATE_INFO,    .pNext = nullptr,    .descriptorPool = descriptorPool_,    .descriptorSetCount = static_cast<uint32_t>(      vkDev.swapchainImages.size()),    .pSetLayouts = layouts.data()

      };

      descriptorSets_.resize(    vkDev.swapchainImages.size());

      VK_CHECK(vkAllocateDescriptorSets(vkDev.device,    &allocInfo, descriptorSets_.data()));

  4. Finally, for each swapchainImage, we update the corresponding descriptorSet with specific buffer handles. We use one uniform buffer and one shader storage buffer to store combined vertices and indices for the programmable vertex pulling technique:

      for (size_t i = 0; i < vkDev.swapchainImages.size();

           i++)

      {

        VkDescriptorSet ds = descriptorSets_[i];

        const VkDescriptorBufferInfo bufferInfo  =      { uniformBuffers_[i], 0, uniformDataSize };

        const VkDescriptorBufferInfo bufferInfo2 =      { storageBuffer_, 0, vertexBufferSize_ };

        const VkDescriptorBufferInfo bufferInfo3 =      { storageBuffer_, vertexBufferSize_,        indexBufferSize_ };

        const VkDescriptorImageInfo  imageInfo =      { textureSampler_, texture_.imageView,        VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL };

        const std::array<VkWriteDescriptorSet, 4>      descriptorWrites = {

          bufferWriteDescriptorSet(ds, &bufferInfo,  0,        VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER),

          bufferWriteDescriptorSet(ds, &bufferInfo2, 1,        VK_DESCRIPTOR_TYPE_STORAGE_BUFFER),

          bufferWriteDescriptorSet(ds, &bufferInfo3, 2,        VK_DESCRIPTOR_TYPE_STORAGE_BUFFER),

          imageWriteDescriptorSet(ds, &imageInfo, 3)

        };

        vkUpdateDescriptorSets(vkDev.device,      static_cast<uint32_t>(descriptorWrites.size()),      descriptorWrites.data(), 0, nullptr);

      }

      return true;

    }

Because all the mesh data is immutable, this function is invoked only once from the ModelRenderer constructor.

There are two more functions that are required to render our 3D model:

  1. The first one should be called every frame to update the uniform buffer with the model-view matrix. All it does is delegate the call to the uploadBufferData() function we implemented in the previous chapter, using the Vulkan buffer corresponding to the current swapchain image:

    void ModelRenderer::updateUniformBuffer(  VulkanRenderDevice& vkDev, uint32_t currentImage,  const void* data, const size_t dataSize)

    {

      uploadBufferData(    vkDev, uniformBuffersMemory_[currentImage],    0, data, dataSize);

    }

  2. The second one, fillCommandBuffer(), does the actual rendering by starting a RenderPass and emitting a single vkCmdDraw command into the Vulkan command buffer:

    void ModelRenderer::fillCommandBuffer(  VkCommandBuffer commandBuffer, size_t currentImage)

    {

      beginRenderPass(commandBuffer, currentImage);

      vkCmdDraw(    commandBuffer, indexBufferSize_/(sizeof(uint32_t),    1, 0, 0);

      vkCmdEndRenderPass(commandBuffer);

    }

This recipe could indeed be called a somewhat generalized version of the rendering routines from the previous chapter. With this code, we can use Vulkan to render multiple 3D models in a single scene, which is a considerable step forward. However, the efficiency of rendering multiple meshes using the approach described in this recipe is somewhat debatable and should be avoided in cases where a lot of meshes need to be rendered. We will return to the question of efficiency in the next chapter.

Implementing an immediate mode drawing canvas

Chapter 3, Getting Started with OpenGL and Vulkan, only scratched the surface of the topic of graphical application debugging. The validation layers provided by the Vulkan API are invaluable but they do not allow you to debug logical and calculation-related errors. To see what is happening in our virtual world, we need to be able to render auxiliary graphical information such as object bounding boxes, plot time-varying charts of different values, or just plain straight lines. The Vulkan API and modern OpenGL do not provide or, rather, explicitly prohibit any immediate mode rendering facilities. To overcome this difficulty and add an immediate mode rendering canvas to our frame composition mechanism, we have to write some additional code. Let's learn how to do that in this recipe.

Getting ready

We will need to revisit the RendererBase and ModelRenderer classes discussed in the two previous recipes. Check the shared/UtilsVulkanCanvas.cpp file for a working implementation of this recipe.

How to do it...

The VulkanCanvas object contains a CPU-accessible list of 3D lines defined by two points and a color. For each frame, the user can call the line() method to draw a new line that should be rendered in the current frame. To render these lines into the framebuffer, we pre-allocate a reasonably large GPU buffer to store line geometry data, which we will update every frame:

  1. The VulkanCanvas class is derived from RendererBase and is similar to the previously discussed ModelRenderer class:

    class VulkanCanvas: public RendererBase {

    public:

      explicit VulkanCanvas(   VulkanRenderDevice& vkDev, VulkanImage depth);

      virtual ~VulkanCanvas();

      virtual void fillCommandBuffer(    VkCommandBuffer commandBuffer,    size_t currentImage) override;

      void updateBuffer(    VulkanRenderDevice& vkDev, size_t currentImage);

      void updateUniformBuffer(    VulkanRenderDevice& vkDev,    const glm::mat4& m, float time,    uint32_t currentImage);

  2. The actual drawing functionality consists of three functions. We want to be able to clear the canvas, render one line, and render a wireframe representation of a 3D plane. Further utility functions can easily be built on top of the functionality provided by line():

      void clear();

      void line(const vec3& p1, const vec3& p2,    const vec4& color);

      void plane3d(    const vec3& orig, const vec3& v1, const vec3& v2,    int n1, int n2, float s1, float s2,    const vec4& color, const vec4& outlineColor);

  3. Our internal data representation stores a pair of vertices for each and every line, whereas each vertex consists of a 3D position and a color. A uniform buffer holds the combined model-view-projection matrix and the current time:

    private:

      struct VertexData {

        vec3 position;

        vec4 color;

      };

      struct UniformBuffer {

        glm::mat4 mvp;

        float time;

      };

      std::vector<VertexData> lines;

      std::vector<VkBuffer> storageBuffer;

      std::vector<VkDeviceMemory> storageBufferMemory;

  4. The longest method of this class, just as is the case with ModelRenderer, is createDescriptorSet(). It is almost the same as in the previous recipe, with the obvious changes to omit textures and the unused index buffer. We omit citing its implementation here, which can be found in the UtilsVulkanCanvas.cpp file:

      bool createDescriptorSet(VulkanRenderDevice& vkDev);

  5. We need to define the maximum number of lines we may want to render. This is necessary to be able to pre-allocate GPU storage for our geometry data:

      static constexpr unsigned kMaxLinesCount = 65536;

      static constexpr unsigned kMaxLinesDataSize = 2 *    kMaxLinesCount * sizeof(VulkanCanvas::VertexData);

    };

Now, let's deal with the non-Vulkan part of the code:

  1. The plane3d() method uses line() internally to create the 3D representation of a plane spanned by the v1 and v2 vectors:

    void VulkanCanvas::plane3d(const vec3& o,  const vec3& v1, const vec3& v2,  int n1, int n2, float s1, float s2,  const vec4& color, const vec4& outlineColor)

    {

  2. Draw the four outer lines representing a plane segment:

      line(o - s1 / 2.0f * v1 - s2 / 2.0f * v2,       o - s1 / 2.0f * v1 + s2 / 2.0f * v2,       outlineColor);

      line(o + s1 / 2.0f * v1 - s2 / 2.0f * v2,       o + s1 / 2.0f * v1 + s2 / 2.0f * v2,       outlineColor);

      line(o - s1 / 2.0f * v1 + s2 / 2.0f * v2,       o + s1 / 2.0f * v1 + s2 / 2.0f * v2,       outlineColor);

      line(o - s1 / 2.0f * v1 - s2 / 2.0f * v2,       o + s1 / 2.0f * v1 - s2 / 2.0f * v2,       outlineColor);

  3. Draw n1 "horizontal" lines and n2 "vertical" lines:

      for(int ii = 1 ; ii < n1 ; ii++) {

        const float t =      ((float)ii - (float)n1 / 2.0f) * s1 / (float)n1;

        const vec3 o1 = o + t * v1;

        line(o1 - s2 / 2.0f * v2, o1 + s2 / 2.0f * v2,      color);

      }

      for(int ii = 1 ; ii < n2 ; ii++) {

        const float t =      ((float)ii - (float)n2 / 2.0f) * s2 / (float)n2;

        const vec3 o2 = o + t * v2;

        line(o2 - s1 / 2.0f * v1, o2 + s1 / 2.0f * v1,      color);

      }

    }

  4. The line() member function itself just adds two points to the collection:

    void VulkanCanvas::line(  const vec3& p1, const vec3& p2, const vec4& color)

    {

      lines.push_back({ .position = p1, .color = color });

      lines.push_back({ .position = p2, .color = color });

    }

  5. The clear() method is fairly trivial:

    void VulkanCanvas::clear() {

      lines.clear();

    }

Now, let's go back to the Vulkan code necessary to render the lines:

  1. In the constructor, we will allocate one geometry data buffer for each swapchain image:

    VulkanCanvas::VulkanCanvas(  VulkanRenderDevice& vkDev, VulkanImage depth)

    : RendererBase(vkDev, depth) {

      const size_t imgCount =    vkDev.swapchainImages.size();

      storageBuffer.resize(imgCount);

      storageBufferMemory.resize(imgCount);

      for(size_t i = 0 ; i < imgCount ; i++) {

        if (!createBuffer(vkDev.device,           vkDev.physicalDevice,           kMaxLinesDataSize,           VK_BUFFER_USAGE_STORAGE_BUFFER_BIT,           VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |           VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,           storageBuffer[i], storageBufferMemory[i]))

        {

          printf("VulkanCanvas: createBuffer() failed ");

          exit(EXIT_FAILURE);

        }

      }

      // ... pipeline creation code skipped here ...

    }

    We will skip the Vulkan pipeline creation boilerplate code again for the sake of brevity. Check the UtilsVulkanCanvas.cpp file for all the details.

  2. The updateBuffer() routine checks that our lines list is not empty and then calls the uploadBufferData() function to upload the lines' geometry data into the GPU buffer:

    void VulkanCanvas::updateBuffer(  VulkanRenderDevice& vkDev, size_t i)

    {

      if (lines.empty()) return;

      VkDeviceSize bufferSize =    lines.size() * sizeof(VertexData);

      uploadBufferData(vkDev, storageBufferMemory[i], 0,    lines.data(), bufferSize);

    }

  3. updateUniformBuffer() packs two per-frame parameters in a structure and calls the usual uploadBufferData() for the current swapchain image:

    void VulkanCanvas::updateUniformBuffer(  VulkanRenderDevice& vkDev,  const glm::mat4& modelViewProj,  float time,  uint32_t currentImage)

    {

      const UniformBuffer ubo = {    .mvp = modelViewProj,    .time = time   };

      uploadBufferData(vkDev,    uniformBuffersMemory_[currentImage],    0, &ubo, sizeof(ubo));

    }

  4. fillCommandBuffer() checks the lines' presence and emits vkCmdDraw to render all the lines in one draw call:

    void VulkanCanvas::fillCommandBuffer(  VkCommandBuffer commandBuffer, size_t currentImage)

    {

      if (lines.empty()) return;

      beginRenderPass(commandBuffer, currentImage);

      vkCmdDraw(commandBuffer, lines.size(), 1, 0, 0);

      vkCmdEndRenderPass(commandBuffer);

    }

For a comprehensive example showing how to use this canvas, check the last recipe in this chapter Putting it all together into a Vulkan application.

The next recipe will conclude Vulkan auxiliary rendering by showing how to render user interfaces with the ImGui library.

Rendering a Dear ImGui user interface with Vulkan

In Chapter 3, Getting Started with OpenGL and Vulkan, we demonstrated how to render the Dear ImGui user interface using OpenGL in 200 lines of C++ code. Here we try to transfer this knowledge to Vulkan and complement our existing frame composition routines. Even though fitting the entire Vulkan ImGui renderer into a few hundred lines of code is definitely not possible, we will do our best to keep the implementation reasonably compact for it to serve as a good teaching example.

Getting ready

It is recommended to revisit the Rendering a basic UI with Dear ImGui recipe from Chapter 2, Using Essential Libraries, and also recall our Vulkan frame composition scheme described in the first two recipes of this chapter.

This recipe covers the source code of shared/vkRenderers/VulkanImGui.cpp.

How to do it...

Let's take a look at the minimalistic ImGui Vulkan renderer implementation, which takes the ImDrawData data structure as the input and fills Vulkan command buffers with appropriate drawing commands:

  1. The ImGuiRenderer class is derived from RendererBase and contains the four usual member functions:

    class ImGuiRenderer: public RendererBase {

    public:

      explicit ImGuiRenderer(VulkanRenderDevice& vkDev);

      virtual ~ImGuiRenderer();

      virtual void fillCommandBuffer(    VkCommandBuffer commandBuffer, size_t     currentImage) override;

      void updateBuffers(    VulkanRenderDevice& vkDev, uint32_t currentImage,    const ImDrawData* imguiDrawData);

  2. Let's store a pointer to ImDrawData that holds all the ImGui-rendered widgets:

    private:

      const ImDrawData* drawData = nullptr;

      bool createDescriptorSet(VulkanRenderDevice& vkDev);

  3. We allocate one geometry buffer for each swapchain image to avoid any kind of synchronization, just like in the case with VulkanCanvas:

      VkDeviceSize bufferSize;

      std::vector<VkBuffer> storageBuffer;

      std::vector<VkDeviceMemory> storageBufferMemory;

      VkSampler fontSampler;

      VulkanImage font;

    };

The descriptor set creation function is also present. It is almost entirely identical to ModelRenderer::createDescriptorSet(), with one minor difference. Each geometry buffer from the storageBuffer container is bound to its corresponding descriptor set and there is one descriptor set per swapchain image.

Let's quickly go through the initialization code:

  1. First, we define constants to hold the maximum size of the vertex and index buffers. 65536 elements are enough for the default ImGui rendering mode:

      const uint32_t ImGuiVtxBufferSize =    64 * 1024 * sizeof(ImDrawVert);

      const uint32_t ImGuiIdxBufferSize =    64 * 1024 * sizeof(int);

  2. The constructor loads all the necessary resources, a TTF font file, and a font texture generated by ImGui; it creates a sampler and a texture view, allocates buffers for geometry, and creates a Vulkan pipeline:

    ImGuiRenderer::ImGuiRenderer(  VulkanRenderDevice& vkDev)

    : RendererBase(vkDev, VulkanImage())

    {

      ImGuiIO& io = ImGui::GetIO();

      createFontTexture(io, "data/OpenSans-Light.ttf",    vkDev, font_.image, font_.imageMemory);

      createImageView(vkDev.device, font_.image,    VK_FORMAT_R8G8B8A8_UNORM,    VK_IMAGE_ASPECT_COLOR_BIT, &font_.imageView);

      createTextureSampler(vkDev.device, &fontSampler_);

      const size_t imgCount =    vkDev.swapchainImages.size();

      storageBuffer_.resize(imgCount);

      storageBufferMemory_.resize(imgCount);

      bufferSize = ImGuiVtxBufferSize +               ImGuiIdxBufferSize;

  3. The code for buffer creation is essentially boilerplate code similar to the previous recipe and can be safely skipped here:

      for(size_t i = 0 ; i < imgCount ; i++) {

        ... buffers creation code skipped here ...

      }

      // ... pipeline creation code is skipped again ...

    }

Let's quickly review the createFontTexture() function:

  1. Similar to how createTextureFromFile() works, createFontTexture() uses the createTextureFromData() helper function internally:

    bool createFontTexture(ImGuiIO& io,  const char* fontFile,  VulkanRenderDevice& vkDev, VkImage& textureImage,  VkDeviceMemory& textureImageMemory)

    {

      ImFontConfig cfg = ImFontConfig();

      cfg.FontDataOwnedByAtlas = false;

      cfg.RasterizerMultiply = 1.5f;

      cfg.SizePixels = 768.0f / 32.0f;

      cfg.PixelSnapH = true;

      cfg.OversampleH = 4;

      cfg.OversampleV = 4;

  2. We will use ImGui to rasterize the TrueType font file into a bitmap:

      ImFont* Font = io.Fonts->AddFontFromFileTTF(    fontFile, cfg.SizePixels, &cfg);

      unsigned char* pixels = nullptr;

      int texWidth, texHeight;

      io.Fonts->GetTexDataAsRGBA32(    &pixels, &texWidth, &texHeight);

  3. Then we will call createTextureImageFromData() to build a Vulkan texture from bitmap data:

      if (!pixels || !createTextureImageFromData(vkDev,       textureImage, textureImageMemory,       pixels, texWidth, texHeight,       VK_FORMAT_R8G8B8A8_UNORM)) {

        printf("Failed to load texture ");

        return false;

      }

  4. At the end, we will store the ImGui font handle inside the ImGuiIO structure, similar to how it was done in OpenGL:

      io.Fonts->TexID = (ImTextureID)0;

      io.FontDefault = Font;

      io.DisplayFramebufferScale = ImVec2(1, 1);

      return true;

    }

    The actual rendering code that populates a Vulkan command buffer consists of two functions:

    • The first one starts a rendering pass, iterates the entire collection of ImGui command lists, invokes addImGuiItem() for each of them, and calls vkCmdEndRenderPass() at the end:

      void ImGuiRenderer::fillCommandBuffer(  VkCommandBuffer commandBuffer, size_t currentImage)

      {

        beginRenderPass(commandBuffer, currentImage);

        ImVec2 clipOff = drawData->DisplayPos;

        ImVec2 clipScale = drawData->FramebufferScale;

        int vtxOffset = 0;

        int idxOffset = 0;

        for (int n = 0; n < drawData->CmdListsCount; n++) {

          const ImDrawList* cmdList = drawData->CmdLists[n];

          for (int cmd = 0; cmd < cmdList->CmdBuffer.Size;         cmd++) {

            const ImDrawCmd* pcmd =        &cmdList->CmdBuffer[cmd];

            addImGuiItem(        framebufferWidth_, framebufferHeight_,        commandBuffer, pcmd, clipOff, clipScale,        idxOffset, vtxOffset);

          }

          idxOffset += cmdList->IdxBuffer.Size;

          vtxOffset += cmdList->VtxBuffer.Size;

        }

        vkCmdEndRenderPass(commandBuffer);

      }

    • The second helper function handles the generation of Vulkan commands for a single ImGui item:

      void addImGuiItem(uint32_t width, uint32_t height, VkCommandBuffer commandBuffer, const ImDrawCmd* pcmd, ImVec2 clipOff, ImVec2 clipScale, int idxOffset, int vtxOffset)

      {

        if (pcmd->UserCallback) return;

        ImVec4 clipRect;

        clipRect.x =    (pcmd->ClipRect.x - clipOff.x) * clipScale.x;

        clipRect.y =    (pcmd->ClipRect.y - clipOff.y) * clipScale.y;

        clipRect.z =    (pcmd->ClipRect.z - clipOff.x) * clipScale.x;

        clipRect.w =    (pcmd->ClipRect.w - clipOff.y) * clipScale.y;

        if (clipRect.x < width && clipRect.y < height &&      clipRect.z >= 0.0f && clipRect.w >= 0.0f)

        {

          if (clipRect.x < 0.0f) clipRect.x = 0.0f;

          if (clipRect.y < 0.0f) clipRect.y = 0.0f;

  5. Apply the clipping rectangle provided by ImGui to construct a Vulkan scissor. The ImGui rectangle is stored as vec4:

        const VkRect2D scissor = {      .offset = {        .x = (int32_t)(clipRect.x),        .y = (int32_t)(clipRect.y)},

          .extent = {        .width = (uint32_t)(clipRect.z - clipRect.x),        .height = (uint32_t)(clipRect.w - clipRect.y)}

        };

        vkCmdSetScissor(commandBuffer, 0, 1, &scissor);

        … Chapter 6 will add descriptor indexing here         using vkCmdPushConstants() …

        vkCmdDraw(commandBuffer, pcmd->ElemCount, 1,      pcmd->IdxOffset + idxOffset,      pcmd->VtxOffset + vtxOffset);

      }

    }

  6. One more important point to mention is how to update GPU buffers with vertex and index data and with the projection matrix. Here is a function to do that:

    void ImGuiRenderer::updateBuffers(  VulkanRenderDevice& vkDev,  uint32_t currentImage,  const ImDrawData* imguiDrawData)

    {

      drawData = imguiDrawData;

      const float L = drawData->DisplayPos.x;

      const float R =    drawData->DisplayPos.x+drawData->DisplaySize.x;

      const float T = drawData->DisplayPos.y;

      const float B =    drawData->DisplayPos.y+drawData->DisplaySize.y;

      const mat4 inMtx = glm::ortho(L, R, T, B);

      uploadBufferData(vkDev,    uniformBuffersMemory_[currentImage],    0, glm::value_ptr(inMtx), sizeof(mat4));

      void* data = nullptr;

      vkMapMemory(vkDev.device,    storageBufferMemory_[currentImage],    0, bufferSize_, 0, &data);

      ImDrawVert* vtx = (ImDrawVert*)data;

      for (int n = 0; n < drawData->CmdListsCount; n++) {

        const ImDrawList* cmdList = drawData->CmdLists[n];

        memcpy(vtx, cmdList->VtxBuffer.Data,      cmdList->VtxBuffer.Size * sizeof(ImDrawVert));

        vtx += cmdList->VtxBuffer.Size;

      }

      const uint32_t* idx = (const uint32_t*)(    (uint8_t*)data + ImGuiVtxBufferSize);

      for (int n = 0; n < drawData->CmdListsCount; n++) {

        const ImDrawList* cmdList = drawData->CmdLists[n];

        const uint16_t* src =      (const uint16_t*)cmdList->IdxBuffer.Data;

        for (int j = 0; j < cmdList->IdxBuffer.Size; j++)

          *idx++ = (uint32_t)*src++;

      }

      vkUnmapMemory(vkDev.device,    storageBufferMemory_[currentImage]);

    }

Now we have all the frame composition routines for this chapter's Vulkan demo application. Feel free to jump directly to the last recipe to see how the frame composition works. However, it is useful to get familiar with other user interface and productivity helpers discussed in the next recipes, such as the 3D camera control implementation, FPS counters, rendering charts, and profiling.

Working with a 3D camera and basic user interaction

To debug a graphical application, it is very helpful to be able to navigate and move around within a 3D scene using a keyboard or mouse. Graphics APIs by themselves are not familiar with the concepts of camera and user interaction, so we have to implement a camera model that will convert user input into a view matrix usable by OpenGL or Vulkan. In this recipe, we will learn how to create a very simple yet extensible camera implementation and use it to enhance the functionality of examples from the previous chapter.

Getting ready

The source code for this recipe can be found in Chapter4/GL01_Camera.

How to do it...

Our camera implementation will calculate a view matrix and a 3D position point based on the selected dynamic model. Let's look at the steps:

  1. First, let's implement the Camera class, which will represent our main API to work with the 3D camera. The class stores a reference to an instance of the CameraPositionerInterface class, which will represent a polymorphic implementation of the underlying camera model:

    class Camera final {

    public:

      explicit Camera(    CameraPositionerInterface& positioner)

      : positioner_(positioner) {}

      glm::mat4 getViewMatrix() const {

        return positioner_.getViewMatrix();

      }

      glm::vec3 getPosition() const {

        return positioner_.getPosition(); }

      private:

        CameraPositionerInterface& positioner_;

    };

    The interface of CameraPositionerInterface contains only pure virtual methods and the default virtual destructor:

    class CameraPositionerInterface {

    public:

      virtual ~CameraPositionerInterface() = default;

      virtual glm::mat4 getViewMatrix() const = 0;

      virtual glm::vec3 getPosition() const = 0;

    };

  2. Now we can implement an actual camera model. We will start with a quaternion-based first-person camera that can be freely moved in space in any direction.

    Let's look at the CameraPositioner_FirstPerson class. The inner Movement structure contains Boolean flags that define the current motion state of our camera. This is useful to decouple keyboard input from the camera logic:

    class CameraPositioner_FirstPerson final:

      public CameraPositionerInterface

    {

    public:

      struct Movement {    bool forward_ = false;    bool backward_ = false;    bool left_ = false;    bool right_ = false;    bool up_ = false;    bool down_ = false;    bool fastSpeed_ = false;  } movement_;

  3. Various numeric parameters define how responsive the camera will be to acceleration and damping. These parameters can be tweaked as you see fit:

      float mouseSpeed_ = 4.0f;

      float acceleration_ = 150.0f;

      float damping_ = 0.2f;

      float maxSpeed_ = 10.0f;

      float fastCoef_ = 10.0f;

  4. Some private data members are necessary to control the camera state, such as the previous mouse position, current camera position and orientation, and current movement speed:

    private:

      glm::vec2 mousePos_ = glm::vec2(0);

      glm::vec3 cameraPosition_ =    glm::vec3(0.0f, 10.0f, 10.0f);

      glm::quat cameraOrientation_ =    glm::quat(glm::vec3(0));

      glm::vec3 moveSpeed_ = glm::vec3(0.0f);

  5. The non-default constructor takes the camera's initial position, a target position, and a vector pointing upward. This input is similar to what you might normally use to construct a look-at viewing matrix. Indeed, we use the glm::lookAt() function to initialize the camera:

    public:

      CameraPositioner_FirstPerson() = default;

      CameraPositioner_FirstPerson(const glm::vec3& pos,    const glm::vec3& target, const glm::vec3& up)

      : cameraPosition_(pos)

      , cameraOrientation_(glm::lookAt(pos, target, up))

      {}

  6. Now, we want to add some dynamics to our camera model:

      void update(double deltaSeconds,    const glm::vec2& mousePos, bool mousePressed) {

        if (mousePressed) {

          const glm::vec2 delta = mousePos - mousePos_;

          const glm::quat deltaQuat = glm::quat(glm::vec3(        mouseSpeed_ * delta.y,        mouseSpeed_ * delta.x, 0.0f));

          cameraOrientation_ = glm::normalize(        deltaQuat * cameraOrientation_);

        }

        mousePos_ = mousePos;

    The update() method should be called at every frame and take the time elapsed since the previous frame, as well as the mouse position and a mouse button pressed flag. In cases when the mouse button is pressed, we calculate a delta vector versus the previous mouse position and use it to construct a rotation quaternion. This quaternion is used to rotate the camera. Once the camera rotation is applied, we should update the mouse position state.

  7. Now we should establish the camera's coordinate system to calculate the camera movement. Let's extract the forward, right, and up vectors from the 4x4 view matrix:

        const glm::mat4 v =      glm::mat4_cast(cameraOrientation_);

        const glm::vec3 forward =      -glm::vec3(v[0][2], v[1][2], v[2][2]);

        const glm::vec3 right =      glm::vec3(v[0][0], v[1][0], v[2][0]);

        const glm::vec3 up = glm::cross(right, forward);

    The forward vector corresponds to the camera's direction, that is, the direction the camera is pointing. The right vector corresponds to the positive x axis of the camera space. The up vector is the positive y axis of the camera space, which is perpendicular to the first two vectors and can be calculated as their cross product.

  8. The camera coordinate system has been established. Now we can apply our input state from the Movement structure to control the movement of our camera:

        glm::vec3 accel(0.0f);

        if (movement_.forward_) accel += forward;

        if (movement_.backward_) accel -= forward;

        if (movement_.left_) accel -= right;

        if (movement_.right_) accel += right;

        if (movement_.up_) accel += up;

        if (movement_.down_) accel -= up;

        if (movement_.fastSpeed_) accel *= fastCoef_;

    Instead of controlling the camera speed or position directly, we let the user input control only the acceleration vector directly. This way, the camera's behavior is much more natural and non-jerky.

  9. If, based on the input state, the calculated camera acceleration is 0, we should decelerate the camera's motion speed gradually, according to the damping_ parameter. Otherwise, we should integrate the camera motion using simple Euler integration. The maximum possible speed value is clamped according to the maxSpeed_ parameter:

        if (accel == glm::vec3(0)) {

          moveSpeed_ -= moveSpeed_ * std::min(        (1.0f / damping_) *        static_cast<float>(deltaSeconds), 1.0f);

        }

        else {

          moveSpeed_ += accel * acceleration_ *        static_cast<float>(deltaSeconds);

          const float maxSpeed = movement_.fastSpeed_ ?        maxSpeed_ * fastCoef_ : maxSpeed_;

          if (glm::length(moveSpeed_) > maxSpeed)

            moveSpeed_ =          glm::normalize(moveSpeed_) * maxSpeed;

        }

        cameraPosition_ +=      moveSpeed_ * static_cast<float>(deltaSeconds);

      }

  10. The view matrix can be calculated from the camera orientation quaternion and camera position in the following way:

      virtual glm::mat4 getViewMatrix() const override {

        const glm::mat4 t = glm::translate(      glm::mat4(1.0f), -cameraPosition_);

        const glm::mat4 r =      glm::mat4_cast(cameraOrientation_);

        return r * t;

      }

    The translational part is inferred from the cameraPosition_ vector and the rotational part is calculated directly from the orientation quaternion.

  11. Helpful getters and setters are trivial, except for the setUpVector() method, which has to recalculate the camera orientation using the existing camera position and direction as follows:

      virtual glm::vec3 getPosition() const override {

        return cameraPosition_;

      }

      void setPosition(const glm::vec3& pos) {

        cameraPosition_ = pos;

      }

      void setUpVector(const glm::vec3& up)

      {

        const glm::mat4 view = getViewMatrix();

        const glm::vec3 dir =      -glm::vec3(view[0][2], view[1][2], view[2][2]);

        cameraOrientation_ = glm::lookAt(      cameraPosition_, cameraPosition_ + dir, up);

      }

  12. One additional helper function is necessary to reset the previous mouse position to prevent jerky rotation movements:

      void resetMousePosition(const glm::vec2& p) {

          mousePos_ = p;

      };

    };

The preceding class can be used in an application to move the viewer around. Let's see how it works.

How it works...

The demo application for this recipe is based on the cube map OpenGL example from the previous chapter:

  1. First, we need to add a mouse state and define CameraPositioner and Camera. Let them be global variables:

    struct MouseState {

      glm::vec2 pos = glm::vec2(0.0f);

      bool pressedLeft = false;

    } mouseState;

    CameraPositioner_FirstPerson positioner( vec3(0.0f),  vec3(0.0f, 0.0f, -1.0f), vec3(0.0f, 1.0f, 0.0f) );

    Camera camera(positioner);

  2. Now, the GLFW cursor position callback should update mouseState in the following way:

    glfwSetCursorPosCallback(  window, [](auto* window, double x, double y) {

        int width, height;

        glfwGetFramebufferSize(window, &width, &height);

        mouseState.pos.x = static_cast<float>(x / width);

        mouseState.pos.y = static_cast<float>(y / height);

      }

    );

    Here, we convert window pixel coordinates into normalized 0...1 coordinates.

  3. The GLFW mouse button callback raises the pressedLeft flag when the left mouse button is pressed:

    glfwSetMouseButtonCallback(window,  [](auto* window, int button, int action, int mods)

    {

      if (button == GLFW_MOUSE_BUTTON_LEFT)

        mouseState.pressedLeft = action == GLFW_PRESS;

    });

  4. To handle keyboard input for camera movement, let's write the following GLFW keyboard callback:

    glfwSetKeyCallback(window,  [](GLFWwindow* window,     int key, int scancode, int action, int mods)

      {

        const bool press = action != GLFW_RELEASE;

        if (key == GLFW_KEY_ESCAPE)      glfwSetWindowShouldClose(window, GLFW_TRUE);

        if (key == GLFW_KEY_W)      positioner.movement_.forward_ = press;

        if (key == GLFW_KEY_S)      positioner.movement_.backward_= press;

        if (key == GLFW_KEY_A)      positioner.movement_.left_ = press;

        if (key == GLFW_KEY_D)      positioner.movement_.right_ = press;

        if (key == GLFW_KEY_1)      positioner.movement_.up_ = press;

        if (key == GLFW_KEY_2)      positioner.movement_.down_ = press;

        if (mods & GLFW_MOD_SHIFT)      positioner.movement_.fastSpeed_ = press;

        if (key == GLFW_KEY_SPACE)      positioner.setUpVector(vec3(0.0f, 1.0f, 0.0f));

    });

    The WSAD keys are used to move the camera around and the Spacebar is used to reorient the camera up vector to the world (0, 1, 0) vector. The Shift key is used to move the camera faster.

  5. Now we can update the camera positioner from the main loop using the following statement:

    positioner.update(deltaSeconds, mouseState.pos,  mouseState.pressedLeft);

  6. Here's a code fragment to upload matrices into the OpenGL uniform buffer object, similar to how it was done with fixed values in the previous chapters:

    const mat4 p = glm::perspective(  45.0f, ratio, 0.1f, 1000.0f);

    const mat4 view = camera.getViewMatrix();

    const PerFrameData perFrameData = {  .view = view,  .proj = p,  .cameraPos = glm::vec4(camera.getPosition(), 1.0f) };

    glNamedBufferSubData(perFrameDataBuffer, 0,  kUniformBufferSize, &perFrameData);

Run the demo from Chapter4/GL01_Camera to play around with the keyboard and mouse.

There's more...

This camera design approach can be extended to accommodate different motion behaviors. In later recipes, we will learn how to implement some other useful camera positioners.

Adding a frames-per-second counter

The frames per second (FPS) counter is the cornerstone of all graphical application profiling and performance measurements. In this recipe, we will learn how to implement a simple FPS counter class and use it to roughly measure the performance of a running application.

Getting ready

The source code for this recipe can be found in Chapter4/GL02_FPS.

How to do it...

Let's implement the FramesPerSecondCounter class containing all the machinery required to calculate the average FPS rate for a given time interval:

  1. First, we need some member fields to store the duration of a sliding window, the number of frames rendered in the current interval, and the accumulated time of this interval:

    class FramesPerSecondCounter {

    private:

      const float avgIntervalSec_ = 0.5f;

      unsigned int numFrames_ = 0;

      double accumulatedTime_ = 0;

      float currentFPS_ = 0.0f;

  2. The single constructor can override the averaging's interval default duration:

    public:

      explicit FramesPerSecondCounter(    float avgIntervalSec = 0.5f)

      : avgIntervalSec_(avgIntervalSec)

      { assert(avgIntervalSec > 0.0f); }

  3. The tick() method should be called from the main loop. It accepts the time elapsed since the previous call and a Boolean flag that should be set to true if a new frame has been rendered during this iteration. This flag is a convenience feature to handle situations when frame rendering can be skipped in the main loop for various reasons. The time is accumulated until it reaches the value of avgInterval_:

      bool tick(    float deltaSeconds, bool frameRendered = true)

      {

        if (frameRendered) numFrames_++;

        accumulatedTime_ += deltaSeconds;

  4. Once enough time has accumulated, we can do averaging, update the current FPS value, and print debug info to the console. We should reset the number of frames and accumulated time here:

        if (accumulatedTime_ < avgIntervalSec_)

          return false;

        currentFPS_ = static_cast<float>(      numFrames_ / accumulatedTime_);

        printf("FPS: %.1f ", currentFPS_);

        numFrames_ = 0;

        accumulatedTime_ = 0;

        return true;

      }

  5. Let's add a helper method to retrieve the current FPS value:

      inline float getFPS() const { return currentFPS_; }

    };

Now, let's take a look at how to use this class in our main loop. Let's augment the main loop of our OpenGL demo applications to display an FPS counter in the console:

  1. First, let's define a FramesPerSecondCounter object and a couple of variables to store the current time and the current delta since the last rendered frame. We used a 0.5-second averaging interval; feel free to try out different values:

      double timeStamp = glfwGetTime();

      float deltaSeconds = 0.0f;

      FramesPerSecondCounter fpsCounter(0.5f);

  2. Inside the main loop, update the current timestamp and calculate the frame duration as a delta between the two timestamps:

      while (!glfwWindowShouldClose(window))

      {

        const double newTimeStamp = glfwGetTime();

        deltaSeconds = static_cast<float>(      newTimeStamp – timeStamp);

        timeStamp = newTimeStamp;

  3. Pass the calculated delta to the tick() method:

        fpsCounter.tick(deltaSeconds);

        // ...do the rest of rendering here...

    }

The console output of the running application should look similar to the following lines:

FPS: 3238.1

FPS: 3101.3

FPS: 1787.5

FPS: 3609.4

FPS: 3927.0

FPS: 3775.0

FPS: 4119.0

Vertical sync is turned off.

There's more...

The frameRendered parameter in float tick(float deltaSeconds, bool frameRendered = true) will be used in the subsequent recipes to allow Vulkan applications to skip frames when a swapchain image is not available.

Adding camera animations and motion

Besides having a user-controlled free camera, it is convenient to be able to position and move the camera programmatically inside a 3D scene. In this recipe, we will show how to do this using a 3D camera framework from the Working with a 3D camera and basic user interaction recipe. We will draw a combo box using ImGui to select between two camera modes: a first-person free camera and a fixed camera moving to a user-specified point settable from the user interface.

Getting ready

The full source code for this recipe can be found in Chapter4/VK01_DemoApp. Implementations of camera-related functionality are located in shared/Camera.h.

How to do it...

Let's look at how to programmatically control our 3D camera using a simple ImGui-based user interface:

  1. First, we need to define the camera positioners attached to the camera – just a bunch of global variables:

    glm::vec3 cameraPos(0.0f, 0.0f, 0.0f);

    glm::vec3 cameraAngles(-45.0f, 0.0f, 0.0f);

    CameraPositioner_FirstPerson positioner_firstPerson(  cameraPos, vec3(0.0f, 0.0f, -1.0f),  vec3(0.0f, 1.0f, 0.0f));

    CameraPositioner_MoveTopositioner_moveTo(  cameraPos, cameraAngles);

    Camera camera = Camera(positioner_firstPerson);

  2. Inside the main loop, we should update both camera positioners:

    positioner_firstPerson.update(deltaSeconds,  mouseState.pos, mouseState.pressedLeft);

    positioner_moveTo.update(deltaSeconds,  mouseState.pos, mouseState.pressedLeft);

Now, let's draw an ImGui combo box to select which camera positioner should be used to provide data to the camera:

  1. First, a few more global variables will come in handy to store the current camera type, items of the combo box user interface, and the new value selected in the combo box:

    const char* cameraType = "FirstPerson";

    const char* comboBoxItems[] =  { "FirstPerson", "MoveTo" };

    const char* currentComboBoxItem = cameraType;

  2. To render the camera control user interface with a combo box, let's write the following code. A new ImGui window starts with a call to ImGui::Begin():

    ImGui::Begin("Camera Control", nullptr);

    {

  3. The combo box itself is rendered via ImGui::BeginCombo(). The second parameter is the previewed label name to show before opening the combo box. This function will return true if the user has clicked on a label:

      if (ImGui::BeginCombo("##combo",        currentComboBoxItem))

      {

        for (int n = 0; n < IM_ARRAYSIZE(comboBoxItems);

             n++)

        {

          const bool isSelected =        (currentComboBoxItem == comboBoxItems[n]);

          if (ImGui::Selectable(comboBoxItems[n],            isSelected))

            currentComboBoxItem = comboBoxItems[n];

  4. You may set the initial focus when opening the combo. This is useful if you want to support scrolling or keyboard navigation inside the combo box:

          if (isSelected) ImGui::SetItemDefaultFocus();   

        }

  5. Finalize the ImGui combo box rendering:

        ImGui::EndCombo();

      }

  6. If the MoveTo camera type is selected, render vec3 input sliders to get the camera position and Euler angles from the user:

      if (!strcmp(cameraType, "MoveTo")) {

        if (ImGui::SliderFloat3("Position",        glm::value_ptr(cameraPos), -10.0f, +10.0f))

          positioner_moveTo.setDesiredPosition(cameraPos);

        if (ImGui::SliderFloat3("Pitch/Pan/Roll",        glm::value_ptr(cameraAngles), -90.0f, +90.0f))

         positioner_moveTo.setDesiredAngles(cameraAngles);

      }

  7. If the new selected combo box item is different from the current camera type, print a debug message and change the active camera mode:

      if (currentComboBoxItem &&      strcmp(currentComboBoxItem, cameraType)) {

        printf("New camera type selected %s ",      currentComboBoxItem);

        cameraType = currentComboBoxItem;

        reinitCamera();

      }

    }

The resulting combo box should look as in the following screenshot:

Figure 4.1 – Camera controls

Figure 4.1 – Camera controls

The previously mentioned code is called from the main loop on every frame. Check out renderGUI() in Chapter4/VK01_DemoApp/src/main.cpp for the complete source code.

How it works...

Let's take a look at the implementation of the CameraPositioner_MoveTo class. Contrary to the first-person camera, this implementation uses a simple Euler angles approach to store the camera orientation, which makes it more intuitive for the user to control:

  1. First, we want to have some user-configurable parameters for linear and angular damping coefficients:

    class CameraPositioner_MoveTo final:

      public CameraPositionerInterface

    {

    public:

      float dampingLinear_ = 10.0f;

      glm::vec3 dampingEulerAngles_ =    glm::vec3(5.0f, 5.0f, 5.0f);

  2. We store the current and desired positions of the camera as well as two sets of pitch, pan, and roll Euler angles in vec3 member fields. The current camera transformation is updated at every frame and saved in the mat4 field:

    private:

      glm::vec3 positionCurrent_ = glm::vec3(0.0f);

      glm::vec3 positionDesired_ = glm::vec3(0.0f);

      glm::vec3 anglesCurrent_ = glm::vec3(0.0f); //

    pitch, pan, roll

      glm::vec3 anglesDesired_ = glm::vec3(0.0f);

      glm::mat4 currentTransform_ = glm::mat4(1.0f);

    A single constructor initializes both the current and desired datasets of the camera:

    public:

      CameraPositioner_MoveTo(    const glm::vec3& pos, const glm::vec3& angles)

      : positionCurrent_(pos)

      , positionDesired_(pos)

      , anglesCurrent_(angles)

      , anglesDesired_(angles)

      {}

  3. The most interesting part happens in the update() function. The current camera position is changed to move toward the desired camera position. The movement speed is proportional to the distance between these two positions and is scaled using the linear damping coefficient:

      void update(float deltaSeconds,    const glm::vec2& mousePos, bool mousePressed)

      {

        positionCurrent_+= dampingLinear_ * deltaSeconds *       (positionDesired_ - positionCurrent_);

  4. Now, let's deal with Euler angles. We should make sure that they remain inside the 0…360 degree range and clip them accordingly:

        anglesCurrent_ = clipAngles(anglesCurrent_);

        anglesDesired_ = clipAngles(anglesDesired_);

  5. Similar to how we dealt with the camera position, the Euler angles are updated based on the distance between the desired and current set of angles. Before calculating the camera transformation matrix, clip the updated angles again and convert from degrees to radians. Note how the pitch, pan, and roll angles are swizzled before they are forwarded into glm::yawPitchRoll():

        anglesCurrent_ -= deltaSeconds *      angleDelta(anglesCurrent_, anglesDesired_) *      dampingEulerAngles_;

        anglesCurrent_ = clipAngles(anglesCurrent_);

        const glm::vec3 ang =      glm::radians(anglesCurrent_);

        currentTransform_ = glm::translate(       glm::yawPitchRoll(ang.y, ang.x, ang.z),       -positionCurrent_);

      }

  6. The functions for the angle clipping are straightforward and look as follows:

    private:

      static inline float clipAngle(float d) {

        if (d < -180.0f) return d + 360.0f;

        if (d > +180.0f) return d - 360.f;

        return d;

      }

      static inline glm::vec3 clipAngles(    const glm::vec3& angles) {

        return glm::vec3(

          std::fmod(angles.x, 360.0f),

          std::fmod(angles.y, 360.0f),

          std::fmod(angles.z, 360.0f)

        );

      }

  7. The delta between two sets of angles can be calculated in the following way:

      static inline glm::vec3 angleDelta(    const glm::vec3& anglesCurrent,

        const glm::vec3& anglesDesired)

      {

        const glm::vec3 d = clipAngles(anglesCurrent) –       clipAngles(anglesDesired);

        return glm::vec3(      clipAngle(d.x), clipAngle(d.y), clipAngle(d.z));

      }

    };

Try running the demo application, switch to the MoveTo camera, and change position and orientation from the user interface.

There's more...

Further camera functionality can be built on top of this example implementation. One more useful extension might be a camera that follows a spline curve defined using a set of key points. We will leave it as an exercise for you.

Integrating EasyProfiler and Optick into C++ applications

In Chapter 2, Using Essential Libraries, we learned how to link our projects with EasyProfiler and Optick, and covered the basic functionality of these profiling libraries. In real-world applications, it is often beneficial to be able to quickly change profiling backends at will due to changes in requirements or to use unique features of different profiling libraries. In this recipe, we will show how to make a minimalistic wrapper on top of EasyProfiler and Optick to allow seamless switching between them using only CMake build options.

Getting ready

The complete source code of the demo application for this recipe is located in Chapter4/VK01_DemoApp.

How to do it...

The demo application, as well as some parts of our Vulkan rendering code, is augmented with calls to profiling functions. Instead of calling EasyProfiler or Optick directly, we have created a set of macros, one of which is picked based on compiler options provided by CMake:

  1. First, let's take a look at the root CMakeLists.txt CMake configuration file to see how these options are added. In the beginning, we should add two declarations:

    option(BUILD_WITH_EASY_PROFILER   "Enable EasyProfiler usage" ON)

    option(BUILD_WITH_OPTICK "Enable Optick usage" OFF)

  2. A few pages later in the same file, we will convert the CMake options into the C++ compiler definitions and print info messages to the system console:

    if(BUILD_WITH_EASY_PROFILER)

      message("Enabled EasyProfiler")

      add_definitions(-DBUILD_WITH_EASY_PROFILER=1)

      include_directories(deps/src/easy_profiler/include)

    endif()

    if(BUILD_WITH_OPTICK)

      message("Enabled Optick")

      add_definitions(-DBUILD_WITH_OPTICK=1)

      set_property(TARGET OptickCore PROPERTY FOLDER                "ThirdPartyLibraries")

    endif()

  3. Then, let's check the shared/CMakeLists.txt file, which defines linking options for our shared utility code. Based on the enabled CMake options, we will link our utilities library with EasyProfiler and Optick:

    if(BUILD_WITH_EASY_PROFILER)

      target_link_libraries(SharedUtils PUBLIC                         easy_profiler)

    endif()

    if(BUILD_WITH_OPTICK)

      target_link_libraries(SharedUtils PUBLIC OptickCore)

    endif()

  4. CMake code similar to the following is used in the Chapter4/VK01_DemoApp/CMakeLists.txt file:

    if(BUILD_WITH_EASY_PROFILER)

      target_link_libraries(    Ch4_SampleVK01_DemoApp PRIVATE easy_profiler)

    endif()

    if(BUILD_WITH_OPTICK)

      target_link_libraries(Ch4_SampleVK01_DemoApp PRIVATE                         OptickCore)

    endif()

  5. The C++ source code uses only redefined macros for profiling, for example, like in the following block:

    {

      EASY_BLOCK(    "vkQueueSubmit", profiler::colors::Magenta);

      VK_CHECK(vkQueueSubmit(    vkDev.graphicsQueue, 1, &si, nullptr));

      EASY_END_BLOCK;

    }

This way, we can choose EasyProfiler and Optick at project generation time with the following commands. This one will enable EasyProfiler, which is the default option:

cmake .. -G "Visual Studio 16 2019" -A x64   -DBUILD_WITH_EASY_PROFILER=ON -DBUILD_WITH_OPTICK=OFF

This one will switch to Optick:

cmake .. -G "Visual Studio 16 2019" -A x64   -DBUILD_WITH_EASY_PROFILER=OFF -DBUILD_WITH_OPTICK=ON

How it works...

The actual wrapping code is located in the shared/EasyProfilerWrapper.h file. We decided to unify the profiling API we use so that it looks like the original EasyProfiler API. This way, we don't have to redefine complex EasyProfiler macros and can just use them as is in a pass-through fashion. Let's go through it step by step and see how to do it:

  1. First, we should check that only one profiler is actually enabled:

    #if BUILD_WITH_EASY_PROFILER && BUILD_WITH_OPTICK

    #error Cannot enable both profilers at once. Just pick one.

    #endif // BUILD_WITH_EASY_PROFILER &&          BUILD_WITH_OPTICK

  2. If there is no profiler enabled, just declare a bunch of empty macros to skip all the profiling code generation. These macros match the EasyProfiler API:

    #if !BUILD_WITH_EASY_PROFILER && !BUILD_WITH_OPTICK

    #  define EASY_FUNCTION(...)

    #  define EASY_BLOCK(...)

    #  define EASY_END_BLOCK

    #  define EASY_THREAD_SCOPE(...)

    #  define EASY_PROFILER_ENABLE

    #  define EASY_MAIN_THREAD

    #  define PROFILER_FRAME(...)

    #  define PROFILER_DUMP(fileName)

    #endif // !BUILD_WITH_EASY_PROFILER &&          !BUILD_WITH_OPTICK

  3. If EasyProfiler is enabled, just include its header file to use the original API and add two more macros to handle the frame rendering marker, which is supported by Optick and not supported by EasyProfiler. The PROFILER_DUMP() macro is used to save the final profiling results into a file:

    #if BUILD_WITH_EASY_PROFILER

    #  include "easy/profiler.h"

    #  define PROFILER_FRAME(...)

    #  define PROFILER_DUMP(fileName)

       profiler::dumpBlocksToFile(fileName);

    #endif // BUILD_WITH_EASY_PROFILER

  4. If Optick is enabled, convert EasyProfiler API calls into the corresponding Optick function calls and macros:

    #if BUILD_WITH_OPTICK

    #  include "optick.h"

    #  define EASY_FUNCTION(...) OPTICK_EVENT()

    #  define EASY_BLOCK(name, ...)     { OptickScopeWrapper Wrapper(name);

    #  define EASY_END_BLOCK };

    #  define EASY_THREAD_SCOPE(...)

       OPTICK_START_THREAD(__VA_ARGS__)

    #  define EASY_PROFILER_ENABLE OPTICK_START_CAPTURE()

    #  define EASY_MAIN_THREAD OPTICK_THREAD("MainThread")

    #  define PROFILER_FRAME(name) OPTICK_FRAME(name)

    #  define PROFILER_DUMP(fileName)     OPTICK_STOP_CAPTURE();     OPTICK_SAVE_CAPTURE(fileName);

  5. One major difference between the EasyProfiler and Optick APIs is that EasyProfiler blocks are based on C++ RAII and are automatically closed when the current scope is exited. For Optick, we have to use OPTICK_PUSH() and OPTICK_POP(), which we wrap into a simple RAII class:

    class OptickScopeWrapper {

    public:

      OptickScopeWrapper(const char* name) {

        OPTICK_PUSH(name);

      }

      ~OptickScopeWrapper() {

        OPTICK_POP();

      }

    };

  6. Yet another thing necessary to use Optick and EasyProfiler interchangeably is handling the way they deal with block colors. Both use constants with different names. There are many possible solutions to this kind of wrapping; we decided to declare constants the following way so that the EasyProfiler API can be emulated by adding all necessary colors:

    namespace profiler {

      namespace colors {

        const int32_t Magenta = Optick::Color::Magenta;

        const int32_t Green = Optick::Color::Green;

        const int32_t Red = Optick::Color::Red;

      } // namespace colors

    } // namespace profiler

    #endif // BUILD_WITH_OPTICK

Let's compare how profiling information of the same demo application looks in EasyProfiler and Optick. Here is a screenshot from the EasyProfiler user interface showing a flamegraph of our app:

Figure 4.2 – EasyProfiler user interface

Figure 4.2 – EasyProfiler user interface

Here is the output from the Optick user interface. Conceptually, these two profilers are very similar:

Figure 4.3 – Optick user interface

Figure 4.3 – Optick user interface

This approach enables fully transparent switching between EasyProfiler and Optick at build time. Adding other profilers that provide similar APIs is mostly trivial and can be easily implemented.

Using cube map textures in Vulkan

This recipe shows how to implement a feature with Vulkan that was already done using OpenGL in Chapter 3, Getting Started with OpenGL and Vulkan. The VulkanCubeRenderer class implements a simple cube map renderer used to draw a skybox.

Getting ready

Before proceeding with this recipe, it is recommended to revisit the Vulkan texture and texture sampler creation code from Chapter 3, Getting Started with OpenGL and Vulkan.

How to do it...

As in the previous recipes, we derive our VulkanCubeRenderer class from RendererBase. We need a texture sampler and a cube texture. The actual 3D cube geometry is generated using the programmable vertex pulling technique in the vertex shader, as shown in the previous chapters:

class CubeRenderer: public RendererBase {

public:

  CubeRenderer(VulkanRenderDevice& vkDev,    VulkanImage inDepthTexture, const char* textureFile);

  virtual ~CubeRenderer();

  virtual void fillCommandBuffer(     VkCommandBuffer commandBuffer,     size_t currentImage) override;

  void updateUniformBuffer(     VulkanRenderDevice& vkDev,     uint32_t currentImage, const mat4& m);

private:

  VkSampler textureSampler;

  VulkanImage texture;

  bool createDescriptorSet(VulkanRenderDevice& vkDev);

};

Let's go through the steps necessary to implement the CubeRenderer class:

  1. First, the class constructor loads a cube map texture from a file and creates a corresponding cube map sampler:

    CubeRenderer::CubeRenderer(  VulkanRenderDevice& vkDev,  VulkanImage inDepthTexture,  const char* textureFile)

    : RendererBase(vkDev, inDepthTexture)

    {

      createCubeTextureImage(vkDev, textureFile,    texture.image, texture.imageMemory);

      createImageView(vkDev.device, texture.image,    VK_FORMAT_R32G32B32A32_SFLOAT,    VK_IMAGE_ASPECT_COLOR_BIT,    &texture.imageView, VK_IMAGE_VIEW_TYPE_CUBE, 6);

      createTextureSampler(vkDev.device, &textureSampler);

  2. This time, we will show the full code fragment to create all the necessary Vulkan objects and the pipeline. Check the shared/UtilsVulkanImGui.cpp file for further details:

      if (!createColorAndDepthRenderPass(vkDev, true,        &renderPass_, RenderPassCreateInfo()) ||      !createUniformBuffers(vkDev, sizeof(mat4)) ||      !createColorAndDepthFramebuffers(vkDev,        renderPass_, depthTexture_.imageView,        swapchainFramebuffers_) ||      !createDescriptorPool(vkDev, 1, 0, 1,        &descriptorPool_) ||      !createDescriptorSet(vkDev) ||      !createPipelineLayout(vkDev.device,        descriptorSetLayout_, &pipelineLayout_) ||      !createGraphicsPipeline(vkDev, renderPass_,        pipelineLayout_,        { "data/shaders/chapter04/VKCube.vert",          "data/shaders/chapter04/VKCube.frag" },        &graphicsPipeline_)) {

        printf(      "CubeRenderer: failed to create pipeline ");

        exit(EXIT_FAILURE);

      }

    }

  3. The updateUniformBuffer() routine uploads the current camera matrix into a GPU buffer:

    void CubeRenderer::updateUniformBuffer(  VulkanRenderDevice& vkDev,  uint32_t currentImg, const mat4& m)

    {

      uploadBufferData(vkDev,    uniformBuffersMemory_[currentImg], 0,    glm::value_ptr(m), sizeof(mat4));

    }

  4. The fillCommandBuffer() member function emits vkCmdDraw into the Vulkan command buffer to render 12 triangles representing 6 cube faces:

    void CubeRenderer::fillCommandBuffer(  VkCommandBuffer commandBuffer, size_t currentImage)

    {

      beginRenderPass(commandBuffer, currentImage);

      vkCmdDraw(commandBuffer, 36, 1, 0, 0);

      vkCmdEndRenderPass(commandBuffer);

    }

  5. The createDescriptorSet() function closely mimics a similar function from ModelRenderer. Here is how it begins:

    bool CubeRenderer::createDescriptorSet(  VulkanRenderDevice& vkDev)

    {

      const std::array<VkDescriptorSetLayoutBinding, 2>    bindings = {

          descriptorSetLayoutBinding(0,        VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,

            VK_SHADER_STAGE_VERTEX_BIT),

          descriptorSetLayoutBinding(1,        VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,        VK_SHADER_STAGE_FRAGMENT_BIT)    };

      ...

The rest of the routine is the same, with an obvious omission of the unused buffers in the vkUpdateDescriptorSet() call.

Rendering onscreen charts

In the Implementing an immediate mode drawing canvas recipe, we learned how to create immediate mode drawing facilities in Vulkan with basic drawing functionality. In this recipe, we will continue adding useful utilities built on top of a 2D line drawing.

Getting ready

We recommend revisiting the Implementing an immediate mode drawing canvas recipe to get a better grasp of how a simple Vulkan drawing canvas can be implemented.

How to do it...

What we need at this point essentially boils down to decomposing a 2D chart or graph into a set of lines. Let's go through the code to see how to do it:

  1. We introduce the LinearGraph class to render a graph of floating-point values. It stores a collection of values and the maximum number of points that should be visible on the screen. A deque is used here for the sake of simplicity:

    class LinearGraph {

      std::deque<float> graph_;

      const size_t maxPoints_;

    public:

      explicit LinearGraph(size_t maxGraphPoints = 256)

      : maxPoints_(maxGraphPoints)

      {}

  2. When adding a new point to the graph, we check and maintain the maximum number of points:

      void addPoint(float value) {

        graph_.push_back(value);

        if (graph_.size() > maxPoints_)

          graph_.pop_front();

      }

  3. Rendering is done through the VulkanCanvas class introduced in the Implementing an immediate mode drawing canvas recipe. The idea is first to find minimum and maximum values to normalize the graph into the 0...1 range:

      void renderGraph(VulkanCanvas& c,    const glm::vec4& color = vec4(1.0)) const

      {

        float minfps = std::numeric_limits<float>::max();

        float maxfps = std::numeric_limits<float>::min();

        for (float f : graph_) {

          if (f < minfps) minfps = f;

          if (f > maxfps) maxfps = f;

        }

        const float range = maxfps - minfps;

  4. At this point, we need to iterate all the points once again and draw them from left to right near the bottom part of the screen. The vertical scaling can be tweaked by changing the scalingFactor variable:

        float x = 0.0;

        vec3 p1 = vec3(0, 0, 0);

        const float scalingFactor = 0.15f;

        for (float f : graph_) {

          const float val = (f - minfps) / range;

          const vec3 p2 = vec3(x, val * scalingFactor, 0);

          x += 1.0f / maxPoints_;

          c.line(p1, p2, color);

          p1 = p2;

        }

      }

    };

As we add more points to the graph, the old points are popped out, making the graph look like it is scrolling on the screen from right to left. This is helpful to observe local fluctuations in values such as FPS counters, and so on.

How it works...

If we take a look at the Chapter4/VK01_DemoApp example, it makes use of LinearGraph to render an FPS graph, and a simple sine graph for reference. Here is how it works:

  1. In the main loop, we add points to the graph this way:

      if (fpsCounter.tick(deltaSeconds, frameRendered))

        fpsGraph.addPoint(fpsCounter.getFPS());

      sineGraph.addPoint(    (float)sin(glfwGetTime() * 10.0));

  2. Then we render both charts using VulkanCanvas like this, using the current swapchain image:

    void update2D(uint32_t imageIndex) {

      canvas2d->clear();

      sineGraph.renderGraph(*canvas2d.get(),    vec4(0.0f, 1.0f, 0.0f, 1.0f));

      fpsGraph.renderGraph(*canvas2d.get(),    vec4(1.0f, 0.0f, 0.0f, 1.0f));

      canvas2d->updateBuffer(vkDev, imageIndex);

    }

The resulting charts look as in the following screenshot:

Figure 4.4 – FPS and sine wave charts

Figure 4.4 – FPS and sine wave charts

Putting it all together into a Vulkan application

In this recipe, we use all the material from previous recipes of this chapter to build a Vulkan demo application.

Getting ready

It might be useful to revisit the first recipe, Organizing Vulkan frame rendering code, to get to grips with the frame composition approach we use in our applications.

The full source code for this recipe can be found in Chapter4/VK01_DemoApp.

How to do it...

Let's skim through the source code to see how we can integrate the functionality from all the recipes together into a single application:

  1. Just like in the previous demo, we declare a Vulkan instance and render device objects:

    VulkanInstance vk;

    VulkanRenderDevice vkDev;

  2. We should declare all our "layer" renderers:

    std::unique_ptr<ImGuiRenderer> imgui;

    std::unique_ptr<ModelRenderer> modelRenderer;

    std::unique_ptr<CubeRenderer> cubeRenderer;

    std::unique_ptr<VulkanCanvas> canvas;

    std::unique_ptr<VulkanCanvas> canvas2d;

    std::unique_ptr<VulkanClear> clear;

    std::unique_ptr<VulkanFinish> finish;

  3. Let's create an FPS counter and charts (graphs), like in the Rendering onscreen charts recipe:

    FramesPerSecondCounter fpsCounter(0.02f);

    LinearGraph fpsGraph;

    LinearGraph sineGraph(4096);

  4. All camera-related objects should be defined here as well, similar to what we did in the Working with a 3D camera and basic user interaction and Adding camera animations and motion recipes:

    glm::vec3 cameraPos(0.0f, 0.0f, 0.0f);

    glm::vec3 cameraAngles(-45.0f, 0.0f, 0.0f);

    CameraPositioner_FirstPerson positioner_firstPerson(  cameraPos, vec3(0.0f, 0.0f, -1.0f),  vec3(0.0f, 1.0f, 0.0f));

    CameraPositioner_MoveTo positioner_moveTo(  cameraPos, cameraAngles);

    Camera camera = Camera(positioner_firstPerson);

  5. Vulkan initialization code does a construction of all the "layer" renderers. Note how we insert profiling markers at the beginning of all heavy functions:

    bool initVulkan() {

      EASY_FUNCTION();

      createInstance(&vk.instance);

      ...

      imgui = std::make_unique<ImGuiRenderer>(vkDev);

  6. modelRenderer is initialized before other layers, since it contains a depth buffer. The clear, finish, and canvas layers use the depth buffer from modelRenderer as well. The canvas2d object, which renders fullscreen graphs, takes an empty depth texture to disable depth testing:

      modelRenderer = std::make_unique<ModelRenderer>(    vkDev,    "data/rubber_duck/scene.gltf",    "data/ch2_sample3_STB.jpg",    (uint32_t)sizeof(glm::mat4));

      cubeRenderer = std::make_unique<CubeRenderer>(vkDev,    modelRenderer->getDepthTexture(),    "data/piazza_bologni_1k.hdr");

      clear = std::make_unique<VulkanClear>(vkDev,    modelRenderer->getDepthTexture());

      finish = std::make_unique<VulkanFinish>(vkDev,    modelRenderer->getDepthTexture());

      canvas2d = std::make_unique<VulkanCanvas>(vkDev,    VulkanImage { .image= VK_NULL_HANDLE,                  .imageView= VK_NULL_HANDLE }  );

      canvas = std::make_unique<VulkanCanvas>(vkDev,    modelRenderer->getDepthTexture());

      return true;

    }

  7. The current 3D camera selection code is found in reinitCamera(), which is called from renderGUI() when the user changes the camera mode:

    void reinitCamera() {

      if (!strcmp(cameraType, "FirstPerson")) {

        camera = Camera(positioner_firstPerson);

      }

      else if (!strcmp(cameraType, "MoveTo")) {

        positioner_moveTo.setDesiredPosition(cameraPos);

        positioner_moveTo.setDesiredAngles(      cameraAngles.x, cameraAngles.y, cameraAngles.z);

        camera = Camera(positioner_moveTo);

      }

    }

    Check the Adding camera animations and motion recipe for more details.

  8. All the Dear ImGui user interface rendering for the specified swapchain image is done in the following way:

    void renderGUI(uint32_t imageIndex) {

      int width, height;

      glfwGetFramebufferSize(window, &width, &height);

      ImGuiIO& io = ImGui::GetIO();

      io.DisplaySize =    ImVec2((float)width, (float)height);

      ImGui::NewFrame();

  9. Render the FPS counter in a borderless ImGui window so that just the text is rendered on the screen:

      const ImGuiWindowFlags flags =    ImGuiWindowFlags_NoTitleBar |    ImGuiWindowFlags_NoResize |    ImGuiWindowFlags_NoMove |    ImGuiWindowFlags_NoScrollbar |    ImGuiWindowFlags_NoSavedSettings |    ImGuiWindowFlags_NoInputs |    ImGuiWindowFlags_NoBackground;

      ImGui::SetNextWindowPos(ImVec2(0, 0));

      ImGui::Begin("Statistics", nullptr, flags);

      ImGui::Text("FPS: %.2f", fpsCounter.getFPS());

      ImGui::End();

  10. Render the camera controls window. It contains a combo box to select the current camera mode and a couple of sliders to tweak the camera position and orientation:

      ImGui::Begin("Camera Control", nullptr);

      if (ImGui::BeginCombo("##combo",       currentComboBoxItem)) {

        for (int n = 0; n < IM_ARRAYSIZE(comboBoxItems);         n++) {

          const bool isSelected =        (currentComboBoxItem == comboBoxItems[n]);

          if (ImGui::Selectable(comboBoxItems[n],            isSelected))

            currentComboBoxItem = comboBoxItems[n];

          if (isSelected) ImGui::SetItemDefaultFocus();

        }

        ImGui::EndCombo();

      }

  11. If the MoveTo camera mode is selected, draw the ImGui sliders to select the camera position coordinates and orientation angles:

      if (!strcmp(cameraType, "MoveTo")) {

        if (ImGui::SliderFloat3("Position",          glm::value_ptr(cameraPos), -10.0f, +10.0f))

          positioner_moveTo.setDesiredPosition(cameraPos);

        if (ImGui::SliderFloat3("Pitch/Pan/Roll",        glm::value_ptr(cameraAngles), -90.0f, +90.0f))

         positioner_moveTo.setDesiredAngles(cameraAngles);

      }

  12. Reinitialize the camera if the camera mode has changed. Finalize the ImGui rendering and update the Vulkan buffers before issuing any Vulkan drawing commands:

      if (currentComboBoxItem &&      strcmp(currentComboBoxItem, cameraType)) {

        printf("New camera type selected %s ",      currentComboBoxItem);

        cameraType = currentComboBoxItem;

        reinitCamera();

      }

      ImGui::End();

      ImGui::Render();

      imgui->updateBuffers(    vkDev, imageIndex, ImGui::GetDrawData());

    }

  13. The update3D() function calculates the appropriate view and projection matrices for all objects and updates uniform buffers:

    void update3D(uint32_t imageIndex)

    {

      int width, height;

      glfwGetFramebufferSize(window, &width, &height);

      const float ratio = width / (float)height;

      const mat4 t = glm::translate(mat4(1.0f),    vec3(0.0f, 0.5f, - 1.5f));

      const float angle = (float)glfwGetTime();

      const mat4 m1 = glm::rotate( t * glm::rotate(       mat4(1.f), glm::pi<float>(), vec3(1, 0, 0)),     angle, vec3(0.0f, 1.0f, 0.0f) );

      const mat4 p = glm::perspective(    45.0f, ratio, 0.1f, 1000.0f);

      const mat4 view = camera.getViewMatrix();

      const mat4 mtx = p * view * m1;

      EASY_BLOCK("UpdateUniformBuffers");

      modelRenderer->updateUniformBuffer(vkDev,    imageIndex, glm::value_ptr(mtx), sizeof(mat4));

      canvas->updateUniformBuffer(    vkDev, p * view, 0.0f, imageIndex);

      canvas2d->updateUniformBuffer(    vkDev, glm::ortho(0, 1, 1, 0), 0.0f, imageIndex);

      cubeRenderer->updateUniformBuffer(    vkDev, imageIndex, p * view * m1);

      EASY_END_BLOCK;

    }

  14. The update2D() function does the same thing for the user interface and onscreen graphs as described in the Rendering onscreen charts recipe:

    void update2D(uint32_t imageIndex) {

      canvas2d->clear();

      sineGraph.renderGraph(    *canvas2d.get(),vec4(0.f, 1.f, 0.f, 1.f));

      fpsGraph.renderGraph(*canvas2d.get());

      canvas2d->updateBuffer(vkDev, imageIndex);

    }

With all the necessary helper functions defined previously, the frame composition works as follows:

  1. First, all the 2D, 3D, and user interface rendering data is updated:

    void composeFrame(  uint32_t imageIndex,  const std::vector<RendererBase*>& renderers)

    {

      update3D(imageIndex);

      renderGUI(imageIndex);

      update2D(imageIndex);

  2. Then we begin to fill a new command buffer by iterating all the layer renderers and calling their fillCommandBuffer() virtual function:

      EASY_BLOCK("FillCommandBuffers");

      VkCommandBuffer commandBuffer =    vkDev.commandBuffers[imageIndex];

      const VkCommandBufferBeginInfo bi = {    .sType =      VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,   .pNext = nullptr,   .flags =      VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT,   .pInheritanceInfo = nullptr   };

      VK_CHECK(vkBeginCommandBuffer(commandBuffer, &bi));

      for (auto& r : renderers)    r->fillCommandBuffer(commandBuffer, imageIndex);

      VK_CHECK(vkEndCommandBuffer(commandBuffer));

      EASY_END_BLOCK;

    }

  3. Once our frame composition is done, we can proceed with the frame rendering:

    bool drawFrame(  const std::vector<RendererBase*>& renderers)

    {

      EASY_FUNCTION();

      uint32_t imageIndex = 0;

      VkResult result = vkAcquireNextImageKHR(    vkDev.device, vkDev.swapchain, 0,    vkDev.semaphore, VK_NULL_HANDLE, &imageIndex);

      VK_CHECK(vkResetCommandPool(    vkDev.device, vkDev.commandPool, 0));

  4. Here, if the next swapchain image is not yet available, we should return and skip this frame. It might just be that our GPU is rendering frames slower than we are filling in the command buffers:

      if (result != VK_SUCCESS) return false;

      composeFrame(imageIndex, renderers);

      const VkPipelineStageFlags waitStages[] =    { VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT };

  5. Submit the command buffer into the Vulkan graphics queue:

      const VkSubmitInfo si = {    .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO,    .pNext = nullptr,    .waitSemaphoreCount = 1,    .pWaitSemaphores = &vkDev.semaphore,    .pWaitDstStageMask = waitStages,    .commandBufferCount = 1,    .pCommandBuffers =      &vkDev.commandBuffers[imageIndex],    .signalSemaphoreCount = 1,    .pSignalSemaphores = &vkDev.renderSemaphore   };

      EASY_BLOCK(    "vkQueueSubmit", profiler::colors::Magenta);

      VK_CHECK(vkQueueSubmit(    vkDev.graphicsQueue, 1, &si, nullptr));

      EASY_END_BLOCK;

  6. Present the results on the screen:

      const VkPresentInfoKHR pi = {    .sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR,    .pNext = nullptr,    .waitSemaphoreCount = 1,    .pWaitSemaphores = &vkDev.renderSemaphore,    .swapchainCount = 1,    .pSwapchains = &vkDev.swapchain,    .pImageIndices = &imageIndex   };

      EASY_BLOCK(    "vkQueuePresentKHR", profiler::colors::Magenta);

      VK_CHECK(vkQueuePresentKHR(    vkDev.graphicsQueue, &pi));

      EASY_END_BLOCK;

  7. Wait for the GPU to finish rendering:

      EASY_BLOCK(    "vkDeviceWaitIdle", profiler::colors::Red);

      VK_CHECK(vkDeviceWaitIdle(vkDev.device));

      EASY_END_BLOCK;

      return true;

    }

  8. The drawFrame() function is invoked from the main loop using the following list of layer renderers:

    const std::vector<RendererBase*> renderers = {  clear.get(),  cubeRenderer.get(),  modelRenderer.get(),  canvas.get(),  canvas2d.get(),  imgui.get(),  finish.get()};

The last component of the demo application is the main loop. Conceptually, it remains unchanged. However, it includes some additional elements, such as profiling, initializing the ImGui library, and the 3D camera keyboard controls. Here is a screenshot from the running application:

Figure 4.5 – Demo application

Figure 4.5 – Demo application

This chapter focused on combining multiple rendering aspects into one working Vulkan application. The graphical side still lacks some essential features, such as advanced lighting and materials, but we have almost everything in place to start rendering complex scenes. The next couple of chapters will cover more complicated mesh rendering techniques and physically-based lighting calculations.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset