Vulkan Tutorial 教程翻译(九)统一变量缓冲区

139 阅读18分钟

统一缓冲区

布局描述符与缓冲区

介绍

我们现在可以为每个顶点传递各种各样的属性,可是,应该如何处理全局变量呢?我们准备在这一章进入3D图形的领域,现在需要一个 Model - View - Project 矩阵,可以在顶点属性中去包含它,但是这太过浪费内存了,而且当需要变换矩阵时,就需要更新顶点缓冲区,而这样的转换在每一帧都有可能发生.

Vulkan中处理这个场景的正确方法是使用资源描述符.描述符是一种可以让着色器自由地访问资源的方法,我们将会去安装一个包含了变换矩阵的缓冲区,并且让顶点着色器可以通过描述符访问矩阵.描述符的使用包含三部分:

  • 在管线创建时指定一个描述符集合的布局(descriptor set layout)
  • 从描述符池中分配描述符集合
  • 在渲染时绑定描述符

描述符集合布局指定了管线访问时资源的布局,就像渲染通道指定了附件的类型一样,一个描述符集合指定了实际的缓冲区或者图像资源,就像帧缓冲区指定了真正的绑定到渲染通道附件的图像资源一样,描述符集也会被绑定到绘制命令中,就像顶点缓冲区和帧缓冲.

有许多类型的描述符,但是在本章,我们使用统一变量缓冲区(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.0f ,1.0f);
    fragColor = inColor;
}

我们将在每一帧去更新 模型,视图,透视矩阵,以让前一章的矩形绕固定轴旋转。

顶点着色器

像上面提到的那样,修改顶点着色器让它包含统一变量缓冲区,我们假定你熟悉 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代表的属性是相似的,我们会在绑定描述符集中引用这个 binding . gl_Position 这一行修改成矩阵乘法去计算出最终的屏幕裁剪坐标系坐标,与2D三角形不同,最后一个参数可能不是1,这将会导致坐标值被除以最后一个分量,以最终适配标准归一化的屏幕坐标,这被使用在投影矩阵中,作为投影切割,它是产生近大远小效果的核心。

描述符集合布局

下一步是在C++侧定义UBO,告诉Vulkan在顶点着色器中的描述.

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

我们可以使用 GLM 库中的数据类型完美匹配glsl中的类型,矩阵的数据是在二进制层面兼容的,所以我们可以使用memcpy拷贝 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 变换只有一个单个的统一缓冲区变量,所以这里count 设置为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;

你也许想知道为什么这里可以设置多个描述符集合,因为似乎单个描述符集合已经包含了所有的绑定信息了.下一章会深入讨论描述符集合与描述符池.

描述符集合的生命周期是随着图形管线绑定在一起的,所以在cleanup中才可销毁它.

void cleanup() {
    cleanupSwapChain();

    vkDestroyDescriptorSetLayout(device, descriptorSetLayout, nullptr);
    ...
}

统一缓冲区

下一章我们会指定包含UBO数据的缓冲区,但是首先我们需要创建出这个缓冲区.我们需要在每一帧拷贝新的数据到统一缓冲区中,所以这里并不适合使用暂存缓冲,它将会增加额外的负载.

我们应该有多个UBO缓冲区,因为存在多帧并行渲染,而我们并不想在更新下一帧时,前一帧仍然能读取到它。所以这里要像CommandBuffer 一样创建多个UBO Buffer,从而保证当前写入的UBO不是GPU渲染时正在读取的。

现在添加全局变量 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驱动都有这样的实现,这样就不需要每次需要更新时,再映射缓存了,从而提升效率。

会在绘制时,会使用这些统一变量,所以只有在程序退出时,才可以销毁这些UBO.

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);
    ...
}

更新统一变量数据

创建新函数 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 函数计算出当前时间的秒数,类型为浮点型.

我们现在在统一变量缓冲区中定义了 模型,视图和投影变换,模型的旋转是随着时间绕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°角的Fov,其他参数分别是宽高比,近裁剪平面和远裁剪平面.使用交换链的大小动态计算出宽高比,这可以适配窗口大小修改的场景.

ubo.proj[1][1] *= -1;

