Vulkan 学习记录
本学习记录完全基于 https://vulkan-tutorial.com/。可能有很多地方理解不当或错误,请注意。
为了方便理解,我对教程代码做了简单的模块化,每一分支对应一节内容。代码地址:https://github.com/Zwei-Reverberate/Zw_Vulkan_Learning。
1. Project Structure
Vulkan 是一个基于 GPU 的跨平台图形 API,完全按照现代图形架构设计。
Vulkan 绘制是一个比较复杂的过程,我们希望将绘制的各个步骤模块化。
1. Vulkan Procedure
以绘制一个三角形为例,让我们简要了解一下 Vulkan 的绘制流程。
1. Instance and physical device selection
构建一个 Vulkan 应用程序从创建一个 VkInstance 来配置 Vulkan API 开始。而一个 VkInstance 的创建需要指明应用程序所要用到的 API extensions。创建 VkInstance 之后,就可以查询 Vulkan 支持的硬件并从中选取一个或多个 VkPhysicalDevice 进行操作。可以通过查询设备属性如 VRAM 大小,选择一个适合的 device。
2. Logical device and queue families
选择好硬件设备之后,需要创建一个 VkDevice (logical device) 并对它更加具体的指定所要使用的 VkPhysicalDeviceFeatures,如 multi viewport rendering 和 64 bit floats。
你还需要指定所想使用的 queue families,Vulkan 的大多数操作,如 draw commands 和 memory operations 都是将它们提交到 VkQueue 中异步执行的。Queues 由 queue families 分配,每个 queue family 支持其 queues 中的一组特定操作。例如对于 graphics, compute 和 memory transfer operations 可能对应不同的 queue families。
Queue families 的可用性也可以用于区分 physical device,支持 Vulkan 的设备可能不提供任何图形功能,但是现在支持 Vulkan 的所有显卡通常都支持常用的队列操作。
3. Window suface and swap chain
我们常常需要创建一个窗口来显示渲染的图像。我们可以使用 GLFW 或是 SDL 这样的库来完成这一任务。 我们至少需要两个组件来渲染一个窗口:一个 window surface(VkSurfaceKHR) 和一个 swap chain (VkSwapchainKHR)。(KHR 后缀表示它们是 Vulkan extension 的一部分)。 Vulkan API 本身是平台无关的,这也是我们为什么使用标准化的 WSI(Window System Interface) extension 来和 window manager 交互。Surface 是对要呈现的窗口的跨平台抽象,通常通过提供一个 native window handle 的引用来实例化,例如 Windows 上的 HWND。幸运的是,GLFW 库中有一个内置的函数来处理平台特定的细节。 Swap chain 是渲染目标的集合。它的目的是确保我们当前渲染的图像和当前屏幕呈现的图像不同,这可以使显示的图像具有连续性。绘制每一帧时,我们必须向 swap chain 请求要渲染的图像。当这一帧绘制完成时,将图像返回 swap chain 以便后续显示在屏幕上。渲染目标的数量以及将渲染好的图像呈现在屏幕上的图像的条件取决于当前的呈现模式,常见的模式有双缓冲 (double buffer, vssync) 和三缓冲 (triple buffering)。 某些平台允许你直接渲染到显示器而无需通过 VK_KHR_display 和 VK_KHR_swapchain extension 与任何 window manager 进行交互。
4. Image views and framebuffers
为了绘制从 swap chain 获取的图像,我们还需要将其包装为 VkImageView 和 VkFramebuffer。 一个 image view 引用一个图像的特定部分,一个 framebuffer 引用要用于颜色,深度和模板目标的 image views。Swapchain 中可能有许多不同的图像,可以预先为每个图像都创建好 Image view 和 framebuffer,然后在绘制时选择对应的 image view 或 framebuffer。
5. Render passes
Vulkan 中的 render passes 描述了渲染操作中的图像类型,它们将如何使用以及它们的内容应该如何被处理。 例如,在我们的三角形绘制程序中,我们将告诉 Vulkan 我们将使用单个图像作为颜色目标,并希望在绘图操作之前将其清除为纯色。Render pass 仅仅描述图像的类型,VkFramebuffer 实际上将特定的图像绑定到这些插槽。
6. Graphics pipeline
Vulkan 中的渲染管线是通过创建一个 VkPipeline 对象来设定的。它描述了显卡的可配置状态,如视口大小和深度缓冲操作以及使用 VkShaderModule 对象的可编程状态。VkShaderModule 对象是通过 shader 字节码创建的。驱动程序还需要知道管线中将使用哪些渲染目标。 与现有的 API 相比,Vulkan 的显著特点之一就是渲染管线的几乎所有配置都需要提前设置。这意味着你若是想切换到不同的着色器或是稍微改变一下顶点布局,那么你需要完全重新创建渲染管线,这样做的效率很低。这就迫使你提前创建出所有需要的渲染管线,在需要时直接使用已创建好渲染管线。渲染管线只有很少一部分配置可以动态修改,如视口大小和清除颜色。所有状态也需要有明确描述。 这样做的好处类似于预编译相比于即时编译,驱动程序有更大的优化空间,运行时性能更加可预测。
7. Command pools and command buffers
如前所述,我们要执行的许多操作(例如绘图操作)需要提交到 queue。这些操作首先需要记录到 VkCommandBuffer 中,然后才能提交。这些 command buffers 是从与特定 queue family 关联的 VkCommandPool 分配的。例如,若要绘制一个三角形,我们需要将以下操作记录到一个 command buffer:
Begin the render pass
Bind the graphics pipeline
Draw 3 vertices
End the render pass
因为 framebuffer 中的图像取决于 swap chain 给我们的图像,所以我们需要为每个可能的图像记录一个 command buffer,并在绘制时选择正确的图像。另一种方法是每帧再次记录 command buffer,但是效率不高。
8. Main loop
现在绘制命令已经被包装进 command buffer 中,主循环变得非常简单。 我们首先使用 vkAcquireNextImageKHR 从 swap chain 获取图像,然后我们可以为该图像选择适当的 command buffer 并使用 vkQueueSubmit 执行它。最后,我们将图像返回 swap chain,以使用 vkQueuePresentKHR 呈现到屏幕上。 提交到 queues 的操作是异步执行的,因此必须使用同步对象(如信号量)来确保正确的执行顺序。执行绘制 command buffer 必须设置为等待图像采集完成,否则我们可能开始渲染正在被读取并显示到屏幕上的图像。反过来,vkQueuePresentKHR 调用需要等待完成渲染,为此,需要使用发出第二个信号量以通知渲染结束。
9. Summary
简而言之,绘制三角形的大致步骤是:
Create a VkInstance
Select a supported graphics card (VkphysicalDevice)
Create a VkDevice and VkQueue for drawing and presetation
Create a window, window surface and swap chain
Wrap the swap chain image into VkImageView
Create a render pass that specifies the render targets and usage
Create framebuffers for the render pass
Set up the graphics pipeline
Allocate and record a command buffer with the draw commands for every possible swap chain image
Draw frames by acquiring images, submitting the right draw command buffer and returning the images back to the swap chain
2. API concepts
1. Coding conventions
所有的 Vulkan 函数,枚举和结构都被定义在 Vulkan SDK 的 vulkan.h 中。
Vulkan 中的许多结构要求你使用 sType 成员明确指明结构类型。pNext 结构可以指向扩展结构。创建或销毁对象的函数有一个 VkAllocationCallbacks 参数,允许你为驱动程序的内存使用自定义的分配器。
几乎所有的函数都会返回一个 VkResult,它要么是 VK_SUBESS,要么是一个 error code。
2. Validation layers
如前所述,Vulkan 是为高性能和低驱动程序开销而设计的,因此默认情况下它提供的错误检测和调试功能非常有限。驱动程序会在发生错误时直接崩溃,而不是返回一个错误代码。这可能导致对于某种显卡可以工作,不会崩溃,但对于其它显卡无法工作,驱动程序崩溃。
Vulkan 允许你通过 validation layer 进行一定的错误检查。验证层能帮开发者进行参数验证(nullptr等)、内存泄漏检测、线程安全检测、日志、Profiling,以及常见逻辑(runtime)错误等,辅助开发者更好的 debug 不易发现的错误和不规范的 API 调用。
2. Development environment
环境配置步骤:https://vulkan-tutorial.com/Development_environment
对应分支:01_environment_configure
3. Set up
1. BaseCode
1. General structure
为了代码的模块化,我们特地将 GLFWwindow 单独封装为一个类,并在其中处理 GLFWwindow 的创建和销毁工作,并提供一个 get 接口,让其他地方可以获取其中封装的 GLFWwindow* 指针。
建立一个 VulkanApp 类负责整合所有的 vulkan 模块以及 GLFWwindow。
2. Resource management
和 malloc 函数分配的内存需要使用 free 释放类似,使用 Vulkan API 创建的的对象也需要使用显式地销毁。不过,我们也可以选择使用智能指针等手段进行自动资源管理。
Vulkan 对象可以直接通过类似 vkCreateXXXX 的函数创建,或是通过其他对象调用类似 vkAllocateXXXX 的函数创建。当创建的对象不再使用时,使用对应的 vkDestroyXXXX 或 vkFreeXXX 函数进行清除操作。对于不同种类的对象这些函数的参数会有所不同,但是有一个参数是相同的:pAllocator。这是一个可选参数,允许你通过这个参数来指定回调函数编写自己的内存分配器。
3. Integrating GLFW
我们使用 GLFW 创建窗口来显示渲染结果,虽然 Vulkan 可以在没有显示窗口的情况下正常运作。
对应分支:02_base_code
2. Instance
1. Creating an instance
我们首先需要通过创建 instance 来初始化 Vulkan library。Instance 是应用程序和 Vulkan library 之间的连接。创建 instance 涉及向驱动程序指定有关你应用程序的一些详细信息。
为了代码的模块化,我们单独将其封装为一个类,并在其中处理它的创建和销毁工作:
为了创建一个 instance,我们填入一些应用程序的相关信息:
如前所述,许多 Vulkan 中的结构需要显式地设定 sType 成员以指明结构类型。此外,许多 Vulkan 结构还有一个 pNext 成员用来指明可能的拓展结构,现在我们并未使用,所以将其设为 nullptr。
Vulkan 中的很多信息不是通过函数参数传递而是通过结构传递的。我们必须再填写一个结构来为创建 instance 提供足够的信息。下面的这个结构体是必须的,它告诉 Vulkan 的驱动程序需要使用的全局扩展和校验层。全局是指这里的设置对于整个应用程序都有效,而不仅仅对一个设备有效:
接下来,我们需要指定需要的全局扩展,如前所述,Vulkan 是平台无关的 API,所以需要一个和窗口系统交互的扩展。GLFW 库包含了一个可以返回这一扩展的函数,我们可以直接使用它:
结构的最后两个成员变量用来指定全局的 validation layers:
createInfo.enabledLayerCount = 0;
我们现在有了 Vulkan 创建 instance 所需要的一切,可以直接使用 vkCreateInstance 来创建 instance::
可以看到,创建 Vulkan 对象的函数参数的一般形式是:
一个包含了创建信息的结构体指针
一个自定义的 allocator 回调函数,这里我们没有使用自定义的 allocator,所以置 nullptr
一个指向新对象 handle 的指针
如果正确执行,那么创建的 instance 的 handle 将会存储在 m_instance 中,返回一个 VK_SUCESS,否则返回 error code。
2. Checking for extension support
关于 vkCreateInstance 一个可能的错误是 VK_ERROR_EXTENSION_NOT_PRESENT。我们可以简单地指定我们需要的扩展,并在该错误代码返回时终止。这对于像窗口系统这样必要的扩展来说非常合适,但如果我们请求的扩展是非必须的,有了很好,没有的话,程序仍然可以运行。这时,我们该怎么做呢?
为了在创建 instance 之前检索支持的的扩展列表,Vulkan 提供了 vkenumerateInstanceExtensionProperties 函数。通过它,我们可以获取扩展的个数以及扩展的详细信息。此外,它还允许我们指定 validation layer 来对扩展进行过滤,这里置为 nullptr。
我们首先需要知道扩展的数量,以便分配合适的数组大小来存储信息,可以通过下面的代码来获取扩展的数量:
知道了扩展的数量后,就可以分配数组来存储扩展信息:
我们可以查询扩展的详情:
vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount, extensions.data());
每个 VkExtensionProperties 结构都包含扩展的名称和版本信息:
3. Cleaning up
代码分支:03_instance
3. Validation layers
1. What are validation layers?
Validation layers 本身并不在 Vulkan 的渲染流程之中,它只是用于 Vulkan 程序的校验和查错。
Vulkan 的 API 是以最小的驱动开销为理念设计的,所以默认情况下其错误检查非常有限。即使像是枚举值设置错误或是将空指针传递给所需参数这样简单的错误通常也不会显式处理,只会导致程序崩溃或未定义行为。Vulkan 需要我们显式地定义每一个操作,所以很容易犯一些小错误,比如使用了一个新的 GPU 特性,却忘记在 logic device 创建时请求这一特性。
然而,这并不意味着这些检查不能添加到 API 中。Vulkan 引入了 validation layers 来解决。Validation layers 是可选的可以用来在 vulkan API 函数调用上进行附加操作的组件。validation layers 中常见的操作是:
Checking the values of parameters against the specification to detect misuse
Tracking creation and destruction of objects to find resource leaks
Checking thread safety by tracking the threads that calls originate from
Logging every call and its parameters to the standard output
Tracing Vulkan calls for profiling and replaying
以下是一个在 diagnostics validation layer 中的函数实现示例:
这些 validation layers 可以任意包含你想要的调式功能。可以在程序的 debug 版本开启它们,在 release 版本关闭它们。
Vulkan 库本身没有提供任何内建的 validation layers, 但 LunarG 的 Vulkan SDK 提供了一个 validation layer 的实现。该 validation layer 只能在安装了 Vulkan SDK 的设备上使用。Vulkan 可以使用 instance 和 device specific 两种不同的 validation layers。更加推荐使用 instance valition layer。
2. Using validation layers
Validation layers 需要通过指定名称来启用。所有的可用的标准 validation 都绑定在 SDK 的一个称为 VK_LAYER_KHRONOS_validation 的 layer 中。
我们将其名称存储为全局变量,并设定 bool 变量来控制其不同模式下是否启用:
我们使用封装一个类单独管理 validation layers,并将其耦合进 instance。这样做的理由是 validation layer 的大部分工作都是针对 instance 的。
我们首先为 Validation 类添加一个静态成员函数 checkValidationLayerSupport 以检查所有的 validation layers 是否可用:
然后,我们在 instance 的 create 函数中调用一次这个函数:
现在在 debug 模式下运行程序,确保没有错误发生。如果有,就要查看帮助文档寻找错误原因。
最后,修改 VkInstanceCreateInfo 结构体信息以在 validation layers 启用时包含 validation layer 的名称。
如果 validation layers 检查成功,vkCreateInstance 就不应返回 VK_ERROR_LAYER_NOT_PRESENT 错误。
3. Message callback
默认情况下,validation layers 会将调试信息打印到标准输出,但我们也可以通过在程序中提供显式的回调来自己处理它们。
要在程序中设置回调以处理消息和相关细节,我们必须使用 VK_EXT_debug_utils extension 设置回调函数来接受调试信息。
我们首先创建一个静态 getRequiredExtensions 函数根据是否启用 validation layers 返回需要的 extensions 列表。
GLFW 指定的扩展是必需的,但调式报告相关的 extensions 可根据条件添加。代码中我们使用了等价于 “VK_EXT_debug_utils” 的 VK_EXT_DEBUG_UTILS_EXTENSION_NAME 宏用来避免打字时的手误。
我们在 instance 的 create 函数中加入此函数的调用:
运行程序确保没有收到 VK_ERROR_EXTENSION_NOT_PRESENT 错误。
现在我们来完成接受调试信息的回调函数。同样为 Validation 类加入一个静态成员函数,
第一个入参指定了消息级别,它可能是以下这些值:
VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT: 诊断信息
VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT: 资源创建之类的信息
VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT: 警告信息
VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT: 不合法可能造成崩溃的操作信息
这些值经过一定设计,可以使用比较运算符来过滤处理一定级别以上的调试信息:
第二个参数 messageType 可以是下面这些值:
VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT: 发生了一些与规范和性能无关的事件
VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT: 出现了违反规范的情况或发生了一个可能的错误
VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT: 进行了可能影响 Vulkan 性能的行为
第三个参数 pCallbackData 是一个指向 VkDebugUtilsMessengerCallbackDataEXT 的结构体指针,这一结构体包含了下面这些非常重要的成员:
pMessage: 一个以 null 结尾的包含调试信息的字符串
pObjects: 存储有和消息相关的 Vulkan 对象句柄的数组
objectCount: 数组中的对象个数
最后一个参数 pUserData 是一个指向我们设置回调函数时传递的数据的指针。
回调函数返回一个 bool 值,用来指示是否应该中止触发 validation layers 消息的 Vulkan 调用。如果回调函数返回 true,则调用中止并触发 VK_ERROR_VALIDATION_FAILED_EXT 错误。通常,只在测试 validation layers 本身时返回 true,其余情况下,回调函数应该返回 VK_FALSE。
定义完回调函数, 接下来要做的就是设置 Vulkan 使用这一回调函数。我们需要一个 VkDebugUtilsMessengerEXT 对象来存储回调函数信息,然后将它提交给 Vulkan 完成回调函数的设置。我们将其设定为 VkValidation 的成员变量。
VkDebugUtilsMessengerEXT debugMessenger;
并为其提供相应的 get 和 set 方法以便在外部对其进行访问。
我们在 VkcoreInstance 类中添加 setupDebugMessenger() 函数,并在最外层的 vulkanapp 类的 initCoreVulkan 函数中对其进行调用:
现在,在 VkValidation 类中添加一个 populateDebugMessengerCreateInfo() 函数用来填写 VkDebugUtilsMessengerCreateInfoEXT 结构的信息:
messageSeverity 域用来指定回调函数处理的消息级别。在这里,我们设置回调函数处理除了 VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT 的所有级别的消息,这使得我们的回调函数可以接收到可能的问题信息,同时忽略掉冗长的一般调试信息。
messageType 域用来指定回调函数处理的消息类型。在这里,我们设置处理所有类型的消息。
pfnUserCallback 域是一个指向回调函数的指针。pUserData 是一个指向用户自定义数据的指针。它是可选的,这个指针所指的地址会被作为回调函数的参数,用来向回调函数传递用户数据。
还有其他许多方法可以配置 validation layers 消息和 debug callbacks。
VkDebugUtilsMessengerCreateInfoEXT 这个结构体应该被当作参数传递给 vkCreateDebugUtilsMessengerEXT 函数以创建 VkDebugUtilsMessengerEXT 对象。不幸的是,由于这是一个 extension functioin,所以不会自动加载,我们必须手动地使用 vkGetInstanceProcAddr 手动地寻找它的地址。我们将创建我们自己的 proxy function 在后台处理这个问题。我们在 VkcoreInstance 中添加一个静态成员函数:
有了以上准备,我们现在可以完成 setupDebugMessenger() 函数:
VkDebugUtilsMessengerEXT 对象需要使用 vkDestroyDebugUtilsMessengerEXT 函数进行清理。和 vkCreateDebugUtilsMessengerEXT 函数一样,它也需要显式地被加载,所以我们同样在 VkcoreInstance 类中添加一个静态成员函数来处理这件事情:
同时,在 VkcoreInstance 的 destroy() 对其进行调用:
4. Debugging instance creation and destruction
vkCreateDebugUtilsMessengerEXT 调用需要创建一个有效的 instance,并且在销毁 instance 之前调用 vkDestroyDebugUtilsMessengerEXT。这样导致的问题是 vkCreateInstance 和 vkDestroyInstance 之中的问题无法被调试。
有一种方式可以专门为这两个函数创建一个单独的 debug utils messager。需要在 VkInstanceCreateInfo 的 pNext 扩展字段中简单地传递一个指向 VkDebugUtilsMessengerCreateInfoEXT 结构的指针:
debugCreateInfo 变量放在 if 语句之外,以确保它不会在 vkCreateInstance 调用之前被销毁。使用此方法创建一个额外的 debug messager,它将在 vkCreateInstance 和 vkDestroyInstance 期间自动使用,并在之后清理。
5. Testing
在 VkcoreInstance 的 destroy 中注释下面的代码:
//DestroyDebugUtilsMessengerEXT(m_instance, m_debugMessenger, nullptr);
运行程序并退出,退出时命令行就会打印出 validation layer 的错误信息。
(注意是要退出才能看到错误信息,不然程序就一直在执行 MainLoop)
代码分支:04_validation
4. Physical devices and queue families
1. Selecting a physical device
通过一个 VKInstance 初始化 Vulkan library 之后,我们需要在系统中寻找并选择一张显卡。可以选择任意数量的显卡并同时使用。在这里选择满足需求的第一张显卡。
为此,我们封装一个 VkcorePhysicalDevice 类进行管理:
并将其作为指针加入 VulkanApp 的成员变量。在 initVulkan() 函数中调用 pickPhysicalDevice() 函数。
2. Queue Families
几乎 Vulkan 中的每个操作,都需要将命令提交到 Queue。不同类型的 Queue 分属不同的 Queue Familes,并且每个 Queue Family 只允许执行特定的一部分指令,比如,可能存在只允许执行计算相关指令的 Queue Family 或者只允许执行内存传输相关指令的 Queue Family。
我们需要检查设备支持哪些 Queue Family。同时,我们需要找到我们所有需要的 Queue Family 来支持我们想要使用的所有指令。
我们建立一个结构体 QueueFamilyIndices:
optional 是一个包装器,在为其分配内容之前,不包含任何值。可以使用 has_value() 查询它是否包含值。
我们添加一个新函数 findQueueFamilies 来查找我们需要的 Queue Familes:
3. Base device suitability checks
为 VkcorePhysicalDevice 类添加一个函数 isDeviceSuitable(),用来验证 Physical Device 是否合适:
然后据此,完成 pickPhysicalDevice() 函数:
至此,我们已经完成了 physical device 的查找。
代码分支:05_physical_device
5. Logical device and queues
1. Introduction
选择好了 physical device,我们需要设置一个 logical device 与之交互。可以为同一 physical device 创建多个 logical device。
我们同样封装一个类来管理:
并将其 create() 调用加入 initCoreVulkan() 函数中。
2. Specifying the queues to be created
Logical device 的创建同样需要在 struct 指定一些细节。
VkDeviceQueueCreateInfo 描述了单个 queue family 所需要的队列数。现在我们只对具有图形功能的队列有兴趣。
Vulkan 允许使用 0.0 到 1.0 之间的浮点数为 Queue 分配优先级以影响命令缓冲区执行的调度。即使只有一个 Queue,这也是必须的:
3. Specifying used device features
VkPhysicalDeviceFeatures 指定了支持的一系列功能,比如几何着色器。我们暂时不包含任何特殊的东西,但一旦开始使用 Vulkan 做更多有趣的事情时,我们就会指定相应的内容。可以通过 vkGetPhysicalDeviceFeatures 函数查询到指定的功能。
VkPhysicalDeviceFeatures deviceFeatures{};
4. Creating the logical device
有了前两个结构,我们来填写 VkDeviceCreateInfo 结构:
首先添加指向 Queue 创建信息和 device features 结构的指针:
其余信息与 VkInstanceCreateInfo 相似,需要指定扩展和 validation layers。不同之处在于这次是对于设备的:
然后,调用 vkCreateDevice 函数来实例化 logical device。参数是与之交互的 physical device,刚刚指定的 queue 及使用信息 ,可选的分配回调指针和指向用于存储 logical device 的 handle。
最后,设定一个 destroy() 函数,用于进行相关的销毁,并在 cleanUp() 中进行调用:
5. Retrieving queue handles
Queues 和 logical device 一起创建,但是我们还没有它们交互的 handle,所以我们在 VkcoreLogicalDevice 类中添加 一个成员变量来存储图形 Queue 的 handle。
当 device 被销毁时,device queue 会被隐式清理,所以不需要进行销毁处理。
我们可以使用 vkGetDeviceQueue 函数来检索每个 queue family 的 queue handle。参数是 logical device,queue family,queue index,和指向存储 queue handle 的指针。因为我们只是从这个 family 中创建一个 queue,所以简单地使用索引 0。
vkGetDeviceQueue(m_device, indices.m_graphicsFamily.value(), 0, &m_graphicsQueue);
代码分支:06_logical_device
4. Presentation
1. Window surface
因为 Vulkan 是一个平台无关的 API,它不能直接与 Window 系统建立连接。我们要在 Vulkan 和 windows 系统之间建立连接用以将结果呈现在屏幕上。
我们需要使用 WSI(Window System Integration) 扩展。这里我们使用的 VK_KHR_surface, 它公开一个 VkSurfaceKHR 对象,该对象代表一种抽象类型的表面可呈现渲染图像。我们使用 GLFW 来得到 VkSurfaceKHR 对象。
VK_KHR_surface 是一个 instance 级别的扩展,它包含在 glfwGetRequiredInstanceExtensions 获取的扩展列表中。该列表还包含了一些其他的 WSI 扩展。
Window surface 需要在 instance 创建后立即创建。因为它会影响到 physical device 的选择。
1. Window surface creation
我们同样封装一个类来管理:
并将它的指针加入到 VulkanApp 类中,并用 glfw 提供的接口完成 surface 的创建函数:
并在 initVulkan 函数中进行调用。
同时,我们需要对其进行销毁:
我们需要确保 surface 在 instance 之前进行销毁。
2. Querying for presentation support
虽然 Vulkan 可能支持 windows 系统集成,但并不意味着系统中的每个设备都支持它。因此我们需要扩展 isDeviceSuitable 函数以确保设备可以呈现出 surface 的图像。
因为呈现是对于 Queue 的功能,因此问题实际上是找到支持呈现我们创建的 surface 的 queue family。
支持图形命令的 queue family 和支持演示的 queue family 实际上有可能不重叠,因此我们需要修改 QueueFamilyIndices,添加成员变量存储呈现 queue family 的索引。
std::optional<uint32_t> presentFamily;
接下来,修改 findQueueFamilies 函数以查找呈现相关的 queue family 并存储其索引:
请注意,它们很可能最终成为同一个 queue family,但在整个程序中,我们会将它们视为独立的 queue,以实现统一的方法。不过,可以添加逻辑首选支持同一 queue 中的绘图和呈现的 physical device,以提高性能。
3. Creating the presentation queue
接下来修改 logical device 的创建过程。我们需要在其中创建呈现(present) queue,并检索对应的 handle。
为此,我们为 VkcoreLogicalDevice 类添加一个成员变量:
VkQueue m_presentQueue;
接下来使用一个 set 创建来自两个 queue family 的 queue:
如果 queue family 相同,则两个 handle 很可能具有相同的值。
代码分支:07_window_surface
2. Swap chain
Vulkan 没有“默认帧缓冲区”的概念,因此我们需要一个 infrastructure 来将存储我们渲染的 buffers,然后才将它呈现到屏幕上。
这在 Vulkan 中称为 swap chain,必须显式创建。
Swap chain 的本质是一个等待呈现在屏幕上的图像 queue。目的使屏幕显示和屏幕刷新率同步。
1. Checking for swap chain support
并非所有的显卡都能直接将图像呈现到屏幕上,例如为服务器设计的显卡可能就没有任何的显示输出。所以我们必须在启用 VK_KHR_swapchain 扩展之前校验是否支持。我们在 isDeviceSuitable 中完成这项工作。
首先在 appenum 中声明所需的扩展列表,类似于要启用 validation layers 的列表:
接着,在 VkcorePhysicalDevice 类中创建 checkDeviceExtensionSupport 函数,并在 isDeviceSuitable 添加其调用:
这里选择使用字符串来表示未确认的所需扩展。这样我们就可以在枚举可用扩展的序列时轻松地勾选它们
2. Enabling device extensions
启用 swap chain 需要 VK_KHR_swapchain 先启用扩展:
createInfo.enabledExtensionCount = static_cast<uint32_t>(deviceExtensions.size());
createInfo.ppEnabledExtensionNames = deviceExtensions.data();
3. Querying details of swap chain support
仅仅查询 swap chains 是否可用还不够,因为它可能与 window surface 不兼容,因此我们还要查询更详细的信息。
我们需要检查三种属性:
基本的 surface 功能(swap chain 中图像的最小/最大数量,图像的最小/最大宽度和高度)
surface 格式(像素格式,色彩空间)
可用的显示模式
类似于 findQueueFamilies,我们使用一个结构体来传递这些信息:
然后在 isDeviceSuitable() 函数中调用它:
return indices.isComplete() && extensionsSupported && swapChainAdequate;
4. Choosing the right settings for the swap chain
如果满足了上文 swapChainAdequate 的条件,就继续为 swap chain 选择正确的设置。这通过几个函数来完成,确定以下三种类型的设置:
surface 格式(颜色深度)
呈现模式(“交换”图像到屏幕的条件)
交换范围(swap chain 中图像的分辨率)
对于这些设置中的每一个,我们都会在脑海中有一个理想值,如果它可用,我们将使用它,否则我们将创建一些逻辑来找到下一个最好的选择。
我们首先定义出一个专门的类来管理 swapChain:
1. Surface format
添加一个成员函数 chooseSwapSurfaceFormat(),入参与 SwapChainSupportDetails 的 formats 相对应:
每个 VVkSurfaceFormatKHR 对象都包含一个 format 和 一个 colorSpace 成员。
format 指定了 color 的 channels 和 types。例如 VK_FORMAT_B8G8R8A8_SRGB 的意思是以 B, G, R, A 通道的顺序,每个通道存 8bit,总共存 32bit 的方式对应每个像素。
color space 成员使用 VK_COLOR_SPACE_SRGB_NONLINEAR_KHR 标志指示是否支持 SRGB 颜色空间。
对于 color space 来说,我们将使用 SRGB(如果可用),因为它很准确,且几乎是图像的标准色彩空间。对于 format 来说,常见的 SRGB 格式是 VK_FORMAT_B8G8R8A8_SRGB。
2. Presentation mode
Presentation mode 可以说是 swap chain 最重要的设置,因为它代表了屏幕上显示图像的实际条件。Vulkan 中有四种可能的呈现模式:
VK_PRESENT_MODE_IMMEDIATE_KHR:应用程序提交的图像会立即传输到屏幕上,这可能会导致撕裂
VK_PRESENT_MODE_FIFO_KHR:swap chain 是一个队列,当显示刷新时,显示从队列的前面获取图像,程序在队列的后面插入渲染图像。如果队列已满,则程序必须等待。这与现代游戏中的垂直同步最为相似。刷新显示的时刻称为“垂直空白”
VK_PRESENT_MODE_FIFO_RELAXED_KHR:如果应用程序迟到并且队列在最后一个垂直空白处为空,则此模式与之前的模式不同。而不是等待下一个垂直空白,图像最终到达时立即传输。这可能会导致可见的撕裂
VK_PRESENT_MODE_MAILBOX_KHR:这是第二种模式的另一种变体。当队列已满时,不会阻塞应用程序,而是将已经排队的图像简单地替换为较新的图像。此模式可用于尽可能快地渲染帧,同时仍然避免撕裂,从而比标准垂直同步产生更少的延迟问题。这就是俗称的“三重缓冲”,虽然单独存在三个缓冲区并不一定意味着帧率被解锁
创建一个成员函数 chooseSwapPresentMode() 用于选择 Presentation mode。如果有足够的资源,我们优先选择 VK_PRESENT_MODE_MAILBOX_KHR。它允许我们渲染尽可能新的图像,同时保持一个较低的延迟。
3. Swap extent
Swap extent 是 swap chain 图像的分辨率,它几乎总是完全等于我们正在绘制的窗口的分辨率。
我们使用 glfwGetFramebufferSize 查询窗口像素的分辨率,然后再将其和最小和最大的图像范围进行匹配。
添加一个成员函数 chooseSwapExtent() 用以选择 swap extent:
5. Creating the swap chain
有了以上辅助函数帮助我们选择创建 swap chain 的信息,我们来完成 swap chain 的 create 函数。
在 create 函数的末尾,我们新增了三个成员变量 sm_swapChainImages, m_swapChainImageFormat, m_swapChainExtent 用于后续使用。
同时,完成对应的销毁函数:
代码分支:08_swap_chain
3. Imge views
在上一节创建 swap chain 的过程中,我们保留了用 vector 存储的 vkImage 对象。现在我们想在渲染管线中使用它。为此,我们需要创建 VkImageView 对象。VkImageView 描述了如何访问图像以及要访问图像的哪一部分。例如,是否应将其视为没有任何 mipmapping 级别的 2D 纹理深度纹理。
为此,我们同样封装一个类来管理 image views:
同时,完成对应的 destroy() 函数:
代码分支:09_image_views
5. Graphics pipeline basics
1. Introduction
Graphics pipeline 是将 mesh 的 vertices 和 textures 渲染成像素的一系列操作。
input assembler
input assembler 从指定的缓冲区收集原始顶点数据,并且还可以使用索引缓冲区来重复使用某些元素,避免同一顶点信息的重复存储
vertex shader
vertex shader 对于每个顶点执行计算,通常会将顶点位置从模型坐标系转换到屏幕坐标系
tessellation shader
tessellation shader 使用某些规则细分几何体以提高 mesh 质量
geometry shader
geometry shader 在每个图元(三角形、线、点)上执行计算。可以丢弃图元或者输出更多的图元。这和 tessellation shader 类似,但更加灵活。
rasterization stage
rasterization stage 将图元离散化为 fragments。任何在屏幕之外的 fragments 都会被丢弃,并且 vertex shader 输出的属性会在 fragments 间进行插值
fragment shader
fragment shader 在每个被保留的 fragment 上执行计算,并确定将 fragment 写入哪个缓冲区及使用哪些颜色和深度值
color blending stage
color blending stage 应用操作来混合映射到帧缓冲区中相同像素的不同 fragment。fragment 可以简单地相互覆盖、相加或根据透明度混合
Vulkan 种 graphics pipeline 几乎是完全不可变的,如果想更改 shader,绑定不同的 framebuffers,或是更改 blend 函数,则必须重新创建 graphics pipeline。这样的缺点是需要创建许多 graphics pipeline 来表示在渲染操作中使用的所有不同状态的组合。优点是因为在 pipeline 中所有操作都是预先知道的,因此驱动程序可以更好地进行优化。
2. Shader modules
Vulkan 中着色器代码必须以 SPIR-V 字节码格式指定。而不是 GLSL 和 HLSL 等可以阅读的语法。
不过我们仍然可以使用 GLSL 书写我们的着色器,然后在 Vulkan SDK 中使用 glslangValidator.exe 去把它编译成字节码。
1. Vertex Shader
Vertex shader 为每个传入的顶点执行计算。输入的顶点属性包括 world position, color, normal, texture coordinate 等,输出的是裁剪坐标以及需要传递给 fragment shader 的属性。经过 vertex shader 计算后,这些值将被传递给 rasterizer 进行插值以产生平滑的过渡。
Vertex shader 输出的裁剪坐标是一个四维向量,我们将整个向量除以最后一个分量将其转化为 NDC (normalized device coordinates) 坐标,它的范围在 (-1, 1) 之间。
我们先将顶点坐标硬编码在 vertex shader 中:
2. Fragment shader
对于 fragment shader,我们同样先简单处理:
3. Compiling the shaders
现在,我们需要将 shader 转换为 SPIR-V 字节码。
编写如下 bat 文件并执行:
D:/Vulkan/Bin/glslc.exe triangle.vert -o trianglevert.spv
D:/Vulkan/Bin/glslc.exe triangle.frag -o trianglefrag.spv
pause
会在我们编写 shader 的同一目录下生成对应的 spv 文件。
4. Manage graphics pipeline
后文的内容都会基于 graphics pipeline 展开,因此,我们需要封装一个类专门负责管理:
5. Loading a shader
对于 shader,我们同样封装一个类来进行管理:
添加一个静态成员函数 realFile 用于读取 shader 文件:
6. Creating shader modules
为 VkShader 类添加 create 和 createShaderModule 函数:
我们现在有了我们的 shader 结构,并且以枚举的形式区分了不同类型的 shader,现在需要在 VkGraphicsPipeline 中引入 shader 结构。
我们添加两个成员变量分别代表 vertex shader 和 fragment shader:
std::shared_ptr<VkShader> m_vertexShader;
std::shared_ptr<VkShader> m_fragmentSahder;
据此,我们可以先填写一部分的 graphics pipeline 的创建函数:
代码分支:10_shader_modules
3. Fixed functions
较旧的图形 API 为 graphics pipeline 的大部分阶段提供默认状态,但是在 Vulkan 中,必须明确设定大多数 graphics pipeline 的状态。因为它将被烘焙到一个不可变的 pipeline 状态对象中。
1. Dynamic state
虽然 pipeline 的大部分状态需要烘焙到 pipeline state 中,但改变一小部分的状态而不重建 pipeline 是可以做到的。例如视口的大小,线宽和 blend constants 这些状态。如果想要动态设定这些状态并将保存在外面,需要填写 VkPipelineDynamicStateCreateInfo 结构体。这个过程仍在 VkGraphicsPipeline 的 create 函数中。
这种做法将使这些值的配置被忽略。我们可以并且必须在绘制的时候指定这些数据。对于 viewport 和 scissor state 等来说这样的设置更加灵活,同时也更加复杂。
2. Vertex input
VkPipelineVertexInputStateCreateInfo 结构描述了将传递给顶点着色器的顶点数据的格式。它大致以两种方式描述这一点:
Bindings:数据之间的间距和数据是按逐顶点的方式还是按逐实例的方式 进行组织
Attribute descriptions:传递给顶点着色器的属性类型,用于将属性绑定到顶点着色器中的变量
不过我们现在是在 vertex shader 中对顶点数据进行了硬编码,所以在这里先进行简单处理:
3. Input assembly
VkPipelineInputAssemblyStateCreateInfo 结构描述了两件事:将从点点绘制什么样的几何图形,以及 primitive restart should be enabled。
前者在 topology 成员中指定,可以具有如下值:
VK_PRIMITIVE_TOPOLOGY_POINT_LIST: points from vertices
VK_PRIMITIVE_TOPOLOGY_LINE_LIST: line from every 2 vertices without reuse
VK_PRIMITIVE_TOPOLOGY_LINE_STRIP: the end vertex of every line is used as start vertex for the next line
VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST: triangle from every 3 vertices without reuse
VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP: the second and third vertex of every triangle are used as first two vertices of the next triangle
通常,顶点是按照索引顺序从 vertex buffer 中加载的,但是使用 element buffer 的话就可以自己指定使用自己的索引。这样做可以执行优化,比如重复使用顶点。如果将 primitiveRestartEnable 成员设置为 VK_TRUE,则可以 通过特殊索引中的 0xFFFF 或者 0xFFFFFFFF 分解拓扑模式中的之间和三角形。
我们准备绘制三角形,所以使用以下定义:
4. Viewports and scissors
Viewport 是 framebuffer 将被渲染输出的区域。大多数情况下都是从 (0, 0) 到 (width, height)。
Swap chain 的图像大小可能和窗口大小不同,swap chain 图像在之后会被用作 frame buffer,所以我们在这里设定 viewport 的大小为 swap chain 图像的大小。
Viewport 定义了图像到 frame buffer 的映射关系,裁剪矩形定义了哪一区域的像素实际被存储在 frame buffer。任何位于裁剪矩形之外的像素都会在光栅化时被丢弃。
如上文所言,我们动态地指定视口和裁剪矩形,这使得我们可以并且必须在绘制时指定二者,而无需在改变二者时重新建立 pipeline。
我们在上文中已经为 viewport 和 scissor 开启了动态 state,这里只需要在 pipeline 创建时指定其计数:
5. Rasterizer
Rasterizer 从顶点着色器中获取由顶点组成的几何体,并将其转换为要由片段着色器着色的片段。它还执行 depth testing, face culling 和 the scissor test。并且可以设定是否使用线框渲染。
所有的这些都由 VkPipelineRasterizationStateCreateInfo 结构配置。
6. Multisampling
VkPipelineMultisampleStateCreateInfo 结构配置多重采样,这是进行抗锯齿的方法之一。现在暂时禁用。
7. Depth and stencil testing
如果想要使用深度缓冲或模板缓冲,需要配置 VkPipelineDepthStencilStateCreateInfo。现在暂时不需要。
8. Color blending
Fragment shader 返回颜色以后,需要将其与 framebuffer 中已有的颜色组合,这就是 color blending。有两种方式可以实现:
Mix the old and new value to produce a final color
Combine the old and new value using a bitwise operation
9. Pipeline layout
为了在 shader 中使用 uniform 变量(常常用于将变换矩阵传递给 shader),我们需要在管线创建期间通过 VkPipelineLayout 对象指定。但现在不使用它们。我们需要在 VkGraphicsPipeline 中将其存为成员变量,因为我们需要在相应的 destroy 函数中销毁它们:
代码分支:11_fixed_functions
4. Render passes
1. Setup
在 pipeline 创建之前,我们需要告诉 Vulkan 渲染时将使用的 framebuffer attachments。我们需要指定将有多少颜色和深度缓冲区,以及每个缓冲区如何进行采样和处理。所有这些信息都被封装在 render pass 对象中。
我们封装一个类用以专门管理 renderpass:
2. Attachment description
目前,我们只有一个代表 swap chain 图像的 color buffer attachment:
loadOp 和 storeOp 确定在渲染之前如何处理 attachment 中的数据,对于 loadOp 我们有以下选择:
VK_ATTACHMENT_LOAD_OP_LOAD:保留 attachment 的现有内容
VK_ATTACHMENT_LOAD_OP_CLEAR:使用一个常量来清除 attachment 的内容
VK_ATTACHMENT_LOAD_OP_DONT_CARE:attachment 现有内容未定义或者我们并不关心
我们使用第二种选择,在绘制新的 frame 之前将 frame buffer 清除为黑色。
storeOp 有以下两种选择:
VK_ATTACHMENT_STORE_OP_STORE:将渲染内容存储在内存中,稍后可以读取、
VK_ATTACHMENT_STORE_OP_DONT_CARE:渲染后,不读取 frame buffer 的内容
我们想在屏幕上看到渲染的图像,所以选择第一种。
colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
loadOp 和 storeOp 的设置对于颜色缓冲和深度缓冲生效。stencilLoadOp / stencilStoreOp 对于模板缓冲生效,我们现在没有使用模板缓冲,所以此设置其不关心即可:
colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
colorAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
Vulkan 中的 texture 和 frame buffer 由特定像素格式的 VkImage 对象来表示,图像的像素在内存中的分布取决于我们对图像进行的操作。一些常见的图像内存布局是:
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL:图像被用作 color attachment
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR:图像在 swap chain 中用于呈现
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL:图像被用作复制操作的目标
在这里我们对初始 layout 使用未定义 layout,意味着我们不关心以前的 layout。而渲染后使用 swap chain 进行呈现。所以 final layout 使用 VK_IMAGE_LAYOUT_PRESENT_SRC_KHR。
3. Subpasses and attachment references
单个 render pass 可能包括多个 subpass。Subpass 是后续渲染操作,依赖于先前 render pass 中 frame buffer 的内容。例如,许多叠加的后期处理效果就是在上一次的处理结果上进行的。如果将后续的一些操作组合到一个 subpass 中,Vulkan 就能够对其优化节省内存。我们现在只使用单个 subpass。
每个 subpass 可以引用一个或者多个 attachment,这些引用的 attachment 是通过 VkAttachmentReference 结构指定的:
subPass 使用 VkSubpassDescription 结构进行描述:
4. Render pass
现在我们已经设置好 attachment 和引用它的 subpass,现在可以来创建 render pass 本身。
填写完 create 函数,我们来完成 destroy 函数:
代码分支:12_renderpass
5. Conclusion
现在我们可以结合前面所有章节的结构和对象来创建 pipeline。我们现在拥有的对象类型有:
Shader stages: the shader modules that define the functionality of the programmable stages of the graphics pipeline
Fixed-function state: all of the structures that define the fixed-function stages of the pipeline, like input assembly, rasterizer, viewport and color blending
Pipeline layout: the uniform and push values referenced by the shader that can be updated at draw time
Render pass: the attachments referenced by the pipeline stages and their usage
所有这些结合起来完全定义了 pipeline 的功能。我们现在往 VkcoreGraphicsPipeline 里面添加一个 VkPipeline 成员变量, 并来最终完成 其 create() 函数:
同时更新 destroy 函数:
vkDestroyPipeline(pLogicalDevice->getDevice(), m_graphicsPipeline, nullptr);
代码分支:13_graphics_pipeline
6. Drawing
1. Framebuffers
我们必须为 swap chains 中的所有图像创建一个 framebbuffer,并在绘制时使用与检索到的 image 对应的 framebuffer。
我们使用一个类用于专门管理 frame buffers:
并填写完成相应的 create 和 destroy 函数:
代码分支:14_frame_buffers
2. Command buffers
Vulkan 中的命令,如绘图和内存操作不是直接使用函数调用执行的。我们必须在 command buffer 对象中记录要执行的所有操作。这样做的好处是,当我们准备好告诉 Vulkan 我们想做什么时,所有的命令都会一起提交,Vulkan 可以更有效地处理这些命令,因为它们都是同时可用的。此外,如果需要,可以在多个线程中进行命令记录。
1. Command pools
在创建 command buffers 之前,我们必须先创建 command pool。Command pool 用于管理 command buffers 的内存。
我们同样封装一个类用于专门管理 command pool:
完成其 create() 和 destroy() 函数:
2. Command buffer allocation
我们现在来分配 command buffers。
同样使用一个类来进行管理:
3. Command buffer recording
添加一个 recordCommandBuffer 函数,它将我们想要执行的命令写入 command buffers。
我们总是通过调用 vkBeginCommandBuffer 来开始记录 command buffer。
代码分支:15_command_buffer
3. Rendering and presentation
1. Outline of a frame
在 VulkanApp 类中添加一个 drawFrame() 函数,并在 mainLoop() 函数中调用。
在 Vulkan 中渲染一帧通常由以下步骤组成:
等待上一帧完成
从 swap chain 获取图像
记录一个将场景绘制到图像的 command buffer
提交 command buffer
呈现 swap chain image
2. Synchronization
Vulkan 的核心设计理念是 GPU 上的执行同步是明确的。操作的顺序由我们使用的各种同步原语来定义。这些同步原语告诉驱动程序我们所希望的事物运行的顺序。许多在 GPU 上执行的 Vulkan API 是异步的,这些函数会在操作完成前返回。
所以我们需要显式地排列一些事件的顺序,因为它们发生在 GPU 上:
从 swap chain 获取图像
执行在获取到的图像上的绘制命令
将图像呈现到屏幕上显示,并将其返回 swap chain
以上事件都是使用单个函数调用启动的,但都是异步执行的。这些函数会在操作实际完成前返回,且执行的顺序也是未定义的。我们不希望这样的结果,因为每一项操作都依赖于前一项操作的完成。所以我们需要使用原语实现所期望的顺序。
1. Semaphores
Semaphore 用于在队列操作之间添加顺序。队列操作是指我们提交到队列的工作,可以是在 command buffer 中,也可以是从我们稍后将看到的函数中。队列的示例是图形队列和演示队列。Semaphore 于在同一队列内和不同队列之间对工作进行排序。
Vulkan 中有两种信号量,binary 和 timeline。我们只讨论 binary。
一个 semaphore 要么是 unsignaled 状态的,要么是 signaled 状态的。从一开始,它处于 unsignaled 状态。
我们使用 semaphore 对队列操作进行排序的方式是在一个队列操作中提供与另一个队列信号量相等的 semaphore。前者作为 "signal",后者作为 "wait"。
例如,假设我们有 semaphore S 以及要按顺序执行的队列操作 A 和 B,我们告诉 Vulkan 的是,操作 A 在执行完成时将 semaphore S 发出 "signal",而操作 B 在开始执行之前维持 semaphore S 的 "wait" 状态。当操作 A 完成时,操作 B 收到信号才能开始执行。
2. Fences
Fences 同样是为了同步。它用于 CPU 上命令的执行。如果 CPU 想知道 GPU 何时完成某些操作,我们可以使用 fences。
同 semaphores 类似,fences 要么是 unsignaled 状态的,要么是 signaled 状态的。每当我们提交要执行的工作时,我们都可以为该工作附加一个 fence,工作完成后,fence 置为 signaled 状态。这样我们就可以保证当某项特定的工作完成之后,CPU 能够获知。
上面的意思是 A 完成之后,CPU 才能继续执行。但一般来说,我们并不想让 CPU 置于闲置状态。
总的来说,semaphores 用于指定 GPU 上操作的执行顺序,而 fences 用于保持 CPU 和 GPU 的同步。
3. What to choose
我们需要在两个地方应用同步:swap chain 操作和等待前一帧完成。对于前者,我们使用 semaphore,因为它发生在 GPU 上。对于后者,我们使用 fence 使 CPU 等待,这样我们每次就不会绘制超过一帧。
3. Creating the synchronization objects
我们需要一个 semaphore 来表示已从 swap chain 获取图像并准备好渲染,另一个 semaphore 表示渲染已完成并可以进行显示。还需要一个 fence 来确保每次仅渲染一帧。
我们使用一个类来管理同步工作:
同时完成其 create 和 destroy 函数:
4. Draw frame
现在来完成 drawFrme() 函数:
至此,我们已经绘制出了一个完整的三角形。
代码分支: 16_rendering_presentation