深度缓冲

简介

到目前为止,我们使用的几何体都投影到了 3D 空间,但它仍然是完全平面的。在本章中,我们将向位置添加 Z 坐标,为 3D 网格做好准备。我们将使用第三个坐标将一个正方形放置在当前正方形之上,以查看当几何体不按深度排序时出现的问题。

3D 几何体

Vertex 结构体更改为使用 3D 向量表示位置,并更新相应的 VkVertexInputAttributeDescription 中的 format

struct Vertex {
    glm::vec3 pos;
    glm::vec3 color;
    glm::vec2 texCoord;

    ...

    static std::array<VkVertexInputAttributeDescription, 3> getAttributeDescriptions() {
        std::array<VkVertexInputAttributeDescription, 3> attributeDescriptions{};

        attributeDescriptions[0].binding = 0;
        attributeDescriptions[0].location = 0;
        attributeDescriptions[0].format = VK_FORMAT_R32G32B32_SFLOAT;
        attributeDescriptions[0].offset = offsetof(Vertex, pos);

        ...
    }
};

接下来,更新顶点着色器以接受和转换 3D 坐标作为输入。别忘了之后重新编译它!

layout(location = 0) in vec3 inPosition;

...

void main() {
    gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 1.0);
    fragColor = inColor;
    fragTexCoord = inTexCoord;
}

最后,更新 vertices 容器以包含 Z 坐标。

const std::vector<Vertex> vertices = {
    {{-0.5f, -0.5f, 0.0f}, {1.0f, 0.0f, 0.0f}, {0.0f, 0.0f}},
    {{0.5f, -0.5f, 0.0f}, {0.0f, 1.0f, 0.0f}, {1.0f, 0.0f}},
    {{0.5f, 0.5f, 0.0f}, {0.0f, 0.0f, 1.0f}, {1.0f, 1.0f}},
    {{-0.5f, 0.5f, 0.0f}, {1.0f, 1.0f, 1.0f}, {0.0f, 1.0f}}
};

如果您现在运行应用程序,您应该会看到与之前完全相同的结果。现在是时候添加一些额外的几何体,使场景更有趣,并演示我们将在本章中解决的问题。复制顶点以定义当前正方形正下方的正方形的位置,如下所示

extra square

使用 -0.5f 的 Z 坐标,并为额外的正方形添加适当的索引。

const std::vector<Vertex> vertices = {
    {{-0.5f, -0.5f, 0.0f}, {1.0f, 0.0f, 0.0f}, {0.0f, 0.0f}},
    {{0.5f, -0.5f, 0.0f}, {0.0f, 1.0f, 0.0f}, {1.0f, 0.0f}},
    {{0.5f, 0.5f, 0.0f}, {0.0f, 0.0f, 1.0f}, {1.0f, 1.0f}},
    {{-0.5f, 0.5f, 0.0f}, {1.0f, 1.0f, 1.0f}, {0.0f, 1.0f}},

    {{-0.5f, -0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}, {0.0f, 0.0f}},
    {{0.5f, -0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}, {1.0f, 0.0f}},
    {{0.5f, 0.5f, -0.5f}, {0.0f, 0.0f, 1.0f}, {1.0f, 1.0f}},
    {{-0.5f, 0.5f, -0.5f}, {1.0f, 1.0f, 1.0f}, {0.0f, 1.0f}}
};

const std::vector<uint16_t> indices = {
    0, 1, 2, 2, 3, 0,
    4, 5, 6, 6, 7, 4
};

现在运行您的程序,您将看到类似埃舍尔插图的东西。

depth issues

问题是,较低的正方形的片段被绘制在较高正方形的片段之上,仅仅是因为它在索引数组中出现得较晚。有两种方法可以解决这个问题

  • 按从后到前的深度对所有绘制调用进行排序

  • 使用深度测试和深度缓冲

