Link

弃用警告

本文已过时,不适用于当前版本的 vkguide,仅适用于旧版 Vkguide。本文的大部分概念已整合到主教程中。

管理 Descriptor

创建和管理 descriptor sets 是 Vulkan 中最痛苦的事情之一。创建一个简化它的抽象非常重要,并且将大大改善工作流程。

我将向您展示一种简单的方法来创建 descriptor sets 的轻量级抽象,使其更易于处理。

最终结果将如下所示。

VkDescriptorSet GlobalSet;
vkutil::DescriptorBuilder::begin(_descriptorLayoutCache, _descriptorAllocator)
	.bind_buffer(0, &dynamicInfo, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC, VK_SHADER_STAGE_VERTEX_BIT )
	.bind_buffer(1, &dynamicInfo, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC, VK_SHADER_STAGE_FRAGMENT_BIT)
	.build(GlobalSet);

VkDescriptorSet ObjectDataSet;
vkutil::DescriptorBuilder::begin(_descriptorLayoutCache, _descriptorAllocator)
	.bind_buffer(0, &objectBufferInfo, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, VK_SHADER_STAGE_VERTEX_BIT)
	.build(ObjectDataSet);

VkDescriptorSet ImageSet;
vkutil::DescriptorBuilder::begin(_descriptorLayoutCache, _descriptorAllocator)
		.bind_image(0, &imageBufferInfo, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, VK_SHADER_STAGE_FRAGMENT_BIT)
		.build(ImageSet);

此抽象的实现将完全独立,您可以在示例代码的 Engine 分支中找到其代码。

  • https://github.com/vblanco20-1/vulkan-guide/blob/engine/extra-engine/vk_descriptors.h
  • https://github.com/vblanco20-1/vulkan-guide/blob/engine/extra-engine/vk_descriptors.cpp

引擎使用此抽象而不是手动创建 descriptors。

该抽象由 3 个模块化部分组成。

  • DescriptorAllocator:管理 descriptor sets 的分配。一旦 descriptor pools 被填满,将持续创建新的 descriptor pools。可以重置整个系统并重用 pools。
  • DescriptorLayoutCache:缓存 DescriptorSetLayouts 以避免创建重复的 layouts。
  • DescriptorBuilder:使用上述 2 个对象来自动分配和写入 descriptor set 及其 layout。

请注意,layout 和 allocator 都不是线程安全的,但可以相对容易地使其线程安全。descriptor builder 是完全无状态的,因此默认情况下是线程安全的。

我们将把这 3 个类放入 vk_descriptors.hvk_descriptors.cpp 中。它们不会依赖于引擎的其余部分,仅依赖于 vulkan.h 和 STL。

Descriptor Allocator

如第 4 章所述,descriptors 必须从 pool 中分配。您需要管理此 pool,并在需要时创建更多 pool,如果您每帧都分配 descriptors,也可以尽可能重用它们。

我们将创建一个基本抽象,它一次分配 1000 个 descriptors 的 pools,当 pool 被填满时,它将分配新的 pools。当 allocator 被重置时,它将重置其所有 pools,并将它们移动到可重用 pools 的列表。只有在没有剩余可重用 pools 时,它才会分配新的 pools。此 allocator 的设计是 https://github.com/vblanco20-1/Vulkan-Descriptor-Allocator 上的线程安全版本的简化版本

我们将在引擎中拥有多个 allocators。其中一个将用于“持久” descriptors,每个帧一个 allocator 用于动态分配的 descriptors。

让我们从类本身开始

class DescriptorAllocator {
	public:

		struct PoolSizes {
			std::vector<std::pair<VkDescriptorType,float>> sizes =
			{
				{ VK_DESCRIPTOR_TYPE_SAMPLER, 0.5f },
				{ VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 4.f },
				{ VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 4.f },
				{ VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, 1.f },
				{ VK_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER, 1.f },
				{ VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER, 1.f },
				{ VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 2.f },
				{ VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 2.f },
				{ VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC, 1.f },
				{ VK_DESCRIPTOR_TYPE_STORAGE_BUFFER_DYNAMIC, 1.f },
				{ VK_DESCRIPTOR_TYPE_INPUT_ATTACHMENT, 0.5f }
			};
		};

