Search icon CANCEL
Subscription
0
Cart icon
Your Cart (0 item)
Close icon
You have no products in your basket yet
Save more on your purchases! discount-offer-chevron-icon
Savings automatically calculated. No voucher code required.
Arrow left icon
Explore Products
Best Sellers
New Releases
Books
Videos
Audiobooks
Learning Hub
Newsletter Hub
Free Learning
Arrow right icon
timer SALE ENDS IN
0 Days
:
00 Hours
:
00 Minutes
:
00 Seconds
Arrow up icon
GO TO TOP
Vulkan 3D Graphics Rendering Cookbook

You're reading from   Vulkan 3D Graphics Rendering Cookbook Implement expert-level techniques for high-performance graphics with Vulkan

Arrow left icon
Product type Paperback
Published in Feb 2025
Publisher Packt
ISBN-13 9781803248110
Length 714 pages
Edition 2nd Edition
Languages
Tools
Arrow right icon
Authors (2):
Arrow left icon
Alexey Medvedev Alexey Medvedev
Author Profile Icon Alexey Medvedev
Alexey Medvedev
Sergey Kosarevsky Sergey Kosarevsky
Author Profile Icon Sergey Kosarevsky
Sergey Kosarevsky
Arrow right icon
View More author details
Toc

Table of Contents (14) Chapters Close

Preface 1. Establishing a Build Environment 2. Getting Started with Vulkan FREE CHAPTER 3. Working with Vulkan Objects 4. Adding User Interaction and Productivity Tools 5. Working with Geometry Data 6. Physically Based Rendering Using the glTF 2.0 Shading Model 7. Advanced PBR Extensions 8. Graphics Rendering Pipeline 9. glTF Animations 10. Image-Based Techniques 11. Advanced Rendering Techniques and Optimizations 12. Other Books You May Enjoy
13. Index

Initializing Vulkan pipelines

A Vulkan pipeline is an implementation of an abstract graphics pipeline, which is a sequence of operations that transform vertices and rasterize the resulting image. Essentially, it’s like a single snapshot of a “frozen” OpenGL state. Vulkan pipelines are mostly immutable, meaning multiple Vulkan pipelines should be created to allow different data paths through the graphics pipeline. In this recipe, we will learn how to create a Vulkan pipeline suitable for rendering a colorful triangle and explore how low-level and verbose Vulkan can be wrapped into a simple, high-level interface.

Getting ready...

To get all the basic information about Vulkan pipelines, we recommend reading Vulkan Cookbook by Pawel Lapinski which was published by Packt, or the Vulkan Tutorial series by Alexander Overvoorde https://vulkan-tutorial.com/Drawing_a_triangle/Graphics_pipeline_basics/Introduction.

For additional information on descriptor set layouts, check out the chapter https://vulkan-tutorial.com/Uniform_buffers/Descriptor_layout_and_buffer.

Vulkan pipelines require Vulkan shader modules. Check the previous recipe Initializing Vulkan shader modules before going through this recipe.

How to do it...

Let’s dive into how to set up a Vulkan pipeline suitable for our triangle rendering application. Due to the verbosity of the Vulkan API, this recipe will be one of the longest. We will begin with the high-level code in our demo application, Chapter02/02_HelloTriangle, and work our way through to the internals of LightweightVK down to the Vulkan API. In the following chapters, we will explore more details, such as dynamic states, multisampling, vertex input, and more.

