
很多朋友学到 Vulkan 管线的可编程阶段写完了顶点着色器和片元着色器就以为万事大吉。结果在创建VkGraphicsPipelineCreateInfo的时候突然冒出一大堆结构体要填顶点输入、输入装配、光栅化、深度模板、颜色混合。这就是 Vulkan 里常说的固定功能阶段Fixed Functions也是这个系列第 11 章的核心内容。说句实话固定功能阶段不写代码但比写着色器更容易翻车一个cullMode填错三角形直接消失着色器再对也白搭。这篇文章从实际创建管线的顺序出发把每个固定功能阶段拆开讲透参数为什么这么配、踩过哪些坑一次说清。适合刚学完着色器、准备组装第一条图形流水线的朋友参考。1. 固定功能阶段到底“固定”在哪1.1 可编程阶段与固定功能阶段的分工Vulkan 图形流水线由多个阶段组成按可编程性可以分成两类。顶点着色器、细分着色器、几何着色器、片元着色器这些属于可编程阶段执行的是你写的 GLSL/HLSL 编译出来的 SPIR-V 代码逻辑完全由你控制。而输入装配、光栅化、深度模板测试、颜色混合这些阶段没有给你一行代码的编写入口只能通过创建管线时传入的结构体去“配置”这就是固定功能阶段。为什么叫“固定”因为现代 GPU 里这些阶段本来就是芯片上已经做死的硬件逻辑比如光栅化单元功能就是这么个功能你不能给它编程只能告诉它“按什么规则干活”。Vulkan 的设计目标就是贴近硬件、减少驱动猜测所以它把这些硬件的控制方式原封不动地暴露给你用结构体填参数只是换个方式使用硬件能力。1.2 Vulkan 与旧式状态机的最大区别用过 OpenGL 的朋友可能有印象旧式 API 的管线状态是全局的、隐式的你在任何地方改个深度测试开关随后所有的渲染都可能受影响。Vulkan 把这套逻辑干掉了它把所有固定功能阶段的配置随同着色器阶段、渲染目标格式等信息打包成不可变的VkPipeline对象。创建完管线后这些状态就被“冻结”你不能再修改只能创建一条新管线。这个设计带来的好处非常明显驱动拿到一条完整的管线描述可以在创建时就做好所有优化不用再像 OpenGL 那样在运行时频繁校验和切换状态。坏处就是创建管线时你必须一次把所有状态说清楚。很多人第一个 Vulkan 程序黑屏往往不是着色器写错而是某个固定功能阶段漏填或者填错。1.3 一条图形流水线的固定阶段全貌为了方便后面对照我用表格把常见的固定功能阶段和对应结构体列出来后面再逐个拆解阶段作用对应结构体顶点输入描述顶点数据从缓冲区怎么取VkPipelineVertexInputStateCreateInfo输入装配定拓扑类型按什么规则连成图元VkPipelineInputAssemblyStateCreateInfo细分阶段可选控制细分曲面相关状态VkPipelineTessellationStateCreateInfo视口裁剪设置视口矩形和裁剪矩形VkPipelineViewportStateCreateInfo光栅化把图元转成片元剔除、偏移VkPipelineRasterizationStateCreateInfo深度模板测试控制深度和模板比较、写入VkPipelineDepthStencilStateCreateInfo颜色混合控制片元颜色与颜色缓冲的混合VkPipelineColorBlendStateCreateInfo需要留意细分阶段是可选的只有启用了细分着色器才需要关注。最常用、最容易出问题的是输入装配、光栅化、深度模板、颜色混合这四块。接下来的内容就按实际创建管线的逻辑顺序来走。2. 顶点怎么进流水线顶点输入与输入装配2.1 顶点绑定与属性描述是两件事第一个要配置的是VkPipelineVertexInputStateCreateInfo它做的事就是告诉显卡顶点数据存在哪个缓冲区、步长多少、每个属性在哪个位置、什么格式。很多人一开始搞不清binding和attribute的区别简单说binding决定“数据从哪里取每隔多大取一个顶点”attribute决定“取出来的这块数据怎么切分成属性”。来个最简单案例顶点包含位置vec3和颜色vec4。先定义绑定VkVertexInputBindingDescription binding{}; binding.binding 0; binding.stride sizeof(Vertex); // 每个顶点之间间隔多少字节 binding.inputRate VK_VERTEX_INPUT_RATE_VERTEX;这里stride就是 Vertex 结构体的大小。inputRate有两个值可选VK_VERTEX_INPUT_RATE_VERTEX表示每个顶点读取一份数据另一个是VK_VERTEX_INPUT_RATE_INSTANCE用于实例化渲染表示每个实例读取一份数据重复顶点共用。实例化在大量绘制相同模型时非常常用。接下来是属性描述std::vectorVkVertexInputAttributeDescription attrs(2); attrs[0].location 0; // 对应 shader 里的 layout(location 0) attrs[0].binding 0; // 对应第 0 个绑定 attrs[0].format VK_FORMAT_R32G32B32_SFLOAT; attrs[0].offset offsetof(Vertex, pos); attrs[1].location 1; attrs[1].binding 0; attrs[1].format VK_FORMAT_R32G32B32A32_SFLOAT; attrs[1].offset offsetof(Vertex, color);这里有个比较容易忽略的点format必须和 shader 中的layout(location x) in vec3 pos严格对应。R32G32B32_SFLOAT对应 vec3如果是 vec2 就得用R32G32_SFLOAT数据类型和长度都得匹配不匹配轻则数据错乱重则一堆 validation error 刷屏。2.2 顶点布局错误会以什么形式暴露我说一个自己踩过很多次的坑结构体里字段顺序和属性偏移写错了导致顶点位置错位。最典型的症状是渲染出来的几何体完全变形三角形像被揉碎了一样乱飞而不是整体挪动。出现这种情况第一时间查stride和offset不要先去怀疑 shader。还有一种更隐蔽的错法stride填了sizeof(float) * 3而实际结构体里还有颜色数据结果每个顶点的位置都被读歪了一段距离画面看起来就像模型“长刺”了。2.3 拓扑类型决定了图元怎么连输入装配阶段用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顶点依次生成连续的三角形条带VK_PRIMITIVE_TOPOLOGY_TRIANGLE_FAN以一个顶点为中心扇出三角形如果模型中包含索引缓冲primitiveRestartEnable有可能会用到。它开启后可以使用一个特殊索引值通常是0xFFFFFFFF或0xFFFF取决于索引类型来中断当前的 strip实现一个顶点缓冲里放多个独立线段或条带的效果。平时绘制普通三角形网格primitiveRestartEnable保持VK_FALSE就行真用到 sprite 图集、线条动画这类场景才需要。3. 视口、裁剪矩形与光栅化3.1 视口和裁剪矩形是两个不同概念到这一步我们已经把一堆顶点变成了三角形的位置但还没走到像素层面。VkPipelineViewportStateCreateInfo就是决定“从哪个窗口区域去看这些三角形”的。这里有viewport和scissor两个矩形新手经常混淆。viewport决定把 NDC标准化设备坐标映射到帧缓冲的哪块区域。比如窗口是 800x600viewport 一般就设置成原点 (0, 0)、宽 800、高 600。scissor则是一个裁剪窗口只有落在 scissor 矩形内的片元才会被保留外面的直接丢掉。这里有一个很值得注意的坑视口和裁剪矩形的宽高是浮点数和整数的区别。VkViewport的width和height是 floatVkRect2D的extent是整数。如果你设置了 viewport但忘了设置 scissor那么 scissor 默认值不安全很多实现里会导致什么都画不出来或者只画一小块。要是所有渲染结果都丢失可以优先检查 scissor 的extent是不是覆盖了整个渲染目标。3.2 光栅化状态就是通向像素的大门VkPipelineRasterizationStateCreateInfo是最重要的固定功能结构体之一它控制着图元到片元这一步。结构体里这些字段每个都不白给rasterizerDiscardEnable设为VK_TRUE后整个光栅化阶段被跳过片元不会生成。这个功能在做阴影贴图的深度预 pass、纯计算用途时很有用可以省掉大量片元着色器的开销。depthClampEnable默认情况下超出近远裁剪面的顶点会被裁剪掉。开启后这些深度值会被“钳制”到最近的可用深度值不裁剪。常用于阴影贴图让光源视角外的几何体也能写入深度。polygonMode支持VK_POLYGON_MODE_FILL填充、VK_POLYGON_MODE_LINE线框、VK_POLYGON_MODE_POINT点。线框模式调试时特别好用但需要设备支持fillRectangleNonSolid特性不是所有设备默认开启。cullMode背面剔除模式是去掉逆时针还是顺时针的三角形。frontFace定义哪个朝向算正面VK_FRONT_FACE_COUNTER_CLOCKWISE还是CLOCKWISE。lineWidth线宽大于 1.0 需要wideLines特性支持一般移动端没有别乱设置。3.3 剔除配置错误就意味着黑屏很多人的第一个三角形画不出来问题就出在cullMode和frontFace的搭配上。我在调试一个旋转立方体的时候把frontFace从逆时针改成顺时针结果整个立方体消失了。原因很简单顶点数据是逆时针绕序定义的正面我却告诉显卡“顺时针是正面”结果所有三角形都被当成背面剔掉了。几个经验初次调试时cullMode直接设成VK_CULL_MODE_NONE先把画面跑起来再开剔除优化。开背面剔除后如果画面异常检查frontFace是否和顶点绕序一致。线框模式调试时别开背面剔除否则被剔除的部分你根本看不到。除了这两个depthBias也值得知道。它用来在深度值上人为加偏移解决阴影贴图中“深度自遮挡”造成的条纹噪声。具体参数包括depthBiasConstantFactor、depthBiasSlopeFactor、depthBiasClamp比例因子作用在多边形的深度斜率上。这个偏置的经验值不是固定的跟场景规模和深度精度都有关系通常从很小的值开始试。4. 深度、模板与颜色混合像素怎么落进缓冲4.1 深度测试和深度写入的配合关系VkPipelineDepthStencilStateCreateInfo控制深度/模板测试。逐字段看核心就两个开关加一个比较操作字段作用depthTestEnable是否执行深度测试depthWriteEnable通过测试后是否把新深度写入深度缓冲depthCompareOp比较规则如VK_COMPARE_OP_LESS_OR_EQUALdepthBoundsTestEnable是否限定深度值范围主要用于特殊效果这里的细节在于depthCompareOp的选择。OpenGL 时代大家习惯用GL_LESSVulkan 里我更推荐VK_COMPARE_OP_LESS_OR_EQUAL否则两个几何体深度完全相同时后画的会被前一个的深度测试挡掉可能导致闪烁或拼接缝问题。depthWriteEnable的选择也很讲究。渲染半透明物体时通常需要关闭深度写入否则半透明物体之间、半透明与不透明物体之间的混合会出现错误遮挡。一个常见场景先画不透明物体关闭深度写入再画半透明物体能大幅减少透明排序问题。4.2 模板测试的实际使用场景模板测试在结构体中和深度测试捆在一起很多人第一次学 Vulkan 时会忽略它。模板缓冲本身是一张额外的缓冲每个像素存一个整数。测试可以和你设定的值做比较通过后还可以更新这个值。常用场景包括描边效果、遮罩、轮廓高亮、反射平面范围限制。配置模板需要三个层面的配合渲染目标必须带模板格式如VK_FORMAT_D24_UNORM_S8_UINT、管线的模板比较和更新参数、以及每次绘制前的vkCmdClearAttachments或 clear value 设置。我在项目里做“选中物体描边”时第一遍只写模板标记角色区域第二遍用模板测试过滤出边缘区域做一个放大描边思路清晰但配置起来确实比深度测试要繁琐。4.3 颜色混合的状态必须逐个 attachment 设置颜色混合配置分成两级全局的VkPipelineColorBlendStateCreateInfo和每个颜色附件独立的VkPipelineColorBlendAttachmentState。一个容易忽视的点是即使你这辈子不打算混合也建议把VkPipelineColorBlendAttachmentState显式填好默认值不保证为关闭状态。开启混合后混合公式为finalColor.rgb srcColor * srcColorBlendFactor dstColor * dstColorBlendFactor finalColor.a srcAlpha * srcAlphaBlendFactor dstAlpha * dstAlphaBlendFactor最常见的半透明混合配置是attachment.blendEnable VK_TRUE; attachment.srcColorBlendFactor VK_BLEND_FACTOR_SRC_ALPHA; attachment.dstColorBlendFactor VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; attachment.colorBlendOp VK_BLEND_OP_ADD; attachment.srcAlphaBlendFactor VK_BLEND_FACTOR_ONE; attachment.dstAlphaBlendFactor VK_BLEND_FACTOR_ZERO; attachment.alphaBlendOp VK_BLEND_OP_ADD;这个公式的意思就是俗话说的“前景半透明叠在背景上”。很多人在混合上踩的坑是片元着色器输出的 alpha 值早就被预设逻辑写死成 1.0 了混合配了也白配。要检查混合效果得先从 shader 的输出入手确认 alpha 真的在变化。另一个坑是colorWriteMask。如果写代码时图省事把它设成 0那 geming 渲染结果会整个消失因为所有颜色都被屏蔽了。我见过有人找了一下午黑屏原因最后发现就是这里写了个 0。4.4 动态状态为什么那么香固定功能阶段配置好后就锁死了但实际渲染中很多状态需要在不同绘制之间切换比如视口大小随窗口变化、泛光混合因子变化。如果每个状态都重新创建管线会产生大量管线对象浪费性能。Vulkan 提供了动态状态机制把某个状态标记为动态后创建管线时不再读取对应结构体而是在命令缓冲区录制时通过vkCmdSet*函数设置。需要启用哪些动态状态在VkPipelineDynamicStateCreateInfo里声明VkDynamicState dynamicStates[] { VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR, }; VkPipelineDynamicStateCreateInfo dynamicState{}; dynamicState.sType VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO; dynamicState.dynamicStateCount 2; dynamicState.pDynamicStates dynamicStates;一旦把 viewport 和 scissor 声明为动态创建管线时就能给一个空的 viewport state甚至全 0然后在录制命令时调用vkCmdSetViewport(cmdBuffer, 0, 1, viewport); vkCmdSetScissor(cmdBuffer, 0, 1, scissor);窗口 resize 时只需要重新设置视图和裁剪矩形的值不需要重新创建管线。这种做法在窗口适配、多视口渲染、VR 渲染里都是标配。不过注意动态状态声明之后如果你忘了在渲染前用vkCmdSet*设置对应状态行为是未定义的大概率黑屏或者画错区域。我自己的习惯是能动态的都动态这样代码更灵活但代价是每次绘制前要记得多传几个状态。5. 把所有固定阶段装进一个 CreateInfo5.1 一个完整管线的代码骨架到这里每个阶段都准备好了接下来就是把它们整体塞进VkGraphicsPipelineCreateInfo。一个稍微完整的骨架长这样VkGraphicsPipelineCreateInfo pipelineInfo{}; pipelineInfo.sType VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; pipelineInfo.stageCount 2; pipelineInfo.pStages stages; // 两个 shader 阶段 pipelineInfo.pVertexInputState vertexInput; pipelineInfo.pInputAssemblyState inputAssembly; pipelineInfo.pViewportState viewportState; pipelineInfo.pRasterizationState rasterizer; pipelineInfo.pMultisampleState multisample; // MSAA 相关一般先填默认 pipelineInfo.pDepthStencilState depthStencil; pipelineInfo.pColorBlendState colorBlend; pipelineInfo.pDynamicState dynamicState; pipelineInfo.layout pipelineLayout; pipelineInfo.renderPass renderPass; pipelineInfo.subpass 0;每一个指针都得指向有效的结构体除了可选阶段细分、管道缓存可以为空。pMultisampleState经常被新手留空这是不对的。哪怕你暂时不用 MSAA也必须提供一个VkPipelineMultisampleStateCreateInfo并将rasterizationSamples设为VK_SAMPLE_COUNT_1_BIT。在组装过程中还有两个重要的关联pipelineLayout和renderPass。pipelineLayout负责和 shader 里的 descriptor set、push constant 对接renderPass定义了渲染目标格式和子 pass 布局。这两个对象在创建管线之前就得准备好它们和管线状态之间存在复杂的兼容性要求附件的格式必须和 render pass 里声明的一致否则创建直接失败。5.2 render pass 兼容性是黑屏重灾区关于 render pass 兼容性我多说一句。就算你用同一个 render pass 对象如果 subpass 里 attachment 格式定义不一致也是不行的。最常见的错误是你在交换链里用的颜色格式是VK_FORMAT_B8G8R8A8_UNORM但在 render pass 定义里写成了VK_FORMAT_R8G8B8A8_UNORM创建管线时 Vulkan 不会给你任何有意义的报错validation layer 可能也只在某个角落提醒一句结果就是渲染出来颜色通道错乱或者 pipeline 创建失败。因此我建议做一个工具函数直接从交换链图像格式推导 render pass 的 attachment 格式避免手写两遍出错。5.3 管线缓存与管线派生VkPipelineCache是一个很实在的优化点。它的原理非常简单创建管线时驱动会做大量编译优化把中间结果缓存下来。下次创建相同状态的管线时直接从缓存加载大幅缩短创建时间。在程序启动阶段创建几百条相似的管线时效果特别明显。用法不复杂创建VkPipelineCacheCreateInfo得到一个VkPipelineCache创建管线时在VkGraphicsPipelineCreateInfo里填pipelineCache。如果你分享缓存数据也允许后续加载从而进一步减少冷启动的编译时间。还有一个叫“管线派生”的机制flags设为VK_PIPELINE_CREATE_DERIVATIVE_BIT并指定basePipelineHandle可以让新管线继承基础管线的部分状态同样是为了减少编译量。但这个功能不是所有驱动都支持得很好如果不确定可以先用 pipeline cache效果已经足够。5.4 固定功能阶段常见问题速查表症状可能原因排查方向渲染结果全黑/空白scissor 太小或未设置检查视口和裁剪矩形是否覆盖完整目标三角形消失背面剔除配置错误检查 cullMode 与 frontFace模型几何体扭曲stride 或 offset 错误检查顶点绑定/属性描述颜色完全不对交换链格式与 render pass 格式不匹配统一颜色格式深度闪烁depthCompareOp 或清除值不匹配统一深度测试规则与 clear value半透明排序异常depthWriteEnable 未按需关闭不透明/半透明物体分组渲染创建管线报错attachment 格式不兼容用vkGetPhysicalDeviceSurfaceFormatsKHR检查格式屏幕只画了一半viewport 中心偏移检查 viewport 的宽高和位置这些基本覆盖了初学阶段能遇到的绝大部分“画不出来”问题。遇到问题第一反应应该去开 validation layer第二反应检查这些固定功能配置污染源比 shader 的概率大得多。6. 一次调了两小时的黑屏排错实录6.1 画面为什么突然没了这里分享一个真实的排错经历正好证明固定功能阶段的坑有多隐蔽。当时我在做一个旋转立方体代码写完编译通过validation layer 也没有报错但画面就是黑的。一开始我先怀疑 shader仔仔细细看了一遍 VS/FS 的代码没问题。又怀疑 uniform buffer 更新时序也没问题。最后都快准备删掉重写了突然意识到前一次调试时为了让线框更明显我把frontFace改成了VK_FRONT_FACE_CLOCKWISE但顶点数据的绕序是逆时针。结果就是所有三角形都在但全部被当成背面被cullMode踢掉了。改回VK_FRONT_FACE_COUNTER_CLOCKWISE的瞬间立方体就出现了。6.2 错误发生的底层逻辑这件事让我彻底理解了cullMode和frontFace的组合逻辑。Vulkan 所谓的“正面”“背面”完全取决于顶点在 NDC 空间里呈现的绕序。如果frontFace指定逆时针为正面那么顺时针的三角形就是背面cullMode选择剔除背面后逆时针三角形保留顺时针被丢。我改乱了frontFace等于把所有逆时针三角形都归为了背面自然什么都看不见。这个问题 shader 输出完全正常逻辑上传数据也正确唯一坏掉的就是那一个枚举值。6.3 把排查顺序刻进肌肉记忆经过这次事故我给自己定了一套排查顺序分享出来开 validation layer先看有没有 error 或者 warning。检查 viewport 和 scissor 是否都在有效范围内。检查 cullMode/frontFace 是否与顶点绕序匹配。检查顶点 bind/attribute 的 stride 和 offset。最后才去怀疑 shader 和渲染数据。这套顺序看起来普通但每一条都有血泪教训。多数时候问题根本不复杂就藏在某个被忽略的枚举值里。写在最后说真的Vulkan 的学习曲线陡峭但固定功能阶段其实不是难而是琐碎。只要把每个 CreateInfo 的字段原理搞清楚出错的时候能明白地往哪个方向排查后面写渲染框架就会顺很多。我个人在实际操作中的体会是花半天时间把这一堆固定功能配置理清楚远比急着写 fancy 的渲染效果更值得因为几乎所有高级特效——阴影、HDR、描边、半透明本质上都是在跟这些阶段打交道。另外一个建议把“创建管线”相关的代码封装成一个函数参数化所有固定功能状态之后调试各种效果时只需要传不同参数省下大量重复劳动。希望这篇内容能帮你少查半小时文档多留点时间研究真正有意思的渲染效果。