多重采样

导言

我们的程序现在可以为纹理加载多个细节级别,这修复了渲染远离观看者的对象时的伪影。图像现在更加平滑,但在仔细检查时,您会注意到沿绘制的几何形状边缘的锯齿状图案。这在我们早期渲染四边形的程序中尤其明显

texcoord visualization

这种不良效果称为“走样”,它是可用于渲染的像素数量有限的结果。由于没有任何显示器具有无限分辨率,因此它在某种程度上总是可见的。有很多方法可以解决这个问题,在本章中,我们将重点介绍一种更流行的方法:多重采样抗锯齿 (MSAA)。

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

aliasing

MSAA 的作用是每个像素使用多个采样点(因此得名)来确定其最终颜色。正如人们可能期望的那样,更多的采样会导致更好的结果,但它的计算量也更大。

antialiasing

在我们的实现中,我们将重点使用可用的最大采样计数。根据您的应用程序,这可能并不总是最好的方法,如果最终结果满足您的质量要求,则为了更高的性能,最好使用更少的采样。

获取可用的采样计数

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

...
VkSampleCountFlagBits msaaSamples = VK_SAMPLE_COUNT_1_BIT;
...

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

VkSampleCountFlagBits getMaxUsableSampleCount() {
    VkPhysicalDeviceProperties physicalDeviceProperties;
    vkGetPhysicalDeviceProperties(physicalDevice, &physicalDeviceProperties);

    VkSampleCountFlags counts = physicalDeviceProperties.limits.framebufferColorSampleCounts & physicalDeviceProperties.limits.framebufferDepthSampleCounts;
    if (counts & VK_SAMPLE_COUNT_64_BIT) { return VK_SAMPLE_COUNT_64_BIT; }
    if (counts & VK_SAMPLE_COUNT_32_BIT) { return VK_SAMPLE_COUNT_32_BIT; }
    if (counts & VK_SAMPLE_COUNT_16_BIT) { return VK_SAMPLE_COUNT_16_BIT; }
    if (counts & VK_SAMPLE_COUNT_8_BIT) { return VK_SAMPLE_COUNT_8_BIT; }
    if (counts & VK_SAMPLE_COUNT_4_BIT) { return VK_SAMPLE_COUNT_4_BIT; }
    if (counts & VK_SAMPLE_COUNT_2_BIT) { return VK_SAMPLE_COUNT_2_BIT; }

    return VK_SAMPLE_COUNT_1_BIT;
}

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

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

设置渲染目标

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

...
VkImage colorImage;
VkDeviceMemory colorImageMemory;
VkImageView colorImageView;
...

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

void createImage(uint32_t width, uint32_t height, uint32_t mipLevels, VkSampleCountFlagBits numSamples, VkFormat format, VkImageTiling tiling, VkImageUsageFlags usage, VkMemoryPropertyFlags properties, VkImage& image, VkDeviceMemory& imageMemory) {
    ...
    imageInfo.samples = numSamples;
    ...

现在,使用 VK_SAMPLE_COUNT_1_BIT 更新对此函数的所有调用 - 我们将在实施过程中用正确的值替换它

createImage(swapChainExtent.width, swapChainExtent.height, 1, VK_SAMPLE_COUNT_1_BIT, depthFormat, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, depthImage, depthImageMemory);
...
createImage(texWidth, texHeight, mipLevels, VK_SAMPLE_COUNT_1_BIT, VK_FORMAT_R8G8B8A8_SRGB, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, textureImage, textureImageMemory);

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

void createColorResources() {
    VkFormat colorFormat = swapChainImageFormat;

    createImage(swapChainExtent.width, swapChainExtent.height, 1, msaaSamples, colorFormat, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT | VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, colorImage, colorImageMemory);
    colorImageView = createImageView(colorImage, colorFormat, VK_IMAGE_ASPECT_COLOR_BIT, 1);
}

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

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

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

void createDepthResources() {
    ...
    createImage(swapChainExtent.width, swapChainExtent.height, 1, msaaSamples, depthFormat, VK_IMAGE_TILING_OPTIMAL, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, depthImage, depthImageMemory);
    ...
}

我们现在创建了一些新的 Vulkan 资源,所以我们不要忘记在必要时释放它们

void cleanupSwapChain() {
    vkDestroyImageView(device, colorImageView, nullptr);
    vkDestroyImage(device, colorImage, nullptr);
    vkFreeMemory(device, colorImageMemory, nullptr);
    ...
}

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

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

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

添加新附件

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

void createRenderPass() {
    ...
    colorAttachment.samples = msaaSamples;
    colorAttachment.finalLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
    ...
    depthAttachment.samples = msaaSamples;
    ...

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

    ...
    VkAttachmentDescription colorAttachmentResolve{};
    colorAttachmentResolve.format = swapChainImageFormat;
    colorAttachmentResolve.samples = VK_SAMPLE_COUNT_1_BIT;
    colorAttachmentResolve.loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
    colorAttachmentResolve.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
    colorAttachmentResolve.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
    colorAttachmentResolve.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
    colorAttachmentResolve.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
    colorAttachmentResolve.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
    ...

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

    ...
    VkAttachmentReference colorAttachmentResolveRef{};
    colorAttachmentResolveRef.attachment = 2;
    colorAttachmentResolveRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
    ...

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

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

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

    ...
    dependency.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
    ...

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

    ...
    std::array<VkAttachmentDescription, 3> attachments = {colorAttachment, depthAttachment, colorAttachmentResolve};
    ...

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

void createFramebuffers() {
        ...
        std::array<VkImageView, 3> attachments = {
            colorImageView,
            depthImageView,
            swapChainImageViews[i]
        };
        ...
}

最后,通过修改 createGraphicsPipeline,告诉新创建的管线使用多个采样。

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

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

multisampling

就像使用mipmap一样,差异可能不会立即显现。仔细观察你会注意到边缘不再那么锯齿状,并且与原始图像相比,整个图像看起来更平滑一些。

multisampling comparison

当近距离观察其中一个边缘时,差异更加明显。

multisampling comparison2

质量改进

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

void createLogicalDevice() {
    ...
    deviceFeatures.sampleRateShading = VK_TRUE; // enable sample shading feature for the device
    ...
}

void createGraphicsPipeline() {
    ...
    multisampling.sampleShadingEnable = VK_TRUE; // enable sample shading in the pipeline
    multisampling.minSampleShading = .2f; // min fraction for sample shading; closer to one is smoother
    ...
}

在此示例中,我们将禁用采样着色,但在某些情况下,质量的提高可能很明显。

sample shading

结论

为了达到这一点,我们已经做了很多工作,但现在你终于拥有了 Vulkan 程序的一个良好基础。你现在掌握的 Vulkan 基本原理知识应该足以开始探索更多功能,例如

  • 推送常量

  • 实例渲染

  • 动态 uniform

  • 单独的图像和采样器描述符

  • 管线缓存

  • 多线程命令缓冲区生成

  • 多个子通道

  • 计算着色器

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