Let’s take a look at the initialization and the main loop of Chapter02/02_HelloTriangle.

  1. First, we create a window and a Vulkan context as described in the previous recipes.
    GLFWwindow* window =
      lvk::initWindow("Simple example", width, height);
    std::unique_ptr<lvk::IContext> ctx =
      lvk::createVulkanContextWithSwapchain(window, width, height, {});
    
  2. Next, we need to create a rendering pipeline. LightweightVK uses opaque handles to work with resources, so lvk::RenderPipelineHandle is an opaque handle that manages a collection of VkPipeline objects, and lvk::Holder is a RAII wrapper that automatically disposes of handles when they go out of scope. The method createRenderPipeline() takes a RenderPipelineDesc structure, which contains the data necessary to configure a rendering pipeline. For our first triangle demo, we aim to keep things as minimalistic as possible, so we simply set the vertex and fragment shaders, and define the format of a color attachment. This is the absolute minimum required to render something into a swapchain image.
    Holder<lvk::ShaderModuleHandle> vert = loadShaderModule(
      ctx, "Chapter02/02_HelloTriangle/src/main.vert");
    Holder<lvk::ShaderModuleHandle> frag = loadShaderModule(
      ctx, "Chapter02/02_HelloTriangle/src/main.frag");
    Holder<lvk::RenderPipelineHandle> rpTriangle =
      ctx->createRenderPipeline({
        .smVert = vert,
        .smFrag = frag,
        .color  = { { .format = ctx->getSwapchainFormat() } } });
    
  3. Inside the main loop, we acquire a command buffer as described in the recipe Using Vulkan command buffers and issue some drawing commands.
    while (!glfwWindowShouldClose(window)) {
      glfwPollEvents();
      glfwGetFramebufferSize(window, &width, &height);
      if (!width || !height) continue;
      lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
    
  4. The member function cmdBeginRendering() wraps Vulkan 1.3 dynamic rendering functionality, which enables rendering directly into Vulkan images without explicitly creating any render passes or framebuffer objects. It takes a description of a render pass lvk::RenderPass and a description of a framebuffer lvk::Framebuffer. We will explore it in more detail in subsequent chapters. Here, we use the current swapchain texture as the first color attachment and clear it to a white color before rendering using the attachment load operation LoadOp_Clear, which corresponds to VK_ATTACHMENT_LOAD_OP_CLEAR in Vulkan. The store operation is set to StoreOp_Store by default.
      buf.cmdBeginRendering(
        {.color = {{ .loadOp = LoadOp_Clear, .clearColor = {1,1,1,1} }}},
        {.color = {{ .texture = ctx->getCurrentSwapchainTexture() }}});
    
  5. The render pipeline can be bound to the command buffer in one line. Then we can issue a drawing command cmdDraw(), which is a wrapper on top of vkCmdDraw(). You may have noticed that we did not use any index or vertex buffers at all. We will see why in a moment as we look into GLSL shaders. The command cmdEndRendering() corresponds to vkCmdEndRendering() from Vulkan 1.3.
      buf.cmdBindRenderPipeline(rpTriangle);
      buf.cmdPushDebugGroupLabel("Render Triangle", 0xff0000ff);
      buf.cmdDraw(3);
      buf.cmdPopDebugGroupLabel();
      buf.cmdEndRendering();
      ctx->submit(buf, ctx->getCurrentSwapchainTexture());
    }
    

Let’s take a look at the GLSL shaders.

  1. As we do not provide any vertex input, the vertex shader has to generate vertex data for a triangle. We use the built-in GLSL variable gl_VertexIndex, which gets incremented automatically for every subsequent vertex, to return hardcoded values for positions and vertex colors.
    #version 460
    layout (location=0) out vec3 color;
    const vec2 pos[3] = vec2[3](
      vec2(-0.6, -0.4), vec2(0.6, -0.4), vec2(0.0, 0.6) );
    const vec3 col[3] = vec3[3](
      vec3(1.0, 0.0, 0.0), vec3(0.0, 1.0, 0.0), vec3(0.0, 0.0, 1.0) );
    void main() {
      gl_Position = vec4(pos[gl_VertexIndex], 0.0, 1.0);
      color = col[gl_VertexIndex];
    }
    
  2. The GLSL fragment shader is trivial and just outputs the interpolated fragment color.
    #version 460
    layout (location=0) in vec3 color;
    layout (location=0) out vec4 out_FragColor;
    void main() {
      out_FragColor = vec4(color, 1.0);
    }
    

The application should render a colorful triangle as in the following picture.

Figure 2.2: Hello Triangle

We learned how to draw a triangle with Vulkan using LightweightVK. It is time to look under the hood and find out how this high-level render pipeline management interface is implemented via Vulkan.

How it works…

