P.A. Minerva

18 November 2023

vk01.K - Hello Push and Specialization Constants

by P. A. Minerva


Image


1 - Introduction

This tutorial is provided as a bonus, aiming to illustrate how to use push and specialization constants by reimplementing the sample presented in the last tutorial (01.H - Hello Lighting). To be honest, this tutorial will present a somewhat contrived example that lacks practical application, as the intention here is not to highlight scenarios where using push and specialization constants is particularly convenient. Nevertheless, if your objective is to grasp a practical understanding of how to incorporate these constants into your application, then this tutorial can serve as a valuable starting point.

In an earlier tutorial (01.B - Hello Triangle), we introduced the concepts of push and specialization constants. We explained that push constants are beneficial when you have a small set of data that needs to be frequently updated to be used as a shader resource. This allows to bypass the double indirection typically involved when using descriptors. Specialization constants are handy for defining constant (scalar) values in the compiled SPIR-V module at the time of pipeline object creation.

Before reviewing the code of the sample, let’s revisit the code of the previous one (VKHelloLighting) to identify potential areas where we could implement push and/or specialization constants. The following listing shows the fragment shader used to shade the cube at the center of the scene by using a semplified lambertian shading model.


#version 450

layout (location = 0) in vec3 inNormal;
layout (location = 0) out vec4 outFragColor;

layout(std140, set = 0, binding = 0) uniform buf {
    mat4 View;
    mat4 Projection;
    vec4 lightDirs[2];
    vec4 lightColors[2];
} uBuf;

layout(std140, set = 0, binding = 1) uniform dynbuf {
    mat4 World;
    vec4 solidColor;
} dynBuf;

// Fragment shader applying Lambertian lighting using two directional lights
void main() 
{
    vec4 finalColor = {0.0, 0.0, 0.0, 0.0};
    
    //do N-dot-L lighting for 2 light sources
    for( int i=0; i< 2; i++ )
    {
        finalColor += clamp(dot(uBuf.lightDirs[i].xyz, inNormal) * uBuf.lightColors[i], 0.0, 1.0);
    }
    finalColor.a = 1;

  outFragColor = finalColor;
}


In the first uniform block, the direction of the rotating light source is updated quite frequently, and there is no requirement for either the View or the Projection matrix. Consequently, a logical optimization could be to relocate the lightDirs array (along with the lightColors one, as the shader code makes use of it) to a distinct uniform block to be used as a push constant block.

Additionally, we have a few literal numbers that define the size of the lightDirs and lightColors arrays, as well as the alpha channel of the final color. Consequently, we could define a couple of constants to be specialized (set) during compilation.

It’s important to note that in this analysis, the focus has been solely on identifying areas where push and specialization constants can be applied, without delving into a detailed discussion on their potential impact on performance improvement. Regardless, the following listing shows the final result.


#version 450

layout (location = 0) in vec3 inNormal;
layout (location = 0) out vec4 outFragColor;

layout (constant_id = 0) const uint LIGHT_NUM = 2;
layout (constant_id = 1) const float ALPHA = 0.0;

layout(push_constant) uniform push {
    vec4 lightDirs[LIGHT_NUM];
    vec4 lightColors[LIGHT_NUM];
} pushConsts;


// Fragment shader applying Lambertian lighting using 2 directional lights
void main() 
{
    vec4 finalColor = {0.0, 0.0, 0.0, 0.0};
    
    //do N-dot-L lighting for LIGHT_NUM light sources
    for( int i=0; i< LIGHT_NUM; i++ )
    {
        finalColor += clamp(dot(pushConsts.lightDirs[i].xyz, inNormal) * pushConsts.lightColors[i], 0.0, 1.0);
    }
    finalColor.a = ALPHA;

  outFragColor = finalColor;
}


Now, we can start reviewing the code of the sample that re-implements VKHelloLighting while incorporating both push and specialization constants.



2 - VKHelloPushSpecConstants: code review

Let’s start by examining the definition of the VKHelloPushSpecConstants class.


#define LIGHT_NUM 2

class VKHelloPushSpecConstants : public VKSample
{
public:


    // ...


private:
    

    // ...


    // In the fragment shader:
    //
    // layout(std140, push_constant) uniform push {
    //     vec4 lightDirs[LIGHT_NUM];
    //     vec4 lightColors[LIGHT_NUM];
    // } pushConsts;
    struct PushConsts {
        glm::vec4 lightDirs[LIGHT_NUM];
        glm::vec4 lightColors[LIGHT_NUM];
    } m_pushConstants;

    // In the fragment shader:
    //
    // layout (constant_id = 0) const uint LIGHT_NUM = 2;
    // layout (constant_id = 1) const float ALPHA = 0.0;
    struct SpecConsts {
        uint32_t lightNumber;
        float alphaChannel;
    } m_specConstants;

