Vulkan Tutorial 教程翻译(三) 绘制三角形 2.1 安装

绘制三角形

安装

基础代码

总体结构

在前一章,已经创建了基本的Vulkan工程,并用测试代码测试了它。在这一章我们将从以下的代码开始:

c++ 复制代码
#include <vulkan/vulkan.h>

#include <iostream>
#include <stdexcept>
#include <cstdlib>

class HelloTriangleApplication{
public:
    void run(){
        initVulkan();
        mainLoop();
        cleanup();
    }

private:
    void initVulkan(){
    }

    void mainLoop(){
    }

    void cleanup(){
    }
};

int main(){
    HelloTriangleApplication app;

    try{
        app.run();
    }catch(const std::exception &e){
        std:err << e.what() << std::endl;
        return EXIT_FAILURE;
    }

    return EXIT_SUCCESS;
}

首先从LunarG SDK中包含Vulkan的头文件,它提供了函数,结构体及枚举类型。stdexceptiostream 头文件用于报告和打印错误日志。cstdlib头文件提供了 EXIT_SUCCESS , EXIT_FAILURE 这两个宏定义。

程序整个被包裹在一个类中,我们用这个类来保存Vulkan的对象,也会在未来添加函数用于初始化这些Vulkan对象。这些都会在initVulkan函数中被调用。一切都准备就绪后,我们会进入主循环开始每一帧的渲染。会在mainloop函数里添加一个循环,直到窗口退出才会结束此循环。会在cleanup中确保资源被回收。

一旦有任何致命的错误发生,都会抛出一个包含了错误描述的的std::runtime_error 对象,它将会被上抛到main函数中,在控制台被输出。为了处理各种异常,我们捕获了最基础的std::exception ,很快我们就会看到一个扩展不支持的异常。

大体上,每一章都会增加一个新的函数,创建一个新的Vulkan对象,并在程序退出时将对象清理掉。

资源管理

就像每一块通过malloc分配的内存都需要free掉一样,每一个我们不再需要的Vulkan对象都需要被显示的销毁掉,在C++中使用智能指针或者RAII是可能实现资源的自动管理的,但是,在这个教程中,我们会显示的分配及销毁Vulkan对象,毕竟Vulkan的优势就是显示明确地操作对象以避免错误,所以对学习Vulkan而言,显示地分配管理对象是有好处的。

在这个教程结束之后,你可以通过构造及析构函数,或者向智能指针提供自己的回收器,这视你的需求而定。RAII技术对大型的Vulkan程序是很合适的。但是对于目前的学习阶段,知道后面发生了什么,肯定更好。

Vulkan的对象要么是通过vkCreateXXX创建出来的,要么是通过vkAllocateXXX分配出来的,在确保资源已经不需要使用后,需要通过对应的vkDestroyXXX ,vkFreeXXX 来销毁它们。这些函数的参数有着各种各样的参数类型,但是都有一个相同的参数pAllocator,这是一个自定义的函数,可以让你自定义内存的分配,在教程中,我们忽略这个参数,给它传nullptr。

集成GLFW库

如果你想使用离屏渲染,Vulkan在没有窗口的情况下可以工作的很好。但显示出一些东西来不是更让人兴奋吗, 我们先用以下代码替换掉原有的#include <vulkan/vulkan.h> :

c++ 复制代码
#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>

GLFW头文件会包含自己的头文件库,并自动导入Vulkan头文件。增加一个initWindow 函数 并在run()函数中去调用它,我们会使用这个函数去初始化GLFW库,并创建出一个窗口。

c++ 复制代码
void run(){
    initWindow();
    initVulkan();
    mainLoop();
    cleanup();
}

void initWindow(){

}

initWindow第一句调用应该是 glfwInit() 这用于初始化GLFW库,因为GLFW库最开始是被用于创建OpenGL上下文的,我们需要告诉它在这里不需要创建OpenGL环境。使用如下代码:

scss 复制代码
glfwWindowHint(GLFW_CLIENT_API,GLFW_NO_API);

因为处理窗口大小改变事件会耗费额外的精力,我们先让窗口大小不可变

scss 复制代码
glfwWindowHint(GLFW_RESIZABLE,GLFW_FALSE);

接下来的工作就是去实际创建一个窗口了,添加一个GLFWwindow *window; 将这个对象保存为一个私有的类成员对象,然后再初始化这个window。

ini 复制代码
windows = glfwCreateWindow( 800, 600, "Vulkan" , nullptr, nullptr);

前三个参数指定窗口的宽,高和标题,第四个参数指定在哪个显示器上显示窗口,最后一个参数仅对OpenGL有效,这里不管传nullptr。

使用常量代替硬编码是一个好的编程习惯,因为我们可能会在未来多次使用这些常量,在class内增加以下代码:

ini 复制代码
const uint32_t WIDTH = 800;
const uint32_t HEIGHT = 600;

然后再替换之前的代码

ini 复制代码
windows = glfwCreateWindow( WIDTH, HEIGHT, "Vulkan" , nullptr, nullptr);

现在你的initWindow函数看起来应该是如下这样的;

scss 复制代码
void initWindow(){
    glfwInit();

    glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
    glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE);

    window = glfwCreateWindow(WIDTH, HEIGHT, "Vulkan", nullptr, nullptr);
}

为了让应用程序持续运行,直到发生错误或者窗口关闭才退出,我们在mainloop函数中增加一个事件循环.

