Skip to content

门泊吴船亦已谋

Vulkan 画三角形「十一」 创建图形流水线

首先还是添加一个成员函数并在 initVulkan 中调用它

void initVulkan() {
    createInstance();
    createSurface();
    pickPhysicalDevice();
    createLogicalDevice();
    createSwapchain();
    createImageViews();
    createRenderPass();
    createGraphicsPipeline();
}

void createGraphicsPipeline() {

}

着色器

首先我们使用上一篇文章中的工具方法,载入编译好的着色器,并填充到 VkPipelineShaderStageCreateInfo 结构体中

auto vertShaderCode = readFile("shaders/vert.spv");
auto fragShaderCode = readFile("shaders/frag.spv");

VkShaderModule vertShaderModule = createShaderModule(vertShaderCode);
VkShaderModule fragShaderModule = createShaderModule(fragShaderCode);

VkPipelineShaderStageCreateInfo vertShaderStageInfo = {};
vertShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
vertShaderStageInfo.stage = VK_SHADER_STAGE_VERTEX_BIT;
vertShaderStageInfo.module = vertShaderModule;
vertShaderStageInfo.pName = "main";

VkPipelineShaderStageCreateInfo fragShaderStageInfo = {};
fragShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
fragShaderStageInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT;
fragShaderStageInfo.module = fragShaderModule;
fragShaderStageInfo.pName = "main";

VkPipelineShaderStageCreateInfo shaderStages[] = {vertShaderStageInfo, fragShaderStageInfo};

对于上面的代码,我们直接介绍 VkPipelineShaderStageCreateInfo 结构体

stage 用来指定着色器会被用于哪一个流水线阶段

module 指定包含着色器代码的着色器模块

pName 则是着色器代码中的入口点,这意味着我们把多个着色器结合在同一个着色器模块,然后指定不同的入口点来控制着色器不同的行为。当然这里我们使用标准的 main 函数来作为入口点

最后我们需要定义一个数组把所有的着色器扔进去

需要注意,着色器模块需要手动销毁,而这里我们只有在创建图形流水线之后才能销毁着色器模块,为了保证代码的顺序,我们将会在文章的末尾才贴出这段代码

固定功能

早期的图形 API 会为图形流水线的大多数阶段都提供默认状态。而在 Vulkan 中我们必须显式地指定一切东西,从视口大小到颜色混合函数。于是我们来通过填充结构体来配置所有的这些固定功能的操作

顶点输入

VkPipelineVertexInputStateCreateInfo 结构体描述了将会被传递给顶点着色器的顶点的格式。有以下两个内容

  • 绑定描述。指定了数据之间的间隔,以及每个数据条目是一个顶点还是一个实例
  • 属性描述。指定了传递给顶点着色器的顶点数据的各个属性的类型

因为我们已经把顶点数据硬编码在顶点着色器中,所以我们只需要在这个结构体中指定不需要顶点数据作为输入

VkPipelineVertexInputStateCreateInfo vertexInputInfo = {};
vertexInputInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
vertexInputInfo.vertexBindingDescriptionCount = 0;
vertexInputInfo.pVertexBindingDescriptions = nullptr;
vertexInputInfo.vertexAttributeDescriptionCount = 0;
vertexInputInfo.pVertexAttributeDescriptions = nullptr;

其中,pVertexBindingDescriptions 和 pVertexAttributeDescriptions 都是指向相应结构体数组的指针,分别用来指定上面提到的绑定描述和属性描述

输入装配

VkPipelineInputAssemblyStateCreateInfo 结构体描述了两个东西:根据输入顶点应当画出什么样的几何图形,以及是否需要图元重启。

第一个可以在 topology 成员中指定,有以下几种取值

  • VK_PRIMITIVE_TOPOLOGY_POINT_LIST
    输入的顶点各自是独立的点图元
  • VK_PRIMITIVE_TOPOLOGY_LINE_LIST
    每两个顶点构成一条线段图元,也就是说一个顶点只会出现在一条线段中
  • VK_PRIMITIVE_TOPOLOGY_LINE_STRIP
    与上一个类似,但是每个线段图元的终止顶点会作为下一个线段图元的起点,也就是说所有的点依次相连
  • VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST
    每三个顶点构成一个三角形图元,同样意味着一个顶点只会出现在一个三角形中
  • VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP
    与上一个类似,但是每个三角形图元的最后两个顶点会作为下一个三角形图元的前两个顶点,也就是说相邻的两个三角形会共享两个点,所有的三角形连成一长条

