Vulkan 学习笔记(十四)固定功能(Fixed functions)
旧的图形 API 为图形管线的大部分阶段提供了默认状态。而在 Vulkan 中,你必须对大多数管线状态进行显式声明,因为它会被“烘焙”(永久固化)到一个不可变的管线状态对象中。本章我们将填充所有结构体,以配置这些固定功能操作。
💡 固定功能:管线的“硬装”部分。这些 GPU 硬件里的专用电路负责处理顶点装配、光栅化、混合这类标准化流水线作业,干活效率极高,但没法像着色器那样靠写代码来自由编程。你只能通过配置开关和参数去指挥它干活。
动态状态
虽然大多数管线状态需要被烘焙到管线状态对象中,但实际上有一部分状态是无需重建管线就能在绘制时更改的,例如视口大小、线宽和混合常量等。如果你想使用动态状态(让这些属性保持灵活可变),就需要像下面这样填充一个 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();
这样一来,你在这里对这些值的静态配置就会被忽略,而你需要(且必须) 在绘制时实时指定这些数据。这种设置方式更加灵活,尤其对视口(Viewport)和裁剪(Scissor)状态来说非常常见,如果把它们都烘焙进管线状态,只会让设置变得无比繁琐。
🎮 动态状态:烘焙规则的“灵活例外条款”。有些状态,比如视口大小、裁剪矩形、混合常量,你们在创建管线时不必写死,而是可以标为“动态”。这样在录制命令缓冲时,可以直接通过 vkCmdSetViewport 这类命令现场改参数,而不用重建整个管线。虽然它比“烘焙版”状态多一丢丢运行时开销,但对于需要频繁切换视口的场景来说,这点代价比重新创建管线小太多了。
顶点输入
VkPipelineVertexInputStateCreateInfo 结构体描述了即将传递给顶点着色器的顶点数据的格式,主要包含两个方面:
- • 绑定(Bindings):数据之间的间距,以及数据是“逐顶点”还是“逐实例”的(参见几何实例化)。
- • 属性描述(Attribute descriptions):传递给顶点着色器的属性类型、从哪个绑定加载它们,以及起始偏移量是多少。
🌳 实例化:一只猴子画千遍的省流魔法。你想画一千个相同的石头,只需准备一份石头模型数据(顶点缓冲),再额外传一个“在哪儿画”(实例数据)的数组,就可以用一个绘制命令让 GPU 把这一千份石头统统渲染出来。
由于我们目前直接把顶点数据硬编码在顶点着色器里,所以暂时把该结构体设置成“无数据需要加载”,顶点缓冲区(Vertex Buffer)那章我们再回来啃这块硬骨头。
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; // 可选
输入装配
VkPipelineInputAssemblyStateCreateInfo 结构体会回答两个灵魂拷问:顶点数据将装配成什么样的几何图元,以及是否开启图元重启(Primitive Restart)功能。前者由 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:三角形条带,前一个三角形的后两个顶点成为下一个三角形的前两个顶点
🔺 图元:GPU 画画的最小几何单位。任何复杂模型,拆解到底都是点、线、三角形这些基本“图元”。它们是 GPU 能直接理解和操作的几何元素。
正常情况下,顶点按顺序从顶点缓冲区读取。但如果你用上了元素缓冲区(也就是索引缓冲区),就可以自行指定索引顺序,从而实现复用顶点之类的优化。而如果你想在 _STRIP 这类拓扑模式下“另起一行”,只需将 primitiveRestartEnable 成员设为 VK_TRUE,然后插入一个特殊索引值(0xFFFF 或 0xFFFFFFFF)就能断开当前的线段或三角形条带。
♻️ 图元重启:一种打断连续条带的特效技巧。画 TRIANGLE_STRIP 这种首尾相连的条带时,如果想“抬笔”另起一条,可以塞一个特殊索引值(通常是 0xFFFF)进去当分隔符。开启此功能后,就能用一个绘制命令画出多个不相连的条带。
我们这节的任务是画个三角形,所以选用以下结构体配置就万事大吉:
VkPipelineInputAssemblyStateCreateInfo inputAssembly{};inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;inputAssembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;inputAssembly.primitiveRestartEnable = VK_FALSE;
视口和裁剪
视口(Viewport)就像是在帧缓冲上画个框,决定了我们要把画面的哪些部分渲出来;而裁剪(Scissor)矩形则更进一步,把不想被像素数据碰到的区域直接“切掉”。换句话说,视口定义了“往哪儿画”,裁剪定义了“哪儿不准画”。这俩家伙必须紧密配合。
🖼️ 视口:坐标系的“翻译官”。它负责将顶点着色器输出的归一化坐标(NDC,范围 -1.0 到 1.0),映射成实际画到屏幕上哪个位置、占多大范围的像素坐标。
✂️ 裁剪:一个简单粗暴的“矩形过滤器”。它比视口更底层,直接在屏幕上划定矩形区域,凡在此区域外的像素碎片,一概丢掉(不测试、不混合,直接消失)。如果你想让渲染只影响屏幕的某个小窗口,用这个准没错。
我们之前把它们设为了动态状态,所以在管线创建时只需声明一下用到了几个视口和裁剪矩形就够了。
VkPipelineViewportStateCreateInfo viewportState{};viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;viewportState.viewportCount = 1;viewportState.scissorCount = 1;
光栅化
光栅化器会接过顶点着色器精心算出的几何图形,将其“压扁”并转换成一个个碎片(Fragment),也就是待上色的像素预备军。
🎨 片段:像素的“预备役”。光栅化器生成的所有数据(位置、颜色、深度等)统称为一个“片段”。它要经过深度、模板测试、颜色混合等一系列考验后,才能转正成为屏幕上的一个像素。简单理解:片段是“候选人”,像素是“正式工”。
与此同时,它还能玩出花活:既可以进行深度测试(判断谁在前谁在后)、面剔除(Face Culling,比如只保留三角形的正面),还能让场景在实体填充(VK_POLYGON_MODE_FILL)与线框模式之间自由切换。总之,VkPipelineRasterizationStateCreateInfo 结构体会帮你把这些安排得明明白白。
VkPipelineRasterizationStateCreateInfo rasterizer{};rasterizer.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;rasterizer.depthClampEnable = VK_FALSE;
如果 depthClampEnable 设为 VK_TRUE,那么被裁剪面推出去的超近或超远片段,会被直接拉到近平面或远平面上趴着,而不是简单扔掉。这功能在生成阴影贴图(Shadow Map)等特殊场合很有用,但需要开启对应的 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 特性。
rasterizer.cullMode = VK_CULL_MODE_BACK_BIT;rasterizer.frontFace = VK_FRONT_FACE_CLOCKWISE;
cullMode 是面剔除开关,决定是扔掉正面、背面、还是统统不扔。至于哪个面算“正面”,由 frontFace 说了算——顶点顺序是顺时针还是逆时针决定一切。我们这里设成剔除背面,并规定顺时针为正面。
🙈 面剔除:把看不见的背面对策掉,节省算力。渲染一个不透明的箱子,你永远看不到它的内壁和背面。开启面剔除后,GPU 会在光栅化前就把背对我们的三角形统统舍弃,显著提升渲染效率。
rasterizer.depthBiasEnable = VK_FALSE;rasterizer.depthBiasConstantFactor = 0.0f; // 可选rasterizer.depthBiasClamp = 0.0f; // 可选rasterizer.depthBiasSlopeFactor = 0.0f; // 可选
光栅化器还可以给深度值加个偏移,用来对付阴影痤疮(Shadow Acne)这种烦人玩意儿。不过现在我们不需要,关掉即可。
🕳️ 阴影痤疮:深度缓冲精度不足留下的“麻子脸”。绘制阴影时,由于阴影贴图的精度不够,物体表面可能会被自己的阴影贴图误认为是“被遮挡区域”,从而出现一圈圈难看的条纹或斑点。光栅化器里的“深度偏移”(Depth Bias)就是用来对付这个问题的特效药。
多重采样
多重采样抗锯齿(MSAA)是抗锯齿的一种手段,用多个采样点混合边缘像素,让画面告别狗牙。想用?先开个 GPU 特性,再把 VkPipelineMultisampleStateCreateInfo 配好。
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; // 可选multisampling.pSampleMask = nullptr; // 可选multisampling.alphaToCoverageEnable = VK_FALSE; // 可选multisampling.alphaToOneEnable = VK_FALSE; // 可选
🔬 多重采样抗锯齿 (MSAA):性能与画质的“折中大师”。在每个像素内部放置多个采样点(比如 4x MSAA 就有 4 个点)。判断三角形覆盖时,会统计它盖住了几个采样点,并根据覆盖率来混合边缘颜色,从而实现平滑的视觉效果。它的开销比超级采样(SSAA)低得多,是实时渲染的中流砥柱。
因为我们暂时不碰多重采样,设置了 rasterizationSamples 为 1(每个像素只采一次),等下章再和它斗智斗勇。
深度和模板测试
若要启用深度/模板测试,除了准备相应的图像,还得把 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;
colorWriteMask 好比颜色通道的看门大爷,决定了哪些颜色通道(RGBA)能被写进帧缓冲。我们让它大门敞开,全部放行。
✏️ 颜色写入掩码:RGBA 通道的交通管制员。它控制最终能否把某个颜色分量写入到帧缓冲。比如,你可以让它只写入 RGB 通道而屏蔽掉 Alpha 通道,非常适用于需要写入深度信息但又不想污染颜色缓冲等特殊效果场景。
colorBlendAttachment.srcColorBlendFactor = VK_BLEND_FACTOR_ONE; // 可选colorBlendAttachment.dstColorBlendFactor = VK_BLEND_FACTOR_ZERO; // 可选colorBlendAttachment.colorBlendOp = VK_BLEND_OP_ADD; // 可选colorBlendAttachment.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; // 可选colorBlendAttachment.dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO; // 可选colorBlendAttachment.alphaBlendOp = VK_BLEND_OP_ADD; // 可选
VkPipelineColorBlendStateCreateInfo 则掌管全局的混合和逻辑运算:
VkPipelineColorBlendStateCreateInfo colorBlending{};colorBlending.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;colorBlending.logicOpEnable = VK_FALSE;colorBlending.logicOp = VK_LOGIC_OP_COPY; // 可选colorBlending.attachmentCount = 1;colorBlending.pAttachments = &colorBlendAttachment;colorBlending.blendConstants[0] = 0.0f; // 可选colorBlending.blendConstants[1] = 0.0f; // 可选colorBlending.blendConstants[2] = 0.0f; // 可选colorBlending.blendConstants[3] = 0.0f; // 可选
你或许想问,如果同时开启了逻辑运算和混合会怎样?很简单,逻辑运算会直接顶掉混合的效果,就像你决定用计算器算完账,就不再扳手指头了。除非你用的是那些能同时进行的骚操作(高级混合扩展功能),但在标准 Vulkan 里,开了逻辑运算,混合就自动退位。
🧮 逻辑操作:直接对像素的二进制位动手脚。在颜色混合阶段,你可以选择不混合、只混合,或者对源和目标的颜色值执行逻辑操作。它会按位进行与、或、异或这类运算。注意: 在标准 Vulkan 中,一旦 logicOpEnable 为 VK_TRUE,颜色混合就会乖乖让位,逻辑操作的输出结果将直接覆盖。
最后看一眼我们创建好的固定函数结构全家福,再瞅一眼之前配好的着色器阶段和管线布局,这下我们终于可以召唤真正的图形管线了。
🍞 烘焙:管线状态的“速冻”定型。你在创建管线对象时设定的所有状态(比如混合模式、光栅化参数)都会被永久固化进 VkPipeline 这个不可变对象里。好处是 GPU 以后调用时能直接拿来用,零开销;坏处是你的管线状态只要想改一丁点,都得重新造一个管线对象。Vulkan 这种“一锤子买卖”的作风,让驱动程序卸下了预测状态的沉重包袱,换取了极致的运行时性能。