既然已经解释了描述符集,让我们看看在一个最小的示例中如何在实践中使用它们。我们将修改代码库和着色器,以便不再通过推送常量发送对象的最终变换矩阵,而是在着色器上读取相机矩阵,并将相机矩阵与对象矩阵相乘,乘法运算在着色器中完成。为了实现这一点,我们需要为我们的相机矩阵创建一个 uniform 缓冲区,并使用单个描述符集将其暴露给着色器。
设置相机缓冲区
我们将为每个帧创建一个相机缓冲区。这样做是为了我们可以正确地重叠数据,并在 GPU 渲染最后一帧时修改相机矩阵。
鉴于我们开始创建大量的缓冲区,我们将首先将缓冲区创建抽象成一个函数。
将函数声明添加到 VulkanEngine 类 vk_engine.cpp 中
AllocatedBuffer VulkanEngine::create_buffer(size_t allocSize, VkBufferUsageFlags usage, VmaMemoryUsage memoryUsage)
{
//allocate vertex buffer
VkBufferCreateInfo bufferInfo = {};
bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferInfo.pNext = nullptr;
bufferInfo.size = allocSize;
bufferInfo.usage = usage;
VmaAllocationCreateInfo vmaallocInfo = {};
vmaallocInfo.usage = memoryUsage;
AllocatedBuffer newBuffer;
//allocate the buffer
VK_CHECK(vmaCreateBuffer(_allocator, &bufferInfo, &vmaallocInfo,
&newBuffer._buffer,
&newBuffer._allocation,
nullptr));
return newBuffer;
}
在该函数中,我们将仅请求缓冲区大小、缓冲区用途和内存用途。这是基本缓冲区创建所需的一切。这与我们用于顶点缓冲区的代码类似。
现在,我们将添加一个变量来保存相机缓冲区到我们的 FrameData 结构中,并为相机数据创建一个结构体。
struct GPUCameraData{
glm::mat4 view;
glm::mat4 proj;
glm::mat4 viewproj;
};
struct FrameData {
// other code ...
//buffer that holds a single GPUCameraData to use when rendering
AllocatedBuffer cameraBuffer;
VkDescriptorSet globalDescriptor;
};
GPUCameraData 结构体仅保存我们将需要的一些矩阵。视图矩阵(相机位置/变换)、投影矩阵(用于透视)和 ViewProj,它只是将它们两个相乘,以避免在着色器中相乘。
在 FrameData 上,我们为其添加了 AllocatedBuffer,但我们也添加了一个 VkDescriptorSet
,我们将缓存它以保存全局描述符。我们将向其中添加一些比相机 uniform 缓冲区更多的东西。
我们将添加另一个初始化函数 init_descriptors()
到 VulkanEngine 类。也将其添加到主 init 函数中,但在 init_pipelines()
调用之前。我们在那里初始化的一些描述符内容在创建管线时将需要。
class VulkanEngine {
//other code....
AllocatedBuffer create_buffer(size_t allocSize, VkBufferUsageFlags usage, VmaMemoryUsage memoryUsage);
void init_descriptors();
}
void VulkanEngine::init()
{
// other code ....
init_sync_structures();
init_descriptors();
init_pipelines();
//other code ....
}
现在函数和数据已添加,我们需要创建这些相机缓冲区。
void VulkanEngine::init_descriptors()
{
for (int i = 0; i < FRAME_OVERLAP; i++)
{
_frames[i].cameraBuffer = create_buffer(sizeof(GPUCameraData), VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU);
}
// add buffers to deletion queues
for (int i = 0; i < FRAME_OVERLAP; i++)
{
_mainDeletionQueue.push_function([&]() {
vmaDestroyBuffer(_allocator, _frames[i].cameraBuffer._buffer, _frames[i].cameraBuffer._allocation);
});
}
}
为了创建缓冲区,我们将使用 Uniform Buffer 用途和 CPU_TO_GPU
内存类型。Uniform 缓冲区最适合这种小型、只读的着色器数据。它们具有大小限制,但在着色器中访问速度非常快。
尝试运行此代码,看看验证层是否报错。它们不应该报错。
描述符集:着色器
我们将开始处理着色器数据本身。第一件事是修改我们使用的着色器,以在那里使用矩阵。
#version 450
layout (location = 0) in vec3 vPosition;
layout (location = 1) in vec3 vNormal;
layout (location = 2) in vec3 vColor;
layout (location = 0) out vec3 outColor;
layout(set = 0, binding = 0) uniform CameraBuffer{
mat4 view;
mat4 proj;
mat4 viewproj;
} cameraData;
//push constants block
layout( push_constant ) uniform constants
{
vec4 data;
mat4 render_matrix;
} PushConstants;
void main()
{
mat4 transformMatrix = (cameraData.viewproj * PushConstants.render_matrix);
gl_Position = transformMatrix * vec4(vPosition, 1.0f);
outColor = vColor;
}
新的块是 CameraBuffer uniform 声明。在其中,你可以看到它遵循与推送常量块相同的语法,但具有不同的 layout()
。通过具有 set = 0
和 binding = 0
,我们声明 CameraBuffer uniform 将从在槽 0 绑定的描述符集中获取,并且它是该描述符集中的绑定 0。
在顶点着色器的核心中,我们将推送常量中的渲染矩阵与 CameraBuffer 上的 viewproj 矩阵相乘。这将获得最终变换矩阵,然后我们可以将顶点位置乘以它。
现在让我们在 cpp 端进行设置。我们需要的第一件事是创建描述符集布局。
描述符集布局
向 vulkan 引擎添加一个新的成员变量。我们将使用它来存储全局数据的描述符布局。 также 添加一个成员用于我们稍后需要的描述符池
class VulkanEngine {
VkDescriptorSetLayout _globalSetLayout;
VkDescriptorPool _descriptorPool;
}
VkDescriptorSetLayout
保存有关描述符集形状的信息。在这种情况下,我们的描述符集将是一个在绑定 0 处保存单个 uniform 缓冲区引用的集合。
void VulkanEngine::init_descriptors()
{
//information about the binding.
VkDescriptorSetLayoutBinding camBufferBinding = {};
camBufferBinding.binding = 0;
camBufferBinding.descriptorCount = 1;
// it's a uniform buffer binding
camBufferBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
// we use it from the vertex shader
camBufferBinding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;
VkDescriptorSetLayoutCreateInfo setinfo = {};
setinfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
setinfo.pNext = nullptr;
//we are going to have 1 binding
setinfo.bindingCount = 1;
//no flags
setinfo.flags = 0;
//point to the camera buffer binding
setinfo.pBindings = &camBufferBinding;
vkCreateDescriptorSetLayout(_device, &setinfo, nullptr, &_globalSetLayout);
// other code ....
// add descriptor set layout to deletion queues
_mainDeletionQueue.push_function([&]() {
vkDestroyDescriptorSetLayout(_device, _globalSetLayout, nullptr);
}
}
要创建描述符集布局,我们需要另一个 CreateInfo 结构体。create-info 将指向 VkDescriptorSetLayoutBinding
结构体的数组。这些结构体中的每一个都将包含有关描述符本身的信息。在这种情况下,我们只有一个绑定,即绑定 0,它是一个 Uniform Buffer。
我们现在有了为我们的描述符创建的描述符集布局,因此我们需要将其连接到管线创建。当您创建管线时,您还需要让管线知道哪些描述符将被绑定到它。
回到 init_pipelines()
。我们需要通过将描述符布局连接到它来修改 VkPipelineLayout
的创建。
//push-constant setup
mesh_pipeline_layout_info.pPushConstantRanges = &push_constant;
mesh_pipeline_layout_info.pushConstantRangeCount = 1;
//hook the global set layout
mesh_pipeline_layout_info.setLayoutCount = 1;
mesh_pipeline_layout_info.pSetLayouts = &_globalSetLayout;
VkPipelineLayout meshPipLayout;
VK_CHECK(vkCreatePipelineLayout(_device, &mesh_pipeline_layout_info, nullptr, &meshPipLayout));
现在我们的管线构建器将管线布局连接到所有内容,这将允许管线在我们绑定描述符集后访问它们。
管线设置完成,所以现在我们必须分配一个描述符集,并在渲染时绑定它。
分配描述符集
回到 init_descriptors()
,我们首先需要创建一个 VkDescriptorPool
以从中分配描述符。
void VulkanEngine::init_descriptors()
{
//create a descriptor pool that will hold 10 uniform buffers
std::vector<VkDescriptorPoolSize> sizes =
{
{ VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 10 }
};
VkDescriptorPoolCreateInfo pool_info = {};
pool_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
pool_info.flags = 0;
pool_info.maxSets = 10;
pool_info.poolSizeCount = (uint32_t)sizes.size();
pool_info.pPoolSizes = sizes.data();
vkCreateDescriptorPool(_device, &pool_info, nullptr, &_descriptorPool);
// other code ....
// add descriptor set layout to deletion queues
_mainDeletionQueue.push_function([&]() {
vkDestroyDescriptorSetLayout(_device, _globalSetLayout, nullptr);
vkDestroyDescriptorPool(_device, _descriptorPool, nullptr);
}
}
在这种情况下,我们确切地知道我们需要从池中分配什么,即指向 uniform 缓冲区的描述符集。在创建描述符池时,您需要指定您将需要多少每种类型的描述符,以及从其中分配的最大集合数。现在,我们将保留 10 个 uniform 缓冲区指针/句柄,以及从池中分配的最大 10 个描述符集。
我们现在可以从中分配描述符。为此,继续在 init_descriptors()
函数中的 FRAME_OVERLAP 循环中。
for (int i = 0; i < FRAME_OVERLAP; i++)
{
_frames[i].cameraBuffer = create_buffer(sizeof(GPUCameraData), VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU);
//allocate one descriptor set for each frame
VkDescriptorSetAllocateInfo allocInfo ={};
allocInfo.pNext = nullptr;
allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
//using the pool we just set
allocInfo.descriptorPool = _descriptorPool;
//only 1 descriptor
allocInfo.descriptorSetCount = 1;
//using the global data layout
allocInfo.pSetLayouts = &_globalSetLayout;
vkAllocateDescriptorSets(_device, &allocInfo, &_frames[i].globalDescriptor);
}
有了这个,我们现在在我们的帧结构中存储了一个描述符。但是这个描述符尚未指向任何缓冲区,因此我们需要使其指向我们的相机缓冲区。
for (int i = 0; i < FRAME_OVERLAP; i++)
{
// allocation code ...
//information about the buffer we want to point at in the descriptor
VkDescriptorBufferInfo binfo;
//it will be the camera buffer
binfo.buffer = _frames[i].cameraBuffer._buffer;
//at 0 offset
binfo.offset = 0;
//of the size of a camera data struct
binfo.range = sizeof(GPUCameraData);
VkWriteDescriptorSet setWrite = {};
setWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
setWrite.pNext = nullptr;
//we are going to write into binding number 0
setWrite.dstBinding = 0;
//of the global descriptor
setWrite.dstSet = _frames[i].globalDescriptor;
setWrite.descriptorCount = 1;
//and the type is uniform buffer
setWrite.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
setWrite.pBufferInfo = &binfo;
vkUpdateDescriptorSets(_device, 1, &setWrite, 0, nullptr);
}
我们需要使用我们想要在描述符集中拥有的缓冲区数据填充 VkDescriptorBufferInfo
。因为我们已经定义了我们的相机缓冲区在绑定 0 上,那么我们需要在此处设置它,并具有足够的大小来容纳结构体。
现在我们有了一个填充的描述符集,所以我们可以在渲染时使用它。在 draw_objects()
函数中,我们将首先使用当前相机矩阵写入相机缓冲区。这是我们之前用于推送常量的代码,但我们现在填充一个 GPUCameraData 结构体,然后将其复制到缓冲区中。如果您已经实现了移动相机,则需要修改此代码。
void VulkanEngine::draw_objects(VkCommandBuffer cmd,RenderObject* first, int count)
{
//camera view
glm::vec3 camPos = { 0.f,-6.f,-10.f };
glm::mat4 view = glm::translate(glm::mat4(1.f), camPos);
//camera projection
glm::mat4 projection = glm::perspective(glm::radians(70.f), 1700.f / 900.f, 0.1f, 200.0f);
projection[1][1] *= -1;
//fill a GPU camera data struct
GPUCameraData camData;
camData.proj = projection;
camData.view = view;
camData.viewproj = projection * view;
//and copy it to the buffer
void* data;
vmaMapMemory(_allocator, get_current_frame().cameraBuffer._allocation, &data);
memcpy(data, &camData, sizeof(GPUCameraData));
vmaUnmapMemory(_allocator, get_current_frame().cameraBuffer._allocation);
}
我们填充结构体,然后使用与处理顶点缓冲区时相同的模式将其复制到缓冲区。首先,您将缓冲区映射到一个 void 指针,然后将数据 memcpy 到其中,然后取消映射缓冲区。
缓冲区现在保存了正确的相机数据,所以现在我们可以绑定它。
//only bind the pipeline if it doesn't match with the already bound one
if (object.material != lastMaterial) {
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, object.material->pipeline);
lastMaterial = object.material;
//bind the descriptor set when changing pipeline
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, object.material->pipelineLayout, 0, 1, &get_current_frame().globalDescriptor, 0, nullptr);
}
我们将在每次切换管线时绑定该集合。现在这不是绝对必要的,因为我们所有的管线都是相同的,但这会更容易。
最后一件事是修改推送常量代码,使其不在那里乘以矩阵,而只是推送常量模型矩阵。
MeshPushConstants constants;
constants.render_matrix = object.transformMatrix;
//upload the mesh to the GPU via push constants
vkCmdPushConstants(cmd, object.material->pipelineLayout, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(MeshPushConstants), &constants);
如果您现在运行代码,一切都应该正常工作。但是我们现在有一种方法可以让着色器从缓冲区读取一些数据,而不必推送常量着色器的所有数据。
下一步:动态描述符集