Link

漫游

因为我们从一个已经制作好的代码框架开始本章,我们将看看它实际做了什么。

所有文件都存储在 project/src/ 文件夹中

  • vk_engine.h/cpp : 这将是引擎的主要类,也是教程中大部分代码的存放位置
  • main.cpp : 代码的入口点。除了调用 vk_engine 代码之外,没有其他内容
  • vk_initializers.h/cpp : 这将包含创建 Vulkan 结构的辅助函数
  • vk_images.h/cpp : 这将包含图像相关的 Vulkan 辅助函数
  • vk_pipelines.h/cpp : 将包含管线的抽象。
  • vk_descriptors.h/cpp : 将包含描述符集抽象。
  • vk_loader.h/cpp : 将包含 GLTF 加载逻辑。
  • vk_types.h : 整个代码库将包含此头文件。它将提供广泛使用的默认结构和包含。

vk_engine 将是我们的主引擎类,也是项目的核心。vk_loader 将与之关联,因为它在加载 GLTF 文件时需要与之交互。其他文件是通用的 Vulkan 抽象层,将根据教程的需要构建。这些抽象文件除了 Vulkan 之外没有其他依赖项,因此您可以将它们保留用于您自己的项目。

代码

#include <vk_engine.h>

int main(int argc, char* argv[])
{
	VulkanEngine engine;

	engine.init();	
	
	engine.run();	

	engine.cleanup();	

	return 0;
}

我们从一些简单的东西开始,main.cpp。我们在这里什么都不做,只是立即调用 Vulkan 引擎方法。

将来,这可能是一个设置配置参数的好地方,这些参数来自 argc/argv 的命令行参数或设置文件。

vk_types.h 包含以下内容

#pragma once

#include <memory>
#include <optional>
#include <string>
#include <vector>
#include <span>
#include <array>
#include <functional>
#include <deque>

#include <vulkan/vulkan.h>
#include <vulkan/vk_enum_string_helper.h>
#include <vk_mem_alloc.h>

#include <fmt/core.h>

#include <glm/mat4x4.hpp>
#include <glm/vec4.hpp>
#define VK_CHECK(x)                                                     \
    do {                                                                \
        VkResult err = x;                                               \
        if (err) {                                                      \
             fmt::print("Detected Vulkan error: {}", string_VkResult(err)); \
            abort();                                                    \
        }                                                               \
    } while (0)

#pragma once 是一个预处理器指令,它告诉编译器永远不要在同一个文件中包含两次。它等同于包含守卫,但更简洁。

我们包含了 Vulkan 的主头文件,您可以看到它是 <vulkan/vulkan.h>。这将包含我们需要的一切 Vulkan 函数定义和类型。我们还包含了 fmt lib 核心头文件,因为我们将在整个代码库中使用它,并创建一个 VK_CHECK 宏,我们将使用它来处理 Vulkan 调用的错误。我们将在教程中使用 vk_enum_string_helper.h。这是一个 Vulkan SDK 提供的头文件,允许我们获取给定 Vulkan 枚举的字符串。在像这种情况下的日志记录中非常有用。

本教程不会使用标准的 std::cout 来打印信息。我们将改用 {fmt} lib。这是一个用于格式化字符串和打印它们的高质量库。Cpp 20 std::format 基于此库,但我们可以使用该库获得更广泛的功能集和更好的支持。在这里,我们使用 fmt::println 在 Vulkan 给出错误的情况下将错误输出到控制台。

vk_initializers.h 是预先编写的。它包含大多数 Vulkan info 结构和其他类似结构的初始化程序。它们稍微抽象了这些结构,每次我们使用其中一个时,都会解释其代码和抽象。

我们包含了 vk_types 头文件,它引入了 Vulkan 本身(我们将需要它),并且我们为稍后将在此处添加的函数声明了一个命名空间。

最后,我们进入 vk_engine.h,主类

#pragma once

#include <vk_types.h>

class VulkanEngine {
public:

	bool _isInitialized{ false };
	int _frameNumber {0};
	bool stop_rendering{ false };
	VkExtent2D _windowExtent{ 1700 , 900 };

	struct SDL_Window* _window{ nullptr };

	static VulkanEngine& Get();

	//initializes everything in the engine
	void init();

	//shuts down the engine
	void cleanup();

	//draw loop
	void draw();

	//run main loop
	void run();
};

与 vk_initializers 一样,我们包含了 vk_types。我们已经需要 VkExtent2D 中的 Vulkan 类型。Vulkan 引擎将是我们将要做的所有事情的核心。我们将把引擎所做的一切几乎都集中到这个类中,这样我们可以简化项目的架构。

我们有一个标志来了解引擎是否已初始化,一个帧号整数(非常有用!),以及我们要打开的窗口的大小,以像素为单位。

声明 struct SDL_Window* window; 特别值得关注。请注意开头的 struct。这称为前向声明,它允许我们在类中拥有 SDL_Window 指针,而无需在 Vulkan 引擎头文件中包含 SDL。此变量保存我们为应用程序创建的窗口。

我们还添加了一个 Get() 函数作为全局单例模式。

看完头文件后,让我们转到 cpp 文件。

vk_engine.cpp 第 1 行

