Link

现在让我们编写计算着色器所需的代码。我们将从一个非常简单的着色器开始,该着色器以图像作为输入,并根据线程 ID 向其写入颜色,从而形成渐变。这个着色器已经在代码的 shaders 文件夹中。从现在开始,我们添加的所有着色器都将放入该文件夹,因为 CMake 脚本会构建它们。

gradient.comp

//GLSL version to use
#version 460

//size of a workgroup for compute
layout (local_size_x = 16, local_size_y = 16) in;

//descriptor bindings for the pipeline
layout(rgba16f,set = 0, binding = 0) uniform image2D image;


void main() 
{
    ivec2 texelCoord = ivec2(gl_GlobalInvocationID.xy);
	ivec2 size = imageSize(image);

    if(texelCoord.x < size.x && texelCoord.y < size.y)
    {
        vec4 color = vec4(0.0, 0.0, 0.0, 1.0);

        if(gl_LocalInvocationID.x != 0 && gl_LocalInvocationID.y != 0)
        {
            color.x = float(texelCoord.x)/(size.x);
            color.y = float(texelCoord.y)/(size.y);	
        }
    
        imageStore(image, texelCoord, color);
    }
}

我们首先指定默认的 glsl 版本,460 对应于 GLSL 4.6 即可。

接下来是 layout 语句,它定义了工作组大小。正如之前的文章中所解释的,当执行计算着色器时,它们将以 N 条通道/线程为一组执行。我们指定的尺寸为 x=16、y=16、z=1(默认)。这意味着在着色器中,每个组将有 16x16 条通道协同工作。

下一个 layout 语句用于通过描述符集进行着色器输入。我们将单个 image2D 设置为集合 0,并在该集合中绑定 0。使用 Vulkan,每个描述符*集*可以有多个绑定,这些绑定是在绑定该集合时由该集合绑定的事物。因此,这是一个集合,索引为 0,它将在绑定 #0 处包含单个图像。

着色器代码是一个非常简单的虚拟着色器,它将从全局调用 ID 的坐标创建渐变。如果局部调用 ID 在 X 或 Y 上为 0,我们将默认为黑色。这将创建一个网格,该网格将直接显示我们的着色器工作组调用。

任何时候您修改着色器,请确保从构建中编译 shaders 目标,如果您添加新文件,则必须重新配置 cmake。此过程必须成功且没有错误,否则项目将缺少在 gpu 上运行此着色器所需的 spirv 文件。

设置描述符布局

要构建计算管线,我们需要创建其布局。在本例中,布局将仅包含一个描述符集,然后该描述符集将图像作为其绑定 0。

要构建描述符布局,我们需要存储一个绑定数组。让我们创建一个结构来抽象它,以便更轻松地处理这些绑定。我们的描述符抽象将放入 vk_descriptors.h/cpp 中

struct DescriptorLayoutBuilder {

    std::vector<VkDescriptorSetLayoutBinding> bindings;

    void add_binding(uint32_t binding, VkDescriptorType type);
    void clear();
    VkDescriptorSetLayout build(VkDevice device, VkShaderStageFlags shaderStages, void* pNext = nullptr, VkDescriptorSetLayoutCreateFlags flags = 0);
};

我们将存储 `VkDescriptorSetLayoutBinding`(一个配置/信息结构)到一个数组中,然后有一个 build() 函数来创建 `VkDescriptorSetLayout`,这是一个 Vulkan 对象,而不是信息/配置结构。

让我们为该构建器编写函数

void DescriptorLayoutBuilder::add_binding(uint32_t binding, VkDescriptorType type)
{
    VkDescriptorSetLayoutBinding newbind {};
    newbind.binding = binding;
    newbind.descriptorCount = 1;
    newbind.descriptorType = type;

    bindings.push_back(newbind);
}

void DescriptorLayoutBuilder::clear()
{
    bindings.clear();
}

首先,我们有 add_binding 函数。这将只写入一个 `VkDescriptorSetLayoutBinding` 并将其推送到数组中。在创建布局绑定时,目前我们只需要知道绑定编号和描述符类型。以上面的计算着色器示例为例,绑定 = 0,类型为 `VK_DESCRIPTOR_TYPE_STORAGE_IMAGE`,这是一个可写入的图像。

