Ch2-0 代码整理及一些辅助类

本章的示例代码参见:EasyVulkan_Ch2

从下一节开始,就将逐步对各种Vulkan对象进行封装,在这一节定义一些会让书写代码变得方便的宏及辅助类。
此外,在事情变得更复杂之前,有必要说明一下这套教程中所用的错误处理策略。

辅助类

result_t

当程序出现问题时,理应让它抛出异常以停止运行,并且不光是在Debug Build下需要如此,因此assert(...)不作考虑。
那么,有没有什么办法可以让函数执行失败时总是抛出异常,或者如果你想处理错误的话(尽管很多错误一旦发生便很可能无法处理),总是提醒你处理错误?这当然是可行的,通过使用一个可从VkResult构造的自定义类型即可实现,我将其称为result_t

//情况1:根据函数返回值确定是否抛异常
#ifdef VK_RESULT_THROW
class result_t {
    VkResult result;
public:
    static void(*callback_throw)(VkResult);
    result_t(VkResult result) :result(result) {}
    result_t(result_t&& other) noexcept :result(other.result) { other.result = VK_SUCCESS; }
    ~result_t() noexcept(false) {
        if (uint32_t(result) < VK_RESULT_MAX_ENUM)
            return;
        if (callback_throw)
            callback_throw(result);
        throw result;
    }
    operator VkResult() {
        VkResult result = this->result;
        this->result = VK_SUCCESS;
        return result;
    }
};
inline void(*result_t::callback_throw)(VkResult);

//情况2:若抛弃函数返回值,让编译器发出警告
#elif defined VK_RESULT_NODISCARD
struct [[nodiscard]] result_t {
    VkResult result;
    result_t(VkResult result) :result(result) {}
    operator VkResult() const { return result; }
};
//在本文件中关闭弃值提醒(因为我懒得做处理)
#pragma warning(disable:4834)
#pragma warning(disable:6031)

//情况3:啥都不干
#else
using result_t = VkResult;
#endif

VK_RESULT_NODISCARD分支中的result_t没什么好说的,简要说明下VK_RESULT_THROW分支中的result_t

  • callback_throw是回调函数的指针,因为光是抛出异常可能不够,你可能想在抛出异常前干某些事,比如输出错误代码(不过光有错误代码难以知道问题出在哪,这一点会在本节的后文中得到解决)。

  • 当一个result_t中的result被处理时,由于result_t无法被直接转到bool或整型,必定会执行operator VkResult(),该函数返回原先保有的result,并将result置为VK_SUCCESS,这意味着,若一个result_t类型对象在析构时仍旧保有一个错误代码,那么说明错误没有得到任何处理,抛出异常。

之后将目前为止写过的函数的返回值类型从VkResult改为result_t即可。

arrayRef

一些Vulkan创建信息或函数会要求提供一个指向某类型数组的指针和数组元素个数,比如:

void VKAPI_CALL vkFreeCommandBuffers(...) 的参数说明

VkDevice device

逻辑设备的handle

VkCommandPool commandPool

命令池的handle

uint32_t commandBufferCount

要被释放的命令缓冲区的个数

const VkCommandBuffer* pCommandBuffers

指向要被释放的命令缓冲区构成的数组

如果将vkFreeCommandBuffers(...)封装在commandPool类型的成员函数中,那么可能会是这样:

class commandPool {
    VkCommandPool handle = VK_NULL_HANDLE;
public:
    //省略其他函数
    void FreeBuffers(uint32_t commandBufferCount, const VkCommandBuffer* pCommandBuffers) const {
        vkFreeCommandBuffers(graphicsBase::Base().Device(), handle, commandBufferCount, pCommandBuffers);
        memset(pCommandBuffers, 0, commandBufferCount * sizeof(VkCommandPool));
    }
};

这里的问题在于,即便你只要释放一个命令缓冲区,你也不得不对其取地址并将commandBufferCount指定为1。
编程中常用的一种技巧是,把一系列相关参数放到一个类或结构体中,并为其提供多种构造器,这样就能以多种方式指定这些参数。
于是,引入我的arrayRef类:

template<typename T>
class arrayRef {
    T* const pArray = nullptr;
    size_t count = 0;
public:
    //从空参数构造,count为0
    arrayRef() = default;
    //从单个对象构造,count为1
    arrayRef(T& data) :pArray(&data), count(1) {}
    //从顶级数组构造
    template<size_t elementCount>
    arrayRef(T(&data)[elementCount]) : pArray(data), count(elementCount) {}
    //从指针和元素个数构造
    arrayRef(T* pData, size_t elementCount) :pArray(pData), count(elementCount) {}
    //复制构造,若T带const修饰,兼容从对应的无const修饰版本的arrayRef构造
    //24.01.07 修正因复制粘贴产生的typo:从pArray(&other)改为pArray(other.Pointer())
    arrayRef(const arrayRef<std::remove_const_t<T>>& other) :pArray(other.Pointer()), count(other.Count()) {}
    //Getter
    T* Pointer() const { return pArray; }
    size_t Count() const { return count; }
    //Const Function
    T& operator[](size_t index) const { return pArray[index]; }
    T* begin() const { return pArray; }
    T* end() const { return pArray + count; }
    //Non-const Function
    //禁止复制/移动赋值
    arrayRef& operator=(const arrayRef&) = delete;
};

Note

C++标准库中提供了std::span类型,这东西可以从数组构造,具有与我的arrayRef类相似的功能,但是不支持从单个对象构造。

实用的宏

ENABLE_DEBUG_MESSENGER

