很多朋友学到 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::vector<VkVertexInputAttributeDescription> 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(标准化设备坐标)映射到帧缓冲的哪块区域。比如窗口是 800x600,viewport 一般就设置成原点 (0, 0)、宽 800、高 600。scissor则是一个裁剪窗口,只有落在 scissor 矩形内的片元才会被保留,外面的直接丢掉。
这里有一个很值得注意的坑:视口和裁剪矩形的宽高是浮点数和整数的区别。VkViewport的width和height是 float,VkRect2D的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_EQUAL |
depthBoundsTestEnable | 是否限定深度值范围,主要用于特殊效果 |
这里的细节在于depthCompareOp的选择。OpenGL 时代大家习惯用GL_LESS,Vulkan 里我更推荐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. 把所有固定阶段装进一个 CreateInfo
5.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、描边、半透明,本质上都是在跟这些阶段打交道。另外一个建议:把“创建管线”相关的代码封装成一个函数,参数化所有固定功能状态,之后调试各种效果时只需要传不同参数,省下大量重复劳动。希望这篇内容能帮你少查半小时文档,多留点时间研究真正有意思的渲染效果。