漫游
因为我们从一个已经制作好的代码框架开始本章,我们将看看它实际做了什么。
所有文件都存储在 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