教程 7:统一缓冲区 - 在着色器中使用缓冲区
返回上一个教程Vulkan 简介第 6 部分 – 描述符集
现在我们来总结一下目前介绍的知识并创建一个更典型的渲染场景。这里我们将看到一个非常简单的例子,但它反映了在屏幕上显示 3D 几何图形的最常用方法。我们将在着色器统一数据中添加变换矩阵,对上一个教程的代码进行扩展。这样我们可以看到如何在一个描述符集中使用多个不同的描述符。
当然,这里介绍的知识适用于许多其他用例,因为描述符集可能包含多种类型的资源,既有不同的也有相同的。我们将创建一个具有许多存储缓冲区或采样图像的描述符集。我们也可以将它们混合起来,如本课程所示。这里我们使用纹理(组合图像采样器)和统一缓冲区。我们将了解如何为这样的描述符创建布局,如何创建描述符集以及如何用适当的资源进行填充。
在本教程的前一部分中,我们学习了如何创建图像并将其用作着色器中的纹理。本教程也需要用到这一知识,但我们在本教程中只关注缓冲区,并学习如何将它们作为统一数据源。我们还将了解如何准备投影矩阵,如何将其复制到缓冲区,以及如何在着色器中访问它。
创建统一缓冲区
在这个例子中,我们希望在着色器中使用两种类型的统一变量:组合图像采样器(sampler2D 内部着色器)和统一投影矩阵 (mat4)。在 Vulkan* 中,统一变量(采样器等不透明类型除外)不能在全局范围内声明(如在 OpenGL* 中); 它们必须从统一缓冲区内访问。我们首先创建一个缓冲区。
缓冲区可用于许多不同用途。它们可以是顶点数据的来源(顶点属性);我们可以在缓冲区中保留顶点索引,以便它们作为索引缓冲区;它们可以包含着色器统一数据,或者我们可以将数据存储在着色器内的缓冲区,并将它们作为存储缓冲区。我们甚至可以将格式化数据保存在缓冲区中,通过缓冲区视图访问,并将它们视为 texel 缓冲区(类似于 OpenGL 的缓冲区纹理)。为实现上述所有目的,我们使用始终以相同方式创建的缓冲区。但是在缓冲区创建期间提供的用法定义了我们如何在其生命周期中使用既定缓冲区。
我们在“Vulkan 简介第 4 部分 – 顶点属性”中介绍了如何创建缓冲区,因此此处只提供源代码,而没有探究细节:
VkBufferCreateInfo buffer_create_info = { VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, // VkStructureType sType nullptr, // const void *pNext 0, // VkBufferCreateFlags flags buffer.Size, // VkDeviceSize size usage, // VkBufferUsageFlags usage VK_SHARING_MODE_EXCLUSIVE, // VkSharingMode sharingMode 0, // uint32_t queueFamilyIndexCount nullptr // const uint32_t *pQueueFamilyIndices }; if( vkCreateBuffer( GetDevice(), &buffer_create_info, nullptr, &buffer.Handle ) != VK_SUCCESS ) { std::cout << "Could not create buffer!"<< std::endl; return false; } if( !AllocateBufferMemory( buffer.Handle, memoryProperty, &buffer.Memory ) ) { std::cout << "Could not allocate memory for a buffer!"<< std::endl; return false; } if( vkBindBufferMemory( GetDevice(), buffer.Handle, buffer.Memory, 0 ) != VK_SUCCESS ) { std::cout << "Could not bind memory to a buffer!"<< std::endl; return false; } return true;
1.Tutorial07.cpp, function CreateBuffer()
我们首先在 VkBufferCreateInfo 类型的变量中定义其参数,创建缓冲区。在这里我们定义了缓冲区最重要的参数,缓冲区的大小和用途。接下来,我们通过调用 vkCreateBuffer()函数来创建缓冲区。之后,我们需要通过 vkBindBufferMemory()函数调用分配一个内存对象(或使用另一个现有内存对象的一部分),将其绑定到缓冲区。只有这样,我们才能在应用中按照我们希望的方式使用缓冲区。分配专用内存对象的步骤如下:
VkMemoryRequirements buffer_memory_requirements; vkGetBufferMemoryRequirements( GetDevice(), buffer, &buffer_memory_requirements ); VkPhysicalDeviceMemoryProperties memory_properties; vkGetPhysicalDeviceMemoryProperties( GetPhysicalDevice(), &memory_properties ); for( uint32_t i = 0; i < memory_properties.memoryTypeCount; ++i ) { if( (buffer_memory_requirements.memoryTypeBits & (1 << i)) && (memory_properties.memoryTypes[i].propertyFlags & property) ) { VkMemoryAllocateInfo memory_allocate_info = { VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, // VkStructureType sType nullptr, // const void *pNext buffer_memory_requirements.size, // VkDeviceSize allocationSize i // uint32_t memoryTypeIndex }; if( vkAllocateMemory( GetDevice(), &memory_allocate_info, nullptr, memory ) == VK_SUCCESS ) { return true; } } } return false;
2.Tutorial07.cpp, function AllocateBufferMemory()
若要创建可作为着色器统一数据源的缓冲区,我们需要创建一个具有 VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT 用途的缓冲区。不过,我们可能也需要其他用途,这取决于我们想要如何将数据传输到缓冲区。这里我们希望使用一个绑定设备本地内存的缓冲区,因为此类内存的性能可能更高。不过,根据硬件的架构,可能无法直接从 CPU 映射这些内存并将数据复制到其中。这就是为何我们要使用分段缓冲区,我们要通过这个分段缓冲区将数据从 CPU 复制到统一缓冲区。为此,我们的统一缓冲区也必须使用 VK_BUFFER_USAGE_TRANSFER_DST_BIT 用途创建,因为它将成为数据复制操作的目标。下面,我们可以看到我们的缓冲区是如何创建的:
Vulkan.UniformBuffer.Size = 16 * sizeof(float); if( !CreateBuffer( VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, Vulkan.UniformBuffer ) ) { std::cout << "Could not create uniform buffer!"<< std::endl; return false; } if( !CopyUniformBufferData() ) { return false; } return true;
3.Tutorial07.cpp, function CreateUniformBuffer()
将数据复制到缓冲区
接下来就是将适当的数据上传到统一缓冲区。我们将在缓冲区中存储 4 x 4 矩阵的 16 个元素。我们使用的是正交投影矩阵,但我们可以存储任何其他类型的数据;我们只需要记住,每个统一变量必须放置在适当的偏移上,从缓冲区的内存开始计数。这种偏移必须是特定值的倍数。换句话说,它必须与特定值对齐,或者它必须有特定的对齐方式。每个统一变量的对齐方式取决于变量的类型,规范将其定义如下:
- 类型有 N 个字节的标量变量必须与是 N 的倍数的地址对齐。
- 具有两个大小为 N(其类型有 N 个字节)的元素的矢量必须对齐到 2 N。
- 具有三个或四个大小为 N 的元素的矢量必须对齐到 4 N。
- 数组的对齐方式按照其元素的对齐方式进行计算,四舍五入为 16 的倍数。
- 结构的对齐方式按照其任意成员的最大对齐方式进行计算,四舍五入为 16 的倍数。
- 具有 C 列的行主矩阵的对齐方式等同于具有与矩阵元素相同类型 C 元素的矢量的对齐方式。
- 列主矩阵的对齐方式与矩阵列类型的对齐方式相同。
上述规则与为标准 GLSL 140 布局定义的规则类似,我们也可以将其应用于 Vulkan 的统一缓冲区。但我们需要记住,将数据置于不适当的偏移会导致在着色器中获取不正确的值。
为了简单起见,我们的示例只有一个统一变量,因此它可以放在缓冲区的最开始。为了传输数据,我们将使用分段缓冲区。它是通过VK_BUFFER_USAGE_TRANSFER_SRC_BIT
用途创建的,并由支持 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT
属性的内存提供支持,因此我们可以映射。下面,我们可以看到数据如何被复制到分段缓冲区:
const std::array<float, 16> uniform_data = GetUniformBufferData(); void *staging_buffer_memory_pointer; if( vkMapMemory( GetDevice(), Vulkan.StagingBuffer.Memory, 0, Vulkan.UniformBuffer.Size, 0, &staging_buffer_memory_pointer) != VK_SUCCESS ) { std::cout << “Could not map memory and upload data to a staging buffer!”<< std::endl; return false; } memcpy( staging_buffer_memory_pointer, uniform_data.data(), Vulkan.UniformBuffer.Size ); VkMappedMemoryRange flush_range = { VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE, // VkStructureType sType nullptr, // const void *pNext Vulkan.StagingBuffer.Memory, // VkDeviceMemory memory 0, // VkDeviceSize offset Vulkan.UniformBuffer.Size // VkDeviceSize size }; vkFlushMappedMemoryRanges( GetDevice(), 1, &flush_range ); vkUnmapMemory( GetDevice(), Vulkan.StagingBuffer.Memory );
4.Tutorial07.cpp, function CopyUniformBufferData()
首先,我们准备投影矩阵数据。它存储在一个 std:: 数组中,但我们可以将它保存在其他任何类型的变量中。接下来,我们映射绑定到分段缓冲区的内存。我们需要访问至少与我们想要复制的数据一样大小的内存,所以我们还需要创建足够大的暂存缓冲区来保存它。接下来,我们使用普通的 memcpy() 函数将数据复制到分段缓冲区。现在,我们必须告诉驱动程序缓冲区内存的哪些部分发生了变化;这个操作被称为刷新。之后,我们取消映射暂存缓冲区的内存。请记住,频繁的映射和取消映射可能会影响我们应用的性能。在 Vulkan 中,资源可以随时进行映射,这不会对我们的应用造成任何影响。如果我们想要使用分段资源频繁传输数据,我们应该只映射一次,并保留获取的指针以供将来使用。我们将取消映射,为您展示如何操作。
现在我们需要将数据从暂存缓冲区传输到我们的目标——统一缓冲区。为此,我们需要准备一个命令缓冲器,在其中记录适当的操作并提交这些操作。
我们从未使用的任何命令缓冲区开始。它必须从针对支持传输操作的队列创建的池中分配。Vulkan 规范要求至少有一个可用的通用队列——一个支持图形(渲染)、计算和传输操作的队列。在英特尔® 硬件中,只有一个队列系列包含一个通用队列,所以我们没有这个问题。其他硬件厂商可能支持其他类型的队列系列,甚至可能支持专门用于数据传输的队列系列。在这种情况下,我们应该从这样的系列中选择一个队列。
我们通过调用 vkBeginCommandBuffer()函数开始记录命令缓冲区。接下来,我们记录执行数据传输的 vkCmdCopyBuffer()命令, 告知其我们想要将数据从分段缓冲区的最开始(0th偏移)复制到统一缓冲区的最开始(也是 0th偏移)。我们还提供要复制的数据的大小。
接下来我们需要告诉驱动程序,在执行数据传输之后,我们的整个统一缓冲区将被用作统一缓冲区。这是通过放置一个缓冲内存屏障来实现的,在这个屏障中我们告知它,到目前为止,我们将数据传输到缓冲区(VK_ACCESS_TRANSFER_WRITE_BIT)
,但从现在开始,我们将使用 (VK_ACCESS_UNIFORM_READ_BIT)
作为统一变量的数据源。使用 vkCmdPipelineBarrier()函数调用放置缓冲内存屏障。它发生在数据传输操作 (VK_PIPELINE_STAGE_TRANSFER_BIT)
之后、顶点着色器执行之前, 因为我们在顶点着色器(VK_PIPELINE_STAGE_VERTEX_SHADER_BIT)
中访问我们的统一变量。
最后,我们可以结束命令缓冲区并将其提交给队列。整个过程在下面的代码中展现:
// Prepare command buffer to copy data from staging buffer to a uniform buffer VkCommandBuffer command_buffer = Vulkan.RenderingResources[0].CommandBuffer; VkCommandBufferBeginInfo command_buffer_begin_info = { VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, // VkStructureType sType nullptr, // const void *pNext VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, // VkCommandBufferUsageFlags flags nullptr // const VkCommandBufferInheritanceInfo *pInheritanceInfo }; vkBeginCommandBuffer( command_buffer, &command_buffer_begin_info); VkBufferCopy buffer_copy_info = { 0, // VkDeviceSize srcOffset 0, // VkDeviceSize dstOffset Vulkan.UniformBuffer.Size // VkDeviceSize size }; vkCmdCopyBuffer( command_buffer, Vulkan.StagingBuffer.Handle, Vulkan.UniformBuffer.Handle, 1, &buffer_copy_info ); VkBufferMemoryBarrier buffer_memory_barrier = { VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, // VkStructureType sType; nullptr, // const void *pNext VK_ACCESS_TRANSFER_WRITE_BIT, // VkAccessFlags srcAccessMask VK_ACCESS_UNIFORM_READ_BIT, // VkAccessFlags dstAccessMask VK_QUEUE_FAMILY_IGNORED, // uint32_t srcQueueFamilyIndex VK_QUEUE_FAMILY_IGNORED, // uint32_t dstQueueFamilyIndex Vulkan.UniformBuffer.Handle, // VkBuffer buffer 0, // VkDeviceSize offset VK_WHOLE_SIZE // VkDeviceSize size }; vkCmdPipelineBarrier( command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_VERTEX_SHADER_BIT, 0, 0, nullptr, 1, &buffer_memory_barrier, 0, nullptr ); vkEndCommandBuffer( command_buffer ); // Submit command buffer and copy data from staging buffer to a vertex buffer VkSubmitInfo submit_info = { VK_STRUCTURE_TYPE_SUBMIT_INFO, // VkStructureType sType nullptr, // const void *pNext 0, // uint32_t waitSemaphoreCount nullptr, // const VkSemaphore *pWaitSemaphores nullptr, // const VkPipelineStageFlags *pWaitDstStageMask; 1, // uint32_t commandBufferCount &command_buffer, // const VkCommandBuffer *pCommandBuffers 0, // uint32_t signalSemaphoreCount nullptr // const VkSemaphore *pSignalSemaphores }; if( vkQueueSubmit( GetGraphicsQueue().Handle, 1, &submit_info, VK_NULL_HANDLE ) != VK_SUCCESS ) { return false; } vkDeviceWaitIdle( GetDevice() ); return true;
5.Tutorial07.cpp, function CopyUniformBufferData()
在上面的代码中,我们调用 vkDeviceWaitIdle()函数,以确保数据传输操作在我们继续处理之前完成。但在实际情形中,我们应该使用信号和/或栅栏来执行更适当的同步。等待所有的 GPU 操作完成可能会破坏应用的性能。
准备描述符集
现在我们可以准备描述符集,即应用和管道之间的接口,通过这个管道我们可以提供着色器所用的资源。我们首先创建一个描述符集布局。
创建描述符集布局
渲染 3D 几何图形的最典型方式是将顶点与顶点着色器内的模型、视图和投影矩阵相乘。这些矩阵可以在模型视图投影矩阵中累积。我们需要为统一变量中的顶点着色器提供这样一个矩阵。通常我们希望自己的几何图形具有纹理;片段着色器需要访问纹理 - 组合图像采样器。我们也可以使用单独的采样图像和采样器;使用组合图像采样器可能会在某些平台上实现更出色的性能。
当我们发出绘图命令时,我们希望顶点着色器能够访问统一变量,片段着色器能够访问组合图像采样器。这些资源必须在描述符集中提供。为提供这样的集合,我们需要创建一个适当的布局,它定义了描述符集内存储哪些类型的资源。
std::vector<VkDescriptorSetLayoutBinding> layout_bindings = { { 0, // uint32_t binding VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, // VkDescriptorType descriptorType 1, // uint32_t descriptorCount VK_SHADER_STAGE_FRAGMENT_BIT, // VkShaderStageFlags stageFlags nullptr // const VkSampler *pImmutableSamplers }, { 1, // uint32_t binding VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, // VkDescriptorType descriptorType 1, // uint32_t descriptorCount VK_SHADER_STAGE_VERTEX_BIT, // VkShaderStageFlags stageFlags nullptr // const VkSampler *pImmutableSamplers } }; VkDescriptorSetLayoutCreateInfo descriptor_set_layout_create_info = { VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO, // VkStructureType sType nullptr, // const void *pNext 0, // VkDescriptorSetLayoutCreateFlags flags static_cast<uint32_t>(layout_bindings.size()), // uint32_t bindingCount layout_bindings.data() // const VkDescriptorSetLayoutBinding *pBindings }; if( vkCreateDescriptorSetLayout( GetDevice(), &descriptor_set_layout_create_info, nullptr, &Vulkan.DescriptorSet.Layout ) != VK_SUCCESS ) { std::cout << "Could not create descriptor set layout!"<< std::endl; return false; } return true;
6.Tutorial07.cpp, function CreateDescriptorSetLayout()
描述符集布局是通过指定绑定来创建的。每个绑定在描述符集中定义一个单独的条目,并在描述符集中有其自己的唯一索引。在上面的代码中,我们定义了描述符集(及其布局);它包含两个绑定。第一个绑定的索引为 0,用于片段着色器访问的一个组合图像采样器。第二个绑定的索引为 1,用于顶点着色器访问的的统一缓冲区。这两者都是单一资源;它们不是阵列。不过,我们还可以在 VkDescriptorSetLayoutBindin 结构的 descriptorCount 成员中提供大于 1 的数值,以此指定每个绑定表示一组资源。
着色器中也使用绑定。当我们定义统一变量时,我们需要指定与创建布局时提供的绑定值相同的绑定值:
layout( set=S, binding=B ) uniform <variable type> <variable name>;
有两点值得一提。绑定不需要连续。我们可以创建一个包含三个绑定的布局,例如索引 2、5 和 9。但未使用的插槽仍可能使用某些内存,因此我们应该使绑定尽可能接近 0。
我们还指定哪些着色器阶段需要访问哪些类型的描述符(哪些绑定)。如果不确定,我们可以提供更多阶段。例如,假设我们要创建多个管道,所有管道都使用相同布局的描述符集。在其中一些管道中,统一缓冲区将在顶点着色器、其他的几何图形着色器以及其他的顶点和片段着色器中访问。为此,我们可以创建一个布局,在这个布局中指定统一缓冲区将由顶点、几何图形和片段着色器访问。但我们不应该提供不必要的着色器阶段,因为像往常一样,它可能会影响性能(尽管这并不意味着它会影响性能)。
指定一个绑定阵列后,我们在一个类型为 VkDescriptorSetLayoutCreateInfo 的变量中为其提供一个指针。这个变量的指针在 vkCreateDescriptorSetLayout()函数中提供,该函数创建实际布局。当我们有这个函数时,我们可以分配一个描述符集。但首先,我们需要一个可以分配集合的内存池。
创建描述符池
当我们想创建一个描述符池时,我们需要知道将有哪些类型的资源在从池中分配的描述符集中定义。我们还需要指定可以存储在从池分配的描述符集中的每种类型的最大资源数量,以及从池中分配的描述符集的最大数量。例如,我们可以为一个组合图像采样器和一个统一缓冲区准备一个存储,但总共需要两个集合。这意味着我们可以有两个集合,一个带纹理,一个带统一缓冲区。或者只有一个同时带统一缓冲区和纹理的集合(在这种情况下,第二个池为空,因为它不能包含这两个资源)。
在我们的示例中,我们只需要一个描述符集,我们可以在下面看到如何为它创建一个描述符池:
std::vector<VkDescriptorPoolSize> pool_sizes = { { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, // VkDescriptorType type 1 // uint32_t descriptorCount }, { VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, // VkDescriptorType type 1 // uint32_t descriptorCount } }; VkDescriptorPoolCreateInfo descriptor_pool_create_info = { VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO, // VkStructureType sType nullptr, // const void *pNext 0, // VkDescriptorPoolCreateFlags flags 1, // uint32_t maxSets static_cast<uint32_t>(pool_sizes.size()), // uint32_t poolSizeCount pool_sizes.data() // const VkDescriptorPoolSize *pPoolSizes }; if( vkCreateDescriptorPool( GetDevice(), &descriptor_pool_create_info, nullptr, &Vulkan.DescriptorSet.Pool ) != VK_SUCCESS ) { std::cout << "Could not create descriptor pool!"<< std::endl; return false; } return true;
7.Tutorial07.cpp, function CreateDescriptorPool()
现在,我们准备使用先前创建的布局从池中分配描述符集。
分配描述符集
描述符集合分配非常简单。我们只需要一个描述符池和一个布局即可。我们指定要分配的描述符集的数量,并这样调用 vkAllocateDescriptorSets()函数:
VkDescriptorSetAllocateInfo descriptor_set_allocate_info = { VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO, // VkStructureType sType nullptr, // const void *pNext Vulkan.DescriptorSet.Pool, // VkDescriptorPool descriptorPool 1, // uint32_t descriptorSetCount &Vulkan.DescriptorSet.Layout // const VkDescriptorSetLayout *pSetLayouts }; if( vkAllocateDescriptorSets( GetDevice(), &descriptor_set_allocate_info, &Vulkan.DescriptorSet.Handle ) != VK_SUCCESS ) { std::cout << "Could not allocate descriptor set!"<< std::endl; return false; } return true;
8.Tutorial07.cpp, function AllocateDescriptorSet()
更新描述符集
我们已经分配了一个描述符集。它用于为管道提供纹理和统一缓冲区,以便它们可以在着色器中使用。现在我们必须提供将用作描述符的特定资源。对于组合的图像采样器,我们需要两个资源——可以在着色器中进行采样的图像(必须使用 VK_IMAGE_USAGE_SAMPLED_BIT 用途创建)
和采样器。这是两个单独的资源,但它们一起提供以形成单个组合的图像采样器描述符。如欲了解关于如何创建这两个资源的详细信息,请参见 Vulkan 简介第 6 部分 – 描述符集。对于统一缓冲区,我们将提供一个先前创建的缓冲区。为了向描述符提供特定资源,我们需要更新描述符集。在更新期间,我们指定描述符类型,绑定数量,并完全按照我们在创建布局时的方式进行计数。这些值必须匹配。除此之外,根据描述符类型,我们还需要创建这些类型的变量:
- VkDescriptorImageInfo,用于采样器、采样图像、组合图像采样器和输入附件
- VkDescriptorBufferInfo,用于统一和存储缓冲区及其动态变化
- VkBufferView,用于统一和存储 texel 缓冲区
通过它们,我们提供了应当用于相应描述符的特定 Vulkan 资源的句柄。所有这些都提供给 vkUpdateDescriptorSets()函数,如下所示:
VkDescriptorImageInfo image_info = { Vulkan.Image.Sampler, // VkSampler sampler Vulkan.Image.View, // VkImageView imageView VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL // VkImageLayout imageLayout }; VkDescriptorBufferInfo buffer_info = { Vulkan.UniformBuffer.Handle, // VkBuffer buffer 0, // VkDeviceSize offset Vulkan.UniformBuffer.Size // VkDeviceSize range }; std::vector<VkWriteDescriptorSet> descriptor_writes = { { VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, // VkStructureType sType nullptr, // const void *pNext Vulkan.DescriptorSet.Handle, // VkDescriptorSet dstSet 0, // uint32_t dstBinding 0, // uint32_t dstArrayElement 1, // uint32_t descriptorCount VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, // VkDescriptorType descriptorType &image_info, // const VkDescriptorImageInfo *pImageInfo nullptr, // const VkDescriptorBufferInfo *pBufferInfo nullptr // const VkBufferView *pTexelBufferView }, { VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, // VkStructureType sType nullptr, // const void *pNext Vulkan.DescriptorSet.Handle, // VkDescriptorSet dstSet 1, // uint32_t dstBinding 0, // uint32_t dstArrayElement 1, // uint32_t descriptorCount VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, // VkDescriptorType descriptorType nullptr, // const VkDescriptorImageInfo *pImageInfo &buffer_info, // const VkDescriptorBufferInfo *pBufferInfo nullptr // const VkBufferView *pTexelBufferView } }; vkUpdateDescriptorSets( GetDevice(), static_cast<uint32_t>(descriptor_writes.size()), &descriptor_writes[0], 0, nullptr ); return true;
9.Tutorial07.cpp, function UpdateDescriptorSet()
现在我们有一个有效的描述符集。我们可以在命令缓冲区记录期间绑定它。但我们需要一个使用适当的管道布局创建的管道对象。
准备绘图状态
创建的描述符集布局有两种用途:
- 从池中分配描述符集
- 创建管道布局
描述符集布局指定了描述符集包含的资源类型。管道布局指定管道及其着色器可以访问哪些类型的资源。这就是我们可以在命令缓冲区记录过程中使用描述符集的原因,我们需要创建管道布局。
创建管线布局
管道布局定义既定管道可以访问的资源。这些分为描述符和 push 常量。要创建管道布局,我们需要提供描述符集布局的列表以及 push 常量范围的列表。
Push 常量提供了一种方法,可以轻松快速地将数据传递到着色器。遗憾的是,数据量也非常有限。规范仅允许 128 个字节可用于在既定时间提供给管道的 push 常量数据。硬件厂商可能允许我们提供更多数据,但与通常的描述符(如统一缓冲区)相比,数据量仍然非常少。
在本例中,我们不使用 push 常量范围,所以我们只需要提供描述符集布局并调用 vkCreatePipelineLayout()函数。下面的代码就是这样做的:
VkPipelineLayoutCreateInfo layout_create_info = { VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, // VkStructureType sType nullptr, // const void *pNext 0, // VkPipelineLayoutCreateFlags flags 1, // uint32_t setLayoutCount &Vulkan.DescriptorSet.Layout, // const VkDescriptorSetLayout *pSetLayouts 0, // uint32_t pushConstantRangeCount nullptr // const VkPushConstantRange *pPushConstantRanges }; if( vkCreatePipelineLayout( GetDevice(), &layout_create_info, nullptr, &Vulkan.PipelineLayout ) != VK_SUCCESS ) { std::cout << "Could not create pipeline layout!"<< std::endl; return false; } return true;
10.Tutorial07.cpp, function CreatePipelineLayout()
创建着色器程序
现在我们需要一个图形管道。从性能和代码开发的角度来看,管道创建是一个非常耗时的过程。我将跳过这段代码,仅提供着色器的 GLSL 源代码。
在绘制过程中使用的顶点着色器获取顶点位置,并将其乘以从统一变量读取的投影矩阵。这个变量存储在一个统一缓冲区中。描述符集(我们通过描述符集提供统一缓冲区)是管道布局创建过程中指定的描述符集列表中的第一个(也是这种情况下的唯一一个)。因此,当我们记录命令缓冲区时,我们可以将其绑定到 0th索引。这是因为,我们绑定描述符集的索引必须与在管道布局创建期间提供的描述符集布局相对应的索引相匹配。必须在着色器中指定相同的集合索引。统一缓冲区由该集合内的第二个绑定(其索引等于 1)表示,并且还必须指定相同的绑定编号。这是整个顶点着色器的源代码:
#version 450 layout(set=0, binding=1) uniform u_UniformBuffer { mat4 u_ProjectionMatrix; }; layout(location = 0) in vec4 i_Position; layout(location = 1) in vec2 i_Texcoord; out gl_PerVertex { vec4 gl_Position; }; layout(location = 0) out vec2 v_Texcoord; void main() { gl_Position = u_ProjectionMatrix * i_Position; v_Texcoord = i_Texcoord; }
11. shader.vert, -
在着色器内,我们还将纹理坐标传递给片段着色器。片段着色器利用它们,对组合的图像采样器进行采样。它通过绑定到索引 0 的同一个描述符集来提供,但它是其中的第一个描述符,所以在这种情况下,我们指定 0(零)作为绑定的数值。查看片段着色器的完整 GLSL 源代码:
#version 450 layout(set=0, binding=0) uniform sampler2D u_Texture; layout(location = 0) in vec2 v_Texcoord; layout(location = 0) out vec4 o_Color; void main() { o_Color = texture( u_Texture, v_Texcoord ); }
12. shader.frag, -
在应用中使用它们之前,需要将上述两个着色器转换为 SPIR-V* 程序集。核心 Vulkan 规范仅允许将二进制 SPIR-V 数据作为着色器指令的来源。我们可以利用它们创建两个着色器模块,每个着色器阶段一个模块,并使用它们创建图形管道。其余的管道状态保持不变。
绑定描述符集
假设我们已经创建了所有其他资源并准备好用于绘制几何图形。接下来开始记录命令缓冲区。绘图命令只能在渲染通道内调用。绘制任何几何图形之前,我们需要设置所有需要的状态 - 首先,我们需要绑定图形管道。除此之外,如果我们使用顶点缓冲区,我们需要为此绑定合适的缓冲区。如果我们想发布索引绘图命令,我们也需要绑定一个具有顶点索引的缓冲区。当我们使用统一缓冲区或纹理等着色器资源时,我们需要绑定描述符集。我们的方法是调用 vkCmdBindDescriptorSets()函数。在这个函数中,我们不仅需要提供描述符集的句柄,还需要提供管线布局的句柄(所以我们需要保留它)。只有在此之后,我们才能记录绘图命令。这些操作在下面的代码中提供:
vkCmdBeginRenderPass( command_buffer, &render_pass_begin_info, VK_SUBPASS_CONTENTS_INLINE ); vkCmdBindPipeline( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, Vulkan.GraphicsPipeline ); // ... VkDeviceSize offset = 0; vkCmdBindVertexBuffers( command_buffer, 0, 1, &Vulkan.VertexBuffer.Handle, &offset ); vkCmdBindDescriptorSets( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, Vulkan.PipelineLayout, 0, 1, &Vulkan.DescriptorSet.Handle, 0, nullptr ); vkCmdDraw( command_buffer, 4, 1, 0, 0 ); vkCmdEndRenderPass( command_buffer );
13.Tutorial07.cpp, function PrepareFrame()
不要忘记,典型的动画帧需要我们从交换链获取图像,如上所述记录命令缓冲区(或更多),将其提交给队列,并呈现之前获取的交换链图像,以便其根据交换链创建期间请求的当前模式进行显示。
教程 7 执行
我们来看看示例程序生成的最终图像应该如何显示:
我们仍渲染一个纹理应用于其表面的四边形。但当我们改变应用窗口的大小时,四边形的大小应该保持不变。
清理
像往常一样,当应用结束时,我们应该执行清理。
// ... if( Vulkan.GraphicsPipeline != VK_NULL_HANDLE ) { vkDestroyPipeline( GetDevice(), Vulkan.GraphicsPipeline, nullptr ); Vulkan.GraphicsPipeline = VK_NULL_HANDLE; } if( Vulkan.PipelineLayout != VK_NULL_HANDLE ) { vkDestroyPipelineLayout( GetDevice(), Vulkan.PipelineLayout, nullptr ); Vulkan.PipelineLayout = VK_NULL_HANDLE; } // ... if( Vulkan.DescriptorSet.Pool != VK_NULL_HANDLE ) { vkDestroyDescriptorPool( GetDevice(), Vulkan.DescriptorSet.Pool, nullptr ); Vulkan.DescriptorSet.Pool = VK_NULL_HANDLE; } if( Vulkan.DescriptorSet.Layout != VK_NULL_HANDLE ) { vkDestroyDescriptorSetLayout( GetDevice(), Vulkan.DescriptorSet.Layout, nullptr ); Vulkan.DescriptorSet.Layout = VK_NULL_HANDLE; } DestroyBuffer( Vulkan.UniformBuffer );
14.Tutorial07.cpp, function destructor
像往常一样,大部分资源都被销毁。我们按照与创建顺序相反的顺序来执行此操作。这里只介绍与我们的示例相关的代码部分。通过调用 vkDestroyPipeline()函数销毁图形管道。若要销毁其布局,我们需要调用 vkDestroyPipelineLayout()函数。我们不需要单独销毁所有描述符集,因为当我们销毁一个描述符池时,从它分配的所有集也都被销毁。若要销毁描述符池,我们需要调用 vkDestroyDescriptorPool()函数。描述符集布局需要使用 vkDestroyDescriptorSetLayout()函数单独销毁。之后,我们可以销毁统一缓冲区;这一操作通过 vkDestroyBuffer()函数完成。但我们不能忘记通过 vkFreeMemory()函数销毁绑定到它的内存,如下所示:
if( buffer.Handle != VK_NULL_HANDLE ) { vkDestroyBuffer( GetDevice(), buffer.Handle, nullptr ); buffer.Handle = VK_NULL_HANDLE; } if( buffer.Memory != VK_NULL_HANDLE ) { vkFreeMemory( GetDevice(), buffer.Memory, nullptr ); buffer.Memory = VK_NULL_HANDLE; }
15.Tutorial07.cpp, function DestroyBuffe()
结论
在本部分教程中,我们通过将统一缓冲区添加到描述符集来扩展前一部分的示例。与所有其他缓冲区一样创建统一缓冲区,但在创建期间指定了VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT
用途。我们还分配了专用内存并将其绑定到缓冲区,并使用分段缓冲区将投影矩阵数据上传到缓冲区。
接下来,我们准备了描述符集,首先创建一个描述符集布局,其中包含一个组合图像采样器和一个统一缓冲区。接下来,我们创建了一个足够大的描述符池来包含这两种类型的描述符资源,并且我们从中分配了一个描述符集。之后,我们更新了带采样器句柄的描述符集,采样图像的图像视图以及在这部分教程中创建的缓冲区。
其余操作与我们已知的操作相似。我们在管道布局创建时使用了描述符集布局,然后在将描述符集绑定到命令缓冲区时也使用了描述符集布局。
我们再次了解了如何为顶点着色器和片段着色器准备着色器代码,并且我们学习了如何访问通过相同描述符集的不同绑定提供的不同类型描述符。
本教程的下一部分将有所不同,我们将了解并比较管理多个资源并处理各种更复杂任务的不同方法。