javascript 复制代码
void mainLoop() {
    while (!glfwWindowShouldClose(window)) {
        glfwPollEvents();
    }
}

以上代码不难理解,它循环检测是否有用户关闭窗口的事件发生,这也是之后我们渲染操作需要添加的代码位置。

一旦窗口被关闭,需要清理分配的资源,结束GLFW库,在cleanup()中添加以下代码:

scss 复制代码
void cleanup(){
    glfwDestoryWindow(window);

    glfwTerminate();
}

当你运行此程序时,你将会看到一个标题是Vulkan的窗口显示出来,知道其被关闭掉。现在我们已经有了Vulkan程序的骨架,接下来就开始创建第一个Vulkan对象吧。

实例

创建一个实例

初始化Vulkan库第一件要做的事就是创建一个实例 instance .实例连接了你的应用程序和Vulkan库,应用程序涉及要向驱动提供一些指定的细节。

声明一个createInstance()函数,并且在initVulkan()中调用它

scss 复制代码
void initVulkan(){
    createInstance();
}

新增一个类成员去存储这个实例

arduino 复制代码
private:
    VkInstance instance;

创建一个实例,我们需要填写一个结构体,以此向应用提供一些信息,这些数据总体来说都是可选的,不过也许会给驱动层提供一些更好的优化建议(比如一些知名的游戏引擎做的特殊操作)。这个结构体被交过VkApplicationInfo

ini 复制代码
void createInstance(){
    VkApplicationInfo appInfo{};
    appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
    appInfo.pApplicationName = "Hello Triangle";
    appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0);
    appInfo.pEngineName = "No Engine";
    appInfo.engineVersion = VK_MAKE_VERSION(1, 0, 0);
    appInfo.apiVersion = VK_API_VERSION_1_0;
}

如前所述,许多的Vulkan结构体需要你指定sType类型成员。 许多Vulkan的信息事通过结构体传递的而不是函数参数,为了创建实例,需要提供一个或多个充足的结构体信息,下一个结构体就不是可选的了,它告诉驱动,我们希望使用什么样的全局扩展及验证层。全局在这里意味着整个程序都可用,而不仅限于当前实例。下一节会继续讨论。

ini 复制代码
VkCreateInstanceInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
createInfo.pApplicationInfo = &appInfo;

前两个参数意义很明显,后面两个与层相关的参数指出了需要的全局扩展。在概览中已经提到过,Vulkan是一个平台无关的API,所以需要通过扩展来与窗口系统交互,GLFW库已经内置了获取扩展的函数,可以直接将它们传给实例创建的结构体。

ini 复制代码
uint32_t glfwExtensionCount = 0;
const char **glfwExtensions;

glfwExtensions = glfwGetRequiredInstanceExtensions(&glfwExtensionCount);

createInfo.enabledExtensionCount = glfwExtensionCount;
createInfo.ppEnabledExtensionNames = glfwExtensions;

结构体的最后两个成员,决定了全局可用的验证层。我们下一章深入讨论,现在先把它设置为空。

ini 复制代码
createInfo.enabledLayoutCount = 0;

现在已经指定了创建Vulkan实例所需要的所有东西,可以发起vkCreateInstance的调用了:

ini 复制代码
VkResult result = vkCreateInstance(&createInfo, nullptr, &instance);

如您所见,创建Vulkan对象的函数参数都向如下这样:

  • 一个指向创建信息的结构体
  • 一个自定义的分配器回调, 本教程都是nullptr
  • 一个指向新创建对象的变量handle 如果一切正常,指向实例的句柄就会被存储到类成员中了,几乎所有的Vulkan函数都会返回一个类型为VkResult的对象,其要么是成功,要么是一个错误码。检查以下实例是否被成功创建,并不需要存下结果,只需要检查是否成功.
scss 复制代码
if(vkCreateInstance(&createInfo, nullptr , &instance) != VK_SUCCESS){
    throw std::runtime_error("falied to create instance!");
}

现在运行程序,确保实例被成功创建。

遇到VK_ERROR_INCOMPATIBLE_DRIVER

如果在macos上使用MoltenVK,也许在创建CreateInstance的时候会得到 VK_ERROR_INCOMPATIBLE_DRIVER错误。自Vulkan 1.3.216 版本开始,扩展 VK_KHR_PORTABILITY_subset 需要手动指定。

为了解决这个问题,在VkInstanceCreateInfo结构体中添加 VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR 标志位,然后添加扩展VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME 到实例创建结构体中.

实例代码如下:

ini 复制代码
std::vector<const char *> requiredExtensions;
for(uint32_t i = 0; i < glfwExtensionCount ; i++) {
    requiredExtensions.emplace_back(glfwExtensions[i]);
}//end for i
requiredExtensions.emplace_back(VK_KHR_PORTABILITY_ENUMERATION_EXTENSION_NAME);

createInfo.flags != VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR;

createInfo.enabledExtensionCount = static_cast<uint32_t>(requiredExtensions.size());
createInfo.ppEnabledExtensionNames = requiredExtensions.data();

if(vkCreateInstance(&createInfo , nullptr , &instance) != VK_SUCESS) {
    throw std::runtime_error("failed to create instance!");
}
检查对扩展的支持

如果看一眼vkCreateInstance的文档,可以发现一个可能的错误码是 VK_ERROR_EXTENSION_NOT_PRESENT。我们可以简单的指定扩展,当返回错误码时直接终止程序。这对于基础的扩展如窗口系统扩展时有效的,但如果我们想要一些可选的功能呢?

