为了正确渲染对象,我们需要将顶点数据发送到顶点着色器。目前,我们正在使用硬编码的数组,但这不适用于单个三角形或类似几何体以外的任何内容。
由于我们没有在管线上使用固定功能顶点属性获取逻辑,因此我们可以完全自由地决定如何在着色器中加载顶点数据。我们将从通过缓冲区设备地址传递的大型 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 加载网格文件,以便我们可以玩比矩形更精细的东西。
下一步: 网格加载