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:
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.
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.
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.
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:
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;
inline VulkanImage getDepthTexture() const
{ return depthTexture_; }
protected:
void beginRenderPass(VkCommandBuffer commandBuffer, size_t currentImage);
bool createUniformBuffers(VulkanRenderDevice& vkDev, size_t uniformDataSize);
uint32_t framebufferWidth_;
uint32_t framebufferHeight_;
VkDescriptorSetLayout descriptorSetLayout_;
VkDescriptorPool descriptorPool_;
std::vector<VkDescriptorSet> descriptorSets_;
std::vector<VkFramebuffer> swapchainFramebuffers_;
VulkanImage depthTexture_;
VkRenderPass renderPass_;
VkPipelineLayout pipelineLayout_;
VkPipeline graphicsPipeline_;
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:
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;
}
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);
}
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:
class VulkanClear: public RendererBase {
public:
VulkanClear(VulkanRenderDevice& vkDev, VulkanImage depthTexture);
virtual void fillCommandBuffer( VkCommandBuffer commandBuffer, size_t currentImage) override;
private:
bool shouldClearDepth;
};
VulkanClear::VulkanClear(
VulkanRenderDevice& vkDev, VulkanImage depthTexture)
: RendererBase(vkDev, depthTexture)
, shouldClearDepth( depthTexture.image != VK_NULL_HANDLE)
{
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_);
}
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_ }
};
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:
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.
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.
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.
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.
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.
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.
Let's declare the ModelRenderer class, which contains a texture and combined vertex and index buffers:
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.
private:
size_t vertexBufferSize_;
size_t indexBufferSize_;
VkBuffer storageBuffer_;
VkDeviceMemory storageBufferMemory_;
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:
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);
}
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:
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) };
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_));
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()));
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:
void ModelRenderer::updateUniformBuffer( VulkanRenderDevice& vkDev, uint32_t currentImage, const void* data, const size_t dataSize)
{
uploadBufferData( vkDev, uniformBuffersMemory_[currentImage], 0, data, dataSize);
}
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.
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.
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.
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:
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);
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);
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;
bool createDescriptorSet(VulkanRenderDevice& vkDev);
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:
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)
{
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);
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);
}
}
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 });
}
void VulkanCanvas::clear() {
lines.clear();
}
Now, let's go back to the Vulkan code necessary to render the lines:
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.
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);
}
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));
}
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.
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.
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.
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:
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);
private:
const ImDrawData* drawData = nullptr;
bool createDescriptorSet(VulkanRenderDevice& vkDev);
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:
const uint32_t ImGuiVtxBufferSize = 64 * 1024 * sizeof(ImDrawVert);
const uint32_t ImGuiIdxBufferSize = 64 * 1024 * sizeof(int);
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;
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:
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;
ImFont* Font = io.Fonts->AddFontFromFileTTF( fontFile, cfg.SizePixels, &cfg);
unsigned char* pixels = nullptr;
int texWidth, texHeight;
io.Fonts->GetTexDataAsRGBA32( &pixels, &texWidth, &texHeight);
if (!pixels || !createTextureImageFromData(vkDev, textureImage, textureImageMemory, pixels, texWidth, texHeight, VK_FORMAT_R8G8B8A8_UNORM)) {
printf("Failed to load texture ");
return false;
}
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:
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);
}
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;
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);
}
}
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.
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.
The source code for this recipe can be found in Chapter4/GL01_Camera.
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:
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;
};
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_;
float mouseSpeed_ = 4.0f;
float acceleration_ = 150.0f;
float damping_ = 0.2f;
float maxSpeed_ = 10.0f;
float fastCoef_ = 10.0f;
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);
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))
{}
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.
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.
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.
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);
}
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.
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);
}
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.
The demo application for this recipe is based on the cube map OpenGL example from the previous chapter:
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);
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.
glfwSetMouseButtonCallback(window, [](auto* window, int button, int action, int mods)
{
if (button == GLFW_MOUSE_BUTTON_LEFT)
mouseState.pressedLeft = action == GLFW_PRESS;
});
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.
positioner.update(deltaSeconds, mouseState.pos, mouseState.pressedLeft);
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.
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.
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.
The source code for this recipe can be found in Chapter4/GL02_FPS.
Let's implement the FramesPerSecondCounter class containing all the machinery required to calculate the average FPS rate for a given time interval:
class FramesPerSecondCounter {
private:
const float avgIntervalSec_ = 0.5f;
unsigned int numFrames_ = 0;
double accumulatedTime_ = 0;
float currentFPS_ = 0.0f;
public:
explicit FramesPerSecondCounter( float avgIntervalSec = 0.5f)
: avgIntervalSec_(avgIntervalSec)
{ assert(avgIntervalSec > 0.0f); }
bool tick( float deltaSeconds, bool frameRendered = true)
{
if (frameRendered) numFrames_++;
accumulatedTime_ += deltaSeconds;
if (accumulatedTime_ < avgIntervalSec_)
return false;
currentFPS_ = static_cast<float>( numFrames_ / accumulatedTime_);
printf("FPS: %.1f ", currentFPS_);
numFrames_ = 0;
accumulatedTime_ = 0;
return true;
}
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:
double timeStamp = glfwGetTime();
float deltaSeconds = 0.0f;
FramesPerSecondCounter fpsCounter(0.5f);
while (!glfwWindowShouldClose(window))
{
const double newTimeStamp = glfwGetTime();
deltaSeconds = static_cast<float>( newTimeStamp – timeStamp);
timeStamp = newTimeStamp;
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.
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.
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.
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.
Let's look at how to programmatically control our 3D camera using a simple ImGui-based user interface:
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);
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:
const char* cameraType = "FirstPerson";
const char* comboBoxItems[] = { "FirstPerson", "MoveTo" };
const char* currentComboBoxItem = cameraType;
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();
}
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);
}
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
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.
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:
class CameraPositioner_MoveTo final:
public CameraPositionerInterface
{
public:
float dampingLinear_ = 10.0f;
glm::vec3 dampingEulerAngles_ = glm::vec3(5.0f, 5.0f, 5.0f);
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)
{}
void update(float deltaSeconds, const glm::vec2& mousePos, bool mousePressed)
{
positionCurrent_+= dampingLinear_ * deltaSeconds * (positionDesired_ - positionCurrent_);
anglesCurrent_ = clipAngles(anglesCurrent_);
anglesDesired_ = clipAngles(anglesDesired_);
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_);
}
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)
);
}
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.
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.
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.
The complete source code of the demo application for this recipe is located in Chapter4/VK01_DemoApp.
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:
option(BUILD_WITH_EASY_PROFILER "Enable EasyProfiler usage" ON)
option(BUILD_WITH_OPTICK "Enable Optick usage" OFF)
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()
if(BUILD_WITH_EASY_PROFILER)
target_link_libraries(SharedUtils PUBLIC easy_profiler)
endif()
if(BUILD_WITH_OPTICK)
target_link_libraries(SharedUtils PUBLIC OptickCore)
endif()
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()
{
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
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:
#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
#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
#if BUILD_WITH_EASY_PROFILER
# include "easy/profiler.h"
# define PROFILER_FRAME(...)
# define PROFILER_DUMP(fileName)
profiler::dumpBlocksToFile(fileName);
#endif // BUILD_WITH_EASY_PROFILER
#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);
class OptickScopeWrapper {
public:
OptickScopeWrapper(const char* name) {
OPTICK_PUSH(name);
}
~OptickScopeWrapper() {
OPTICK_POP();
}
};
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
Here is the output from the Optick user interface. Conceptually, these two profilers are very similar:
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.
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.
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.
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:
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);
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);
}
}
void CubeRenderer::updateUniformBuffer( VulkanRenderDevice& vkDev, uint32_t currentImg, const mat4& m)
{
uploadBufferData(vkDev, uniformBuffersMemory_[currentImg], 0, glm::value_ptr(m), sizeof(mat4));
}
void CubeRenderer::fillCommandBuffer( VkCommandBuffer commandBuffer, size_t currentImage)
{
beginRenderPass(commandBuffer, currentImage);
vkCmdDraw(commandBuffer, 36, 1, 0, 0);
vkCmdEndRenderPass(commandBuffer);
}
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.
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.
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.
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:
class LinearGraph {
std::deque<float> graph_;
const size_t maxPoints_;
public:
explicit LinearGraph(size_t maxGraphPoints = 256)
: maxPoints_(maxGraphPoints)
{}
void addPoint(float value) {
graph_.push_back(value);
if (graph_.size() > maxPoints_)
graph_.pop_front();
}
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;
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.
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:
if (fpsCounter.tick(deltaSeconds, frameRendered))
fpsGraph.addPoint(fpsCounter.getFPS());
sineGraph.addPoint( (float)sin(glfwGetTime() * 10.0));
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
In this recipe, we use all the material from previous recipes of this chapter to build a Vulkan demo application.
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.
Let's skim through the source code to see how we can integrate the functionality from all the recipes together into a single application:
VulkanInstance vk;
VulkanRenderDevice vkDev;
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;
FramesPerSecondCounter fpsCounter(0.02f);
LinearGraph fpsGraph;
LinearGraph sineGraph(4096);
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);
bool initVulkan() {
EASY_FUNCTION();
createInstance(&vk.instance);
...
imgui = std::make_unique<ImGuiRenderer>(vkDev);
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;
}
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.
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();
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();
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();
}
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);
}
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());
}
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;
}
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:
void composeFrame( uint32_t imageIndex, const std::vector<RendererBase*>& renderers)
{
update3D(imageIndex);
renderGUI(imageIndex);
update2D(imageIndex);
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;
}
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));
if (result != VK_SUCCESS) return false;
composeFrame(imageIndex, renderers);
const VkPipelineStageFlags waitStages[] = { VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT };
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;
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;
EASY_BLOCK( "vkDeviceWaitIdle", profiler::colors::Red);
VK_CHECK(vkDeviceWaitIdle(vkDev.device));
EASY_END_BLOCK;
return true;
}
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
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.