To get to the underlying Vulkan implementation, we have to peel a few layers one by one. When we want to create a graphics pipeline in our application, we call the member function IContext::createRenderPipeline() which is implemented in VulkanContext. This function takes in a structure lvk::RenderPipelineDesc which describes our rendering pipeline. Let’s take a closer look at it.

  1. The structure contains a subset of information necessary to create a valid graphics VkPipeline object. It starts with the topology and vertex input descriptions, followed by shader modules for all supported shader stages. While LightweightVK supports mesh shaders, in this book, we will use only vertex, fragment, geometry, and tessellation shaders.
    struct RenderPipelineDesc final {
      Topology topology = Topology_Triangle;
      VertexInput vertexInput;
      ShaderModuleHandle smVert;
      ShaderModuleHandle smTesc;
      ShaderModuleHandle smTese;
      ShaderModuleHandle smGeom;
      ShaderModuleHandle smTask;
      ShaderModuleHandle smMesh;
      ShaderModuleHandle smFrag;
    
  2. Specialization constants allow Vulkan shader modules to be specialized after their compilation, at the pipeline creation time. We will demonstrate how to use them in the next chapter.
      SpecializationConstantDesc specInfo = {};
      const char* entryPointVert = "main";
      const char* entryPointTesc = "main";
      const char* entryPointTese = "main";
      const char* entryPointGeom = "main";
      const char* entryPointTask = "main";
      const char* entryPointMesh = "main";
      const char* entryPointFrag = "main";
    
  3. The maximum number of color attachments is set to 8. We do not store the number of used attachments here. Instead, we use a short helper function to calculate how many attachments we actually have.
      ColorAttachment color[LVK_MAX_COLOR_ATTACHMENTS] = {};
      uint32_t getNumColorAttachments() const {
        uint32_t n = 0;
        while (n < LVK_MAX_COLOR_ATTACHMENTS &&
          color[n].format != Format_Invalid) n++;
        return n;
      }
    
  4. Other member fields represent a typical rendering state with a cull mode, face winding, polygon mode, and so on.
      Format depthFormat = Format_Invalid;
      Format stencilFormat = Format_Invalid;
      CullMode cullMode = lvk::CullMode_None;
      WindingMode frontFaceWinding = lvk::WindingMode_CCW;
      PolygonMode polygonMode = lvk::PolygonMode_Fill;
      StencilState backFaceStencil = {};
      StencilState frontFaceStencil = {};
      uint32_t samplesCount = 1u;
      uint32_t patchControlPoints = 0;
      float minSampleShading = 0.0f;
      const char* debugName = "";
    };
    

When we call VulkanContext::createRenderPipeline(), it performs sanity checks on RenderPipelineDesc and stores all the values in the RenderPipelineState struct. LightweightVK pipelines cannot be directly mapped one-to-one to VkPipeline objects because the actual VkPipeline objects have to be created lazily. For example, LightweightVK manages Vulkan descriptor set layouts automatically. Vulkan requires a descriptor set layout to be specified for a pipeline object. To address this, the data stored in RenderPipelineState is used to lazily create actual VkPipeline objects through the function VulkanContext::getVkPipeline(). Let’s take a look at how this mechanism works, with error checking and some minor details omitted for simplicity.

  1. The RenderPipelineState structure contains some precached values to avoid reinitializing them every time a new Vulkan pipeline object is created. All shader modules must remain alive as long as any pipeline that uses them is still in use.
    class RenderPipelineState final {
      RenderPipelineDesc desc_;
      uint32_t numBindings_ = 0;
      uint32_t numAttributes_ = 0;
      VkVertexInputBindingDescription
        vkBindings_[VertexInput::LVK_VERTEX_BUFFER_MAX] = {};
      VkVertexInputAttributeDescription
        vkAttributes_[VertexInput::LVK_VERTEX_ATTRIBUTES_MAX] = {};
      VkDescriptorSetLayout lastVkDescriptorSetLayout_ =
        VK_NULL_HANDLE;
      VkShaderStageFlags shaderStageFlags_ = 0;
    
  2. Each RenderPipelineState owns a pipeline layout, a pipeline, and memory storage for specialization constants.
      VkPipelineLayout pipelineLayout_ = VK_NULL_HANDLE;
      VkPipeline pipeline_ = VK_NULL_HANDLE;
      void* specConstantDataStorage_ = nullptr;
    };
    