通常来说,从顶点缓冲区中载入的顶点是以数组中的索引为顺序的,但是我们可以使用元素缓冲区来手动指定索引,从而实现更优化的性能,比如复用顶点。如果把 primitiveRestart 设为 VK_TRUE,那么对于带有_STRIP 后缀的拓扑模式,我们就可以使用一个特殊的索引值 0xFFFF 或 0xFFFFFFFF,来中断当前连续的线段或连续的三角形,重新开启新的一系列线段或一系列三角形

我们要画的是一个三角形,所以这样写

VkPipelineInputAssemblyStateCreateInfo inputAssembly = {};
inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
inputAssembly.primitiveRestartEnable = VK_FALSE;

视口和剪裁

一个视口主要描述帧缓冲区中会被渲染的区域。它几乎都会被设置为(0, 0)到(width, height),且本文也会这样设置

VkViewport viewport = {};
viewport.x = 0.0f;
viewport.y = 0.0f;
viewport.width = (float) swapchainExtent.width;
viewport.height = (float) swapchainExtent.height;
viewport.minDepth = 0.0f;
viewport.minDepth = 1.0f;

需要注意交换链及其图像的尺寸可能和窗体的不同。交换链图像在之后会作为帧缓冲区使用,因此我们这里要使用交换链的尺寸

minDepth 与 maxDepth 指定了用于帧缓冲区的深度值范围。它们的值必须在[0.0f, 1.0f]之内,但是 minDepth 可以比 maxDepth 高。如果不是有特殊情况,应当使用标准值 0.0f 和 1.0f

视口定义了从图像到帧缓冲区的变换,而剪裁矩形定义了实际会被存储的像素的区域。在剪裁矩形之外的任何像素都会被光栅化阶段抛弃。这个功能比起变换更像是过滤器

我们这里只是要简单地绘制整个帧缓冲区,所以我们指定一个覆盖了所有区域剪裁矩形

VkRect2D scissor = {};
scissor.offset = {0, 0};
scissor.extent = swapchainExtent;

接下来我们把视口和剪裁矩形通过 VkPipelineViewportStateCreateInfo 结构体结合起来。对于某些显卡而言,可以有多个视口与剪裁矩形,因此它的成员可以是一个数组的指针,这需要在逻辑设备创建的时候启用相关的 GPU 特性

VkPipelineViewportStateCreateInfo viewportState = {};
viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
viewportState.viewportCount = 1;
viewportState.pViewports = &viewport;
viewportState.scissorCount = 1;
viewportState.pScissors = &scissor;

光栅化

光栅化阶段可以把来自于顶点着色器的顶点组成的几何体转换成片元着色器所需要的片元。它同样会进行深度测试,面剔除,以及剪裁测试,并且它可以配置输出的片元会填充整个多边形还是仅仅是线框渲染。这些都会在 VkPipelineRasterizationStateCreateInfo 中配置

VkPipelineRasterizationStateCreateInfo rasterizer = {};
rasterizer.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
rasterizer.depthClampEnable = VK_FALSE;
rasterizer.rasterizerDiscardEnable = VK_FALSE;
rasterizer.polygonMode = VK_POLYGON_MODE_FILL;
rasterizer.lineWidth = 1.0f;
rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;
rasterizer.frontFace = VK_FRONT_FACE_CLOCKWISE;
rasterizer.depthBiasEnable = VK_FALSE;

如果把 depthClampEnable 设为 VK_TRUE,那么在近裁和远裁平面之外的片元会被截断在平面上,而不是直接丢弃。这在阴影贴图等特殊情况下非常的实用。使用它也需要开启相应的 GPU 特性

如果把 rasterizerDiscardEnable 设为 VK_TRUE,那么几何体将不会通过光栅化阶段。它从根本上禁止了到帧缓冲区的任何输出

polygonMode 决定了如何为几何体生成片元,有以下几种模式

  • VK_POLYGON_MODE_FILL
    使用片元填充多边形的区域
  • VK_POLYGON_MODE_LINE
    只显示多边形的边
  • VK_POLYGON_MODE_POINT
    只显示多边形的点

lineWidth 成员十分显然,它描述了线的厚度,也就是片元的数量。最大线宽的支持取决于硬件,且如果要让它高于 1.0f,我们还需要启用 wideLines 的 GPU 特性

cullMode 变量决定了面剪裁的类型,我们可以禁用剪裁、剪裁前面、剪裁后面或者剪裁全部的面

frontFace 变量指定了前面的顶点序,顺时针或者逆时针

