Link

我们将从设置新的绘制循环开始,使用上一章解释的 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 相机。

下一章:第五章:交互式相机