Link

间接绘制 API

在 GPU 驱动的文章中,间接绘制在代码库的工作原理的快速概述中进行了解释,但让我们更好地了解间接绘制的工作原理。

vulkan 中的间接绘制调用如下所示。

//indexed draw
VKAPI_ATTR void VKAPI_CALL vkCmdDrawIndexedIndirect(
    VkCommandBuffer                             commandBuffer,
    VkBuffer                                    buffer,
    VkDeviceSize                                offset,
    uint32_t                                    drawCount,
    uint32_t                                    stride);

//non indexed draw
VKAPI_ATTR void VKAPI_CALL vkCmdDrawIndirect(
    VkCommandBuffer                             commandBuffer,
    VkBuffer                                    buffer,
    VkDeviceSize                                offset,
    uint32_t                                    drawCount,
    uint32_t                                    stride);

间接绘制命令将 VkBuffer 作为第一个参数,这是存储命令的位置。您还可以将 DrawCount 设置为您想要的任何数字,在这种情况下,它将从缓冲区执行多个绘制命令,每次将步幅添加到偏移量。每个命令将根据此布局从 buffer + offset + (stride * index) 读取 5 个整数。请注意,索引和非索引调用完全相同。它们仅在其命令结构体中有所不同


//indexed 
struct VkDrawIndexedIndirectCommand {
    uint32_t    indexCount;
    uint32_t    instanceCount;
    uint32_t    firstIndex;
    int32_t     vertexOffset;
    uint32_t    firstInstance;
};

//non indexed
typedef struct VkDrawIndirectCommand {
    uint32_t    vertexCount;
    uint32_t    instanceCount;
    uint32_t    firstVertex;
    uint32_t    firstInstance;
} VkDrawIndirectCommand;

重要的是要知道,您不需要使数据成为 packed 命令结构体数组。您可以在缓冲区中拥有更多内容,只要您正确设置偏移量和步幅即可。在引擎中,我们在缓冲区中存储额外的数据。

要创建间接绘制缓冲区,它可以位于 CPU 端和 GPU 端缓冲区上,如果您正在执行只读操作,则它实际上并不重要。在引擎中,我们将间接绘制缓冲区放在 gpu 中,因为我们正在从剔除计算着色器写入它。

您可以在此处看到创建 CPU 可写间接缓冲区的示例


create_buffer(MAX_COMMANDS * sizeof(VkDrawIndexedIndirectCommand),VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT |  VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU);
	

创建间接绘制缓冲区时,您需要将 VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT 添加到用法标志。否则 gpu 将报错。您还可以使它们具有传输和存储缓冲区用法,因为它们对于从着色器写入和读取非常有用。

执行间接绘制调用将等同于执行此操作。


void FakeDrawIndirect(VkCommandBuffer commandBuffer,void* buffer,VkDeviceSize offset, uint32_t drawCount,uint32_t stride);

    char* memory = (char*)buffer + offset;

    for(int i = 0; i < drawCount; i++)
    {
        VkDrawIndexedIndirectCommand* command = VkDrawIndexedIndirectCommand*(memory + (i * stride));

        VkCmdDrawIndexed(commandBuffer, 
        command->indexCount, 
        command->instanceCount, 
        command->firstIndex, 
        command->vertexOffset,
        command->firstInstance);
    }
}   

还有一个非常流行的扩展,它使间接绘制更加强大,称为 DrawIndirectCount。该扩展是 Vulkan 1.2 中的默认功能,并且几乎适用于所有 PC 硬件。可悲的是,任天堂 Switch 不支持它,因此本教程将不使用它。间接绘制计数与普通间接绘制调用相同,但 “drawCount” 是从另一个缓冲区获取的。这使得 GPU 可以决定要绘制多少间接绘制命令,从而可以轻松移除剔除的绘制,从而不会浪费工作。

使用间接绘制