GLM库最初是被设计用来给OpenGL使用的。它的裁剪坐标系Y轴方向与Vulkan是相反的,最简单的修正方式是对投影矩阵的y缩放轴取反,如果你不做这一步,得到的渲染图像是相反的。

所有的变化都已经定义完成了,现在可以将这些数据拷贝进入当前帧的UBO中了,与之前更新顶点缓冲区数据一样(不使用临时缓冲的方式),我们之前已经映射了一次缓存了,所以这里不用再重新映射:

memcpy(uniformBuffersMapped[currentImage], &ubo, sizeof(ubo));

使用UBO并不是给shader传递频繁更改的数据最有效率的方式,一个更有效率的方式是传递一个更小的缓冲区给着色器,称之为推送常量(Push Constants),后面会介绍到.

在下一章,我们会看到描述符集,它会真正将 VkBuffer 绑定到 统一缓冲区描述符上以便于着色器可以访问这些变换矩阵数据.

描述符池与描述符集合

介绍

前一章介绍的描述符集合布局说明了描述符的种类,在本章中,我们准备去为每一个 VkBuffer 资源绑定一个统一缓冲区描述符.

描述符池

描述符集合不能被直接创建,必须从一个描述符池中分配出来,现在创建一个 createDescriptorPool 函数.

void initVulkan() {
    ...
    createUniformBuffers();
    createDescriptorPool();
    ...
}

...

void createDescriptorPool() {

}

首先使用结构体 VkDescriptorPoolSize ,说明需要包含多少个描述符集合.

VkDescriptorPoolSize poolSize{};
poolSize.type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
poolSize.descriptorCount = static_cast<uint32_t>(MAX_FRAMES_IN_FLIGHT);

我们会为每一帧都分配一个描述符,这个结构体被后面的 VkDescriptorPoolCreateInfo 结构体所引用.

VkDescriptorPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
poolInfo.poolSizeCount = 1;
poolInfo.pPoolSizes = &poolSize;

除了可用的最大描述符数量以外,还需要指定可分配的最大的描述符集合数量:

poolInfo.maxSets = static_cast<uint32_t>(MAX_FRAMES_IN_FLIGHT);

还有一个可选的标志位 VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT,用来标识是否单独的描述符集合可以被释放,现在不需要它,将这个 flag 设为 0.

VkDescriptorPool descriptorPool;

...
if (vkCreateDescriptorPool(device, &poolInfo, nullptr, &descriptorPool) != VK_SUCCESS) {
    throw std::runtime_error("failed to create descriptor pool!");
}

添加新的成员变量来接收描述符池 ,然后调用 vkCreateDescriptorPool 来创建它.

描述符集合

现在可以去分配描述符集合了,创建函数 createDescriptorSets :

void initVulkan(){
    ...
    createDescriptorPool();
    createDecriptorSets();
    ...
}
...

void createDescriptorSets(){

}

通过结构体 VkDescriptorSetAllocateInfo 来对描述符集合进行分配,需要在这个结构体里指定是从哪个池中分配出来的,并且把描述符集合的布局也填入.

std::vector<VkDescriptorSetLayout> layouts(MAX_FRAMES_IN_FLIGHT, descriptorSetLayout);
VkDescriptorSetAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
allocInfo.descriptorPool = descriptorPool;
allocInfo.descriptorSetCount = static_cast<uint32_t>(MAX_FRAMES_IN_FLIGHT);
allocInfo.pSetLayouts = layouts.data();

在我们的示例中,会为每一个可并行渲染的帧创建一个描述符集合,它们均拥有相同的布局,不幸的是,我们需要将这个布局对象拷贝多份,因为后面的函数期望输入是一个匹配集合的列表形式。

通过 vkAllocateDescriptorSets 完成描述符集合的分配,并添加新的类成员来接收对象句柄.

VkDescriptorPool descriptorPool;
std::vector<VkDescriptorSet> descriptorSets;

...
descriptorSets.resize(MAX_FRAMES_IN_FLIGHT);
if (vkAllocateDescriptorSets(device, &allocInfo, descriptorSets.data()) != VK_SUCCESS) {
    throw std::runtime_error("failed to allocate descriptor sets!");
}

