旧的图形 API 为图形管线的大部分阶段提供了默认状态。在 Vulkan 中,您必须显式地指定大多数管线状态,因为它们将被烘焙到不可变的管线状态对象中。在本章中,我们将填写所有结构以配置这些固定功能操作。

动态状态

虽然大多数管线状态需要烘焙到管线状态中,但实际上可以在不重新创建管线的情况下在绘制时更改有限数量的状态。例如,视口的大小、线宽和混合常量。如果您想使用动态状态并保持这些属性不变,那么您必须填写一个 VkPipelineDynamicStateCreateInfo 结构,如下所示

std::vector<VkDynamicState> dynamicStates = {
    VK_DYNAMIC_STATE_VIEWPORT,
    VK_DYNAMIC_STATE_SCISSOR
};

VkPipelineDynamicStateCreateInfo dynamicState{};
dynamicState.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
dynamicState.dynamicStateCount = static_cast<uint32_t>(dynamicStates.size());
dynamicState.pDynamicStates = dynamicStates.data();

这将导致忽略这些值的配置,并且您将能够(并且需要)在绘制时指定数据。这会产生更灵活的设置,并且对于视口和裁剪矩形状态等事物非常常见,如果将它们烘焙到管线状态中,则会导致更复杂的设置。

顶点输入

