由于 Vulkan 是一个平台无关的 API,它自身无法直接与窗口系统交互。为了建立 Vulkan 和窗口系统之间的连接,以便将结果呈现到屏幕上,我们需要使用 WSI(窗口系统集成)扩展。在本章中,我们将讨论第一个扩展,即 VK_KHR_surface。它公开了一个 VkSurfaceKHR 对象,该对象表示一种抽象的表面类型,用于呈现渲染后的图像。我们程序中的表面将由我们已经使用 GLFW 打开的窗口支持。

VK_KHR_surface 扩展是一个实例级别的扩展,实际上我们已经启用了它,因为它包含在 glfwGetRequiredInstanceExtensions 返回的列表中。该列表还包括我们将在接下来的几章中使用的其他 WSI 扩展。

窗口表面需要在实例创建之后立即创建,因为它实际上会影响物理设备的选择。我们推迟这一步的原因是,窗口表面是更大的渲染目标和呈现主题的一部分,对此的解释会使基本设置变得混乱。还应该注意的是,如果您只需要离屏渲染,窗口表面在 Vulkan 中是完全可选的组件。Vulkan 允许您在不需要像 OpenGL 那样创建不可见窗口的 hack 方法的情况下做到这一点。

窗口表面创建

首先在调试回调下方添加一个 surface 类成员。

VkSurfaceKHR surface;

虽然 VkSurfaceKHR 对象及其用法与平台无关,但其创建并非如此,因为它取决于窗口系统的详细信息。例如,它需要 Windows 上的 HWNDHMODULE 句柄。因此,该扩展有一个特定于平台的附加组件,在 Windows 上称为 VK_KHR_win32_surface,并且也自动包含在 glfwGetRequiredInstanceExtensions 的列表中。

我将演示如何使用这个特定于平台的扩展在 Windows 上创建表面,但我们实际上不会在本教程中使用它。使用像 GLFW 这样的库,然后继续使用特定于平台的代码没有任何意义。GLFW 实际上有 glfwCreateWindowSurface,它可以为我们处理平台差异。不过,在开始依赖它之前,了解它在幕后做了什么是有好处的。

要访问原生平台函数,您需要更新顶部的包含项

#define VK_USE_PLATFORM_WIN32_KHR
#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>
#define GLFW_EXPOSE_NATIVE_WIN32
#include <GLFW/glfw3native.h>

由于窗口表面是一个 Vulkan 对象,因此它带有一个需要填写的 VkWin32SurfaceCreateInfoKHR 结构体。它有两个重要的参数:hwndhinstance。这些是窗口和进程的句柄。

VkWin32SurfaceCreateInfoKHR createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_WIN32_SURFACE_CREATE_INFO_KHR;
createInfo.hwnd = glfwGetWin32Window(window);
createInfo.hinstance = GetModuleHandle(nullptr);

glfwGetWin32Window 函数用于从 GLFW 窗口对象获取原始 HWNDGetModuleHandle 调用返回当前进程的 HINSTANCE 句柄。

之后,可以使用 vkCreateWin32SurfaceKHR 创建表面,其中包括实例、表面创建详细信息、自定义分配器以及要存储表面句柄的变量的参数。从技术上讲,这是一个 WSI 扩展函数,但它非常常用,以至于标准 Vulkan 加载器包含了它,因此与其他扩展不同,您不需要显式加载它。

if (vkCreateWin32SurfaceKHR(instance, &createInfo, nullptr, &surface) != VK_SUCCESS) {
    throw std::runtime_error("failed to create window surface!");
}

对于其他平台(如 Linux)来说,过程类似,其中 vkCreateXcbSurfaceKHR 使用 X11 将 XCB 连接和窗口作为创建详细信息。

glfwCreateWindowSurface 函数使用每个平台的不同实现来执行此操作。我们现在将其集成到我们的程序中。添加一个函数 createSurface,以便在实例创建和 setupDebugMessenger 之后从 initVulkan 调用它。

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