		void reset_pools();
		bool allocate(VkDescriptorSet* set, VkDescriptorSetLayout layout);

		void init(VkDevice newDevice);

		void cleanup();

		VkDevice device;
	private:
		VkDescriptorPool grab_pool();

		VkDescriptorPool currentPool{VK_NULL_HANDLE};
		PoolSizes descriptorSizes;
		std::vector<VkDescriptorPool> usedPools;
		std::vector<VkDescriptorPool> freePools;
	};

我们将在 PoolSizes 结构中存储 descriptor 类型的乘数。其思想是它是为 pools 分配的 descriptor sets 数量的乘数。

例如,如果您在其中将 VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER 设置为 4.f,则意味着当分配 1000 个 descriptors 的 pool 时,该 pool 将有空间容纳 4000 个组合图像 descriptors。其中的数字作为默认值是合理的,但是如果您根据您的项目使用情况对其进行调整,则可以显着提高此 allocator 的内存使用率。

其余函数很简单。我们有一个 init()cleanup() 函数来启动和销毁 allocator,以及 reset_pools()allocate() 函数。

reset_pools() 将重置系统内部持有的所有 DescriptorPools,并将它们移动到 freePools 数组,以便稍后重用。allocate() 将执行 descriptor set allocator。如果出现重大错误,则返回 false。

usedPools; 将保存 allocator 中“活动”且已在其中分配了 descriptors 的 pools。freePools 存储完全重置的 pools 以供重用。

我们现在可以开始关注类的实现。我们将从 init()cleanup() 函数开始。

void DescriptorAllocator::init(VkDevice newDevice)
{
	device = newDevice;
}

void DescriptorAllocator::cleanup()
{
	//delete every pool held
	for (auto p : freePools)
	{
		vkDestroyDescriptorPool(device, p, nullptr);
	}
	for (auto p : usedPools)
	{
		vkDestroyDescriptorPool(device, p, nullptr);
	}
}

在 init 中,我们只需要设置设备。对于 cleanup,我们将迭代 descriptor pool 数组,并销毁每个 pool。

让我们填写 createPool() 函数,其中分配了新 pools。

VkDescriptorPool createPool(VkDevice device, const DescriptorAllocator::PoolSizes& poolSizes, int count, VkDescriptorPoolCreateFlags flags)
	{
		std::vector<VkDescriptorPoolSize> sizes;
		sizes.reserve(poolSizes.sizes.size());
		for (auto sz : poolSizes.sizes) {
			sizes.push_back({ sz.first, uint32_t(sz.second * count) });
		}
		VkDescriptorPoolCreateInfo pool_info = {};
		pool_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
		pool_info.flags = flags;
		pool_info.maxSets = count;
		pool_info.poolSizeCount = (uint32_t)sizes.size();
		pool_info.pPoolSizes = sizes.data();

		VkDescriptorPool descriptorPool;
		vkCreateDescriptorPool(device, &pool_info, nullptr, &descriptorPool);

		return descriptorPool;
	}

createPool() 函数会将我们的乘数数组转换为正确的 VkDescriptorPoolSize 数组,然后使用它来分配 pool。

让我们也填写 grab_pool() 函数,其中根据需要抓取 pools。

VkDescriptorPool DescriptorAllocator::grab_pool()
{
	//there are reusable pools availible
	if (freePools.size() > 0)
	{
		//grab pool from the back of the vector and remove it from there.
		VkDescriptorPool pool = freePools.back();
		freePools.pop_back();
		return pool;
	}
	else
	{
		//no pools availible, so create a new one
		return createPool(device, descriptorSizes, 1000, 0);
	}
}

在该函数中,如果 descriptor pool 可用,我们将重用它,如果我们没有可用的 pool,那么我们将创建一个新的 pool 来容纳 1000 个 descriptors。1000 这个计数是任意的。也可以创建增长的 pools 或不同大小的 pools。

