目录
Getting available sample count
Introduction
我们的程序现在可以为纹理加载多个级别的细节,从而在渲染远离查看器的对象时修复瑕疵。图像现在平滑了很多,但是仔细观察,你会发现沿着绘制的几何形状边缘的锯齿状图案。这在我们早期的一个程序中尤其明显,当时我们渲染了一个四边形:
这种不希望的效果称为“混叠”,这是可用于渲染的像素数量有限的结果。由于没有无限分辨率的显示器,因此在某种程度上它总是可见的。有很多方法可以解决这个问题,在本章中,我们将重点介绍一种更流行的方法:多采样抗锯齿(MSAA)。
在普通渲染中,像素颜色是基于单个采样点确定的,在大多数情况下,采样点是屏幕上目标像素的中心。如果绘制的线的一部分穿过某个像素,但未覆盖采样点,则该像素将保持空白,从而产生锯齿状的“楼梯”效果。
MSAA的作用是每个像素使用多个采样点(因此得名)来确定其最终颜色。正如人们可能预期的那样,更多的样本会导致更好的结果,但计算成本也更高。
在我们的实现中,我们将专注于使用最大可用样本数。根据您的应用程序,这可能并不总是最好的方法,如果最终结果满足您的质量要求,为了获得更高的性能,最好使用更少的样本。
Getting available sample count
让我们从确定硬件可以使用多少样本开始。大多数现代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;
}
}
...
}
Setting up a render target
在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设置,现在我们需要开始在图形管道、帧缓冲区、渲染过程中使用这个新资源,并查看结果!
Adding new attachments
让我们先处理渲染过程。修改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_IMACE_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一样,差异可能不会立即显现出来。仔细一看,你会发现边缘不再是锯齿状的,整个图像看起来比原始图像更平滑。
当近距离观察其中一个边缘时,差异更为明显:
Quality improvements
我们当前的MSAA实现存在某些限制,这可能会影响更详细场景中输出图像的质量。例如,我们目前没有解决着色器混叠导致的潜在问题,即MSAA仅平滑几何体的边缘,而不平滑内部填充。当您在屏幕上渲染平滑多边形时,这可能会导致一种情况,但如果应用的纹理包含高对比度的颜色,则该纹理看起来仍然会有锯齿。解决此问题的一种方法是启用“采样着色”(Sample Shading),这将进一步提高图像质量,但需要额外的性能成本:
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
...
}
在本例中,我们将禁用示例着色,但在某些情况下,质量改善可能会很明显:
Conclusion
要达到这一点需要做很多工作,但现在你终于为Vulkan项目打下了良好的基础。您现在掌握的Vulkan基本原理的知识应该足以开始探索更多功能,如:
- Push constants
- Instanced rendering
- Dynamic uniforms
- Separate images and sampler descriptors
- Pipeline cache
- Multi-threaded command buffer generation
- Multiple subpasses
- Compute shaders
当前的程序可以通过多种方式进行扩展,如添加Blinn Phong照明、后期处理效果和阴影映射。您应该能够从其他API的教程中了解这些效果是如何工作的,因为尽管Vulkan很明确,但许多概念仍然是一样的。