光栅化阶段能通过加上一个常量或者基于片元的斜率改变深度值。有时可以用在阴影贴图上,但是这里我们不需要它。于是把 depthBiasEnable 设为 VK_FALSE

多重采样

也就是在游戏中常见的的 MSAA。我们使用 VkPipelineMultisampleStateCreateInfo 结构体来配置多重采样,多重采样是抗锯齿的一种方式。如果有多个几何体在光栅化后有某些像素重合,在片元着色器阶段之后,多重采样可以把这些像素混合起来。这种情况主要发生在边上,且在边上的锯齿问题也是最为严重的。因为如果只有一个多边形被映射为一个像素的话,对于这个像素就不需要多次执行片元着色器,所以多重采样的性能优势就远远大于简单的先高分辨率渲染再压缩图像。如果要使用多重采样的话,也需要启用对应的 GPU 特性

当然目前我们只需要禁用它就行

VkPipelineMultisampleStateCreateInfo multisampling = {};
multisampling.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
multisampling.sampleShadingEnable = VK_FALSE;
multisampling.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;

深度与模板测试

如果使用深度或模板缓冲,就需要使用 VkPipelineDepthStencilStateCreateInfo 结构体来配置深度和模板测试。目前我们也不需要它,所以只需要在后面创建流水线的时候简单地传入 nullptr,而不是这个结构体的指针

颜色混合

片元着色器已经返回了一个颜色之后,它需要与帧缓冲区中已经存在的颜色进行结合,有以下两种方式。

  • 把旧的值跟新的值混合起来产生最终的颜色
  • 把旧的值跟新的值使用位运算结合

配置颜色混合需要两种结构体。第一种是 VkPipelineColorBlendAttachmentState,描述了每个被附着的帧缓冲区的配置;第二种是 VkPipelineColorBlendStateCreateInfo,描述了全局的颜色混合设置

VkPipelineColorBlendAttachmentState colorBlendAttachment = {};
colorBlendAttachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT  VK_COLOR_COMPONENT_G_BIT  VK_COLOR_COMPONENT_B_BIT  VK_COLOR_COMPONENT_A_BIT;
colorBlendAttachment.blendEnable = VK_FALSE;

VkPipelineColorBlendStateCreateInfo colorBlending = {};
colorBlending.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
colorBlending.logicOpEnable = VK_FALSE;
colorBlending.attachmentCount = 1;
colorBlending.pAttachments = &colorBlendAttachment;

第一种结构体是对于每一个帧缓冲区配置的,不过这里我们只有一个帧缓冲区。它可以配置上面提到的颜色混合的第一种方式。使用颜色混合最常见的方式是实现 alpha 混合,也就是通过透明度来把新颜色跟旧颜色混合起来。不过我们不管它,把 blendEnable 设为 VK_FALSE 来直接使用新颜色覆盖掉旧的颜色

第二种结构体持有了所有的第一种结构体的数组,且它可以为颜色混合的第一种方式(也就是第一种结构体中配置的混合方式)设置常量。它还可以使用第二种混合方式,这时就要把 logicOpEnable 设为 VK_TRUE 来启用位运算,再配置具体的位运算操作。当然这里我们直接禁用掉就行

动态状态

我们在之前配置的结构体中有一部分状态实际上是可以在不重新创建流水线的情况下被改变的。比如视口的尺寸、线宽、混合常量等。如果有这样的需要,就应当在 VkPipelineDynamicStateCreateInfo 中进行配置,然后在结构体中配置的这些状态将会失效,于是就需要在绘制的时候再进行指定。当然现在我们不需要它,所以在创建流水线的时候传入 nullptr 就行了

流水线布局

我们可以在着色器中使用 uniform 变量,它是全局变量。它可以在绘制的时候进行动态修改来改变着色器的行为,而不需要重新创建着色器,这有一点类似于动态状态中的变量。这种变量通常用于为顶点着色器传递变换矩阵,或者在片元着色器中创建纹理采样器

这些变量需要在创建流水线的过程中使用 VkPipelineLayout 对象指定。尽管我们不需要使用它,但还是需要创建一个空的流水线布局

我们需要创建一个成员变量来存储它,因为后续还有别的函数需要调用它

VkPipelineLayout pipelineLayout;

然后还是在 createGraphicsPipeline 函数中接着写

VkPipelineLayoutCreateInfo pipelineLayoutInfo = {};
pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
if (vkCreatePipelineLayout(device, &pipelineLayoutInfo, nullptr, &pipelineLayout) != VK_SUCCESS)
    throw std::runtime_error("failed to create pipeline layout!");

