我将自己的任务设定为在一周内使用自己的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受窗口标题中显示的帧速率限制。该项目分为两个部分:VoxelEngine
和VoxelGame
。第二天
我集成了Vulkan内存分配器库。该库负责处理大多数Vulkan内存分配样板:内存类型,设备内存堆和辅助分配。现在我有了内存分配,我为网格和顶点缓冲区创建了类。我更改了三角形的渲染器,以便它使用网格的类别,而不是着色器中内置的数组。当前,通过手动渲染三角形将网格数据传输到GPU。几乎没有改变第三天
我添加了图形渲染系统。这篇文章被用作创建此类的基础,但是该类非常简化。我的渲染图仅包含处理与Vulkan同步的基本要素。渲染图允许我设置节点和边。节点是GPU执行的工作。肋骨是节点之间的数据依赖关系。每个节点接收其自己的指令缓冲区,并在其中写入数据。该图参与双缓冲命令缓冲区,并将它们与先前的帧同步。边用于在节点写入每个指令缓冲区之前和之后自动插入传送带屏障。管道障碍会同步所有资源的使用,并在队列之间转移所有权。另外,边缘在节点之间插入信号量。节点和边形成有向无环图。然后,渲染图执行拓扑排序。节点,这将导致创建一个已排序节点的平面列表,以便每个节点都跟随它所依赖的所有节点。引擎具有三种类型的节点。AcquireNode
从缓冲区链(交换链)接收图像,TransferNode
将数据从CPU传输到GPU,并PresentNode
提供要显示的缓冲区链的图像。每个节点都可以实现preRender
,render
并且postRender
,其在每一帧执行。AcquireNode
在期间获取缓冲区链的图像preRender
。PresentNode
按时提供此图片postRender
。我重构了三角形渲染器,以便它使用渲染图系统,而不是自己处理所有内容。AcquireNode
和之间有一条边TriangleRenderer
之间以及TriangleRenderer
和PresentNode
。这样可以确保缓冲区链的图像在帧中使用期间正确同步。我发誓引擎里面变了第四天
我创建了相机和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),
glm::ivec3(-1, 0, 0),
glm::ivec3(0, 1, 0),
glm::ivec3(0, -1, 0),
glm::ivec3(0, 0, 1),
glm::ivec3(0, 0, -1)
};
Neighbors26
-这些都是立方体具有共同的面,边或顶点的邻居。也就是说,它是一个没有中央立方体的3x3x3网格。对于其他邻居集合和2D邻居集合,也存在类似的数组。有一个数组定义创建多维数据集的一个面所需的数据。此数组中每个面的方向与数组中的方向相对应Neighbors6
。static constexpr std::array<FaceArray, 6> NeighborFaces = {
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;
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));
}
}
}
}
我换成TriangleRenderer
用ChunkRenderer
。我还添加了深度缓冲区,以便可以正确渲染块网格。有必要在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
确保在执行命令缓冲区时不会删除资源。(就性能而言,此解决方案不是很好。稍后将对此进行更多介绍。)现在,在每个帧中都重新生成块网格。结论
这就是我一周内所做的全部工作-准备了使用多个体素块渲染世界的基础知识,并将在第二周继续工作。