跳转至

深度缓冲

前言

现在,我们已经将几何体载入了三维空间,但我们只输入了二维坐标。 在本章中,我们会为3D网格添加 Z 坐标,并通过一个例子向你展示图像是否处理深度时的差异。

3D几何体

1. 添加三维坐标

改变C++代码中的 Vertex 结构体,使用三维坐标,同时更新 attributeDescriptions 中的format字段:

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

    ...

    static std::array<vk::VertexInputAttributeDescription, 3>  getAttributeDescriptions() {
        std::array<vk::VertexInputAttributeDescription, 3> attributeDescriptions;

        attributeDescriptions[0].binding = 0;
        attributeDescriptions[0].location = 0;
        attributeDescriptions[0].format = vk::Format::eR32G32B32Sfloat;
        attributeDescriptions[0].offset = offsetof(Vertex, pos);

        ...
    }
}

然后需要更新顶点着色器的输入和坐标变换代码,用于适配我们的三维坐标:

...

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}, {1.0f, 0.0f}},
    {{0.5f, -0.5f, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f}},
    {{0.5f, 0.5f, 0.0f}, {0.0f, 0.0f, 1.0f}, {0.0f, 1.0f}},
    {{-0.5f, 0.5f, 0.0f}, {1.0f, 1.0f, 1.0f}, {1.0f, 1.0f}}
};

现在运行程序,你看到的内容应该和之前一样。

2. 添加几何体

现在可以添加一个额外的几何体让场景更有趣些,它会为我们展示本场景我们需要解决的问题。 我们希望在现有矩形的下方再放置一个矩形,就像这样:

extra_square

现在往顶点数据中添加内容,新顶点的Z坐标使用 -0.5f ,不要忘了顶点索引:

inline static const std::vector<Vertex> vertices = {
    {{-0.5f, -0.5f, 0.0f}, {1.0f, 0.0f, 0.0f}, {1.0f, 0.0f}},
    {{0.5f, -0.5f, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f}},
    {{0.5f, 0.5f, 0.0f}, {0.0f, 0.0f, 1.0f}, {0.0f, 1.0f}},
    {{-0.5f, 0.5f, 0.0f}, {1.0f, 1.0f, 1.0f}, {1.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}}
};
inline static const std::vector<uint16_t> indices = {
    0, 1, 2, 2, 3, 0,
    4, 5, 6, 6, 7, 4
};

现在运行程序你应该会看到类似下面的结果:

片段着色器中颜色计算使用 outColor = texture(texSampler, fragTexCoord);

depth_issues

3. 问题

两个几何体的实际大小是一样的,显示的更小说明距离摄像机更远,但现在小的几何体居然显示在了大的上方! 这是因为我们没处理前后遮挡关系,只使用索引按顺序绘制。对此问题,我们有两种解决方案:

  • 将所有绘制命令从后往前排序
  • 使用深度缓冲,近的覆盖远的

第一种方法通常用于绘制透明对象,因为与顺序无关的透明对象绘制并不容易。 而深度片段排序问题更常见的解决方案就是深度缓冲(depth buffer)。

深度缓冲是额外的附件,它存储每个片段对应的深度,每次在光栅化器生成片段时都会判断是否比前一个片段更近。 如果更近则替换,更远则抛弃,这被称为深度测试(depth testing),用于处理物体的远近关系。 可以在片段着色器中操作此值,就像操作颜色的输出一样。

4. GLM配置

GLM 生成的透视投影矩阵默认使用 OpenGL 的 [-1.0, 1.0] 的范围,我们需要使用 GLM_FORCE_DEPTH_ZERO_TO_ONE 宏让他变为 Vulkan 使用的 [0.0, 1.0]

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

深度图像与视图

1. 成员变量与辅助函数

深度附件基于图像,我们依然需要手动创建,但只需要一个图像(而不是飞行帧的数量),因为程序一次只运行一个绘制操作。

