Vulkan教程 – 17 描述符与内存对齐

之前章节的描述符布局描述了描述符可以绑定的类型。本章我们要对每个VkBuffer资源创建一个描述符集合来将它绑定到统一缓冲描述符上。

描述符集合不能够直接创建,必须从一个池中分配,就和命令缓冲一样。同样的,对应也有描述符池。写一个新方法createDescriptorPool来建立它,把它放在初始化Vulkan的创建统一缓冲之后:

createUniformBuffers();
createDescriptorPool();

我们需要描述我们的描述符集合打算包含哪种描述符:

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

我们会为每一帧从这些描述符中分配一个,该池大小会被主VkDescriptorPoolCreateInfo引用:

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

除了可以获得各个描述符的最大值之外,我们还要指定可以分配的描述符集合的最大值:

poolInfo.maxSets = static_cast<uint32_t>(swapChainImages.size());

该结构体有一个可选标记VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT,和命令池类似,该标记确定了各个描述符集合是否可以被释放。我们不会在创建后再去接触描述符集合,所以我们不用该标记。

添加一个新的类成员来存储描述符池的句柄,调用vkCreateDescriptorPool来创建它。

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

描述符池应该在交换链重建的时候销毁因为它依赖图像个数:

for (size_t i = 0; i < swapChainImages.size(); i++) {
    vkDestroyBuffer(device, uniformBuffers[i], nullptr);
    vkFreeMemory(device, uniformBuffersMemory[i], nullptr);
}

vkDestroyDescriptorPool(device, descriptorPool, nullptr);

然后重建交换链的时候进行重建:

createUniformBuffers();
createDescriptorPool();
createCommandBuffers();

现在我们可以分配描述符集合了。添加一个方法createDescriptorSets:

createDescriptorPool();
createDescriptorSets();
createCommandBuffers();

在初始化Vulkan的部分调用如上面所示。重建交换链的时候也要调用,如下:

createDescriptorPool();
createDescriptorSets();
createCommandBuffers();

描述符集合分配通过VkDescriptorSetAllocateInfo结构体描述。你需要指定要分配的描述符池,描述符集合要分配的个数,以及描述符布局:

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

我们这里会为每个交换链图像创建一个描述符,都使用一样的布局。不幸的是,我们需要所有布局的副本,因为下面一个方法会需要一个数组匹配集合个数。

添加一个类成员来保存描述符集合句柄并用vkAllocateDescriptorSets分配:

VkDescriptorPool descriptorPool;
std::vector<VkDescriptorSet> descriptorSets;
...
descriptorSets.resize(swapChainImages.size());
if (vkAllocateDescriptorSets(device, &allocInfo, descriptorSets.data()) != VK_SUCCESS) {
    throw std::runtime_error("failed to allocate descriptor sets!");
}

你不需要显式清理描述符集合,因为它们会在描述符池销毁的时候自动释放。vkAllocateDescriptorSets调用会分配描述符集合,每个有一个统一缓冲描述符。

描述符集合已经分配了,但是其中的描述符还需要配置。我们现在需要添加一个循环来产生每个描述符:

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

引用该缓冲的描述符,和我们的统一缓冲描述符类似,是通过VkDescriptorBufferInfo配置的。该结构体指定了缓冲和它中间的包含描述符所需数据的区域。

如果你覆盖整个缓冲,就像我们这个情况一样,那么范围也可以使用VK_WHOLE_SIZE值。描述符配置使用vkUpdateDescriptorSets方法进行更新,它接收一组VkWriteDescriptorSet结构体作为参数。

VkWriteDescriptorSet descriptorWrite = {};
descriptorWrite.sType = VK_STRUCTURE_TYPE_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

最后的字段用descriptorCount结构体引用一个数组,该数组才是实际配置描述符的。它依赖于描述符类型,也就是三种之中要用的一个。pBufferInfo字段用于引用缓冲数据的描述符,pImageInfo用于引用图像数据的描述符,pTexelBufferView用于引用缓冲视图的描述符。我们的描述符是基于缓冲的,所以我们用pBufferInfo。

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

