Ch7-5 初识Uniform缓冲区

本节的main.cpp对应示例代码中的:Ch7-5.hpp

这一节尝试使用uniform缓冲区来为三角形指定位移,并用实例化来绘制多个在不同位置的三角形。

使用Uniform缓冲区绘制多个三角形的流程

在之前Ch7-1.hpp用顶点缓冲区绘制三角形代码的基础上,使用顶点和uniform缓冲区绘制三角形需要经历以下步骤:
1.创建uniform缓冲区
2.创建描述符布局和管线布局
3.创建描述符
4.将uniform缓冲区的信息写入描述符
5.书写着色器
6.在命令缓冲区中绑定描述符并绘制

这一节会绘制出与前一节效果完全相同的图像,但比使用push constant会麻烦得多。

比较push constant和uniform缓冲区

Push constant的优势在于它会被直接记录在命令缓冲区中,且更新push constant这一行为不需要被显式同步,适合用于一些即时(immediate)设计的程序。从OpenGL移植过来的程序,若常量不是很大,适合把常量放进push constant。
劣势是push constant只支持128个字节,且在同一命令缓冲区中频繁更新可能显著增大命令缓冲区的体积。

Uniform缓冲区的优势在于其大小可以比较大(具体大小取决于硬件,若要更大,应当使用storage缓冲区)。
更新uniform缓冲区时,若数据量较大,那么需要使用vkCmdCopyBuffer(...)从暂存缓冲区拷贝数据,这一行为要么在单独的(与绘制无关)的命令缓冲区中进行,要么在渲染通道前进行并使用内存屏障进行同步。若数据量小于等于65536个字节,那么无需暂存缓冲区,可以用vkCmdUpdateBuffer(...)直接更新(仍旧需要同步)。
Uniform缓冲区需要通过描述符使用而不能直接绑定,相比之下比较费事。

创建Uniform缓冲区

glm::vec2 uniform_positions[] = {
    {  .0f, .0f }, {},
    { -.5f, .0f }, {},
    {  .5f, .0f }, {}
};
uniformBuffer uniformBuffer(sizeof uniform_positions);
uniformBuffer.TransferData(uniform_positions);

着色器中uniform缓冲区的内存布局只能为std140,由于C++中vec2的大小和对齐是8,凑整到16为16,于是需要在每个数组成员后再放一个vec2作为空数据以满足对其要求。

创建描述符布局和管线布局

请先参阅Ch3-6 描述符,并完成该节中所涉及到的所有Vulkan对象的封装。

main.cpp中为描述符布局定义一个新的全局变量:

descriptorSetLayout descriptorSetLayout_triangle;

创建描述符布局。填写VkDescriptorSetLayoutBinding结构体:描述符被绑定到0号binding,类型为uniform缓冲区,个数是一个,在顶点着色器阶段使用它。

VkDescriptorSetLayoutBinding descriptorSetLayoutBinding_trianglePosition = {
    .binding = 0,                                       //描述符被绑定到0号binding
    .descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,//类型为uniform缓冲区
    .descriptorCount = 1,                               //个数是1个
    .stageFlags = VK_SHADER_STAGE_VERTEX_BIT            //在顶点着色器阶段读取uniform缓冲区
};
VkDescriptorSetLayoutCreateInfo descriptorSetLayoutCreateInfo_triangle = {
    .bindingCount = 1,
    .pBindings = &descriptorSetLayoutBinding_trianglePosition
};
descriptorSetLayout_triangle.Create(descriptorSetLayoutCreateInfo_triangle);

然后创建管线布局。
这部分代码应该会被写在CreateLayout()函数中,整个函数如下:

void CreateLayout() {
    VkDescriptorSetLayoutBinding descriptorSetLayoutBinding_trianglePosition = {
        .binding = 0,                                       //描述符被绑定到0号binding
        .descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,//类型为uniform缓冲区
        .descriptorCount = 1,                               //个数是1个
        .stageFlags = VK_SHADER_STAGE_VERTEX_BIT            //在顶点着色器阶段读取uniform缓冲区
    };
    VkDescriptorSetLayoutCreateInfo descriptorSetLayoutCreateInfo_triangle = {
        .bindingCount = 1,
        .pBindings = &descriptorSetLayoutBinding_trianglePosition
    };
    descriptorSetLayout_triangle.Create(descriptorSetLayoutCreateInfo_triangle);
    VkPipelineLayoutCreateInfo pipelineLayoutCreateInfo = {
        .setLayoutCount = 1,
        .pSetLayouts = descriptorSetLayout_triangle.Address()
    };
    pipelineLayout_triangle.Create(pipelineLayoutCreateInfo);
}