现在创建三个成员变量:内存资源、图像、图像视图,放在交换链的下方:

std::vector<vk::raii::ImageView> m_swapChainImageViews;
vk::raii::DeviceMemory m_depthImageMemory{ nullptr };
vk::raii::Image m_depthImage{ nullptr };
vk::raii::ImageView m_depthImageView{ nullptr };

我们将他放在交换链的下方,因为创建它需要交换链信息。

然后创建一个新函数 createDepthResources 来设置这些资源:

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

...

void createDepthResources() {

}

2. 查找深度图像格式

创建深度图像非常简单。它应该具有与颜色附件相同的分辨率(由交换链 extent 定义),适用于深度附件的图像用途(usage),最佳平铺和设备本地内存。

唯一的问题是:正确的深度图像格式是什么?格式必须包含深度组件,所以枚举名会有 D 字母。

与纹理图像不同,我们不需要指定图像的色彩格式,因为深度图记录的是深度信息。 我们只需要指定精度即可,下面是几种常见的选择:

vk::Format 含义
eD32Sfloat 每个深度使用32位有符号浮点数
eD32SfloatS8Uint 32 位有符号浮点数记录深度,外加 8 位模板分量
eD24UnormS8Uint 24 位浮点数记录深度,外加 8 位模板分量

模板分量(stencil component)被用于 模板测试(stencil test)。 它可以与深度测试组合,我们会在后面的章节中介绍。

可以简单的使用 eD32Sfloat ,它受到广泛支持。 但这里选择编写 findSupportedFormat 函数查询合适的格式,这带来更好的灵活性和可用性:

vk::Format findSupportedFormat(
    const std::vector<vk::Format>& candidates,
    vk::ImageTiling tiling,
    vk::FormatFeatureFlags features
) {

}

支持的格式取决于平铺模式和用途,所以我们包含了这些参数。

我们可以通过物理设备的 getFormatProperties 函数获取需要的信息:

for(vk::Format format : candidates) {
    // vk::FormatProperties
    auto props = m_physicalDevice.getFormatProperties(format);
}

vk::FormatProperties 结构体包含以下字段:

  • linearTilingFeatures:线性平铺支持的用例
  • optimalTilingFeatures:最优平铺支持的用例
  • bufferFeatures:缓冲支持的用例

只有前两个与这里相关,我们根据参数的 tiling 进行选择:

switch (tiling){
case vk::ImageTiling::eLinear:
    if(props.linearTilingFeatures & features) return format;
    break;
case vk::ImageTiling::eOptimal:
    if(props.optimalTilingFeatures & features) return format;
    break;
default: 
    break;
}

如果所有候选格式都不支持所需的用途,我们可以直接抛出异常或返回特殊值:

vk::Format findSupportedFormat(
    const std::vector<vk::Format>& candidates,
    vk::ImageTiling tiling,
    vk::FormatFeatureFlags features
) {
    for(vk::Format format : candidates) {
        // vk::FormatProperties
        auto props = m_physicalDevice.getFormatProperties(format);

        switch (tiling){
        case vk::ImageTiling::eLinear:
            if(props.linearTilingFeatures & features) return format;
            break;
        case vk::ImageTiling::eOptimal:
            if(props.optimalTilingFeatures & features) return format;
            break;
        default: 
            break;
        }
    }
    throw std::runtime_error("failed to find supported format!");
}

现在创建一个 findDepthFormat 函数,用于选择具体深度分量并支持用于深度附件的格式:

vk::Format findDepthFormat() {
    return findSupportedFormat(
        { vk::Format::eD32Sfloat, vk::Format::eD32SfloatS8Uint, vk::Format::eD24UnormS8Uint },
        vk::ImageTiling::eOptimal,
        vk::FormatFeatureFlagBits::eDepthStencilAttachment
    );
}