为了在创建实例之前获取到一个支持扩展的列表,有一个vkEnumerateInstanceExtensionProperties函数,它接收一个指针变量,返回扩展个数 和 一个类型为VkExtensionProperties的数组变量,用于返回扩展的细节。还有第一个可选参数,允许用户过滤指定的验证层,在这里,我们先忽略掉。

为了分配存储了扩展细节数据的数组,首先我们要知道有多少扩展。可以将其他参数都设置为空去查询有多少个扩展。

ini 复制代码
uint32_t extensionCount = 0;
vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount, nullptr);

现在分配一个数组,去存储扩展的细节数据(#include <vector>)

c 复制代码
std::vector<VkExtensionProperties> extensions(extensionCount);

最后就可以查询到这些扩展的细节了。

scss 复制代码
vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount, extensions.data());

每一个VkExtensionPerproties结构体都包含一个扩展的名称及版本,可以通过一个简单的for循环将其列出

c 复制代码
std::cout << "available extensions:\n";
for(const auto& extension : extensions){
    std::cout << "\t" << extension.extensionName << "\n";
}

如果想提供一些Vulkan支持的细节,可以将以上代码加入到CreateInstance函数中,作为练习可以试着去创建一个函数检查从glfwGetRequiredInstanceExtensions返回的扩展都是被支持的。

资源清理

在程序退出时,需要清理实例对象。它可以在cleanup()函数中,通过调用vkDestroyInstance函数去销毁实例。

scss 复制代码
void cleanup() {
    vkDestroyInstance(instance, nullptr);

    glfwDestroyWindow(window);
    glfwTerminate();
}

vkDestroyInstance的参数很直接,分配和回收功能在Vulkan中都是一个可选的回调参数,直接传nullptr。后面所有创建的Vulkan资源,都需要在instance销毁前被清理掉。

实例创建完成后,进入更复杂的主题前,是时候通过验证层来评估以下我们的调试选项了。

验证层

什么是验证层

Vulkan的API是以最小化驱动负载为目标来设计的,此目标的一个表现就是会限制默认的错误检查。即使是最简单的枚举值传错或参数为空也不会被处理,而是直接崩溃或导致未定义的行为。由于Vulkan需要开发者显示地去处理每一件事情,很容易产生一些小的错误,例如,使用了一个新的GPU特性但在创建逻辑设备时忘记去请求它。

然而,这并不意味着这些检查不可以被加入到API中。Vulkan引入了一个称之为验证层的优雅解决方案,验证层是一个可选的组件,它可以hook进Vulkan的函数中,以添加额外的一些操作。 通常,验证层的操作有:

  • 检查参数值以判断是否是错误的调用
  • 跟踪对象的创建和析构事件以发现对象是否泄露
  • 跟踪线程的原始调用检查是否线程安全
  • 每一个调用的日志与参数都做标准化的输出
  • 跟踪Vulkan的调用以实现分析和重新还原现场

以下是一个实现诊断功能的验证层实现的例子:

arduino 复制代码
VkResult vkCreateInstance(
    const VkInstanceCreateInfo *pCreateInfo,
    const VkAllocationCallbacks *pAllocator,
    VkInstance *instance
    ){
        if(pCreateInfo == nullptr || instance == nullptr){
            log("Null pointer passed to required parameter!");
            return VK_ERROR_INITILAZATION_FAILED;
        }

        return real_vkCreateInstance(pCreateInfo , pAllocator, instance);
    }

这些验证层可以自由地嵌入所有你感兴趣地调试功能,可在调试时开启它,在发布时禁用它,两个场景均可受益。

Vulkan本身并没有任何内建的验证层,但LunarG Vulkan SDK提供了一系列很好用的层,可用于常规的错误检测。它们也是完全开源的。因此你可以查看他们查看他们检测了哪些错误,使用验证层是最好的避免在不同驱动上引发未定义的崩溃的方法。

验证层只有被安装到系统上才可以使用,通过LunarG SDK可以获取到。

使用验证层

在这一节,我们将看到怎样为Vulkan SDK打开标准的诊断层,与扩展一样,要打开验证层,需指定它的名字。所有有用的标准验证层都被打包绑定在SDK中称为VK_LAYER_KHRONOS_validation。

首先在程序中新增两个变量,标识验证层是否可用。我这里选择了一个基础的标识程序是否是debug编译的变量,NDEBUG宏是C++标准的一部分,指的是"不是debug"。

arduino 复制代码
const uint32_t WIDTH = 800;
const uint32_t HEIGHT = 600;
const std::vector<const char *> validateLayers = {
    "VK_LAYER_KHRONOS_validation"
};

#ifdef NDEBUG
const bool enableValidationLayers = false;
#else
const bool enableValidationLayers = true;
#endif

我们新增一个函数 checkValidationLayerSupport 用于检查是否请求的层是可用的。首先使用函数vkEnmuerateInstanceLayerProperties列出所有可用的层。它的使用与之前提到的vkEnumerateInstanceExtensionProperties类似,下一步,检查是否所有的层都存在在availableLayers中。可能需要包含头文件<cstring>,里面有字符串判断函数strcmp。