With all data structures in place, we are now ready to go through the implementation of VulkanContext::createRenderPipeline(). Most of the error checking code is skipped for the sake of brevity.

  1. The constructor iterates over vertex input attributes, and precaches all necessary data into Vulkan structures for further use.
    Holder<RenderPipelineHandle> VulkanContext::createRenderPipeline(
      const RenderPipelineDesc& desc, Result* outResult)
    {
      const bool hasColorAttachments =
        desc.getNumColorAttachments() > 0;
      const bool hasDepthAttachment =
        desc.depthFormat != Format_Invalid;
      const bool hasAnyAttachments =
        hasColorAttachments || hasDepthAttachment;
      if (!LVK_VERIFY(hasAnyAttachments)) return {};
      if (!LVK_VERIFY(desc.smVert.valid())) return {};
      if (!LVK_VERIFY(desc.smFrag.valid())) return {};
      RenderPipelineState rps = {.desc_ = desc};
    
  2. Iterate and cache vertex input bindings and attributes. Vertex buffer bindings are tracked in bufferAlreadyBound. Everything else is a very trivial conversion code from our high-level data structures to Vulkan.
      const lvk::VertexInput& vstate = rps.desc_.vertexInput;
      bool bufferAlreadyBound[LVK_VERTEX_BUFFER_MAX] = {};
      rps.numAttributes_ = vstate.getNumAttributes();
      for (uint32_t i = 0; i != rps.numAttributes_; i++) {
        const VertexInput::VertexAttribute& attr = vstate.attributes[i];
        rps.vkAttributes_[i] = { .location = attr.location,
                                 .binding = attr.binding,
                                 .format =
                                   vertexFormatToVkFormat(attr.format),
                                 .offset = (uint32_t)attr.offset };
        if (!bufferAlreadyBound[attr.binding]) {
          bufferAlreadyBound[attr.binding] = true;
          rps.vkBindings_[rps.numBindings_++] = {
            .binding = attr.binding,
            .stride = vstate.inputBindings[attr.binding].stride,
            .inputRate = VK_VERTEX_INPUT_RATE_VERTEX };
        }
      }
    
  3. If specialization constants data is provided, copy it out of RenderPipelineDesc into local memory storage owned by the pipeline. This simplifies RenderPipelineDesc management on the application side, allowing it to be destroyed after the pipeline is created.
      if (desc.specInfo.data && desc.specInfo.dataSize) {
        rps.specConstantDataStorage_ =
          malloc(desc.specInfo.dataSize);
        memcpy(rps.specConstantDataStorage_,
          desc.specInfo.data, desc.specInfo.dataSize);
        rps.desc_.specInfo.data = rps.specConstantDataStorage_;
      }
      return {this, renderPipelinesPool_.create(std::move(rps))};
    }
    

