Link

为了正确渲染对象,我们需要将顶点数据发送到顶点着色器。目前,我们正在使用硬编码的数组,但这不适用于单个三角形或类似几何体以外的任何内容。

由于我们没有在管线上使用固定功能顶点属性获取逻辑,因此我们可以完全自由地决定如何在着色器中加载顶点数据。我们将从通过缓冲区设备地址传递的大型 GPU 缓冲区加载顶点,这将提供高性能和出色的灵活性。

Vulkan 缓冲区

在 Vulkan 中,我们可以通过缓冲区分配通用内存。它们与图像不同,因为它们不需要采样器,更像是典型的 CPU 端结构或数组。我们可以在着色器中像结构或结构数组一样访问它们。

创建缓冲区时,我们需要为其设置使用标志。我们将在使用它们时设置这些标志。

对于着色器中的通用读/写操作,有两种类型的缓冲区。统一缓冲区和存储缓冲区

使用统一缓冲区 (UBO),只能在着色器中访问少量数据(取决于供应商,保证最小 16 KB),并且内存将是只读的。另一方面,这提供了最快的访问速度,因为 GPU 可能会在加载管线时预缓存它。大小限制仅限于绑定到着色器的部分。创建一个大的统一缓冲区,然后仅将其中一小部分绑定到着色器是完全可以的。根据硬件的不同,推送常量可以实现为驱动程序处理的一种统一缓冲区。

存储缓冲区 (SSBO) 是完全通用的读写缓冲区,尺寸非常大。规范最小尺寸为 128 MB,而我们本教程针对的现代 PC GPU 都达到了 4 GB,这仅仅是因为这是 uint32 大小可以容纳的范围。存储缓冲区不会像统一缓冲区那样被预加载,并且更像是“通用”数据加载/存储。

由于统一缓冲区的大小较小,我们不能将它们用于顶点几何体。但它们非常适合材质参数和全局场景配置。

统一缓冲区和存储缓冲区之间的确切速度差异取决于特定的 GPU 以及着色器的具体操作,因此通常使用存储缓冲区来处理几乎所有事情,并利用其更大的灵活性,因为可能的速度差异最终可能对项目无关紧要。

在此基准测试中,比较了访问缓冲区的不同方式 https://github.com/sebbbi/perftest 。

创建描述符时,也可以将其作为动态缓冲区。如果您使用它,则可以在写入命令时控制缓冲区绑定的偏移量。这允许您为多个对象绘制使用 1 个描述符集,方法是将多个对象的统一数据存储到一个大缓冲区中,然后在该缓冲区内的不同偏移量处绑定该描述符。它适用于统一缓冲区,但对于存储缓冲区,最好使用设备地址。

缓冲区设备地址

通常,缓冲区需要通过描述符集绑定,我们将在其中绑定给定类型的一个缓冲区。这意味着我们需要从 CPU 知道特定的缓冲区维度(对于统一缓冲区),并且需要处理描述符集的生命周期。对于此项目,由于我们的目标是 Vulkan 1.3,我们可以利用另一种访问缓冲区的方式,即缓冲区设备地址。这本质上允许我们将 int64 指针发送到 GPU(通过任何方式),然后在着色器中访问它,甚至允许对其进行指针运算。它本质上与 C++ 指针具有相同的机制,允许使用链表和间接访问等功能。

我们将为顶点使用此功能,因为通过设备地址访问 SSBO 比通过描述符集访问它更快,并且我们可以通过推送常量发送它,从而以非常快速且非常简单的方式将顶点数据绑定到着色器。

创建缓冲区

让我们开始编写将网格上传到 GPU 所需的代码。首先,我们需要一种创建缓冲区的方法。

将其添加到 vk_types.h

struct AllocatedBuffer {
    VkBuffer buffer;
    VmaAllocation allocation;
    VmaAllocationInfo info;
};