arduino 复制代码
bool checkValidationLayerSupport() {
    uint32_t layerCount;
    vkEnumerateInstanceLayerProperties(&layerCount, nullptr);

    std::vector<VkLayerProperties> availableLayers(layerCount);
    vkEnumerateInstanceLayerProperties(&layerCount , availableLayers.data());

    for(const char *layerName : validationLayers){
        bool layerFound = false;

        for(const auto &layerProperties : availableLayers){
            if(strcmp(layerName, layerProperties.layerName) == 0){
                layerFound = true;
                break;
            }
        }

        if(!layerFound){
            return false;
        }
    }//end for each

    return true;
}

现在可以在createInstance中使用这个函数了

scss 复制代码
void createInstance(){
    if(enableValidationLayers && !checkValidationLayerSupport()){
        throw std::runtime_error("validation layer requested, but not available!");
    }

    ...
}

现在以debug模式运行程序,确保没有错误发生。

最后修改VkCreateInstanceInfo结构体的设置信息,如果验证层打开,去包含验证层的数据

ini 复制代码
if(enableValidationLayers){
    createInfo.enableLayerCount = static_cast<uint32_t>(validationLayers.data());
    createInfo.ppEnableLayerNames = validationLayers.data(); 
}else{
    createInfo.enableLayerCount = 0;
}

若检查成功,vkCreateInstance就不会返回VK_ERROR_LAYER_NOT_PRESENT错误了,不过你需要亲自运行一下确保无误。

消息回调

默认情况下验证层会打印出debug信息到标准输出中。但是我们也可以通过提供一个显示的回调来自行处理。这允许你决定应该显示出何种消息,因为并不是所有的错误都需要被打印输出出来,如果想输出所有,这节可以跳过。

为了在程序中安装一个回调去处理有关的消息,必须通过 VK_EXT_debug_utils 扩展来安装一个调试messenger。

我们首先创建一个getRequiredExtensions函数,它返回一个验证层是否可用的扩展列表。

c 复制代码
std::vector<const char *> getRequiredExtensions(){
    uint32_t glfwExtensionCount = 0;
    const char **glfwExtensions;
    glfwExtensions = glfwGetRequiredInstanceExtensions(&glfwExtensionCount);

    std::vector<const char*> extensions(glfwExtensions, glfwExtensions + glfwExtensionCount);

    if(enableValidationLayers){
        extensions.push_back(VK_EXT_DEBUG_UTILS_EXTENSION_NAME);
    }

    return extensions;
}

变量extensions 指定了GLFW所必须的扩展,但调试messenger需要的这个扩展却是可选的,注意,我们这里使用了 VK_EXT_DEBUG_UTILS_EXTENSION_NAME 宏定义,以代替字符串 "VK_EXT_debug_utils" ,使用这个宏可以让你避免拼写上的错误。

现在可以在createInstance中使用这个函数了

ini 复制代码
auto extensions = getRequiredExtensions();
createInfo.enabledExtensionCount = static_cast<uint32_t>(extensions.size());
createInfo.ppEnabledExtensionNames = extensions.data();

运行程序,确保不会接收到 VK_ERROR_EXTENSION_NOT_PRESENT 错误,我们并不需要真正去检查这些扩展的存在,因为在验证层中,它是隐式可用的。

现在让我们看看调试的回调函数长什么样。增添一个新的名为 debugCallback 静态成员函数,原型是 PFN_vkDebugUtilMessengerCallbackEXT, VKAPI_ATTR 与 VKAPI_CALL 用于确保这个函数有正确的函数签名,可以让Vulkan能正确无误地调用.

c 复制代码
static VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback(
    VkDebugUtilsMessengerSeverityFlagBitsEXT messageSeverity,
    VkDebugUtilsMessengerTypeFlagsEXT messageType,
    const VkDebugUtilMessengerCallbackDataEXT *pCallbackData,
    void *pUserData
){
    std:cerr << "validation layer : " << pCallbackData->pMessage << std::endl;
    return VK_FALSE;
}

第一个参数指定了消息地重要等级,可取以下的值:

  • VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT : 诊断信息
  • VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT : 提示性消息,如资源的创建
  • VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT : 警告性消息,如潜在的bug
  • VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT : 不合法可能会引发崩溃的消息

可以使用比较操作符来检出需要关注的消息,如下:

scss 复制代码
if(messageSeverity >= VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT){
    // Message is important enough to show
}

messageType 参数有以下取值:

  • VK_DEBUG_UTIL_MESSAGE_TYPE_GENERAL_BIT_EXT : 发生的事件都是正常事件
  • VK_DEBUG_UTIL_MESSAGE_TYPE_VALIDATION_BIT_EXT : 发生的事件违反了规范或者有潜在的错误
  • VK_DEBUG_UTIL_MESSAGE_TYPE_PERFORMANCE_BIT_EXT : 有潜在的性能问题

参数pCallbackData 指向一个 VkDebugUtilsMessengerCallbackDataEXT 的结构体,它包含着这个消息自身的细节信息,其中最重要的几个成员:

  • pMessage : 一个C字符串的调试信息
  • pObjects : 与这条消息关联的Vulkan对象
  • objectCount : 上面对象的数量

最后,pUserData 参数允许你给回调函数传入一些预先设置好的自定义数据。此回调函数返回一个布尔值,以决定是否验证层的消息可以被终止。如果返回true,调用就会返回错误码 VK_ERROR_VALIDATION_FAILED_EXT,通常都是返回 VK_FALSE.

