Vulkan 学习笔记(九) 窗口表面(Window surface)
介绍
由于Vulkan是一个平台无关的API,它无法直接与窗口系统交互。为了在Vulkan和窗口系统之间建立连接,以便将渲染结果呈现到屏幕上,我们需要使用WSI(窗口系统集成)扩展。在本章中,我们将讨论第一个扩展,即VK_KHR_surface。它暴露了一个VkSurfaceKHR对象,该对象表示一个抽象类型的表面,用于呈现渲染后的图像。我们程序中的表面将由我们已经使用GLFW打开的窗口支持。
VK_KHR_surface扩展是一个实例级别的扩展,实际上我们已经启用了它,因为它包含在glfwGetRequiredInstanceExtensions返回的列表中。该列表还包括一些其他WSI扩展,我们将在接下来的几章中使用它们。
窗口表面需要在实例创建后立即创建,因为它实际上可以影响物理设备的选择。我们推迟这部分内容的原因是,窗口表面属于渲染目标和呈现这个更大的主题,如果在基础设置中解释会显得杂乱。还应该注意的是,窗口表面在Vulkan中是完全可选的组件,如果你只需要离屏渲染。Vulkan允许你这样做,而无需像创建不可见窗口这样的hack(OpenGL所必需的)。
💡 术语解释:WSI (Window System Integration)
WSI是Vulkan中用于与操作系统窗口系统集成的一组扩展。可以这样理解:Vulkan本身只负责图形计算和渲染,但它不知道如何将结果展示在屏幕上。WSI就像是Vulkan和操作系统窗口系统之间的"翻译官",它告诉Vulkan:"这是屏幕上的一个窗口,你可以把渲染结果放在这里"。
没有WSI,Vulkan就像一个会画画的艺术家,但没有画布。WSI提供了这个"画布",让艺术家的作品能够被观众看到。不同的操作系统(Windows、Linux、macOS)有不同的窗口系统,WSI扩展确保Vulkan可以在所有这些平台上一致地工作。
💡 术语解释:VkSurfaceKHR
VkSurfaceKHR是Vulkan中表示窗口表面的核心对象。可以把这个对象想象成一个"虚拟画布":
- • 抽象性:它不关心底层是Windows窗口、X11窗口还是Wayland表面,对Vulkan来说都是一样的
- • 桥梁作用:它连接了Vulkan的渲染世界和操作系统的窗口世界
- • 轻量级:这个对象本身不包含像素数据,它只是一个"位置标识符",告诉Vulkan"在这里显示内容"
重要的是,VkSurfaceKHR只定义了"在哪里显示",而没有定义"如何显示"。显示的具体机制(如双缓冲、垂直同步等)由交换链(Swap Chain)来处理,这是下一章的内容。
窗口表面创建
首先,在调试回调下方添加一个surface类成员。
VkSurfaceKHR surface;
虽然VkSurfaceKHR对象及其使用是平台无关的,但其创建却不是,因为它依赖于窗口系统的细节。例如,在Windows上它需要HWND和HMODULE句柄。因此,该扩展有一个特定于平台的补充,在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结构体,需要填充。它有两个重要参数:hwnd和hinstance。这些是窗口和进程的句柄。
VkWin32SurfaceCreateInfoKHR createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_WIN32_SURFACE_CREATE_INFO_KHR;
createInfo.hwnd = glfwGetWin32Window(window);
createInfo.hinstance = GetModuleHandle(nullptr);
glfwGetWin32Window函数用于从GLFW窗口对象获取原始的HWND。GetModuleHandle调用返回当前进程的HINSTANCE句柄。
之后,可以使用vkCreateWin32SurfaceKHR创建表面,它包含实例参数、表面创建细节、自定义分配器和用于存储表面句柄的变量。从技术上讲,这是一个WSI扩展函数,但它被使用得如此普遍,以至于标准的Vulkan加载器包含了它,因此与其他扩展不同,你不需要显式加载它。
if (vkCreateWin32SurfaceKHR(instance, &createInfo, nullptr, &surface) != VK_SUCCESS) {
throw std::runtime_error("failed to create window surface!");
}
在其他平台(如Linux)上,过程类似,其中vkCreateXcbSurfaceKHR接受XCB连接和窗口作为X11的创建细节。
glfwCreateWindowSurface函数正是通过为每个平台使用不同的实现来执行这个操作。我们现在将其集成到我们的程序中。添加一个createSurface函数,在initVulkan中实例创建和setupDebugMessenger之后调用它。
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](https://www.khronos.org/registry/vulkan/specs/1.0/man/html/VkInstance.html)、GLFW窗口指针、自定义分配器和指向VkSurfaceKHR变量的指针。它只是传递相关平台调用的[VkResult](https://www.khronos.org/registry/vulkan/specs/1.0/man/html/VkResult.html)。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;
}
需要注意的是,这些队列家族最终很可能相同,但在整个程序中,为了统一的方法,我们将它们视为不同的队列。尽管如此,你可以添加逻辑,明确偏好支持在同一队列中进行绘制和呈现的物理设备,以提高性能。
💡 术语解释:队列家族分离 (Queue Family Separation)
在Vulkan中,不同的队列家族专门处理不同类型的工作。队列家族分离是一个重要的架构概念:
- • 图形队列:专门处理3D渲染命令(三角形绘制、纹理映射等)
- • 呈现队列:专门处理将图像从GPU传输到屏幕的操作
- • 计算队列:专门处理通用GPU计算(物理模拟、AI等)
为什么需要分离?想象一个工厂:
- • 图形队列是"生产工人",负责制造产品(渲染图像)
- • 呈现队列是"物流部门",负责将产品运送到商店(显示到屏幕)
在某些GPU上,这两个部门可能是同一个团队(同一个队列家族),但在其他GPU上,它们可能是完全独立的团队。Vulkan的设计哲学是"不要假设",因此我们必须显式检查和处理这两种情况。这种分离允许GPU制造商优化硬件设计,而应用程序可以充分利用这些优化。
创建呈现队列
剩下的就是修改逻辑设备创建过程,以创建呈现队列并获取[VkQueue](https://www.khronos.org/registry/vulkan/specs/1.0/man/html/VkQueue.html)句柄。为句柄添加一个成员变量:
VkQueue presentQueue;
接下来,我们需要多个[VkDeviceQueueCreateInfo](https://www.khronos.org/registry/vulkan/specs/1.0/man/html/VkDeviceQueueCreateInfo.html)结构体,以从两个家族创建队列。一个优雅的方法是创建一个包含所有必需队列所需唯一队列家族的集合:
#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](https://www.khronos.org/registry/vulkan/specs/1.0/man/html/VkDeviceCreateInfo.html)以指向该向量:
createInfo.queueCreateInfoCount = static_cast<uint32_t>(queueCreateInfos.size());
createInfo.pQueueCreateInfos = queueCreateInfos.data();
如果队列家族相同,那么我们只需要传递其索引一次。
最后,添加一个调用以获取队列句柄:
vkGetDeviceQueue(device, indices.presentFamily.value(), 0, &presentQueue);
如果队列家族相同,那么这两个句柄现在很可能具有相同的值。在下一章中,我们将研究交换链以及它们如何赋予我们向表面呈现图像的能力。
[C++ 代码]https://vulkan-tutorial.com/code/05_window_surface.cpp