使用C ++和Vulkan在一周内进行Minecraft创作

我将自己的任务设定为在一周内使用自己的C ++和Vulkan引擎从头开始重新创建Minecraft。我受到Hopson的启发,他对C ++和OpenGL也做了同样的事情。反过来,他的灵感来自谢恩·贝克Shane Beck),他的灵感来自Minecraft,灵感来源是Infiniminer,其创作大概是受真实采矿启发的。


此项目的GitHub存储库在此处每天都有自己的git标签。

当然,我并不打算从字面上重新创建《我的世界》。这个项目本来应该是一个教育性项目。我想学习在比vulkan-tutorial.com或Sasha Willem演示更复杂的内容中使用Vulkan的知识因此,主要重点是Vulkan引擎的设计,而不是游戏的设计。

任务


在Vulkan上的开发要比在OpenGL上慢得多,因此我无法将此Minecraft的许多功能集成到游戏中。没有暴民,没有手工艺,没有红石头,没有块物理,等等。从一开始,该项目的目标如下:

  • 创建地形渲染系统
    • 捣碎
    • 灯光
  • 创建地形生成器系统
    • 浮雕
    • 树木
    • 生物群落
  • 增加了改变地形和移动方块的能力

我需要找到一种无需在游戏中添加GUI即可实现所有这些功能的方法,因为我找不到任何适用于Vulkan且易于集成的GUI库。

图书馆


当然,我不会从头开始编写Vulkan应用程序。为了加快开发过程,我将尽可能使用现成的库。即:


第一天


在第一天,我准备了Vulkan样板和引擎骨架。大部分代码都是样板,我可以从vulkan-tutorial.com复制它它还包括将顶点数据存储为顶点着色器一部分的技巧。这意味着我什至不必调整内存分配。只是一个只能做一件事的简单传送带:画一个三角形。

该引擎非常简单,可以支持三角形的渲染器。它具有一个窗口和一个可以连接系统的游戏循环。GUI受窗口标题中显示的帧速率限制。

该项目分为两个部分:VoxelEngineVoxelGame


第二天


我集成了Vulkan内存分配器库。该库负责处理大多数Vulkan内存分配样板:内存类型,设备内存堆和辅助分配。

现在我有了内存分配,我为网格和顶点缓冲区创建了类。我更改了三角形的渲染器,以便它使用网格的类别,而不是着色器中内置的数组。当前,通过手动渲染三角形将网格数据传输到GPU。


几乎没有改变

第三天


我添加了图形渲染系统。这篇文章被用作创建此类的基础,但是该类非常简化。我的渲染图仅包含处理与Vulkan同步的基本要素。

渲染图允许我设置节点。节点是GPU执行的工作。肋骨是节点之间的数据依赖关系。每个节点接收其自己的指令缓冲区,并在其中写入数据。该图参与双缓冲命令缓冲区,并将它们与先前的帧同步。边用于在节点写入每个指令缓冲区之前和之后自动插入传送带屏障。管道障碍会同步所有资源的使用,并在队列之间转移所有权。另外,边缘在节点之间插入信号量。

节点和边形成有向无环图。然后,渲染图执行拓扑排序。节点,这将导致创建一个已排序节点的平面列表,以便每个节点都跟随它所依赖的所有节点。

引擎具有三种类型的节点。AcquireNode从缓冲区链(交换链)接收图像,TransferNode将数据从CPU传输到GPU,并PresentNode提供要显示的缓冲区链的图像。

每个节点都可以实现preRenderrender并且postRender,其在每一帧执行。AcquireNode在期间获取缓冲区链的图像preRenderPresentNode按时提供此图片postRender

我重构了三角形渲染器,以便它使用渲染图系统,而不是自己处理所有内容。AcquireNode之间有一条边TriangleRenderer之间以及TriangleRendererPresentNode这样可以确保缓冲区链的图像在帧中使用期间正确同步。


我发誓引擎里面变了

第四天


我创建了相机和3D渲染系统。到目前为止,摄像机收到了自己的持久缓冲区和描述符池。

那天我放慢了速度,因为我试图为Vulkan寻找3D渲染的正确配置。大多数在线资料都集中在使用OpenGL渲染上,OpenGL使用的坐标系与Vulkan略有不同。在OpenGL中,剪辑空间的Z轴指定为[-1, 1],屏幕的上边缘为Y = 1。在Vulkan中,Z轴指定为[0, 1],屏幕的上边缘位于Y = -1。由于这些微小的差异,标准的GLM投影矩阵无法正常工作,因为它们是为OpenGL设计的。

GLM有一个选择GLM_FORCE_DEPTH_ZERO_TO_ONE,消除了Z轴的问题,此后,只需更改(1, 1)投影矩阵元素的符号即可消除Y轴的问题(GLM使用从0开始的索引)。

如果翻转Y轴,则需要翻转顶点数据,因为在此之前,Y轴的负方向向上。


现在处于3D模式!

第五天


我添加了用户输入以及使用鼠标移动相机的功能。输入系统过于复杂,但消除了GLFW输入的怪异之处。特别是,我遇到了在锁定鼠标时更改鼠标位置的问题。

键盘和鼠标输入本质上是通过信号处理程序打开的GLFW顶部的薄包装entt

只是为了进行比较-霍普森在项目的第一天所做的事情差不多。


第六天


我开始添加代码以生成和渲染体素块。编写网格代码很容易,因为我以前做过,并且知道抽象使我犯了更少的错误。