最后需要告诉Vulkan这个回调函数的信息,调试的回调需要被显示的创建及销毁。这个回调时调试messenger的一部分,你可以增加任意数量的回调。在instance实例下面,添加一个新的成员变量。

ini 复制代码
VkDebugUtilMessengerEXT debugMessenger;

现在添加一个 setupDebugMessenger()函数在createInstance的后面,

scss 复制代码
void initVulkan(){
    createInstance();
    setupDebugMessenger();
}

void setupDebugMessenger(){
    if(!enableValidationLayer){
        return;
    }
}

需要填写一个关于Messenger 以及它的回调的结构体:

ini 复制代码
VkDebugUtilsMessengerCreateInfoEXT createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT;
createInfo.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT;
createInfo.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT;
createInfo.pfnUserCallback = debugCallback;
createInfo.pUserData = nullptr; //可选

messageSeverity允许你的回调显示所有的消息,设置为接收除了VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT 之外的所有通知。 messageType 与上面类似,可以让你过滤掉一些回调消息,如果觉得没用,你可以禁用它们。

最后 pfnUserCallback 参数指定了回调函数的指针,可以使用pUserData传递一些自定义参数给回调函数中。 我们教程里只用这些,但还有更多可选择项。 这个结构体需要被传给 vkCreateDebugUtilsMessengerEXT 函数用于创建 VkDebugUtilsMessengerEXT 对象,不幸的是,由于这个函数是一个扩展函数,因此不会自动导入。我们必须使用 vkGetInstanceProcAddr 去查询它的地址,我们准备去创建自己的代理函数来处理这个场景,在 HelloTriangleApplication类之前添加这个定义:

go 复制代码
VkResult CreateDebugUtilsMessengerEXT(VkInstance instance, 
    const VkDebugUtilsMessengerCreateInfoEXT *pCreateInfo,
    const VkAllocationCallbacks *pAllocator,
    VkDebugUtilsMessengerEXT *pDebugMessenger){
    auto func = (PFN_vkCreateDebugUtilsMessengerEXT)vkGetInstanceProcAddr(instance, "vkCreateDebugUtilsMessengerEXT");
    if(func != nullptr){
        return func(instance, pCreateInfo, pAllocator, pDebugMessenger);
    }else{
        return VK_ERROR_EXTENSION_NOT_PRESENT;
    }
}

若函数未被载入,vkGetInstanceProcAddr会返回nullptr,我们现在可以调用这个函数去创建这个扩展对象了:

scss 复制代码
if(CreateDebugUtilsMessengerEXT(instance, &createInfo, nullptr, &debugMessenger) != VK_SUCCESS){
    throw std::runtime_error("failed to set up debug messenger!");
}

倒数第二个参数依旧是可以设置为nullptr的自定义分配函数,其他的参数意义也很明显,由于debug messenger是与instance 和层相关联的,故其创建时,需要显示地传入第一个instance参数,我们会在后面看到创建其他子对象时也使用这种模式。

VkDebugUtilsMessengerEXT 也需要调用 vkDestroyDebugUtilsMessengerEXT 来清理掉。与 vkCreateDebugUtilsMessengerEXT 函数类似,也需要动态的载入这个函数。

创建如下地函数代理

scss 复制代码
void DestroyDebugUtilsMessengerEXT(VkInstance instance, 
    VkDebugUtilsMessengerEXT debugMessenger, 
    const vkAllocationCallbacks *pAllocator){
    auto func = (PFN_vkDestroyDebugUtilsMessenegrEXT)vkGetInstanceProcAddr(instance, "vkDestroyDebugUtilsMessengerEXT");
    if(func != nullptr) {
        func(instance , debugMessenger , pAllocator);
    }
}

确保这个函数时一个类内地静态函数或者是一个单独的全局函数。我们可以在cleanup函数中调用它

scss 复制代码
void cleanup(){
    if(enableValidationLayer){
        DestroyDebugUtilsMessengerEXT(instance, debugMessenger , nullptr);
    }

    vkDestroyInstance(instance , nullptr);
    
    glfwDestroyWindow(window);
    glfwTerminate();
}
调试实例的创建与析构

尽管现在我们已经添加了验证层的debug对象到程序中,但是仍没用覆盖到所有的场景。vkCreateDebugUtilsMessengerEXT 的调用依赖于被正确创建的 instance, vkDestroyDebugUtilsMessengerEXT也必须在实例被销毁前调用。我们目前并不能对 vkCreateInstance 与 vkDestroyInstance 的调用进行调试。

然而,如果你最近阅读了扩展相关的文档。有一个方法去创建一个单独的debug messenger去关联这两个函数的调用, 这需要你在使用VkInstanceCreateInfo创建实例时额外传递一个类型是VkDebugUtilsMessengerCreateInfoEXT 结构体的指针给 pNext 字段, 我们首先抽取出填充messenger创建的结构体代码到单独的函数中

ini 复制代码
void populateDebugMessengerCreateInfo(VkDebugUtilsMessengerCreateInfoEXT &createInfo){
    createInfo = {};
    createInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT;
    createInfo.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;
    createInfo.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;
    createInfo.pFnUserCallback = debugCallback;
}

void setupDebugMessenger(){
    if(!enableValidationLayers){
        return;
    }

    VkDebugUtilsMessengerCreateInfoEXT createInfo;
    populateDebugMessengerCreateInfo(createInfo);

    if (CreateDebugUtilsMessengerEXT(instance, &createInfo, nullptr, &debugMessenger) != VK_SUCCESS) {
        throw std::runtime_error("failed to set up debug messenger!");
    }
}

