下载 [PDF 890KB]
请前往: 没有任何秘密的 API: Vulkan* 简介第 3 部分: 第一个三角形
目录
教程 4: 顶点属性 – 缓冲区、图像和栅栏
我们在之前的教程中学些了一些基本知识。 教程篇幅比较长而且(我希望)介绍得比较详细。 这是因为 Vulkan* API 的学习曲线非常陡峭。 而且大家看,即使是准备最简单的应用,我们也需要了解大量的知识。
但现在我们已经打好了基础。 因此本教程篇幅会短一些,并重点介绍与 Vulkan API 相关的一些话题。 本节我们将通过从顶点缓冲区提供顶点属性,介绍绘制任意几何图形的推荐方法。 由于本课程的代码与“03 – 第一个三角形”教程的代码类似,因此我仅介绍与之不同的部分。
并介绍另外一种整理渲染代码的方法。 之前我们在主渲染循环前记录了命令缓冲区。 但在实际情况中,每帧动画都各不相同,因此我们无法预先记录所有渲染命令。 我们应该尽可能地晚一些记录和提交命令缓冲区,以最大限度地降低输入延迟,并获取最新的输入数据。 我们将正好在提交至队列之前记录命令缓冲区。 不过,一个命令缓冲区远远不够。 必须等到显卡处理完提交的命令缓冲区后,才记录相同的命令缓冲区。 此时将通过栅栏发出信号。 但每一帧都等待栅栏实在非常浪费时间,因此我们需要更多的命令缓冲区,以便交换使用。 命令缓冲区越多,所需的栅栏越多,情况也将越复杂。 本教程将展示如何组织代码,尽可能地实现代码的轻松维护性、灵活性和快速性。
指定渲染通道相关性
我们从创建渲染通道开始,方法与之前教程中所介绍的相同。 但这次我们还需要提供其他信息。 渲染通道描述渲染资源(图像/附件)的内部组织形式,使用方法,以及在渲染流程中的变化。 通过创建图像内存壁垒,可明确更改图像的布局。 如果指定了合适的渲染通道描述(初始布局、子通道布局和最终图像布局),这种操作也可以隐式地进行。 我们首选隐式过渡,因为驱动程序可以更好地执行此类过渡。
在本教程的这一部分,与之前相同,我们将初始和最终图像布局指定为“transfer src”,而将渲染通道指定为“color attachment optimal”子通道布局。 但之前的教程缺乏其他重要信息,尤其是如何使用图像(即执行哪些与图像相关的操作),以及何时使用图像(渲染管道的哪些部分使用图像)。 此类信息可在图像内存壁垒和渲染通道描述中指定。 创建图像内存壁垒时,我们指定与特定图像相关的操作类型(壁垒之前和之后的内存访问类型),而且我们还指定何时放置壁垒(壁垒之前和之后使用图像的管道阶段)。
创建渲染通道并为其提供描述时,通过子通道相关性指定相同的信息。 其他数据对驱动程序也至关重要,可更好地准备隐式壁垒。 以下源代码可创建渲染通道并准备子通道相关性。
std::vector<VkSubpassDependency> dependencies = { { VK_SUBPASS_EXTERNAL, // uint32_t srcSubpass 0, // uint32_t dstSubpass VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, // VkPipelineStageFlags srcStageMask VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, // VkPipelineStageFlags dstStageMask VK_ACCESS_MEMORY_READ_BIT, // VkAccessFlags srcAccessMask VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, // VkAccessFlags dstAccessMask VK_DEPENDENCY_BY_REGION_BIT // VkDependencyFlags dependencyFlags }, { 0, // uint32_t srcSubpass VK_SUBPASS_EXTERNAL, // uint32_t dstSubpass VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, // VkPipelineStageFlags srcStageMask VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, // VkPipelineStageFlags dstStageMask VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, // VkAccessFlags srcAccessMask VK_ACCESS_MEMORY_READ_BIT, // VkAccessFlags dstAccessMask VK_DEPENDENCY_BY_REGION_BIT // VkDependencyFlags dependencyFlags } }; VkRenderPassCreateInfo render_pass_create_info = { VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO, // VkStructureType sType nullptr, // const void *pNext 0, // VkRenderPassCreateFlags flags 1, // uint32_t attachmentCount attachment_descriptions, // const VkAttachmentDescription *pAttachments 1, // uint32_t subpassCount subpass_descriptions, // const VkSubpassDescription *pSubpasses static_cast<uint32_t>(dependencies.size()), // uint32_t dependencyCount&dependencies[0] // const VkSubpassDependency *pDependencies }; if( vkCreateRenderPass( GetDevice(), &render_pass_create_info, nullptr, &Vulkan.RenderPass ) != VK_SUCCESS ) { std::cout << "Could not create render pass!"<< std::endl; return false; }
1.Tutorial04.cpp,函数 CreateRenderPass()
子通道相关性描述不同子通道之间的相关性。 当附件以特定方式用于特定子通道(例如渲染)时,会以另一种方式用于另一个子通道(取样),因此我们可创建内存壁垒或提供子通道相关性,以描述附件在这两个子通道中的预期用法。 当然,我们推荐使用后一种选项,因为驱动程序能够(通常)以最佳的方式准备壁垒。 而且代码本身也会得到完善 — 了解代码所需的一切都收集在一个位置,一个对象中。
在我们的简单示例中,我们仅有一个子通道,但我们指定了两种相关性。 因为我们能够(而且应该)指定渲染通道(通过提供特定子通道的编号)与其外侧操作(通过提供 VK_SUBPASS_EXTERNAL 值)之间的相关性。 这里我们为渲染通道与其唯一的子通道之前执行的操作之间的颜色附件提供相关性。 第二种相关性针对子通道内和渲染通道之后执行的操作定义。
我们将讨论哪些操作。 我们仅使用一个附件,即从演示引擎(交换链)获取的图像。 演示引擎将图像用作可演示数据源。 它仅显示一个图像。 因此涉及图像的唯一操作是在“present src”布局的图像上进行“内存读取”。 该操作不会在任何正常的管道阶段中进行,而在“管道底部”阶段再现。
在渲染通道内部唯一的子通道(索引为 0)中,我们将渲染用作颜色附件的图像。 因此在该图像上进行的操作为“颜色附件写入”,在“颜色附件输入”管道阶段中(碎片着色器之后)执行。 演示并将图像返回至演示引擎后,会再次将该图像用作数据源。 因此在本示例中,渲染通道之后的操作与之前的一样: “内存读取”。
我们通过 VkSubpassDependency 成员阵列指定该数据。 创建渲染通道和 VkRenderPassCreateInfo 结构时,我们(通过 dependencyCount 成员)指定相关性阵列中的要素数量,并(通过 pDependencies)提供第一个要素的地址。 在之前的教程中,我们将这两个字段设置为 0 和 nullptr。 VkSubpassDependency 结构包含以下字段:
- srcSubpass – 第一个(前面)子通道的索引或 VK_SUBPASS_EXTERNAL(如果希望指示子通道与渲染通道外的操作之间的相关性)。
- dstSubpass – 第二个(后面)子通道的索引(或 VK_SUBPASS_EXTERNAL)。
- srcStageMask – 之前(src 子通道中)使用特定附件的管道阶段。
- dstStageMask – 之后(dst 子通道中)将使用特定附件的管道阶段。
- srcAccessMask – src 子通道中或渲染通道前所发生的内存操作类型。
- dstAccessMask – dst 子通道中或渲染通道后所发生的内存操作类型。
- dependencyFlags – 描述相关性类型(区域)的标记。
图形管道创建
现在我们将创建图形管道对象。 (我们应创建面向交换链图像的帧缓冲器,不过这一步骤在命令缓冲区记录期间进行)。 我们不想渲染在着色器中进行过硬编码的几何图形, 而是想绘制任意数量的顶点,并提供其他属性(不仅仅是顶点位置)。 我们首先应该做什么?
编写着色器
首先查看用 GLSL 代码编写的顶点着色器:
#version 450 layout(location = 0) in vec4 i_Position; layout(location = 1) in vec4 i_Color; out gl_PerVertex { vec4 gl_Position; }; layout(location = 0) out vec4 v_Color; void main() { gl_Position = i_Position; v_Color = i_Color; }
2.shader.vert
尽管比教程 03 的复杂,但该着色器非常简单。
我们指定两个输入属性(named i_Position 和 i_Color)。 在 Vulkan中,所有属性必须有一个位置布局限定符。 在 Vulkan API 中指定顶点属性描述时,属性名称不重要,重要的是它们的索引/位置。 在 OpenGL* 中,我们可请求特定名称的属性位置。 在 Vulkan 中不能这样做。 位置布局限定符是唯一的方法。
接下来我们重新声明着色器中的 gl_PerVertex 模块。 Vulkan 使用着色器 I/O 模块,所以我们应该重新声明 gl_PerVertex 以明确指定该模块使用哪些成员。 如果没有指定,将使用默认定义。 但我们必须记住该默认定义 contains gl_ClipDistance[],它要求我们启用特性 shaderClipDistance(而且在 Vulkan 中,不能使用创建设备期间没有启用的特性,或可能无法正常运行的应用)。 这里我们仅使用 gl_Position 成员,因此不要求启用该特性。
然后我们指定一个与变量 v_Color 不同的附加输入,以保存顶点颜色。 在主函数中,我们将应用提供的值拷贝至相应的输入变量:position to gl_Position 和 color to v_Color。
现在查看碎片着色器,以了解如何使用属性。
#version 450 layout(location = 0) in vec4 v_Color; layout(location = 0) out vec4 o_Color; void main() { o_Color = v_Color; }
3.shader.frag
在碎片着色器中,将与变量 v_Color 不同的输入仅拷贝至输出变量 o_Color。 两个变量都有位置布局说明符。 在顶点着色器中变量 v_Color 的位置与输出变量相同,因此它将包含定点之间插值替换的颜色值。
着色器能够以与之前相同的方式转换成 SPIR-V 汇编。 这一步骤可通过以下命令完成:
glslangValidator.exe -V -H shader.vert > vert.spv.txt
glslangValidator.exe -V -H shader.frag > frag.spv.txt
因此现在,了解哪些是我们希望在着色器中使用的属性后,我们将可以创建相应的图形管道。
顶点属性指定
在本教程中,我们将对顶点输入状态创建进行最重要的改进,为此我们指定类型变量 VkPipelineVertexInputStateCreateInfo。 在该变量中我们提供结构指示器,定义顶点输入数据类型,以及属性的数量和布局。
我们希望使用两个属性:顶点位置和顶点颜色,前者由四个浮点组件组成,后者由四个浮点值组成。 我们以交错属性布局的形式将所有顶点数据放在缓冲器中。 这表示我们将依次放置第一个顶点的位置,相同顶点的颜色,第二个顶点的位置,第二个顶点的颜色,第三个顶点的位置和颜色,依此类推。 我们借助以下代码完成这种指定:
std::vector<VkVertexInputBindingDescription> vertex_binding_descriptions = { { 0, // uint32_t binding sizeof(VertexData), // uint32_t stride VK_VERTEX_INPUT_RATE_VERTEX // VkVertexInputRate inputRate } }; std::vector<VkVertexInputAttributeDescription> vertex_attribute_descriptions = { { 0, // uint32_t location vertex_binding_descriptions[0].binding, // uint32_t binding VK_FORMAT_R32G32B32A32_SFLOAT, // VkFormat format offsetof(struct VertexData, x) // uint32_t offset }, { 1, // uint32_t location vertex_binding_descriptions[0].binding, // uint32_t binding VK_FORMAT_R32G32B32A32_SFLOAT, // VkFormat format offsetof( struct VertexData, r ) // uint32_t offset } }; VkPipelineVertexInputStateCreateInfo vertex_input_state_create_info = { VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO, // VkStructureType sType nullptr, // const void *pNext 0, // VkPipelineVertexInputStateCreateFlags flags; static_cast<uint32_t>(vertex_binding_descriptions.size()), // uint32_t vertexBindingDescriptionCount&vertex_binding_descriptions[0], // const VkVertexInputBindingDescription *pVertexBindingDescriptions static_cast<uint32_t>(vertex_attribute_descriptions.size()), // uint32_t vertexAttributeDescriptionCount&vertex_attribute_descriptions[0] // const VkVertexInputAttributeDescription *pVertexAttributeDescriptions };
4.Tutorial04.cpp,函数 CreatePipeline()
首先通过 VkVertexInputBindingDescription 指定顶点数据绑定(通用内存信息)。 它包含以下字段:
- binding – 与顶点数据相关的绑定索引。
- stride – 两个连续要素(两个相邻顶点的相同属性)之间的间隔(字节)。
- inputRate – 定义如何使用数据,是按照顶点还是按照实例使用。
步长和 inputRate 字段不言而喻。 绑定成员可能还要求提供其他信息。 创建顶点缓冲区时,在执行渲染操作之前我们将其绑定至所选的插槽。 插槽编号(索引)就是这种绑定,此处我们描述该插槽中的数据如何与内存对齐,以及如何使用数据(按顶点或实例)。 不同的顶点缓冲区可绑定至不同的绑定。 而且每个绑定都可放在内存中的不同位置。
接下来定义所有顶点属性。 我们必须指定各属性的位置(索引)(与着色器源代码相同,以位置布局限定符的形式)、数据源(从哪个绑定读取数据)、格式(数据类型和组件数量),以及查找特定属性数据的偏移(从特定顶点数据的开头,而非所有顶点数据的开头)。 这种情况与 OpenGL 几乎相同,我们创建顶点缓冲区对象(VBO,可视作等同于“绑定”),并使用 glVertexAttribPointer() 函数(通过该函数指定属性索引(位置)、大小和类型(组件数量和格式)、步长和偏移)定义属性。 可通过 VkVertexInputAttributeDescription 结构提供这类信息。 它包含以下字段:
- location – 属性索引,与着色器源代码中由位置布局说明符定义的相同。
- binding – 供数据读取的插槽编号(与 OpenGL 中的 VBO 等数据源),与 VkVertexInputBindingDescription 结构和 vkCmdBindVertexBuffers()函数(稍后介绍)中的绑定相同。
- format – 数据类型和每个属性的组件数量。
- offset – 特定属性数据的开头。
准备好后,我们可以通过填充类型变量 VkPipelineVertexInputStateCreateInfo 准备顶点输入状态描述,该变量包含以下字段:
- sType – 结构类型,此处应等于 VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO。
- pNext – 为扩展功能预留的指示器。 目前将该数值设为 null。
- flags – 留作将来使用的参数。
- vertexBindingDescriptionCount – pVertexBindingDescriptions 阵列中的要素数量。
- pVertexBindingDescriptions – 描述为特定管道(支持读取所有属性的缓冲区)定义的所有绑定的阵列。
- vertexAttributeDescriptionCount – pVertexAttributeDescriptions 阵列中的要素数量。
- pVertexAttributeDescriptions – 指定所有顶点属性的要素阵列。
它包含创建管道期间的顶点属性指定。 如要使用它们,我们必须创建顶点缓冲区,并在发布渲染命令之前将其绑定至命令缓冲区。
输入汇编状态指定
之前我们使用三角形条拓扑绘制了一个简单的三角形。 现在我们绘制一个四边形,通过定义四个顶点(而非两个三角形和六个顶点)绘制起来非常方便。 为此我们必须使用三角形条带拓扑。 我们通过 VkPipelineInputAssemblyStateCreateInfo 结构定义该拓扑,其中包含以下成员:
- sType – 结构类型,此处等于 VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO。
- pNext – 为扩展功能预留的指示器。
- flags – 留作将来使用的参数。
- topology – 用于绘制顶点的拓扑(比如三角扇、带、条)。
- primitiveRestartEnable – 该参数定义是否希望使用特定顶点索引值重新开始汇编基元。
以下简单代码可用于定义三角条带拓扑:
VkPipelineInputAssemblyStateCreateInfo input_assembly_state_create_info = { VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO, // VkStructureType sType nullptr, // const void *pNext 0, // VkPipelineInputAssemblyStateCreateFlags flags VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP, // VkPrimitiveTopology topology VK_FALSE // VkBool32 primitiveRestartEnable };
5.Tutorial04.cpp,函数 CreatePipeline()
视口状态指定
本教程引进了另外一个变化。 之前为了简单起见,我们对视口和 scissor 测试参数进行了硬编码,可惜导致图像总保持相同的大小,无论应用窗口多大。 这次我们不通过 VkPipelineViewportStateCreateInfo 结构指定这些数值, 而是使用动态。 以下代码负责定义静态视口状态参数:
VkPipelineViewportStateCreateInfo viewport_state_create_info = { VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO, // VkStructureType sType nullptr, // const void *pNext 0, // VkPipelineViewportStateCreateFlags flags 1, // uint32_t viewportCount nullptr, // const VkViewport *pViewports 1, // uint32_t scissorCount nullptr // const VkRect2D *pScissors };
6.Tutorial04.cpp,函数 CreatePipeline()
定义静态视口参数的结构包含以下成员:
- sType – 结构类型,此处为 VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO。
- pNext – 为特定于扩展的参数预留的指示器。
- flags – 留作将来使用的参数。
- viewportCount – 视口数量。
- pViewports – 定义静态视口参数的结构指示器。
- scissorCount – scissor 矩形的数量(数值必须与 viewportCount 参数相同)。
- pScissors – 定义视口的静态 scissor 测试参数的 2D 矩形阵列指示器。
如果希望通过动态定义视口和 scissor 参数,则无需填充 pViewports 和 pScissors 成员。 因此在上述示例中将其设置为 null。 但我们必须定义视口和 scissor 测试矩形的数量。 通常通过 VkPipelineViewportStateCreateInfo 结构指定这些值,无论是否希望使用动态或静态视口和 scissor 状态。
动态指定
创建管道时,我们可以指定哪部分始终保持静态 — 在管道创建期间通过结构定义,哪部分保持动态 — 在命令缓冲区记录期间通过调用相应的函数来指定。 这可帮助我们减少仅在细节方面有所差异(比如线条宽度、混合常量、模板参数或之前提到的视口大小)的管道对象的数量。 以下代码用于定义管道应保持动态的部分:
std::vector<VkDynamicState> dynamic_states = { VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR, }; VkPipelineDynamicStateCreateInfo dynamic_state_create_info = { VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO, // VkStructureType sType nullptr, // const void *pNext 0, // VkPipelineDynamicStateCreateFlags flags static_cast<uint32_t>(dynamic_states.size()), // uint32_t dynamicStateCount&dynamic_states[0] // const VkDynamicState *pDynamicStates };
7.Tutorial04.cpp,函数 CreatePipeline()
该步骤通过类型结构 VkPipelineDynamicStateCreateInfo 完成,其中包含以下字段:
- sType – 定义特定结构类型的参数,此处等于 VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO。
- pNext – 为扩展功能预留的参数。
- flags – 留作将来使用的参数。
- dynamicStateCount – pDynamicStates 阵列中的要素数量。
- pDynamicStates – 包含 enum 的阵列,指定将哪部分管道标记为动态。 该阵列的要素类型为 VkDynamicState。
管道对象创建
定义完图形管道的所有必要参数后,将可以开始创建管道对象。 以下代码可帮助完成这一操作:
VkGraphicsPipelineCreateInfo pipeline_create_info = { VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, // VkStructureType sType nullptr, // const void *pNext 0, // VkPipelineCreateFlags flags static_cast<uint32_t>(shader_stage_create_infos.size()), // uint32_t stageCount&shader_stage_create_infos[0], // const VkPipelineShaderStageCreateInfo *pStages&vertex_input_state_create_info, // const VkPipelineVertexInputStateCreateInfo *pVertexInputState;&input_assembly_state_create_info, // const VkPipelineInputAssemblyStateCreateInfo *pInputAssemblyState nullptr, // const VkPipelineTessellationStateCreateInfo *pTessellationState&viewport_state_create_info, // const VkPipelineViewportStateCreateInfo *pViewportState&rasterization_state_create_info, // const VkPipelineRasterizationStateCreateInfo *pRasterizationState&multisample_state_create_info, // const VkPipelineMultisampleStateCreateInfo *pMultisampleState nullptr, // const VkPipelineDepthStencilStateCreateInfo *pDepthStencilState&color_blend_state_create_info, // const VkPipelineColorBlendStateCreateInfo *pColorBlendState&dynamic_state_create_info, // const VkPipelineDynamicStateCreateInfo *pDynamicState pipeline_layout.Get(), // VkPipelineLayout layout Vulkan.RenderPass, // VkRenderPass renderPass 0, // uint32_t subpass VK_NULL_HANDLE, // VkPipeline basePipelineHandle -1 // int32_t basePipelineIndex }; if( vkCreateGraphicsPipelines( GetDevice(), VK_NULL_HANDLE, 1, &pipeline_create_info, nullptr, &Vulkan.GraphicsPipeline ) != VK_SUCCESS ) { std::cout << "Could not create graphics pipeline!"<< std::endl; return false; } return true;
8.Tutorial04.cpp,函数 CreatePipeline()
最重要的变量(包含对所有管道参数的引用)的类型为 VkGraphicsPipelineCreateInfo。 与之前教程相比,唯一的变化是添加了 pDynamicState 参数,以指出 VkPipelineDynamicStateCreateInfo 结构的类型,如上所示。 每个指定为动态的管道状态均在命令缓冲区记录期间通过相应的函数调用进行设置。
通过调用 vkCreateGraphicsPipelines()函数创建管道对象。
顶点缓冲区创建
如要使用顶点属性,除了在创建管道期间指定它们外,还需准备包含所有这些属性数据的缓冲区。 我们将从该缓冲区读取属性值并将其提供给顶点着色器。
在 Vulkan 中,缓冲区和图像创建包含至少两个阶段: 首先创建对象本身。 然后,我们需要创建内存对象,该对象之后将绑定至缓冲区(或图像)。 缓冲区将从该内存对象中提取存储空间。 这种方法有助于我们指定针对内存的其他参数,并通过更多细节对其进行控制。
我们调用 vkCreateBuffer()创建(通用)缓冲区对象。 它从其他参数中接受类型变量 VkBufferCreateInfo 的指示器,以定义已创建缓冲区的参数。 以下代码负责创建用于顶点属性数据源的缓冲区:
VertexData vertex_data[] = { { -0.7f, -0.7f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f }, { -0.7f, 0.7f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f }, { 0.7f, -0.7f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f }, { 0.7f, 0.7f, 0.0f, 1.0f, 0.3f, 0.3f, 0.3f, 0.0f } }; Vulkan.VertexBuffer.Size = sizeof(vertex_data); VkBufferCreateInfo buffer_create_info = { VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, // VkStructureType sType nullptr, // const void *pNext 0, // VkBufferCreateFlags flags Vulkan.VertexBuffer.Size, // VkDeviceSize size VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, // VkBufferUsageFlags usage VK_SHARING_MODE_EXCLUSIVE, // VkSharingMode sharingMode 0, // uint32_t queueFamilyIndexCount nullptr // const uint32_t *pQueueFamilyIndices }; if( vkCreateBuffer( GetDevice(), &buffer_create_info, nullptr, &Vulkan.VertexBuffer.Handle ) != VK_SUCCESS ) { std::cout << "Could not create a vertex buffer!"<< std::endl; return false; }
9.Tutorial04.cpp,函数 CreateVertexBuffer()
我们在 CreateVertexBuffer() 函数开头定义了大量用于位置和颜色属性的数值。 首先为第一个顶点定义四个位置组件,然后为相同的顶点定义四个颜色组件,之后为第二个顶点定义四个有关位置属性的组件,然后为相同顶点定义四个颜色值,之后依次为第三个和第四个顶点定义位置和颜色值。 阵列大小用于定义缓冲区大小。 但请记住,内部显卡驱动程序要求缓冲区的存储大于应用请求的大小。
接下来定义 VkBufferCreateInfo 类型变量。 该结构包含以下字段:
- sType – 结构类型,应设置为 VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO 。
- pNext – 为扩展功能预留的参数。
- flags – 定义其他创建参数的参数。 此处它支持创建通过稀疏内存备份的缓冲区(类似于宏纹理)。 我们不想使用稀疏内存,因此将该参数设为 0。
- size – 缓冲区的大小(字节)。
- usage – 定义将来打算如何使用该缓冲区的参数。 我们可以指定为将该缓冲区用作统一缓冲区、索引缓冲区、传输(拷贝)操作数据源等。 这里我们打算将该缓冲区用作顶点缓冲区。 请记住,我们不能将该缓冲区用于缓冲器创建期间未定义的目的。
- sharingMode – 共享模式,类似于交换链图像,定义特定缓冲区能否供多个队列同时访问(并发共享模式),还是仅供单个队列访问(专有共享模式)。 如果指定为并发共享模式,那么必须提供所有将访问缓冲区的队列的索引。 如果希望定义为专有共享模式,我们仍然可以在不同队列中引用该缓冲区,但一次仅引用一个。 如果希望在不同的队列中使用缓冲区(提交将该缓冲区引用至另一队列的命令),我们需要指定缓冲区内存壁垒,以将缓冲区的所有权移交至另一队列。
- queueFamilyIndexCount – pQueueFamilyIndices 阵列中的队列索引数量(仅指定为并发共享模式时)。
- pQueueFamilyIndices – 包含所有队列(将引用缓冲区)的索引阵列(仅指定为并发共享模式时)。
为创建缓冲区,我们必须调用 vkCreateBuffer()函数。
缓冲区内存分配
我们接下来创建内存对象,以备份缓冲区存储。
VkMemoryRequirements buffer_memory_requirements; vkGetBufferMemoryRequirements( GetDevice(), buffer, &buffer_memory_requirements ); VkPhysicalDeviceMemoryProperties memory_properties; vkGetPhysicalDeviceMemoryProperties( GetPhysicalDevice(), &memory_properties ); for( uint32_t i = 0; i < memory_properties.memoryTypeCount; ++i ) { if( (buffer_memory_requirements.memoryTypeBits & (1 << i)) && (memory_properties.memoryTypes[i].propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) ) { VkMemoryAllocateInfo memory_allocate_info = { VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, // VkStructureType sType nullptr, // const void *pNext buffer_memory_requirements.size, // VkDeviceSize allocationSize i // uint32_t memoryTypeIndex }; if( vkAllocateMemory( GetDevice(), &memory_allocate_info, nullptr, memory ) == VK_SUCCESS ) { return true; } } } return false;
10.Tutorial04.cpp,函数 AllocateBufferMemory()
首先必须检查创建缓冲区需满足哪些内存要求。 为此我们调用 vkGetBufferMemoryRequirements()函数。 它将供内存创建的参数保存在我们在最后一个参数中提供了地址的变量中。 该变量的类型必须为 VkMemoryRequirements,并包含与所需大小、内存对齐,以及支持内存类型等相关的信息。 内存类型有哪些?
每种设备都可拥有并展示不同的内存类型 — 属性不同的尺寸堆。 一种内存类型可能是设备位于 GDDR 芯片上的本地内存(因此速度极快)。 另一种可能是显卡和 CPU 均可见的共享内存。 显卡和应用都可以访问该内存,但这种内存的速度比(仅供显卡访问的)设备本地内存慢。
为查看可用的内存堆和类型,我们需要调用 vkGetPhysicalDeviceMemoryProperties()函数,将相关内存信息保存在类型变量 VkPhysicalDeviceMemoryProperties 中。 它包含以下信息:
- memoryHeapCount – 特定设备展示的内存堆数量。
- memoryHeaps – 内存堆阵列。 每个堆代表大小和属性各不相同的内存。
- memoryTypeCount – 特定设备展示的内存类型数量。
- memoryTypes – 内存类型阵列。 每个要素描述特定的内存属性,并包含拥有这些特殊属性的内存堆索引。
为特定缓冲区分配内存之前,我们需要查看哪种内存类型满足缓冲区的内存要求。 如果还有其他特定的需求,我们也需要检查。 为此,我们迭代所有可用的内存类型。 缓冲区内存要求中有一个称为 memoryTypeBits 的字段,如果在该字段中设置特定索引的位,表示我们可以为特定缓冲区分配该索引代表的类型的内存。 但必须记住,尽管始终有一种内存类型满足缓冲区的内存要求,但可能不支持其他特定需求。 在这种情况下,我们需要查看另一种内存类型,或更改其他要求。
这里我们的其他要求指内存需要对主机是可见的, 表示应用可以映射并访问该内存 — 读写数据。 这种内存的速度通常比设备本地内存慢,但这样我们可以轻松为顶点属性上传数据。 接下来的教程将介绍如何使用设备本地内存,以提升性能。
幸运的是,主机可见要求非常普遍,因此我们能够轻松找到一种内存类型既能满足缓冲区的内存需求,也具备主机可见性。 然后我们准备类型变量 VkMemoryAllocateInfo,并填充其中的字段:
- sType – 结构类型,此处设置为 VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO。
- pNext – 为扩展功能预留的指示器。
- allocationSize – 要求分配的最小内存。
- memoryTypeIndex – 希望用于已创建内存对象的内存类型索引。 该索引的其中一位在缓冲区内存要求中设置(值为 1)。
填充该结构后,我们调用 vkAllocateMemory()并检查内存对象分配是否成功。
绑定缓冲区内存
创建完内存对象后,必须将其绑定至缓冲区。 如果不绑定,缓冲区中将没有存储空间,我们将无法保存任何数据。
if( !AllocateBufferMemory( Vulkan.VertexBuffer.Handle, &Vulkan.VertexBuffer.Memory ) ) { std::cout << "Could not allocate memory for a vertex buffer!"<< std::endl; return false; } if( vkBindBufferMemory( GetDevice(), Vulkan.VertexBuffer.Handle, Vulkan.VertexBuffer.Memory, 0 ) != VK_SUCCESS ) { std::cout << "Could not bind memory for a vertex buffer!"<< std::endl; return false; }
11.Tutorial04.cpp,函数 CreateVertexBuffer()
函数 AllocateBufferMemory() 可分配内存对象, 之前已介绍过。 创建内存对象时,我们通过调用 vkBindBufferMemory()函数,将其绑定至缓冲区。 在调用期间,我们必须指定缓冲区句柄、内存对象句柄和偏移。 偏移非常重要,需要我们另加介绍。
查询缓冲区内存要求时,我们获取了有关所需大小、内存类型和对齐方面的信息。 不同的缓冲区用法要求不同的内存对齐。 内存对象的开头(偏移为 0)满足所有对齐要求。 这表示所有内存对象将在满足所有不同用法要求的地址创建。 因此偏移指定为 0 时,我们完全不用担心。
但我们可以创建更大的内存对象,并将其用作多个缓冲区(或图像)的存储空间。 事实上我们建议使用这种方法。 创建大型内存对象表示我们只需创建较少的内存对象。 这有利于驱动程序跟踪较少数量的对象。 出于操作系统要求和安全措施,驱动程序必须跟踪内存对象。 大型内存对象不会造成较大的内存碎片问题。 最后,我们还应分配较多的内存数量,保持对象的相似性,以提高缓存命中率,从而提升应用性能。
但当我们分配大型内存对象并将其绑定至多个缓冲区(或图像)时,并非所有对象都能绑定在 0 偏移的位置。 只有一种可以如此,其他必须绑定得远一些,在第一个缓冲区(或图像)使用的空间之后。 因此第二个,以及绑定至相同内存对象的缓冲区的偏移必须满足查询报告的对齐要求。 而且我们必须牢记这点。 因此对齐成员至关重要。
创建缓冲区,并为其分配和绑定内存时,我们可以用顶点属性数据填充该缓冲区。
上传顶点数据
我们创建了缓冲区,并绑定了主机可见的内存。 这表示我们可以映射该内存、获取该内存的指示器,并使用该指示器将数据从应用拷贝至该缓冲区(与 OpenGL 的 glBufferData() 函数类似):
void *vertex_buffer_memory_pointer; if( vkMapMemory( GetDevice(), Vulkan.VertexBuffer.Memory, 0, Vulkan.VertexBuffer.Size, 0, &vertex_buffer_memory_pointer ) != VK_SUCCESS ) { std::cout << "Could not map memory and upload data to a vertex buffer!"<< std::endl; return false; } memcpy( vertex_buffer_memory_pointer, vertex_data, Vulkan.VertexBuffer.Size ); VkMappedMemoryRange flush_range = { VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE, // VkStructureType sType nullptr, // const void *pNext Vulkan.VertexBuffer.Memory, // VkDeviceMemory memory 0, // VkDeviceSize offset VK_WHOLE_SIZE // VkDeviceSize size }; vkFlushMappedMemoryRanges( GetDevice(), 1, &flush_range ); vkUnmapMemory( GetDevice(), Vulkan.VertexBuffer.Memory ); return true;
12.Tutorial04.cpp,函数 CreateVertexBuffer()
我们调用 vkMapMemory()函数映射内存。 在该调用中,必须指定我们希望映射哪个内存对象以及访问区域。 区域表示内存对象的存储与大小开头的偏移。 调用成功后我们获取指示器。 我们可以使用该指示器将数据从应用拷贝至提供的内存地址。 这里我们从包含顶点位置和颜色的阵列拷贝顶点数据。
内存拷贝操作之后,取消内存映射之前(无需取消内存映射,可以保留指示器,不会影响性能),我们需要告知驱动程序我们的操作修改了哪部分内存。 该操作称为 flushing。 尽管如此,我们指定所有内存范围,以支持应用将数据拷贝至该范围。 范围无需具备连续性。 通过包含以下字段的 VkMappedMemoryRange 要素阵列可定义该范围:
- sType – 结构类型,此处等于 VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE。
- pNext – 为扩展功能预留的指示器。
- memory – 已映射并修改的内存对象句柄。
- offset – 特定范围开始的偏移(从特定内存对象的存储开头)。
- size – 受影响区域的大小(字节)。 如果从偏移到结尾的整个内存均受影响,我们可以使用 VK_WHOLE_SIZE 的特定值。
定义应闪存的所有内存范围时,我们可调用 vkFlushMappedMemoryRanges()函数。 之后,驱动程序将知道哪些部分已修改,并重新加载它们(即刷新高速缓存)。 重新加载通常在壁垒上执行。 修改缓冲区后,我们应设置缓冲区内存壁垒,告知驱动程序部分操作对缓冲区造成了影响,应进行刷新。 不过幸运的是,在本示例中,驱动程序隐式地将壁垒放在命令缓冲区(引用特定缓冲区且不要求其他操作)的提交操作上。 现在我们可以在渲染命令记录期间使用该缓冲区。
渲染资源创建
现在必须准备命令缓冲区记录所需的资源。 我们在之前的教程中为每个交换链图像记录了一个静态命令缓冲区。 这里我们将重新整理渲染代码。 我们仍然展示一个比较简单的静态场景,不过此处介绍的方法可以用于展示动态场景的真实情况。
要想有效地记录命令缓冲区并将其提交至队列,我们需要四类资源:命令缓冲区、旗语、栅栏和帧缓冲器。 旗语我们之前介绍过,用于内部队列同步。 而栅栏支持应用检查是否出现了特定情况,例如命令缓冲区提交至队列后是否已执行完。 如有必要,应用可以等待栅栏,直到收到信号。 一般来说,旗语用于同步队列 (GPU),栅栏用于同步应用 (CPU)。
如要渲染一帧简单的动画,我们(至少)需要一个命令缓冲区,两个旗语 — 一个用于获取交换链图像(图像可用的旗语),另一个用于发出可进行演示的信号(渲染已完成的旗语) —,一个栅栏和一个帧缓冲器。 栅栏稍后将用于检查我们是否重新记录了特定命令缓冲区。 我们将保留部分渲染资源,以调用虚拟帧。 虚拟帧(包含一个命令缓冲区、两个旗语、一个栅栏和一个帧缓冲器)的数量与交换链图像数量无关。
渲染算法进展如下: 我们将渲染命令记录至第一个虚拟帧,然后将其提交至队列。 然后记录另一帧(命令缓冲区)并将其提交至队列。 直到记录并提交完所有虚拟帧。 这时我们通过提取并再次重新记录最之前(最早提交)的命令缓冲区,开始重复使用帧。 然后使用另一命令缓冲区,依此类推。
此时栅栏登场。我们不允许记录已提交至队列,但在队列中的没有执行完的命令缓冲区。 在记录命令缓冲区期间,我们可以使用“simultaneous use”标记,以记录或重新提交之前已提交过的命令缓冲区。 不过这样会影响性能。 最好的方法是使用栅栏,检查命令缓冲区是否不能再使用。 如果显卡仍然在处理命令缓冲区,我们可以等待与特定命令缓冲区相关的栅栏,或将这额外的时间用于其他目的,比如改进 AI 计算,并在这之后再次检查以查看栅栏是否收到了信号。
我们应准备多少虚拟帧? 一个远远不够。 记录并提交单个命令缓冲区时,我们会立即等待,直到能够重新记录该缓冲区。 这样会导致 CPU 和 GPU 浪费时间。 GPU 的速度通常更快,因此等待 CPU 会造成 GPU 等待的时间更长。 我们应该保持 GPU 的繁忙状态。 因此我们创建了 Vulkan 等瘦 API。 使用两个虚拟帧将会显著提升性能,因为这样会大大缩短 CPU 和 GPU 的等待时间。 添加第三个虚拟帧能够进一步提升性能,但提升空间不大。 使用四组以上渲染资源没有太大意义,因为其性能提升可忽略不计(当然这取决于已渲染场景的复杂程度和类似 CPU 的物理组件或 AI 所执行的计算)。 增加虚拟帧数量时,会提高输入延迟,因为我们在 CPU 背后演示的帧为 1-3 个。 因此两个或三个虚拟帧似乎是最合理的组合,能够使性能、内存使用和输入延迟之间达到最佳动平衡。
您可能想知道虚拟帧的数量为何与交换链图像数量无关。 这种方法会影响应用的行为。 创建交换链时,我们请求所需图像的最小数量,但驱动程序允许创建更多图像。 因此不同的硬件厂商可能实施提供不同数量交换链图像的驱动程序,甚至要求相同(演示模式和最少图像数量)时也如此。 建立虚拟帧数量与交换链数量的相关性后,应用将在一个显卡上仅使用两个虚拟帧,而在另一显卡上使用四个虚拟帧。 这样会影响性能和之前提到的输入延迟。 因此我们不希望出现这种行为。 通过保持固定数量的虚拟帧,我们可以控制渲染算法,并对其进行调优以满足需求,即渲染时间与 AI 或物理计算之间的平衡。
命令池创建
分配命令缓冲区之前,我们首先需要创建一个命令池。
VkCommandPoolCreateInfo cmd_pool_create_info = { VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, // VkStructureType sType nullptr, // const void *pNext VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT | // VkCommandPoolCreateFlags flags VK_COMMAND_POOL_CREATE_TRANSIENT_BIT, queue_family_index // uint32_t queueFamilyIndex }; if( vkCreateCommandPool( GetDevice(), &cmd_pool_create_info, nullptr, pool ) != VK_SUCCESS ) { return false; } return true;
13.Tutorial04.cpp,函数 CreateCommandPool()
通过调用 vkCreateCommandPool()创建命令池,其中要求我们提供类型变量 VkCommandPoolCreateInfo 的指示器。 与之前的教程相比,代码基本保持不变。 但这次添加两个其他的标记以创建命令池:
- VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT – 表示通过命令池分配的命令缓冲区可单独重新设置。 正常来说,如果没有这一标记,我们将无法多次重新记录相同的命令缓冲区。 它必须首先重新设置。 而且从命令池创建的命令缓冲区只能一起重新设置。 指定该标记可支持我们单独重新设置命令缓冲区,而且(甚至更好)通过调用 vkBeginCommandBuffer()函数隐式地完成这一操作。
- VK_COMMAND_POOL_CREATE_TRANSIENT_BIT – 该标记告知驱动程序通过该命令池分配的命令缓冲区将仅短时间内存在,需要经常重新记录和重新设置。 该信息可帮助优化命令缓冲区分配并以更好地方式执行。
命令缓冲区分配
命令缓冲区分配与之前的相同。
for( size_t i = 0; i < Vulkan.RenderingResources.size(); ++i ) { if( !AllocateCommandBuffers( Vulkan.CommandPool, 1, &Vulkan.RenderingResources[i].CommandBuffer ) ) { std::cout << "Could not allocate command buffer!"<< std::endl; return false; } } return true;
14.Tutorial04.cpp,函数 CreateCommandBuffers()
唯一的变化是命令缓冲区收集在渲染资源矢量中。 每个渲染资源结构均包含一个命令缓冲区、图像可用旗语、渲染已完成旗语、一个栅栏和一个帧缓冲器。 命令缓冲区循环分配。 渲染资源矢量中的要素数量可随意选择。 在本教程中,该数量为 3。
旗语创建
负责创建旗语的代码非常简单,与之前的相同:
VkSemaphoreCreateInfo semaphore_create_info = { VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO, // VkStructureType sType nullptr, // const void* pNext 0 // VkSemaphoreCreateFlags flags }; for( size_t i = 0; i < Vulkan.RenderingResources.size(); ++i ) { if( (vkCreateSemaphore( GetDevice(), &semaphore_create_info, nullptr, &Vulkan.RenderingResources[i].ImageAvailableSemaphore ) != VK_SUCCESS) || (vkCreateSemaphore( GetDevice(), &semaphore_create_info, nullptr, &Vulkan.RenderingResources[i].FinishedRenderingSemaphore ) != VK_SUCCESS) ) { std::cout << "Could not create semaphores!"<< std::endl; return false; } } return true;
15.Tutorial04.cpp,函数 CreateSemaphores()
栅栏创建
以下代码负责创建栅栏对象:
VkFenceCreateInfo fence_create_info = { VK_STRUCTURE_TYPE_FENCE_CREATE_INFO, // VkStructureType sType nullptr, // const void *pNext VK_FENCE_CREATE_SIGNALED_BIT // VkFenceCreateFlags flags }; for( size_t i = 0; i < Vulkan.RenderingResources.size(); ++i ) { if( vkCreateFence( GetDevice(), &fence_create_info, nullptr, &Vulkan.RenderingResources[i].Fence ) != VK_SUCCESS ) { std::cout << "Could not create a fence!"<< std::endl; return false; } } return true;
16.Tutorial04.cpp,函数 CreateFences()
我们调用 vkCreateFence()函数,以创建栅栏对象。 它从其他参数中接受类型变量 VkFenceCreateInfo 的指示器,其中包含以下成员:
- sType – 结构类型。 此处应设置为 VK_STRUCTURE_TYPE_FENCE_CREATE_INFO。
- pNext – 为扩展功能预留的指示器。
- flags – 目前该参数支持创建已收到信号的栅栏。
栅栏包含两种状态:收到信号和未收到信号。 该应用检查特定栅栏是否处于收到信号状态,否则将等待栅栏收到信号。 提交至队列的所有操作均处理完后,由 GPU 发出信号。 提交命令缓冲区时,我们可以提供一个栅栏,该栅栏将在队列执行完提交操作发布的所有命令后收到信号。 栅栏收到信号后,将由应用负责将其重新设置为未收到信号状态。
为何创建收到信号的栅栏? 渲染算法将命令记录至第一个命令缓冲区,然后是第二个命令缓冲器,之后是第三个,然后(队列中的执行结束后)再次记录至第一个命令缓冲区。 我们使用栅栏检查能否再次记录特定命令缓冲区。 那么第一次记录会怎样呢? 我们不希望第一次命令缓冲区记录和接下来的记录操作为不同的代码路径。 因此第一次发布命令缓冲区记录时,我们还检查栅栏是否已收到信号。 但因为我们没有提交特定命令缓冲区,因此与此相关的栅栏在执行完成后无法变成收到信号状态。 因此需要以已收到信号状态创建栅栏。 这样第一次记录时我们无需等待它变成已收到信号状态(因为它已经是已收到信号状态),但检查之后,我们要重新设置并立即前往记录代码。 之后我们提交命令缓冲区并提供相同的栅栏,这样在完成操作后将收到队列发来的信号。 下一次当我们希望将渲染命令记录至相同的命令缓冲区时,我们可以执行同样的操作:等待栅栏,重新设置栅栏,然后开始记录命令缓冲区。
绘制
现在我们准备记录渲染操作。 我们将正好在提交至队列之前记录命令缓冲区。 记录并提交一个命令缓冲区,然后记录并提交下一个命令缓冲区,然后记录并提交另一个。 在这之后我们提取第一个命令缓冲区,检查是否可用,并记录并将其提交至队列。
static size_t resource_index = 0; RenderingResourcesData ¤t_rendering_resource = Vulkan.RenderingResources[resource_index]; VkSwapchainKHR swap_chain = GetSwapChain().Handle; uint32_t image_index; resource_index = (resource_index + 1) % VulkanTutorial04Parameters::ResourcesCount; if( vkWaitForFences( GetDevice(), 1, ¤t_rendering_resource.Fence, VK_FALSE, 1000000000 ) != VK_SUCCESS ) { std::cout << "Waiting for fence takes too long!"<< std::endl; return false; } vkResetFences( GetDevice(), 1, ¤t_rendering_resource.Fence ); VkResult result = vkAcquireNextImageKHR( GetDevice(), swap_chain, UINT64_MAX, current_rendering_resource.ImageAvailableSemaphore, VK_NULL_HANDLE, &image_index ); switch( result ) { case VK_SUCCESS: case VK_SUBOPTIMAL_KHR: break; case VK_ERROR_OUT_OF_DATE_KHR: return OnWindowSizeChanged(); default: std::cout << "Problem occurred during swap chain image acquisition!"<< std::endl; return false; } if( !PrepareFrame( current_rendering_resource.CommandBuffer, GetSwapChain().Images[image_index], current_rendering_resource.Framebuffer ) ) { return false; } VkPipelineStageFlags wait_dst_stage_mask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; VkSubmitInfo submit_info = { VK_STRUCTURE_TYPE_SUBMIT_INFO, // VkStructureType sType nullptr, // const void *pNext 1, // uint32_t waitSemaphoreCount ¤t_rendering_resource.ImageAvailableSemaphore, // const VkSemaphore *pWaitSemaphores &wait_dst_stage_mask, // const VkPipelineStageFlags *pWaitDstStageMask; 1, // uint32_t commandBufferCount¤t_rendering_resource.CommandBuffer, // const VkCommandBuffer *pCommandBuffers 1, // uint32_t signalSemaphoreCount ¤t_rendering_resource.FinishedRenderingSemaphore // const VkSemaphore *pSignalSemaphores }; if( vkQueueSubmit( GetGraphicsQueue().Handle, 1, &submit_info, current_rendering_resource.Fence ) != VK_SUCCESS ) { return false; } VkPresentInfoKHR present_info = { VK_STRUCTURE_TYPE_PRESENT_INFO_KHR, // VkStructureType sType nullptr, // const void *pNext 1, // uint32_t waitSemaphoreCount ¤t_rendering_resource.FinishedRenderingSemaphore, // const VkSemaphore *pWaitSemaphores 1, // uint32_t swapchainCount &swap_chain, // const VkSwapchainKHR *pSwapchains&image_index, // const uint32_t *pImageIndices nullptr // VkResult *pResults }; result = vkQueuePresentKHR( GetPresentQueue().Handle, &present_info ); switch( result ) { case VK_SUCCESS: break; case VK_ERROR_OUT_OF_DATE_KHR: case VK_SUBOPTIMAL_KHR: return OnWindowSizeChanged(); default: std::cout << "Problem occurred during image presentation!"<< std::endl; return false; } return true;
17.Tutorial04.cpp,函数 Draw()
首先提取最近使用的渲染资源。 然后等待与该组相关的栅栏收到信号。 如果收到了信号,表示我们可以安全提取并记录命令缓冲区。 不过它还表示我们可以提取用于获取并演示在特定命令缓冲区中引用的旗语。 不能将同一个旗语用于不同的目的或两项不同的提交操作,必须等待之前的提交操作完成。 栅栏可防止我们修改命令缓冲区和旗语。 而且大家会看到,帧缓冲器也是如此。
栅栏完成后,我们重新设置栅栏并执行与正常绘制相关的操作:获取图像,记录渲染已获取图像的操作,提交命令缓冲区,并演示图像。
然后提取另一渲染资源集并执行相同的操作。 由于保留了三组渲染资源,三个虚拟帧,我们可缩短等待栅栏收到信号的时间。
记录命令缓冲区
负责记录命令缓冲区的函数很长。 此时会更长,因为我们使用顶点缓冲区和动态视口及 scissor 测试。 而且我们还创建临时帧缓冲器!
帧缓冲器的创建非常简单、快速。 同时保留帧缓冲器对象和交换链意味着,需要重新创建交换链时,我们需要重新创建这些对象。 如果渲染算法复杂,我们将有多个图像以及与之相关的帧缓冲器。 如果这些图像的大小必须与交换链图像的大小相同,那么我们需要重新创建所有图像(以纳入潜在的大小变化)。 因此最好按照需求创建帧缓冲器,这样也更方便。 这样它们的大小将始终符合要求。 帧缓冲器在面向特定图像创建的图像视图上运行。 交换链重新创建时,旧的图像将无效并消失。 因此我们必须重新创建图像视图和帧缓冲器。
在“03-第一个三角形”教程中,我们有大小固定的帧缓冲器,而且需要与交换链同时重新创建。 现在我们的帧缓冲器对象在每个虚拟帧资源组中。 记录命令缓冲器之前,我们要为将渲染的图像创建帧缓冲器,其大小与图像相同。 这样当我们重新创建交换链时,将立即调整下一帧的大小,而且新交换链图像的句柄及其图像视图将用于创建帧缓冲器。
记录使用渲染通道和帧缓冲器对象的命令缓冲区时,在队列处理命令缓冲区期间,帧缓冲器必须始终保持有效。 创建新的帧缓冲器时,命令提交至队列的操作完成后我们才开始破坏它。 不过由于我们使用栅栏,而且等待与特定命令缓冲区相关的栅栏,因为能够确保安全地破坏帧缓冲器。 然后我们创建新的帧缓冲器,以纳入潜在的大小和图像句柄变化。
if( framebuffer != VK_NULL_HANDLE ) { vkDestroyFramebuffer( GetDevice(), framebuffer, nullptr ); } VkFramebufferCreateInfo framebuffer_create_info = { VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO, // VkStructureType sType nullptr, // const void *pNext 0, // VkFramebufferCreateFlags flags Vulkan.RenderPass, // VkRenderPass renderPass 1, // uint32_t attachmentCount &image_view, // const VkImageView *pAttachments GetSwapChain().Extent.width, // uint32_t width GetSwapChain().Extent.height, // uint32_t height 1 // uint32_t layers }; if( vkCreateFramebuffer( GetDevice(), &framebuffer_create_info, nullptr, &framebuffer ) != VK_SUCCESS ) { std::cout << "Could not create a framebuffer!"<< std::endl; return false; } return true;
18.Tutorial04.cpp,函数 CreateFramebuffer()
创建帧缓冲器时,我们提取当前的交换链扩展,以及已获取交换链图像的图像视图。
接下来开始记录命令缓冲区:
if( !CreateFramebuffer( framebuffer, image_parameters.View ) ) { return false; } VkCommandBufferBeginInfo command_buffer_begin_info = { VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, // VkStructureType sType nullptr, // const void *pNext VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, // VkCommandBufferUsageFlags flags nullptr // const VkCommandBufferInheritanceInfo *pInheritanceInfo }; vkBeginCommandBuffer( command_buffer, &command_buffer_begin_info ); VkImageSubresourceRange image_subresource_range = { VK_IMAGE_ASPECT_COLOR_BIT, // VkImageAspectFlags aspectMask 0, // uint32_t baseMipLevel 1, // uint32_t levelCount 0, // uint32_t baseArrayLayer 1 // uint32_t layerCount }; if( GetPresentQueue().Handle != GetGraphicsQueue().Handle ) { VkImageMemoryBarrier barrier_from_present_to_draw = { VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, // VkStructureType sType nullptr, // const void *pNext VK_ACCESS_MEMORY_READ_BIT, // VkAccessFlags srcAccessMask VK_ACCESS_MEMORY_READ_BIT, // VkAccessFlags dstAccessMask VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, // VkImageLayout oldLayout VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, // VkImageLayout newLayout GetPresentQueue().FamilyIndex, // uint32_t srcQueueFamilyIndex GetGraphicsQueue().FamilyIndex, // uint32_t dstQueueFamilyIndex image_parameters.Handle, // VkImage image image_subresource_range // VkImageSubresourceRange subresourceRange }; vkCmdPipelineBarrier( command_buffer, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier_from_present_to_draw ); } VkClearValue clear_value = { { 1.0f, 0.8f, 0.4f, 0.0f }, // VkClearColorValue color }; VkRenderPassBeginInfo render_pass_begin_info = { VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO, // VkStructureType sType nullptr, // const void *pNext Vulkan.RenderPass, // VkRenderPass renderPass framebuffer, // VkFramebuffer framebuffer { // VkRect2D renderArea { // VkOffset2D offset 0, // int32_t x 0 // int32_t y }, GetSwapChain().Extent, // VkExtent2D extent; }, 1, // uint32_t clearValueCount &clear_value // const VkClearValue *pClearValues }; vkCmdBeginRenderPass( command_buffer, &render_pass_begin_info, VK_SUBPASS_CONTENTS_INLINE );
19.Tutorial04.cpp,函数 PrepareFrame()
首先定义类型变量 VkCommandBufferBeginInfo,并指定命令缓冲区只能提交一次。 指定 VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT 标记时,不能多次提交特定命令缓冲区。 每次提交后,必须重新设置。 但记录操作重新设置它的原因是 VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT 标记用于命令池创建。
接下来我们定义有关图像内存壁垒的子资源范围。 交换链图像布局过渡在渲染通道中隐式执行,但如果显卡队列和演示队列不同,则必须手动执行队列过渡。
然后启动包含临时帧缓冲器对象的渲染通道。
vkCmdBindPipeline( command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, Vulkan.GraphicsPipeline ); VkViewport viewport = { 0.0f, // float x 0.0f, // float y static_cast<float>(GetSwapChain().Extent.width), // float width static_cast<float>(GetSwapChain().Extent.height), // float height 0.0f, // float minDepth 1.0f // float maxDepth }; VkRect2D scissor = { { // VkOffset2D offset 0, // int32_t x 0 // int32_t y }, { // VkExtent2D extent GetSwapChain().Extent.width, // uint32_t width GetSwapChain().Extent.height // uint32_t height } }; vkCmdSetViewport( command_buffer, 0, 1, &viewport ); vkCmdSetScissor( command_buffer, 0, 1, &scissor ); VkDeviceSize offset = 0; vkCmdBindVertexBuffers( command_buffer, 0, 1, &Vulkan.VertexBuffer.Handle, &offset ); vkCmdDraw( command_buffer, 4, 1, 0, 0 ); vkCmdEndRenderPass( command_buffer ); if( GetGraphicsQueue().Handle != GetPresentQueue().Handle ) { VkImageMemoryBarrier barrier_from_draw_to_present = { VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, // VkStructureType sType nullptr, // const void *pNext VK_ACCESS_MEMORY_READ_BIT, // VkAccessFlags srcAccessMask VK_ACCESS_MEMORY_READ_BIT, // VkAccessFlags dstAccessMask VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, // VkImageLayout oldLayout VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, // VkImageLayout newLayout GetGraphicsQueue().FamilyIndex, // uint32_t srcQueueFamilyIndex GetPresentQueue().FamilyIndex, // uint32_t dstQueueFamilyIndex image_parameters.Handle, // VkImage image image_subresource_range // VkImageSubresourceRange subresourceRange }; vkCmdPipelineBarrier( command_buffer, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier_from_draw_to_present ); } if( vkEndCommandBuffer( command_buffer ) != VK_SUCCESS ) { std::cout << "Could not record command buffer!"<< std::endl; return false; } return true;
20.Tutorial04.cpp,函数 PrepareFrame()
接下来我们绑定图像管道。 它包含两个标记为动态的状态:视口和 scissor 测试。 因此我们准备能够定义视口和 scissor 测试参数的结构。 通过调用 vkCmdSetViewport()函数设置动态视口。 通过调用 vkCmdSetScissor()函数设置动态 scissor 测试。 这样图像管道可用于渲染大小不同的图像。
进行绘制之前的最后一件事是绑定相应的顶点缓冲区,为顶点属性提供缓冲区数据。 此操作通过调用 vkCmdBindVertexBuffers()函数完成。 我们指定一个绑定号码(哪个顶点属性集从该缓冲区提取数据)、一个缓冲区句柄指示器(或较多句柄,如果希望绑定多个绑定的缓冲区),以及偏移。 偏移指定应从缓冲区的较远部分提取有关顶点属性的数据。 但指定的偏移不能大于相应缓冲区(该缓冲区未绑定内存对象)的范围。
现在我们已经指定了全部所需要素:帧缓冲器、视口和 scissor 测试,以及顶点缓冲区。 我们可以绘制几何图形、完成渲染通道,并结束命令缓冲区。
教程 04 执行
以下是渲染操作的结果:
我们渲染了一个四边形,各个角落的颜色均不相同。 尝试更改窗口的大小;之前的三角形始终保持相同的大小,仅应用窗口右侧和底部的黑色方框变大或变小。 现在,由于是动态视口,因此四边形将随着窗口的变化而变大或变小。
清空
完成渲染后,关闭应用之前,我们需要破坏所有资源。 以下代码负责完成此项操作:
if( GetDevice() != VK_NULL_HANDLE ) { vkDeviceWaitIdle( GetDevice() ); for( size_t i = 0; i < Vulkan.RenderingResources.size(); ++i ) { if( Vulkan.RenderingResources[i].Framebuffer != VK_NULL_HANDLE ) { vkDestroyFramebuffer( GetDevice(), Vulkan.RenderingResources[i].Framebuffer, nullptr ); } if( Vulkan.RenderingResources[i].CommandBuffer != VK_NULL_HANDLE ) { vkFreeCommandBuffers( GetDevice(), Vulkan.CommandPool, 1, &Vulkan.RenderingResources[i].CommandBuffer ); } if( Vulkan.RenderingResources[i].ImageAvailableSemaphore != VK_NULL_HANDLE ) { vkDestroySemaphore( GetDevice(), Vulkan.RenderingResources[i].ImageAvailableSemaphore, nullptr ); } if( Vulkan.RenderingResources[i].FinishedRenderingSemaphore != VK_NULL_HANDLE ) { vkDestroySemaphore( GetDevice(), Vulkan.RenderingResources[i].FinishedRenderingSemaphore, nullptr ); } if( Vulkan.RenderingResources[i].Fence != VK_NULL_HANDLE ) { vkDestroyFence( GetDevice(), Vulkan.RenderingResources[i].Fence, nullptr ); } } if( Vulkan.CommandPool != VK_NULL_HANDLE ) { vkDestroyCommandPool( GetDevice(), Vulkan.CommandPool, nullptr ); Vulkan.CommandPool = VK_NULL_HANDLE; } if( Vulkan.VertexBuffer.Handle != VK_NULL_HANDLE ) { vkDestroyBuffer( GetDevice(), Vulkan.VertexBuffer.Handle, nullptr ); Vulkan.VertexBuffer.Handle = VK_NULL_HANDLE; } if( Vulkan.VertexBuffer.Memory != VK_NULL_HANDLE ) { vkFreeMemory( GetDevice(), Vulkan.VertexBuffer.Memory, nullptr ); Vulkan.VertexBuffer.Memory = VK_NULL_HANDLE; } if( Vulkan.GraphicsPipeline != VK_NULL_HANDLE ) { vkDestroyPipeline( GetDevice(), Vulkan.GraphicsPipeline, nullptr ); Vulkan.GraphicsPipeline = VK_NULL_HANDLE; } if( Vulkan.RenderPass != VK_NULL_HANDLE ) { vkDestroyRenderPass( GetDevice(), Vulkan.RenderPass, nullptr ); Vulkan.RenderPass = VK_NULL_HANDLE; } }
21.Tutorial04.cpp,destructor
设备处理完所有提交至阵列的命令后,我们开始破坏所有资源。 资源破坏以相反的顺序进行。 首先破坏所有渲染资源:帧缓冲器、命令缓冲区、旗语和栅栏。 栅栏通过调用 vkDestroyFence()函数破坏。 然后破坏命令池。 之后通过调用 vkDestroyBuffer()函数和 vkFreeMemory()函数分别破坏缓冲区和空闲内存对象。 最后破坏管道对象和渲染通道。
结论
本教程的编写以“03-第一个三角形”教程为基础。 我们通过在图像管道中使用顶点属性,并在记录命令缓冲区期间绑定顶点缓冲区,以此改进渲染过程。 我们介绍了顶点属性的数量和布局, 针对视口和 scissors 测试引入了动态管道状态, 并学习了如何创建缓冲区和内存对象,以及如何相互绑定。 我们还映射了内存,并将数据从 CPU 上传至 GPU。
我们创建了渲染资源集,以高效记录和发布渲染命令。 这些资源包括命令缓冲器、旗语、栅栏和帧缓冲器。 我们学习了如何使用栅栏,如何设置动态管道状态的值,以及如何在记录命令缓冲区期间绑定顶点缓冲区(顶点属性数据源)。
下节教程将介绍分期资源。 它们是用于在 CPU 和 GPU 之间拷贝数据的中间缓冲区。 这样应用无需映射用于渲染的缓冲区(或图像),它们也不必绑定至设备的本地(快速)内存。