很长一段时间没写Vulkan了,今天把“绘制三角形”系列的第二篇补上。这一篇聚焦在setup阶段最容易被新手绕晕的两个概念:Physical devices(物理设备)和queue families(队列族)。很多人一上来照着教程抄代码,以为“枚举设备”就是拿个列表,然后挑一个名字带“NVIDIA”就算完事。但实际上,这一步直接决定了你后续能否画出一个三角形,也决定了你的Vulkan程序会不会在别人的机器上翻车。
如果你正在学习Vulkan基础,或者刚写完实例(VkInstance)创建、正发愁接下来怎么选GPU,这篇文章就是为你准备的。我会把物理设备怎么枚举、队列族到底是什么、逻辑设备怎么创建,以及我在实际项目里踩过的一些坑,全部用大白话加可运行代码讲清楚。看完你不仅能跑通这段代码,还能明白每一步背后的设计逻辑。
1. 先弄清楚“物理设备和队列族”到底是怎么回事
1.1 Vulkan设备抽象背后的“为什么”
Vulkan和OpenGL天生就是两种思路。OpenGL里你只要有一个上下文(Context),然后好像什么显卡就会被自动选好,底层怎么调度完全不用管,但换来的是驱动帮你做了太多决策,性能上限被锁死了。Vulkan为了做到极致可控,把“设备”这个概念拆成两层:物理设备是你电脑里真实的那块GPU,逻辑设备则是对物理设备上某些能力的一种“租约”式封装。
为什么要拆?因为现代系统里可能同时存在好几块不同工作性质的GPU。你有一块带核显的CPU,可能还有一张独立显卡,甚至某些平台还有专门负责媒体编解码的硬件单元。如果只给一个“默认设备”的抽象,你就永远无法在代码里区分“我到底是在核显上跑还是在独显上跑”。Vulkan的做法是让你自己枚举、自己判断、自己选。选完之后,你并不是直接拿物理设备来提交命令,而是通过创建一个逻辑设备(VkDevice)——逻辑设备相当于你向这块物理设备索要的一组能力(队列、扩展、特性),之后所有命令都以逻辑设备为准。
1.2 物理设备、逻辑设备、队列、队列族四者的关系
这里我打个比方。物理设备是一栋办公楼,办公楼的各个楼层有不同部门的工位,每个部门能干不同的活,有一个部门专门处理图像绘制,另一个部门专门处理数据搬运。这个“部门”就是队列族(Queue Family)。部门里的每一个工位就是一个队列(Queue)。而逻辑设备是你代表自己的程序,跟这栋楼签了一份“工位租赁协议”,协议上写明白你要租哪个部门、租几个工位。
映射到Vulkan术语:
- 物理设备(VkPhysicalDevice):由Vulkan实例枚举出来的一个句柄,代表系统中的一块GPU。
- 逻辑设备(VkDevice):通过vkCreateDevice创建,用来提交命令、创建资源的核心句柄。
- 队列族(Queue Family):物理设备上的一组队列集合,具有相同的属性,例如支持图形操作、或者只支持内存传输。
- 队列(VkQueue):实际提交命令缓冲区的通道,由逻辑设备在某个队列族下创建出来。
命令提交流程是:你写一个命令缓冲区,然后交给一个队列,队列把它所在的队列族对应的硬件单元执行。所以如果没有正确选取支持图形绘制的队列族,你的三角形渲染命令根本没地方可去。
1.3 绘制三角形的场景下,我们到底需要什么
我们现在的目标是画一个最简单的三角形,不涉及计算、不做复杂的异步传输、也不碰视频解码。那么对物理设备的需求其实只有三条:
- 物理设备必须存在,且至少有一个队列族支持
VK_QUEUE_GRAPHICS_BIT——能画图的部门。 - 物理设备必须支持我们交换链需要的扩展——确切说是
VK_KHR_swapchain,否则你无法把渲染结果显示到窗口。 - 逻辑设备创建后,我们能通过
vkGetDeviceQueue拿一个图形队列,之后用这个队列提交绘制命令。
听起来简单,但繁琐就繁琐在第一步:你必须遍历所有物理设备,逐个检查它们的队列族属性和扩展支持,这一套流程漏一步都会导致程序在别人机器上崩得莫名其妙。
2. 物理设备枚举:把系统里的GPU都挖出来
2.1 获取设备数量:先问“有几个”
在Vulkan里,获取物理设备列表跟获取实例扩展列表的套路一模一样,永远是两步走。第一次调用vkEnumeratePhysicalDevices时传入一个空指针给pPhysicalDevices,函数会返回设备数量和VK_INCOMPLETE或VK_SUCCESS。然后你根据返回的数量分配数组,再调用一次,把设备句柄填进去。
uint32_t deviceCount = 0; vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr); if (deviceCount == 0) { throw std::runtime_error("连一块支持Vulkan的GPU都找不到"); } std::vector<VkPhysicalDevice> devices(deviceCount); vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data());这里有个细节值得多说一句:vkEnumeratePhysicalDevices返回的设备顺序不是固定的,不同驱动、不同平台可能顺序都不同。所以千万不要认为devices[0]一定是独立显卡。关于这一点,我们在第5节还会继续聊。
2.2 从“候选名单”到“设备信息”:读取属性与特性
拿到一坨VkPhysicalDevice句柄后,光有名字没用,必须读取设备属性和特性。属性是硬件层面的固定信息,比如设备名称、设备类型、驱动版本、管线限制等;特性则是硬件支持的可选功能开关,比如几何着色器、独立纹理等。
VkPhysicalDeviceProperties deviceProperties; VkPhysicalDeviceFeatures deviceFeatures; vkGetPhysicalDeviceProperties(device, &deviceProperties); vkGetPhysicalDeviceFeatures(device, &deviceFeatures);初学者最容易忽略的一点是:VkPhysicalDeviceFeatures返回里的每个布尔字段表示硬件基础支持,不代表你可以在创建逻辑设备时随便请求所有特性。如果你在vkCreateDevice里启用了某个硬件不支持的特性,验证层会直接报错。所以一般做法是:先判断deviceFeatures.geometryShader之类的字段,再决定要不要在逻辑设备中启用对应特性。
2.3 给GPU打分:怎么挑出最合适的那一张
很多教程会写一个isDeviceSuitable函数,但我建议不要只做一个布尔判断,而是用一个分数来打分。因为现实世界的情况很复杂:你可能有一块核显+一块独显,独显当然更强;但有时用户的机器上只有一块集成的GPU,你也不能直接放弃,因为人家照样能画三角形。
我的评分策略:
- 优先选择离散GPU(
VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU),因为它通常性能最强。但这只是“通常”,某些笔记本的独显可能因为散热限制反而不如核显稳定。 - 检查设备是否支持交换链扩展,如果支持就给一个很高的加分。不支持直接判死刑。
- 检查是否有图形队列族,有就保证能绘制。如果连图形队列族都没有,说明这个设备根本无法用来做传统渲染,直接排除。
- 把设备名称、类型、队列族信息打印出来,便于调试。
示例判断函数:
int rateDeviceSuitability(const VkPhysicalDevice& device) { VkPhysicalDeviceProperties props; VkPhysicalDeviceFeatures features; vkGetPhysicalDeviceProperties(device, &props); vkGetPhysicalDeviceFeatures(device, &features); int score = 0; // 扩展支持检查 uint32_t extensionCount = 0; vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, nullptr); std::vector<VkExtensionProperties> availableExtensions(extensionCount); vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, availableExtensions.data()); bool swapchainSupported = false; for (const auto& ext : availableExtensions) { if (strcmp(ext.extensionName, VK_KHR_SWAPCHAIN_EXTENSION_NAME) == 0) { swapchainSupported = true; break; } } if (!swapchainSupported) return -1; // 队列族检查 uint32_t queueFamilyCount = 0; vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, nullptr); std::vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount); vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, queueFamilies.data()); bool graphicsQueueAvailable = false; for (const auto& qf : queueFamilies) { if (qf.queueFlags & VK_QUEUE_GRAPHICS_BIT) { graphicsQueueAvailable = true; break; } } if (!graphicsQueueAvailable) return -1; // 加分项:离散GPU优先 if (props.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) { score += 1000; } score += props.limits.maxImageDimension2D > 4096 ? 100 : 0; return score; }实际选择时对所有设备评分,取最高分且不是负数的那个。这个函数还有优化空间,比如笔记本的独显可以额外加分,或者通过VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU来判断是否是核显。
3. 队列族:GPU上的“部门”与“工种”
3.1 queueFlags:怎么判断一个队列族能干什么
队列族是这一步的重头戏。每个物理设备都对应一个或多个队列族。查询方式和设备枚举类似:
uint32_t queueFamilyCount = 0; vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, nullptr); std::vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount); vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, queueFamilies.data());VkQueueFamilyProperties结构体里有一个关键字段queueFlags,它是位掩码,可能的取值包括:
VK_QUEUE_GRAPHICS_BIT:支持图形管线命令,渲染必需的。VK_QUEUE_COMPUTE_BIT:支持计算着色器。VK_QUEUE_TRANSFER_BIT:支持内存传输(复制缓冲、图像)。VK_QUEUE_SPARSE_BINDING_BIT:支持稀疏资源绑定。VK_QUEUE_PROTECTED_BIT:支持受保护内容。
看到queueFlags这样的设计,老经验是:判断支持用按位与,而不是等号。因为一个队列族极可能同时支持多个位,比如一个队列族同时有VK_QUEUE_GRAPHICS_BIT和VK_QUEUE_COMPUTE_BIT。如果写成if (queueFamily.queueFlags == VK_QUEUE_GRAPHICS_BIT),会把很多合格的设备误判成“没有图形队列”。
正确写法:
bool isGraphicsQueueFamily = (queueFamily.queueFlags & VK_QUEUE_GRAPHICS_BIT) != 0;当然,只用按位与判断还不够,还要注意queueCount字段,如果某个队列族的queueCount是0,表示该队列族虽然存在但没有任何可用队列。这种现象在部分硬件/驱动组合下会出现,尤其是设备枚举时某个队列族对应的是不活跃的多GPU组合。
3.2 常见的队列族配置:核显、独显、转译GPU对比
不同GPU的队列族布局千差万别,但大体上可以分成几类。我拿真实设备做例子:
- Intel核显:通常只有一个队列族,同时支持Graphics和Transfer,queueCount通常是1或2。这意味着你在核显设备上几乎不用纠结选哪个队列族,反正只有一个。
- NVIDIA独显:一般有多个队列族。有的队列族同时支持Graphics和Compute,另外还有一个独立的Transfer队列族。NVIDIA驱动通常还提供一个专门用于视频解码的队列族,但标准Vulkan API里不一定要用。
- 移动GPU(Adreno/Mali):移动SoC往往只有一个全能队列族,支持Graphics、Compute和Transfer,但queueCount可能只有1。
- 硬件转译层(比如某些软件实现的Vulkan):可能会有1个队列族,且所有位都支持,但性能一言难尽。
画了一个表格方便对照:
| 设备类型 | 常见队列族布局 | 是否容易踩坑 |
|---|---|---|
| 核显 | 1个全能队列族 | 低 |
| NVIDIA独显 | 多个:图形+计算,独立传输 | 中 |
| AMD独显 | 多个,布局依驱动而异 | 中高 |
| 移动SoC | 1~2个队列族 | 低 |
| 转译层 | 1个雏形队列族 | 高 |
如果只是画三角形,选一个同时支持Graphics和Transfer的队列族最省事。因为你后续还要往GPU上传顶点数据,如果图形和传输是同族,你只需要一个队列,不用考虑同步问题。
3.3 为什么要警惕“只有一个图形队列族”的坑
我发现新手很容易被网上一些教程误导,认为“图形队列族一定在index=0”。实测并不一定。某些驱动会把计算或视频队列放在index 0,图形队列放在index 1。如果没有正确遍历判断,而是直接假设queueFamilyIndex = 0,结果就会在提交渲染命令时提示队列族不支持图形操作,验证层甚至报VUID-vkCmdDraw-None-02693之类的问题。
所以算法上必须做两件事:
- 遍历所有队列族,找到第一个满足要求且
queueCount > 0的索引。 - 把找到的索引保存下来,后续创建逻辑设备时传递给
VkDeviceQueueCreateInfo。
另外,如果同一个队列族的queueCount > 1,你还可以在逻辑设备创建时请求多个队列,从而在后续实现多线程渲染。但这一步对“三角形”项目来说完全没必要,我们先把一个队列用起来再说。
4. 完整实操:从物理设备到逻辑设备
4.1 找队列族索引:写一个可复用的辅助函数
不要在主流程里写一堆if else,最好封装一个独立的函数,输入物理设备和一个标志位,输出合适的队列族索引。下面这个函数我项目里一直用,改造一下可以支持找“计算队列族”或者“传输队列族”。
uint32_t findQueueFamily(VkPhysicalDevice device, VkQueueFlags requiredFlags) { uint32_t queueFamilyCount = 0; vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, nullptr); std::vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount); vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, queueFamilies.data()); for (uint32_t i = 0; i < queueFamilyCount; i++) { if ((queueFamilies[i].queueFlags & requiredFlags) == requiredFlags) { return i; } } // 如果找不到完全满足的,尝试只满足部分 for (uint32_t i = 0; i < queueFamilyCount; i++) { if ((queueFamilies[i].queueFlags & requiredFlags) != 0) { return i; } } throw std::runtime_error("没有找到匹配的队列族"); }注意我上面写了两个循环:第一个循环要求同时具备所有标志位(比如同时支持Graphics和Transfer),第二个循环允许退而求其次。平时使用时,可以按需选择,但对于画三角形,我建议用第一个循环,优先找同时支持Graphics和Transfer的队列族,这样后面创建缓冲时方便。
4.2 创建逻辑设备的配置细节
有了物理设备和队列族索引,接下来要创建VkDevice。不少人在这儿犯了一个经典错误:把物理设备当成参数直接传给vkCreateDevice。实际正确的做法是,创建过程需要你描述“我要哪些队列、要哪些扩展、要哪些特性”,然后Vulkan根据你的描述在该物理设备上创建一个逻辑设备。
代码:
VkDeviceQueueCreateInfo queueCreateInfo{}; queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO; queueCreateInfo.queueFamilyIndex = graphicsQueueFamilyIndex; queueCreateInfo.queueCount = 1; float queuePriority = 1.0f; queueCreateInfo.pQueuePriorities = &queuePriority; VkPhysicalDeviceFeatures deviceFeatures{}; std::vector<const char*> deviceExtensions = { VK_KHR_SWAPCHAIN_EXTENSION_NAME }; VkDeviceCreateInfo createInfo{}; createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; createInfo.queueCreateInfoCount = 1; createInfo.pQueueCreateInfos = &queueCreateInfo; createInfo.pEnabledFeatures = &deviceFeatures; createInfo.enabledExtensionCount = deviceExtensions.size(); createInfo.ppEnabledExtensionNames = deviceExtensions.data();三个重点:
pQueuePriorities必须指向一个数组,数组长度等于queueCount。我每次只创建一个队列,所以只需要一个float,优先级设为1.0f。优先级取值范围是0到1,同一个队列族里优先级高的可能先在GPU上执行,但这是调度层面的优化,项目初期不必深究。deviceExtensions一定要包含交换链扩展。很多人问“画三角形必须要交换链吗”?如果你要做presentation到窗口,必须。这一项漏掉,后面创建VkSwapchainKHR时直接报VK_ERROR_EXTENSION_NOT_PRESENT。deviceFeatures可以为空结构体,但pEnabledFeatures不能传nullptr。如果你在创建逻辑设备时想启用某个特性(比如samplerAnisotropy),前提是物理设备查询结果里对应字段为VK_TRUE。
4.3 用vkGetDeviceQueue把队列拿到手里
创建成功后,调用vkCreateDevice:
VkDevice device; if (vkCreateDevice(physicalDevice, &createInfo, nullptr, &device) != VK_SUCCESS) { throw std::runtime_error("逻辑设备创建失败"); }然后立刻用vkGetDeviceQueue取出队列:
VkQueue graphicsQueue; vkGetDeviceQueue(device, graphicsQueueFamilyIndex, 0, &graphicsQueue);第三个参数是队列索引,范围是0到queueCount - 1。因为我们只请求了一个队列,所以传0。
到这里,你已经拥有了一个逻辑设备和一根可以提交图形命令的队列。后续画三角形时的命令缓冲区都会提交到这个队列上。逻辑设备没有“销毁”问题吗?记得在程序退出时调用:
vkDestroyDevice(device, nullptr);这是后话,但提前提一句,所有Vulkan对象都是显式销毁的,哪怕你忘了,验证层也会给你提示。
5. 这里我踩过的坑和排查思路
5.1 设备枚举时忘了第二次调用导致空数据
这是我自己第一次写时就犯过的错。看到vkEnumeratePhysicalDevices返回的设备数量是2,然后就直接devices.resize(2),却忘了真正去填充,结果后面vkGetPhysicalDeviceProperties拿到的是一个空句柄。实际代码如下,注意步骤顺序不能变:
uint32_t deviceCount = 0; vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr); std::vector<VkPhysicalDevice> devices(deviceCount); vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data()); // 这行必须有调试时可以用验证层稳定捕获这个问题:漏掉第二次调用会导致返回的devices全部是VK_NULL_HANDLE,验证层会报告VUID-vkGetPhysicalDeviceProperties-physicalDevice-parameter,意思是你传给函数的参数根本不是可用的物理设备句柄。
5.2 盲目选择离散GPU导致验证层报错
我在一个混合图形笔记本上测试时,发现两台不同厂商笔记本对“离散GPU”的判别逻辑不一样。有的笔记本系统中VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU确实是独显,但有的所谓独显因为驱动原因,并不能支持Vulkan的图形队列族。你如果只因为它是DISCRETE就选择它,后面创建逻辑设备时就会报VK_ERROR_FEATURE_NOT_PRESENT或类似错误。
所以我现在的经验是:设备类型只能作为加分项,绝对不能作为一票否决的硬性条件。一个合格的isDeviceSuitable必须把“是否支持交换链”和“是否有图形队列”作为硬性条件,把设备类型作为软性偏好。
5.3 队列族索引与queueFlags的误判
有一种情况特别容易迷惑新手:你打印某个队列族属性,看到queueFlags = 5,十六进制5对应0101,也就是VK_QUEUE_GRAPHICS_BIT(值为1)和VK_QUEUE_SPARSE_BINDING_BIT(值为4)的组合。这时候如果你写if (qf.queueFlags == 1)就会漏掉这个队列族。相反,用按位与(qf.queueFlags & VK_QUEUE_GRAPHICS_BIT)则能正确识别。
另外,不要看到queueFlags里没有VK_QUEUE_TRANSFER_BIT就认为不能做缓冲上传。实际上,几乎所有支持VK_QUEUE_GRAPHICS_BIT的队列族都可以隐式执行传输命令,也就是说,即使不在队列族属性中声明TRANSFER,你依然可以在图形队列里使用vkCmdCopyBuffer。只有当你想要一个专门的异步传输队列做优化时,才需要找显式带VK_QUEUE_TRANSFER_BIT的队列族。
5.4 多队列族应用在早期不要盲目上
我在探索Vulkan高级用法时,曾经试图用3个队列分别做图形、计算、传输,结果同步逻辑写起来相当痛苦。如果没有处理好memset、semaphore、fence,经常出现黑屏或者死锁。对“画三角形”这种入门级项目,老老实实用单队列族就够了。
等你把管线、帧缓冲、命令录制这些基本概念都搞明白,再回过来研究多队列也不迟。盲目追求多队列只是给自己增加心智负担,对入门毫无帮助。
5.5 扩展名与验证层的诡异错误
还有一次我在手机上调试,发现设备明明支持交换链扩展,但创建逻辑设备时总报VK_ERROR_EXTENSION_NOT_PRESENT。排查半天才发现我忘了在deviceExtensions里加VK_KHR_SWAPCHAIN_EXTENSION_NAME,而验证层这个错误描述得很隐晦,根本不会告诉你“你缺了哪个扩展”,只会告诉你“有扩展没被启用”。
所以建议总是在代码里做一次“当前设备实际支持的扩展列表”与“你想要启用扩展列表”的差集打印,尤其是当你换设备调试时:
std::vector<const char*> requiredExtensions = { VK_KHR_SWAPCHAIN_EXTENSION_NAME }; for (const auto& req : requiredExtensions) { bool found = false; for (const auto& ext : availableExtensions) { if (strcmp(ext.extensionName, req) == 0) { found = true; break; } } if (!found) { throw std::runtime_error("缺少所需扩展: " + std::string(req)); } }6. 最后聊聊我在选择物理设备时沉淀下来的习惯
可能是我写多了跨平台引擎的底层封装,现在不管在哪个新平台上跑Vulkan,我都会先打出一份“设备体检报告”:列出所有物理设备的名称、类型、队列族数量和flags、扩展数量。这样能避免很多玄学问题。比如在Ubuntu上用软件渲染器的时候,你看到llvmpipe也支持Vulkan,但它的队列族和真实GPU不一样,如果你只会写死逻辑,就会莫名其妙跑不通。
还有一个小习惯:永远不要在代码里把graphicsQueueFamilyIndex写死成0。哪怕你的开发机只有一个队列族,也要通过函数去查。这样代码迁移到Android、macOS或者别的厂商平台时,你不需要回过头来改这段代码。我因为这个吃过亏,以前偷懒写0,后来在一台NVIDIA公版驱动上遇到了队列族index=0是可计算族,直接导致渲染黑屏,排查了整整一个下午。
另外,物理设备选择这块,很多人会纠结要不要用VK_KHR_portability_subset扩展。那是在macOS上使用MoltenVK时的特殊要求,不过那句老话放在两端都合适:先保证扩展齐全,再谈性能取舍。做底层图形程序,扎实地把设备和队列搞明白,后面画三角形时才能心不慌。希望这篇能帮你少走点弯路。