我们现在有两个描述符集。它们都指向不同的 VkBuffer
,我们在那里保存相机信息。但是我们不需要让 1 个描述符指向 1 个缓冲区。我们可以让多个描述符指向同一个缓冲区的不同部分。
我们将稍微更改代码。我们将添加一个“环境数据”缓冲区,其中包含环境光颜色和雾设置等信息。但是我们不会将其保存为 2 个缓冲区,而是将其存储在一个缓冲区中。
通常在 Vulkan 中,将多个事物分配到同一个缓冲区中是一个好主意。可以使用一些非常好的技术,例如动态描述符,它允许重复使用相同的描述符集,但每次都使用缓冲区中不同的偏移量。
从缓冲区子分配数据的主要复杂之处在于,您需要非常注意对齐。GPU 通常无法从任意地址读取数据,并且您的缓冲区偏移必须与某个最小大小对齐。
查找 GPU 统计信息
要了解缓冲区的最小对齐大小是多少,我们需要从 GPU 查询它。我们正在寻找的限制称为 minUniformBufferOffsetAlignment
。您可以在 vulkaninfo 页面 Link 中查看该限制
我们可以看到所有 GPU 至少都支持 256 字节的对齐,但我们无论如何都会在运行时检查它。为此,我们将在 init_vulkan()
中添加一些代码,在其中我们将请求 GPU 信息并存储我们需要的最小对齐方式。
我们将向 VulkanEngine 类添加一个 VkPhysicalDeviceProperties
成员,并从 vkb::Device
获取数据。该结构将包含大多数 GPU 属性,因此保留它很有用。
class VulkanEngine {
VkPhysicalDeviceProperties _gpuProperties;
}
void VulkanEngine::init_vulkan()
{
// other initialization code .....
_gpuProperties = vkbDevice.physical_device.properties;
std::cout << "The GPU has a minimum buffer alignment of " << _gpuProperties.limits.minUniformBufferOffsetAlignment << std::endl;
}
现在运行它,并记下统一缓冲区的对齐方式。在 Nvidia 2080 RTX 中,偏移对齐为 64 字节。其他 GPU 可能具有不同的对齐方式。
现在我们知道了我们需要的偏移对齐方式,我们将创建一个结构来保存我们的 SceneParameters,并将其绑定到着色器。让我们首先创建一个新结构来保存我们将在着色器中使用的状态。
设置场景数据
struct GPUSceneData {
glm::vec4 fogColor; // w is for exponent
glm::vec4 fogDistances; //x for min, y for max, zw unused.
glm::vec4 ambientColor;
glm::vec4 sunlightDirection; //w for sun power
glm::vec4 sunlightColor;
};
class VulkanEngine {
GPUSceneData _sceneParameters;
AllocatedBuffer _sceneParameterBuffer;
}
我们将向场景数据添加一些默认参数。我们将在此处添加雾、环境光颜色和阳光。我们将不使用所有数据,它作为示例数据存在,您可以随意使用。请注意一切都在 glm::vec4s 上,并使用打包数据。GPU 读取数据的方式与 CPU 完全不同。这方面的规则很复杂。绕过这些规则的一个简单方法是只坚持使用 vec4s 和 mat4s,并自己打包东西。如果您决定将类型混合到缓冲区中,请务必小心。
此结构为 4 (float) * 4 (vec4) * 5 字节,80 字节。80 字节不符合任何 GPU 的对齐方式。如果我们想将它们像普通数组一样打包在缓冲区中,那将不起作用。为了使其工作,我们将不得不填充缓冲区,以便事物对齐。
我们将需要一个函数来将某事物的大小填充到对齐边界,因此让我们添加它。
size_t VulkanEngine::pad_uniform_buffer_size(size_t originalSize)
{
// Calculate required alignment based on minimum device offset alignment
size_t minUboAlignment = _gpuProperties.limits.minUniformBufferOffsetAlignment;
size_t alignedSize = originalSize;
if (minUboAlignment > 0) {
alignedSize = (alignedSize + minUboAlignment - 1) & ~(minUboAlignment - 1);
}
return alignedSize;
}
感谢 Sascha Willems 及其 Vulkan 示例代码片段。https://github.com/SaschaWillems/Vulkan/tree/master/examples/dynamicuniformbuffer
在 init_descriptors()
函数中,我们将为此创建缓冲区。由于对齐,我们将不得不增加缓冲区的大小,以便它可以容纳 2 个填充的 GPUSceneData 结构,我们将使用上面的函数来实现。
const size_t sceneParamBufferSize = FRAME_OVERLAP * pad_uniform_buffer_size(sizeof(GPUSceneData));
_sceneParameterBuffer = create_buffer(sceneParamBufferSize, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU);
这样,我们的 _sceneParameterBuffer
将为 FRAME_OVERLAP 计数的场景数据结构保留足够的空间。请记住,由于填充,此缓冲区的大小将因 GPU 而异。
我们现在可以修改全局集的描述符布局,以便我们添加指向场景参数缓冲区的链接。
看到我们正在创建更多 VkDescriptorSetLayoutBinding
,让我们首先将其抽象到 vk_initializers 中。我们还将为 VkWriteDescriptorSet
进行抽象
VkDescriptorSetLayoutBinding vkinit::descriptorset_layout_binding(VkDescriptorType type, VkShaderStageFlags stageFlags, uint32_t binding)
{
VkDescriptorSetLayoutBinding setbind = {};
setbind.binding = binding;
setbind.descriptorCount = 1;
setbind.descriptorType = type;
setbind.pImmutableSamplers = nullptr;
setbind.stageFlags = stageFlags;
return setbind;
}
VkWriteDescriptorSet vkinit::write_descriptor_buffer(VkDescriptorType type, VkDescriptorSet dstSet, VkDescriptorBufferInfo* bufferInfo , uint32_t binding)
{
VkWriteDescriptorSet write = {};
write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
write.pNext = nullptr;
write.dstBinding = binding;
write.dstSet = dstSet;
write.descriptorCount = 1;
write.descriptorType = type;
write.pBufferInfo = bufferInfo;
return write;
}
我们现在可以在创建描述符布局时在 init_descriptors()
中使用它。
//binding for camera data at 0
VkDescriptorSetLayoutBinding cameraBind = vkinit::descriptorset_layout_binding(VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER,VK_SHADER_STAGE_VERTEX_BIT,0);
//binding for scene data at 1
VkDescriptorSetLayoutBinding sceneBind = vkinit::descriptorset_layout_binding(VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 1);
VkDescriptorSetLayoutBinding bindings[] = { cameraBind,sceneBind };
VkDescriptorSetLayoutCreateInfo setinfo = {};
setinfo.bindingCount = 2;
setinfo.flags = 0;
setinfo.pNext = nullptr;
setinfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
setinfo.pBindings = bindings;
对于场景绑定,我们还将着色器阶段更改为包括片段着色器,因为我们希望从片段着色器中读取它。
完成布局后,我们现在需要修改描述符集写入,以便它们指向正确的缓冲区和其中的正确偏移量。
我们继续在 init_descriptors()
中,但在帧循环内。我们将旧的 VkWriteDescriptorSet
部分替换为新的抽象版本。
VkDescriptorBufferInfo cameraInfo;
cameraInfo.buffer = _frames[i].cameraBuffer._buffer;
cameraInfo.offset = 0;
cameraInfo.range = sizeof(GPUCameraData);
VkDescriptorBufferInfo sceneInfo;
sceneInfo.buffer = _sceneParameterBuffer._buffer;
sceneInfo.offset = pad_uniform_buffer_size(sizeof(GPUSceneData)) * i;
sceneInfo.range = sizeof(GPUSceneData);
VkWriteDescriptorSet cameraWrite = vkinit::write_descriptor_buffer(VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, _frames[i].globalDescriptor,&cameraInfo,0);
VkWriteDescriptorSet sceneWrite = vkinit::write_descriptor_buffer(VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, _frames[i].globalDescriptor, &sceneInfo, 1);
VkWriteDescriptorSet setWrites[] = { cameraWrite,sceneWrite };
vkUpdateDescriptorSets(_device, 2, setWrites, 0, nullptr);
这里需要注意的重要一点是,在 sceneInfo 中,我们指向场景参数缓冲区,但我们这样做是在偏移量处进行的。并且该偏移量使用来自结构的填充大小。通过这样做,您将指向一个统一缓冲区,但位于偏移量处。这对着色器是不可见的,并且可以让您在一个缓冲区中子分配大量数据。您甚至可以将不同的数据混合并匹配到同一个缓冲区中,它不必全部来自同一个结构。
写入描述符集与之前相同,但我们写入的是 2 个绑定而不是 1 个绑定,因为我们的全局描述符集现在指向两个缓冲区而不是一个缓冲区。
现在我们需要修改着色器,以便它使用来自场景参数缓冲区的一些内容。我们将做最简单的事情,即将环境光颜色添加到像素颜色。
新着色器
我们将复制我们到目前为止一直使用的着色器 colored_triangle.frag
,并将其称为 default_lit.frag
,因为我们将向其中添加照明。
default_lit.frag
//glsl version 4.5
#version 450
//shader input
layout (location = 0) in vec3 inColor;
//output write
layout (location = 0) out vec4 outFragColor;
layout(set = 0, binding = 1) uniform SceneData{
vec4 fogColor; // w is for exponent
vec4 fogDistances; //x for min, y for max, zw unused.
vec4 ambientColor;
vec4 sunlightDirection; //w for sun power
vec4 sunlightColor;
} sceneData;
void main()
{
outFragColor = vec4(inColor + sceneData.ambientColor.xyz,1.0f);
}
请注意,我们在 binding = 1
上有 SceneData uniform,以便它与 cpp 端匹配。我们不需要在此处为相机数据添加绑定 0。
替换 init_pipelines
中对 colored_triangle.frag
的引用,将其切换为这个新着色器,并确保重新编译着色器。
VkShaderModule colorMeshShader;
if (!load_shader_module("../../shaders/default_lit.frag.spv", &colorMeshShader))
{
std::cout << "Error when building the colored mesh shader" << std::endl;
}
如果您现在尝试运行它,它应该可以工作,但对象的颜色将是未知的,因为我们尚未写入缓冲区。它可能已初始化为零,这将意味着环境光不起作用。
让我们从我们的核心渲染循环写入缓冲区。在 draw_objects()
中,在我们映射相机缓冲区并写入它之前或之后。
float framed = (_frameNumber / 120.f);
_sceneParameters.ambientColor = { sin(framed),0,cos(framed),1 };
char* sceneData;
vmaMapMemory(_allocator, _sceneParameterBuffer._allocation , (void**)&sceneData);
int frameIndex = _frameNumber % FRAME_OVERLAP;
sceneData += pad_uniform_buffer_size(sizeof(GPUSceneData)) * frameIndex;
memcpy(sceneData, &_sceneParameters, sizeof(GPUSceneData));
vmaUnmapMemory(_allocator, _sceneParameterBuffer._allocation);
我们需要进行邪恶的指针运算来偏移数据指针并使其指向我们想要的位置。除此之外,它或多或少与相机缓冲区相同。
如果您现在运行它,您将看到对象的色调会随着时间而变化。
我们还可以做最后一件事。现在,我们在写入描述符集时对缓冲区偏移量进行硬编码。但这没有必要。通过使用 Dynamic Uniform Buffer 类型的描述符,可以在绑定缓冲区时设置缓冲区偏移量。这使您可以为一个缓冲区使用许多不同的偏移量。
动态 Uniform Buffer。
让我们重构场景缓冲区的代码,以使用动态 uniform 描述符,而不是硬编码偏移量。
首先要做的是在描述符池中为动态 uniform 描述符保留一些大小。
在 init_descriptors()
中,更改用于创建描述符池的描述符大小,如下所示
//create a descriptor pool that will hold 10 uniform buffers and 10 dynamic uniform buffers
std::vector<VkDescriptorPoolSize> sizes =
{
{ VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 10 },
{ VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC, 10 }
};
动态 uniform 缓冲区是一种不同的描述符类型,因此在创建池时我们需要添加一些。现在,我们需要在创建描述符集布局时更改场景绑定的描述符类型,使其成为 uniform buffer dynamic。
VkDescriptorSetLayoutBinding sceneBind = vkinit::descriptorset_layout_binding(VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 1);
我们从 VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER
更改为 VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC
现在我们进入我们写入描述符的位置,我们删除硬编码的偏移量,并将类型更改为动态
VkDescriptorBufferInfo sceneInfo;
sceneInfo.buffer = _sceneParameterBuffer._buffer;
sceneInfo.offset = 0;
sceneInfo.range = sizeof(GPUSceneData);
// other code ....
VkWriteDescriptorSet sceneWrite = vkinit::write_descriptor_buffer(VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC, _frames[i].globalDescriptor, &sceneInfo, 1);
就这样,现在我们的描述符被创建为动态的。现在,在绑定描述符集时,我们可以告诉它要使用哪个偏移量。
让我们转到 draw_objects()
函数,并修改绑定描述符集的位置,以便它使用偏移量。
//only bind the pipeline if it doesn't match with the already bound one
if (object.material != lastMaterial) {
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, object.material->pipeline);
lastMaterial = object.material;
//offset for our scene buffer
uint32_t uniform_offset = pad_uniform_buffer_size(sizeof(GPUSceneData)) * frameIndex;
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, object.material->pipelineLayout, 0, 1, &get_current_frame().globalDescriptor, 1, &uniform_offset);
}
我们需要将偏移量发送到 vkCmdBindDescriptorSets()
调用。偏移量将按顺序完成。由于绑定号 0 没有动态偏移量,因此向该函数发送 1 个偏移量将影响第二个绑定,我们在其中有动态描述符。
如果我们有一个描述符集,其绑定 0、2 和 3 使用静态 uniform 缓冲区,而绑定 1、4、5 使用动态描述符,则我们需要向绑定函数发送 3 个 uint32_t。
动态 uniform 缓冲区绑定在 GPU 中可能比硬编码的绑定稍慢,但总的来说,差异很小,很难衡量。在 CPU 端,它们是一个相当大的优势,因为它们消除了不断重新分配描述符集的需要,因为您可以只保持在不同的偏移量处重复使用相同的动态描述符。如果您有每个帧绑定一次的数据(例如相机矩阵),则普通描述符可能是最好的,但是如果您有每个对象的数据,请考虑使用动态描述符。
由于其动态和非硬编码的性质,它们在游戏引擎中非常流行。一些游戏引擎甚至不使用普通的 uniform 缓冲区描述符,而更喜欢严格地仅使用动态描述符。
动态 uniform 缓冲区绑定允许您执行的操作之一是,您可以在渲染时在运行时分配和写入缓冲区,并绑定您写入的确切偏移量。
最后,让我们为本章中添加的 _sceneParameterBuffer
添加必要的清理代码。
_mainDeletionQueue.push_function([&]() {
// other code ....
vmaDestroyBuffer(_allocator, _sceneParameterBuffer._buffer, _sceneParameterBuffer._allocation);
}
下一步:存储缓冲区