上面选择的三种模式都包含深度分量,但后两者还包含模板分量。 虽然我们尚未使用它,但在图像布局转换时需要考虑这一点。 现在添加一个简单的辅助函数判断是否包含深度分量:

bool hasStencilComponent(vk::Format format) {
    return format == vk::Format::eD32SfloatS8Uint || format == vk::Format::eD24UnormS8Uint;
}

3. 创建深度图像

createDepthResources 中使用刚才的函数:

vk::Format depthFormat = findDepthFormat();

然后使用前几章的辅助函数 createImagecreateImageView 创建对象:

createImage(
    m_swapChainExtent.width,
    m_swapChainExtent.height,
    depthFormat,
    vk::ImageTiling::eOptimal,
    vk::ImageUsageFlagBits::eDepthStencilAttachment,
    vk::MemoryPropertyFlagBits::eDeviceLocal,
    m_depthImage,
    m_depthImageMemory
);
m_depthImageView = createImageView(m_depthImage, depthFormat);

现在 createImageView 函数内部的 subresourceRange.aspectMask 始终使用 eColor,但深度缓冲并不是。 我们需要使用参数传递 aspectMask 而非默认:

vk::raii::ImageView createImageView(vk::Image image, vk::Format format, vk::ImageAspectFlags aspectFlags) {
    ...
    viewInfo.subresourceRange.aspectMask = aspectFlags;
    ...
}

然后需要修改用到此函数的三个地方:

m_swapChainImageViews.emplace_back( 
    createImageView(m_swapChainImages[i], m_swapChainImageFormat, vk::ImageAspectFlagBits::eColor) 
);
...
m_textureImageView = createImageView(m_textureImage, vk::Format::eR8G8B8A8Srgb, vk::ImageAspectFlagBits::eColor);
...
m_depthImageView = createImageView(m_depthImage, depthFormat, vk::ImageAspectFlagBits::eDepth);

创建深度图像就到此为止,我们不需要映射或拿另一个图像复制进去,因为它会在渲染管线开始时像颜色附件一样被清理。

显式转换深度图像

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

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

transitionImageLayout(
    m_depthImage,
    depthFormat,
    vk::ImageLayout::eUndefined,
    vk::ImageLayout::eDepthAttachmentOptimal
);

我们可以使用 eUndefined 是因为深度图像此时并没有内容。 现在还需要修改 transitionImageLayout ,保证它使用正确的 aspectMask

if( newLayout == vk::ImageLayout::eDepthStencilAttachmentOptimal ) {
    barrier.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eDepth;
    if( hasStencilComponent(format) ){
        barrier.subresourceRange.aspectMask |= vk::ImageAspectFlagBits::eStencil;
    }
} else {
    barrier.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eColor;
}

即使我们没用到模板(stencil)分量,也在启用时需要设置相关属性。

最后还需要设置正确的访问掩码和管线阶段:

if( oldLayout == vk::ImageLayout::eUndefined &&
    newLayout == vk::ImageLayout::eTransferDstOptimal
) {
    barrier.srcAccessMask = {};
    barrier.dstAccessMask = vk::AccessFlagBits::eTransferWrite;
    sourceStage = vk::PipelineStageFlagBits::eTopOfPipe;
    destinationStage = vk::PipelineStageFlagBits::eTransfer;
} else if(
    oldLayout == vk::ImageLayout::eTransferDstOptimal &&
    newLayout == vk::ImageLayout::eShaderReadOnlyOptimal
) {
    barrier.srcAccessMask = vk::AccessFlagBits::eTransferWrite;
    barrier.dstAccessMask = vk::AccessFlagBits::eShaderRead;
    sourceStage = vk::PipelineStageFlagBits::eTransfer;
    destinationStage = vk::PipelineStageFlagBits::eFragmentShader;
} else if (
    oldLayout == vk::ImageLayout::eUndefined &&
    newLayout == vk::ImageLayout::eDepthStencilAttachmentOptimal
) {
    barrier.srcAccessMask = {};
    barrier.dstAccessMask = vk::AccessFlagBits::eDepthStencilAttachmentRead | vk::AccessFlagBits::eDepthStencilAttachmentWrite;
    sourceStage = vk::PipelineStageFlagBits::eTopOfPipe;
    destinationStage = vk::PipelineStageFlagBits::eEarlyFragmentTests;
} else {
    throw std::invalid_argument("unsupported layout transition!");
}