    // In the vertex shader:
    //
    // layout(std140, set = 0, binding = 0) uniform buf {
    //     mat4 View;
    //     mat4 Projection;
    // } uBuf;
    //
    // This way we can just memcopy the m_uBufVS data to match the uBuf memory layout.
    // Note: You should use data types that align with the GPU in order to avoid manual padding (vec4, mat4)
    struct {
        glm::mat4 viewMatrix;         // 64 bytes
        glm::mat4 projectionMatrix;   // 64 bytes
    } m_uBufVS;

    // Uniform block defined in the vertex and fragment shaders to be used as a dynamic uniform buffer:
    //
    //layout(std140, set = 0, binding = 1) uniform dynbuf {
    //     mat4 World;
    //     vec4 solidColor;
    // } dynBuf;
    //
    // Allow the specification of different world matrices for different objects by offsetting
    // into the same buffer.
    struct MeshInfo{
        glm::mat4 worldMatrix;
        glm::vec4 solidColor;
    };


    // ...

};


As mentioned in tutorial tutorial 01.B - Hello Triangle, push constants are part of the pipeline layout. Therefore, we need to identify some ranges of values within data buffers to be used as push constants for specific programmable stages, as illustrated in the listing below. In this case, we have a single range that covers all the values in the PushConsts structure defined above and that will be used by the fragment shader.

It’s worth noting that we can establish different ranges in the pipeline layout, from different buffers or even the same one, to update the push constants declared in various programmable stages.


void VKHelloPushSpecConstants::CreatePipelineLayout()
{
    // Define a push constant range, which allows to select a subset of values in the buffer specified by calling vkCmdPushConstants
    VkPushConstantRange pushContRange{};
    pushContRange.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
    pushContRange.offset = 0;
    pushContRange.size = sizeof(PushConsts);

    // Create a pipeline layout that will be used to create one or more pipeline objects.
    // In this case we have a pipeline layout with a descriptor set layout and a push constant range.
    VkPipelineLayoutCreateInfo pPipelineLayoutCreateInfo = {};
    pPipelineLayoutCreateInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
    pPipelineLayoutCreateInfo.pNext = nullptr;
    pPipelineLayoutCreateInfo.pushConstantRangeCount  = 1;
    pPipelineLayoutCreateInfo.pPushConstantRanges = &pushContRange;
    pPipelineLayoutCreateInfo.setLayoutCount = 1;
    pPipelineLayoutCreateInfo.pSetLayouts = &m_sampleParams.DescriptorSetLayout;
    
    VK_CHECK_RESULT(vkCreatePipelineLayout(m_vulkanParams.Device, &pPipelineLayoutCreateInfo, nullptr, &m_sampleParams.PipelineLayout));
}


Regarding specialization constants, we only need to specialize (set) the constant values for the shader code where they are declared, before creating the pipeline object, which involves compiling the shader code. In particular, the pSpecializationInfo member of the VkPipelineShaderStageCreateInfo structure is used to specialize constant values in a specific shader during its compilation.


[!NOTE] The layout of arrays declared in a uniform block is static. This implies that their size is determined by the default value defined in the shader code and cannot be modified in any way, such as by setting a different value for a specialization constant used to size arrays defined in uniform blocks. In other words, you cannot use specialization constants to compile different copies of the same shader to use arrays of different dimensions.


Observe that setting the alpha channel using a specialization constant in this sample is quite useless as blending is disabled. However, as previously stated, the objective of this tutorial is to demonstrate how to use push and specialization constants in a graphics application, rather than providing a real-world use case that illustrates when their use is advantageous.


