我们将从设置新的绘制循环开始,使用上一章解释的 RenderObjects。我们之前在 GLTF 加载的网格列表上硬编码渲染,但现在我们将把该列表转换为 RenderObjects,然后再绘制它。我们还不能从 GLTF 加载纹理,所以我们将使用默认材质。
我们将通过在 vk_types.h 中添加场景节点基础结构来开始创建架构
struct DrawContext;
// base class for a renderable dynamic object
class IRenderable {
virtual void Draw(const glm::mat4& topMatrix, DrawContext& ctx) = 0;
};
// implementation of a drawable scene node.
// the scene node can hold children and will also keep a transform to propagate
// to them
struct Node : public IRenderable {
// parent pointer must be a weak pointer to avoid circular dependencies
std::weak_ptr<Node> parent;
std::vector<std::shared_ptr<Node>> children;
glm::mat4 localTransform;
glm::mat4 worldTransform;
void refreshTransform(const glm::mat4& parentMatrix)
{
worldTransform = parentMatrix * localTransform;
for (auto c : children) {
c->refreshTransform(worldTransform);
}
}
virtual void Draw(const glm::mat4& topMatrix, DrawContext& ctx)
{
// draw children
for (auto& c : children) {
c->Draw(topMatrix, ctx);
}
}
};
节点将是我们拥有的第一个 IRenderable。我们将使用智能指针构建节点树。对于父指针,我们将其存储为 weak_ptr 以避免循环依赖。子节点将存储为 shared_ptr。
Node 类将保存用于变换的对象矩阵。包括局部变换和世界变换。世界变换需要更新,因此每当局部变换发生更改时,都必须调用 refreshTransform。这将递归地向下遍历节点树,并确保矩阵位于正确的位置。
draw 函数将不执行任何操作,仅对子节点调用 Draw()。
这个基础节点类不执行任何操作,因此我们需要在 vk_engine.h 中添加一个 MeshNode 类来显示网格。
struct MeshNode : public Node {
std::shared_ptr<MeshAsset> mesh;
virtual void Draw(const glm::mat4& topMatrix, DrawContext& ctx) override;
};
MeshNode 持有一个指向网格资源的指针,并重写 draw 函数以向绘制上下文添加命令。
让我们也编写 DrawContext。全部在 vk_engine.h 中
struct RenderObject {
uint32_t indexCount;
uint32_t firstIndex;
VkBuffer indexBuffer;
MaterialInstance* material;
glm::mat4 transform;
VkDeviceAddress vertexBufferAddress;
};
struct DrawContext {
std::vector<RenderObject> OpaqueSurfaces;
};
绘制上下文现在只是一个 RenderObject 结构列表。RenderObject 是我们渲染的核心。引擎本身不会在节点类上调用任何 vulkan 函数,渲染器将从上下文中获取 RenderObject 数组,每帧(或缓存)构建,并为每个对象执行单个 vulkan 绘制函数。
有了这些定义,meshnode 的 Draw() 函数看起来像这样
void MeshNode::Draw(const glm::mat4& topMatrix, DrawContext& ctx)
{
glm::mat4 nodeMatrix = topMatrix * worldTransform;
for (auto& s : mesh->surfaces) {
RenderObject def;
def.indexCount = s.count;
def.firstIndex = s.startIndex;
def.indexBuffer = mesh->meshBuffers.indexBuffer.buffer;
def.material = &s.material->data;
def.transform = nodeMatrix;
def.vertexBufferAddress = mesh->meshBuffers.vertexBufferAddress;
ctx.OpaqueSurfaces.push_back(def);
}
// recurse down
Node::Draw(topMatrix, ctx);
}
一个网格可以有多个具有不同材质的表面,因此我们将循环网格的表面,并将生成的 RenderObject 添加到列表中。请注意我们如何处理矩阵。我们没有直接从节点 WorldTransform 插入对象,而是将其乘以 TopMatrix。这意味着如果多次调用 Draw() 函数,我们可以使用不同的变换多次绘制相同的对象。如果我们想多次渲染相同的对象,这非常有用,这是一种常见的做法。
我们在这里需要的最后一件事是将对象绘制循环添加到 VulkanEngine 类中,以便可以处理 DrawContext 并将其转换为真正的 vulkan 调用。
为此,删除与该矩形硬编码网格相关的代码以及用于绘制猴头模型的代码。我们将替换这些代码。删除 draw_geometry 中第一个三角形绘制之后的所有代码。
为了保存绘制列表,我们将 DrawContext 结构添加到 VulkanEngine 类中。我们还将添加一个 ` update_scene()` 函数,我们将在 vulkan 渲染循环之外调用绘制函数。还有一个 Nodes 的哈希映射,其中将包含我们加载的网格。此函数还将处理诸如设置相机之类的逻辑。
class VulkanEngine{
DrawContext mainDrawContext;
std::unordered_map<std::string, std::shared_ptr<Node>> loadedNodes;
void update_scene();
}
我们将把代码添加到 draw_geometry 中的渲染器,就在创建 GPUSceneData 描述符集之后,以便我们可以绑定它。将函数中绘制硬编码猴头模型的代码替换为此代码。保留用于场景数据的描述符集分配,因为它会使用它。
for (const RenderObject& draw : mainDrawContext.OpaqueSurfaces) {
vkCmdBindPipeline(cmd,VK_PIPELINE_BIND_POINT_GRAPHICS, draw.material->pipeline->pipeline);
vkCmdBindDescriptorSets(cmd,VK_PIPELINE_BIND_POINT_GRAPHICS,draw.material->pipeline->layout, 0,1, &globalDescriptor,0,nullptr );
vkCmdBindDescriptorSets(cmd,VK_PIPELINE_BIND_POINT_GRAPHICS,draw.material->pipeline->layout, 1,1, &draw.material->materialSet,0,nullptr );
vkCmdBindIndexBuffer(cmd, draw.indexBuffer,0,VK_INDEX_TYPE_UINT32);
GPUDrawPushConstants pushConstants;
pushConstants.vertexBuffer = draw.vertexBufferAddress;
pushConstants.worldMatrix = draw.transform;
vkCmdPushConstants(cmd,draw.material->pipeline->layout ,VK_SHADER_STAGE_VERTEX_BIT,0, sizeof(GPUDrawPushConstants), &pushConstants);
vkCmdDrawIndexed(cmd,draw.indexCount,1,draw.firstIndex,0,0);
}
当 RenderObject 被设计出来时,它的目的是直接转换为 vulkan 上的单个绘制命令。因此,除了直接绑定内容并调用 VkCmdDraw 之外,没有其他逻辑。我们每次绘制都绑定数据,这效率低下,但我们稍后会修复它。
最后一件事是使用我们上一章加载的网格加载来创建一些节点,然后绘制它们,以便它们将网格添加到绘制上下文中。loadGltfMeshes 没有正确加载材质,但我们可以给它默认材质。
首先更新 vk_loader.h 中的 GeoSurface 结构,使其可以容纳材质。
struct GLTFMaterial {
MaterialInstance data;
};
struct GeoSurface {
uint32_t startIndex;
uint32_t count;
std::shared_ptr<GLTFMaterial> material;
};
接下来,在 vk_engine.cpp init_default_data
中,在创建默认材质之后。
for (auto& m : testMeshes) {
std::shared_ptr<MeshNode> newNode = std::make_shared<MeshNode>();
newNode->mesh = m;
newNode->localTransform = glm::mat4{ 1.f };
newNode->worldTransform = glm::mat4{ 1.f };
for (auto& s : newNode->mesh->surfaces) {
s.material = std::make_shared<GLTFMaterial>(defaultData);
}
loadedNodes[m->name] = std::move(newNode);
}
对于每个测试网格,我们创建一个新的 MeshNode,并将网格资源复制到该节点的共享指针中。然后我们对默认材质做类似的操作。
这是因为通常我们不会像这样加载对象,而是直接从 GLTF 正确加载节点、网格和材质。在那里,多个节点可以引用同一个网格,多个网格可以引用同一个材质,因此需要 shared_ptr,即使在这种情况下它们看起来毫无意义。
让我们创建 update_scene() 函数。我们还将上一章中猴头模型上的相机逻辑移动到这里。
void VulkanEngine::update_scene()
{
mainDrawContext.OpaqueSurfaces.clear();
loadedNodes["Suzanne"]->Draw(glm::mat4{1.f}, mainDrawContext);
sceneData.view = glm::translate(glm::vec3{ 0,0,-5 });
// camera projection
sceneData.proj = glm::perspective(glm::radians(70.f), (float)_windowExtent.width / (float)_windowExtent.height, 10000.f, 0.1f);
// invert the Y direction on projection matrix so that we are more similar
// to opengl and gltf axis
sceneData.proj[1][1] *= -1;
sceneData.viewproj = sceneData.proj * sceneData.view;
//some default lighting parameters
sceneData.ambientColor = glm::vec4(.1f);
sceneData.sunlightColor = glm::vec4(1.f);
sceneData.sunlightDirection = glm::vec4(0,1,0.5,1.f);
}
我们首先清除绘制上下文中的渲染对象,然后循环遍历 loadedNodes 并在 Suzanne
上调用 Draw,这是猴头模型的网格名称。
此函数在 draw() 函数的最开始被调用,在等待帧栅栏之前。
void VulkanEngine::draw()
{
update_scene();
//wait until the gpu has finished rendering the last frame. Timeout of 1 second
VK_CHECK(vkWaitForFences(_device, 1, &get_current_frame()._renderFence, true, 1000000000));
}
如果你现在绘制引擎,你会看到猴头模型正在以一些戏剧性的俯视照明方式绘制。如果猴头模型不是白色而是彩色的,请检查你是否在 vk_loader.cpp 上将 OverrideColors
设置为 false。
现在,为了演示它,我们将操作节点并稍微绘制一下。
首先,我们将通过获取绘制立方体的 Node,并使其绘制由立方体组成的线来多次绘制对象。我们将 Nodes 存储在哈希映射中,因此我们可以根据需要单独访问和渲染它们。
在 update_scene() 函数中。
for (int x = -3; x < 3; x++) {
glm::mat4 scale = glm::scale(glm::vec3{0.2});
glm::mat4 translation = glm::translate(glm::vec3{x, 1, 0});
loadedNodes["Cube"]->Draw(translation * scale, mainDrawContext);
}
我们使立方体更小,并给它们一个从屏幕左到右的平移。然后我们对它们调用 Draw。每次调用 Draw 时,它都会将具有不同矩阵的 RenderObject 添加到上下文中,因此我们可以在不同的位置多次渲染对象。
第四章到此结束。在下一章中,我们将升级 gltf 加载器以加载带有纹理和多个对象的场景,并设置交互式 FPS 相机。
下一章:第五章:交互式相机