在编码了这些函数之后,我们现在可以专注于主要的 allocate 函数,这是 allocator 的核心所在。

bool DescriptorAllocator::allocate(VkDescriptorSet* set, VkDescriptorSetLayout layout)
	{
		//initialize the currentPool handle if it's null
		if (currentPool == VK_NULL_HANDLE){

			currentPool = grab_pool();
			usedPools.push_back(currentPool);
		}

		VkDescriptorSetAllocateInfo allocInfo = {};
		allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
		allocInfo.pNext = nullptr;

		allocInfo.pSetLayouts = &layout;
		allocInfo.descriptorPool = currentPool;
		allocInfo.descriptorSetCount = 1;

		//try to allocate the descriptor set
		VkResult allocResult = vkAllocateDescriptorSets(device, &allocInfo, set);
		bool needReallocate = false;

		switch (allocResult) {
		case VK_SUCCESS:
			//all good, return
			return true;
		case VK_ERROR_FRAGMENTED_POOL:
		case VK_ERROR_OUT_OF_POOL_MEMORY:
			//reallocate pool
			needReallocate = true;
			break;
		default:
			//unrecoverable error
			return false;
		}

		if (needReallocate){
			//allocate a new pool and retry
			currentPool = grab_pool();
			usedPools.push_back(currentPool);

			allocResult = vkAllocateDescriptorSets(device, &allocInfo, set);

			//if it still fails then we have big issues
			if (allocResult == VK_SUCCESS){
				return true;
			}
		}

		return false;
	}

在该函数中,我们首先初始化 currentPool 句柄(如果它为空),并重用或分配一个新的 descriptor pool。

之后,我们尝试使用 vkAllocateDescriptorSets() 分配 descriptor。在调用之后,我们检查错误代码。如果没有错误 VK_SUCCESS,一切都很好,我们可以从函数返回。如果错误是 VK_ERROR_FRAGMENTED_POOLVK_ERROR_OUT_OF_POOL_MEMORY 中的任何一个,我们需要分配一个新的 pool,因此我们继续。如果错误不是这些内存错误之一,则说明真的出了问题,因此我们停止该函数并返回 false

在内存错误时,我们尝试抓取另一个 pool,然后重新分配。如果我们仍然在第二次重新分配时遇到错误(这是从干净分配/重用的 pool 中发生的),则说明真的出了问题,因此我们让它继续并返回 false。

在编写了主要的分配函数之后,剩下要做的就是实现 reset() 函数。

void DescriptorAllocator::reset_pools(){
	//reset all used pools and add them to the free pools
	for (auto p : usedPools){
		vkResetDescriptorPool(device, p, 0);
		freePools.push_back(p);
	}

	//clear the used pools, since we've put them all in the free pools
	usedPools.clear();

	//reset the current pool handle back to null
	currentPool = VK_NULL_HANDLE;
}

在 reset 中,我们在每个使用的 pool 上调用 vkResetDescriptorPool,然后将它们移动到 freePools 数组以供重用。我们还将 currentPool 句柄设置为 null,以便分配函数在下次分配时尝试抓取一个 pool。

这个简单的 allocator 不会是最优的,但是如果您通过设置适当的大小乘数来正确使用它,它将是最优的。如果您有常见的 descriptor set 形状,则最好为这些 descriptors 专门设置一个 allocator。例如,仅用于保存纹理的 sets 的 allocator。

接下来是 LayoutCache

Descriptor Set Layout Cache

尝试手动通过代码库重用 descriptor set layouts 可能非常棘手。更好的方法是拥有一个缓存,然后在创建新的 descriptor set layout 时,如果已创建该特定 layout,我们可以从缓存中重用它。

缓存将是一个简单的 unordered_map。它不是此用例中最有效的 hashmap,但作为示例,它可以很好地工作。如果您在更大的项目中使用它,请考虑用更好的 hashmap 替换它。

缓存的工作方式在 Vulkan 中非常常见。许多对象都可以以这种完全相同的方式进行缓存,例如 pipeline layouts 或 render passes。此 descriptor layout cache 充当如何实现此类缓存的一个很好的示例。

