Link

既然材质系统已经解释完毕,我们可以继续深入到核心部分,网格渲染系统。

大部分系统代码位于 vk_scene.h/cpp 文件中。负责渲染命令的部分在 vk_engine_scenerender.h/cpp 文件中完成。

该系统的设计灵感来源于 Unreal Engine 和 OurMachinery 博客和演示中展示的工作。它不是最佳解决方案,但它确实运行良好。

网格通道

该系统的主要思想是渲染通过 MeshPass 完成,给定的网格通道保存了渲染器一部分网格通道所需的信息。在编写本文时,引擎有 3 个网格通道:前向渲染、太阳阴影渲染和透明渲染。

可以有更多的网格通道,不会有问题。如果你想让点光源也投射阴影,那么每个投射阴影的光源将有 1 个网格通道,如果你有需要更多摄像机的传送门,你将有更多前向网格通道。代码的一个非常明显的改进是为动态对象设置一个 MeshPass,使其与静态关卡加载对象分离。另一个改进是将大型前向网格通道拆分为瓦片,用于开放世界游戏,以便不同的前向通道可以以不同的速率更新。

即使使用允许增量重建的系统,重建 MeshPass 也可能很昂贵。如果从渲染通道中添加或删除对象,则需要重建其大部分结构。移动对象或更改其属性不需要重建 MeshPass,只有添加/删除或材质切换才需要。

因为 MeshPass 是一个完全独立的绘制调用集合,它们可以被保存、恢复和并行重新计算。这自动提供了并行重建阴影投射绘制调用的可能性,而不会出现任何问题。

网格通道由其内部数据的一组扁平数组组成。由于完全扁平化,因此可以轻松地将其数据与 GPU 同步。

    // final draw-indirect segments
    std::vector<RenderScene::Multibatch> multibatches;
    // draw indirect batches
	std::vector<RenderScene::IndirectBatch> batches;
    // sorted list of objects in the pass
	std::vector<RenderScene::RenderBatch> flat_batches;

    //unsorted object data
	std::vector<PassObject> objects;

    //objects pending addition
    std::vector<Handle<RenderObject>> unbatchedObjects;

    //indicides for the objects array that can be reused
	std::vector<Handle<PassObject>> reusableObjects;

    //objects pending removal
	std::vector<Handle<PassObject>> objectsToDelete;

主要的渲染数组是 multibatchesbatchesflat_batches。它们每个都经过排序,并且直接映射到绘制调用。

struct RenderBatch {
		Handle<PassObject> object;
		uint64_t sortKey;

flat_batches 是通道中每个对象的单独的非实例化绘制。如果对象足够多,这个数组可能会变得非常大。如果你不想使用间接绘制,你可以直接渲染这个数组。它们只是对象数组的句柄 + 计算出的排序键。flat-batches 数组将始终保持有序。

struct IndirectBatch {
		Handle<DrawMesh> meshID;
		PassMaterial material;
		uint32_t first;
		uint32_t count;
	};

Batches 是 DrawIndirect 数据的数组,每个数组覆盖 flat_batches 数组上的一个范围。给定的批次直接映射到单个 VkDrawIndirectCommand,使用实例化渲染来绘制一组对象。

struct Multibatch {
		uint32_t first;
		uint32_t count;
	};

MultibatchesBatches 数组的另一个间接层级。它包含可以一起执行的绘制命令段。这样,每个 VkDrawIndirect 调用都可以同时渲染多个 VkDrawIndirectCommand。

struct PassMaterial {
		VkDescriptorSet materialSet;
		vkutil::ShaderPass* shaderPass;
	};
struct PassObject {
		PassMaterial material;
		Handle<DrawMesh> meshID;
		Handle<RenderObject> original;
		uint32_t customKey;
	};

Pass Object 数组是此网格通道处理的对象列表。它们中的每一个都包含一个简化的材质(仅材质描述符集 + 通道)以及网格和原始渲染对象的句柄。当网格通道更新时,它将使用此数组来构建实际的绘制调用。objects 数组将有空洞,因为当对象被删除时,它们只是被发送到 null,并且索引将在以后重用。我们将这些索引保存在 reusableObjects 数组中。

要渲染网格通道,首先必须执行裁剪计算着色器,该着色器将构建其间接绘制缓冲区,然后您可以遍历 multibatch 数组执行绘制调用。这在 vk_engine_scenerender.cpp 中的 execute_draw_commands() 函数中显示。

渲染场景

网格通道必须存储在某个地方,那就是 RenderScene。RenderScene 是处理所有对象并将它们插入到正确的网格通道中的组件。一旦可渲染对象被插入到渲染场景中,它将被转换为更优化的、更小的结构,以便更好地映射到 RenderScene 内部的数据。

RenderScene 将对象、网格和材质存储在扁平数组中,以便网格通道可以使用整数句柄引用它们,这比普通指针更安全,内存占用更小。

std::vector<RenderObject> renderables;
std::vector<DrawMesh> meshes;
std::vector<vkutil::Material*> materials;

std::vector<Handle<RenderObject>> dirtyObjects;

它还保留一个 dirtyObjects 列表,以了解哪些对象必须重新上传到 GPU。RenderScene 将负责将其对象数据保存在正确的缓冲区中,因为所有网格通道都将使用相同的对象数据进行裁剪和对象变换。

通过调用 register_object_batch()register_object 函数,并将 MeshObject 传递给它们,可以将对象注册到 RenderScene 中。

struct MeshObject {
	Mesh* mesh{ nullptr };