深度缓冲区会在深度测试时被读取并在绘制新片段时被写入。 读取发生在 eEarlyFragmentTests ,写入发生在 eLateFragmentTests ,我们只需要选择在最早的阶段之前完成转换操作即可。

渲染通道

我们现在需要修改 createRenderPass 以包含深度附件,首先指定 vk::AttachmentDescription

vk::AttachmentDescription depthAttachment;
depthAttachment.format = findDepthFormat();
depthAttachment.samples = vk::SampleCountFlagBits::e1;
depthAttachment.loadOp = vk::AttachmentLoadOp::eClear;
depthAttachment.storeOp = vk::AttachmentStoreOp::eDontCare;
depthAttachment.stencilLoadOp = vk::AttachmentLoadOp::eDontCare;
depthAttachment.stencilStoreOp = vk::AttachmentStoreOp::eDontCare;
depthAttachment.initialLayout = vk::ImageLayout::eUndefined;
depthAttachment.finalLayout = vk::ImageLayout::eDepthStencilAttachmentOptimal;

format 应该与深度图像本身的属性相同。 我们指定了 loadOp 开始时清理内容。 它绘制完成后不会被使用,所以storeOpeDontCare

注意我们在此处指定了 initialLayoutfinalLayout ,所以渲染管线为我们处理了深度图像布局的转换。 所以 createDepthResources 中的转换代码可以忽略:

// transitionImageLayout(
//     m_depthImage,
//     depthFormat,
//     vk::ImageLayout::eUndefined,
//     vk::ImageLayout::eDepthStencilAttachmentOptimal
// );

然后我们回到渲染通道的创建,现在为第一个(唯一的)子通道添加对附件的引用:

vk::AttachmentReference depthAttachmentRef;
depthAttachmentRef.attachment = 1;
depthAttachmentRef.layout = vk::ImageLayout::eDepthStencilAttachmentOptimal;

vk::SubpassDescription subpass;
subpass.pipelineBindPoint = vk::PipelineBindPoint::eGraphics;
subpass.setColorAttachments( colorAttachmentRef );
subpass.pDepthStencilAttachment = &depthAttachmentRef;

与颜色附件不同,子Pass只能使用单个深度(+模板)附件,所以没有 Count 字段,直接赋值初始指针即可。

然后需要更新渲染通道的创建信息,加上我们的深度附件。 上面的depthAttachmentRef.attachment是1,因为深度附件会是附件数组的1号元素,正如下面的代码。

auto attachments = { colorAttachment, depthAttachment };
vk::RenderPassCreateInfo renderPassInfo;
renderPassInfo.setAttachments( attachments );
...

这里用到了初始化列表

最后,我们需要扩展我们的子通道依赖项,以确保深度图像的转换操作和加载开始时的清除操作没有冲突。 深度图像首先在早期片段测试管线阶段被访问,并且因为我们有一个清除的加载操作,我们应该为写入指定访问掩码。

dependency.srcStageMask = vk::PipelineStageFlagBits::eColorAttachmentOutput | vk::PipelineStageFlagBits::eEarlyFragmentTests;
dependency.srcAccessMask = {};
dependency.dstStageMask = vk::PipelineStageFlagBits::eColorAttachmentOutput | vk::PipelineStageFlagBits::eEarlyFragmentTests;
dependency.dstAccessMask = vk::AccessFlagBits::eColorAttachmentWrite | vk::AccessFlagBits::eDepthStencilAttachmentWrite;