类定义将如下所示

class DescriptorLayoutCache {
	public:
		void init(VkDevice newDevice);
		void cleanup();

		VkDescriptorSetLayout create_descriptor_layout(VkDescriptorSetLayoutCreateInfo* info);

		struct DescriptorLayoutInfo {
			//good idea to turn this into a inlined array
			std::vector<VkDescriptorSetLayoutBinding> bindings;

			bool operator==(const DescriptorLayoutInfo& other) const;

			size_t hash() const;
		};



	private:

		struct DescriptorLayoutHash		{

			std::size_t operator()(const DescriptorLayoutInfo& k) const{
				return k.hash();
			}
		};

		std::unordered_map<DescriptorLayoutInfo, VkDescriptorSetLayout, DescriptorLayoutHash> layoutCache;
		VkDevice device;
};

我们将具有与 allocator 类似的流程,包括 init(device)cleanup() 函数。主要函数是 create_descriptor_layout,它直接模仿 vkCreateDescriptorSetLayout,接受 VkDescriptorSetLayoutCreateInfo 结构。

缓存将与 DescriptorLayoutCache::DescriptorLayoutInfo 结构一起使用,该结构保存 VkDescriptorSetLayoutBinding 的向量。这就是我们将在 hashmap 中使用的内容,因此我们需要哈希和相等性检查实现。

std::unordered_map 可以在模板参数中接收自定义哈希实现作为第三个参数。为此,我们需要专门创建一个哈希器对象 DescriptorLayoutHash。这将是一个空结构,它实现 operator() 并将其转发到 DescriptorLayoutInfo::hash()

要将对象作为键放在 hashmap 中,我们需要使其具有相等性和哈希函数,这就是为什么我们实现 operator== 和哈希器对象的原因。

此缓存的想法是,我们将原始 VkDescriptorSetLayoutCreateInfo 结构转换为我们的 DescriptorLayoutCache::DescriptorLayoutInfo,然后在 hashmap 中查找它,以查看是否已为该特定 layout 描述创建了 VkDescriptorSetLayout。

让我们从 init 和 cleanup 函数开始

void DescriptorLayoutCache::init(VkDevice newDevice){
	device = newDevice;
}
void DescriptorLayoutCache::cleanup(){
	//delete every descriptor layout held
	for (auto pair : layoutCache){
		vkDestroyDescriptorSetLayout(device, pair.second, nullptr);
	}
}

它们的工作方式几乎完全相同。对于 cleanup 函数,我们遍历 hashmap 中所有存储的 layouts,并销毁它们。

现在让我们实现 create layout 函数。

VkDescriptorSetLayout DescriptorLayoutCache::create_descriptor_layout(VkDescriptorSetLayoutCreateInfo* info){
	DescriptorLayoutInfo layoutinfo;
	layoutinfo.bindings.reserve(info->bindingCount);
	bool isSorted = true;
	int lastBinding = -1;

	//copy from the direct info struct into our own one
	for (int i = 0; i < info->bindingCount; i++) {
		layoutinfo.bindings.push_back(info->pBindings[i]);

		//check that the bindings are in strict increasing order
		if (info->pBindings[i].binding > lastBinding){
			lastBinding = info->pBindings[i].binding;
		}
		else{
			isSorted = false;
		}
	}
	//sort the bindings if they aren't in order
	if (!isSorted){
		std::sort(layoutinfo.bindings.begin(), layoutinfo.bindings.end(), [](VkDescriptorSetLayoutBinding& a, VkDescriptorSetLayoutBinding& b ){
				return a.binding < b.binding;
		});
	}

	//try to grab from cache
	auto it = layoutCache.find(layoutinfo);
	if (it != layoutCache.end()){
		return (*it).second;
	}
	else {
		//create a new one (not found)
		VkDescriptorSetLayout layout;
		vkCreateDescriptorSetLayout(device, info, nullptr, &layout);

		//add to cache
		layoutCache[layoutinfo] = layout;
		return layout;
		}
	}