这个宏的目的纯粹是为了把预编译指令#ifdef的分支改成常量表达式if语句if constexpr分支。
这么做使得代码会被编译器诊断,并且在Visual Studio中总是维持正常的代码着色:

#ifndef NDEBUG
#define ENABLE_DEBUG_MESSENGER true
#else
#define ENABLE_DEBUG_MESSENGER false
#endif

于是之前的vulkan::graphicsBase::CreateInstance(...)变成这样:

result_t CreateInstance(VkInstanceCreateFlags flags = 0) {
    if constexpr (ENABLE_DEBUG_MESSENGER)
        AddInstanceLayer("VK_LAYER_KHRONOS_validation"),
        AddInstanceExtension(VK_EXT_DEBUG_UTILS_EXTENSION_NAME);
    VkApplicationInfo applicatianInfo = {
        .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO,
        .apiVersion = apiVersion
    };
    VkInstanceCreateInfo instanceCreateInfo = {
        .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,
        .flags = flags,
        .pApplicationInfo = &applicatianInfo,
        .enabledLayerCount = uint32_t(instanceLayers.size()),
        .ppEnabledLayerNames = instanceLayers.data(),
        .enabledExtensionCount = uint32_t(instanceExtensions.size()),
        .ppEnabledExtensionNames = instanceExtensions.data()
    };
    if (VkResult result = vkCreateInstance(&instanceCreateInfo, nullptr, &instance)) {
        std::cout << std::format("[ graphicsBase ] ERROR\nFailed to create a vulkan instance!\nError code: {}\n", int32_t(result));
        return result;
    }
    std::cout << format(
        "Vulkan API Version: {}.{}.{}\n",
        VK_VERSION_MAJOR(apiVersion),
        VK_VERSION_MINOR(apiVersion),
        VK_VERSION_PATCH(apiVersion));
    if constexpr (ENABLE_DEBUG_MESSENGER)
        CreateDebugMessenger();
    return VK_SUCCESS;
}

封装Vulkan对象用

用于析构器中销毁Vulkan对象的宏,该宏调用相应Destroy函数后将handle设置为VK_NULL_HANDLE,以防止重复析构:

#define DestroyHandleBy(Func) if (handle) { Func(graphicsBase::Base().Device(), handle, nullptr); handle = VK_NULL_HANDLE; }
  • 这里的nullptr对应的参数是pAllocator。

用于移动构造器中的宏,该宏复制来自另一对象的handle后,将另一对象的handle设置为VK_NULL_HANDLE,转移析构权限:

#define MoveHandle handle = other.handle; other.handle = VK_NULL_HANDLE;
  • 如之前在单例类所说,定义移动构造器后若不定义复制构造器、复制赋值、移动赋值,那么这三个函数一概不可用,你不应该在封装Vulkan对象的类中定义复制构造/赋值(因为重复析构会导致重复销毁同一Vulkan对象最终导致非法访问),但是移动赋值函数可能在一些场合下有用,本教程后续的示例代码中没有写任何移动赋值函数,有需要请自行添加:
    #define DefineMoveAssignmentOperator(type) type& operator=(type&& other) { this->~type(); MoveHandle; return *this; }

该宏定义转换函数,通过返回handle将封装类型对象隐式转换到被封装handle的原始类型:

#define DefineHandleTypeOperator operator decltype(handle)() const { return handle; }
  • 别把operator decltype(handle)写成operator auto(除非你是对继承深恶痛绝的的程序员),到占位类型的类型转换函数不能在派生类中被using重设其访问级别

该宏定义转换函数,用于取得被封装handle的地址:

#define DefineAddressFunction const decltype(handle)* Address() const { return &handle; }
  • 你可以把const decltype(handle)*简化成auto

ExecuteOnce(...)

这个宏用来把函数分割成能被多次执行,以及只执行一次的两个部分:

#define ExecuteOnce(...) { static bool executed = false; if (executed) return __VA_ARGS__; executed = true; }

于是,这个宏能如同函数般被使用,在其之上的部分可以被多次执行,在其之下的部分只会被执行一次,如果在其之下的部分已经执行过了,那么就直接返回括号中的参数。
用例:Ch2-2 创建渲染通道和帧缓冲

错误信息的输出位置

之前一直将错误信息直接输出到std::cout,现在定义outStream

inline auto& outStream = std::cout;//不是constexpr,因为std::cout具有外部链接

然后将目前为止写的std::cout全部变更为outStream

效果上跟原先也没啥差别嘛,那么这么做意义何在?重点不在把outStream定义为std::cout,将std::cout替换成outStream方便你把错误信息输出到自定义的位置,毕竟程序可能没有控制台(如果你不想要的话),在Windows系统上你可能会想把错误信息输出到信息弹窗,比如,将outStream定义为如下形式:

constexpr struct outStream_t {
    static std::stringstream ss;
    struct returnedStream_t {
        returnedStream_t operator<<(const std::string& string) const {
            ss << string;
            return {};
        }
    };
    returnedStream_t operator<<(const std::string& string) const {
        ss.clear();
        ss << string;
        return {};
    }
} outStream;
inline  std::stringstream outStream_t::ss;

前面定义了用于抛出异常的result_t,如果在主函数中指定它里头的回调函数:

//需要windows.h和Win32窗口,hWindow是窗口的handle
result_t::callback_throw = [](VkResult) {
    MessageBoxA(hWindow, outStream.ss.str().c_str(), nullptr, MB_OK);
};

这么一来,每次发生未被处理的异常时,都会有信息弹窗显示书写在代码中的错误信息了。