第一种方法通常用于绘制透明对象,因为与顺序无关的透明度是一个难以解决的难题。然而,按深度对片段排序的问题更常见的方法是使用深度缓冲。深度缓冲是一个额外的附件,它存储每个位置的深度,就像颜色附件存储每个位置的颜色一样。每次光栅化器产生片段时,深度测试都会检查新片段是否比前一个片段更近。如果不是,则丢弃新片段。通过深度测试的片段将其自身的深度写入深度缓冲区。可以从片段着色器中操作此值,就像您可以操作颜色输出一样。

#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>

GLM 生成的透视投影矩阵默认将使用 OpenGL 的 -1.01.0 深度范围。我们需要配置它以使用 Vulkan 的 0.01.0 范围,使用 GLM_FORCE_DEPTH_ZERO_TO_ONE 定义。

深度图像和视图

深度附件基于图像,就像颜色附件一样。不同之处在于,交换链不会自动为我们创建深度图像。我们只需要一个深度图像,因为一次只运行一个绘制操作。深度图像将再次需要三位一体的资源:图像、内存和图像视图。

VkImage depthImage;
VkDeviceMemory depthImageMemory;
VkImageView depthImageView;

创建一个新函数 createDepthResources 来设置这些资源。

void initVulkan() {
    ...
    createCommandPool();
    createDepthResources();
    createTextureImage();
    ...
}

...

void createDepthResources() {

}

创建深度图像非常简单。它应该具有与颜色附件相同的分辨率(由交换链范围定义),适合深度附件的图像用法,最佳平铺和设备本地内存。唯一的问题是:深度图像的正确格式是什么?该格式必须包含深度组件,由 VK_FORMAT_ 中的 D?? 指示。

与纹理图像不同,我们不一定需要特定的格式,因为我们不会直接从程序访问纹素。它只需要具有合理的精度,至少 24 位在现实世界应用程序中很常见。有几种格式符合此要求

  • VK_FORMAT_D32_SFLOAT:用于深度的 32 位浮点数

  • VK_FORMAT_D32_SFLOAT_S8_UINT:用于深度的 32 位有符号浮点数和 8 位模板分量

  • VK_FORMAT_D24_UNORM_S8_UINT:用于深度的 24 位浮点数和 8 位模板分量

模板分量用于 模板测试,这是可以与深度测试结合使用的附加测试。我们将在以后的章节中讨论这一点。

我们可以简单地使用 VK_FORMAT_D32_SFLOAT 格式,因为对它的支持非常普遍(请参阅硬件数据库),但是尽可能为我们的应用程序增加一些额外的灵活性是很好的。我们将编写一个函数 findSupportedFormat,它按从最理想到最不理想的顺序获取候选格式列表,并检查第一个受支持的格式。

VkFormat findSupportedFormat(const std::vector<VkFormat>& candidates, VkImageTiling tiling, VkFormatFeatureFlags features) {

}

对格式的支持取决于平铺模式和用法,因此我们还必须将它们作为参数包括在内。可以使用 vkGetPhysicalDeviceFormatProperties 函数查询对格式的支持

for (VkFormat format : candidates) {
    VkFormatProperties props;
    vkGetPhysicalDeviceFormatProperties(physicalDevice, format, &props);
}

VkFormatProperties 结构体包含三个字段

  • linearTilingFeatures:支持使用线性平铺的用例

  • optimalTilingFeatures:支持使用最佳平铺的用例

  • bufferFeatures:支持用于缓冲区的用例

这里只有前两个是相关的,我们检查哪一个取决于函数的 tiling 参数

if (tiling == VK_IMAGE_TILING_LINEAR && (props.linearTilingFeatures & features) == features) {
    return format;
} else if (tiling == VK_IMAGE_TILING_OPTIMAL && (props.optimalTilingFeatures & features) == features) {
    return format;
}

如果任何候选格式都不支持所需的用法,那么我们可以返回一个特殊值或直接抛出异常