帧缓冲

下一步是修改帧缓冲的创建,从而将深度图像绑定到深度附件。

转到 createFramebuffers 并将深度图像视图指定为第二个附件:

vk::FramebufferCreateInfo framebufferInfo;
framebufferInfo.renderPass = m_renderPass;
std::array<vk::ImageView, 2> attachments { m_swapChainImageViews[i], m_depthImageView };
framebufferInfo.setAttachments( attachments );
framebufferInfo.width = m_swapChainExtent.width;
framebufferInfo.height = m_swapChainExtent.height;
framebufferInfo.layers = 1;

注意前后顺序不能反,第一个是颜色附件,第二个彩色深度附件。
你可以用 auto 初始化列表,但是需要显式将 vk::raii::ImageView 转换成 vk::ImageView

颜色附件对每个交换链都不同,深度图像可以被所有的交换链图像使用,因为我们的信号量保证了同一时间只运行一个子通道。

我们还需要移动 createFramebuffers,保证它在深度图像视图之后调用:

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

清除值

因为我们现在有多个具有 vk::AttachmentLoadOp::eClear 的附件,所以我们还需要指定多个清除值。转到 recordCommandBuffer 并创建一个 vk::ClearValue 结构体数组:

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

renderPassInfo.setClearValues( clearValues );

Vulkan 中深度缓冲的深度范围为 [0.0, 1.0],其中 1.0 位于远裁剪面,0.0 位于近裁剪面。 深度缓冲中每个点的初始值应该是最远的可能深度,即 1.0

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

深度与模板状态

深度附件已经可以使用了,但仍然需要在图形管线中启用深度测试。 现在转到 createGraphicsPipeline 函数中添加 vk::PipelineDepthStencilStateCreateInfo 结构体配置:

vk::PipelineDepthStencilStateCreateInfo depthStencil;
depthStencil.depthTestEnable = true;
depthStencil.depthWriteEnable = true;

depthTestEnable指定是否将新片段与深度缓冲中的片段对比。 depthWriteEnable指定是否将通过测试的片段写入深度缓冲。

然后还需通过 depthCompareOp 字段定义比较方式,我们坚持更小的深度=更近,所以应该这样:

depthStencil.depthCompareOp = vk::CompareOp::eLess;

还可以用 depthBoundsTestEnable 进行边界测试,从而只保留指定深度范围的片段,我们不启用它:

depthStencil.depthBoundsTestEnable = false; // Optional
depthStencil.minDepthBounds = 0.0f; // Optional if depthBoundsTestEnable is false
depthStencil.maxDepthBounds = 1.0f; // Optional if depthBoundsTestEnable is false

还有三个字段配置模板缓冲操作,我们也不使用它。

depthStencil.stencilTestEnable = false; // Optional
depthStencil.front = vk::StencilOpState{}; // Optional if stencilTestEnable is false
depthStencil.back = vk::StencilOpState{}; // Optional if stencilTestEnable is false

然后更新 vk::GraphicsPipelineCreateInfo 结构体,引用我们刚刚填写的内容:

pipelineInfo.pDepthStencilState = &depthStencil;

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

depth_correct

处理窗口大小调整

当窗口大小调整以匹配新的颜色附件分辨率时,深度缓冲的分辨率应更改。 扩展 recreateSwapChain 函数以在此情况下重建深度资源:

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

    m_device.waitIdle();

    m_swapChainFramebuffers.clear();

    m_depthImageView = nullptr;
    m_depthImage = nullptr;
    m_depthImageMemory = nullptr;

    m_swapChainImageViews.clear();
    m_swapChainImages.clear(); // optional
    m_swapChain = nullptr;


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

    m_framebufferResized = false;
}

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


C++代码

C++代码差异

根项目CMake代码

shader-CMake代码

shader-vert代码

shader-vert代码差异

shader-frag代码

shader-frag代码差异