Link

现在我们即将扩展引擎抽象以支持纹理并显著增加复杂性,我们将需要更好的描述符集抽象。

在第 2 章中,我们已经创建了 2 个类,描述符分配器和描述符布局构建器。通过描述符分配器,我们有一种抽象单个 VkDescriptorPool 以分配描述符的基本方法,而 LayoutBuilder 抽象了创建描述符集布局。

描述符分配器 2

我们将创建一个新版本的描述符分配器,DescriptorAllocatorGrowable。我们之前创建的那个在池空间耗尽时只会崩溃。这对于某些我们提前知道描述符数量的情况来说是可以的,但是当我们需要从任意文件加载网格并且无法提前知道我们需要多少描述符时,它就行不通了。这个新类几乎执行相同的操作,除了它处理的是一堆池而不是单个池。每当一个池分配失败时,我们就会创建一个新的池。当这个分配器被清除时,它会清除它的所有池。这样我们就可以使用 1 个描述符分配器,它会随着我们的需要而增长。

这是我们将在 vk_descriptors.h 头文件中实现的。

struct DescriptorAllocatorGrowable {
public:
	struct PoolSizeRatio {
		VkDescriptorType type;
		float ratio;
	};

	void init(VkDevice device, uint32_t initialSets, std::span<PoolSizeRatio> poolRatios);
	void clear_pools(VkDevice device);
	void destroy_pools(VkDevice device);

    VkDescriptorSet allocate(VkDevice device, VkDescriptorSetLayout layout, void* pNext = nullptr);
private:
	VkDescriptorPool get_pool(VkDevice device);
	VkDescriptorPool create_pool(VkDevice device, uint32_t setCount, std::span<PoolSizeRatio> poolRatios);

	std::vector<PoolSizeRatio> ratios;
	std::vector<VkDescriptorPool> fullPools;
	std::vector<VkDescriptorPool> readyPools;
	uint32_t setsPerPool;

};

公共接口与另一个描述符分配器中的相同。变化的是,现在我们需要存储池大小比率数组(用于重新分配池时)、每个池分配多少个集合以及 2 个数组。fullPools 包含我们知道无法再从中分配的池,而 readyPools 包含仍然可以使用的池,或新创建的池。

分配逻辑将首先从 readyPools 中获取一个池,并尝试从中分配。如果成功,它会将池添加回 readyPools 数组。如果失败,它会将池放在 fullPools 数组中,并尝试获取另一个池以重试。get_pool 函数将从 readyPools 中选取一个池,或创建一个新池。

让我们编写 get_pool 和 create_pool 函数

VkDescriptorPool DescriptorAllocatorGrowable::get_pool(VkDevice device)
{       
    VkDescriptorPool newPool;
    if (readyPools.size() != 0) {
        newPool = readyPools.back();
        readyPools.pop_back();
    }
    else {
	    //need to create a new pool
	    newPool = create_pool(device, setsPerPool, ratios);

	    setsPerPool = setsPerPool * 1.5;
	    if (setsPerPool > 4092) {
		    setsPerPool = 4092;
	    }
    }   

    return newPool;
}

VkDescriptorPool DescriptorAllocatorGrowable::create_pool(VkDevice device, uint32_t setCount, std::span<PoolSizeRatio> poolRatios)
{
	std::vector<VkDescriptorPoolSize> poolSizes;
	for (PoolSizeRatio ratio : poolRatios) {
		poolSizes.push_back(VkDescriptorPoolSize{
			.type = ratio.type,
			.descriptorCount = uint32_t(ratio.ratio * setCount)
		});
	}

	VkDescriptorPoolCreateInfo pool_info = {};
	pool_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
	pool_info.flags = 0;
	pool_info.maxSets = setCount;
	pool_info.poolSizeCount = (uint32_t)poolSizes.size();
	pool_info.pPoolSizes = poolSizes.data();

	VkDescriptorPool newPool;
	vkCreateDescriptorPool(device, &pool_info, nullptr, &newPool);
    return newPool;
}

