狸村 Mystic Web · 狸村
目录

引言

我们的程序现在可以加载纹理的多个细节级别,从而修复渲染远离观察者的物体时的伪影。图像现在平滑多了,然而,仔细观察时,你会注意到绘制几何形状的边缘有锯齿状的图案。 这在我们早期渲染四边形的程序中尤其明显:

texcoord visualization

这种不良效果称为“走样(aliasing)”,它是由于可用于渲染的像素数量有限造成的。由于没有分辨率无限的显示器,它在某种程度上总是可见的。 有几种方法可以解决这个问题,在本章中,我们将重点关注其中一种更流行的方法:https://en.wikipedia.org/wiki/Multisample_anti-aliasing[多重采样抗锯齿(MSAA)]。

在普通渲染中,像素颜色是基于单个采样点确定的,在大多数情况下,该采样点是屏幕上目标像素的中心。 如果绘制的线条的一部分穿过某个像素但没有覆盖采样点,则该像素将保持空白,从而导致锯齿状的“阶梯”效应。

aliasing

MSAA 所做的是使用每个像素多个采样点(因此得名)来确定其最终颜色。 正如人们所料,更多的采样点会带来更好的结果,但计算成本也更高。

antialiasing

在我们的实现中,我们将专注于使用最大可用样本数。根据你的应用程序,这可能并不总是最佳方法,如果最终结果满足你的质量要求,为了更高的性能,使用更少的样本可能更好。

获取可用样本数

让我们首先确定我们的硬件可以使用多少样本。大多数现代 GPU 至少支持 8 个样本,但这个数字并不能保证在所有地方都相同。 我们将通过添加一个新的类成员来跟踪它:

...
vk::SampleCountFlagBits msaaSamples = vk::SampleCountFlagBits::e1;
...

默认情况下,我们将每个像素只使用一个样本,这相当于没有多重采样,在这种情况下,最终图像将保持不变。 确切的最大样本数可以从与我们选择的物理设备关联的 VkPhysicalDeviceProperties 中提取。 我们使用了深度缓冲,因此我们必须同时考虑颜色和深度的样本数。两者都支持的最高样本数就是我们可以支持的最大值。 添加一个函数来为我们获取这些信息:

vk::SampleCountFlagBits getMaxUsableSampleCount() {
    vk::PhysicalDeviceProperties physicalDeviceProperties = physicalDevice->getProperties();

    vk::SampleCountFlags counts = physicalDeviceProperties.limits.framebufferColorSampleCounts & physicalDeviceProperties.limits.framebufferDepthSampleCounts;
    if (counts & vk::SampleCountFlagBits::e64) { return vk::SampleCountFlagBits::e64; }
    if (counts & vk::SampleCountFlagBits::e32) { return vk::SampleCountFlagBits::e32; }
    if (counts & vk::SampleCountFlagBits::e16) { return vk::SampleCountFlagBits::e16; }
    if (counts & vk::SampleCountFlagBits::e8) { return vk::SampleCountFlagBits::e8; }
    if (counts & vk::SampleCountFlagBits::e4) { return vk::SampleCountFlagBits::e4; }
    if (counts & vk::SampleCountFlagBits::e2) { return vk::SampleCountFlagBits::e2; }

    return vk::SampleCountFlagBits::e1;
}

我们现在将在物理设备选择过程中使用此函数来设置 msaaSamples 变量。 为此,我们必须稍微修改 pickPhysicalDevice 函数:

void pickPhysicalDevice() {
    ...
    for (const auto& device : devices) {
        if (isDeviceSuitable(device)) {
            physicalDevice = device;
            msaaSamples = getMaxUsableSampleCount();
            break;
        }
    }
    ...
}

设置渲染目标

在 MSAA 中,每个像素在离屏缓冲中采样,然后将其渲染到屏幕。 这个新缓冲与我们一直渲染到的常规图像略有不同——它必须能够每个像素存储多个样本。 一旦创建了多重采样缓冲,它必须被解析到默认帧缓冲(每个像素只存储一个样本)。 这就是为什么我们必须创建一个额外的渲染目标并修改我们当前的绘制过程。 我们只需要一个渲染目标,因为一次只有一个绘制操作处于活动状态,就像深度缓冲一样。 添加以下类成员:

...
vk::raii::Image colorImage = nullptr;
vk::raii::DeviceMemory colorImageMemory = nullptr;
vk::raii::ImageView colorImageView = nullptr;
...