void VKHelloPushSpecConstants::CreatePipelineObjects()
{
    //
    // Construct the different states making up the only graphics pipeline needed by this sample
    //


    // ...

    
    //
    // Shaders
    //
    // This sample will only use two programmable stage: Vertex and Fragment shaders
    std::array<VkPipelineShaderStageCreateInfo, 2> shaderStages{};
    
    // Vertex shader
    shaderStages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
    // Set pipeline stage for this shader
    shaderStages[0].stage = VK_SHADER_STAGE_VERTEX_BIT;
    // Load binary SPIR-V shader module
    shaderStages[0].module = LoadSPIRVShaderModule(m_vulkanParams.Device, GetAssetsPath() + "/data/shaders/main.vert.spv");
    // Main entry point for the shader
    shaderStages[0].pName = "main";
    assert(shaderStages[0].module != VK_NULL_HANDLE);
    
    // Fragment shader
    shaderStages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
    // Set pipeline stage for this shader
    shaderStages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT;
    // Load binary SPIR-V shader module
    shaderStages[1].module = LoadSPIRVShaderModule(m_vulkanParams.Device, GetAssetsPath() + "/data/shaders/lambertian.frag.spv");
    // Main entry point for the shader
    shaderStages[1].pName = "main";
    assert(shaderStages[1].module != VK_NULL_HANDLE);

    //
    // Set specialization constants
    //

    // Each VkSpecializationMapEntry maps a constant ID to an offset into the buffer specified by VkSpecializationInfo::pData
    std::array<VkSpecializationMapEntry, 2> specializationMapEntries;

    // This entry maps constant ID 0 to SpecConsts::lightNumber
    specializationMapEntries[0].constantID = 0;
    specializationMapEntries[0].size = sizeof(SpecConsts::lightNumber);
    specializationMapEntries[0].offset = offsetof(SpecConsts, lightNumber);

    // This entry maps constant ID 1 to SpecConsts::alphaChannel
    specializationMapEntries[1].constantID = 1;
    specializationMapEntries[1].size = sizeof(SpecConsts::alphaChannel);
    specializationMapEntries[1].offset = offsetof(SpecConsts, alphaChannel);

    // Prepare specialization info for the shader stage
    VkSpecializationInfo specializationInfo{};
    specializationInfo.dataSize = sizeof(m_specConstants);
    specializationInfo.mapEntryCount = static_cast<uint32_t>(specializationMapEntries.size());
    specializationInfo.pMapEntries = specializationMapEntries.data();
    specializationInfo.pData = &m_specConstants;

    // Specialization info is assigned as part of the shader stage and must be set after creating the module and before creating the pipeline
    shaderStages[1].pSpecializationInfo = &specializationInfo;
    m_specConstants.lightNumber = LIGHT_NUM; // we cannot change this value to set arrays of different size from the one specified by the default constant value in the shader code
    m_specConstants.alphaChannel = 1.0f; // useless in this sample as it does not use blending though

    //
    // Create the graphics pipelines used in this sample
    //
    
    VkGraphicsPipelineCreateInfo pipelineCreateInfo = {};
    pipelineCreateInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
    // The pipeline layout used for this pipeline (can be shared among multiple pipelines using the same layout)
    pipelineCreateInfo.layout = m_sampleParams.PipelineLayout;
    // Render pass object defining what render pass instances the pipeline will be compatible with
    pipelineCreateInfo.renderPass = m_sampleParams.RenderPass;
    
    // Set pipeline shader stage info
    pipelineCreateInfo.stageCount = static_cast<uint32_t>(shaderStages.size());
    pipelineCreateInfo.pStages = shaderStages.data();
    
    // Assign the pipeline states to the pipeline creation info structure
    pipelineCreateInfo.pVertexInputState = &vertexInputState;
    pipelineCreateInfo.pInputAssemblyState = &inputAssemblyState;
    pipelineCreateInfo.pRasterizationState = &rasterizationState;
    pipelineCreateInfo.pColorBlendState = &colorBlendState;
    pipelineCreateInfo.pMultisampleState = &multisampleState;
    pipelineCreateInfo.pViewportState = &viewportState;
    pipelineCreateInfo.pDepthStencilState = &depthStencilState;
    pipelineCreateInfo.pDynamicState = &dynamicState;
    
    // Create a graphics pipeline for lambertian illumination
    VK_CHECK_RESULT(vkCreateGraphicsPipelines(m_vulkanParams.Device, VK_NULL_HANDLE, 1, &pipelineCreateInfo, nullptr, &m_sampleParams.GraphicsPipelines["Lambertian"]));

    // Specify a different fragment shader
	shaderStages[1].module = LoadSPIRVShaderModule(m_vulkanParams.Device, GetAssetsPath() + "/data/shaders/solid.frag.spv");
    shaderStages[1].pSpecializationInfo = nullptr;
	// Create a graphics pipeline to draw using a solid color
	VK_CHECK_RESULT(vkCreateGraphicsPipelines(m_vulkanParams.Device, VK_NULL_HANDLE, 1, &pipelineCreateInfo, nullptr, &m_sampleParams.GraphicsPipelines["SolidColor"]));
    
    // SPIR-V shader modules are no longer needed once the graphics pipeline has been created
    // since the SPIR-V modules are compiled during pipeline creation.
    vkDestroyShaderModule(m_vulkanParams.Device, shaderStages[0].module, nullptr);
    vkDestroyShaderModule(m_vulkanParams.Device, shaderStages[1].module, nullptr);
}


In PopulateCommandBuffer, we update the direction of the rotating light source and call vkCmdPushConstants to update the values of the push constants declared in the shader code.