Now we can create actual Vulkan pipelines. Well, almost. A couple of very long code snippets await us. These are the longest functions in the entire book, but we have to go through them at least once. Though, error checking is skipped to simplify things a bit.

  1. The getVkPipeline() functions retrieves a RenderPipelineState struct from a pool using a provided pipeline handle.
    VkPipeline VulkanContext::getVkPipeline(
      RenderPipelineHandle handle)
    {
      lvk::RenderPipelineState* rps =
        renderPipelinesPool_.get(handle);
      if (!rps) return VK_NULL_HANDLE;
    
  2. Then we check if the descriptor set layout used to create a pipeline layout for this VkPipeline object has changed. Our implementation uses Vulkan descriptor indexing to manage all textures in a large descriptor set and creates a descriptor set layout to store them. When new textures are created, there might not be enough space to store them, requiring the creation of a new descriptor set layout. Every time this happens, we have to delete the old VkPipeline and VkPipelineLayout objects and create new ones.
      if (rps->lastVkDescriptorSetLayout_ != vkDSL_) {
        deferredTask(std::packaged_task<void()>(
          [device = getVkDevice(), pipeline = rps->pipeline_]() {
            vkDestroyPipeline(device, pipeline, nullptr); }));
        deferredTask(std::packaged_task<void()>(
          [device = getVkDevice(), layout = rps->pipelineLayout_]() {
            vkDestroyPipelineLayout(device, layout, nullptr); }));
        rps->pipeline_ = VK_NULL_HANDLE;
        rps->lastVkDescriptorSetLayout_ = vkDSL_;
      }
    
  3. If there is already a valid Vulkan graphics pipeline compatible with the current descriptor set layout, we can simply return it.
      if (rps->pipeline_ != VK_NULL_HANDLE) {
        return rps->pipeline_;
      }
    
  4. Let’s prepare to build a new Vulkan pipeline object. We need to create color blend attachments only for active color attachments. Helper functions, such as formatToVkFormat(), convert LightweightVK enumerations to Vulkan.
      VkPipelineLayout layout = VK_NULL_HANDLE;
      VkPipeline pipeline = VK_NULL_HANDLE;
      const RenderPipelineDesc& desc = rps->desc_;
      const uint32_t numColorAttachments =
        desc_.getNumColorAttachments();
      VkPipelineColorBlendAttachmentState
        colorBlendAttachmentStates[LVK_MAX_COLOR_ATTACHMENTS] = {};
      VkFormat colorAttachmentFormats[LVK_MAX_COLOR_ATTACHMENTS] ={};
      for (uint32_t i = 0; i != numColorAttachments; i++) {
        const lvk::ColorAttachment& attachment = desc_.color[i];
        colorAttachmentFormats[i] =
          formatToVkFormat(attachment.format);
    
  5. Setting up blending states for color attachments is tedious but very simple.
        if (!attachment.blendEnabled) {
          colorBlendAttachmentStates[i] =
            VkPipelineColorBlendAttachmentState{
              .blendEnable = VK_FALSE,
              .srcColorBlendFactor = VK_BLEND_FACTOR_ONE,
              .dstColorBlendFactor = VK_BLEND_FACTOR_ZERO,
              .colorBlendOp = VK_BLEND_OP_ADD,
              .srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE,
              .dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO,
              .alphaBlendOp = VK_BLEND_OP_ADD,
              .colorWriteMask = VK_COLOR_COMPONENT_R_BIT |
                                VK_COLOR_COMPONENT_G_BIT |
                                VK_COLOR_COMPONENT_B_BIT |
                                VK_COLOR_COMPONENT_A_BIT,
          };
        } else {
          colorBlendAttachmentStates[i] =
            VkPipelineColorBlendAttachmentState{
              .blendEnable = VK_TRUE,
              .srcColorBlendFactor = blendFactorToVkBlendFactor(
                attachment.srcRGBBlendFactor),
              .dstColorBlendFactor = blendFactorToVkBlendFactor(
                attachment.dstRGBBlendFactor),
              .colorBlendOp = blendOpToVkBlendOp(attachment.rgbBlendOp),
              .srcAlphaBlendFactor = blendFactorToVkBlendFactor(
                attachment.srcAlphaBlendFactor),
              .dstAlphaBlendFactor = blendFactorToVkBlendFactor(
                attachment.dstAlphaBlendFactor),
              .alphaBlendOp =
                blendOpToVkBlendOp(attachment.alphaBlendOp),
              .colorWriteMask = VK_COLOR_COMPONENT_R_BIT |
                                VK_COLOR_COMPONENT_G_BIT |
                                VK_COLOR_COMPONENT_B_BIT |
                                VK_COLOR_COMPONENT_A_BIT,
          };
        }
      }
    
  6. Retrieve VkShaderModule objects from the pool using opaque handles. We will discuss how pools work in the next chapters. Here all we have to know is that they allow fast conversion of an integer handle into the actual data associated with it. The geometry shader is optional. We skip all other shaders here for the sake of brevity.
      const VkShaderModule* vert =
        ctx_->shaderModulesPool_.get(desc_.smVert);
      const VkShaderModule* geom =
        ctx_->shaderModulesPool_.get(desc_.smGeom);
      const VkShaderModule* frag =
        ctx_->shaderModulesPool_.get(desc_.smFrag);
    
  7. Prepare the vertex input state.
      const VkPipelineVertexInputStateCreateInfo ciVertexInputState =
      {
        .sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO,
        .vertexBindingDescriptionCount = rps->numBindings_,
        .pVertexBindingDescriptions = rps->numBindings_ ?
          rps->vkBindings_ : nullptr,
        .vertexAttributeDescriptionCount = rps->numAttributes_,
        .pVertexAttributeDescriptions =
          rps->numAttributes_ ? rps->vkAttributes_ : nullptr,
      };
    
  8. Populate the VkSpecializationInfo structure to describe specialization constants for this graphics pipeline.
      VkSpecializationMapEntry
        entries[LVK_SPECIALIZATION_CONSTANTS_MAX] = {};
      const VkSpecializationInfo si =
        lvk::getPipelineShaderStageSpecializationInfo(
          desc.specInfo, entries);
    
  9. Create a suitable VkPipelineLayout object for this pipeline. Use the current descriptor set layout stored in VulkanContext. Here one descriptor set layout vkDSL_ is duplicated multiple times to create a pipeline layout. This is necessary to ensure compatibility with MoltenVK which does not allow aliasing of different descriptor types. Push constant sizes are retrieved from precompiled shader modules as was described in the previous recipe Initializing Vulkan shader modules.
        const VkDescriptorSetLayout dsls[] =
          { vkDSL_, vkDSL_, vkDSL_, vkDSL_ };
        const VkPushConstantRange range = {
          .stageFlags = rps->shaderStageFlags_,
          .offset = 0,
          .size = pushConstantsSize,
        };
        const VkPipelineLayoutCreateInfo ci = {
          .sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO,
          .setLayoutCount = (uint32_t)LVK_ARRAY_NUM_ELEMENTS(dsls),
          .pSetLayouts = dsls,
          .pushConstantRangeCount = pushConstantsSize ? 1u : 0u,
          .pPushConstantRanges = pushConstantsSize ? &range:nullptr,
      };
      vkCreatePipelineLayout(vkDevice_, &ci, nullptr, &layout);
    

    More information

    Here’s a snippet to retrieve precalculated push constant sizes from shader modules:

    #define UPDATE_PUSH_CONSTANT_SIZE(sm, bit) if (sm) { \
      pushConstantsSize = std::max(pushConstantsSize,    \
      sm->pushConstantsSize);                            \
      rps->shaderStageFlags_ |= bit; }
    rps->shaderStageFlags_ = 0;
    uint32_t pushConstantsSize = 0;
    UPDATE_PUSH_CONSTANT_SIZE(vertModule,
      VK_SHADER_STAGE_VERTEX_BIT);
    UPDATE_PUSH_CONSTANT_SIZE(tescModule,
      VK_SHADER_STAGE_TESSELLATION_CONTROL_BIT);
    UPDATE_PUSH_CONSTANT_SIZE(teseModule,
      VK_SHADER_STAGE_TESSELLATION_EVALUATION_BIT);
    UPDATE_PUSH_CONSTANT_SIZE(geomModule,
      VK_SHADER_STAGE_GEOMETRY_BIT);
    UPDATE_PUSH_CONSTANT_SIZE(fragModule,
      VK_SHADER_STAGE_FRAGMENT_BIT);
    #undef UPDATE_PUSH_CONSTANT_SIZE
    
  1. As we peel more and more implementation layers, here is yet another level to peel. However, it is the last one. For convenience, the creation of actual VkPipeline objects is encapsulated in VulkanPipelineBuilder, which provides reasonable default values for all the numerous Vulkan data members that we do not want to set. Those familiar with Java will recognize a typical Builder design pattern here.
      lvk::vulkan::VulkanPipelineBuilder()
          // from Vulkan 1.0
          .dynamicState(VK_DYNAMIC_STATE_VIEWPORT)
          .dynamicState(VK_DYNAMIC_STATE_SCISSOR)
          .dynamicState(VK_DYNAMIC_STATE_DEPTH_BIAS)
          .dynamicState(VK_DYNAMIC_STATE_BLEND_CONSTANTS)
          // from Vulkan 1.3
          .dynamicState(VK_DYNAMIC_STATE_DEPTH_TEST_ENABLE)
          .dynamicState(VK_DYNAMIC_STATE_DEPTH_WRITE_ENABLE)
          .dynamicState(VK_DYNAMIC_STATE_DEPTH_COMPARE_OP)
          .dynamicState(VK_DYNAMIC_STATE_DEPTH_BIAS_ENABLE)
          .primitiveTopology(
            topologyToVkPrimitiveTopology(desc.topology))
          .rasterizationSamples(
            getVulkanSampleCountFlags(desc.samplesCount,
              getFramebufferMSAABitMask()), desc.minSampleShading)
          .polygonMode(polygonModeToVkPolygonMode(desc_.polygonMode))
          .stencilStateOps(VK_STENCIL_FACE_FRONT_BIT,
            stencilOpToVkStencilOp(desc_.frontFaceStencil.stencilFailureOp),
            stencilOpToVkStencilOp(desc_.frontFaceStencil.depthStencilPassOp),
            stencilOpToVkStencilOp(desc_.frontFaceStencil.depthFailureOp),
            compareOpToVkCompareOp(desc_.frontFaceStencil.stencilCompareOp))
          .stencilStateOps(VK_STENCIL_FACE_BACK_BIT,
            stencilOpToVkStencilOp(desc_.backFaceStencil.stencilFailureOp),
            stencilOpToVkStencilOp(desc_.backFaceStencil.depthStencilPassOp),
            stencilOpToVkStencilOp(desc_.backFaceStencil.depthFailureOp),
            compareOpToVkCompareOp(desc_.backFaceStencil.stencilCompareOp))
          .stencilMasks(VK_STENCIL_FACE_FRONT_BIT, 0xFF,
            desc_.frontFaceStencil.writeMask,
            desc_.frontFaceStencil.readMask)
          .stencilMasks(VK_STENCIL_FACE_BACK_BIT, 0xFF,
            desc_.backFaceStencil.writeMask,
            desc_.backFaceStencil.readMask)
    
  2. Shader modules are provided one by one. Only the vertex and fragment shaders are mandatory. We skip the other shaders for the sake of brevity.
          .shaderStage(lvk::getPipelineShaderStageCreateInfo(
            VK_SHADER_STAGE_VERTEX_BIT,
            vertModule->sm, desc.entryPointVert, &si))
          .shaderStage(lvk::getPipelineShaderStageCreateInfo(
            VK_SHADER_STAGE_FRAGMENT_BIT,
            fragModule->sm, desc.entryPointFrag, &si))
          .shaderStage(geomModule ?
            lvk::getPipelineShaderStageCreateInfo(
              VK_SHADER_STAGE_GEOMETRY_BIT,
              geomModule->sm, desc.entryPointGeom, &si) :
            VkPipelineShaderStageCreateInfo{ .module = VK_NULL_HANDLE } )
          .cullMode(cullModeToVkCullMode(desc_.cullMode))
          .frontFace(windingModeToVkFrontFace(desc_.frontFaceWinding))
          .vertexInputState(vertexInputStateCreateInfo_)
          .colorAttachments(colorBlendAttachmentStates,
            colorAttachmentFormats, numColorAttachments)
          .depthAttachmentFormat(formatToVkFormat(desc.depthFormat))
          .stencilAttachmentFormat(formatToVkFormat(desc.stencilFormat))
          .patchControlPoints(desc.patchControlPoints)
    
  3. Finally, we call the VulkanPipelineBuilder::build() method, which creates a VkPipeline object and we can store it in our RenderPipelineState structure together with the pipeline layout.
          .build(vkDevice_, pipelineCache_, layout, &pipeline, desc.debugName);
      rps->pipeline_ = pipeline;
      rps->pipelineLayout_ = layout;
      return pipeline;
    }
    