VkFormat findSupportedFormat(const std::vector<VkFormat>& candidates, VkImageTiling tiling, VkFormatFeatureFlags features) {
    for (VkFormat format : candidates) {
        VkFormatProperties props;
        vkGetPhysicalDeviceFormatProperties(physicalDevice, format, &props);

        if (tiling == VK_IMAGE_TILING_LINEAR && (props.linearTilingFeatures & features) == features) {
            return format;
        } else if (tiling == VK_IMAGE_TILING_OPTIMAL && (props.optimalTilingFeatures & features) == features) {
            return format;
        }
    }

    throw std::runtime_error("failed to find supported format!");
}

现在我们将使用这个函数创建一个 findDepthFormat 辅助函数,用于选择一个包含深度分量并且支持用作深度附件的格式。

VkFormat findDepthFormat() {
    return findSupportedFormat(
        {VK_FORMAT_D32_SFLOAT, VK_FORMAT_D32_SFLOAT_S8_UINT, VK_FORMAT_D24_UNORM_S8_UINT},
        VK_IMAGE_TILING_OPTIMAL,
        VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT
    );
}

请确保在这种情况下使用 VK_FORMAT_FEATURE_ 标志而不是 VK_IMAGE_USAGE_。所有这些候选格式都包含一个深度分量,但后两种还包含一个模板分量。我们暂时不会使用它,但在对使用这些格式的图像执行布局转换时,我们需要将其考虑在内。添加一个简单的辅助函数,告诉我们所选的深度格式是否包含模板分量。

bool hasStencilComponent(VkFormat format) {
    return format == VK_FORMAT_D32_SFLOAT_S8_UINT || format == VK_FORMAT_D24_UNORM_S8_UINT;
}

调用该函数以从 createDepthResources 中查找深度格式。

VkFormat depthFormat = findDepthFormat();

现在我们拥有了调用 createImagecreateImageView 辅助函数所需的所有信息。

createImage(swapChainExtent.width, swapChainExtent.height, depthFormat, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, depthImage, depthImageMemory);
depthImageView = createImageView(depthImage, depthFormat);

但是,createImageView 函数目前假设子资源始终是 VK_IMAGE_ASPECT_COLOR_BIT,因此我们需要将该字段转换为参数。

VkImageView createImageView(VkImage image, VkFormat format, VkImageAspectFlags aspectFlags) {
    ...
    viewInfo.subresourceRange.aspectMask = aspectFlags;
    ...
}

更新对此函数的所有调用以使用正确的方面。

swapChainImageViews[i] = createImageView(swapChainImages[i], swapChainImageFormat, VK_IMAGE_ASPECT_COLOR_BIT);
...
depthImageView = createImageView(depthImage, depthFormat, VK_IMAGE_ASPECT_DEPTH_BIT);
...
textureImageView = createImageView(textureImage, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_ASPECT_COLOR_BIT);

深度图像的创建到此为止。我们不需要映射它或将另一个图像复制到它,因为我们将在渲染通道的开始时像颜色附件一样清除它。

显式转换深度图像

我们不需要显式地将图像的布局转换为深度附件,因为我们将在渲染通道中处理此事。但是,为了完整起见,我仍然在本节中描述该过程。如果您愿意,可以跳过它。

createDepthResources 函数的末尾调用 transitionImageLayout,如下所示:

transitionImageLayout(depthImage, depthFormat, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL);

未定义的布局可以用作初始布局,因为不存在重要的现有深度图像内容。我们需要更新 transitionImageLayout 中的一些逻辑以使用正确的子资源方面。

if (newLayout == VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL) {
    barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT;

    if (hasStencilComponent(format)) {
        barrier.subresourceRange.aspectMask |= VK_IMAGE_ASPECT_STENCIL_BIT;
    }
} else {
    barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
}

尽管我们没有使用模板分量,但我们确实需要在深度图像的布局转换中包含它。

最后,添加正确的访问掩码和管道阶段。