我们将使用此结构来保存给定缓冲区的数据。我们有 VkBuffer,它是 Vulkan 句柄,以及 VmaAllocation 和 VmaAllocationInfo,其中包含有关缓冲区及其分配的元数据,需要能够释放缓冲区。

让我们在 VulkanEngine 中添加一个函数来创建它

AllocatedBuffer create_buffer(size_t allocSize, VkBufferUsageFlags usage, VmaMemoryUsage memoryUsage);

我们将接受分配大小、使用标志和 vma 内存使用情况,以便我们可以控制缓冲区内存的位置。

这是实现端

AllocatedBuffer VulkanEngine::create_buffer(size_t allocSize, VkBufferUsageFlags usage, VmaMemoryUsage memoryUsage)
{
	// allocate buffer
	VkBufferCreateInfo bufferInfo = {.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
	bufferInfo.pNext = nullptr;
	bufferInfo.size = allocSize;

	bufferInfo.usage = usage;

	VmaAllocationCreateInfo vmaallocInfo = {};
	vmaallocInfo.usage = memoryUsage;
	vmaallocInfo.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT;
	AllocatedBuffer newBuffer;

	// allocate the buffer
	VK_CHECK(vmaCreateBuffer(_allocator, &bufferInfo, &vmaallocInfo, &newBuffer.buffer, &newBuffer.allocation,
		&newBuffer.info));

	return newBuffer;
}

首先,我们需要填充 Vulkan 的 VkBuffercreateInfo 结构。它接受大小和使用标志。然后,我们为 VMA 所需的属性创建 AllocationCreateInfo。我们可以使用 VmaMemoryUsage 标志来控制 VMA 将把我们的缓冲区放在哪里。对于图像,我们在设备本地内存中创建它们,这是最快的内存,因为它位于 GPU VRAM 上,但对于缓冲区,我们必须决定是否希望它们可直接从 CPU 写入。以下是我们主要可以使用的用法。

  • VMA_MEMORY_USAGE_GPU_ONLY 仅用于 GPU 本地内存。此内存无法从 CPU 写入或读取,因为它位于 GPU VRAM 上,但它是着色器读取和写入速度最快的内存。
  • VMA_MEMORY_USAGE_CPU_ONLY 用于 CPU RAM 上的内存。这是我们可以从 CPU 写入的内存,但 GPU 仍然可以从中读取。请记住,由于它位于 GPU 外部的 CPU RAM 上,因此对此内存的访问会产生性能损失。如果我们有每帧都在变化的数据或少量数据,并且较慢的访问速度无关紧要,那么它仍然非常有用
  • VMA_MEMORY_USAGE_CPU_TO_GPU 也可以从 CPU 写入,但从 GPU 访问可能更快。在 Vulkan 1.2 及更高版本中,GPU 在其自己的 VRAM 上有一个小的内存区域,该区域仍然可以从 CPU 写入。除非我们使用 Resizable BAR,否则其大小有限,但它是 CPU 可写且 GPU 访问速度快的内存
  • VMA_MEMORY_USAGE_GPU_TO_CPU 用于我们希望可以从 CPU 安全读取的内存。

我们在所有缓冲区分配中都使用 VMA_ALLOCATION_CREATE_MAPPED_BIT。只要缓冲区可以从 CPU 访问,这就会自动映射指针,以便我们可以写入内存。VMA 将存储该指针作为 allocationInfo 的一部分。

使用创建缓冲区函数,我们还需要一个销毁缓冲区函数。我们唯一需要做的就是调用 vmaDestroyBuffer

void VulkanEngine::destroy_buffer(const AllocatedBuffer& buffer)
{
    vmaDestroyBuffer(_allocator, buffer.buffer, buffer.allocation);
}

有了这个,我们可以创建我们的网格结构并设置顶点缓冲区。

GPU 上的网格缓冲区

vk_types.h

struct Vertex {

	glm::vec3 position;
	float uv_x;
	glm::vec3 normal;
	float uv_y;
	glm::vec4 color;
};

// holds the resources needed for a mesh
struct GPUMeshBuffers {

    AllocatedBuffer indexBuffer;
    AllocatedBuffer vertexBuffer;
    VkDeviceAddress vertexBufferAddress;
};

// push constants for our mesh object draws
struct GPUDrawPushConstants {
    glm::mat4 worldMatrix;
    VkDeviceAddress vertexBuffer;
};

我们需要一种顶点格式,所以让我们使用这种格式。创建顶点格式时,尽可能压缩数据非常重要,但对于本教程的当前阶段,这并不重要。我们稍后将优化此顶点格式。UV 参数交错的原因是 GPU 上的对齐限制。我们希望此结构与着色器版本匹配,因此像这样交错可以改进它。

我们将网格数据存储到 GPUMeshBuffers 结构中,该结构将包含索引和顶点的已分配缓冲区,以及顶点的缓冲区设备地址。

我们将为我们想要绘制网格的推送常量创建一个结构,它将包含对象的变换矩阵和网格缓冲区的设备地址。

现在我们需要一个函数来创建这些缓冲区并在 GPU 上填充它们。

GPUMeshBuffers VulkanEngine::uploadMesh(std::span<uint32_t> indices, std::span<Vertex> vertices)
{
	const size_t vertexBufferSize = vertices.size() * sizeof(Vertex);
	const size_t indexBufferSize = indices.size() * sizeof(uint32_t);

	GPUMeshBuffers newSurface;

	//create vertex buffer
	newSurface.vertexBuffer = create_buffer(vertexBufferSize, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT,
		VMA_MEMORY_USAGE_GPU_ONLY);

	//find the adress of the vertex buffer
	VkBufferDeviceAddressInfo deviceAdressInfo{ .sType = VK_STRUCTURE_TYPE_BUFFER_DEVICE_ADDRESS_INFO,.buffer = newSurface.vertexBuffer.buffer };
	newSurface.vertexBufferAddress = vkGetBufferDeviceAddress(_device, &deviceAdressInfo);

	//create index buffer
	newSurface.indexBuffer = create_buffer(indexBufferSize, VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT,
		VMA_MEMORY_USAGE_GPU_ONLY);

}

也将其添加到 VulkanEngine 类声明中。

该函数将接受 std::span<整数> 作为其索引,以及 Vertex 的 std::span。span 是指针 + 大小对。您可以从 C 样式数组或 std::vector 转换为它,因此在此处使用它来避免数据复制非常棒。

我们首先要做的是计算缓冲区需要多大。然后,我们在 GPU 专用内存上创建缓冲区。

在顶点缓冲区上,我们使用以下 Usage 标志:VK_BUFFER_USAGE_STORAGE_BUFFER_BIT 因为它是 SSBO,以及 VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT 因为我们将获取其地址。

在索引缓冲区上,我们使用 VK_BUFFER_USAGE_INDEX_BUFFER_BIT 来表示我们将要将该缓冲区用于索引绘制。

我们在两个缓冲区上都有 VK_BUFFER_USAGE_TRANSFER_DST_BIT,因为我们将对它们执行内存复制命令。

要获取缓冲区地址,我们需要调用 vkGetBufferDeviceAddress,并为其提供我们想要执行此操作的 VkBuffer。一旦我们有了 VkDeviceAddress,我们就可以根据需要对其进行指针运算,如果我们从更大的缓冲区中进行子分配,这将非常有用。

分配缓冲区后,我们需要将数据写入其中。为此,我们将使用暂存缓冲区。这是 Vulkan 中非常常见的模式。由于 GPU_ONLY 内存无法在 CPU 上写入,我们首先将内存写入可 CPU 写入的临时暂存缓冲区,然后执行复制命令将此缓冲区复制到 GPU 缓冲区中。对于网格来说,不一定需要使用 GPU_ONLY 顶点缓冲区,但强烈建议这样做,除非它是 CPU 端粒子系统或其他动态效果之类的东西。

	AllocatedBuffer staging = create_buffer(vertexBufferSize + indexBufferSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VMA_MEMORY_USAGE_CPU_ONLY);

	void* data = staging.allocation->GetMappedData();

	// copy vertex buffer
	memcpy(data, vertices.data(), vertexBufferSize);
	// copy index buffer
	memcpy((char*)data + vertexBufferSize, indices.data(), indexBufferSize);

	immediate_submit([&](VkCommandBuffer cmd) {
		VkBufferCopy vertexCopy{ 0 };
		vertexCopy.dstOffset = 0;
		vertexCopy.srcOffset = 0;
		vertexCopy.size = vertexBufferSize;

		vkCmdCopyBuffer(cmd, staging.buffer, newSurface.vertexBuffer.buffer, 1, &vertexCopy);

		VkBufferCopy indexCopy{ 0 };
		indexCopy.dstOffset = 0;
		indexCopy.srcOffset = vertexBufferSize;
		indexCopy.size = indexBufferSize;

		vkCmdCopyBuffer(cmd, staging.buffer, newSurface.indexBuffer.buffer, 1, &indexCopy);
	});

	destroy_buffer(staging);

	return newSurface;

我们首先创建暂存缓冲区,它将是用于复制到索引缓冲区和顶点缓冲区的 1 个缓冲区。它的内存类型为 CPU_ONLY,其使用标志为 VK_BUFFER_USAGE_TRANSFER_SRC_BIT,因为我们将对其执行的唯一操作是复制命令。

一旦我们有了缓冲区,我们就可以使用 GetMappedData() 获取其映射地址,这会给我们一个 void* 指针,我们可以写入。因此,我们执行 2 个 memcpy 命令以将两个 span 复制到其中。

写入暂存缓冲区后,我们运行 immediate_submit 以运行 GPU 端命令来执行此复制。该命令将运行 2 个 VkCmdCopyBuffer 命令,这与 memcpy 大致相同,但由 GPU 完成。您可以看到 VkBufferCopy 结构如何直接镜像我们为写入暂存缓冲区而执行的 memcpy。

一旦 immediate submit 完成,我们就可以安全地处理暂存缓冲区并删除它。

请注意,这种模式效率不高,因为我们正在等待 GPU 命令完全执行完毕,然后再继续我们的 CPU 端逻辑。人们通常会将此操作放在后台线程中,其唯一的工作是执行像这样的上传,以及删除/重用暂存缓冲区。

绘制网格

让我们继续使用所有这些来制作一个网格,并绘制它。我们将绘制一个索引矩形,以与我们的三角形组合。

着色器需要针对我们的顶点缓冲区进行更改,因此虽然我们仍将使用 colored_triangle.frag 作为我们的片段着色器,但我们将更改顶点着色器以从推送常量加载数据。我们将创建该着色器为 colored_triangle_mesh.vert,因为它将与硬编码三角形相同。

#version 450
#extension GL_EXT_buffer_reference : require

layout (location = 0) out vec3 outColor;
layout (location = 1) out vec2 outUV;

struct Vertex {

	vec3 position;
	float uv_x;
	vec3 normal;
	float uv_y;
	vec4 color;
}; 

layout(buffer_reference, std430) readonly buffer VertexBuffer{ 
	Vertex vertices[];
};

//push constants block
layout( push_constant ) uniform constants
{	
	mat4 render_matrix;
	VertexBuffer vertexBuffer;
} PushConstants;

void main() 
{	
	//load vertex data from device adress
	Vertex v = PushConstants.vertexBuffer.vertices[gl_VertexIndex];

	//output data
	gl_Position = PushConstants.render_matrix *vec4(v.position, 1.0f);
	outColor = v.color.xyz;
	outUV.x = v.uv_x;
	outUV.y = v.uv_y;
}

我们需要启用 GL_EXT_buffer_reference 扩展,以便着色器编译器知道如何处理这些缓冲区引用。

然后我们有了顶点结构,它与我们在 CPU 上的顶点结构完全相同。

之后,我们声明 VertexBuffer,它是一个只读缓冲区,其中包含 Vertex 结构数组(未调整大小)。通过在布局中包含 buffer_reference,可以告诉着色器此对象是从缓冲区地址使用的。std430 是结构的对齐规则。

我们有 push_constant 块,其中包含 VertexBuffer 的单个实例和一个矩阵。由于顶点缓冲区声明为 buffer_reference,因此这是一个 uint64 句柄,而矩阵是一个普通矩阵(无引用)。

从我们的 main() 中,我们使用 gl_VertexIndex 索引顶点数组,就像我们对硬编码数组所做的那样。访问指针时,我们没有像 C++ 中那样的 ->,在 GLSL 中,缓冲区地址作为引用访问,因此它使用 . 来访问它。获取顶点后,我们只需输出我们想要的颜色和位置,并将位置与渲染矩阵相乘。

现在让我们创建管线。我们将创建一个新的管线函数,与 init_triangle_pipeline() 分开,但几乎相同。将其添加到 vulkanEngine 类

VkPipelineLayout _meshPipelineLayout;
VkPipeline _meshPipeline;

GPUMeshBuffers rectangle;

void init_mesh_pipeline();

它将主要是 init_triangle_pipeline() 的复制粘贴

	VkShaderModule triangleFragShader;
	if (!vkutil::load_shader_module("../../shaders/colored_triangle.frag.spv", _device, &triangleFragShader)) {
		fmt::print("Error when building the triangle fragment shader module");
	}
	else {
		fmt::print("Triangle fragment shader succesfully loaded");
	}

	VkShaderModule triangleVertexShader;
	if (!vkutil::load_shader_module("../../shaders/colored_triangle_mesh.vert.spv", _device, &triangleVertexShader)) {
		fmt::print("Error when building the triangle vertex shader module");
	}
	else {
		fmt::print("Triangle vertex shader succesfully loaded");
	}

	VkPushConstantRange bufferRange{};
	bufferRange.offset = 0;
	bufferRange.size = sizeof(GPUDrawPushConstants);
	bufferRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;

	VkPipelineLayoutCreateInfo pipeline_layout_info = vkinit::pipeline_layout_create_info();
	pipeline_layout_info.pPushConstantRanges = &bufferRange;
	pipeline_layout_info.pushConstantRangeCount = 1;

	VK_CHECK(vkCreatePipelineLayout(_device, &pipeline_layout_info, nullptr, &_meshPipelineLayout));

我们将顶点着色器更改为加载 colored_triangle_mesh.vert.spv,并修改管线布局以使其具有我们上面定义的推送常量结构。

对于函数的其余部分,我们执行与三角形管线函数中相同的操作,但将管线布局和管线名称更改为新的名称。

	PipelineBuilder pipelineBuilder;

	//use the triangle layout we created
	pipelineBuilder._pipelineLayout = _meshPipelineLayout;
	//connecting the vertex and pixel shaders to the pipeline
	pipelineBuilder.set_shaders(triangleVertexShader, triangleFragShader);
	//it will draw triangles
	pipelineBuilder.set_input_topology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST);
	//filled triangles
	pipelineBuilder.set_polygon_mode(VK_POLYGON_MODE_FILL);
	//no backface culling
	pipelineBuilder.set_cull_mode(VK_CULL_MODE_NONE, VK_FRONT_FACE_CLOCKWISE);
	//no multisampling
	pipelineBuilder.set_multisampling_none();
	//no blending
	pipelineBuilder.disable_blending();

	pipelineBuilder.disable_depthtest();

	//connect the image format we will draw into, from draw image
	pipelineBuilder.set_color_attachment_format(_drawImage.imageFormat);
	pipelineBuilder.set_depth_format(VK_FORMAT_UNDEFINED);

	//finally build the pipeline
	_meshPipeline = pipelineBuilder.build_pipeline(_device);

	//clean structures
	vkDestroyShaderModule(_device, triangleFragShader, nullptr);
	vkDestroyShaderModule(_device, triangleVertexShader, nullptr);

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