The last method we want to explore here is VulkanPipelineBuilder::build() which is pure Vulkan. Let’s take a look at it to conclude the pipeline creation process.

  1. First, we put provided dynamic states into VkPipelineDynamicStateCreateInfo.
    VkResult VulkanPipelineBuilder::build(
      VkDevice device,
      VkPipelineCache pipelineCache,
      VkPipelineLayout pipelineLayout,
      VkPipeline* outPipeline,
      const char* debugName)
    {
      const VkPipelineDynamicStateCreateInfo dynamicState = {
        .sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO,
        .dynamicStateCount = numDynamicStates_,
        .pDynamicStates = dynamicStates_,
      };
    
  2. The Vulkan specification says viewport and scissor can be nullptr if the viewport and scissor states are dynamic. We are definitely happy to make the most of this opportunity.
      const VkPipelineViewportStateCreateInfo viewportState = {
        .sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO,
        .viewportCount = 1,
        .pViewports = nullptr,
        .scissorCount = 1,
        .pScissors = nullptr,
      };
    
  3. Use the color blend states and attachments we prepared earlier in this recipe. The VkPipelineRenderingCreateInfo is necessary for the Vulkan 1.3 dynamic rendering feature.
      const VkPipelineColorBlendStateCreateInfo colorBlendState = {
        .sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO,
        .logicOpEnable = VK_FALSE,
        .logicOp = VK_LOGIC_OP_COPY,
        .attachmentCount = numColorAttachments_,
        .pAttachments = colorBlendAttachmentStates_,
      };
      const VkPipelineRenderingCreateInfo renderingInfo = {
        .sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO_KHR,
        .colorAttachmentCount = numColorAttachments_,
        .pColorAttachmentFormats = colorAttachmentFormats_,
        .depthAttachmentFormat   = depthAttachmentFormat_,
        .stencilAttachmentFormat = stencilAttachmentFormat_,
      };
    
  4. Put everything together into VkGraphicsPipelineCreateInfo and call vkCreateGraphicsPipelines().
      const VkGraphicsPipelineCreateInfo ci = {
        .sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO,
        .pNext = &renderingInfo,
        .flags = 0,
        .stageCount = numShaderStages_,
        .pStages = shaderStages_,
        .pVertexInputState = &vertexInputState_,
        .pInputAssemblyState = &inputAssembly_,
        .pTessellationState = &tessellationState_,
        .pViewportState = &viewportState,
        .pRasterizationState = &rasterizationState_,
        .pMultisampleState = &multisampleState_,
        .pDepthStencilState = &depthStencilState_,
        .pColorBlendState = &colorBlendState,
        .pDynamicState = &dynamicState,
        .layout = pipelineLayout,
        .renderPass = VK_NULL_HANDLE,
        .subpass = 0,
        .basePipelineHandle = VK_NULL_HANDLE,
        .basePipelineIndex = -1,
      };
      vkCreateGraphicsPipelines(
        device, pipelineCache, 1, &ci, nullptr, outPipeline);
      numPipelinesCreated_++;
    }
    

