多重采样
介绍
我们的程序现在可以加载纹理的多个细节级别,这解决了在渲染远离观察者的对象时出现的伪影。 图像现在更加平滑,然而,仔细观察,您会注意到沿着绘制的几何形状边缘有锯齿状的图案。 这在我们早期的程序中特别明显,当时我们渲染了一个四边形:
这种不希望出现的效果被称为"锯齿",它是由于可用于渲染的像素数量有限造成的。 由于没有无限分辨率的显示器,它在某种程度上总是可见的。 有多种方法可以解决这个问题,在本章中,我们将专注于其中一种更流行的方法:https://en.wikipedia.org/wiki/Multisample_anti-aliasing[多重采样抗锯齿](MSAA)。
在普通渲染中,像素颜色是基于单个采样点确定的,在大多数情况下,这个采样点是屏幕上目标像素的中心。 如果绘制的线的一部分通过某个像素但没有覆盖采样点,那么该像素将保持空白,导致锯齿状的"阶梯"效果。
MSAA 的做法是每个像素使用多个采样点(因此得名)来确定其最终颜色。 正如人们所预期的,更多的样本会带来更好的结果,但是,这也会消耗更多的计算资源。
在我们的实现中,我们将专注于使用最大可用的采样计数。 根据您的应用程序,这可能并不总是最佳方法,为了获得更高的性能,如果最终结果满足您的质量要求,使用更少的样本可能会更好。
获取可用的采样计数
让我们首先确定我们的硬件可以使用多少个样本。 大多数现代 GPU 至少支持八个样本,但这个数字不能保证在任何地方都是相同的。 我们将通过添加一个新的类成员来跟踪它:
...
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;
...
您会注意到我们已经将 finalLayout 从 VK_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;
...
由于我们重用多重采样颜色图像,因此有必要更新 VkSubpassDependency 的 srcAccessMask。
此更新确保对颜色附件的任何写操作在后续操作开始之前完成,从而防止写后写冲突,这可能导致不稳定的渲染结果:
...
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;
...
}
现在运行您的程序,您应该看到以下内容:
就像 mipmap 一样,差异可能不会立即明显。 仔细观察,您会注意到边缘不再那么锯齿状,整个图像与原始图像相比似乎更平滑一些。
当近距离观察其中一个边缘时,差异更加明显:
质量改进
我们当前的 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 基本原理知识应该足以开始探索更多的功能,例如:
-
推送常量
-
实例化渲染
-
动态统一变量
-
分离图像和采样器描述符
-
管线缓存
-
多线程命令缓冲区生成
-
多个子通道
当前程序可以通过多种方式扩展,例如添加 Blinn-Phong 光照、后处理效果和阴影映射。 您应该能够从其他 API 的教程中学习这些效果的工作原理,因为尽管 Vulkan 很明确,但许多概念仍然以相同的方式工作。
C++ 代码 / slang 着色器 / GLSL 顶点着色器 / GLSL 片段着色器