狸村 Mystic Web · 狸村
目录

引言

到目前为止,我们处理的几何体是投影到 3D 中的,但它仍然是完全平坦的。 在本章中,我们将向位置添加 Z 坐标,为 3D 网格做准备。 我们将使用这个第三个坐标在当前正方形之上放置另一个正方形,以观察当几何体未按深度排序时出现的问题。

3D 几何体

Vertex 结构体改为使用 3D 向量作为位置,并更新相应的 vk::VertexInputAttributeDescription 中的 format

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

    ...

    static std::array<vk::VertexInputAttributeDescription, 3> getAttributeDescriptions() {
        return {
            vk::VertexInputAttributeDescription(0, 0, vk::Format::eR32G32B32Sfloat, offsetof(Vertex, pos)),
            vk::VertexInputAttributeDescription(1, 0, vk::Format::eR32G32B32Sfloat, offsetof(Vertex, color)),
            vk::VertexInputAttributeDescription(2, 0, vk::Format::eR32G32Sfloat, offsetof(Vertex, texCoord))
        };

        ...
    }
};

接下来,更新顶点着色器,将 inPosition 的类型从 float2 改为 float3,以接受并变换 3D 坐标作为输入:

struct VSInput {
    float3 inPosition;
    ...
};

...

[shader("vertex")]
VSOutput vertMain(VSInput input) {
    VSOutput output;
    output.pos = mul(ubo.proj, mul(ubo.view, mul(ubo.model, float4(input.inPosition, 1.0))));
    output.fragColor = input.inColor;
    output.fragTexCoord = input.inTexCoord;
    return output;
}

记得之后重新编译着色器!

最后,更新 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

问题在于下方正方形的片段绘制在上方正方形的片段之上,仅仅因为它在索引数组中出现在后面。 有两种方法可以解决这个问题:

  • 将所有绘制调用按深度从后到前排序

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

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

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

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

深度图像和视图

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

vk::raii::Image        depthImage       = nullptr;
vk::raii::DeviceMemory depthImageMemory = nullptr;
vk::raii::ImageView    depthImageView   = nullptr;

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

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

...

void createDepthResources()
{
}

创建深度图像相当简单。 它应该具有与颜色附件相同的分辨率(由交换链范围定义),适合深度附件的图像用途,最优平铺和设备本地内存。 唯一的问题是:深度图像的合适格式是什么? 格式必须包含深度分量,在 vk::Format 中用 D?? 表示。

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

  • vk::Format::eD32Sfloat:32 位浮点深度

  • vk::Format::eD32SfloatS8Uint:32 位有符号浮点深度和 8 位模板分量

  • vk::Format::eD24UnormS8Uint:24 位浮点深度和 8 位模板分量

模板分量用于模板测试,这是一种可以与深度测试结合使用的额外测试。 我们将在未来的章节中讨论它。

我们可以直接选择 vk::Format::eD32Sfloat 格式,因为它的支持非常普遍(参见硬件数据库),但最好尽可能为应用程序增加一些灵活性。 我们将编写一个函数 findSupportedFormat,该函数接受一个候选格式列表(从最期望到最不期望),并检查第一个受支持的格式:

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

格式的支持取决于平铺模式和用途,因此我们也必须将这些作为参数包含在内。 可以使用 physicalDevice.getFormatProperties 函数查询格式的支持情况:

for (const auto format : candidates) {
    vk::FormatProperties props = physicalDevice.getFormatProperties(format);
}

vk::FormatProperties 结构体包含三个字段:

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

  • optimalTilingFeatures:最优平铺支持的用例

  • bufferFeatures:缓冲支持的用例

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

if (((tiling == vk::ImageTiling::eLinear) && ((props.linearTilingFeatures & features) == features)) ||
    ((tiling == vk::ImageTiling::eOptimal) && ((props.optimalTilingFeatures & features) == features)))
{
    return format;
}

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

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

        if (((tiling == vk::ImageTiling::eLinear) && ((props.linearTilingFeatures & features) == features)) ||
            ((tiling == vk::ImageTiling::eOptimal) && ((props.optimalTilingFeatures & features) == features)))
        {
          return format;
        }
    }

    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);
}

在这种情况下,确保使用 vk::FormatFeatureFlagBits 而不是 vk::ImageUsageFlagBits。 所有这些候选格式都包含深度分量,但后两种还包含模板分量。 我们暂时不会使用它,但在对具有这些格式的图像执行布局转换时,我们需要考虑到这一点。 vulkan/vulkan_format_traits.hpp 头文件中提供了简单的辅助函数,如 vk::hasDepthComponentvk::hasStencilComponent

createDepthResources 调用该函数以查找深度格式:

vk::Format depthFormat = findDepthFormat();

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

std::tie(depthImage, depthImageMemory) = createImage(swapChainExtent.width, swapChainExtent.height, depthFormat, vk::ImageTiling::eOptimal, vk::ImageUsageFlagBits::eDepthStencilAttachment, vk::MemoryPropertyFlagBits::eDeviceLocal);
depthImageView                         = createImageView(depthImage, depthFormat, vk::ImageAspectFlagBits::eDepth);

然而,createImageView 函数目前假定子资源始终是 vk::ImageAspectFlagBits::eColor,因此我们需要将该字段改为参数:

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

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

swapChainImageViews.emplace_back(createImageView(image, swapChainSurfaceFormat.format, vk::ImageAspectFlagBits::eColor));
...
depthImageView = createImageView(depthImage, depthFormat, vk::ImageAspectFlagBits::eDepth);
...
textureImageView = createImageView(*textureImage, vk::Format::eR8G8B8A8Srgb, vk::ImageAspectFlagBits::eColor);