	vkutil::Material* material;
	uint32_t customSortKey;
	glm::mat4 transformMatrix;

	RenderBounds bounds;

	uint32_t bDrawForwardPass : 1;
	uint32_t bDrawShadowPass : 1;
};

当对象被注册时,它会被转换为 RenderObject 并插入到 renderables 数组中。对于网格和材质,它们将在哈希映射中查找,如果材质尚未在数组中,则会将其添加到那里。

注册对象也会将其添加到相关的网格通道中。

Handle<RenderObject> RenderScene::register_object(MeshObject* object)
{
    //convert it into a RenderObject
	RenderObject newObj;
	newObj.bounds = object->bounds;
	newObj.transformMatrix = object->transformMatrix;
	newObj.material = getMaterialHandle(object->material);
	newObj.meshID = getMeshHandle(object->mesh);
	newObj.updateIndex = (uint32_t)-1;
	newObj.customSortKey = object->customSortKey;
	newObj.passIndices.clear(-1);
	Handle<RenderObject> handle;
	handle.handle = static_cast<uint32_t>(renderables.size());

	renderables.push_back(newObj);

    //add to relevant mesh passes
	if (object->bDrawForwardPass)
	{
		if (object->material->original->passShaders[MeshpassType::Transparency])
		{
			_transparentForwardPass.unbatchedObjects.push_back(handle);
		}
		if (object->material->original->passShaders[MeshpassType::Forward])
		{
			_forwardPass.unbatchedObjects.push_back(handle);
		}
	}
	if (object->bDrawShadowPass)
	{
		if (object->material->original->passShaders[MeshpassType::DirectionalShadow])
		{
			_shadowPass.unbatchedObjects.push_back(handle);
		}
	}

    //flag as changed so that its data is uploaded to gpu
	update_object(handle);
	return handle;
}

当将对象添加到给定的网格通道时,它会查找材质是否实际具有该类型通道的着色器。材质透明的对象将被注册到透明网格通道中,而没有 DirectionalShadow 着色器效果的对象将不会投射阴影。

这样做是为了使每个通道完全独立,并且不做任何不必要的工作。在测试场景中,通常透明对象非常少,因此透明网格通道始终非常轻量级。

网格通道更新逻辑

当从网格通道中添加或删除对象时,网格通道需要更新其主 flat_batches 数组及其父级。这是通过 refresh_pass 函数完成的,每当有更改要应用时都必须调用它。不必每帧都调用它,并且可以安全地从多个线程调用。

代码仓库中这部分的代码仍在开发中,围绕部分更新进行的优化可能难以理解。但是,对于完全重建,一般的逻辑是这样的。

对于 unbatchedObjects 数组中的每个对象,它都会被转换并插入到 objects 数组中。正确的描述符集会被获取并存储为 PassObject 转换的一部分。

一旦 PassObject 数组被填充,我们就遍历它,并计算其中每个对象的绘制哈希值。这些绘制哈希值用于对新构建的(和未排序的)flat_batches 数组进行排序。

一旦我们对 flat_batches 数组进行了排序,我们就遍历它,并将绘制压缩到 IndirectBatch 数组中。flat_batches 数组中的多个绘制将变成一个 IndirectBatch,这是一个实例化绘制。

在创建该数组之后,它再次被压缩到 multibatch 数组中。

GPU 端缓冲区

每帧,负责裁剪的计算着色器将构建最终的间接绘制命令。这通过几个在所述着色器中使用的 GPU 缓冲区来处理。

struct GPUInstance {
	uint32_t objectID;
	uint32_t batchID;
};
struct GPUIndirectObject {
	VkDrawIndexedIndirectCommand command;
	uint32_t objectID;
	uint32_t batchID;
};

AllocatedBuffer<uint32_t> compactedInstanceBuffer;
AllocatedBuffer<GPUInstance> instanceBuffer;

AllocatedBuffer<GPUIndirectObject> drawIndirectBuffer;
AllocatedBuffer<GPUIndirectObject> clearIndirectBuffer;

drawIndirectBufferclearIndirectBuffer 都是从网格通道 batches 数组创建的,它们保存间接绘制命令。clearIndirectBuffer 是一个 CPU 可写缓冲区,它具有正确的数据,但 command.instanceCount 设置为 0。从裁剪着色器,我们不断添加实例,因此我们使用这个缓冲区每帧“重置” drawIndirectBuffer

drawIndirectBuffer 是一个 GPU 端缓冲区,它是我们实际用于渲染的缓冲区。

passObjectsBuffer 是用于 GPU 计算通道的主要数组。每个 GPUInstance 对象都保存 objectID 和 batchID。如果裁剪通过,batchID 将用于访问正确的绘制命令,而 object ID 只是对象的全局索引,用于访问其数据。

此数组直接从网格通道中的 objects 数组构建。

最后一个是 compactedInstanceBuffer,它是将 gl_InstanceID 映射到 objectID 的缓冲区。这是从裁剪着色器写入的,并从实际的顶点着色器中使用,以便在渲染每个对象时访问正确的 ObjectID。

缓冲区的上传全部在 vk_engine_scenerender.cpp 中的 ready_mesh_draw() 函数中完成。对于每个网格通道,如果网格通道已更改,它会将扁平数组上传到 gpu 缓冲区。

对于 clearIndirectBuffer,它是从网格通道中的 “batches” 数组填充的,如下所示。

void RenderScene::fill_indirectArray(GPUIndirectObject* data, MeshPass& pass)
{
	int dataIndex = 0;
	for (int i = 0; i < pass.batches.size(); i++) {

		auto batch = pass.batches[i];

		data[dataIndex].command.firstInstance = batch.first;
		//set instance Count to 0 because it will be filled from the compute shader
		data[dataIndex].command.instanceCount = 0;
		data[dataIndex].command.firstIndex = get_mesh(batch.meshID)->firstIndex;
		data[dataIndex].command.vertexOffset = get_mesh(batch.meshID)->firstVertex;
		data[dataIndex].command.indexCount = get_mesh(batch.meshID)->indexCount;
		data[dataIndex].objectID = 0;
		data[dataIndex].batchID = i;

		dataIndex++;
	}
}

instances 数组将从 flat batches 数组填充,从 batches 数组保存的范围复制。

void RenderScene::fill_instancesArray(GPUInstance* data, MeshPass& pass)
{
	int dataIndex = 0;
	for (int i = 0; i < pass.batches.size(); i++) {

		auto batch = pass.batches[i];

		for (int b = 0; b < batch.count; b++)
		{
			data[dataIndex].objectID = pass.get(pass.flat_batches[b + batch.first].object)->original.handle;
			data[dataIndex].batchID = i;
			dataIndex++;
		}
	}
}

上传缓冲区时,它将检查缓冲区中是否已有足够的空间,如果大小已增加,它将销毁旧缓冲区,并分配一个新的更大的缓冲区。