简介

我们的程序现在可以为纹理加载多个细节级别,这修复了渲染远离观察者的物体时出现的瑕疵。图像现在平滑了很多,但是仔细观察你会注意到沿着绘制的几何形状边缘出现锯齿状的图案。这在我们早期的程序中尤其明显,当时我们渲染了一个四边形

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

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

MSAA 的作用是每个像素使用多个采样点(因此得名)来确定其最终颜色。正如人们可能预料的那样,样本越多,效果越好,但这在计算上也更昂贵。

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

获取可用的采样计数

让我们首先确定我们的硬件可以使用多少个样本。大多数现代 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;
    ...

您会注意到,我们已将 finalLayout 从 VK_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;
    ...

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

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

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

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

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

质量提升

我们当前的 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
    ...
}

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

结论

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

  • 推送常量
  • 实例渲染
  • 动态 uniform
  • 分离的图像和采样器描述符
  • 管线缓存
  • 多线程命令缓冲生成
  • 多个子通道
  • 计算着色器

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

C++ 代码 / 顶点着色器 / 片段着色器