This code concludes the pipeline creation process. In addition to the very simple example in Chapter02/02_HelloTriangle, we created a slightly more elaborate app in Chapter02/03_GLM to demonstrate how to use multiple render pipelines to render a rotating cube with a wireframe overlay. This app uses the GLM library for matrix math. You can check it out in Chapter02/03_GLM/src/main.cpp, where it uses cmdPushConstants() to animate the cube and specialization constants to use the same set of shaders for both solid and wireframe rendering. It should look as shown in the following screenshot.

Figure 2.3: GLM usage example

There’s more…

If you are familiar with older versions of Vulkan, you might have noticed that in this recipe we completely left out any references to render passes. They are also not mentioned in any of the data structures. The reason for that is that we use Vulkan 1.3 dynamic rendering functionality, which allows VkPipeline objects to operate without needing a render pass.

In case you want to implement a similar wrapper for older versions of Vulkan and without using the VK_KHR_dynamic_rendering extension, you can maintain a “global” collection of render passes in an array inside VulkanContext and add an integer index of a corresponding render pass as a data member to RenderPipelineDynamicState. Since we can use only a very restricted number of distinct rendering passes — let’s say a maximum of 256 — the index can be saved as uint8_t. This would enable us to store them in array inside VulkanContext.

If you want to explore an actual working implementation of this approach, take a look at Meta’s IGL library https://github.com/facebook/igl/blob/main/src/igl/vulkan/RenderPipelineState.h and check out how renderPassIndex is handled there.

Now let’s jump to the next Chapter 3 to learn how to use Vulkan in a user-friendly way to build more interesting examples.

You have been reading a chapter from
Vulkan 3D Graphics Rendering Cookbook - Second Edition
Published in: Feb 2025
Publisher: Packt
ISBN-13: 9781803248110
Register for a free Packt account to unlock a world of extra content!
A free Packt account unlocks extra newsletters, articles, discounted offers, and much more. Start advancing your knowledge today.
Unlock this book and the full library FREE for 7 days
Get unlimited access to 7000+ expert-authored eBooks and videos courses covering every tech area you can think of
Renews at $19.99/month. Cancel anytime
Visually different images