void createSurface() {

}

GLFW 调用采用简单的参数而不是结构体,这使得函数的实现非常直接

void createSurface() {
    if (glfwCreateWindowSurface(instance, window, nullptr, &surface) != VK_SUCCESS) {
        throw std::runtime_error("failed to create window surface!");
    }
}

参数是 VkInstance、GLFW 窗口指针、自定义分配器和指向 VkSurfaceKHR 变量的指针。它只是传递来自相关平台调用的 VkResult。GLFW 没有提供用于销毁表面的特殊函数,但这可以通过原始 API 轻松完成

void cleanup() {
        ...
        vkDestroySurfaceKHR(instance, surface, nullptr);
        vkDestroyInstance(instance, nullptr);
        ...
    }

确保在实例之前销毁表面。

查询呈现支持

虽然 Vulkan 实现可能支持窗口系统集成,但这并不意味着系统中的每个设备都支持它。因此,我们需要扩展 isDeviceSuitable 以确保设备可以将图像呈现到我们创建的表面。由于呈现是特定于队列的功能,因此问题实际上是找到一个支持呈现到我们创建的表面的队列族。

实际上,支持绘制命令的队列族和支持呈现的队列族可能不重叠。因此,我们必须考虑到可能存在一个不同的呈现队列,方法是修改 QueueFamilyIndices 结构体

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

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

接下来,我们将修改 findQueueFamilies 函数,以查找具有呈现到我们的窗口表面的能力的队列族。用于检查的函数是 vkGetPhysicalDeviceSurfaceSupportKHR,它将物理设备、队列族索引和表面作为参数。在与 VK_QUEUE_GRAPHICS_BIT 相同的循环中添加对其的调用

VkBool32 presentSupport = false;
vkGetPhysicalDeviceSurfaceSupportKHR(device, i, surface, &presentSupport);

然后只需检查布尔值的值并存储呈现族队列索引

if (presentSupport) {
    indices.presentFamily = i;
}

请注意,最终它们很可能最终成为同一个队列族,但在整个程序中,我们将把它们视为单独的队列,以便采用统一的方法。尽管如此,您可以添加逻辑来显式地偏好在同一队列中支持绘制和呈现的物理设备,以提高性能。

创建呈现队列

剩下的最后一件事是修改逻辑设备创建过程,以创建呈现队列并检索 VkQueue 句柄。为句柄添加一个成员变量

VkQueue presentQueue;

接下来,我们需要有多个 VkDeviceQueueCreateInfo 结构体,以便从两个族创建队列。一种优雅的方法是创建一个包含所需队列的所有唯一队列族的集合

#include <set>

...

QueueFamilyIndices indices = findQueueFamilies(physicalDevice);

std::vector<VkDeviceQueueCreateInfo> queueCreateInfos;
std::set<uint32_t> uniqueQueueFamilies = {indices.graphicsFamily.value(), indices.presentFamily.value()};

float queuePriority = 1.0f;
for (uint32_t queueFamily : uniqueQueueFamilies) {
    VkDeviceQueueCreateInfo queueCreateInfo{};
    queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
    queueCreateInfo.queueFamilyIndex = queueFamily;
    queueCreateInfo.queueCount = 1;
    queueCreateInfo.pQueuePriorities = &queuePriority;
    queueCreateInfos.push_back(queueCreateInfo);
}

并修改 VkDeviceCreateInfo 以指向该向量

createInfo.queueCreateInfoCount = static_cast<uint32_t>(queueCreateInfos.size());
createInfo.pQueueCreateInfos = queueCreateInfos.data();

如果队列族相同,那么我们只需要传递其索引一次。最后,添加一个调用来检索队列句柄

vkGetDeviceQueue(device, indices.presentFamily.value(), 0, &presentQueue);

如果队列族相同,则这两个句柄现在很可能具有相同的值。在下一章中,我们将研究交换链以及它们如何使我们能够将图像呈现到表面。

C++ 代码