最后注意要在 cleanup 中销毁它

void cleanup() {
    vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
    vkDestroyRenderPass(device, renderPass, nullptr);
    for (auto imageView : swapchainImageViews)
        vkDestroyImageView(device, imageView, nullptr);
    vkDestroySwapchainKHR(device, swapchain, nullptr);
    vkDestroyDevice(device, nullptr);
    vkDestroySurfaceKHR(instance, surface, nullptr);
    vkDestroyInstance(instance, nullptr);

    glfwDestroyWindow(window);
    glfwTerminate();
}

图形流水线

首先,还是要添加一个成员变量用于持有 VkPipeline 对象

VkPipeline graphicsPipeline;

终于,我们可以开始创建图形流水线了,这需要把上面配置好的所有结构体结合起来,我们先来回顾一下之前做了什么

  • 着色器阶段
    配置了着色器模块,来定义图形流水线中可编程阶段的功能
  • 固定功能阶段
    配置了图形流水线中固定功能阶段所需要的所有结构体,比如输入装配、光栅化、视口、颜色混合等
  • 渲染路径
    流水线阶段中使用到的附着及其作用
  • 流水线布局
    指定了着色器会用到的,可以在绘制时修改的 uniform 变量

把这些东西结合起来,就是完整的图形流水线的功能,于是我们就使用它们来填充 VkGraphicsPipelineCreateInfo 结构体,再创建图形流水线

然后我们开始填充结构体并创建图形流水线

VkGraphicsPipelineCreateInfo pipelineInfo = {};
pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
pipelineInfo.stageCount = 2;
pipelineInfo.pStages = shaderStages;
pipelineInfo.pVertexInputState = &vertexInputInfo;
pipelineInfo.pInputAssemblyState = &inputAssembly;
pipelineInfo.pViewportState = &viewportState;
pipelineInfo.pRasterizationState = &rasterizer;
pipelineInfo.pMultisampleState = &multisampling;
pipelineInfo.pDepthStencilState = nullptr;
pipelineInfo.pColorBlendState = &colorBlending;
pipelineInfo.pDynamicState = nullptr;
pipelineInfo.layout = pipelineLayout;
pipelineInfo.renderPass = renderPass;
pipelineInfo.subpass = 0;
pipelineInfo.basePipelineHandle = VK_NULL_HANDLE;
pipelineInfo.basePipelineIndex = -1;

if (vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &graphicsPipeline) != VK_SUCCESS)
    throw std::runtime_error("failed to create graphics pipeline!");

填充 VkGraphicsPipelineCreateInfo 结构体的前半部分就是把上文填好的结构体扔进去

需要注意的是对于渲染路径而言,需要传入图形流水线所用于的子路径索引。之后这条流水线同样可以选择其他的渲染路径,而不是这个特定的实例,但它们都应当兼容当前所选的的渲染路径。当然我们不需要用到

还有 basePipelineHandle 与 basePipelineIndex 这两个参数。Vulkan 允许我们根据一条已经存在的流水线派生出新的流水线。流水线派生的优势在于,根据一条已有的流水线创建具有类似功能的流水线开销会更小,且在父流水线相同的流水线之间切换也会更快。我们可以通过 basePipelineHandle 指定一条已存在的流水线的句柄,也可以通过 basePipelineIndex 指定即将被创建的流水线的索引。当然现在我们只需要创建一条流水线,直接指定空的句柄和无效索引即可。并且这些值只有在 VkGraphicsPipelineCreateInfo 的 flags 成员中打上 VK_PIPELINE_CREATE_DERIVATIVE_BIT 位域标记才会生效

最后在 cleanup 中销毁流水线

void cleanup() {
    vkDestroyPipeline(device, graphicsPipeline, nullptr);
    vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
    vkDestroyRenderPass(device, renderPass, nullptr);
    for (auto imageView : swapchainImageViews)
        vkDestroyImageView(device, imageView, nullptr);
    vkDestroySwapchainKHR(device, swapchain, nullptr);
    vkDestroyDevice(device, nullptr);
    vkDestroySurfaceKHR(instance, surface, nullptr);
    vkDestroyInstance(instance, nullptr);

    glfwDestroyWindow(window);
    glfwTerminate();
}

额外工作

上文已经提到过了,我们的着色器模块需要在创建流水线之后手动销毁,所以在 createGraphicsPipeline 函数的最后加上这段代码

vkDestroyShaderModule(device, fragShaderModule, nullptr);
vkDestroyShaderModule(device, vertShaderModule, nullptr);