我们现在能为每个顶点传输任意属性到顶点着色器了,但是用全局变量怎么样呢?我们本章要转移到3D图形上,这就需要Model-View-Projection矩阵了,也就是MVP矩阵(模型-视口-投影矩阵)。我们可以将它包括进来作为顶点数据,但是这比较浪费内存,也要求我们在它的变换改变的时候更新顶点缓冲,而变换是很可能在每一帧都改变的。
Vulkan中正确处理该问题的方法是使用资源描述符。描述符是着色器能自由访问缓冲和图像等资源的一种方式。我们要建立一个缓冲,它包含了变换矩阵及让顶点着色器通过描述符访问它们。描述符用法由以下三部分组成:
管线创建阶段指定一个描述符布局;
从描述符池指定一个描述符集合;
在渲染阶段构建描述符。
描述符布局指定了将要被管线访问的资源类型,就和渲染通道指定了将要访问的附件的类型一样。描述符集合指定了将要绑定到描述符的实际缓冲或者图像资源,就和帧缓冲指定了实际图像视图来绑定渲染通道附件一样。描述符集合为绘制命令绑定,就和顶点缓冲及帧缓冲一样。
描述符有很多类型,但是本章我们就用统一缓冲对象。以后的章节再看其他类型的描述符,但是基本的处理都是一样的。假设我们有个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;
}
我们将会每帧更新该MVP矩阵以让矩形在3D模式转动起来。
修改顶点着色器以包括统一缓冲对象,我这里认为你对MVP矩阵比较熟悉,否则就看第一章提到的资源学习。
#version 450
#extension GL_ARB_separate_shader_objects : enable
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到BkBuffer。
我们要提供为创建管线在着色器中使用的每个描述符绑定的细节信息,就和我们要为每个顶点属性和它的location索引做的工作一样。我们将会设置一个新的函数来定义所有这些信息,该函数就是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;
你可能好奇,为什么可以指定多个描述符集合布局,因为单个已经包含了所有的绑定。我们以后会再看,那时候我们还会介绍描述符池和描述符集合。
在我们创建新的图形管线的时候,描述符布局应该就在旁边,直到程序结束。
vkDestroyDescriptorSetLayout(device, descriptorSetLayout, nullptr);
这一行就放在清理交换链之后执行。
下一章我们会为着色器指定含有统一缓冲对象的缓冲,但是我们要先创建缓冲。我们打算每一帧都先复制新的数据到统一缓冲。
我们应该有多个缓冲,因为可能同时有很多帧都在准备中,而前一帧还在读取的时候,我们不想在下一帧准备的时候就更新缓冲。我们可以每一帧或者每个交换链图形都做一个统一缓冲。但是,由于我们需要从命令缓冲引用统一缓冲,我们选择每一个交换链图像都有一个统一缓冲的方式。
为此,为uniformBuffers和uniformBuffersMemory添加新的类成员,
std::vector<VkBuffer> uniformBuffers;
std::vector<VkDeviceMemory> uniformBuffersMemory;
类似的,创建一个新的方法createUniformBuffers,在createIndexBuffer之后调用来分配缓冲:
void createUniformBuffers() {
VkDeviceSize bufferSize = sizeof(UniformBufferObject);
uniformBuffers.resize(swapChainImages.size());
uniformBuffersMemory.resize(swapChainImages.size());
for (size_t i = 0; i < swapChainImages.size(); 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。统一数据会被所有绘制命令使用,所以包含它的缓冲应该在我们停止渲染的时候才进行销毁。由于它依赖于交换链图像个数,这个个数可能会在重建之后改变,所以我们在清理交换链部分结尾处清理它:
for (size_t i = 0; i < swapChainImages.size(); i++) {
vkDestroyBuffer(device, uniformBuffers[i], nullptr);
vkFreeMemory(device, uniformBuffersMemory[i], nullptr);
}
这也意味着我们要在重建交换链的部分重建它:
createFramebuffers();
createUniformBuffers();
createCommandBuffers();
创建一个新的方法updateUniformBuffer,然后从drawFrame中调用,就在知道我们获取的是哪个交换链图像之后:
updateUniformBuffer(imageIndex);
VkSubmitInfo submitInfo = {};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
该方法会每帧生成一个新的变换让几何体转动起来。我们要包含两个新的头文件:
#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方法开始的时候会计算从开始渲染起以秒为单位的时间。
我们现在在同意缓冲对象中定义模型、视图和投影变换。模型旋转就是一个简单的绕着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轴的大小因子的符号。如果你不这么做,那么图像渲染后就是上下颠倒的。
所有变换都定义了,所以我们可以从统一缓冲对象拷贝数据到当前统一缓冲中了。这就和我们对顶点缓冲所做的一样,除了没有临时缓冲:
void* data;
vkMapMemory(device, uniformBuffersMemory[currentImage], 0, sizeof(ubo), 0, &data);
memcpy(data, &ubo, sizeof(ubo));
vkUnmapMemory(device, uniformBuffersMemory[currentImage]);
这种方式使用统一缓冲对象将频繁修改的值传输到着色器不是最高效的。更高效的一种方式将一小部分缓冲数据传到着色器,也就是push constants。
后面我们会查看描述符集合,它会绑定VkBuffers到统一缓冲描述符,以便着色器能访问变换数据。