#include "vk_engine.h"

#include <SDL.h>
#include <SDL_vulkan.h>

#include <vk_initializers.h>
#include <vk_types.h>

#include <chrono>
#include <thread>

与其他文件不同,在这个文件中,我们包含了一些更多的东西。我们同时包含了 <SDL.h><SDL_vulkan.h>。SDL.h 包含用于打开窗口和输入的主 SDL 库数据,而 SDL_vulkan.h 包含用于打开 Vulkan 兼容窗口和其他 Vulkan 特定功能的 Vulkan 特定标志和功能。我们还添加了一些我们将需要的 STL 头文件。

vk_engine.cpp,第 10 行

constexpr bool bUseValidationLayers = false;

VulkanEngine* loadedEngine = nullptr;

VulkanEngine& VulkanEngine::Get() { return *loadedEngine; }
void VulkanEngine::init()
{
    // only one engine initialization is allowed with the application.
    assert(loadedEngine == nullptr);
    loadedEngine = this;

    // We initialize SDL and create a window with it.
    SDL_Init(SDL_INIT_VIDEO);

    SDL_WindowFlags window_flags = (SDL_WindowFlags)(SDL_WINDOW_VULKAN);

    _window = SDL_CreateWindow(
        "Vulkan Engine",
        SDL_WINDOWPOS_UNDEFINED,
        SDL_WINDOWPOS_UNDEFINED,
        _windowExtent.width,
        _windowExtent.height,
        window_flags);

    // everything went fine
    _isInitialized = true;
}

在这里我们看到了我们的第一个真正的代码,以创建 SDL 窗口的形式。我们做的第一件事是初始化 SDL 库。SDL 库包含了很多东西,所以我们必须发送一个标志,说明我们想要使用什么。SDL_INIT_VIDEO 告诉 SDL 我们想要主要的窗口功能。这也包括基本的输入事件,如键盘或鼠标。

我们还为 Vulkan 引擎单例引用设置了一个全局指针。我们这样做而不是典型的单例,因为我们想要显式控制何时初始化和销毁该类。正常的 Cpp 单例模式无法控制这一点。

一旦 SDL 被初始化,我们就使用它来创建一个窗口。该窗口存储在 _window 成员中以供以后使用。

因为 SDL 是一个 C 库,所以它不支持构造函数和析构函数,并且必须手动删除内容。

如果创建了窗口,也必须销毁它。

void VulkanEngine::cleanup()
{
    if (_isInitialized) {

        SDL_DestroyWindow(_window);
    }

    // clear engine pointer
    loadedEngine = nullptr;
}

void VulkanEngine::draw()
{
    // nothing yet
}

以类似于我们执行 SDL_CreateWindow 的方式,我们需要执行 SDL_DestroyWindow。这将销毁程序的窗口。我们还从这里清除引擎的单例指针,现在引擎已完全清除。

随着时间的推移,我们将在此清理函数中添加更多逻辑。

我们的 draw 函数目前是空的,但这里是我们将添加渲染代码的地方。

void VulkanEngine::run()
{
    SDL_Event e;
    bool bQuit = false;

    // main loop
    while (!bQuit) {
        // Handle events on queue
        while (SDL_PollEvent(&e) != 0) {
            // close the window when user alt-f4s or clicks the X button
            if (e.type == SDL_QUIT)
                bQuit = true;

            if (e.type == SDL_WINDOWEVENT) {
                if (e.window.event == SDL_WINDOWEVENT_MINIMIZED) {
                    stop_rendering = true;
                }
                if (e.window.event == SDL_WINDOWEVENT_RESTORED) {
                    stop_rendering = false;
                }
            }
        }

        // do not draw if we are minimized
        if (stop_rendering) {
            // throttle the speed to avoid the endless spinning
            std::this_thread::sleep_for(std::chrono::milliseconds(100));
            continue;
        }

        draw();
    }
}

这是我们的应用程序主循环。我们在 while() 中有一个无限循环,只有当 SDL 接收到 SDL_QUIT 事件时才会停止。

在内部循环的每次迭代中,我们都执行 SDL_PollEvent。这将向 SDL 询问操作系统在上一帧期间发送给应用程序的所有事件。在这里,我们可以检查键盘事件、鼠标移动、窗口移动、最小化和许多其他内容。目前,我们只对 SDL_QUIT 事件和窗口最小化/恢复感兴趣。当我们收到使窗口最小化的事件时,我们将 stop_rendering 布尔值设置为 true,以避免在窗口最小化时进行绘制。恢复窗口将将其设置回 false,从而使其继续绘制。

最后,在主循环的每次迭代中,如果禁用绘图,我们会调用 draw();std::this_thread::sleep_for。这样我们就可以节省性能,因为我们不希望应用程序在用户将其最小化时全速运行。

现在我们已经了解了如何使用 SDL 打开窗口,基本上没有其他内容。

此时真正可以添加到此处的只有一件事,那就是尝试使用 SDL 事件。

作为练习,阅读 SDL2 的文档并尝试获取按键事件,使用 fmt::print 记录它们。

现在我们可以继续进行第一章,并开始进行渲染循环。

下一步:初始化 Vulkan