VkPipelineVertexInputStateCreateInfo 结构描述了将传递给顶点着色器的顶点数据的格式。它大致通过两种方式描述这一点

  • 绑定:数据之间的间距以及数据是逐顶点还是逐实例(参见 实例化
  • 属性描述:传递给顶点着色器的属性类型,从哪个绑定加载它们以及在哪个偏移量加载

因为我们将顶点数据直接硬编码到顶点着色器中,我们将填写此结构以指定现在没有要加载的顶点数据。我们将在顶点缓冲区章节中回到它。

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

pVertexBindingDescriptionspVertexAttributeDescriptions 成员指向一个结构体数组,该数组描述了用于加载顶点数据的上述详细信息。将此结构添加到 createGraphicsPipeline 函数中,紧跟在 shaderStages 数组之后。

输入汇编

VkPipelineInputAssemblyStateCreateInfo 结构描述了两件事:将从顶点绘制哪种类型的几何图形,以及是否应启用图元重启。前者在 topology 成员中指定,可以具有如下值

  • VK_PRIMITIVE_TOPOLOGY_POINT_LIST:来自顶点的点
  • VK_PRIMITIVE_TOPOLOGY_LINE_LIST:每 2 个顶点绘制一条线,不重复使用顶点
  • VK_PRIMITIVE_TOPOLOGY_LINE_STRIP:每条线的结束顶点用作下一条线的起始顶点
  • VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST:每 3 个顶点绘制一个三角形,不重复使用顶点
  • VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP:每个三角形的第二个和第三个顶点用作下一个三角形的前两个顶点

通常,顶点按索引顺序从顶点缓冲区加载,但使用元素缓冲区,您可以自己指定要使用的索引。这允许您执行诸如重用顶点之类的优化。如果您将 primitiveRestartEnable 成员设置为 VK_TRUE,则可以通过使用特殊的索引 0xFFFF0xFFFFFFFF 来在 _STRIP 拓扑模式中打断线和三角形。

我们打算在本教程中绘制三角形,因此我们将坚持以下结构数据

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.maxDepth = 1.0f;

请记住,交换链及其图像的大小可能与窗口的 WIDTHHEIGHT 不同。交换链图像稍后将用作帧缓冲,因此我们应该坚持它们的大小。

minDepthmaxDepth 值指定用于帧缓冲的深度值范围。这些值必须在 [0.0f, 1.0f] 范围内,但 minDepth 可能高于 maxDepth。如果您没有做任何特殊的事情,那么您应该坚持使用 0.0f1.0f 的标准值。

虽然视口定义了从图像到帧缓冲的转换,但裁剪矩形定义了实际存储像素的区域。光栅化器将丢弃裁剪矩形之外的任何像素。它们的功能类似于过滤器,而不是转换。下图说明了差异。请注意,左侧的裁剪矩形只是导致该图像的众多可能性之一,只要它大于视口即可。

因此,如果我们想绘制到整个帧缓冲,我们将指定一个覆盖它的裁剪矩形

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

视口和裁剪矩形可以指定为管线的静态部分,也可以指定为命令缓冲区中设置的 动态状态。虽然前者更符合其他状态,但通常将视口和裁剪矩形状态设置为动态状态更方便,因为它为您提供了更大的灵活性。这非常常见,并且所有实现都可以处理此动态状态而不会产生性能损失。

当选择动态视口和裁剪矩形时,您需要为管线启用各自的动态状态

std::vector<VkDynamicState> dynamicStates = {
    VK_DYNAMIC_STATE_VIEWPORT,
    VK_DYNAMIC_STATE_SCISSOR
};

VkPipelineDynamicStateCreateInfo dynamicState{};
dynamicState.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
dynamicState.dynamicStateCount = static_cast<uint32_t>(dynamicStates.size());
dynamicState.pDynamicStates = dynamicStates.data();

然后您只需要在管线创建时指定它们的计数

VkPipelineViewportStateCreateInfo viewportState{};
viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
viewportState.viewportCount = 1;
viewportState.scissorCount = 1;

实际的视口和裁剪矩形将在稍后的绘制时设置。

使用动态状态,甚至可以在单个命令缓冲区中指定不同的视口和/或裁剪矩形。

在没有动态状态的情况下,需要使用 VkPipelineViewportStateCreateInfo 结构在管线中设置视口和裁剪矩形。这使得此管线的视口和裁剪矩形不可变。对这些值进行的任何更改都需要创建具有新值的新管线。

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

无论您如何设置它们,都可以在某些显卡上使用多个视口和裁剪矩形,因此结构成员引用它们的数组。使用多个需要启用 GPU 功能(请参阅逻辑设备创建)。

光栅化器

光栅化器获取由顶点着色器中的顶点形成的几何图形,并将其转换为片段,以便由片段着色器着色。它还执行 深度测试面剔除和裁剪测试,并且可以配置为输出填充整个多边形或仅边缘(线框渲染)的片段。所有这些都使用 VkPipelineRasterizationStateCreateInfo 结构进行配置。

VkPipelineRasterizationStateCreateInfo rasterizer{};
rasterizer.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
rasterizer.depthClampEnable = VK_FALSE;

如果 depthClampEnable 设置为 VK_TRUE,则超出近平面和远平面的片段将被钳制到它们,而不是丢弃它们。这在某些特殊情况下很有用,例如阴影贴图。使用此功能需要启用 GPU 功能。

rasterizer.rasterizerDiscardEnable = VK_FALSE;

如果 rasterizerDiscardEnable 设置为 VK_TRUE,则几何图形永远不会通过光栅化器阶段。这基本上禁用了对帧缓冲的任何输出。

rasterizer.polygonMode = VK_POLYGON_MODE_FILL;

polygonMode 确定如何为几何图形生成片段。以下模式可用

  • VK_POLYGON_MODE_FILL:用片段填充多边形区域
  • VK_POLYGON_MODE_LINE:多边形边缘绘制为线条
  • VK_POLYGON_MODE_POINT:多边形顶点绘制为点

使用填充以外的任何模式都需要启用 GPU 功能。

rasterizer.lineWidth = 1.0f;

lineWidth 成员很简单,它以片段数为单位描述线条的粗细。支持的最大线宽取决于硬件,任何粗于 1.0f 的线条都需要您启用 wideLines GPU 功能。

rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;
rasterizer.frontFace = VK_FRONT_FACE_CLOCKWISE;

cullMode 变量确定要使用的面剔除类型。您可以禁用剔除、剔除正面、剔除背面或两者都剔除。frontFace 变量指定要被视为正面的面的顶点顺序,可以是顺时针或逆时针。

rasterizer.depthBiasEnable = VK_FALSE;
rasterizer.depthBiasConstantFactor = 0.0f; // Optional
rasterizer.depthBiasClamp = 0.0f; // Optional
rasterizer.depthBiasSlopeFactor = 0.0f; // Optional

光栅化器可以通过添加常量值或根据片段的斜率对其进行偏置来更改深度值。这有时用于阴影贴图,但我们不会使用它。只需将 depthBiasEnable 设置为 VK_FALSE

多重采样

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;
multisampling.minSampleShading = 1.0f; // Optional
multisampling.pSampleMask = nullptr; // Optional
multisampling.alphaToCoverageEnable = VK_FALSE; // Optional
multisampling.alphaToOneEnable = VK_FALSE; // Optional

我们将在后面的章节中重新讨论多重采样,现在让我们保持禁用状态。

深度和模板测试

如果您正在使用深度和/或模板缓冲,那么您还需要使用 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;
colorBlendAttachment.srcColorBlendFactor = VK_BLEND_FACTOR_ONE; // Optional
colorBlendAttachment.dstColorBlendFactor = VK_BLEND_FACTOR_ZERO; // Optional
colorBlendAttachment.colorBlendOp = VK_BLEND_OP_ADD; // Optional
colorBlendAttachment.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; // Optional
colorBlendAttachment.dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO; // Optional
colorBlendAttachment.alphaBlendOp = VK_BLEND_OP_ADD; // Optional

此逐帧缓冲结构允许您配置第一种颜色混合方式。将要执行的操作最好使用以下伪代码进行演示

if (blendEnable) {
    finalColor.rgb = (srcColorBlendFactor * newColor.rgb) <colorBlendOp> (dstColorBlendFactor * oldColor.rgb);
    finalColor.a = (srcAlphaBlendFactor * newColor.a) <alphaBlendOp> (dstAlphaBlendFactor * oldColor.a);
} else {
    finalColor = newColor;
}

finalColor = finalColor & colorWriteMask;

如果 blendEnable 设置为 VK_FALSE,则来自片段着色器的新颜色将未经修改地传递。否则,将执行两个混合操作以计算新颜色。将结果颜色与 colorWriteMask 进行 AND 运算,以确定实际传递哪些通道。

使用颜色混合最常见的方式是实现 alpha 混合,我们希望根据新颜色的不透明度将其与旧颜色混合。然后应按如下方式计算 finalColor

finalColor.rgb = newAlpha * newColor + (1 - newAlpha) * oldColor;
finalColor.a = newAlpha.a;

这可以通过以下参数来实现

colorBlendAttachment.blendEnable = VK_TRUE;
colorBlendAttachment.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA;
colorBlendAttachment.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;
colorBlendAttachment.colorBlendOp = VK_BLEND_OP_ADD;
colorBlendAttachment.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE;
colorBlendAttachment.dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO;
colorBlendAttachment.alphaBlendOp = VK_BLEND_OP_ADD;

您可以在规范的 VkBlendFactorVkBlendOp 枚举中找到所有可能的操作。

第二个结构体引用所有帧缓冲的结构体数组,并允许您设置混合常量,您可以在上述计算中将其用作混合因子。

VkPipelineColorBlendStateCreateInfo colorBlending{};
colorBlending.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
colorBlending.logicOpEnable = VK_FALSE;
colorBlending.logicOp = VK_LOGIC_OP_COPY; // Optional
colorBlending.attachmentCount = 1;
colorBlending.pAttachments = &colorBlendAttachment;
colorBlending.blendConstants[0] = 0.0f; // Optional
colorBlending.blendConstants[1] = 0.0f; // Optional
colorBlending.blendConstants[2] = 0.0f; // Optional
colorBlending.blendConstants[3] = 0.0f; // Optional

如果您想使用第二种混合方法(按位组合),则应将 logicOpEnable 设置为 VK_TRUE。然后可以在 logicOp 字段中指定按位运算。请注意,这将自动禁用第一种方法,就好像您已为每个附加的帧缓冲将 blendEnable 设置为 VK_FALSE 一样!colorWriteMask 也将在此模式下使用,以确定帧缓冲中的哪些通道将实际受到影响。也可以禁用两种模式,就像我们在这里所做的那样,在这种情况下,片段颜色将未经修改地写入帧缓冲。

管线布局

您可以在着色器中使用 uniform 值,这些值是类似于动态状态变量的全局变量,可以在绘制时更改,以更改着色器的行为,而无需重新创建它们。它们通常用于将变换矩阵传递给顶点着色器,或在片段着色器中创建纹理采样器。

这些 uniform 值需要在管线创建期间通过创建 VkPipelineLayout 对象来指定。即使我们在未来的章节中才会使用它们,我们仍然需要创建一个空的管线布局。

创建一个类成员来保存此对象,因为我们将在稍后的时间点从其他函数引用它

VkPipelineLayout pipelineLayout;

然后在 createGraphicsPipeline 函数中创建对象

VkPipelineLayoutCreateInfo pipelineLayoutInfo{};
pipelineLayoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipelineLayoutInfo.setLayoutCount = 0; // Optional
pipelineLayoutInfo.pSetLayouts = nullptr; // Optional
pipelineLayoutInfo.pushConstantRangeCount = 0; // Optional
pipelineLayoutInfo.pPushConstantRanges = nullptr; // Optional

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

该结构还指定了推送常量,这是将动态值传递给着色器的另一种方式,我们可能会在以后的章节中介绍。管线布局将在程序的整个生命周期中被引用,因此应在最后销毁它

void cleanup() {
    vkDestroyPipelineLayout(device, pipelineLayout, nullptr);
    ...
}

结论

这就是所有固定功能状态的全部内容!从头开始设置所有这些工作量很大,但优点是我们现在几乎完全了解图形管线中发生的一切!这减少了因某些组件的默认状态不是您期望的那样而遇到意外行为的可能性。

但是在我们最终可以创建图形管线之前,还需要创建一个对象,那就是 渲染通道

C++ 代码 / 顶点着色器 / 片段着色器