在 get_pools 上,当我们创建一个新池时,我们会增加 setsPerPool,以模拟类似 std::vector resize 的操作。不过,我们仍然会将每个池的最大集合数限制为 4092,以避免其增长过多。如果您发现此最大限制在您的用例中效果更好,则可以修改它。

此函数的一个重要细节是,我们在抓取池时将其从 readyPools 数组中删除。这样我们就可以在分配描述符后将其添加回该数组或另一个数组。

在 create_pool 函数中,它与我们在另一个描述符分配器中使用的相同。

让我们创建我们需要的其他函数,init()、clear_pools() 和 destroy_pools()

void DescriptorAllocatorGrowable::init(VkDevice device, uint32_t maxSets, std::span<PoolSizeRatio> poolRatios)
{
    ratios.clear();
    
    for (auto r : poolRatios) {
        ratios.push_back(r);
    }
	
    VkDescriptorPool newPool = create_pool(device, maxSets, poolRatios);

    setsPerPool = maxSets * 1.5; //grow it next allocation

    readyPools.push_back(newPool);
}

void DescriptorAllocatorGrowable::clear_pools(VkDevice device)
{ 
    for (auto p : readyPools) {
        vkResetDescriptorPool(device, p, 0);
    }
    for (auto p : fullPools) {
        vkResetDescriptorPool(device, p, 0);
        readyPools.push_back(p);
    }
    fullPools.clear();
}

void DescriptorAllocatorGrowable::destroy_pools(VkDevice device)
{
	for (auto p : readyPools) {
		vkDestroyDescriptorPool(device, p, nullptr);
	}
    readyPools.clear();
	for (auto p : fullPools) {
		vkDestroyDescriptorPool(device,p,nullptr);
    }
    fullPools.clear();
}

init 函数仅分配第一个描述符池,并将其添加到 readyPools 数组。

清除池意味着遍历所有池,并将 fullPool 数组复制到 readyPools 数组中。

销毁循环遍历两个列表并销毁所有内容以清除整个分配器。

最后是新的分配函数。

VkDescriptorSet DescriptorAllocatorGrowable::allocate(VkDevice device, VkDescriptorSetLayout layout, void* pNext)
{
    //get or create a pool to allocate from
    VkDescriptorPool poolToUse = get_pool(device);

	VkDescriptorSetAllocateInfo allocInfo = {};
	allocInfo.pNext = pNext;
	allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
	allocInfo.descriptorPool = poolToUse;
	allocInfo.descriptorSetCount = 1;
	allocInfo.pSetLayouts = &layout;

	VkDescriptorSet ds;
	VkResult result = vkAllocateDescriptorSets(device, &allocInfo, &ds);

    //allocation failed. Try again
    if (result == VK_ERROR_OUT_OF_POOL_MEMORY || result == VK_ERROR_FRAGMENTED_POOL) {

        fullPools.push_back(poolToUse);
    
        poolToUse = get_pool(device);
        allocInfo.descriptorPool = poolToUse;

       VK_CHECK( vkAllocateDescriptorSets(device, &allocInfo, &ds));
    }
  
    readyPools.push_back(poolToUse);
    return ds;
}

我们首先获取一个池,然后从中分配,如果分配失败,我们将其添加到 fullPools 数组(因为我们知道这个池已满),然后重试。如果第二次也失败,则所有内容都完全损坏,因此它只是断言并崩溃。一旦我们使用池分配,我们将其添加回 readyPools 数组。

描述符写入器

当我们需要为我们的计算着色器创建描述符集时,我们手动完成了 vulkan vkUpdateDescriptorSets,但这真的很难处理。因此,我们也将抽象化它。在我们的写入器中,我们将具有 write_imagewrite_buffer 函数来绑定数据。让我们看一下结构声明,也在 vk_descriptors.h 文件中。

struct DescriptorWriter {
    std::deque<VkDescriptorImageInfo> imageInfos;
    std::deque<VkDescriptorBufferInfo> bufferInfos;
    std::vector<VkWriteDescriptorSet> writes;

    void write_image(int binding,VkImageView image,VkSampler sampler , VkImageLayout layout, VkDescriptorType type);
    void write_buffer(int binding,VkBuffer buffer,size_t size, size_t offset,VkDescriptorType type); 