现在我们从我们的主 init_pipelines() 函数调用此函数。

void VulkanEngine::init_pipelines()
{
	//COMPUTE PIPELINES	
	init_background_pipelines();

	// GRAPHICS PIPELINES
	init_triangle_pipeline();
	init_mesh_pipeline();
}

接下来,我们需要创建和上传网格。我们为引擎中的默认数据创建一个新的初始化函数 init_default_data()。将其添加到主 init() 函数的末尾。

void VulkanEngine::init_default_data() {
	std::array<Vertex,4> rect_vertices;

	rect_vertices[0].position = {0.5,-0.5, 0};
	rect_vertices[1].position = {0.5,0.5, 0};
	rect_vertices[2].position = {-0.5,-0.5, 0};
	rect_vertices[3].position = {-0.5,0.5, 0};

	rect_vertices[0].color = {0,0, 0,1};
	rect_vertices[1].color = { 0.5,0.5,0.5 ,1};
	rect_vertices[2].color = { 1,0, 0,1 };
	rect_vertices[3].color = { 0,1, 0,1 };

	std::array<uint32_t,6> rect_indices;

	rect_indices[0] = 0;
	rect_indices[1] = 1;
	rect_indices[2] = 2;

	rect_indices[3] = 2;
	rect_indices[4] = 1;
	rect_indices[5] = 3;

	rectangle = uploadMesh(rect_indices,rect_vertices);

	//delete the rectangle data on engine shutdown
	_mainDeletionQueue.push_function([&](){
		destroy_buffer(rectangle.indexBuffer);
		destroy_buffer(rectangle.vertexBuffer);
	});

}