if (oldLayout == VK_IMAGE_LAYOUT_UNDEFINED && newLayout == VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL) {
    barrier.srcAccessMask = 0;
    barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;

    sourceStage = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
    destinationStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
} else if (oldLayout == VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL && newLayout == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) {
    barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
    barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;

    sourceStage = VK_PIPELINE_STAGE_TRANSFER_BIT;
    destinationStage = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT;
} else if (oldLayout == VK_IMAGE_LAYOUT_UNDEFINED && newLayout == VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL) {
    barrier.srcAccessMask = 0;
    barrier.dstAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;

    sourceStage = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
    destinationStage = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT;
} else {
    throw std::invalid_argument("unsupported layout transition!");
}

将读取深度缓冲区以执行深度测试,以查看片段是否可见,并在绘制新片段时写入深度缓冲区。读取发生在 VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT 阶段,写入发生在 VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT 阶段。您应该选择与指定操作匹配的最早管道阶段,以便在需要用作深度附件时准备好使用。

渲染通道

现在我们将修改 createRenderPass 以包含深度附件。首先指定 VkAttachmentDescription

VkAttachmentDescription depthAttachment{};
depthAttachment.format = findDepthFormat();
depthAttachment.samples = VK_SAMPLE_COUNT_1_BIT;
depthAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
depthAttachment.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
depthAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
depthAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
depthAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
depthAttachment.finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;

format 应与深度图像本身相同。这次我们不关心存储深度数据(storeOp),因为它在绘制完成后不会被使用。这可能允许硬件执行额外的优化。就像颜色缓冲区一样,我们不关心之前的深度内容,因此我们可以使用 VK_IMAGE_LAYOUT_UNDEFINED 作为 initialLayout

VkAttachmentReference depthAttachmentRef{};
depthAttachmentRef.attachment = 1;
depthAttachmentRef.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;

为第一个(也是唯一的)子通道添加对附件的引用。

VkSubpassDescription subpass{};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
subpass.colorAttachmentCount = 1;
subpass.pColorAttachments = &colorAttachmentRef;
subpass.pDepthStencilAttachment = &depthAttachmentRef;

与颜色附件不同,子通道只能使用单个深度(+模板)附件。在多个缓冲区上执行深度测试没有任何意义。

std::array<VkAttachmentDescription, 2> attachments = {colorAttachment, depthAttachment};
VkRenderPassCreateInfo renderPassInfo{};
renderPassInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
renderPassInfo.attachmentCount = static_cast<uint32_t>(attachments.size());
renderPassInfo.pAttachments = attachments.data();
renderPassInfo.subpassCount = 1;
renderPassInfo.pSubpasses = &subpass;
renderPassInfo.dependencyCount = 1;
renderPassInfo.pDependencies = &dependency;

接下来,更新 VkSubpassDependency 结构以引用两个附件。

dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT;
dependency.srcAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT;
dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;

最后,我们需要扩展我们的子通道依赖关系,以确保深度图像的转换与其作为其加载操作的一部分被清除之间没有冲突。深度图像首先在早期片段测试管道阶段访问,并且由于我们有一个清除的加载操作,我们应该为写入指定访问掩码。

帧缓冲区

下一步是修改帧缓冲区的创建,以将深度图像绑定到深度附件。转到 createFramebuffers 并将深度图像视图指定为第二个附件。

std::array<VkImageView, 2> attachments = {
    swapChainImageViews[i],
    depthImageView
};

VkFramebufferCreateInfo framebufferInfo{};
framebufferInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
framebufferInfo.renderPass = renderPass;
framebufferInfo.attachmentCount = static_cast<uint32_t>(attachments.size());
framebufferInfo.pAttachments = attachments.data();
framebufferInfo.width = swapChainExtent.width;
framebufferInfo.height = swapChainExtent.height;
framebufferInfo.layers = 1;

每个交换链图像的颜色附件都不同,但所有交换链图像都可以使用相同的深度图像,因为由于我们的信号量,一次只有一个子通道正在运行。