    void clear();
    void update_set(VkDevice device, VkDescriptorSet set);
};

我们在这里使用 std::deque 做一些内存技巧。std::deque 保证保持指向元素的指针有效,因此当我们将新的 VkWriteDescriptorSet 添加到 writes 数组时,我们可以利用这种机制。

让我们看一下 VkWriteDescriptorSet 的定义

typedef struct VkWriteDescriptorSet {
    VkStructureType                  sType;
    const void*                      pNext;
    VkDescriptorSet                  dstSet;
    uint32_t                         dstBinding;
    uint32_t                         dstArrayElement;
    uint32_t                         descriptorCount;
    VkDescriptorType                 descriptorType;
    const VkDescriptorImageInfo*     pImageInfo;
    const VkDescriptorBufferInfo*    pBufferInfo;
    const VkBufferView*              pTexelBufferView;
} VkWriteDescriptorSet;

我们有目标集、目标绑定元素,实际的缓冲区或图像是通过指针完成的。我们需要以指针稳定的方式或在制作最终的 WriteDescriptorSet 数组时修复这些指针的方式来保留 VkDescriptorBufferInfo 和其他信息。

让我们看看 write_buffer 函数的作用。

void DescriptorWriter::write_buffer(int binding, VkBuffer buffer, size_t size, size_t offset, VkDescriptorType type)
{
	VkDescriptorBufferInfo& info = bufferInfos.emplace_back(VkDescriptorBufferInfo{
		.buffer = buffer,
		.offset = offset,
		.range = size
		});

	VkWriteDescriptorSet write = {.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};

	write.dstBinding = binding;
	write.dstSet = VK_NULL_HANDLE; //left empty for now until we need to write it
	write.descriptorCount = 1;
	write.descriptorType = type;
	write.pBufferInfo = &info;

	writes.push_back(write);
}

我们必须首先填充 VkDescriptorBufferInfo,其中包含缓冲区本身,以及它的偏移量和范围(大小)。

然后,我们必须设置写入本身。它只有一个描述符,位于给定的绑定槽中,类型正确,并且是指向 VkDescriptorBufferInfo 的指针。我们通过在 std::deque 上执行 emplace_back 创建了 info,因此获取指向它的指针是可以的。

允许缓冲区使用的描述符类型是这些。

VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER
VK_DESCRIPTOR_TYPE_STORAGE_BUFFER
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC
VK_DESCRIPTOR_TYPE_STORAGE_BUFFER_DYNAMIC

我们在上一章中已经解释了这些缓冲区类型。当我们想要将一种或另一种类型绑定到着色器中时,我们在此处设置正确的类型。请记住,它需要与分配 VkBuffer 时的用法相匹配

对于图像,这是另一个函数。

void DescriptorWriter::write_image(int binding,VkImageView image, VkSampler sampler,  VkImageLayout layout, VkDescriptorType type)
{
    VkDescriptorImageInfo& info = imageInfos.emplace_back(VkDescriptorImageInfo{
		.sampler = sampler,
		.imageView = image,
		.imageLayout = layout
	});

	VkWriteDescriptorSet write = { .sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET };

	write.dstBinding = binding;
	write.dstSet = VK_NULL_HANDLE; //left empty for now until we need to write it
	write.descriptorCount = 1;
	write.descriptorType = type;
	write.pImageInfo = &info;

	writes.push_back(write);
}

与缓冲区函数非常相似,但我们有不同的 Info 类型,使用的是 VkDescriptorImageInfo。对于该类型,我们需要为其提供采样器、图像视图以及图像使用的布局。布局几乎总是 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,这是在着色器中访问纹理的最佳布局,或者 VK_IMAGE_LAYOUT_GENERAL,当我们从计算着色器中使用它们并写入它们时。