使用间接绘制的方法有很多种。为了展示最简单的实现方法,我们将更改第 4 章末尾的代码以使用间接绘制运行。我们将为每个渲染对象执行 1 个间接绘制命令,而不进行实例化。这或多或少与我们已经执行的 “VkCmdDraw()” 调用的深层循环相同。

到第 4 章末尾,渲染循环如下所示(伪代码)

{
    //initial global setup omitted

    //write object matrices
    GPUObjectData* objectSSBO = map_buffer(get_current_frame().objectBuffer);
	
    for (int i = 0; i < count; i++)
    {
	RenderObject& object = objects[i];
	objectSSBO[i].modelMatrix = object.transformMatrix;
    }
	
    Mesh* lastMesh = nullptr;
    Material* lastMaterial = nullptr;
	
    for (int i = 0; i < count; i++)
    {
	RenderObject& object = objects[i];

	//only bind the pipeline if it doesn't match with the already bound one
	if (object.material != lastMaterial) {
	    bind_descriptors(object.material);
            lastMaterial = object.material;
	}	

	//only bind the mesh if its a different one from last bind
	if (object.mesh != lastMesh) {
	    bind_mesh(object.mesh)
            lastMesh = object.mesh;
	}
	
	//we can now draw
	vkCmdDraw(cmd, object.mesh->_vertices.size(), 1,0 , i /*using i to access matrix in the shader */   );
    }
}

对于每个对象,我们一次渲染一个对象。如果材质或网格发生变化,则我们重新绑定它。

重新绑定将成为我们的停止点。虽然我们可以通过将 drawCount 设置为大于 1 在 1 次调用中执行多个绘制命令,但我们无法重新绑定网格或材质。因此,我们每次材质和网格发生变化时都必须执行一次绘制调用。为此,我们将在对象数组中进行预处理,在其中将其“精简”为使用相同网格和材质的部分。


struct IndirectBatch{
    Mesh* mesh;
    Material* material;
    uint32_t first;
    uint32_t count;
}
std::vector<IndirectBatch> compact_draws(RenderObject* objects, int count)
{
    std::vector<IndirectBatch> draws;

    IndirectBatch firstDraw;
    firstDraw.mesh = objects[0].mesh;
    firstDraw.material = objects[0].material;
    firstDraw.first = 0;
    firstDraw.count = 1;

    draws.push_back(firstDraw);

    for (int i = 0; i < count; i++)
    {
        //compare the mesh and material with the end of the vector of draws
        bool sameMesh = objects[i].mesh == draws.back().mesh;
        bool sameMaterial = objects[i].material ==draws.back().material;

        if(sameMesh && sameMaterial)
        {
            //all matches, add count
            draws.back().count++;
        }
        else    
        {
            //add new draw
            IndirectBatch newDraw;
            newDraw.mesh = objects[i].mesh;
            newDraw.material = objects[i].material;
            newDraw.first = i;
            newDraw.count = 1;

            draws.push_back(newDraw);
        }
    }
    return draws;
}

通过这种方式精简绘制后,我们可以将绘制循环重写为如下所示,这更适合间接绘制。

{

    std::vector<IndirectBatch> draws = compact_draws(objects, count);

    for (IndirectBatch& draw : draws)
    {
	bind_descriptors(draw.material);      

	bind_mesh(draw.mesh)

	//we can now draw
        for(int i = draw.first; i < draw.count;i++)
        {       
	    vkCmdDraw(cmd, draw.mesh->_vertices.size(), 1,0 , i /*using i to access matrix in the shader */   );
        }
    }
}

请注意,现在我们有一个直接的 vkCmdDraw() 循环。这完全映射到间接绘制命令。通过这样的循环,我们现在可以编写命令,并以这种方式执行它。

我假设间接缓冲区是如上所示分配的,但使用的是 VkDrawIndirectCommand,而不是 VkDrawInstancedIndirectCommand。因为在第 4 章代码库中,索引渲染尚未实现。


