选择一个物理设备

通过 VkInstance 初始化了 Vulkan 的库之后,我们需要在系统中选择一个支持我们需要的功能的显卡。事实上,我们可以同时选择并使用 任意数量的显卡,但是在这个教程里,我们会专注于第一个满足我们需要的显卡。

我们会添加一个函数 pickPhysicalDevice,并且在 initVulkan 函数中使用它。

void initVulkan() {
    createInstance();
    setupDebugMessenger();
    pickPhysicalDevice();
}

void pickPhysicalDevice() {

}

我们最终选择使用的显卡会被添加为一个 VkPhysicalDevice 句柄的成员变量。在 VkInstance 被销毁的时候,这个对象也会被销毁,所以 我们不需要在 cleanup 里面对它进行清理。

VkPhysicalDevice physicalDevice = VK_NULL_HANDLE;

列出显卡的方式和列出插件的方式很相似。它也是先查询数量。

uint32_t deviceCount = 0;
vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr);

如果没有支持 Vulkan 的显卡,那么就不需要再搜寻下去了。

if (deviceCount == 0) {
    throw std::runtime_error("failed to find GPUs with Vulkan support!");
}

除此之外我们可以分配一个数组来容纳所有的 VkPhysicalDevice 句柄。

std::vector<VkPhysicalDevice> devices(deviceCount);
vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data());

因为显卡之间存在不同之处,现在我们需要去对每一个显卡评估是否合适用于我们要进行的操作。为此,我们将引入一个新的函数:

bool isDeviceSuitable(VkPhysicalDevice device) {
    return true;
}

然后调用这个函数去检查是否有显卡满足我们的要求。

for (const auto& device : devices) {
    if (isDeviceSuitable(device)) {
        physicalDevice = device;
        break;
    }
}

if (physicalDevice == VK_NULL_HANDLE) {
    throw std::runtime_error("failed to find a suitable GPU!");
}

下一部分会介绍我们在 isDeviceSuitable 要检查的第一个要求。在后面的章节中,我们开始使用更多 Vulkan 的功能的时候,我们也会 扩展这个函数来包含更多的检查。

检查设备的基础兼容性

为了评估一个设备的兼容性,我们可以从查询设备的一些细节开始。设备的基础信息比如说设备的名称,类型和支持的 Vulkan 版本可以通过 vkGetPhysicalDeviceProperties 查询到。

VkPhysicalDeviceProperties deviceProperties;
vkGetPhysicalDeviceProperties(device, &deviceProperties);

通过 vkGetPhysicalDeviceFeatures,可以查询是否支持一些可选的功能,比如说纹理压缩,64 位浮点型和多视口渲染(较多用于 VR)。

VkPhysicalDeviceFeatures deviceFeatures;
vkGetPhysicalDeviceFeatures(device, &deviceFeatures);

更多能从设备中查到的细节,比如设备内存和队列族(参见下一部分),将在之后讨论。

例如说,如果我们的应用程序只有在支持几何着色器的显卡中可以使用,那么 isDeviceSuitable 函数就应该是这样:

bool isDeviceSuitable(VkPhysicalDevice device) {
    VkPhysicalDeviceProperties deviceProperties;
    VkPhysicalDeviceFeatures deviceFeatures;
    vkGetPhysicalDeviceProperties(device, &deviceProperties);
    vkGetPhysicalDeviceFeatures(device, &deviceFeatures);

    return deviceProperties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU &&
           deviceFeatures.geometryShader;
}

除了只检查是否设备是否是第一个,你也可以给每个设备打分,然后选择最高分的那个。这样你就可以找到一个最合适的显卡。但是如果只有 集成 GPU 可以用,那就退回到集成 GPU。你可以写成类似于下面这个样子:

#include <map>

...

void pickPhysicalDevice() {
    ...

    // 使用一个有序映射来自动将候选的设备按照分数从小到大排序
    std::multimap<int, VkPhysicalDevice> candidates;

    for (const auto& device : devices) {
        int score = rateDeviceSuitability(device);
        candidates.insert(std::make_pair(score, device));
    }

    // 检查最佳的候选显卡是否可用
    if (candidates.rbegin()->first > 0) {
        physicalDevice = candidates.rbegin()->second;
    } else {
        throw std::runtime_error("failed to find a suitable GPU!");
    }
}

int rateDeviceSuitability(VkPhysicalDevice device) {
    ...

    int score = 0;

    // 独立显卡有非常大的性能优势
    if (deviceProperties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) {
        score += 1000;
    }

    // 不影响渲染质量时能存储的最多的纹理的数量
    score += deviceProperties.limits.maxImageDimension2D;

    // 应用程序必须要求有几何着色器
    if (!deviceFeatures.geometryShader) {
        return 0;
    }

    return score;
}

对于这个教程来说,你不需要去完全照着这样去实现。这只是给你一个思路去设计你选择显卡的过程。当然你也可以直接显示所有显卡的名字 然后让用户去做选择。