更新操作用vkUpdateDescriptorSets执行,它接收两种数组作为参数:一组VkWriteDescriptorSet和一组VkCopyDescriptorSet,后者可以用于将描述符进行互相拷贝。

我们现在要更新createCommandBuffers方法,用cmdBindDescriptorSets来为每个交换链图像真正绑定正确的描述符集合到着色器中的描述符。这需要在vkCmdDrawIndexed之前完成:

vkCmdBindDescriptorSets(commandBuffers[i], VK_PIPELINE_BIND_POINT_GRAPHICS,
    pipelineLayout, 0, 1, &descriptorSets[i], 0, nullptr);

不像是顶点和索引缓冲,描述符集合对图像管线不是独一无二的。因此我们需要指定是否想要绑定描述符集合到图形或者计算管线。下一个参数就是描述符所基于的布局。接着的三个参数指定了第一个描述符集合索引,要绑定的集合个数以及要绑定的数组。我们之后回来看。最后一个参数指定了一个偏置数组,用于动态描述符。我们以后再看。

你现在运行程序会发现什么都不显示,因为我们在投影矩阵中对Y轴做了反转,现在顶点就是顺时针绘制,而不是逆时针。这就导致后面剔除以阻止几何体绘制。在createGraphicsPipeline方法中修改VkPipelineRasterizationStateCreateInfo结构体中的frontFace来修复该问题:

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

现在运行程序你应该能看到:

矩形已经改变成了正方形,因为投影矩阵现在会纠正宽高比。updateUniformBuffer会处理屏幕大小改变问题,所以我们不用在重建交换链中重建描述符集合。

有一件事情我们一直掩饰到现在,就是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;

重新编译着色器,运行程序,发现好不容易做的彩色正方形消失了!因为我们没有考虑对齐要求。

Vulkan要求你结构体中的数据在内存中以一种特殊方式对齐,例如:

标量必须是N对齐的(如对32位浮点数来说就是4个字节);

vec2必须是2N对齐的(8个字节);

vec3和vec4必须4N对齐(16字节);

内嵌结构体必须由它的成员的基础对齐值来对齐,会多达16的倍数;

mat4矩阵必须要有和vec4一样的对齐值。

我们一开始的着色器有三个mat4字段,已经满足了对齐要求。每个mat4是4*4*4=64字节大小,模型偏置为0,视图偏置为64,投影偏置为128。这些都是16的倍数,所以都工作正常。

新结构体用vec2开始,只占用8字节,因此丢掉了所有偏置。现在模型有个偏置8,视图有个偏置72,投影有个偏置136,没一个是16的倍数的。解决这个问题可以用C++11中的alignas标识符:

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

现在运行程序就没问题了。VS中选择17标准,因为14标准会提示std中没有optional。

幸运的是,有一种方法能让你大多数情况下都不用考虑对齐要求。我们可以在包含GLM之前定义GLM_FORCE_DEFAULT_ALIGNED_GENTYPES,它会让GLM使用一种已经满足我们对齐要求的vec2和mat4版本。如果你添加该定义,那么你就可以移除alignas标识符了。

但是不幸的是,这种方法可能会失败,如果你用了嵌入结构体的话。考虑下面的C++代码:

struct Foo {
    glm::vec2 v;
};

struct UniformBufferObject {
    Foo f1;
    Foo f2;
};

以及着色器定义:

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字段后不要忘记重新编译着色器。

就和一些结构体和方法调用所示,可以同时绑定多个描述符集合。当创建管线布局的时候,你需要为每个描述符集合指定一个描述符布局。着色器就可以像这样来引用特定描述符集合了:

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

你可以使用该特性将每个对象上都有所变化的描述符,以及被共享的描述符,放到不同的描述符集合。这样你就能避免重新在多个绘制命令中绑定大多数描述符,从而提高了效率。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片