其中一个抽象是模板类ChunkData<T, chunkSize>,它定义了一个T大小chunkSize为每边大小多维数据集此类将数据存储在1D数组中,并使用3D坐标处理索引数据。每个块的大小为16 x 16 x 16,因此内部数据是一个长度为4096的简单数组。

另一种抽象方法是创建位置的迭代器,该迭代器从生成坐标(0, 0, 0)(15, 15, 15)这两类确保以线性顺序执行具有块数据的迭代,以增加缓存的局部性。3D坐标仍可用于需要它的其他操作。例如:

for (glm::ivec3 pos : Chunk::Positions()) {
    auto& data = chunkData[pos];
    glm::ivec3 offset = ...;
    auto& neighborData = chunkData[pos + offset];
}

我有几个静态数组指定游戏中常用的偏移量。例如,它Neighbors6定义立方体具有共同面的6个邻居。

static constexpr std::array<glm::ivec3, 6> Neighbors6 = {
        glm::ivec3(1, 0, 0),    //right
        glm::ivec3(-1, 0, 0),   //left
        glm::ivec3(0, 1, 0),    //top
        glm::ivec3(0, -1, 0),   //bottom
        glm::ivec3(0, 0, 1),    //front
        glm::ivec3(0, 0, -1)    //back
    };

Neighbors26-这些都是立方体具有共同的面,边或顶点的邻居。也就是说,它是一个没有中央立方体的3x3x3网格。对于其他邻居集合和2D邻居集合,也存在类似的数组。

有一个数组定义创建多维数据集的一个面所需的数据。此数组中每个面的方向与数组中的方向相对应Neighbors6

static constexpr std::array<FaceArray, 6> NeighborFaces = {
    //right face
    FaceArray {
        glm::ivec3(1, 1, 1),
        glm::ivec3(1, 1, 0),
        glm::ivec3(1, 0, 1),
        glm::ivec3(1, 0, 0),
    },
    ...
};

因此,网格创建代码非常简单。它只是绕过块的数据,并在块为实体时添加一个面,但其邻居不是。该代码仅检查块中每个多维数据集的每个面。这类似于此处描述的“幼稚”方法

for (glm::ivec3 pos : Chunk::Positions()) {
    Block block = chunk.blocks()[pos];
    if (block.type == 0) continue;

    for (size_t i = 0; i < Chunk::Neighbors6.size(); i++) {
        glm::ivec3 offset = Chunk::Neighbors6[i];
        glm::ivec3 neighborPos = pos + offset;

        //NOTE: bounds checking omitted

        if (chunk.blocks()[neighborPos].type == 0) {
            Chunk::FaceArray& faceArray = Chunk::NeighborFaces[i];
            for (size_t j = 0; j < faceArray.size(); j++) {
                m_vertexData.push_back(pos + faceArray[j]);
                m_colorData.push_back(glm::i8vec4(pos.x * 16, pos.y * 16, pos.z * 16, 0));
            }
        }
    }
}

我换成TriangleRendererChunkRenderer。我还添加了深度缓冲区,以便可以正确渲染块网格。有必要在TransferNode之间的渲染图上再增加一条边ChunkRenderer。此边缘在传输队列和图形队列之间转移队列系列资源的所有权。

然后,我更改了引擎,以便它可以正确处理窗口更改事件。在OpenGL中,这很简单,但是在Vulkan中却很混乱。由于必须明确创建缓冲区链并具有恒定的大小,因此在调整窗口大小时,需要重新创建它。您必须重新创建依赖于缓冲区链的所有资源。

所有依赖于缓冲区链的命令(现在这些都是绘图命令)必须在销毁旧缓冲区链之前完成执行。这意味着整个GPU将处于空闲状态。

您需要更改图形管道以提供动态视口并调整其大小。

如果窗口在X或Y轴上的大小为0(包括最小化窗口),则无法创建缓冲链。也就是说,发生这种情况时,整个游戏将暂停并仅在窗口打开时才继续。

现在,网格是一个简单的三维棋盘。网格的RGB颜色根据其XYZ位置乘以16来设置。



第七天


我使游戏过程不是一个,而是一次几个块。多个块及其网格由ECS库管理entt。然后,我重构了块渲染器,以便渲染ECS中的所有块。我仍然只有一个街区,但如有必要,我可以添加新街区

我重构了网格,以便在创建之后可以更新其数据。当我添加添加和删除多维数据集的功能时,这将使我将来可以更新块网格。

添加或删除多维数据集时,网格中的顶点数量可能会增加或减少。仅当新的网格大小相同或更小时,才可以使用先前选择的顶点缓冲区。但是,如果网格较大,则必须创建新的顶点缓冲区。

先前的顶点缓冲区无法立即删除。可能存在从特定于特定对象的先前帧执行的指令缓冲区VkBuffer。引擎必须保留一个缓冲区,直到这些命令缓冲区完成。也就是说,如果我们在框架中绘制网格i,GPU可以在框架开始之前使用此缓冲区i + 2。在GPU使用完缓冲区之前,无法从CPU删除缓冲区。因此,我更改了渲染图,以便它跟踪资源的生存期。

如果渲染图节点想要使用资源(缓冲区或图像),则它必须在方法中调用sync方法preRender。此方法获取指向shared_ptr资源的指针。这个shared_ptr确保在执行命令缓冲区时不会删除资源。(就性能而言,此解决方案不是很好。稍后将对此进行更多介绍。)

现在,在每个帧中都重新生成块网格。


结论


这就是我一周内所做的全部工作-准备了使用多个体素块渲染世界的基础知识,并将在第二周继续工作。

Source: https://habr.com/ru/post/undefined/


All Articles