top of page

Vulkan Descriptor Sets for OpenGL Programmers

Writer's picture: riskrisk

I was having some trouble wrapping my mind around descriptor sets and how the fit into Vulkan, and what the OpenGL equivalent is. So, here is the best explanation I can come up with.


In Vulkan, a descriptor is a way to pass data from your application to the shaders running on the GPU. This data can be things like textures, uniform buffers, or storage buffers. A descriptor set, then, is a collection of these descriptors.


To understand descriptor sets, it's helpful to compare them to the way things work in OpenGL. In OpenGL, you can bind textures or buffers to specific slots, and then your shader can access data from those slots. For example, you might bind a texture to texture unit 0, and then in your shader, you can sample from texture unit 0 to get the color of a pixel.


In Vulkan, instead of binding textures or buffers to slots directly, you create a descriptor set that describes all the resources your shader needs. This descriptor set is then bound to the pipeline when you're ready to render. The shader can then access these resources based on the descriptor set layout that was defined when the pipeline was created.

Here's a simple example to illustrate this. Let's say you have a shader that needs a texture and a uniform buffer. In OpenGL, you might do something like this:


glBindTextureUnit(0, texture); 
glBindBufferBase(GL_UNIFORM_BUFFER, 1, uniformBuffer);

And then in your shader, you would do something like this:



layout(binding = 0) uniform sampler2D myTexture;
layout(std140, binding = 1) uniform MyUniformBuffer {
    mat4 modelViewProjectionMatrix;
};

In Vulkan, you would instead create a descriptor set layout that describes the resources your shader needs:


VkDescriptorSetLayoutBinding bindings[2] = {};

bindings[0].binding = 0;
bindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
bindings[0].descriptorCount = 1;
bindings[0].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;

bindings[1].binding = 1;
bindings[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
bindings[1].descriptorCount = 1;
bindings[1].stageFlags = VK_SHADER_STAGE_VERTEX_BIT;

VkDescriptorSetLayoutCreateInfo layoutInfo = {};
layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
layoutInfo.bindingCount = 2;
layoutInfo.pBindings = bindings;

vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &descriptorSetLayout);

Then, when you're ready to render, you would create a descriptor set that matches this layout and bind it to the pipeline:



VkDescriptorSetAllocateInfo allocInfo = {};
allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
allocInfo.descriptorPool = descriptorPool;
allocInfo.descriptorSetCount = 1;
allocInfo.pSetLayouts = &descriptorSetLayout;

vkAllocateDescriptorSets(device, &allocInfo, &descriptorSet);

VkDescriptorImageInfo imageInfo = {};
imageInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
imageInfo.imageView = textureImageView;
imageInfo.sampler = textureSampler;

VkDescriptorBufferInfo bufferInfo = {};
bufferInfo.buffer = uniformBuffer;
bufferInfo.offset = 0;
bufferInfo.range = sizeof(UniformBufferObject);

VkWriteDescriptorSet descriptorWrites[2] = {};

descriptorWrites[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descriptorWrites[0].dstSet = descriptorSet;
descriptorWrites[0].dstBinding = 0;
descriptorWrites[0].dstArrayElement = 0;
descriptorWrites[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
descriptorWrites[0].descriptorCount = 1;
descriptorWrites[0].pImageInfo = &imageInfo;

descriptorWrites[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descriptorWrites[1].dstSet = descriptorSet;
descriptorWrites[1].dstBinding = 1;
descriptorWrites[1].dstArrayElement = 0;
descriptorWrites[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
descriptorWrites[1].descriptorCount = 1;
descriptorWrites[1].pBufferInfo = &bufferInfo;

vkUpdateDescriptorSets(device, 2, descriptorWrites, 0, nullptr);

In the shader:

layout(binding = 0) uniform sampler2D myTexture;
layout(std140, binding = 1) uniform MyUniformBuffer {
    mat4 modelViewProjectionMatrix;
};

The key difference is that in Vulkan, instead of binding each resource individually, you're creating a descriptor set that describes all the resources your shader needs and binding that to the pipeline. This can be more efficient because it allows the GPU to know in advance all the resources that will be needed for a draw call, which can help it optimize resource usage.


  1. Descriptor: In Vulkan, a descriptor is a way to pass data from your application to the shaders running on the GPU. This data can be things like textures, uniform buffers, or storage buffers. Essentially, a descriptor is a reference to a resource that a shader can use.

  2. Descriptor Set: A descriptor set is a collection of descriptors. When you're ready to render, you bind a descriptor set to the pipeline, and the shaders can then access the resources described by the descriptors in the set.

  3. Descriptor Pool: A descriptor pool is a chunk of memory from which descriptor sets are allocated. When you create a descriptor pool, you specify how many descriptor sets, and what types and quantities of descriptors, the pool can accommodate. When you allocate a descriptor set, it comes from a descriptor pool.

Key things to remember:

  • Descriptor sets are allocated from a descriptor pool and typically reside in GPU VRAM. Once a descriptor set is used in a draw call, it can't be modified unless you've set a specific flag (VK_DESCRIPTOR_POOL_CREATE_UPDATE_AFTER_BIND_BIT).

  • When you create a descriptor pool, you specify the maximum number of descriptor sets and descriptors it can hold. If you try to allocate more than this, the allocation will fail. One strategy to handle this is to create a new descriptor pool when the old one runs out of space.

  • Allocating descriptor sets can be made more efficient by disallowing the freeing of individual sets (VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT). This can allow the driver to use a simple linear allocator for the pool, which can be faster. If you're allocating descriptor sets every frame, you can use this flag and then reset the entire pool at the end of the frame, rather than freeing individual sets.

  • A common technique in production engines is to have a separate descriptor pool for each frame. When a pool runs out of space, a new one is created. When a frame is finished, all the pools used for that frame are reset.

The descriptor pool in Vulkan manages the memory for the descriptor sets and the descriptors themselves, but it does not manage the memory for the resources that the descriptors refer to.


In other words, when you allocate a descriptor set from a descriptor pool, the memory for the descriptor set and its descriptors is allocated from the pool. However, the actual resources that the descriptors refer to, such as buffers and images, are not stored in the descriptor pool. Those resources have their own separate memory management.


For example, if you have a descriptor that refers to a texture, the descriptor pool manages the memory for the descriptor itself, but the texture data is stored separately in GPU memory. The descriptor is essentially a reference or a pointer to the texture data.


This separation of concerns allows Vulkan to be more flexible and efficient. The descriptor pool can focus on efficiently managing the memory for descriptors, while the actual resources can be managed in a way that's appropriate for their specific needs.

I hope this helps!


Additional resources: https://vkguide.dev/docs/chapter-4/descriptors/

25 views0 comments

Recent Posts

See All

Comments


PlayTheory®

bottom of page