接下来是创建布局本身

VkDescriptorSetLayout DescriptorLayoutBuilder::build(VkDevice device, VkShaderStageFlags shaderStages, void* pNext, VkDescriptorSetLayoutCreateFlags flags)
{
    for (auto& b : bindings) {
        b.stageFlags |= shaderStages;
    }

    VkDescriptorSetLayoutCreateInfo info = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};
    info.pNext = pNext;

    info.pBindings = bindings.data();
    info.bindingCount = (uint32_t)bindings.size();
    info.flags = flags;

    VkDescriptorSetLayout set;
    VK_CHECK(vkCreateDescriptorSetLayout(device, &info, nullptr, &set));

    return set;
}

我们首先循环遍历绑定并添加 stage-flags。对于描述符集中每个描述符绑定,它们在片段着色器和顶点着色器之间可能不同。我们将不支持每个绑定的 stage flags,而是强制它用于整个描述符集。

接下来,我们需要构建 `VkDescriptorSetLayoutCreateInfo`,我们对此没有做太多事情,只是将其挂钩到描述符绑定数组中。然后我们调用 `vkCreateDescriptorSetLayout` 并返回集布局。

描述符分配器

有了布局,我们现在可以分配描述符集。让我们也编写一个分配器结构来抽象它,以便我们可以继续在整个代码库中使用它。

struct DescriptorAllocator {

    struct PoolSizeRatio{
		VkDescriptorType type;
		float ratio;
    };

    VkDescriptorPool pool;

    void init_pool(VkDevice device, uint32_t maxSets, std::span<PoolSizeRatio> poolRatios);
    void clear_descriptors(VkDevice device);
    void destroy_pool(VkDevice device);

    VkDescriptorSet allocate(VkDevice device, VkDescriptorSetLayout layout);
};

描述符分配通过 `VkDescriptorPool` 完成。这些是需要预先初始化一些大小和描述符类型的对象。可以将其视为某些特定描述符的内存分配器。可以拥有 1 个非常大的描述符池来处理整个引擎,但这意味着我们需要提前知道我们将对所有内容使用哪些描述符。大规模执行此操作可能非常棘手。相反,我们将使其更简单,并且我们将为项目的不同部分设置多个描述符池,并尝试更准确地使用它们。

对池执行的一项非常重要的事情是,当您重置池时,它会销毁从中分配的所有描述符集。这对于诸如每帧描述符之类的东西非常有用。这样,我们可以拥有仅用于一帧的描述符,动态分配,然后在我们开始帧之前,我们一次性完全删除所有这些描述符。GPU 供应商已确认这是一个快速路径,建议在需要处理每帧描述符集时使用。

我们刚刚声明的 DescriptorAllocator 具有初始化池、清除池以及从中分配描述符集的函数。

让我们现在编写代码

void DescriptorAllocator::init_pool(VkDevice device, uint32_t maxSets, std::span<PoolSizeRatio> poolRatios)
{
    std::vector<VkDescriptorPoolSize> poolSizes;
    for (PoolSizeRatio ratio : poolRatios) {
        poolSizes.push_back(VkDescriptorPoolSize{
            .type = ratio.type,
            .descriptorCount = uint32_t(ratio.ratio * maxSets)
        });
    }

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

	vkCreateDescriptorPool(device, &pool_info, nullptr, &pool);
}

void DescriptorAllocator::clear_descriptors(VkDevice device)
{
    vkResetDescriptorPool(device, pool, 0);
}

void DescriptorAllocator::destroy_pool(VkDevice device)
{
    vkDestroyDescriptorPool(device,pool,nullptr);
}

我们添加创建和销毁函数。clear 函数不是删除,而是重置。它将销毁从池中创建的所有描述符,并将其恢复到初始状态,但不会删除 VkDescriptorPool 本身。

要初始化池,我们使用 `vkCreateDescriptorPool` 并为其提供 PoolSizeRatio 数组。这是一个结构,其中包含描述符类型(与上面的绑定中的 VkDescriptorType 相同),以及用于乘以 maxSets 参数的比率。这使我们可以直接控制池的大小。maxSets 控制我们可以从池中创建多少个 VkDescriptorSet,而池大小给出给定类型的单个绑定的数量。