并不需要去显式的清理这些描述符集合对象,因为他们会在描述符池被清理时自动回收.调用 vkAllocateDescriptorSets 分配出的描述符集合每一个都存有一个统一变量缓冲区描述符.

void cleanup(){
    ...
    vkDestroyDescriptorPool(device, descriptorPool, nullptr);

    vkDestroyDescriptorSetLayout(device, descriptorSetLayout, nullptr);
    ...
}

现在描述符集合已经被分配了出来,可是描述符依然需要被配置,现在添加一个循环来填充描述符。

for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
    
}

描述符引用了缓存对象,使用 VkDescriptorBuffInfo 结构体来配置,这个结构体指明了此描述符需要关联的缓存对象和对象的大小范围.

for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
    VkDescriptorBufferInfo bufferInfo{};
    bufferInfo.buffer = uniformBuffers[i];
    bufferInfo.offset = 0;
    bufferInfo.range = sizeof(UniformBufferObject);
}

如果你需要像示例这样覆写整个缓冲区,也可以对range使用 VK_WHOLE_SIZE,描述符的配置,使用函数 vkUpdateDescriptorSets 进行更新,这个函数需要一个 VkWriteDescriptorSet 结构体数组作为参数.

VkWriteDescriptorSet descriptorWrite{};
descriptorWrite.sType = VK_STRUCTURE_WRITE_DESCRIPTOR_SET;
descriptorWrite.dstSet = descriptorSets[i];
descriptorWrite.dstBinding = 0;
descriptorWrite.dstArrayElement = 0;

前两个参数指定了需要更新的描述符集合.我们这里给统一变量缓冲区的绑定位置为0,记住,描述符可以是一个数组,所以我们也需要在这里指定想要更新的第一个索引是什么,我们现在没有使用数据,这里简单填写0.

descriptorWrite.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
descriptorWrite.descriptorCount = 1;

需要再次指定描述符,有可能从 dstArrayElement 标识的索引位置一次更新多个标识符,descriptorCount 指定了想要更新多少个元素.

descriptorWrite.pBufferInfo = &bufferInfo;
descriptorWrite.pImageInfo = nullptr;// Optional
descriptorWrite.pTexelBufferView = nullptr; // Optional

pBufferInfo 被用来引用缓冲数据,pImageInfo代表图像数据,pTexelBufferView被用来描述图像视图.我们的描述符是基于缓冲的,所以这里使用 pBufferInfo.

vkUpdateDescriptorSets(device, 1, &descriptorWrite, 0 , nullptr);

使用 vkUpdateDescriptorSets 进行更新,它接收两种类型的数组作为参数: VkWriteDescriptorSet数组 和 VkCopyDescriptorSet数组.正如名字暗示的那样,我们可以用它来互相拷贝描述符.

使用描述符集合

现在去更新 recordCommandBuffer 函数,使用函数vkCmdBindDescriptorSets 来在每一帧的绘制中绑定正确的描述符集合,这需要在 vkCmdDrawIndexed 之前完成调用.

vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0, 1, &descriptorSets[currentFrame], 0, nullptr);
vkCmdDrawIndexed(commandBuffer, static_cast<uint32_t>(indices.size()), 1, 0, 0, 0);

不像顶点和索引缓冲区,描述符集合并不是仅用于图形管线的,因此这里需要指定我们想将此描述符集合绑定到图形管线还是计算管线.下一个参数是描述符集合是基于哪个布局的,后面的三个参数指定了第一个描述符集合的索引,绑定的数量,以及绑定的数组,我们一会会回到这里继续介绍,最后两个参数指定了用于动态描述符的的数组及偏移,后面的章节也会介绍.

如果现在运行程序,将会什么都看不到.问题出在,我们在实现投影矩阵的时候针对Y轴做了翻转,索引描述的顶点现在以逆时针的顺序进行了绘制,这会导致背面剔除,所以不会绘制任何的几何体,去 createGraphicsPipeline 函数中修改 VkPipelineRasterizationStateCreateInfo 结构体的 frontFace :

rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;
rasterizer.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;

现在再运行程序,将会看到如下的结果.

spinning_quad.png