现在可以在createInstance中重用这些代码

ini 复制代码
void createInstance(){
    ...

    VkInstanceCreateInfo createInfo{};
    createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
    createInfo.pApplicationInfo = &appInfo;

    ...

    VkDebugUtilsMessengerCreateInfoEXT debugCreateInfo{};
    if(enableValidationLayers){
        createInfo.enabledLayerCount = static_cast<uint32_t>(validationLayers.size());
        createInfo.ppEnabledLayerNames = validationLayers.data();

        populateDebugMessengerCreateInfo(debugCreateInfo);
        createInfo.pNext = (VkDebugUtilsMessengerCreateInfoEXT *) &debugCreateInfo;
    }else{
        createInfo.enabledLayerCount = 0;
        createInfo.pNext = nullptr;
    }

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

debugCreateInfo这个变量被放在if语句之外,以确保它不会在vkCreateInstance调用之前被销毁。通过创建一个额外的debug messenger对象,他就会自动在 vkCreateInstance 和 vkDestroyInstance 调用时被使用。

测试

现在让我们故意制造出一个错误看看验证层会发生什么。临时在cleanup函数中移除掉 DestroyDebugUtilsMessengerEXT 的调用,再运行程序。一旦退出就会看到如下的截图:

如果没用看到这些信息 ,检查下你的Vulkan SDK安装

如果你想看到是哪里生成了这个消息,可以在回调函数中添加一个断点,并查看调用堆栈。

配置

除了在 VkDebugUtilsMessengerCreateInfoEXT 结构体中指定的标志之外,验证层还有更多的设置。游览以下SDK中的Config目录,会发现一个 vk_layer_settings.txt 文件,里面解释了如何去配置这些层。

为了让自己的应用去配置这些层的参数,可以拷贝这个文件到自己工程的 Debug 和 Release目录,按照说明设置期望的行为,教程里,我们只用默认的设置。

我们故意放了两个错误用于想你展示验证层对错误的捕获起了多大的作用,教你了解使用Vulkan的重要性。现在是时候去看Vulkan 设备了。

物理设备和队列簇

选择一个物理设备

在通过VkInstance成功导入Vulkan库之后,我们就需要在系统中选择一张满足我们需求的显卡了。事实上我们可以选择任意数量的显卡,并同时使用它们,不过在此教程中,我们坚持使用第一张满足需求的显卡。

增加一个 pickPhysicalDevice 函数,并在initVulkan中调用它

scss 复制代码
void initVulkan(){
    createInstance();
    setupDebugMessenger();
    pickPhysicalDevice();
}

void pickPhysicalDevice(){

}

最终会将 VkPhysicalDevice 的句柄存储到新的类成员变量中,这个对象可以在 VkInstance 被销毁的时候跟着被隐式地销毁,所以并不需要在cleanup函数中多做些什么。

ini 复制代码
VkPhysicalDevice physicalDevice = VK_NULL_HANDLE;

列出显卡的方式与之前列出扩展的方式十分相似,首先仅查询出数量

ini 复制代码
uint32_t deviceCount = 0;
vkEnumeratePhysicalDevice(instance, &deviceCount , nullptr);

如果没有找到支持Vulkan的设备,就没有必要再继续运行下去了

scss 复制代码
if(deviceCount == 0){
    throw std::runtime_error("failed to found GPUs with Vulkan support!");
} 

否则我们就可以分配一个数组去接收 VkPhysicalDevice 了.

c++ 复制代码
std::vector<VkPhysicalDevice> devices(deviceCount);
vkEnumeratePhysicalDevice(instance, &deviceCount , devices.data());

现在需要迭代每一个物理设备,检测它们是否支持我们将要做的操作,因为并不是所有的显卡在制造的时候都是一样的,为此,引入一个新的函数

c++ 复制代码
bool isDeviceSuitable(VkPhysicalDevice device){
    return true;
}

接着检查所有的物理设备,看设备是否能通过这个新增的函数的检测

c++ 复制代码
for(const auto &device : devices){
    if(isDeviceSuitable(device)){
        physicalDevice = device;
        break;
    }
}

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

下一节,我们会介绍添加在isDeviceSuitable 函数里的第一个条件,由于在之后的章节中,会使用越来越多的Vulkan特性,会扩展这个函数,加入更多的检查条件。

基础的设备检查

为了验证一台设备的可用性,需要查询设备的细节信息。基础的设备属性例如名称,类型,支持的Vulkan版本等都可以通过函数 vkGetPhysicalDeviceProperties 查询到。

c++ 复制代码
VkPhysicalDeviceProperties deviceProperties;
vkGetPhysicalDeviceProperties(device, &deviceProperties);

那些可选的特性,例如纹理压缩,64位浮点数,以及多视口的支持(VR设备需要),都可以通过 vkGetPhysicalDeviceFeatures 来查询到

c++ 复制代码
VkPhysicalDeviceFeatures deviceFeatures;
vkGetPhysicalDeviceFeatures(device, &deviceFeatures);

我们之后会讨论到的设备内存,队列簇等信息也会被查询到。

举个例子,我们考虑应用仅可以运行在独立显卡和支持几何着色器的设备上。这个isDeviceSuitable函数就会做如下的实现。

c++ 复制代码
bool isDeviceSuitable(VkPhysicalDevice device){
    VkPhysicalDeviceProperties deviceProperties;
    VkPhysicalDeviceFeatures deviceFeatures;
    vkGetPhysicalDeviceProperties(device, &deviceProperties);
    vkGetPhysicalDeviceFeatures(device, &deviceFeatures);

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

另外一种方案是,给每个设备一个评分,选择评分最高的设备。这种方法,你可以给专业的图形显卡一个更高的得分,但是当仅有集成显卡时,程序也可以正常的工作。可以像这样实现

c++ 复制代码
#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 found 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的设备就可以了,所以这里我们可以选择任何一张显卡。

c++ 复制代码
bool isDeviceSuitable(VkPhysicalDevice device) {
    return true;
}

下一节,会讨论真实需要的特性。

队列簇

前面已经简要概述过,几乎所有的Vulkan操作,从绘制到上传纹理,都需要杯提交到一个队列中。不同的队列类型来自于不同的队列簇,每一个队列簇走只允许一组特定的操作。例如有的队列簇只允许处理计算相关的指令,有的只允许处理内存传输的指令。

我们需要检查设备支持哪些队列簇,确定哪一个是我们需要的。因此我们添加一个函数 findQueueFamilies 用来寻找我们需要的队列簇。

现在我们准备去找到一个支持图形队列的,所以这个函数可像下面这样

c++ 复制代码
uint32_t findQueueFamilies(VkPhysicalDevice device){
    //找到合适的队列索引
}

但是在下一个章节,我们需要另外一种队列,所以更好的方案是把找到的索引绑定到一个结构体中。

c++ 复制代码
struct QueueFamilyIndices{
    uint32_t graphicsFamily;
};

QueueFamilyIndices findQueueFamilies(VkPhysicalDevice device){
    QueueFamilyIndices indices;
    //找到队列簇的逻辑
    return indices;
}

倘若一个队列簇不可用怎么办?我们可以在findQueueFamilies 里抛出异常,但这个函数并不是很适合在里面决定设备是否可用。例如,我们可能更喜欢具有专用传输队列 的设备,但并不真正需要它。因此需要一种方法去标识,是否找到了需要的队列簇。

无法用一个魔法数字去标识不存在的队列,因为uint32_t类型的所有值都是合法的队列索引。幸运的是 C++17 引入了一个新的数据结构去标识值是否存在.

c++ 复制代码
#include <optional>

...

std::optional<uint32_t> graphicsFamily;
std::cout << std::boolalpha << graphicasFamily.has_value() << std::endl;

graphicsFamily = 0;
std::cout << std::boolalpha << graphicsFamily.has_value() << std::endl;

std::optional 包装了一个 no value的值,直到你为其赋值。在任何时候,都可以通过 has_value()查询它是否有值。这样,代码就可以改成如下形式:

c++ 复制代码
#include <optional>

...

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

QueueFamilyIndices findQueueFamilies(VkPhysicalDevice device) {
    QueueFamilyIndices indices;
    // Assign index to queue families that could be found
    return indices;
}

现在可以真正去实现 findQueueFamilies 函数了

c++ 复制代码
QueueFamilyIndices findQueueFamilies(VkPhysicalDevice device) {
    QueueFamilyIndices indices;
    ...
    return indices;
}

使用函数 vkGetPhysicalDeviceQueueFamilyProperties 获取队列簇列表并处理。

c++ 复制代码
uint32_t queueFamilyCount = 0;
vkGetPhysicalDeviceQueueFamilyProperties(device , &queueFamilyCount , nullptr);

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

VkQueueFamilyProperties 结构体包含了队列簇的细节信息,包括支持的操作类型以及可以创建的队列数量。我们需要找到一个至少支持 VK_QUEUE_GRAPHICS_BIT 标记位的队列簇。

c++ 复制代码
int i = 0;
for (const auto& queueFamily : queueFamilies){
    if(queueFamily.queueFlags & VK_QUEUE_GRAPHICS_BIT){
        indices.graphicsFamily = i;
    }
    i++;
}

现在我们已经有了想要的队列簇查找函数,可以把它放到 isDeviceSuitable 函数中,以确保设备可以处理我们需要提交的命令。

c++ 复制代码
bool isDeviceSuitable(VkPhysicalDevice device) {
    QueueFamilyIndices indices = findQueueFamilies(device);
    
    return indices.graphicsFamily.has_value();
}

为了更加方便,我们在 QueueFamilyIndices 结构体中增加一个检查自身是否有值的方法

c++ 复制代码
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 中使用这个方法,提早退出循环。

c++ 复制代码
for (const auto& queueFamily : queueFamilies) {
    ...

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

好的,这就是选择物理设备所需要做的工作,下一节,我们要用物理设备去创建出一个逻辑设备。

逻辑设备和队列

介绍

在选择好需要的物理设备后,就需要创建一个逻辑设备来与它进行交互了。逻辑设备的创建过程与实例的创建过程类似,也需要描述我们要使用的特性。我们也需要指定,创建哪些从查询到的队列簇中获得的队列,你甚至可以从相同的物理设备中创建出多个逻辑设备,如果你有这样的需求。

首先增加一个新的类成员去存储逻辑设备的句柄。

c++ 复制代码
VkDevice device;

然后新增 createLogicDevice() 函数并在initVulkan中调用。

c++ 复制代码
void initVulkan(){
    createInstance();
    setupDebugMessenger();
    pickPhysicalDevice();
    createLogicDevice();
}

void createLogicDevice(){

}
指定需要创建的队列

逻辑设备的创建再次涉及到填充大量结构体的细节,第一个便是 VkDeviceQueueCreateInfo . 这个结构体描述了,想要的队列簇分配的队列数量,目前我们只对有图形能力的队列感兴趣。

c++ 复制代码
QueueFamilyIndices indices = findQueueFamilies(physicalDevice);

VkDeviceQueueCreateInfo queueCreateInfo{};
queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
queueCreateInfo.queueFamilyIndex = indices.graphicsFamily.value();
queueCreateInfo.queueCount = 1;

目前驱动仅允许从队列簇中创建数量较少的队列,实际上你也不需要一个以上的队列,这是因为你可以,使用多个线程去创建命令缓冲,并以较低的开销在主线程中一次提交。

Vulkan允许设置不同的队列优先级以影响命令的运行,此值设置为0.0 ~ 1.0 之间,哪怕只有一个队列,也有必要去设置。

c++ 复制代码
float queuePriority = 1.0f;
queueCreateInfo.pQueuePriorities = &queuePriority;
指定使用的设备特性

下一个要填写的信息是我们要使用的设备特性。它们是我们之前通过 vkGetPhysicalDeviceFeatures 查询到的那些特性,例如几何着色器,目前我们并不需要任何特殊的特性,所以简单地定义一个空对象。一旦我们准备去用Vulkan实现更有趣的功能,我们会回来修改这里。

c++ 复制代码
VkPhysicalDeviceFeatures deviceFeatures{};
创建逻辑设备

前面两个结构体就绪后,就可以填充这个主的 VkDeviceCreateInfo 结构体了.

c++ 复制代码
VkDeviceCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;

首先设置两个指向设备创建信息及特性信息结构体的指针值

c++ 复制代码
createInfo.pQueueCreateInfos = &queueCreateInfo;
createInfo.queueCreateInfoCount = 1;

createInfo.pEnabledFeatures = &deviceFeatures;

其余信息与 VkInstanceCreateInfo 结构体类似,不同点是,这是针对设备的指定的。

一个与设备关联的扩展例子是 VK_KHR_swapchain, 这允许你将渲染出的图像显示到窗口上,有可能你的Vulkan设备并没有这项能力,因为这可能是一台仅支持GPU计算的设备而非用于渲染。我们会在 交换链这一章回到这个扩展

c++ 复制代码
createInfo.enabledExtensionCount = 0;

现在并不需要指定与设备相关的扩展。

现在调用 vkCreateDevice 就可以创建出逻辑设备了

c++ 复制代码
if(vkCreateDevice(physicalDevice , &createInfo , nullptr, &device) != VK_SUCCESS) {
    throw std::runtime_error("failed to create logical device!");
}

参数分别是与逻辑设备关联的物理设备,指定队列信息及使用信息的结构体,一个可以传空的回调指针,以及返回逻辑设备句柄的变量。与创建实例类似,如果有不支持的扩展或不支持的特性,这个函数会返回错误.

逻辑设备需要通过 vkDestroyDevice 在cleanup函数中被销毁掉

c++ 复制代码
void cleanup(){
    vkDestroyDevice(device, nullptr);
}

逻辑设备并不直接与instance实例进行交互,所以此销毁函数中没有instance参数。

接收队列句柄

队列是伴随着逻辑设备的创建也自动创建出来的,可是目前我们并没有一个句柄对象去与之交互,所以,增加一个成员变量去存储图形队列的句柄

c++ 复制代码
VkQueue graphicsQueue;

队列会在设备被清理的时候隐式地销毁掉,所以并不需要在cleanup中再加上这些逻辑。

现在可以使用 vkGetDeviceQueue 函数接收从队列簇中派生地队列,这个函数地参数是 逻辑设备,队列簇,队列索引,以及用来接收队列对象地指针。因为我们只从队列簇中创建了单个队列,所以这里的索引值写0.

c++ 复制代码
vkGetDeviceQueue(device, indices.graphicsFamily.value(), 0, &graphicsQueue);

在有了逻辑设备和队列后,我们就可以操作显卡真正去做事情了。下一章,我们会去准备窗口系统所需要的资源。

相关推荐
EndingCoder1 小时前
React从基础入门到高级实战:React 实战项目 - 项目三:实时聊天应用
前端·react.js·架构·前端框架
阿阳微客2 小时前
Steam 搬砖项目深度拆解:从抵触到真香的转型之路
前端·笔记·学习·游戏
德育处主任Pro3 小时前
『React』Fragment的用法及简写形式
前端·javascript·react.js
CodeBlossom3 小时前
javaweb -html -CSS
前端·javascript·html
打小就很皮...4 小时前
HBuilder 发行Android(apk包)全流程指南
前端·javascript·微信小程序
集成显卡5 小时前
PlayWright | 初识微软出品的 WEB 应用自动化测试框架
前端·chrome·测试工具·microsoft·自动化·edge浏览器
前端小趴菜056 小时前
React - 组件通信
前端·react.js·前端框架
Amy_cx6 小时前
在表单输入框按回车页面刷新的问题
前端·elementui
dancing9996 小时前
cocos3.X的oops框架oops-plugin-excel-to-json改进兼容多表单导出功能
前端·javascript·typescript·游戏程序
后海 0_o7 小时前
2025前端微服务 - 无界 的实战应用
前端·微服务·架构