因为我们只是入门,支持 Vulkan 就是所有我们需要的了。所以我们可以选择任意一个 GPU:

bool isDeviceSuitable(VkPhysicalDevice device) {
    return true;
}

在下一部分中,我们会讨论我们第一个需要去检查的特性。

队列族 (Queue Family)

在之前我们简单提到过,在 Vulkan 中,几乎所有的操作,所有从绘制到上传纹理的部分,都需要将命令上传给一个队列。从不同的 队列族(queue families)中会有不同类型的队列,并且每个队列族只允许一个命令子集。比如说,可能会有一个队列族只允许处理计算 命令,或者一个队列族只允许有关于内存转储的指令。

我们需要去检查哪一个队列族是设备支持的,并且其中哪一个支持我们想要使用的指令。我们可以添加一个新函数 findQueueFamilies 来 查找所有我们需要的队列族。

现在我们要去查找一个支持图形指令的队列族。这个函数可能是这个样子:

uint32_t findQueueFamilies(VkPhysicalDevice device) {
    // 查找图形队列族的逻辑
}

但是,在后面的一个章节中我们需要去查找另外一个队列。所以为将来做准备,更好的做法是把索引存储在一个结构体中:

struct QueueFamilyIndices {
    uint32_t graphicsFamily;
};

QueueFamilyIndices findQueueFamilies(VkPhysicalDevice device) {
    QueueFamilyIndices indices;
    // 查找队列族索引并填充结构体的逻辑
    return indices;
}

但是如果队列族不能使用怎么办?我们可以在 findQueueFamilies 中抛出一个异常,但是这个函数不是一个处理设备兼容性的地方。比如 说,我们可能更倾向于一个有专用的传输队列族的设备,而不是需要它。所以我们需要判断一个特定的队列族是否存在。

因为任何一个 uint32_t 都有可能是一个可用的队列族(包括 0),所以似乎并不能使用一个特定的数字来代表一个队列族不存在。幸运的 是,C++17 引进了一个数据结构来区分值存在或不存在的情况:

#include <optional>

...

std::optional<uint32_t> graphicsFamily;

std::cout << std::boolalpha << graphicsFamily.has_value() << std::endl; // false

graphicsFamily = 0;

std::cout << std::boolalpha << graphicsFamily.has_value() << std::endl; // true

std::optional 是一个直到你给它赋值之前不去存储任何值的封装。你可以通过调用它的成员函数 has_value() 来查询它是否储存着一个 值。这样我们就可以将函数改为:

#include <optional>

...

struct QueueFamilyIndices {
    std::optional<uint32_t> graphicsFamily;
};

QueueFamilyIndices findQueueFamilies(VkPhysicalDevice device) {
    QueueFamilyIndices indices;
    // 将 index 赋值为找到的队列族
    return indices;
}

现在我们就可以去实际地去实现 findQueueFamilies

QueueFamilyIndices findQueueFamilies(VkPhysicalDevice device) {
    QueueFamilyIndices indices;

    ...

    return indices;
}

和往常一样,列出队列族的过程需要使用到 vkGetPhysicalDeviceQueueFamilyProperties

uint32_t queueFamilyCount = 0;
vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, nullptr);

std::vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount);
vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, queueFamilies.data());

VkQueueFamilyProperties 结构体储存了一些关于队列族的细节信息,包括支持的操作的类型,和这个队列族可以创建的队列数量。我们需要 找到至少一个支持 VK_QUEUE_GRAPHICS_BIT 的队列族。

int i = 0;
for (const auto& queueFamily : queueFamilies) {
    if (queueFamily.queueFlags & VK_QUEUE_GRAPHICS_BIT) {
        indices.graphicsFamily = i;
    }

    i++;
}

现在我们有了这个查找队列族的函数,我们可以在 isDeviceSuitable 中使用它来确保设备可以处理我们想要使用的指令:

bool isDeviceSuitable(VkPhysicalDevice device) {
    QueueFamilyIndices indices = findQueueFamilies(device);

    return indices.graphicsFamily.has_value();
}

为了便捷性,我们可以在结构体里面添加一个用来检查的函数:

struct QueueFamilyIndices {
    std::optional<uint32_t> graphicsFamily;

    bool isComplete() {
        return graphicsFamily.has_value();
    }
};

...

bool isDeviceSuitable(VkPhysicalDevice device) {
    QueueFamilyIndices indices = findQueueFamilies(device);

    return indices.isComplete();
}

现在我们就可以在 findQueueFamilies 中使用它来提前退出循环:

for (const auto& queueFamily : queueFamilies) {
    ...

    if (indices.isComplete()) {
        break;
    }

    i++;
}

很好!这些就是我们目前需要的所有的查找合适物理设备的东西了!下一步就是 创建一个逻辑设备与它交互。

C++ code