函数中我们做的第一件事是将绑定从 VkDescriptorSetLayoutCreateInfo 复制到 DescriptorLayoutInfo 向量。我们检查绑定是否按完美升序排列,如果不是,我们使用 std::sort 对它们进行排序。我们希望绑定是有序的,因为它使相等性检查工作得更好。

要使用 std::sort,我们需要包含 <algorithm>。 std::sort 默认使用 operator<,但是我们可以通过使用 lambda 来实现自定义排序来覆盖该逻辑。

排序后,我们现在可以尝试在 hashmap 中找到它。如果找到,我们返回已创建的 layout。如果找不到,我们创建一个新的 layout,将其添加到缓存中,然后返回它。

如果没有运算符,hashmap 将无法工作,因此现在我们实现它们。对于相等性,我们将执行此操作

bool DescriptorLayoutCache::DescriptorLayoutInfo::operator==(const DescriptorLayoutInfo& other) const{
	if (other.bindings.size() != bindings.size()){
		return false;
	}
	else {
		//compare each of the bindings is the same. Bindings are sorted so they will match
		for (int i = 0; i < bindings.size(); i++) {
			if (other.bindings[i].binding != bindings[i].binding){
				return false;
			}
			if (other.bindings[i].descriptorType != bindings[i].descriptorType){
				return false;
			}
			if (other.bindings[i].descriptorCount != bindings[i].descriptorCount){
				return false;
			}
			if (other.bindings[i].stageFlags != bindings[i].stageFlags){
				return false;
			}
		}
		return true;
	}
}

我们首先比较绑定向量的大小是否相同,如果相同,我们比较其中的每个绑定。这就是为什么我们需要对它们进行排序的原因,因此测试相等性不需要更复杂的循环。

哈希看起来像这样。

size_t DescriptorLayoutCache::DescriptorLayoutInfo::hash() const{
		using std::size_t;
		using std::hash;

		size_t result = hash<size_t>()(bindings.size());

		for (const VkDescriptorSetLayoutBinding& b : bindings)
		{
			//pack the binding data into a single int64. Not fully correct but it's ok
			size_t binding_hash = b.binding | b.descriptorType << 8 | b.descriptorCount << 16 | b.stageFlags << 24;

			//shuffle the packed binding data and xor it with the main hash
			result ^= hash<size_t>()(binding_hash);
		}

		return result;
	}

我们将通过哈希我们在 layout info 中拥有的绑定数量来开始哈希。之后,我们将每个绑定的数据压缩为 size_t,并将该数据与哈希进行异或运算。虽然我们所做的打包并不是最好的,但它并没有那么重要。

descriptor 缓存现在已启动并运行。您可以使用类似的代码来缓存几乎任何其他 vulkan 对象。一些推荐的对象是 pipelines 本身和 render passes。

要实现的最后一件事是 descriptor builder 本身。

Descriptor Builder

descriptor builder 将遵循构建器模式。我们将从 “begin()” 调用开始,然后执行其他调用以将对象绑定到它。在构建 descriptor set 时,它将自动生成 descriptor layout(使用缓存),然后分配和写入 descriptor set。

类声明如下所示。

class DescriptorBuilder {
public:
	static DescriptorBuilder begin(DescriptorLayoutCache* layoutCache, DescriptorAllocator* allocator );

	DescriptorBuilder& bind_buffer(uint32_t binding, VkDescriptorBufferInfo* bufferInfo, VkDescriptorType type, VkShaderStageFlags stageFlags);
	DescriptorBuilder& bind_image(uint32_t binding, VkDescriptorImageInfo* imageInfo, VkDescriptorType type, VkShaderStageFlags stageFlags);

	bool build(VkDescriptorSet& set, VkDescriptorSetLayout& layout);
	bool build(VkDescriptorSet& set);
private:

	std::vector<VkWriteDescriptorSet> writes;
	std::vector<VkDescriptorSetLayoutBinding> bindings;

	DescriptorLayoutCache* cache;
	DescriptorAllocator* alloc;
	};