现在我们需要最后一个函数 `DescriptorAllocator::allocate`。它在这里。

VkDescriptorSet DescriptorAllocator::allocate(VkDevice device, VkDescriptorSetLayout layout)
{
    VkDescriptorSetAllocateInfo allocInfo = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
    allocInfo.pNext = nullptr;
    allocInfo.descriptorPool = pool;
    allocInfo.descriptorSetCount = 1;
    allocInfo.pSetLayouts = &layout;

    VkDescriptorSet ds;
    VK_CHECK(vkAllocateDescriptorSets(device, &allocInfo, &ds));

    return ds;
}

我们需要填充 `VkDescriptorSetAllocateInfo`。它需要我们将从中分配的描述符池、要分配的描述符集数量以及集布局。

初始化布局和描述符

让我们向 VulkanEngine 添加一个新函数和一些我们将使用的新成员。

#include <vk_descriptors.h>

struct VulkanEngine{
public:
	DescriptorAllocator globalDescriptorAllocator;

	VkDescriptorSet _drawImageDescriptors;
	VkDescriptorSetLayout _drawImageDescriptorLayout;

private:
	void init_descriptors();
}

我们将在引擎中存储其中一个描述符分配器作为全局分配器。然后我们需要存储将绑定渲染图像的描述符集,以及该类型描述符的描述符布局,我们稍后在创建管线时将需要它。

请记住在引擎的 init() 函数中的 sync_structures 之后添加 `init_descriptors()` 函数。

void VulkanEngine::init()
{
	//other code

	init_commands();

	init_sync_structures();

	init_descriptors();	

	//everything went fine
	_isInitialized = true;
}

我们现在可以开始编写函数

void VulkanEngine::init_descriptors()
{
	//create a descriptor pool that will hold 10 sets with 1 image each
	std::vector<DescriptorAllocator::PoolSizeRatio> sizes =
	{
		{ VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, 1 }
	};

	globalDescriptorAllocator.init_pool(_device, 10, sizes);

	//make the descriptor set layout for our compute draw
	{
		DescriptorLayoutBuilder builder;
		builder.add_binding(0, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE);
		_drawImageDescriptorLayout = builder.build(_device, VK_SHADER_STAGE_COMPUTE_BIT);
	}
}

我们将首先初始化描述符分配器,其中包含 10 个集,并且每个集包含 1 个类型为 `VK_DESCRIPTOR_TYPE_STORAGE_IMAGE` 的描述符。这是用于可以从计算着色器写入的图像的类型。

然后,我们使用布局构建器来构建我们需要的描述符集布局,这是一个布局,其中只有一个绑定,绑定编号为 0,类型也为 `VK_DESCRIPTOR_TYPE_STORAGE_IMAGE`(与池匹配)。

这样,我们将能够分配最多 10 个此类型的描述符,以用于计算绘制。

我们继续该函数,分配其中一个描述符,并编写它,使其指向我们的绘制图像。

void VulkanEngine::init_descriptors()
{
	// other code
	//allocate a descriptor set for our draw image
	_drawImageDescriptors = globalDescriptorAllocator.allocate(_device,_drawImageDescriptorLayout);	

	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);

	//make sure both the descriptor allocator and the new layout get cleaned up properly
	_mainDeletionQueue.push_function([&]() {
		globalDescriptorAllocator.destroy_pool(_device);

		vkDestroyDescriptorSetLayout(_device, _drawImageDescriptorLayout, nullptr);
	});
}

首先,我们在分配器的帮助下,分配一个描述符集对象,其布局为 _drawImageDescriptorLayout,这是我们上面创建的。然后,我们需要使用我们的绘制图像更新该描述符集。为此,我们需要使用 `vkUpdateDescriptorSets` 函数。此函数采用 `VkWriteDescriptorSet` 数组,这些是要执行的单个更新。我们创建一个单独的写入,它指向我们刚刚分配的集合上的绑定 0,并且它具有正确的类型。它还指向一个 `VkDescriptorImageInfo`,其中包含我们要绑定的实际图像数据,这将是我们绘制图像的图像视图。