创建深度图像就是这样。 我们不需要映射它或将另一个图像复制到它,因为我们将像颜色附件一样,在命令缓冲开始时清除它。

命令缓冲

清除值

因为我们现在有多个附件将以 vk::AttachmentLoadOp::eClear 清除(颜色和深度),我们也需要指定多个清除值。 转到 recordCommandBuffer,创建并添加一个额外的 vk::ClearValue 变量,称为 clearDepth

vk::ClearValue clearColor = vk::ClearColorValue(0.0f, 0.0f, 0.0f, 1.0f);
vk::ClearValue clearDepth = vk::ClearDepthStencilValue(1.0f, 0);

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

动态渲染

现在我们有了深度图像,需要在 recordCommandBuffer 中使用它。 这将是动态渲染的一部分,类似于设置颜色输出图像。

首先为深度图像指定一个新的渲染附件:

vk::RenderingAttachmentInfo depthAttachmentInfo = {
    .imageView   = depthImageView,
    .imageLayout = vk::ImageLayout::eDepthAttachmentOptimal,
    .loadOp      = vk::AttachmentLoadOp::eClear,
    .storeOp     = vk::AttachmentStoreOp::eDontCare,
    .clearValue  = clearDepth};

并将其添加到动态渲染信息结构体中:

vk::RenderingInfo renderingInfo = {
    ...
    .pDepthAttachment     = &depthAttachmentInfo};

显式转换深度图像

与颜色附件一样,深度附件需要处于正确的布局以适应预期的用例。为此,我们需要发出一个额外的屏障,以确保深度图像在渲染期间可以用作深度附件。 深度图像在早期片段测试管线阶段首次被访问,并且由于我们有一个 清除 的加载操作,我们应该为写入指定访问掩码。

由于我们现在处理的是另一种图像类型(深度),首先在 transition_image_layout 函数中为图像方面添加一个新参数:

void transition_image_layout(
    ...
    vk::ImageAspectFlags    image_aspect_flags)
{
    vk::ImageMemoryBarrier2 barrier = {
        ...
        .subresourceRange    = {
            .aspectMask     = image_aspect_flags,
            .baseMipLevel   = 0,
            .levelCount     = 1,
            .baseArrayLayer = 0,
            .layerCount     = 1}};
}

现在在 recordCommandBuffer 的命令缓冲开始时添加新的图像布局转换:

commandBuffers[currentFrame].begin({});
// 在开始渲染之前,将交换链图像转换为 vk::ImageLayout::eColorAttachmentOptimal
transition_image_layout(
    ...
    vk::ImageAspectFlagBits::eColor);

// 将深度图像转换为深度附件最优布局
transition_image_layout(
    *depthImage,
    vk::ImageLayout::eUndefined,
    vk::ImageLayout::eDepthAttachmentOptimal,
    vk::AccessFlagBits2::eDepthStencilAttachmentWrite,
    vk::AccessFlagBits2::eDepthStencilAttachmentWrite,
    vk::PipelineStageFlagBits2::eEarlyFragmentTests | vk::PipelineStageFlagBits2::eLateFragmentTests,
    vk::PipelineStageFlagBits2::eEarlyFragmentTests | vk::PipelineStageFlagBits2::eLateFragmentTests,
    vk::ImageAspectFlagBits::eDepth);

与颜色图像不同,我们在这里不需要多个屏障。因为我们不关心帧完成后深度附件的内容,我们总是可以从 vk::ImageLayout::eUndefined 转换。这种布局的特殊之处在于,你总是可以使用它作为源,而无需关心之前发生了什么。

还要确保调整所有其他对 transition_image_layout 函数的调用,以传递正确的图像方面:

// 在开始渲染之前,将交换链图像转换为 COLOR_ATTACHMENT_OPTIMAL
transition_image_layout(
    ...
    // 对于颜色图像也需要指定此项
    vk::ImageAspectFlagBits::eColor);

深度和模板状态

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

vk::PipelineDepthStencilStateCreateInfo depthStencil{
    .depthTestEnable       = vk::True,
    .depthWriteEnable      = vk::True,
    .depthCompareOp        = vk::CompareOp::eLess,
    .depthBoundsTestEnable = vk::False,
    .stencilTestEnable     = vk::False};

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

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

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

stencilTestEnablefrontback 字段配置模板缓冲操作,我们也不会在本教程中使用这些。 如果你想使用这些操作,那么你必须确保深度/模板图像的格式包含模板分量。

如果动态渲染设置包含深度模板附件,则必须始终指定深度模板状态:

更新 pipelineCreateInfoChain 结构链,以引用我们刚刚填充的深度模板状态,并添加对我们正在使用的深度格式的引用:

vk::StructureChain<vk::GraphicsPipelineCreateInfo, vk::PipelineRenderingCreateInfo> pipelineCreateInfoChain = {
    {.stageCount          = 2,
     ...
     .pDepthStencilState  = &depthStencil,
     ...},
    {.colorAttachmentCount = 1, .pColorAttachmentFormats = &swapChainSurfaceFormat.format, .depthAttachmentFormat = depthFormat}};

如果你现在运行程序,你应该会看到几何体的片段现在被正确排序:

depth correct

处理窗口调整大小

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

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

    device.waitIdle(device);

    cleanupSwapChain();
    createSwapChain();
    createImageViews();
    createDepthResources();
}

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

Vulkan 是 Khronos Group Inc. 的注册商标

教程内容版权归原作者,遵循 CC BY-SA 4.0;本站独立代码及设计除外。