描述符布局和缓冲区
简介
我们现在能够为每个顶点向顶点着色器传递任意属性,但是全局变量呢? 从本章开始,我们将转向 3D 图形,这需要一个模型-视图-投影矩阵。 我们可以将其作为顶点数据包含在内,但这会浪费内存,并且每当变换更改时,都需要更新顶点缓冲区。 变换很容易在每一帧都发生变化。
在 Vulkan 中解决此问题的正确方法是使用资源描述符。描述符是着色器自由访问缓冲区和图像等资源的一种方式。 我们将设置一个包含变换矩阵的缓冲区,并让顶点着色器通过描述符访问它们。 描述符的使用包括三个部分
-
在管线创建期间指定描述符集布局
-
从描述符池中分配描述符集
-
在渲染期间绑定描述符集
描述符集布局指定管线将要访问的资源类型,就像渲染通道指定将要访问的附件类型一样。描述符集指定将绑定到描述符的实际缓冲区或图像资源,就像帧缓冲指定要绑定到渲染通道附件的实际图像视图一样。然后,描述符集被绑定用于绘制命令,就像顶点缓冲区和帧缓冲一样。
有许多类型的描述符,但在本章中,我们将使用统一缓冲区对象 (UBO)。 我们将在以后的章节中研究其他类型的描述符,但基本过程是相同的。 假设我们希望顶点着色器在 C 结构中具有如下所示的数据
struct UniformBufferObject {
glm::mat4 model;
glm::mat4 view;
glm::mat4 proj;
};
然后,我们可以将数据复制到 VkBuffer
,并通过顶点着色器的统一缓冲区对象描述符访问它,如下所示
layout(binding = 0) uniform UniformBufferObject {
mat4 model;
mat4 view;
mat4 proj;
} ubo;
void main() {
gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 0.0, 1.0);
fragColor = inColor;
}
我们将每一帧更新模型、视图和投影矩阵,以使上一章中的矩形在 3D 中旋转。
顶点着色器
修改顶点着色器以包含如上所述的统一缓冲区对象。 我假设您熟悉 MVP 变换。 如果您不熟悉,请参阅第一章中提到的资源。
#version 450
layout(binding = 0) uniform UniformBufferObject {
mat4 model;
mat4 view;
mat4 proj;
} ubo;
layout(location = 0) in vec2 inPosition;
layout(location = 1) in vec3 inColor;
layout(location = 0) out vec3 fragColor;
void main() {
gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 0.0, 1.0);
fragColor = inColor;
}
请注意,uniform
、in
和 out
声明的顺序无关紧要。 binding
指令类似于属性的 location
指令。 我们将在描述符集布局中引用此绑定。 gl_Position
行已更改为使用变换来计算剪辑坐标中的最终位置。 与 2D 三角形不同,剪辑坐标的最后一个分量可能不是 1
,这将在转换为屏幕上最终归一化的设备坐标时导致除法。 这在透视投影中用作透视除法,对于使近处的物体看起来比远处的物体大至关重要。
描述符集布局
下一步是在 C++ 端定义 UBO,并告知 Vulkan 关于顶点着色器中的此描述符。
struct UniformBufferObject {
glm::mat4 model;
glm::mat4 view;
glm::mat4 proj;
};
我们可以使用 GLM 中的数据类型完全匹配着色器中的定义。 矩阵中的数据与着色器期望的方式二进制兼容,因此我们稍后可以直接 memcpy
一个 UniformBufferObject
到 VkBuffer
。
我们需要为管线创建中使用的每个描述符绑定提供详细信息,就像我们必须为每个顶点属性及其 location
索引提供详细信息一样。 我们将设置一个新函数来定义所有这些信息,称为 createDescriptorSetLayout
。 它应该在管线创建之前调用,因为我们将在那里需要它。
void initVulkan() {
...
createDescriptorSetLayout();
createGraphicsPipeline();
...
}
...
void createDescriptorSetLayout() {
}
每个绑定都需要通过 VkDescriptorSetLayoutBinding
结构进行描述。
void createDescriptorSetLayout() {
VkDescriptorSetLayoutBinding uboLayoutBinding{};
uboLayoutBinding.binding = 0;
uboLayoutBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
uboLayoutBinding.descriptorCount = 1;
}
前两个字段指定着色器中使用的 binding
和描述符的类型,它是一个统一缓冲区对象。 着色器变量可以表示统一缓冲区对象的数组,descriptorCount
指定数组中值的数量。 例如,这可用于指定骨骼动画中每个骨骼的变换。 我们的 MVP 变换在单个统一缓冲区对象中,因此我们使用 descriptorCount
为 1
。
uboLayoutBinding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;
我们还需要指定在哪些着色器阶段将引用描述符。 stageFlags
字段可以是 VkShaderStageFlagBits
值的组合或值 VK_SHADER_STAGE_ALL_GRAPHICS
。 在我们的例子中,我们仅从顶点着色器引用描述符。
uboLayoutBinding.pImmutableSamplers = nullptr; // Optional
pImmutableSamplers
字段仅与图像采样相关的描述符相关,我们稍后会看到。 您可以将此值保留为默认值。
所有描述符绑定都组合到单个 VkDescriptorSetLayout
对象中。 在 pipelineLayout
上方定义一个新的类成员
VkDescriptorSetLayout descriptorSetLayout;
VkPipelineLayout pipelineLayout;
然后我们可以使用 vkCreateDescriptorSetLayout
创建它。此函数接受一个简单的 VkDescriptorSetLayoutCreateInfo
,其中包含绑定数组。
VkDescriptorSetLayoutCreateInfo layoutInfo{};
layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
layoutInfo.bindingCount = 1;
layoutInfo.pBindings = &uboLayoutBinding;
if (vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &descriptorSetLayout) != VK_SUCCESS) {
throw std::runtime_error("failed to create descriptor set layout!");
}
我们需要在管线创建期间指定描述符集布局,以告知 Vulkan 着色器将使用哪些描述符。描述符集布局在管线布局对象中指定。修改 VkPipelineLayoutCreateInfo
以引用布局对象。
VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutInfo.setLayoutCount = 1;
pipelineLayoutInfo.pSetLayouts = &descriptorSetLayout;
你可能想知道为什么这里可以指定多个描述符集布局,因为单个布局已经包含所有绑定。我们将在下一章中回到这个问题,届时我们将研究描述符池和描述符集。
描述符集布局应该在我们可能创建新图形管线期间保持存在,即直到程序结束。
void cleanup() {
cleanupSwapChain();
vkDestroyDescriptorSetLayout(device, descriptorSetLayout, nullptr);
...
}
Uniform 缓冲区
在下一章中,我们将指定包含着色器 UBO 数据的缓冲区,但我们需要首先创建此缓冲区。我们将在每一帧将新数据复制到 uniform 缓冲区,因此使用临时缓冲区没有任何意义。在这种情况下,它只会增加额外的开销,并可能降低性能而不是提高性能。
我们应该有多个缓冲区,因为可能有多个帧同时在处理中,我们不想在先前帧仍在从中读取数据时,更新缓冲区以准备下一帧!因此,我们需要拥有与飞行中帧数一样多的 uniform 缓冲区,并写入当前未被 GPU 读取的 uniform 缓冲区。
为此,为 uniformBuffers
和 uniformBuffersMemory
添加新的类成员。
VkBuffer indexBuffer;
VkDeviceMemory indexBufferMemory;
std::vector<VkBuffer> uniformBuffers;
std::vector<VkDeviceMemory> uniformBuffersMemory;
std::vector<void*> uniformBuffersMapped;
类似地,创建一个新的函数 createUniformBuffers
,该函数在 createIndexBuffer
之后调用并分配缓冲区。
void initVulkan() {
...
createVertexBuffer();
createIndexBuffer();
createUniformBuffers();
...
}
...
void createUniformBuffers() {
VkDeviceSize bufferSize = sizeof(UniformBufferObject);
uniformBuffers.resize(MAX_FRAMES_IN_FLIGHT);
uniformBuffersMemory.resize(MAX_FRAMES_IN_FLIGHT);
uniformBuffersMapped.resize(MAX_FRAMES_IN_FLIGHT);
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
createBuffer(bufferSize, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, uniformBuffers[i], uniformBuffersMemory[i]);
vkMapMemory(device, uniformBuffersMemory[i], 0, bufferSize, 0, &uniformBuffersMapped[i]);
}
}
我们使用 vkMapMemory
在创建后立即映射缓冲区,以获取一个指针,稍后我们可以将数据写入其中。该缓冲区在应用程序的整个生命周期中都保持映射到此指针。此技术称为“持久映射”,并且适用于所有 Vulkan 实现。不必每次需要更新缓冲区时都映射缓冲区可以提高性能,因为映射并非免费。
uniform 数据将用于所有绘制调用,因此包含该数据的缓冲区仅应在我们停止渲染时销毁。
void cleanup() {
...
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
vkDestroyBuffer(device, uniformBuffers[i], nullptr);
vkFreeMemory(device, uniformBuffersMemory[i], nullptr);
}
vkDestroyDescriptorSetLayout(device, descriptorSetLayout, nullptr);
...
}
更新 uniform 数据
创建一个新的函数 updateUniformBuffer
,并在 drawFrame
函数中提交下一帧之前添加对其的调用。
void drawFrame() {
...
updateUniformBuffer(currentFrame);
...
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
...
}
...
void updateUniformBuffer(uint32_t currentImage) {
}
此函数将在每一帧生成一个新的转换,以使几何体旋转。我们需要包含两个新的头文件来实现此功能。
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <chrono>
glm/gtc/matrix_transform.hpp
头文件公开了可用于生成模型转换(如 glm::rotate
)、视图转换(如 glm::lookAt
)和投影转换(如 glm::perspective
)的函数。
chrono
标准库头文件公开了用于进行精确计时的函数。我们将使用它来确保几何体每秒旋转 90 度,而与帧速率无关。
void updateUniformBuffer(uint32_t currentImage) {
static auto startTime = std::chrono::high_resolution_clock::now();
auto currentTime = std::chrono::high_resolution_clock::now();
float time = std::chrono::duration<float, std::chrono::seconds::period>(currentTime - startTime).count();
}
updateUniformBuffer
函数将首先使用一些逻辑来计算自渲染开始以来以秒为单位的时间(具有浮点精度)。
我们现在将在 uniform 缓冲区对象中定义模型、视图和投影转换。模型旋转将是使用 time
变量绕 Z 轴的简单旋转。
UniformBufferObject ubo{};
ubo.model = glm::rotate(glm::mat4(1.0f), time * glm::radians(90.0f), glm::vec3(0.0f, 0.0f, 1.0f));
glm::rotate
函数采用现有的转换、旋转角度和旋转轴作为参数。glm::mat4(1.0f)
构造函数返回一个单位矩阵。使用 time * glm::radians(90.0f)
的旋转角度可达到每秒旋转 90 度的目的。
ubo.view = glm::lookAt(glm::vec3(2.0f, 2.0f, 2.0f), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f));
对于视图转换,我决定从上方以 45 度角查看几何体。glm::lookAt
函数采用眼睛位置、中心位置和向上轴作为参数。
ubo.proj = glm::perspective(glm::radians(45.0f), swapChainExtent.width / (float) swapChainExtent.height, 0.1f, 10.0f);
我选择使用垂直视野为 45 度的透视投影。其他参数是宽高比、近和远视图平面。重要的是使用当前交换链范围来计算宽高比,以考虑调整大小后窗口的新宽度和高度。
ubo.proj[1][1] *= -1;
GLM 最初是为 OpenGL 设计的,其中裁剪坐标的 Y 坐标是反转的。补偿这种情况的最简单方法是翻转投影矩阵中 Y 轴缩放因子的符号。如果不这样做,则图像将倒置渲染。
现在定义了所有转换,因此我们可以将 uniform 缓冲区对象中的数据复制到当前的 uniform 缓冲区。这与我们对顶点缓冲区所做的操作完全相同,只是没有临时缓冲区。如前所述,我们仅映射 uniform 缓冲区一次,因此我们可以直接写入它,而无需再次映射。
memcpy(uniformBuffersMapped[currentImage], &ubo, sizeof(ubo));
以这种方式使用 UBO 并不是将频繁变化的值传递到着色器的最有效方法。将小缓冲区数据传递到着色器的更有效方法是推送常量。我们可能会在以后的章节中研究这些。
在下一章中,我们将研究描述符集,它实际上会将 VkBuffer
绑定到 uniform 缓冲区描述符,以便着色器可以访问此转换数据。