您还需要移动对 createFramebuffers 的调用,以确保它在深度图像视图实际创建之后被调用。

void initVulkan() {
    ...
    createDepthResources();
    createFramebuffers();
    ...
}

清除值

由于我们现在有多个具有 VK_ATTACHMENT_LOAD_OP_CLEAR 的附件,我们还需要指定多个清除值。转到 recordCommandBuffer 并创建一个 VkClearValue 结构的数组。

std::array<VkClearValue, 2> clearValues{};
clearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}};
clearValues[1].depthStencil = {1.0f, 0};

renderPassInfo.clearValueCount = static_cast<uint32_t>(clearValues.size());
renderPassInfo.pClearValues = clearValues.data();

深度缓冲区中的深度范围在 Vulkan 中为 0.01.0,其中 1.0 位于远视平面,0.0 位于近视平面。深度缓冲区中每个点的初始值应该是最远的可能深度,即 1.0

请注意,clearValues 的顺序应与您的附件顺序相同。

深度和模板状态

深度附件现在已准备好使用,但仍需要在图形管道中启用深度测试。它通过 VkPipelineDepthStencilStateCreateInfo 结构进行配置。

VkPipelineDepthStencilStateCreateInfo depthStencil{};
depthStencil.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
depthStencil.depthTestEnable = VK_TRUE;
depthStencil.depthWriteEnable = VK_TRUE;

depthTestEnable 字段指定是否应将新片段的深度与深度缓冲区进行比较,以查看是否应丢弃它们。depthWriteEnable 字段指定是否应将通过深度测试的片段的新深度实际写入深度缓冲区。

depthStencil.depthCompareOp = VK_COMPARE_OP_LESS;

depthCompareOp 字段指定执行的比较以保留或丢弃片段。我们坚持较低深度 = 更近的惯例,因此新片段的深度应更小

depthStencil.depthBoundsTestEnable = VK_FALSE;
depthStencil.minDepthBounds = 0.0f; // Optional
depthStencil.maxDepthBounds = 1.0f; // Optional

depthBoundsTestEnableminDepthBoundsmaxDepthBounds 字段用于可选的深度边界测试。基本上,这允许您仅保留在指定深度范围内下降的片段。我们不会使用此功能。

depthStencil.stencilTestEnable = VK_FALSE;
depthStencil.front = {}; // Optional
depthStencil.back = {}; // Optional

最后三个字段配置模板缓冲区操作,我们也不会在本教程中使用它们。如果要使用这些操作,则必须确保深度/模板图像的格式包含模板分量。

pipelineInfo.pDepthStencilState = &depthStencil;

更新 VkGraphicsPipelineCreateInfo 结构以引用我们刚刚填写的深度模板状态。如果渲染通道包含深度模板附件,则必须始终指定深度模板状态。

如果您现在运行您的程序,您应该看到几何体的片段现在已正确排序。

depth correct

处理窗口大小调整

当窗口大小改变时,深度缓冲区的分辨率应该随之改变,以匹配新的颜色附件分辨率。扩展 recreateSwapChain 函数,以便在这种情况下重新创建深度资源。

void recreateSwapChain() {
    int width = 0, height = 0;
    while (width == 0 || height == 0) {
        glfwGetFramebufferSize(window, &width, &height);
        glfwWaitEvents();
    }

    vkDeviceWaitIdle(device);

    cleanupSwapChain();

    createSwapChain();
    createImageViews();
    createDepthResources();
    createFramebuffers();
}

清理操作应该在交换链清理函数中进行。

void cleanupSwapChain() {
    vkDestroyImageView(device, depthImageView, nullptr);
    vkDestroyImage(device, depthImage, nullptr);
    vkFreeMemory(device, depthImageMemory, nullptr);

    ...
}

恭喜,你的应用程序现在终于准备好渲染任意的 3D 几何体并使其看起来正确了。我们将在下一章中通过绘制一个带纹理的模型来尝试一下!