既然材质系统已经解释完毕,我们可以继续深入到核心部分,网格渲染系统。
大部分系统代码位于 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;
主要的渲染数组是 multibatches
、batches
和 flat_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;
};
Multibatches
是 Batches
数组的另一个间接层级。它包含可以一起执行的绘制命令段。这样,每个 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;
drawIndirectBuffer
和 clearIndirectBuffer
都是从网格通道 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++;
}
}
}
上传缓冲区时,它将检查缓冲区中是否已有足够的空间,如果大小已增加,它将销毁旧缓冲区,并分配一个新的更大的缓冲区。