Providing data from your application that will be used in shaders is one of the most convoluted aspects of Vulkan and requires several steps that need to be accomplished in the right order (and with the right parameters). In this recipe, with many smaller recipes, you will learn how to provide data used in shaders, such as textures, buffers, and samplers.
Getting ready
Resources consumed by shaders are specified using the layout
keyword, along with set
and binding
qualifiers:
layout(set = 0, binding=0) uniform Transforms
{
mat4 model;
mat4 view;
mat4 projection;
} MVP;
Each resource is represented by a binding. A set is a collection of bindings. One binding doesn’t necessarily represent just one resource; it can also represent an array of resources of the same type.
How to do it…
Providing a resource as input to shaders is a multi-step process that involves the following:
- Specifying sets and their bindings using descriptor set layouts. This step doesn’t associate real resources with sets/bindings. It just specifies the number and types of bindings in a set.
- Building a pipeline layout, which describes which sets will be used in a pipeline.
- Creating a descriptor pool that will provide instances of descriptor sets. A descriptor pool contains a list of how many bindings it can provide grouped by binding type (texture, sampler, shader storage buffer (SSBO), uniform buffers).
- Allocate descriptor sets from the pool with
vkAllocateDescriptorSets
.
- Bind resources to bindings using
vkUpdateDescriptorSets
. In this step, we associate a real resource (a buffer, a texture, and so on) with a binding.
- Bind descriptor sets and their bindings to a pipeline during rendering using
vkCmdBindDescriptorSet
. This step makes resources bound to their set/bindings in the previous step available to shaders in the current pipeline.
The next recipes will show you how to perform each one of those steps.
Specifying descriptor sets with descriptor set layouts
Consider the following GLSL code, which specifies several resources:
struct Vertex {
vec3 pos;
vec2 uv;
vec3 normal;
};
layout(set = 0, binding=0) uniform Transforms
{
mat4 model;
mat4 view;
mat4 projection;
} MVP;
layout(set = 1, binding = 0) uniform texture2D textures[];
layout(set = 1, binding = 1) uniform sampler samplers[];
layout(set = 2, binding = 0) readonly buffer VertexBuffer
{
Vertex vertices[];
} vertexBuffer;
The code requires three sets (0, 1, and 2), so we need to create three descriptor set layouts. In this recipe, you will learn how to create a descriptor set layout for the preceding code.
Getting ready
Descriptor sets and bindings are created, stored, and managed by the VulkanCore::Pipeline
class in the repository. A descriptor set in Vulkan acts as a container that holds resources, such as buffers, textures, and samplers, for use by shaders. Binding refers to the process of associating these descriptor sets with specific shader stages, enabling seamless interaction between shaders and resources during rendering. These descriptor sets serve as gateways through which resources are seamlessly bound to shader stages, orchestrating harmony between data and shader execution. To facilitate this synergy, the class simplifies descriptor set creation and management, complemented by methods for efficient resource binding within the Vulkan rendering pipeline.
How to do it…
A descriptor set layout states its bindings (number and types) with the vkDescriptorSetLayout
structure. Each binding is described using an instance of the vkDescriptorSetLayoutBinding
structure. The relationship between the Vulkan structures needed to create a descriptor set layout for the preceding code is shown in Figure 2.11:
Figure 2.11 – Illustrating the configuration of descriptor set layouts for GLSL shaders
The following code shows how to specify two bindings for set 1, which are stored in a vector of bindings:
constexpr uint32_t kMaxBindings = 1000;
const VkDescriptorSetLayoutBinding texBinding = {
.binding = 0,
.descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE,
.descriptorCount = kMaxBindings,
.stageFlags = VK_SHADER_STAGE_VERTEX_BIT,
};
const VkDescriptorSetLayoutBinding samplerBinding = {
.binding = 1,
.descriptorType = VK_DESCRIPTOR_TYPE_SAMPLER,
.descriptorCount = kMaxBindings,
.stageFlags = VK_SHADER_STAGE_VERTEX_BIT,
};
struct SetDescriptor {
uint32_t set_;
std::vector<VkDescriptorSetLayoutBinding> bindings_;
};
std::vector<SetDescriptor> sets(1);
sets[0].set_ = 1;
sets[0].bindings_.push_back(texBinding);
sets[0].bindings_.push_back(samplerBinding);
Since each binding describes a vector, and the VkDescriptorSetLayoutBinding
structure requires the number of descriptors, we are using a large number that hopefully will accommodate all elements we need in the array. The vector of bindings is stored in a structure that describes a set with its number and all its bindings. This vector will be used to create a descriptor set layout:
constexpr VkDescriptorBindingFlags flagsToEnable =
VK_DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT |
VK_DESCRIPTOR_BINDING_UPDATE_UNUSED_WHILE_PENDING_BIT;
for (size_t setIndex = 0;
const auto& set : sets) {
std::vector<VkDescriptorBindingFlags> bindFlags(
set.bindings_.size(), flagsToEnable);
const VkDescriptorSetLayoutBindingFlagsCreateInfo
extendedInfo{
.sType =
VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_BINDING_FLAGS_CREATE_INFO,
.pNext = nullptr,
.bindingCount = static_cast<uint32_t>(
set.bindings_.size()),
.pBindingFlags = bindFlags.data(),
};
const VkDescriptorSetLayoutCreateInfo dslci = {
.sType =
VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
.pNext = &extendedInfo,
.flags =
VK_DESCRIPTOR_SET_LAYOUT_CREATE_UPDATE_AFTER_BIND_POOL_BIT_EXT,
.bindingCount =
static_cast<uint32_t>(set.bindings_.size()),
.pBindings = set.bindings_.data(),
};
VkDescriptorSetLayout descSetLayout{VK_NULL_HANDLE};
VK_CHECK(vkCreateDescriptorSetLayout(
context_->device(), &dslci, nullptr,
&descSetLayout));
}
Each set requires its own descriptor set layout, and the preceding process needs to be repeated for each one. The descriptor set layout needs to be stored so that it can be referred to in the future.
Passing data to shaders using push constants
Push constants are another way to pass data to shaders. Although a very performant and easy way to do so, push constants are very limited in size, 128 bytes being the only guaranteed amount by the Vulkan specification.
This recipe will show you how to pass a small amount of data from your application to shaders, using push constants for a simple shader.
Getting ready
Push constants are stored and managed by the VulkanCore::Pipeline
class.
How to do it…
Push constants are recorded directly onto the command buffer and aren’t prone to the same synchronization issues that exist with other resources. They are declared in the shader as follows, with one maximum block per shader:
layout (push_constant) uniform Transforms {
mat4 model;
} PushConstants;
The pushed data must be split into the shader stages. Parts of it can be assigned to different shader stages or assigned to one single stage. The important part is that the data cannot be greater than the total amount available for push constants. The limit is provided in VkPhysicalDeviceLimits::maxPushConstantsSize
.
Before using push constants, we need to specify how many bytes we are using in each shader stage:
const VkPushConstantRange range = {
.stageFlags = VK_SHADER_STAGE_VERTEX_BIT,
.offset = 0,
.size = 64,
};
std::vector<VkPushConstantRange> pushConsts;
pushConsts.push_back(range);
The code states that the first (offset == 0
) 64
bytes of the push constant data recorded in the command buffer (the size of a 4x4 matrix of floats) will be used by the vertex shader. This structure will be used in the next recipe to create a pipeline layout object.
Creating a pipeline layout
A pipeline layout is an object in Vulkan that needs to be created and destroyed by the application. The layout is specified using structures that define the layout of bindings and sets. In this recipe, you will learn how to create a pipeline layout.
Getting ready
A VkPipelineLayoutCreateInfo
instance is created automatically by the VulkanCore::Pipeline
class in the repository based on information provided by the application using a vector of VulkanCore::Pipeline::SetDescriptor
structures.
How to do it…
With all descriptor set layouts for all sets and the push constant information in hand, the next step consists of creating a pipeline layout:
std::vector<VkDescriptoSetLayout> descLayouts;
const VkPipelineLayoutCreateInfo pipelineLayoutInfo = {
.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO,
.setLayoutCount = (uint32_t)descLayouts.size(),
.pSetLayouts = descLayouts.data(),
.pushConstantRangeCount =
!pushConsts.empty()
? static_cast<uint32_t>(pushConsts.size())
: 0,
.pPushConstantRanges = !pushConsts.empty()
? pushConsts.data()
: nullptr,
};
VkPipelineLayout pipelineLayout{VK_NULL_HANDLE};
VK_CHECK(vkCreatePipelineLayout(context_->device(),
&pipelineLayoutInfo,
nullptr,
&pipelineLayout));
Once you have the descriptor set layout in hand and know how to use the push constants in your application, creating a pipeline layout is straightforward.
Creating a descriptor pool
A descriptor pool contains a maximum number of descriptors it can provide (be allocated from), grouped by binding type. For instance, if two bindings of the same set require one image each, the descriptor pool would have to provide at least two descriptors. In this recipe, you will learn how to create a descriptor pool.
Getting ready
Descriptor pools are allocated in the VulkanCore::Pipeline::
initDescriptorPool()
method.
How to do it…
Creating a descriptor pool is straightforward. All we need is a list of binding types and the maximum number of resources we’ll allocate for each one:
constexpr uint32_t swapchainImages = 3;
std::vector<VkDescriptorPoolSize> poolSizes;
poolSizes.emplace_back(VkDescriptorPoolSize{
VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE,
swapchainImages* kMaxBindings});
poolSizes.emplace_back(VkDescriptorPoolSize{
VK_DESCRIPTOR_TYPE_SAMPLER,
swapchainImages* kMaxBindings});
Since we duplicate the resources based on the number of swapchain images to avoid data races between the CPU and the GPU, we multiply the number of bindings we requested before (kMaxBindings = 1000
) by the number of swapchain images:
const VkDescriptorPoolCreateInfo descriptorPoolInfo = {
.sType =
VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO,
.flags =
VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT |
VK_DESCRIPTOR_POOL_CREATE_UPDATE_AFTER_BIND_BIT,
.maxSets = MAX_DESCRIPTOR_SETS,
.poolSizeCount =
static_cast<uint32_t>(poolSizes.size()),
.pPoolSizes = poolSizes.data(),
};
VkDescriptorPool descriptorPool{VK_NULL_HANDLE};
VK_CHECK(vkCreateDescriptorPool(context_->device(),
&descriptorPoolInfo,
nullptr,
&descriptorPool));
Be careful not to create pools that are too large. Achieving a high-performing application means not allocating more resources than you need.
Allocating descriptor sets
Once a descriptor layout and a descriptor pool have been created, before you can use it, you need to allocate a descriptor set, which is an instance of a set with the layout described by the descriptor layout. In this recipe, you will learn how to allocate a descriptor set.
Getting ready
Descriptor set allocations are done in the VulkanCore::Pipeline:: allocateDescriptors()
method. Here, developers define the count of descriptor sets required, coupled with binding counts per set. The subsequent bindDescriptorSets()
method weaves the descriptors into command buffers, preparing them for shader execution.
How to do it…
Allocating a descriptor set (or a number of them) is easy. You need to fill the VkDescriptorSetAllocateInfo
structure and call vkAllocateDescriptorSets
:
VkDescriptorSetAllocateInfo allocInfo = {
.sType =
VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO,
.descriptorPool = descriptorPool,
.descriptorSetCount = 1,
.pSetLayouts = &descSetLayout,
};
VkDescriptorSet descriptorSet{VK_NULL_HANDLE};
VK_CHECK(vkAllocateDescriptorSets(context_->device(),
&allocInfo,
&descriptorSet));
When using multiple copies of a resource to avoid race conditions, there are two approaches:
- Allocate one descriptor set for each resource. In other words, call the preceding code once for each copy of the resource.
- Create one descriptor set and update it every time you need to render.
Updating descriptor sets during rendering
Once a descriptor set has been allocated, it is not associated with any resources. This association must happen once (if your descriptor sets are immutable) or every time you need to bind a different resource to a descriptor set. In this recipe, you will learn how to update descriptor sets during rendering and after you have set up the pipeline and its layout.
Getting ready
In the repository, VulkanCore::Pipeline
provides methods to update different types of resources, as each binding can only be associated with one type of resource (image, sampler, or buffer): updateSamplersDescriptorSets()
, updateTexturesDescriptorSets()
, and updateBuffersDescriptorSets
()
.
How to do it…
Associating a resource with a descriptor set is done with the vkUpdateDescriptorSets
function. Each call to vkUpdateDescriptorSets
can update one or more bindings of one or more sets. Before updating a descriptor set, let’s look at how to update one binding.
You can associate either a texture, a texture array, a sampler, a sampler array, a buffer, or a buffer array with one binding. To associate images or samplers, use the VkDescriptorImageInfo
structure. To associate buffers, use the VkDescriptorBufferInfo
structure. Once one or more of those structures have been instantiated, use the VkWriteDescriptorSet
structure to bind them all with a binding. Bindings that represent an array are updated with a vector of VkDescriptor*Info
.
- Consider the bindings declared in the shader code presented next:
layout(set = 1, binding = 0) uniform texture2D textures[];
layout(set = 1, binding = 1) uniform sampler samplers[];
layout(set = 2, binding = 0) readonly buffer VertexBuffer
{
Vertex vertices[];
} vertexBuffer;
- To update the
textures[]
array, we need to create two instances of VkDescriptorImageInfo
and record them in the first VkWriteDescriptorSet
structure:VkImageView imageViews[2]; // Valid Image View objects
VkDescriptorImageInfo texInfos[] = {
VkDescriptorImageInfo{
.imageView = imageViews[0],
.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
},
VkDescriptorImageInfo{
.imageView = imageViews[1],
.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
},
};
const VkWriteDescriptorSet texWriteDescSet = {
.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
.dstSet = 1,
ee,
.dstArrayElement = 0,
.descriptorCount = 2,
.descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE,
.pImageInfo = &texInfos,
.pBufferInfo = nullptr,
};
- The two image views will be bound to set 1 (
.dstSet = 1
) and binding 0 (.dstBinding = 0
) as elements 0 and 1 of the array. If you need to bind more objects to the array, all you need are more instances of VkDescriptorImageInfo
. The number of objects bound to the current binding is specified by the descriptorCount
member of the structure.The process is similar for sampler objects:
VkSampler sampler[2]; // Valid Sampler object
VkDescriptorImageInfo samplerInfos[] = {
VkDescriptorImageInfo{
.sampler = sampler[0],
},
VkDescriptorImageInfo{
.sampler = sampler[1],
},
};
const VkWriteDescriptorSet samplerWriteDescSet = {
.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
.dstSet = 1,
.dstBinding = 1,
.dstArrayElement = 0,
.descriptorCount = 2,
.descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE,
.pImageInfo = &samplerInfos,
.pBufferInfo = nullptr,
};
This time, we are binding the sampler objects to set 1, binding 1. Buffers are bound using the VkDescriptorBufferInfo
structure:
VkBuffer buffer; // Valid Buffer object
VkDeviceSize bufferLength; // Range of the buffer
const VkDescriptorBufferInfo bufferInfo = {
.buffer = buffer,
.offset = 0,
.range = bufferLength,
};
const VkWriteDescriptorSet bufferWriteDescSet = {
.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
.dstSet = 2,
.dstBinding = 0,
.dstArrayElement = 0,
.descriptorCount = 1,
.descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE,
.pImageInfo = nullptr,
.pBufferInfo = &bufferInfo,
};
Besides storing the address of the bufferInfo
variable to the .pBufferInfo
member of VkWriteDescriptorSet
, we are binding one buffer (.descriptorCount = 1
) to set 2 (.dstSet = 2
) and binding 0
(.dstBinding =
0
).
- The last step consists of storing all
VkWriteDescriptorSet
instances in a vector and calling vkUpdateDescriptorSets
:VkDevice device; // Valid Vulkan Device
std::vector<VkWriteDescriptorSet> writeDescSets;
writeDescSets.push_back(texWriteDescSet);
writeDescSets.push_back(samplerWriteDescSet);
writeDescSets.push_back(bufferWriteDescSet);
vkUpdateDescriptorSets(device, static_cast<uint32_t>(writeDescSets.size()),
writeDescSets.data(), 0, nullptr);
Encapsulating this task is the best way to avoid repetition and bugs introduced by forgetting a step in the update procedure.
Passing resources to shaders (binding descriptor sets)
While rendering, we need to bind the descriptor sets we’d like to use during a draw call.
Getting ready
Binding sets is done with the VulkanCore::Pipeline::
bindDescriptorSets()
method.
How to do it…
To bind a descriptor set for rendering, we need to call vkCmdBindDescriptorSets
:
VkCommandBuffer commandBuffer; // Valid Command Buffer
VkPipelineLayout pipelineLayout; // Valid Pipeline layout
uint32_t set; // Set number
VkDescriptorSet descSet; // Valid Descriptor Set
vkCmdBindDescriptorSets(
commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS,
pipelineLayout, set, 1u, &descSet, 0, nullptr);
Now that we’ve successfully bound a descriptor set for rendering, let’s turn our attention to another crucial aspect of our graphics pipeline: updating push constants.
Updating push constants during rendering
Push constants are updated during rendering by recording their values directly into the command buffer being recorded.
Getting ready
Updating push constants is done with the VulkanCore::Pipeline::
udpatePushConstants()
method.
How to do it…
Once rendered, updating push constants is straightforward. All you need to do is call vkCmdPushConstants
:
VkCommandBuffer commandBuffer; // Valid Command Buffer
VkPipelineLayout pipelineLayout; // Valid Pipeline Layout
glm::vec4 mat; // Valid matrix
vkCmdPushConstants(commandBuffer, pipelineLayout,
VK_SHADER_STAGE_FRAGMENT_BIT, 0,
sizeof(glm::vec4), &mat);
This call records the contents of mat
into the command buffer, starting at offset 0 and signaling that this data will be used by the vertex shader.