ImageInfo 中的 3 个参数可以是可选的,具体取决于特定的 VkDescriptorType。

  • VK_DESCRIPTOR_TYPE_SAMPLER 仅是采样器,因此不需要设置 ImageView 或布局。
  • VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE 不需要设置采样器,因为它将在着色器中使用不同的采样器访问,此描述符类型只是指向图像的指针。
  • VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER 需要设置所有内容,因为它保存了采样器和它采样的图像的信息。这是一种有用的类型,因为它意味着我们只需要 1 个描述符绑定即可访问纹理。
  • VK_DESCRIPTOR_TYPE_STORAGE_IMAGE 在第 2 章中已使用,它不需要采样器,并且用于允许计算着色器直接访问像素数据。

在 write_image 和 write_buffer 函数中,我们都过于通用了。这样做是为了简单起见,但是如果您愿意,您可以添加新的函数,例如 write_sampler(),其中它具有 VK_DESCRIPTOR_TYPE_SAMPLER 并将 imageview 和 layout 设置为 null,以及其他类似的抽象。

完成这些操作后,我们可以执行写入本身。

void DescriptorWriter::clear()
{
    imageInfos.clear();
    writes.clear();
    bufferInfos.clear();
}

void DescriptorWriter::update_set(VkDevice device, VkDescriptorSet set)
{
    for (VkWriteDescriptorSet& write : writes) {
        write.dstSet = set;
    }

    vkUpdateDescriptorSets(device, (uint32_t)writes.size(), writes.data(), 0, nullptr);
}

clear() 函数重置所有内容。update_set 函数接受设备和描述符集,将该集合连接到写入数组,然后调用 vkUpdateDescriptorSets 以将描述符集写入其新的绑定。

让我们看看如何使用此抽象来替换我们之前在 init_descriptors 函数中拥有的代码

之前

VkDescriptorImageInfo imgInfo{};
imgInfo.imageLayout = VK_IMAGE_LAYOUT_GENERAL;
imgInfo.imageView = _drawImage.imageView;

VkWriteDescriptorSet drawImageWrite = {};
drawImageWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
drawImageWrite.pNext = nullptr;

drawImageWrite.dstBinding = 0;
drawImageWrite.dstSet = _drawImageDescriptors;
drawImageWrite.descriptorCount = 1;
drawImageWrite.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE;
drawImageWrite.pImageInfo = &imgInfo;

vkUpdateDescriptorSets(_device, 1, &drawImageWrite, 0, nullptr);

之后

DescriptorWriter writer;
writer.write_image(0, _drawImage.imageView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE);

writer.update_set(_device,_drawImageDescriptors);

当我们的描述符集更复杂时,特别是与分配器和布局构建器结合使用时,这种抽象将被证明更有用。

动态描述符分配

让我们开始使用抽象,每帧创建一个全局场景数据描述符。这是我们所有绘制都将使用的描述符集。它将包含相机矩阵,以便我们可以进行 3D 渲染。

为了在运行时分配描述符集,我们将在 FrameData 结构中保留一个描述符分配器。这样,它的工作方式将类似于删除队列,我们在开始渲染该帧时刷新资源并删除内容。一次重置整个描述符池比尝试跟踪单个描述符集资源生命周期要快得多。

我们将其添加到 FrameData 结构中

struct FrameData {
	VkSemaphore _swapchainSemaphore, _renderSemaphore;
	VkFence _renderFence;

	VkCommandPool _commandPool;
	VkCommandBuffer _mainCommandBuffer;

	DeletionQueue _deletionQueue;
	DescriptorAllocatorGrowable _frameDescriptors;
};

现在,让我们在初始化交换链并创建这些结构时初始化它。在 init_descriptors() 的末尾添加此代码

	for (int i = 0; i < FRAME_OVERLAP; i++) {
		// create a descriptor pool
		std::vector<DescriptorAllocatorGrowable::PoolSizeRatio> frame_sizes = { 
			{ VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, 3 },
			{ VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 3 },
			{ VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 3 },
			{ VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 4 },
		};

		_frames[i]._frameDescriptors = DescriptorAllocatorGrowable{};
		_frames[i]._frameDescriptors.init(_device, 1000, frame_sizes);
	
		_mainDeletionQueue.push_function([&, i]() {
			_frames[i]._frameDescriptors.destroy_pools(_device);
		});
	}