完成此操作后,我们现在拥有一个可用于绑定绘制图像的描述符集,以及我们需要的布局。我们终于可以继续创建计算管线了

计算管线

有了描述符集布局,我们现在有了一种创建管线布局的方法。在创建管线之前,我们必须做的最后一件事是将着色器代码加载到驱动程序。在 Vulkan 管线中,要设置着色器,您需要构建一个 `VkShaderModule`。我们将添加一个函数来加载这些着色器,作为 vk_pipelines.h/cpp 的一部分

将这些包含项添加到 vk_pipelines.cpp

#include <vk_pipelines.h>
#include <fstream>
#include <vk_initializers.h>

现在添加此函数。也将其添加到标头中。

bool vkutil::load_shader_module(const char* filePath,
    VkDevice device,
    VkShaderModule* outShaderModule)
{
    // open the file. With cursor at the end
    std::ifstream file(filePath, std::ios::ate | std::ios::binary);

    if (!file.is_open()) {
        return false;
    }

    // find what the size of the file is by looking up the location of the cursor
    // because the cursor is at the end, it gives the size directly in bytes
    size_t fileSize = (size_t)file.tellg();

    // spirv expects the buffer to be on uint32, so make sure to reserve a int
    // vector big enough for the entire file
    std::vector<uint32_t> buffer(fileSize / sizeof(uint32_t));

    // put file cursor at beginning
    file.seekg(0);

    // load the entire file into the buffer
    file.read((char*)buffer.data(), fileSize);

    // now that the file is loaded into the buffer, we can close it
    file.close();

    // create a new shader module, using the buffer we loaded
    VkShaderModuleCreateInfo createInfo = {};
    createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
    createInfo.pNext = nullptr;

    // codeSize has to be in bytes, so multply the ints in the buffer by size of
    // int to know the real size of the buffer
    createInfo.codeSize = buffer.size() * sizeof(uint32_t);
    createInfo.pCode = buffer.data();

    // check that the creation goes well.
    VkShaderModule shaderModule;
    if (vkCreateShaderModule(device, &createInfo, nullptr, &shaderModule) != VK_SUCCESS) {
        return false;
    }
    *outShaderModule = shaderModule;
    return true;
}

使用此函数,我们首先将文件加载到 `std::vector<uint32_t>` 中。这将存储编译后的着色器数据,然后我们可以在调用 `vkCreateShaderModule` 时使用它。着色器模块的创建信息只需要着色器数据作为 int 数组。仅在构建管线时才需要着色器模块,并且一旦构建了管线,就可以安全地销毁它们,因此我们不会将它们存储在 VulkanEngine 类中。

回到 VulkanEngine,让我们添加我们将需要的新成员,以及 init_pipelines() 函数以及 init_background_pipelines() 函数。init_pipelines() 将调用我们将随着教程的进行而添加的其他管线初始化函数。

class VulkanEngine{
public:
	VkPipeline _gradientPipeline;
	VkPipelineLayout _gradientPipelineLayout;

private:
	void init_pipelines();
	void init_background_pipelines();
}

将其添加到 init 函数,并将 vk_pipelines.h 包含项添加到文件顶部。`init_pipelines()` 函数将调用 `init_background_pipelines()`

#include <vk_pipelines.h>

void VulkanEngine::init()
{
	//other code

	init_commands();

	init_sync_structures();

	init_descriptors();	

	init_pipelines();

	//everything went fine
	_isInitialized = true;
}

void VulkanEngine::init_pipelines()
{
	init_background_pipelines();
}

现在让我们开始创建管线。我们要做的第一件事是创建管线布局。

void VulkanEngine::init_background_pipelines()
{
	VkPipelineLayoutCreateInfo computeLayout{};
	computeLayout.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
	computeLayout.pNext = nullptr;
	computeLayout.pSetLayouts = &_drawImageDescriptorLayout;
	computeLayout.setLayoutCount = 1;

	VK_CHECK(vkCreatePipelineLayout(_device, &computeLayout, nullptr, &_gradientPipelineLayout));
}

要创建管线,我们需要一个要使用的描述符集布局数组,以及其他配置,例如推送常量。在此着色器上,我们不需要这些,因此我们可以跳过它们,仅保留 DescriptorSetLayout。

