简介

现在我们能够为每个顶点向顶点着色器传递任意属性,但是全局变量呢?从本章开始,我们将继续学习 3D 图形,这需要一个模型-视图-投影矩阵。我们可以将其作为顶点数据包含在内,但这会浪费内存,并且每次变换更改时都需要更新顶点缓冲区。变换很容易在每一帧都发生变化。

在 Vulkan 中解决这个问题的正确方法是使用资源描述符。描述符是着色器自由访问缓冲区和图像等资源的一种方式。我们将设置一个包含变换矩阵的缓冲区,并让顶点着色器通过描述符访问它们。描述符的使用包括三个部分

  • 在管线创建期间指定描述符布局
  • 从描述符池分配描述符集
  • 在渲染期间绑定描述符集

描述符布局指定管线将要访问的资源类型,就像渲染通道指定将要访问的附件类型一样。描述符集指定将要绑定到描述符的实际缓冲区或图像资源,就像帧缓冲指定要绑定到渲染通道附件的实际图像视图一样。然后,描述符集就像顶点缓冲区和帧缓冲一样,被绑定用于绘制命令。

描述符有很多类型,但在本章中,我们将使用 uniform 缓冲区对象 (UBO)。我们将在以后的章节中查看其他类型的描述符,但基本过程是相同的。假设我们有想要顶点着色器拥有的数据,它在一个像这样的 C 结构中

struct UniformBufferObject {
    glm::mat4 model;
    glm::mat4 view;
    glm::mat4 proj;
};

然后我们可以将数据复制到 VkBuffer,并通过来自顶点着色器的 uniform 缓冲区对象描述符来访问它,就像这样

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 中旋转。

顶点着色器

修改顶点着色器以包含 uniform 缓冲区对象,就像上面指定的那样。我假设你熟悉 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;
}

请注意,uniforminout 声明的顺序无关紧要。binding 指令类似于属性的 location 指令。我们将在描述符布局中引用此绑定。带有 gl_Position 的行被更改为使用变换来计算裁剪坐标中的最终位置。与 2D 三角形不同,裁剪坐标的最后一个分量可能不是 1,这将在转换为屏幕上最终的归一化设备坐标时导致除法。这在透视投影中用作透视除法,对于使较近的物体看起来比远处物体更大至关重要。

描述符集布局

下一步是在 C++ 端定义 UBO,并告诉 Vulkan 关于顶点着色器中的这个描述符。

struct UniformBufferObject {
    glm::mat4 model;
    glm::mat4 view;
    glm::mat4 proj;
};

我们可以使用 GLM 中的数据类型完全匹配着色器中的定义。矩阵中的数据在二进制上与着色器期望的方式兼容,因此我们稍后可以直接 memcpy 一个 UniformBufferObjectVkBuffer

我们需要为管线创建中着色器中使用的每个描述符绑定提供详细信息,就像我们必须为每个顶点属性及其 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 和描述符的类型,即 uniform 缓冲区对象。着色器变量可能表示 uniform 缓冲区对象数组,descriptorCount 指定数组中值的数量。这可以用于为骨骼动画中的每个骨骼指定变换。我们的 MVP 变换在一个单一的 uniform 缓冲区对象中,所以我们使用 descriptorCount1

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 缓冲区

为此,为 uniformBuffersuniformBuffersMemory 添加新的类成员

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) {

}

此函数将每帧生成一个新的变换,以使几何体旋转起来。我们需要包含两个新的头文件来实现此功能

#define GLM_FORCE_RADIANS
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>

#include <chrono>

glm/gtc/matrix_transform.hpp 头文件公开了可用于生成模型变换(如 glm::rotate)、视图变换(如 glm::lookAt)和投影变换(如 glm::perspective)的函数。GLM_FORCE_RADIANS 定义是必要的,以确保像 glm::rotate 这样的函数使用弧度作为参数,以避免任何可能的混淆。

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 缓冲区对象中定义模型、视图和投影变换。模型旋转将是围绕 Z 轴的简单旋转,使用 time 变量

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 缓冲区描述符,以便着色器可以访问此变换数据。

C++ 代码 / 顶点着色器 / 片段着色器