这个新图像必须存储每个像素所需的样本数,因此我们需要在图像创建过程中将此数字传递给 VkImageCreateInfo。 通过添加一个 numSamples 参数来修改 createImage 函数:

void createImage(uint32_t width, uint32_t height, uint32_t mipLevels, vk::SampleCountFlagBits numSamples, vk::Format format, vk::ImageTiling tiling, vk::ImageUsageFlags usage, vk::MemoryPropertyFlags properties, vk::raii::Image& image, vk::raii::DeviceMemory& imageMemory) const {
    ...
    imageInfo.samples = numSamples;
    ...

现在,使用 VK_SAMPLE_COUNT_1_BIT 更新所有对此函数的调用——随着我们继续实现,我们将用正确的值替换它:

createImage(swapChainExtent.width, swapChainExtent.height, 1, vk::SampleCountFlagBits::e1, depthFormat, vk::ImageTiling::eOptimal, vk::ImageUsageFlagBits::eDepthStencilAttachment, vk::MemoryPropertyFlagBits::eDeviceLocal, depthImage, depthImageMemory);
...
createImage(texWidth, texHeight, mipLevels, vk::SampleCountFlagBits::e1, vk::Format::eR8G8B8A8Srgb, vk::ImageTiling::eOptimal, vk::ImageUsageFlagBits::eTransferSrc | vk::ImageUsageFlagBits::eTransferDst | vk::ImageUsageFlagBits::eSampled, vk::MemoryPropertyFlagBits::eDeviceLocal, textureImage, textureImageMemory);

我们现在将创建一个多重采样颜色缓冲。 添加一个 createColorResources 函数,并注意我们在这里使用 msaaSamples 作为 createImage 的函数参数。 我们也只使用一个 mip 级别,因为对于每个像素具有多个样本的图像,Vulkan 规范强制要求这一点。 此外,这个颜色缓冲不需要 mipmap,因为它不会用作纹理:

void createColorResources() {
    vk::Format colorFormat = swapChainImageFormat;

    createImage(swapChainExtent.width, swapChainExtent.height, 1, msaaSamples, colorFormat, vk::ImageTiling::eOptimal, vk::ImageUsageFlagBits::eTransientAttachment | vk::ImageUsageFlagBits::eColorAttachment,  vk::MemoryPropertyFlagBits::eDeviceLocal, colorImage, colorImageMemory);
    colorImageView = createImageView(colorImage, colorFormat, vk::ImageAspectFlagBits::eColor, 1);
}

为保持一致性,在 createDepthResources 之前调用该函数:

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

现在我们已经有了一个多重采样颜色缓冲,是时候处理深度了。 修改 createDepthResources 并更新深度缓冲使用的样本数:

void createDepthResources() {
    ...
    createImage(swapChainExtent.width, swapChainExtent.height, 1, msaaSamples, depthFormat, vk::ImageTiling::eOptimal, vk::ImageUsageFlagBits::eDepthStencilAttachment, vk::MemoryPropertyFlagBits::eDeviceLocal, depthImage_, depthImageMemory_);
    ...
}

并更新 recreateSwapChain,以便在窗口调整大小时可以以正确的分辨率重新创建新的颜色图像:

void recreateSwapChain() {
    ...
    createImageViews();
    createColorResources();
    createDepthResources();
    ...
}

我们已经完成了初始 MSAA 设置,现在我们需要在我们的图形管线、帧缓冲、渲染通道中开始使用这个新资源,并查看结果!

添加新附件

首先处理渲染通道。 修改 createRenderPass 并更新颜色和深度附件创建信息结构体:

void createRenderPass() {
    ...
    colorAttachment.samples = msaaSamples;
    colorAttachment.finalLayout = vk::ImageLayout::eColorAttachmentOptimal;
    ...
    depthAttachment.samples = msaaSamples;
    ...

你会注意到我们将 finalLayoutVK_IMAGE_LAYOUT_PRESENT_SRC_KHR 改为 VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL。 这是因为多重采样图像不能直接呈现。 我们首先需要将它们解析为常规图像。 这个要求不适用于深度缓冲,因为它不会在任何时候呈现。 因此,我们只需要为颜色添加一个新附件,即所谓的解析附件:

    ...
    vk::AttachmentDescription colorAttachmentResolve({}, swapChainImageFormat, vk::SampleCountFlagBits::e1, vk::AttachmentLoadOp::eDontCare,
        vk::AttachmentStoreOp::eStore, vk::AttachmentLoadOp::eDontCare, vk::AttachmentStoreOp::eDontCare, vk::ImageLayout::eUndefined,
        vk::ImageLayout::ePresentSrcKHR);
    ...

现在必须指示渲染通道将多重采样颜色图像解析为常规附件。 创建一个新的附件引用,它将指向将作为解析目标的颜色缓冲:

    ...
    vk::AttachmentReference colorAttachmentResolveRef(2, vk::ImageLayout::eColorAttachmentOptimal);
    ...

将子通道结构体成员 pResolveAttachments 设置为指向新创建的附件引用。 这足以让渲染通道定义多重采样解析操作,从而让我们可以将图像渲染到屏幕:

    ...
    subpass.pResolveAttachments = &colorAttachmentResolveRef;
    ...

由于我们重用多重采样颜色图像,有必要更新 VkSubpassDependencysrcAccessMask。 此更新确保对颜色附件的任何写入操作在后续操作开始之前完成,从而防止写后写风险,这种风险可能导致不稳定的渲染结果:

    ...
    dependency.srcAccessMask = vk::AccessFlagBits::eColorAttachmentWrite | vk::AccessFlagBits::eDepthStencilAttachmentWrite;
    ...

现在使用新的颜色附件更新渲染通道信息结构体:

    ...
    std::array attachments = {colorAttachment, depthAttachment, colorAttachmentResolve};
    ...

设置好渲染通道后,修改 createFramebuffers 并将新的图像视图添加到列表中:

void createFramebuffers() {
        ...
        vk::ImageView attachments[] = { *colorImageView,  *depthImageView, view };
        ...
}

最后,通过修改 createGraphicsPipeline 告诉新创建的管线使用多于一个样本:

void createGraphicsPipeline() {
    ...
    multisampling.rasterizationSamples = msaaSamples;
    ...
}

现在运行你的程序,你应该会看到以下内容:

multisampling

就像 mipmapping 一样,差异可能不会立即显现。 仔细观察,你会注意到边缘不再那么锯齿状,整个图像似乎比原来平滑了一些。

multisampling comparison

当靠近观察其中一个边缘时,差异更加明显:

multisampling comparison2

质量改进

我们当前的 MSAA 实现存在某些限制,可能会在更详细的场景中影响输出图像的质量。 例如,我们目前没有解决着色器走样可能引起的潜在问题,即 MSAA 仅平滑了几何体的边缘,而没有平滑内部填充。 这可能导致一种情况:你在屏幕上渲染了一个平滑的多边形,但如果应用的纹理包含高对比度颜色,它看起来仍然会有锯齿。 解决这个问题的一种方法是启用 样本着色(Sample Shading),这将进一步提高图像质量,但会带来额外的性能成本:

void createLogicalDevice() {
    ...
    deviceFeatures.sampleRateShading = vk::True; // 启用设备样本着色功能
    ...
}

void createGraphicsPipeline() {
    ...
    multisampling.sampleShadingEnable = vk::True; // 在管线中启用样本着色
    multisampling.minSampleShading = .2f; // 样本着色的最小分数;越接近 1 越平滑
    ...
}

在这个示例中,我们保持样本着色禁用,但在某些情况下,质量改进可能是显而易见的:

sample shading

结论

为了达到这一点,我们付出了大量的努力,但现在你终于拥有了一个良好的 Vulkan 程序基础。 你现在所掌握的 Vulkan 基本原理知识应该足以开始探索更多特性,例如:

  • 推送常量(Push constants)

  • 实例化渲染(Instanced rendering)

  • 动态 uniform(Dynamic uniforms)

  • 分离图像和采样器描述符(Separate images and sampler descriptors)

  • 管线缓存(Pipeline cache)

  • 多线程命令缓冲生成(Multithreaded command buffer generation)

  • 多子通道(Multiple subpasses)

  • 计算着色器(Compute shaders)

当前程序可以通过多种方式扩展,例如添加 Blinn-Phong 光照、后期处理效果和阴影映射。 你应该能够从其他 API 的教程中学习这些效果的工作原理,因为尽管 Vulkan 很明确,但许多概念仍然以相同的方式工作。

Vulkan 是 Khronos Group Inc. 的注册商标

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