间接绘制 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 上的性能缺陷。