固定功能

旧的图形 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

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

输入汇编

`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`,则可以使用 `0xFFFF` 或 `0xFFFFFFFF` 的特殊索引来分解 `_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)` 到 `(宽度, 高度)`,在本教程中也将是这种情况。

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;

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

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

视口定义从图像到帧缓冲区的转换,而剪裁矩形定义了实际存储像素的区域。任何超出剪裁矩形范围的像素都将被光栅化器丢弃。它们的作用类似于过滤器而不是转换。区别如下所示。请注意,左侧的剪裁矩形只是导致该图像的许多可能性之一,只要它大于视口即可。

viewports scissors

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

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);
    ...
}

结论

至此,所有固定功能状态都已讲解完毕!从头开始设置所有这些确实需要大量工作,但好处是我们现在几乎完全了解图形管线中发生的一切!这降低了遇到意外行为的可能性,因为某些组件的默认状态可能不是您所期望的那样。

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