现在,我们将通过加载着色器模块并将其与其他选项添加到 VkComputePipelineCreateInfo 中来创建管线对象本身。

void VulkanEngine::init_background_pipelines()
{
	//layout code
	VkShaderModule computeDrawShader;
	if (!vkutil::load_shader_module("../../shaders/gradient.comp.spv", _device, &computeDrawShader))
	{
		fmt::print("Error when building the compute shader \n");
	}

	VkPipelineShaderStageCreateInfo stageinfo{};
	stageinfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
	stageinfo.pNext = nullptr;
	stageinfo.stage = VK_SHADER_STAGE_COMPUTE_BIT;
	stageinfo.module = computeDrawShader;
	stageinfo.pName = "main";

	VkComputePipelineCreateInfo computePipelineCreateInfo{};
	computePipelineCreateInfo.sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO;
	computePipelineCreateInfo.pNext = nullptr;
	computePipelineCreateInfo.layout = _gradientPipelineLayout;
	computePipelineCreateInfo.stage = stageinfo;
	
	VK_CHECK(vkCreateComputePipelines(_device,VK_NULL_HANDLE,1,&computePipelineCreateInfo, nullptr, &_gradientPipeline));
}

首先,我们使用我们刚刚创建的函数加载 computeDrawShader VkShaderModule。我们将检查错误,因为如果文件错误,则很可能失败。请记住,此处的路径配置为适用于默认的 windows + msvc 构建文件夹。如果您使用任何其他选项,请检查文件路径是否正确。考虑自己抽象路径,以便从配置文件中设置的文件夹工作。

然后,我们需要将着色器连接到 `VkPipelineShaderStageCreateInfo` 中。这里要注意的是,我们正在为其提供我们希望着色器使用的函数的名称,这将是 main()。可以通过在同一着色器文件中使用不同的入口点函数,然后在此处进行设置来存储多个计算着色器变体。

最后,我们填充 `VkComputePipelineCreateInfo`。我们将需要计算着色器的阶段信息和布局。然后我们可以调用 `vkCreateComputePipelines`。

在函数末尾,我们将对结构进行适当的清理,以便在程序结束时通过删除队列删除它们。

	vkDestroyShaderModule(_device, computeDrawShader, nullptr);

	_mainDeletionQueue.push_function([&]() {
		vkDestroyPipelineLayout(_device, _gradientPipelineLayout, nullptr);
		vkDestroyPipeline(_device, _gradientPipeline, nullptr);
		});

我们可以直接在函数中销毁着色器模块,我们创建了管线,因此不再需要它。对于管线及其布局,我们需要等到程序结束。

我们现在已准备好使用它进行绘制。

使用计算着色器绘制

返回到 draw_background() 函数,我们将用计算着色器调用替换 vkCmdClear。

	// bind the gradient drawing compute pipeline
	vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, _gradientPipeline);

	// bind the descriptor set containing the draw image for the compute pipeline
	vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, _gradientPipelineLayout, 0, 1, &_drawImageDescriptors, 0, nullptr);

	// execute the compute pipeline dispatch. We are using 16x16 workgroup size so we need to divide by it
	vkCmdDispatch(cmd, std::ceil(_drawExtent.width / 16.0), std::ceil(_drawExtent.height / 16.0), 1);

首先我们需要使用 `vkCmdBindPipeline` 绑定管线。由于管线是计算着色器,因此我们使用 `VK_PIPELINE_BIND_POINT_COMPUTE`。然后,我们需要绑定保存绘制图像的描述符集,以便着色器可以访问它。最后,我们使用 `vkCmdDispatch` 启动计算着色器。我们将通过将绘制图像的分辨率除以 16 并向上舍入来决定要启动多少次着色器调用(请记住,着色器每个工作组为 16x16)。

如果您此时运行程序,它应该显示此图像。如果您在加载着色器时遇到错误,请确保通过重新运行 CMake 并重建 Shaders 目标来构建着色器。此目标在构建引擎时不会自动构建,因此您每次更改着色器时都必须重建它。

chapter2

下一步: 设置 IMGUI