Vulkan 教程 Khronos Vulkan® Tutorial · 中文翻译 GitHub

简介

到目前为止我们打交道的几何体虽然已经投影到三维空间,但本身仍然完全扁平。本章会给位置加上 Z 坐标,为使用三维网格做准备。我们还会借助这第三个坐标,在当前方块上方叠一块方块,从而看到几何体未按深度排序时暴露出的问题。

三维几何体

把 Vertex 结构体中的位置改成三维向量,并同步更新对应的 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:

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

Z 坐标取 -0.5f,并为这块额外的方块补上对应的索引:

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

现在运行程序,你会看到类似埃舍尔(Escher)错觉画的画面:

depth issues

问题在于,下方方块的片元盖在了上方方块的片元之上,原因仅仅是它在索引数组里排在后面。 有两种解决办法:

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

  • 使用深度缓冲区做深度测试

第一种做法常用于绘制透明物体,因为与顺序无关的透明本身就是一个难以解决的问题。 不过,按深度给片元排序的问题,更常见的解法是使用深度缓冲区(depth buffer)。 深度缓冲区是一份额外的附件,它像颜色附件记录每个位置的颜色那样,记录每个位置的深度。 光栅化器每产生一个片元,深度测试就会检查新片元是否比之前的片元更近。 如果不是,新片元就被丢弃。 通过深度测试的片元会把自己的深度写入深度缓冲区。 这个值同样可以在片元着色器中修改,就像你可以修改颜色输出一样。

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

GLM 生成的透视投影矩阵默认使用 OpenGL 的深度范围 -1.0 到 1.0。 我们需要通过 GLM_FORCE_DEPTH_ZERO_TO_ONE 宏,让它改用 Vulkan 的 0.0 到 1.0。

深度图像与图像视图

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

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

新建 createDepthResources 函数来创建这些资源:

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

...

void createDepthResources()
{
}

创建深度图像相当直接。 它的分辨率应当与颜色附件相同,由交换链范围(extent)决定;用途要适合深度附件;使用最优平铺(optimal tiling)和设备本地内存。 唯一的问题是:深度图像该用哪种格式? 格式必须包含深度分量,在 vk::Format 中以 D?? 标示。

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

  • vk::Format::eD32Sfloat:深度使用 32 位浮点数

  • vk::Format::eD32SfloatS8Uint:深度使用 32 位浮点数,外加 8 位模板(stencil)分量

  • 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::hasDepthComponent、vk::hasStencilComponent 这类简单的辅助函数。

在 createDepthResources 中调用该函数取得深度格式:

vk::Format depthFormat = findDepthFormat();

现在所需信息齐全,可以调用 createImage 和 createImageView 辅助函数了:

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, ...}};
    ...
}

把该函数的所有调用点都改成正确的 aspect:

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.0 到 1.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};

显式转换深度图像的布局

和颜色附件一样,深度附件必须处于符合预期用途的正确布局。 为此需要额外插入一个屏障,确保深度图像在渲染期间能作为深度附件使用。 深度图像首次被访问是在早期片元测试(early fragment test)管线阶段;由于加载操作是清除,访问掩码应当指定为写。

现在多了一种图像类型(深度),先在 transition_image_layout 函数里增加一个表示图像 aspect 的参数:

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 的其余调用,传入正确的图像 aspect:

// 开始渲染前,把交换链图像转换到 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 字段指定保留或丢弃片元时执行的比较方式。 我们沿用深度值越小越近的约定,所以新片元的深度应当更小。

depthBoundsTestEnable、minDepthBounds 和 maxDepthBounds 字段用于可选的深度边界测试。 简单说,它让你只保留落在指定深度范围内的片元。 本教程不会使用这项功能。

stencilTestEnable、front 和 back 字段用于配置模板缓冲区操作,本教程同样不会使用。 如果你想用这些操作,就必须确保深度/模板图像所用的格式包含模板分量。

只要动态渲染的设置中包含深度模板附件,就必须指定深度模板状态:

更新 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();
}

恭喜,你的应用终于可以正确渲染任意三维几何体了。 我们将在 下一章实际验证这一点:绘制一个带纹理的模型!