现在,我们可以在每次刷新帧删除队列时清除这些内容。这在 draw() 的开头进行

	//wait until the gpu has finished rendering the last frame. Timeout of 1 second
	VK_CHECK(vkWaitForFences(_device, 1, &get_current_frame()._renderFence, true, 1000000000));

	get_current_frame()._deletionQueue.flush();
	get_current_frame()._frameDescriptors.clear_pools(_device);

现在我们可以动态分配描述符集,我们将分配保存场景数据的缓冲区并创建其描述符集。

添加一个新结构,我们将用于场景数据的 uniform 缓冲区。我们将分别保存视图矩阵和投影矩阵,然后是预乘的视图-投影矩阵。我们还添加了一些 vec4 用于我们接下来将构建的非常基本的光照模型。

struct GPUSceneData {
    glm::mat4 view;
    glm::mat4 proj;
    glm::mat4 viewproj;
    glm::vec4 ambientColor;
    glm::vec4 sunlightDirection; // w for sun power
    glm::vec4 sunlightColor;
};

在 VulkanEngine 类上添加新的描述符布局

GPUSceneData sceneData;

VkDescriptorSetLayout _gpuSceneDataDescriptorLayout;

创建描述符集布局作为 init_descriptors 的一部分。它将是一个具有单个 uniform 缓冲区绑定的描述符集。我们在此处使用 uniform 缓冲区而不是 SSBO,因为这是一个小缓冲区。我们没有通过缓冲区设备地址使用它,因为我们所有对象都只有一个描述符集,因此没有任何管理开销。

{
	DescriptorLayoutBuilder builder;
	builder.add_binding(0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER);
	_gpuSceneDataDescriptorLayout = builder.build(_device, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT);
}

现在,我们将在每帧的 draw_geometry() 函数内部创建此描述符集。我们还将动态分配 uniform 缓冲区本身,以展示您可以如何执行动态创建的每帧时间数据。最好将缓冲区缓存在我们的 FrameData 结构中,但我们将以这种方式进行展示。在动态绘制和通道的情况下,您可能希望以这种方式进行操作。

	//allocate a new uniform buffer for the scene data
	AllocatedBuffer gpuSceneDataBuffer = create_buffer(sizeof(GPUSceneData), VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU);

	//add it to the deletion queue of this frame so it gets deleted once its been used
	get_current_frame()._deletionQueue.push_function([=, this]() {
		destroy_buffer(gpuSceneDataBuffer);
		});

	//write the buffer
	GPUSceneData* sceneUniformData = (GPUSceneData*)gpuSceneDataBuffer.allocation->GetMappedData();
	*sceneUniformData = sceneData;

	//create a descriptor set that binds that buffer and update it
	VkDescriptorSet globalDescriptor = get_current_frame()._frameDescriptors.allocate(_device, _gpuSceneDataDescriptorLayout);

	DescriptorWriter writer;
	writer.write_buffer(0, gpuSceneDataBuffer.buffer, sizeof(GPUSceneData), 0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER);
	writer.update_set(_device, globalDescriptor);

首先,我们使用 CPU_TO_GPU 内存使用情况分配 uniform 缓冲区,使其成为 cpu 可以写入且 gpu 可以读取的内存类型。这可能在 CPU RAM 上完成,但是由于数据量很小,gpu 将可以毫无问题地将其加载到其缓存中。对于这种情况,我们可以跳过使用暂存缓冲区上传到专用 gpu 内存的逻辑。

然后我们将其添加到当前帧的销毁队列中。这将在下一帧渲染后销毁缓冲区,因此它为 GPU 完成访问提供了足够的时间。我们为单个帧动态创建的所有资源都必须在此处进行删除。

为了分配描述符集,我们从 _frameDescriptors 中分配它。该池每帧都会被销毁,因此与删除队列一样,它将在 gpu 完成使用 2 帧后自动删除。

然后我们将新缓冲区写入描述符集。现在我们有了 globalDescriptor 可以用于绘制。我们目前没有使用 scene-data 缓冲区,但这在以后是必要的。

在我们继续绘制之前,让我们设置纹理。

下一步: 纹理