begin() 函数中,我们请求一个 allocator 和一个 layout cache。此 builder 可以在没有它们的情况下使用,但是当它们都在一起时,效果会更好。

接下来我们有绑定函数,一个用于 buffers,另一个用于 images。它们非常相似。

最后,是 2 个 build 函数。一个返回 layout,另一个不返回。您并不总是需要 layout,因此在调用中不包含它是很好的。

类中的数据将是我们存储的 cache 和 allocator,以及 2 个向量。一个用于 descriptor writes,另一个用于 layout bindings。我们将使用它们来创建缓存并写入 descriptor 数据。

让我们从 begin 调用开始。

vkutil::DescriptorBuilder DescriptorBuilder::begin(DescriptorLayoutCache* layoutCache, DescriptorAllocator* allocator){

	DescriptorBuilder builder;

	builder.cache = layoutCache;
	builder.alloc = allocator;
	return builder;
}

begin 调用与其他对象的 init() 调用不同。此调用创建一个新的 descriptor builder,初始化参数,并返回它。

bind_buffer()bind_image() 几乎完全相同。我们只需要查看 bind_buffer() 即可了解逻辑。

	vkutil::DescriptorBuilder& DescriptorBuilder::bind_buffer(uint32_t binding, VkDescriptorBufferInfo* bufferInfo, VkDescriptorType type, VkShaderStageFlags stageFlags)
	{
		//create the descriptor binding for the layout
		VkDescriptorSetLayoutBinding newBinding{};

		newBinding.descriptorCount = 1;
		newBinding.descriptorType = type;
		newBinding.pImmutableSamplers = nullptr;
		newBinding.stageFlags = stageFlags;
		newBinding.binding = binding;

		bindings.push_back(newBinding);

		//create the descriptor write
		VkWriteDescriptorSet newWrite{};
		newWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
		newWrite.pNext = nullptr;

		newWrite.descriptorCount = 1;
		newWrite.descriptorType = type;
		newWrite.pBufferInfo = bufferInfo;
		newWrite.dstBinding = binding;

		writes.push_back(newWrite);
		return *this;
	}

bind_buffer() 函数执行 2 个主要操作。它从参数创建一个新的 descriptor layout binding,并创建一个 descriptor write,该 write 将写入相同的绑定。我们将它们都存储在 2 个数组中。

现在让我们看一下主要的 build() 函数。

bool DescriptorBuilder::build(VkDescriptorSet& set, VkDescriptorSetLayout& layout){
	//build layout first
	VkDescriptorSetLayoutCreateInfo layoutInfo{};
	layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
	layoutInfo.pNext = nullptr;

	layoutInfo.pBindings = bindings.data();
	layoutInfo.bindingCount = bindings.size();

	layout = cache->create_descriptor_layout(&layoutInfo);

	//allocate descriptor
	bool success = alloc->allocate(&set, layout);
	if (!success) { return false; };

	//write descriptor
	for (VkWriteDescriptorSet& w : writes) {
		w.dstSet = set;
	}

	vkUpdateDescriptorSets(alloc->device, writes.size(), writes.data(), 0, nullptr);

	return true;
}

我们首先填写 layoutInfo 并创建 layout。因为我们一直在向量中存储绑定,所以我们只需要将其挂钩到 Info 结构即可。我们还使用缓存来创建 layout,以避免重复创建。

创建 layout 后,我们可以使用 descriptor allocator 来分配新的 descriptor set。

然后我们可以写入其中。我们需要遍历 writes 数组,并确保它们指向新创建的 descriptor set。完成之后,我们最终可以使用该数组调用 vkUpdateDescriptorSets,从而在 set 中设置数据。

就是这样,现在您对 descriptor sets 及其 layouts 有了一个轻量级的抽象,这使得在运行时处理它们变得更加容易。您可以查看引擎中的代码如何使用它。

引擎分支中的 draw_objects() 函数使用此抽象,因此您可以检查它们是如何使用的。 https://github.com/vblanco20-1/vulkan-guide/blob/engine/extra-engine/vk_engine.cpp