描述符集布局和缓冲区
介绍
我们现在能够将任意属性传递给顶点着色器的每个顶点,但全局变量呢?从本章开始,我们将转向 3D 图形,这需要一个模型-视图-投影矩阵。我们可以将其作为顶点数据包含在内,但这会浪费内存,并且每当变换更改时都需要更新顶点缓冲区。变换可能每帧都会更改。
在 Vulkan 中解决这个问题的正确方法是使用 资源描述符。描述符是着色器自由访问缓冲区和图像等资源的一种方式。我们将设置一个包含变换矩阵的缓冲区,并通过描述符让顶点着色器访问它们。描述符的使用包括三个部分:
-
在管线创建期间指定描述符集布局
-
从描述符池分配描述符集
-
在渲染期间绑定描述符集
描述符集布局 指定管线将访问的资源类型,就像渲染通道指定将访问的附件类型一样。描述符集 指定将绑定到描述符的实际缓冲区或图像资源,就像帧缓冲区指定绑定到渲染通道附件的实际图像视图一样。然后,描述符集像顶点缓冲区和帧缓冲区一样被绑定到绘制命令。
描述符有许多类型,但在本章中,我们将使用统一缓冲区对象(UBO)。我们将在未来的章节中探讨其他类型的描述符,但基本过程是相同的。假设我们有一个 C 结构体,其中包含我们希望顶点着色器拥有的数据:
struct UniformBufferObject {
glm::mat4 model;
glm::mat4 view;
glm::mat4 proj;
};
然后,我们可以将数据复制到 VkBuffer 中,并通过顶点着色器中的统一缓冲区对象描述符访问它:
struct VSInput {
float2 inPosition;
float3 inColor;
};
struct UniformBuffer {
float4x4 model;
float4x4 view;
float4x4 proj;
};
ConstantBuffer<UniformBuffer> ubo;
struct VSOutput
{
float4 pos : SV_Position;
float3 color;
};
[shader("vertex")]
VSOutput vertMain(VSInput input) {
VSOutput output;
output.pos = mul(ubo.proj, mul(ubo.view, mul(ubo.model, float4(input.inPosition, 0.0, 1.0))));
output.color = input.inColor;
return output;
}
我们将每帧更新模型、视图和投影矩阵,以使前一章的矩形在 3D 中旋转。
顶点着色器
修改顶点着色器以包含如上所述统一缓冲区对象。我假设你熟悉 MVP 变换。如果不熟悉,请参阅第一章提到的 资源。
struct VSInput {
float2 inPosition;
float3 inColor;
};
struct UniformBuffer {
float4x4 model;
float4x4 view;
float4x4 proj;
};
ConstantBuffer<UniformBuffer> ubo;
struct VSOutput
{
float4 pos : SV_Position;
float3 color;
};
[shader("vertex")]
VSOutput vertMain(VSInput input) {
VSOutput output;
output.pos = mul(ubo.proj, mul(ubo.view, mul(ubo.model, float4(input.inPosition, 0.0, 1.0))));
output.color = input.inColor;
return output;
}
[shader("fragment")]
float4 fragMain(VSOutput vertIn) : SV_TARGET {
return float4(vertIn.color, 1.0);
}
带有 SV_Position 的行被更改为使用变换来计算裁剪坐标中的最终位置。与 2D 三角形不同,裁剪坐标的最后一个分量可能不是 1,这将在转换为屏幕上的最终标准化设备坐标时导致除法。这在透视投影中用作 透视除法,对于使较近的物体看起来比远处的物体更大至关重要。
描述符集布局
下一步是在 C++ 端定义 UBO,并告诉 Vulkan 顶点着色器中的这个描述符。
struct UniformBufferObject {
glm::mat4 model;
glm::mat4 view;
glm::mat4 proj;
};
我们可以使用 GLM 中的数据类型精确匹配着色器中的定义。矩阵中的数据与着色器期望的方式二进制兼容,因此我们稍后可以直接将 UniformBufferObject memcpy 到 VkBuffer 中。
我们需要为管线创建提供着色器中使用的每个描述符绑定的详细信息,就像我们为每个顶点属性及其 location 索引所做的那样。我们将设置一个新函数 createDescriptorSetLayout 来定义所有这些信息。它应该在管线创建之前调用,因为我们需要在那里使用它。
void initVulkan() {
...
createDescriptorSetLayout();
createGraphicsPipeline();
...
}
...
void createDescriptorSetLayout() {
}
每个绑定需要通过 VkDescriptorSetLayoutBinding 结构体描述。
void createDescriptorSetLayout() {
vk::DescriptorSetLayoutBinding uboLayoutBinding(0, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex, nullptr);
}
前两个字段指定着色器中使用的 binding 和描述符类型,即统一缓冲区对象。着色器变量可以表示统一缓冲区对象的数组,descriptorCount 指定数组中的值数量。例如,这可以用于为骨骼动画中的每个骨骼指定变换。我们的 MVP 变换位于单个统一缓冲区对象中,因此我们使用 descriptorCount 为 1。
我们还需要指定描述符将在哪些着色器阶段被引用。stageFlags 字段可以是 VkShaderStageFlagBits 值的组合或值 VK_SHADER_STAGE_ALL_GRAPHICS。在我们的例子中,我们仅从顶点着色器引用描述符。
pImmutableSamplers 字段仅与图像采样相关的描述符相关,我们将在后面探讨。你可以将其保留为默认值。
所有描述符绑定组合成一个 VkDescriptorSetLayout 对象。在 pipelineLayout 上方定义一个新的类成员:
vk::raii::DescriptorSetLayout descriptorSetLayout = nullptr;
vk::raii::PipelineLayout pipelineLayout = nullptr;
然后,我们可以使用 vkCreateDescriptorSetLayout 创建它。此函数接受一个简单的 VkDescriptorSetLayoutCreateInfo,其中包含绑定数组:
vk::DescriptorSetLayoutBinding uboLayoutBinding(0, vk::DescriptorType::eUniformBuffer, 1, vk::ShaderStageFlagBits::eVertex, nullptr);
vk::DescriptorSetLayoutCreateInfo layoutInfo({}, 1, &uboLayoutBinding);
descriptorSetLayout = vk::raii::DescriptorSetLayout( device, layoutInfo );
我们需要在管线创建期间指定描述符集布局,以告诉 Vulkan 着色器将使用哪些描述符。描述符集布局在管线布局对象中指定。修改 VkPipelineLayoutCreateInfo 以引用布局对象:
vk::PipelineLayoutCreateInfo pipelineLayoutInfo{ .setLayoutCount = 1, .pSetLayouts = &*descriptorSetLayout, .pushConstantRangeCount = 0 };
你可能会想知道为什么可以在这里指定多个描述符集布局,因为单个布局已经包含所有绑定。我们将在下一章中回到这个问题,届时我们将探讨描述符池和描述符集。
统一缓冲区
在下一章中,我们将指定包含着色器 UBO 数据的缓冲区,但我们需要首先创建这个缓冲区。我们将每帧将新数据复制到统一缓冲区,因此在这种情况下使用暂存缓冲区没有意义。它只会增加额外的开销,并可能降低性能而不是提高性能。
我们应该有多个缓冲区,因为可能有多个帧同时在运行,我们不希望在准备下一帧时更新缓冲区,而前一帧仍在从中读取!因此,我们需要有与飞行中帧数量相同的统一缓冲区,并写入当前未被 GPU 读取的统一缓冲区。
为此,为 uniformBuffers 和 uniformBuffersMemory 添加新的类成员:
vk::raii::Buffer indexBuffer = nullptr;
vk::raii::DeviceMemory indexBufferMemory = nullptr;
std::vector<vk::raii::Buffer> uniformBuffers;
std::vector<vk::raii::DeviceMemory> uniformBuffersMemory;
std::vector<void*> uniformBuffersMapped;
类似地,创建一个新函数 createUniformBuffers,在 createIndexBuffer 之后调用,并分配缓冲区:
void initVulkan() {
...
createVertexBuffer();
createIndexBuffer();
createUniformBuffers();
...
}
...
void createUniformBuffers() {
uniformBuffers.clear();
uniformBuffersMemory.clear();
uniformBuffersMapped.clear();
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
vk::DeviceSize bufferSize = sizeof(UniformBufferObject);
vk::raii::Buffer buffer({});
vk::raii::DeviceMemory bufferMem({});
createBuffer(bufferSize, vk::BufferUsageFlagBits::eUniformBuffer, vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent, buffer, bufferMem);
uniformBuffers.emplace_back(std::move(buffer));
uniformBuffersMemory.emplace_back(std::move(bufferMem));
uniformBuffersMapped.emplace_back( uniformBuffersMemory[i].mapMemory(0, bufferSize));
}
}
我们在创建后立即使用 vkMapMemory 映射缓冲区,以获取一个指针,稍后我们可以向其写入数据。缓冲区在应用程序的整个生命周期内保持映射到此指针。这种技术称为 "持久映射",适用于所有 Vulkan 实现。不需要每次更新缓冲区时都映射它,可以提高性能,因为映射不是免费的。
更新统一数据
创建一个新函数 updateUniformBuffer,并在提交下一帧之前从 drawFrame 函数调用它:
void drawFrame() {
...
updateUniformBuffer(currentFrame);
...
const vk::SubmitInfo submitInfo{ .waitSemaphoreCount = 1, .pWaitSemaphores = &*presentCompleteSemaphore[currentFrame],
.pWaitDstStageMask = &waitDestinationStageMask, .commandBufferCount = 1, .pCommandBuffers = &*commandBuffers[currentFrame],
.signalSemaphoreCount = 1, .pSignalSemaphores = &*renderFinishedSemaphore[currentFrame] };
...
}
...
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 函数将以一些逻辑开始,计算自渲染开始以来的时间(以秒为单位),具有浮点精度。
我们现在将在统一缓冲区对象中定义模型、视图和投影变换。模型旋转将是一个简单的绕 Z 轴的旋转,使用 time 变量:
UniformBufferObject ubo{};
ubo.model = 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 = 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), static_cast<float>(swapChainExtent.width) / static_cast<float>(swapChainExtent.height), 0.1f, 10.0f);
我选择使用垂直视场为 45 度的透视投影。其他参数是宽高比、近和远视平面。重要的是使用当前交换链范围来计算宽高比,以考虑窗口调整大小后的新宽度和高度。
ubo.proj[1][1] *= -1;
GLM 最初是为 OpenGL 设计的,其中裁剪坐标的 Y 坐标是反转的。补偿这一点的最简单方法是在投影矩阵中翻转 Y 轴的缩放因子的符号。如果不这样做,图像将倒置渲染。
现在所有变换都已定义,我们可以将统一缓冲区对象中的数据复制到当前统一缓冲区中。这与我们对顶点缓冲区所做的完全相同,只是没有暂存缓冲区。如前所述,我们只映射统一缓冲区一次,因此可以直接写入它而无需再次映射:
memcpy(uniformBuffersMapped[currentImage], &ubo, sizeof(ubo));
以这种方式使用 UBO 并不是将频繁更改的值传递给着色器的最有效方法。将少量数据传递给着色器的更有效方法是 推送常量。我们可能会在未来的章节中探讨这些。
在 下一章 中,我们将探讨描述符集,它将实际将 VkBuffer 绑定到统一缓冲区描述符,以便着色器可以访问此变换数据。
C++ 代码 / slang 着色器 / GLSL 顶点着色器 / GLSL 片段着色器