目录
Introduction
我们现在可以将任意属性传递给每个顶点的顶点着色器,但是全局变量呢?从本章开始,我们将继续学习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中旋转。
Vertex shader
修改顶点着色器以包括上面指定的统一缓冲区对象。我假设您熟悉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;
}
注意,统一、输入和输出声明的顺序无关紧要。绑定指令类似于属性的位置指令。我们将在描述符布局中引用此绑定。具有gl_Position的线被更改为使用变换来计算剪辑坐标中的最终位置。与2D三角形不同,剪辑坐标的最后一个分量可能不是1,当转换为屏幕上的最终规格化设备坐标时,这将导致分割。这在透视投影中用作透视分割,对于使较近的对象看起来比较远的对象更大至关重要。
Descriptor set layout
下一步是在C++端定义UBO,并告诉Vulkan顶点着色器中的这个描述符。
struct UniformBufferObject {
glm::mat4 model;
glm::mat4 view;
glm::mat4 proj;
};
我们可以使用GLM中的数据类型精确匹配着色器中的定义。矩阵中的数据与着色器期望的方式是二进制兼容的,因此我们可以稍后将UniformBufferObject存储到VkBuffer中。
我们需要提供着色器中用于管道创建的每个描述符绑定的详细信息,就像我们必须为每个顶点属性及其位置索引所做的那样。我们将设置一个新函数来定义所有这些信息,称为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;
}
前两个字段指定着色器中使用的绑定和描述符的类型,这是一个统一的缓冲区对象。着色器变量可以表示统一缓冲区对象的数组,descriptorCount指定数组中的值数。例如,这可以用于为骨骼动画的骨骼中的每个骨骼指定变换。我们的MVP转换在一个统一的缓冲区对象中,因此我们使用的描述符计数为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 buffer
在下一章中,我们将指定包含着色器的UBO数据的缓冲区,但我们需要先创建此缓冲区。我们将在每一帧将新数据复制到统一缓冲区,因此使用分段缓冲区没有任何意义。在这种情况下,它只会增加额外的开销,可能会降低性能而不是提高性能。
我们应该有多个缓冲区,因为多个帧可能同时在飞行中,我们不想在前一帧仍在读取缓冲区时更新缓冲区以准备下一帧!因此,我们需要有与运行中的帧一样多的统一缓冲区,并写入GPU当前未读取的统一缓冲。
为此,为uniformBuffers和uniformBuffersMemory添加新的类成员:
VkBuffer indexBuffer;
VkDeviceMemory indexBufferMemory;
std::vector<VkBuffer> uniformBuffers;
std::vector<VkDeviceMemory> uniformBuffersMemory;
std::vector<void*> uniformBuffersMapped;
类似地,创建一个在createIndexBuffer之后调用的新函数createUniformBuffers,并分配缓冲区:
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实现。不必在每次需要更新时都映射缓冲区,这会提高性能,因为映射不会释放。
统一数据将用于所有绘制调用,因此只有当我们停止渲染时,包含它的缓冲区才会被破坏。
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);
...
}
Updating uniform data
创建一个新函数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_RIANS定义对于确保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函数将从一些逻辑开始,以计算渲染以浮点精度开始后的时间(以秒为单位)。
现在我们将在统一缓冲区对象中定义模型、视图和投影变换。模型旋转将是使用时间变量围绕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轴比例因子上的符号。如果不执行此操作,则图像将呈现为倒置。
现在定义了所有转换,因此我们可以将统一缓冲区对象中的数据复制到当前的统一缓冲区。这与我们对顶点缓冲区的处理方式完全相同,只是没有暂存缓冲区。如前所述,我们只映射一次统一缓冲区,因此我们可以直接写入它,而无需再次映射:
memcpy(uniformBuffersMapped[currentImage], &ubo, sizeof(ubo));
这样使用UBO并不是将频繁更改的值传递给着色器的最有效方法。将一小部分数据缓冲区传递给着色器的一种更有效的方法是推送常量。我们可以在未来的章节中讨论这些问题。
在下一章中,我们将研究描述符集,它实际上将VkBuffers绑定到统一缓冲区描述符,以便着色器可以访问该转换数据。