引擎架构
我们拥有构建引擎所需的所有底层机制。我们可以绘制任意网格,并将缓冲区和纹理等数据发送到着色器。我们现在需要做的是为绘制对象构建适当的架构,并停止在 VulkanEngine 类上使用硬编码的结构。
这里的架构旨在稍微模仿 GLTF,因为在下一章中,我们将从 GLTF 文件中动态加载整个场景。我们将按照 GLTF PBR 规范为我们提供的,创建我们的基本 GLTF 管线和我们的第一个网格类。
该架构将基于两个级别的结构。一方面,我们将拥有 RenderObject 结构。
struct RenderObject {
uint32_t indexCount;
uint32_t firstIndex;
VkBuffer indexBuffer;
MaterialInstance* material;
glm::mat4 transform;
VkDeviceAddress vertexBufferAddress;
};
此结构是我们需要单个 VkCmdDrawIndexed 调用的参数的完全扁平化的抽象。我们有网格索引所需的结构,然后是一个 MaterialInstance 指针,它将指向给定材质的管线和 DescriptorSet。之后,我们有用于 3d 渲染的对象矩阵及其顶点缓冲区指针。这两个将进入推送常量,因为它们是每个对象的动态数据。
此结构将在每一帧动态写入,渲染器逻辑将遍历这些 RenderObject 结构的数组,并直接记录绘制命令。
MaterialInstance 结构如下所示。
struct MaterialPipeline {
VkPipeline pipeline;
VkPipelineLayout layout;
};
struct MaterialInstance {
MaterialPipeline* pipeline;
VkDescriptorSet materialSet;
MaterialPass passType;
};
对于材质系统,我们将硬编码到 2 个管线中,GLTF PBR 不透明和 GLTF PBR 透明。它们都使用相同的顶点和片段着色器对。我们将仅使用 2 个描述符集。槽 0 将是我们的“全局”描述符集,它将绑定一次,然后用于所有绘制,并将包含全局数据,例如相机和环境信息。稍后我们还将添加灯光之类的东西。槽 1 将是每个材质的描述符集,它将绑定纹理和材质参数。我们将直接镜像 gltf,并拥有 PBR GLTF 材质要求的纹理,以及带有颜色常数(例如对象颜色)的统一缓冲区。GLTF PBR 材质允许不设置纹理,但在这些情况下,我们将绑定默认的白色或默认的黑色纹理,具体取决于我们需要什么。MaterialInstance 结构还有一个 MaterialPass 枚举,它使我们可以在不透明渲染对象和透明渲染对象之间进行区分。
我们专门使用 2 个管线的原因是因为我们希望将管线数量保持在绝对最小值。如果我们拥有更少的管线,我们可以在启动时预加载它们,并且可以使渲染器更快,特别是当我们开始执行无绑定和间接绘制逻辑时。我们的目标是我们将为我们拥有的每种材质类型(例如 GLTF PBR 材质)拥有少量管线。引擎需要使用的管线数量直接影响性能。像 Doom Eternal 这样的引擎游戏总共有大约 200 个管线,而虚幻引擎项目通常最终达到 100,000 多个管线,而编译如此多的管线会导致大量卡顿,占用大量空间,并阻止高级渲染功能(如间接绘制)。
这些 RenderObject 非常底层,因此我们需要一种编写它们的方法。我们将为此使用场景图。这样,我们可以拥有一个层次结构,其中一些网格是其他网格的子项,并且我们也有空的非网格场景节点。这在引擎中很典型,以便能够构建关卡。
我们将拥有的场景图类型是一种中/低性能设计(我们稍后会对此进行改进),但具有非常动态且易于扩展的优势。它仍然足够快,可以渲染数万个对象。
// base class for a renderable dynamic object
class IRenderable {
virtual void Draw(const glm::mat4& topMatrix, DrawContext& ctx) = 0;
};
我们有一个 IRenderable 接口,它定义了一个 Draw() 函数。这需要一个用作父级的矩阵和一个 RenderContext。渲染上下文目前只是一个渲染对象数组。其想法是,当调用 Draw() 函数时,对象会将可渲染对象插入到要在此帧绘制的列表中。这通常被称为立即设计,它最大的优点是我们可以每帧多次使用不同的矩阵绘制同一对象以复制对象,或者根据某些逻辑决定在一帧中不绘制它而只是跳过调用 Draw()。这种方法非常适合动态对象,因为资源管理和生命周期大大简化,并且也很容易编写。缺点是我们正在遍历不同的对象,每帧调用虚拟函数来绘制事物,这在高对象计数时会累加。
Node 类将从 IRenderable 派生,并将具有局部变换矩阵 + 子节点数组。当在其上调用 Draw() 时,它会在其子节点上调用 Draw()
然后我们有一个 MeshNode 类,它从 Node 派生。它持有所需的绘制资源,并且当在其上调用 Draw() 时,它会构建 RenderObject 并将其添加到 DrawContext 以进行绘制。
当我们添加其他绘制类型(例如灯光)时,它仍然以相同的方式工作。我们将在 DrawContext 上保存一个灯光列表,如果灯光已启用,则 LightNode 会将其参数添加到其中。我们可能想要绘制的其他事物(例如地形、粒子等)也是如此。
我们也将采用的一个技巧是,一旦我们添加了 GLTF,我们还将拥有一个 LoadedGLTF 类作为 IRenderable(而不是 Node)。这将保存给定 GLTF 文件的整个状态和所有资源(如纹理和网格),并且当调用 Draw() 时,它将绘制 GLTF 的内容。为 OBJ 和其他格式设置类似的类将很有用。
GLTF 本身的加载将在下一章完成,但我们现在将准备好 RenderObject 和 gltf 材质的机制。
下一步: 设置材质