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
.
- 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, {});
- 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 ofVkPipeline
objects, andlvk::Holder
is a RAII wrapper that automatically disposes of handles when they go out of scope. The methodcreateRenderPipeline()
takes aRenderPipelineDesc
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() } } });
- 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();
- 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 passlvk::RenderPass
and a description of a framebufferlvk::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 operationLoadOp_Clear
, which corresponds toVK_ATTACHMENT_LOAD_OP_CLEAR
in Vulkan. The store operation is set toStoreOp_Store
by default.buf.cmdBeginRendering( {.color = {{ .loadOp = LoadOp_Clear, .clearColor = {1,1,1,1} }}}, {.color = {{ .texture = ctx->getCurrentSwapchainTexture() }}});
- 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 ofvkCmdDraw()
. 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 commandcmdEndRendering()
corresponds tovkCmdEndRendering()
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.
- 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]; }
- 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.
- 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;
- 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";
- 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; }
- 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.
- 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;
- 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.
- 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};
- 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 }; } }
- If specialization constants data is provided, copy it out of
RenderPipelineDesc
into local memory storage owned by the pipeline. This simplifiesRenderPipelineDesc
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.
- The
getVkPipeline()
functions retrieves aRenderPipelineState
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;
- 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 oldVkPipeline
andVkPipelineLayout
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_; }
- 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_; }
- 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);
- 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, }; } }
- 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);
- 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, };
- 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);
- Create a suitable
VkPipelineLayout
object for this pipeline. Use the current descriptor set layout stored inVulkanContext
. Here one descriptor set layoutvkDSL_
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
- 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 inVulkanPipelineBuilder
, 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)
- 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)
- Finally, we call the
VulkanPipelineBuilder::build()
method, which creates aVkPipeline
object and we can store it in ourRenderPipelineState
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.
- 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_, };
- 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, };
- 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_, };
- Put everything together into
VkGraphicsPipelineCreateInfo
and callvkCreateGraphicsPipelines()
.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.