我们为顶点和索引创建 2 个数组,并调用 uploadMesh 函数将其全部转换为缓冲区。

我们现在可以执行绘制。我们将在 draw_geometry() 函数中添加新的绘制命令,在我们拥有的三角形之后。

	//launch a draw command to draw 3 vertices
	vkCmdDraw(cmd, 3, 1, 0, 0);

	vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, _meshPipeline);

	GPUDrawPushConstants push_constants;
	push_constants.worldMatrix = glm::mat4{ 1.f };
	push_constants.vertexBuffer = rectangle.vertexBufferAddress;

	vkCmdPushConstants(cmd, _meshPipelineLayout, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(GPUDrawPushConstants), &push_constants);
	vkCmdBindIndexBuffer(cmd, rectangle.indexBuffer.buffer, 0, VK_INDEX_TYPE_UINT32);

	vkCmdDrawIndexed(cmd, 6, 1, 0, 0, 0);

	vkCmdEndRendering(cmd);

我们绑定另一个管线,这次是矩形网格管线。

然后,我们使用推送常量将 vertexBufferAdress 上传到 GPU。对于矩阵,在我们实现网格变换之前,我们将暂时使用默认值。

然后我们需要执行 cmdBindIndexBuffer 以绑定用于图形的索引缓冲区。遗憾的是,这里无法使用设备地址,您需要为其提供 VkBuffer 和偏移量。

最后,我们使用 vkCmdDrawIndexed 来绘制 2 个三角形(6 个索引)。这与 vkCmdDraw 相同,但它使用当前绑定的索引缓冲区来绘制网格。

就这样,我们现在有了一种渲染任何网格的通用方法。

接下来,我们将以最基本的方式从 GLTF 加载网格文件,以便我们可以玩比矩形更精细的东西。

下一步: 网格加载