void VKHelloPushSpecConstants::PopulateCommandBuffer(uint32_t currentImageIndex)
{

    // ...


    // Render multiple objects by using different pipelines and dynamically offsetting into a uniform buffer
    for (uint32_t j = 0; j < m_numDrawCalls; j++)
    {
        // Dynamic offset used to offset into the uniform buffer described by the dynamic uniform buffer and containing mesh information
        uint32_t dynamicOffset = j * static_cast<uint32_t>(m_dynamicUBOAlignment);

        // Bind the graphics pipeline
        vkCmdBindPipeline(m_sampleParams.FrameRes.GraphicsCommandBuffers[m_frameIndex], 
                            VK_PIPELINE_BIND_POINT_GRAPHICS, 
                            (!j) ? m_sampleParams.GraphicsPipelines["Lambertian"] : m_sampleParams.GraphicsPipelines["SolidColor"]);

        // Bind descriptor sets for drawing a mesh using a dynamic offset
        vkCmdBindDescriptorSets(m_sampleParams.FrameRes.GraphicsCommandBuffers[m_frameIndex], 
                                VK_PIPELINE_BIND_POINT_GRAPHICS, 
                                m_sampleParams.PipelineLayout, 
                                0, 1, 
                                &m_sampleParams.FrameRes.DescriptorSets[m_frameIndex], 
                                1, &dynamicOffset);

        if (!j)
        {
            // Initialize the light direction of the second light source (the rotating one)
            m_pushConstants.lightDirs[1] = {0.0f, -1.0f, 0.0f, 0.0f};

            // Rotate the light direction of the second light source around the z-axis
            glm::mat4 RotZ = glm::rotate(glm::identity<glm::mat4>(), -2.0f * m_curRotationAngleRad, glm::vec3(0.0f, 0.0f, 1.0f));
            m_pushConstants.lightDirs[1] = RotZ * m_pushConstants.lightDirs[1];

            vkCmdPushConstants(m_sampleParams.FrameRes.GraphicsCommandBuffers[m_frameIndex], 
                               m_sampleParams.PipelineLayout, 
                               VK_SHADER_STAGE_FRAGMENT_BIT, 
                               0, sizeof(m_pushConstants), 
                               &m_pushConstants);
        }

        // Draw a cube
        vkCmdDrawIndexed(m_sampleParams.FrameRes.GraphicsCommandBuffers[m_frameIndex], m_vertexindexBuffer.indexBufferCount, 1, 0, 0, 0);
    }
    

    // ...

}


Since we can set one push constant for each programmable stage in different pipeline layouts, as well as multiple ranges from the same buffer or different ones, when calling vkCmdPushConstants we need to specify the buffer containing the push constant values to update, which part of the range to update (start offset and size), the shader stages that use the push constants, and the pipeline layout used to program the updates. This pipeline layout must be compatible with the pipeline layout of the pipeline object bound to the command buffer in order to perform the updates.

The vkCmdPushConstants function copies data from the specified memory buffer into the command buffer, so that the GPU can broadcast values into the push constant blocks defined in shaders, avoiding the double indirection involved when using descriptors to access resource in device memory.

To conclude this tutorial, let’s illustrate with pseudocode a brief example where we have different push constants declared in the vertex and fragment shaders that are backed by the same buffer but from different members.


// VS
layout(push_constant) uniform vsPushConstants {
    uint value_1;
} u_pushConstants;
// FS
layout(push_constant) uniform fsPushConstants {
    layout(offset = 4) float value_2;
} u_pushConstants;


// In C++ application:

struct PushConsts {
    uint32_t value_1;
    float    value_2;
} m_pushConstants;


// ...


VkPushConstantRange pushContRange1{};
pushContRange1.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;
pushContRange1.offset = offsetof(PushConsts, value_1);
pushContRange1.size = sizeof(PushConsts::value_1);

VkPushConstantRange pushContRange2{};
pushContRange2.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
pushContRange2.offset = offsetof(PushConsts, value_2);
pushContRange2.size = sizeof(PushConsts::value_2);


// ...


vkCmdPushConstants(m_sampleParams.FrameRes.GraphicsCommandBuffers[m_frameIndex], 
                    m_sampleParams.PipelineLayout, 
                    VK_SHADER_STAGE_VERTEX_BIT, 
                    offsetof(PushConsts, value_1), sizeof(PushConsts::value_1), 
                    &m_pushConstants);

vkCmdPushConstants(m_sampleParams.FrameRes.GraphicsCommandBuffers[m_frameIndex], 
                    m_sampleParams.PipelineLayout, 
                    VK_SHADER_STAGE_FRAGMENT_BIT, 
                    offsetof(PushConsts, value_2), sizeof(PushConsts::value_2), 
                    &m_pushConstants);


If we have the same push constant block defined in both the vertex and fragment shaders, we can use a single call to vkCmdPushConstants by passing the combined value VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT as a parameter to update the push constant values in both the shaders.



Source code: LearnVulkan


References

[1] Vulkan API Specifications



If you found the content of this tutorial somewhat useful or interesting, please consider supporting this project by clicking on the Sponsor button. Whether a small tip, a one time donation, or a recurring payment, it’s all welcome! Thank you!

Sponsor