矩形现在变成了一个正方形,因为投影矩阵修正了宽高比,updateUniformBuffer 会处理屏幕宽高的变换,所以不需要在 recreatSwapchain 里重建描述符集合.

数据对齐需求

到目前为止,我们一直忽略的一点是如何精确的将C++中的数据类型匹配到着色器中同一变量的定义,看上去很明显的一点是两边数据保持一致

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

layout(binding = 0) uniform UniformBufferObject {
    mat4 model;
    mat4 view;
    mat4 proj;
} ubo;

然而并不是所有的情况都可以这么去做,举个例子,尝试对结构体和着色器代码做如下修改.

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

layout(binding = 0) uniform UniformBufferObject {
    vec2 foo;
    mat4 model;
    mat4 view;
    mat4 proj;
} ubo;

重新编译着色器代码和C++代码,然后运行,你会发现正方形的颜色不见了,那是因为我们并没有考虑到数据对齐的要求。

Vulkan希望你的结构体中的数据是以一种指定的方式进行对齐的,例如:

  • 标量类型必须对齐为N(= 4 字节的32位浮点型)整数倍
  • vec2 必须对齐为 2N (= 8字节)
  • vec3 或者 vec4 必须对齐为 4N ( = 16字节)
  • 一个内联的结构体类型必须与它的基础成员类型对齐,向上舍入为16的倍数.
  • mat4 矩阵类型必须有与 vec4 一样的对齐规则.

可以在此文档找到所有的对齐规则。

我们最开始的shader中仅有三个mat4数据,它是满足对齐需求的。因为每一个矩阵都是 4x4x4 = 64 字节的,model偏移是0,view 偏移64 ,proj偏移128.所有这些都是16的倍数,所以运行正常。

新的结构多出了一个 vec2,它是8字节的,因此会破坏所有的数据偏移,现在model偏移是8,view偏移是 72,proj偏移是136,没有一个是16的倍数.为了修复这个问题,我们可以使用C++11的alginas特性:

struct UniformBufferObject{
    glm::vec2 foo;
    alginas(16) glm::mat4 model;
    glm::mat4 view;
    glm::mat4 proj;
};

如果现在再次编译和运行程序, 会看到 shader再一次接收了正确的矩阵数据.

幸运的是有一种方法可以让我们大多数时候不要去考虑对齐的需求,可以在定义GLM时定义 GLM_FORCE_DEFAULT_ALIGNED_GENTYPES :

#define GLM_FORCE_DEFAULT_ALIGNED_GENTYPES
#include <glm/glm.hpp>

这会强制让 GLM 使用满足对齐需求的 vec2 和 mat4 版本.如果增加了这个宏定义就可以移除 alignas 的设置了.

不幸的是这个方法可能会被破坏如果你使用了自定义的结构体,考察如下的C++代码:

struct Foo {
    glm::vec2 v;
};

struct UniformBufferObject {
    Foo f1;
    Foo f2;
};

再添加如下的shader定义:

struct Foo {
    vec2 v;
};

layout(binding = 0) uniform UniformBufferObject {
    Foo f1;
    Foo f2;
} ubo;

在这个场景下 f2 将会有一个8的偏移,但是由于它是内嵌的结构体实际偏移是16 ,所以这里你必须手动指定偏移:

struct UniformBufferObject {
    Foo f1;
    alignas(16) Foo f2;
};

这些陷阱让显式地说明偏移变成了一个很好的选择,这样就不会出现稀奇古怪地偏移对齐问题.

struct UniformBufferObject {
    alignas(16) glm::mat4 model;
    alignas(16) glm::mat4 view;
    alignas(16) glm::mat4 proj;
};

在移除 foo 成员后不要忘记重新编译着色器和C++代码.

多个描述符集

正如结构体和函数调用暗示的,同时绑定多个描述符集合也是有可能的。需要为每一个管线的布局指定一个描述符集合,着色器可以像下面这样引用指定的描述符集合:

layout(set = 0, binding = 0) uniform UniformBufferObject { ... }

可以使用这个特性让每一个对象设置自己的描述符,描述符也可以被不同的描述符集合所共享,这种情况下,可以跨绘制调用指令避免重新绑定描述符,获得更高的运行效率.

在下一章,我们会基于学到的知识,给场景中添加纹理。