std::vector<IndirectBatch> draws = compact_draws(objects, count);


VkDrawIndirectCommand* drawCommands = map_buffer(get_current_frame().indirectBuffer);
	

//encode the draw data of each object into the indirect draw buffer
for (int i = 0; i < count; i++)
{
    RenderObject& object = objects[i];
    drawCommands[i].vertexCount = object.mesh->_vertices.size();
    drawCommands[i].instanceCount = 1;
    drawCommands[i].firstVertex = 0;
    drawCommands[i].firstInstance = i; //used to access object matrix in the shader
}
	

for (IndirectBatch& draw : draws)
{
    bind_descriptors(draw.material);      

    bind_mesh(draw.mesh)
    
    //we can now draw

    VkDeviceSize indirect_offset = draw.first * sizeof(VkDrawIndirectCommand);
    uint32_t draw_stride = sizeof(VkDrawIndirectCommand);

    //execute the draw command buffer on each section as defined by the array of draws
    vkCmdDrawIndirect(cmd, get_current_frame().indirectBuffer, indirect_offset, draw.count,draw_stride);
}

就是这样,现在渲染循环是间接的,应该会稍微快一点。但这里最需要考虑的事情是,绘制命令缓冲区可以被缓存,并从计算着色器写入/读取。如果需要,您可以只在加载时写入一次,然后每帧执行 vkCmdDrawIndirect 循环。这也是一个添加剔除的设计非常简单的设计。您只需执行一个计算着色器,如果对象被剔除,则将 instanceCount 设置为 0,仅此而已。但请记住,考虑到空绘制命令仍然存在开销,因此这样做并不是很理想,因此最好以某种方式精简它们,无论是通过使用实例化的设计(就像我们在教程引擎中所做的那样),还是在移除空绘制后使用间接绘制计数。

您还可以在那里非常清楚地看到一些东西。网格缓冲区、描述符和管线的组合越少,您可以在每次间接绘制执行中绘制的次数就越多。这就是为什么通常在执行间接绘制时,您希望您的绘制尽可能无绑定。

间接绘制架构

上面解释的系统是您可以执行间接绘制的最简单方式。它很有用,但通常你想要在其基础上做更多的事情。引擎使用许多不同的架构,这些架构以许多不同的方式使用间接绘制。这些方式取决于你究竟想在引擎中做什么,以及这如何映射到间接绘制用法和功能。

在教程引擎中,我们无法使用间接绘制计数,这是一个很大的限制。相反,我们倾向于做非常少的间接绘制命令,而是使用实例化代替。在那里,我们正在执行 1 次间接绘制实例化。

教程的管线首先编写按材质和网格排序的间接绘制命令结构体。然后,它执行剔除,对于每个幸存的网格,它会对相关间接绘制命令的实例计数执行 +1 操作。

在其他引擎中,使用间接绘制非常流行的做法是对细粒度剔除和网格合并。

在刺客信条:团结演示文稿 链接 中,他们进行了多次剔除,并从着色器内部写入索引缓冲区。

在演示文稿中,他们首先基于每个对象进行剔除。幸存的对象被存储到 GPU 缓冲区中。

一旦第一次剔除结束,他们将对象扩展为 “网格簇” 列表,每个网格簇都是每次 64 个三角形的迷你网格。

然后进行另一次剔除,剔除每个小网格簇,输出到另一个列表。

作为最后一步,每个小网格簇的索引缓冲区被复制到索引缓冲区中,并生成将渲染它们的绘制命令。因为他们通过从幸存的网格写入索引缓冲区来合并三角形本身,所以绘制命令的数量非常少。他们每个材质/纹理集仅执行 1 个绘制命令。这也意味着他们有大量非常小的网格,然后在绘制时将它们合并成更大的网格,从而避免在渲染小对象时 GPU 上的性能缺陷。