创建并写入描述符

在主函数中创建描述符。
首先创建描述符池。对于本节的程序,描述符池只需要能分配一个记录uniform缓冲区信息的描述符即可:

VkDescriptorPoolSize descriptorPoolSizes[] = {
    { VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1 }
};
descriptorPool descriptorPool(1, descriptorPoolSizes);

然后分配描述符集:

descriptorSet descriptorSet_trianglePosition;
descriptorPool.AllocateSets(descriptorSet_trianglePosition, descriptorSetLayout_triangle);

将uniform缓冲区的信息写入描述符:

VkDescriptorBufferInfo bufferInfo = {
    .buffer = uniformBuffer,
    .offset = 0,
    .range = sizeof uniform_positions//或VK_WHOLE_SIZE
};
descriptorSet_trianglePosition.Write(bufferInfo, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER);

UniformBuffer.vert.shader

#version 460
#pragma shader_stage(vertex)

layout(binding = 0) uniform trianglePosition {
    vec2 u_Positions[3];
};

layout(location = 0) in vec2 i_Position;
layout(location = 1) in vec4 i_Color;
layout(location = 0) out vec4 o_Color;

void main() {
    gl_Position = vec4(i_Position + u_Positions[gl_InstanceIndex], 0, 1);
    o_Color = i_Color;
}

与前一节中的顶点着色器大致相同,这里仅仅是将前一节中的push constant块改成了uniform缓冲区的块,而这是通过把layout(push_constant)修改为layout(binding = 0)来实现的。因为只有一组描述符且之后会将其绑定到0号描述符集索引,所以这里不必显式指定set = 0

别忘了更改CreatePipeline(...):

void CreatePipeline() {
    static shaderModule vert("shader/UniformBuffer.vert.spv");
    //省略后续代码
}

绑定描述符并绘制

在录制命令缓冲区时,用vkCmdBindDescriptorSets绑定描述符:

void VKAPI_CALL vkCmdBindDescriptorSets(...) 的参数说明

VkCommandBuffer commandBuffer

命令缓冲区的handle

VkPipelineBindPoint pipelineBindPoint

指定会使用描述符的管线的类型

VkPipelineLayout layout

管线布局的handle

uint32_t firstSet

指定pDescriptorSets所指数组中第一个描述符集会被绑定到的索引

uint32_t descriptorSetCount

要被绑定的描述符集的个数

const VkDescriptorSet* pDescriptorSets

指向VkDescriptorSet的数组,指定所要绑定的描述符集

uint32_t dynamicOffsetCount

动态offset的个数,与描述符中包含的动态uniform/storage缓冲区的总数一致

const uint32_t* pDynamicOffsets

指向uint32_t的数组,为每个动态缓冲区提供相应的动态offset

  • 所能使用的描述符集的最大个数由硬件决定,通常会确保有4组可用。因此firstSet的有效范围通常为闭区间[0,3]。

  • pDynamicOffsets所指数组中的元素,与描述符集的中动态uniform/storage缓冲区一一对应。比如说,若只绑定一套描述符集,其中binding为0的是一个动态uniform缓冲区的描述符,binding为3的是包含两个动态storage缓冲区的描述符数组,那么需要为该描述符集提供3个动态offset(重点是只需要考虑动态缓冲区的个数并依序提供offset,不必考虑当中间隔的其他类型的描述符)。

vkCmdDraw(...)前加入以下代码:

vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS,
    pipelineLayout_triangle, 0, 1, descriptorSet_trianglePosition.Address(), 0, nullptr);

绘制命令同上一节一样,绘制三个实例:

vkCmdDraw(commandBuffer, 3, 3, 0, 0);

运行程序,你应该会看到以下图像(还是跟上一节的一样):

_images/ch7-3-1.png

动态Uniform缓冲区

写入描述符集中提到,若多个描述符引用同一个缓冲区的不同部分,则必须满足相应对齐要求。比如,假设有三组数据A~C,各自被用在不同的描述符中,则整个uniform缓冲区的大小如下计算:

//首先取得单位对齐距离
VkDeviceSize uniformAlignment = graphicsBase::Base().PhysicalDeviceProperties()limits.minUniformBufferOffsetAlignment;
//计算每组数据的大小
VkDeviceSize dataSize[] = { sizeof A, sizeof B, sizeof C };
//每组数据的大小向上凑整到单位对齐距离的整数倍并相加,得到整个缓冲区的大小
VkDeviceSize uniformBufferSize = uniformAlignment * (std::ceil(float(dataSize[0]) / uniformAlignment) + ... + std::ceil(float(dataSize[2]) / uniformAlignment));
//上式更快的计算方法,原理请自行推导:
//VkDeviceSize uniformBufferSize = ((uniformAlignment + sizeof A - 1) & ~(uniformAlignment - 1)) + ... + ((uniformAlignment + sizeof C - 1) & ~(uniformAlignment - 1));

绑定动态缓冲区时,动态offset需要满足同样的对齐要求,下文简单做个示例:

前文的程序是:uniform缓冲区的描述符对应包含3组位移数据的整个缓冲区,绑定描述符1次,然后执行1次绘制命令,一次性绘制3个三角形实例。
现试着将程序改写为:动态uniform缓冲区的描述符对应包含1组位移数据的部分缓冲区,绑定描述符3次,然后执行3次绘制命令,各绘制1个三角形实例。
那么首先在创建描述符布局时,指定描述符类型为动态uniform缓冲区:

VkDescriptorSetLayoutBinding descriptorSetLayoutBinding_trianglePosition = {
    .binding = 0,
    .descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC,//类型为动态uniform缓冲区
    .descriptorCount = 1,
    .stageFlags = VK_SHADER_STAGE_VERTEX_BIT
};

Uniform缓冲区中的每组数据需要满足对齐要求,先创建相应大小的缓冲区,然后将数据拷贝到相应对齐位置:

glm::vec2 uniform_positions[] = {
    {  .0f, .0f },
    { -.5f, .0f },
    {  .5f, .0f }
};
VkDeviceSize uniformAlignment = graphicsBase::Base().PhysicalDeviceProperties().limits.minUniformBufferOffsetAlignment;
uniformAlignment *= (std::ceil(float(sizeof(glm::vec2)) / uniformAlignment);
//上式可改为:
//uniformAlignment = (uniformAlignment + sizeof(glm::vec2) - 1) & ~(uniformAlignment - 1);
uniformBuffer uniformBuffer(uniformAlignment * 3);
uniformBuffer.TransferData(uniform_positions, 3, sizeof(glm::vec2), sizeof(glm::vec2), uniformAlignment);

分配相应类型的描述符集,写入描述符:

VkDescriptorPoolSize descriptorPoolSizes[] = {
    { VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC, 1 }
};
descriptorPool descriptorPool(1, descriptorPoolSizes);
descriptorSet descriptorSet_trianglePosition;
descriptorPool.AllocateSets(descriptorSet_trianglePosition, descriptorSetLayout_triangle);
VkDescriptorBufferInfo bufferInfo = {
    .buffer = uniformBuffer,
    .offset = 0,
    .range = sizeof(glm::vec2) //可通过描述符访问的范围为一个vec2的大小即可
};
descriptorSet_trianglePosition.Write(bufferInfo, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC);

在循环中绑定描述符并绘制:

for (size_t i = 0; i < 3; i++) {
    uint32_t dynamicOffset = uniformAlignment * i;
    vkCmdBindDescriptorSets(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS,
    pipelineLayout_triangle, 0, 1, descriptorSet_trianglePosition.Address(), 1, &dynamicOffset);
    vkCmdDraw(commandBuffer, 3, 1, 0, 0);
}

运行结果当然与前文的相同。

上文仅演示用法,所示情形其实并不需要使用动态uniform缓冲区。动态uniform缓冲区/storage缓冲区的优势在于,可以在录制命令时灵活地改变读取缓冲区时的起始位置,而不必再重新写入描述符。

至此为止,你应该已经认识了四种绘制同一图形的不同方式了(实例化绘制、Push Constant、Uniform Buffer、动态Uniform Buffer),它们按可绘制的最大数量、内存开销、代码书写方便与否各有优劣。