深度缓冲
前言
现在,我们已经将几何体载入了三维空间,但我们只输入了二维坐标。 在本章中,我们会为3D网格添加 Z 坐标,并通过一个例子向你展示图像是否处理深度时的差异。
3D几何体
1. 添加三维坐标
改变C++代码中的 Vertex
结构体,使用三维坐标,同时更新 attributeDescriptions
中的format
字段:
struct Vertex {
glm::vec3 pos;
glm::vec3 color;
glm::vec2 texCoord;
...
static std::array<vk::VertexInputAttributeDescription, 3> getAttributeDescriptions() {
std::array<vk::VertexInputAttributeDescription, 3> attributeDescriptions;
attributeDescriptions[0].binding = 0;
attributeDescriptions[0].location = 0;
attributeDescriptions[0].format = vk::Format::eR32G32B32Sfloat;
attributeDescriptions[0].offset = offsetof(Vertex, pos);
...
}
}
然后需要更新顶点着色器的输入和坐标变换代码,用于适配我们的三维坐标:
...
layout(location = 0) in vec3 inPosition;
...
void main() {
gl_Position = ubo.proj * ubo.view * ubo.model * vec4(inPosition, 1.0);
fragColor = inColor;
fragTexCoord = inTexCoord;
}
还需要更新 vertices
数据,为每个顶点添加 Z 轴坐标:
const std::vector<Vertex> vertices = {
{{-0.5f, -0.5f, 0.0f}, {1.0f, 0.0f, 0.0f}, {1.0f, 0.0f}},
{{0.5f, -0.5f, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f}},
{{0.5f, 0.5f, 0.0f}, {0.0f, 0.0f, 1.0f}, {0.0f, 1.0f}},
{{-0.5f, 0.5f, 0.0f}, {1.0f, 1.0f, 1.0f}, {1.0f, 1.0f}}
};
现在运行程序,你看到的内容应该和之前一样。
2. 添加几何体
现在可以添加一个额外的几何体让场景更有趣些,它会为我们展示本场景我们需要解决的问题。 我们希望在现有矩形的下方再放置一个矩形,就像这样:
现在往顶点数据中添加内容,新顶点的Z坐标使用 -0.5f
,不要忘了顶点索引:
inline static const std::vector<Vertex> vertices = {
{{-0.5f, -0.5f, 0.0f}, {1.0f, 0.0f, 0.0f}, {1.0f, 0.0f}},
{{0.5f, -0.5f, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.0f, 0.0f}},
{{0.5f, 0.5f, 0.0f}, {0.0f, 0.0f, 1.0f}, {0.0f, 1.0f}},
{{-0.5f, 0.5f, 0.0f}, {1.0f, 1.0f, 1.0f}, {1.0f, 1.0f}},
{{-0.5f, -0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}, {0.0f, 0.0f}},
{{0.5f, -0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}, {1.0f, 0.0f}},
{{0.5f, 0.5f, -0.5f}, {0.0f, 0.0f, 1.0f}, {1.0f, 1.0f}},
{{-0.5f, 0.5f, -0.5f}, {1.0f, 1.0f, 1.0f}, {0.0f, 1.0f}}
};
inline static const std::vector<uint16_t> indices = {
0, 1, 2, 2, 3, 0,
4, 5, 6, 6, 7, 4
};
现在运行程序你应该会看到类似下面的结果:
片段着色器中颜色计算使用
outColor = texture(texSampler, fragTexCoord);
3. 问题
两个几何体的实际大小是一样的,显示的更小说明距离摄像机更远,但现在小的几何体居然显示在了大的上方! 这是因为我们没处理前后遮挡关系,只使用索引按顺序绘制。对此问题,我们有两种解决方案:
- 将所有绘制命令从后往前排序
- 使用深度缓冲,近的覆盖远的
第一种方法通常用于绘制透明对象,因为与顺序无关的透明对象绘制并不容易。 而深度片段排序问题更常见的解决方案就是深度缓冲(depth buffer)。
深度缓冲是额外的附件,它存储每个片段对应的深度,每次在光栅化器生成片段时都会判断是否比前一个片段更近。 如果更近则替换,更远则抛弃,这被称为深度测试(depth testing),用于处理物体的远近关系。 可以在片段着色器中操作此值,就像操作颜色的输出一样。
4. GLM配置
GLM 生成的透视投影矩阵默认使用 OpenGL 的 [-1.0, 1.0]
的范围,我们需要使用 GLM_FORCE_DEPTH_ZERO_TO_ONE
宏让他变为 Vulkan 使用的 [0.0, 1.0]
:
#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
深度图像与视图
1. 成员变量与辅助函数
深度附件基于图像,我们依然需要手动创建,但只需要一个图像(而不是飞行帧的数量),因为程序一次只运行一个绘制操作。
现在创建三个成员变量:内存资源、图像、图像视图,放在交换链的下方:
std::vector<vk::raii::ImageView> m_swapChainImageViews;
vk::raii::DeviceMemory m_depthImageMemory{ nullptr };
vk::raii::Image m_depthImage{ nullptr };
vk::raii::ImageView m_depthImageView{ nullptr };
我们将他放在交换链的下方,因为创建它需要交换链信息。
然后创建一个新函数 createDepthResources
来设置这些资源:
void initVulkan() {
...
createCommandPool();
createDepthResources();
createTextureImage();
...
}
...
void createDepthResources() {
}
2. 查找深度图像格式
创建深度图像非常简单。它应该具有与颜色附件相同的分辨率(由交换链 extent 定义),适用于深度附件的图像用途(usage),最佳平铺和设备本地内存。
唯一的问题是:正确的深度图像格式是什么?格式必须包含深度组件,所以枚举名会有 D
字母。
与纹理图像不同,我们不需要指定图像的色彩格式,因为深度图记录的是深度信息。 我们只需要指定精度即可,下面是几种常见的选择:
vk::Format | 含义 |
---|---|
eD32Sfloat |
每个深度使用32位有符号浮点数 |
eD32SfloatS8Uint |
32 位有符号浮点数记录深度,外加 8 位模板分量 |
eD24UnormS8Uint |
24 位浮点数记录深度,外加 8 位模板分量 |
模板分量(stencil component)被用于 模板测试(stencil test)。 它可以与深度测试组合,我们会在后面的章节中介绍。
可以简单的使用 eD32Sfloat
,它受到广泛支持。
但这里选择编写 findSupportedFormat
函数查询合适的格式,这带来更好的灵活性和可用性:
vk::Format findSupportedFormat(
const std::vector<vk::Format>& candidates,
vk::ImageTiling tiling,
vk::FormatFeatureFlags features
) {
}
支持的格式取决于平铺模式和用途,所以我们包含了这些参数。
我们可以通过物理设备的 getFormatProperties
函数获取需要的信息:
for(vk::Format format : candidates) {
// vk::FormatProperties
auto props = m_physicalDevice.getFormatProperties(format);
}
vk::FormatProperties
结构体包含以下字段:
linearTilingFeatures
:线性平铺支持的用例optimalTilingFeatures
:最优平铺支持的用例bufferFeatures
:缓冲支持的用例
只有前两个与这里相关,我们根据参数的 tiling
进行选择:
switch (tiling){
case vk::ImageTiling::eLinear:
if(props.linearTilingFeatures & features) return format;
break;
case vk::ImageTiling::eOptimal:
if(props.optimalTilingFeatures & features) return format;
break;
default:
break;
}
如果所有候选格式都不支持所需的用途,我们可以直接抛出异常或返回特殊值:
vk::Format findSupportedFormat(
const std::vector<vk::Format>& candidates,
vk::ImageTiling tiling,
vk::FormatFeatureFlags features
) {
for(vk::Format format : candidates) {
// vk::FormatProperties
auto props = m_physicalDevice.getFormatProperties(format);
switch (tiling){
case vk::ImageTiling::eLinear:
if(props.linearTilingFeatures & features) return format;
break;
case vk::ImageTiling::eOptimal:
if(props.optimalTilingFeatures & features) return format;
break;
default:
break;
}
}
throw std::runtime_error("failed to find supported format!");
}
现在创建一个 findDepthFormat
函数,用于选择具体深度分量并支持用于深度附件的格式:
vk::Format findDepthFormat() {
return findSupportedFormat(
{ vk::Format::eD32Sfloat, vk::Format::eD32SfloatS8Uint, vk::Format::eD24UnormS8Uint },
vk::ImageTiling::eOptimal,
vk::FormatFeatureFlagBits::eDepthStencilAttachment
);
}
上面选择的三种模式都包含深度分量,但后两者还包含模板分量。 虽然我们尚未使用它,但在图像布局转换时需要考虑这一点。 现在添加一个简单的辅助函数判断是否包含深度分量:
bool hasStencilComponent(vk::Format format) {
return format == vk::Format::eD32SfloatS8Uint || format == vk::Format::eD24UnormS8Uint;
}
3. 创建深度图像
在 createDepthResources
中使用刚才的函数:
vk::Format depthFormat = findDepthFormat();
然后使用前几章的辅助函数 createImage
和 createImageView
创建对象:
createImage(
m_swapChainExtent.width,
m_swapChainExtent.height,
depthFormat,
vk::ImageTiling::eOptimal,
vk::ImageUsageFlagBits::eDepthStencilAttachment,
vk::MemoryPropertyFlagBits::eDeviceLocal,
m_depthImage,
m_depthImageMemory
);
m_depthImageView = createImageView(m_depthImage, depthFormat);
现在 createImageView
函数内部的 subresourceRange.aspectMask
始终使用 eColor
,但深度缓冲并不是。
我们需要使用参数传递 aspectMask
而非默认:
vk::raii::ImageView createImageView(vk::Image image, vk::Format format, vk::ImageAspectFlags aspectFlags) {
...
viewInfo.subresourceRange.aspectMask = aspectFlags;
...
}
然后需要修改用到此函数的三个地方:
m_swapChainImageViews.emplace_back(
createImageView(m_swapChainImages[i], m_swapChainImageFormat, vk::ImageAspectFlagBits::eColor)
);
...
m_textureImageView = createImageView(m_textureImage, vk::Format::eR8G8B8A8Srgb, vk::ImageAspectFlagBits::eColor);
...
m_depthImageView = createImageView(m_depthImage, depthFormat, vk::ImageAspectFlagBits::eDepth);
创建深度图像就到此为止,我们不需要映射或拿另一个图像复制进去,因为它会在渲染管线开始时像颜色附件一样被清理。
显式转换深度图像
不需要显式地将图像的布局转换为深度附件,因为我们将在渲染通道中处理它。 但为了完整起见,仍然在本节描述此过程。如果你愿意,完全可以跳过这部分内容。
在 createDepthResources
函数的末尾调用 transitionImageLayout
,如下所示
transitionImageLayout(
m_depthImage,
depthFormat,
vk::ImageLayout::eUndefined,
vk::ImageLayout::eDepthAttachmentOptimal
);
我们可以使用 eUndefined
是因为深度图像此时并没有内容。
现在还需要修改 transitionImageLayout
,保证它使用正确的 aspectMask
:
if( newLayout == vk::ImageLayout::eDepthStencilAttachmentOptimal ) {
barrier.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eDepth;
if( hasStencilComponent(format) ){
barrier.subresourceRange.aspectMask |= vk::ImageAspectFlagBits::eStencil;
}
} else {
barrier.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eColor;
}
即使我们没用到模板(stencil)分量,也在启用时需要设置相关属性。
最后还需要设置正确的访问掩码和管线阶段:
if( oldLayout == vk::ImageLayout::eUndefined &&
newLayout == vk::ImageLayout::eTransferDstOptimal
) {
barrier.srcAccessMask = {};
barrier.dstAccessMask = vk::AccessFlagBits::eTransferWrite;
sourceStage = vk::PipelineStageFlagBits::eTopOfPipe;
destinationStage = vk::PipelineStageFlagBits::eTransfer;
} else if(
oldLayout == vk::ImageLayout::eTransferDstOptimal &&
newLayout == vk::ImageLayout::eShaderReadOnlyOptimal
) {
barrier.srcAccessMask = vk::AccessFlagBits::eTransferWrite;
barrier.dstAccessMask = vk::AccessFlagBits::eShaderRead;
sourceStage = vk::PipelineStageFlagBits::eTransfer;
destinationStage = vk::PipelineStageFlagBits::eFragmentShader;
} else if (
oldLayout == vk::ImageLayout::eUndefined &&
newLayout == vk::ImageLayout::eDepthStencilAttachmentOptimal
) {
barrier.srcAccessMask = {};
barrier.dstAccessMask = vk::AccessFlagBits::eDepthStencilAttachmentRead | vk::AccessFlagBits::eDepthStencilAttachmentWrite;
sourceStage = vk::PipelineStageFlagBits::eTopOfPipe;
destinationStage = vk::PipelineStageFlagBits::eEarlyFragmentTests;
} else {
throw std::invalid_argument("unsupported layout transition!");
}
深度缓冲区会在深度测试时被读取并在绘制新片段时被写入。
读取发生在 eEarlyFragmentTests
,写入发生在 eLateFragmentTests
,我们只需要选择在最早的阶段之前完成转换操作即可。
渲染通道
我们现在需要修改 createRenderPass
以包含深度附件,首先指定 vk::AttachmentDescription
:
vk::AttachmentDescription depthAttachment;
depthAttachment.format = findDepthFormat();
depthAttachment.samples = vk::SampleCountFlagBits::e1;
depthAttachment.loadOp = vk::AttachmentLoadOp::eClear;
depthAttachment.storeOp = vk::AttachmentStoreOp::eDontCare;
depthAttachment.stencilLoadOp = vk::AttachmentLoadOp::eDontCare;
depthAttachment.stencilStoreOp = vk::AttachmentStoreOp::eDontCare;
depthAttachment.initialLayout = vk::ImageLayout::eUndefined;
depthAttachment.finalLayout = vk::ImageLayout::eDepthStencilAttachmentOptimal;
format
应该与深度图像本身的属性相同。
我们指定了 loadOp
开始时清理内容。
它绘制完成后不会被使用,所以storeOp
为 eDontCare
。
注意我们在此处指定了 initialLayout
和 finalLayout
,所以渲染管线为我们处理了深度图像布局的转换。
所以 createDepthResources
中的转换代码可以忽略:
// transitionImageLayout(
// m_depthImage,
// depthFormat,
// vk::ImageLayout::eUndefined,
// vk::ImageLayout::eDepthStencilAttachmentOptimal
// );
然后我们回到渲染通道的创建,现在为第一个(唯一的)子通道添加对附件的引用:
vk::AttachmentReference depthAttachmentRef;
depthAttachmentRef.attachment = 1;
depthAttachmentRef.layout = vk::ImageLayout::eDepthStencilAttachmentOptimal;
vk::SubpassDescription subpass;
subpass.pipelineBindPoint = vk::PipelineBindPoint::eGraphics;
subpass.setColorAttachments( colorAttachmentRef );
subpass.pDepthStencilAttachment = &depthAttachmentRef;
与颜色附件不同,子Pass只能使用单个深度(+模板)附件,所以没有 Count
字段,直接赋值初始指针即可。
然后需要更新渲染通道的创建信息,加上我们的深度附件。
上面的depthAttachmentRef.attachment
是1,因为深度附件会是附件数组的1号元素,正如下面的代码。
auto attachments = { colorAttachment, depthAttachment };
vk::RenderPassCreateInfo renderPassInfo;
renderPassInfo.setAttachments( attachments );
...
这里用到了初始化列表
最后,我们需要扩展我们的子通道依赖项,以确保深度图像的转换操作和加载开始时的清除操作没有冲突。 深度图像首先在早期片段测试管线阶段被访问,并且因为我们有一个清除的加载操作,我们应该为写入指定访问掩码。
dependency.srcStageMask = vk::PipelineStageFlagBits::eColorAttachmentOutput | vk::PipelineStageFlagBits::eEarlyFragmentTests;
dependency.srcAccessMask = {};
dependency.dstStageMask = vk::PipelineStageFlagBits::eColorAttachmentOutput | vk::PipelineStageFlagBits::eEarlyFragmentTests;
dependency.dstAccessMask = vk::AccessFlagBits::eColorAttachmentWrite | vk::AccessFlagBits::eDepthStencilAttachmentWrite;
帧缓冲
下一步是修改帧缓冲的创建,从而将深度图像绑定到深度附件。
转到 createFramebuffers
并将深度图像视图指定为第二个附件:
vk::FramebufferCreateInfo framebufferInfo;
framebufferInfo.renderPass = m_renderPass;
std::array<vk::ImageView, 2> attachments { m_swapChainImageViews[i], m_depthImageView };
framebufferInfo.setAttachments( attachments );
framebufferInfo.width = m_swapChainExtent.width;
framebufferInfo.height = m_swapChainExtent.height;
framebufferInfo.layers = 1;
注意前后顺序不能反,第一个是颜色附件,第二个彩色深度附件。
你可以用auto
初始化列表,但是需要显式将vk::raii::ImageView
转换成vk::ImageView
。
颜色附件对每个交换链都不同,深度图像可以被所有的交换链图像使用,因为我们的信号量保证了同一时间只运行一个子通道。
我们还需要移动 createFramebuffers
,保证它在深度图像视图之后调用:
void initVulkan() {
...
createDepthResources();
createFramebuffers();
...
}
清除值
因为我们现在有多个具有 vk::AttachmentLoadOp::eClear
的附件,所以我们还需要指定多个清除值。转到 recordCommandBuffer
并创建一个 vk::ClearValue
结构体数组:
std::array<vk::ClearValue, 2> clearValues;
clearValues[0].color = vk::ClearColorValue{0.0f, 0.0f, 0.0f, 1.0f};
clearValues[1].depthStencil = vk::ClearDepthStencilValue{1.0f, 0};
renderPassInfo.setClearValues( clearValues );
Vulkan 中深度缓冲的深度范围为 [0.0, 1.0]
,其中 1.0
位于远裁剪面,0.0
位于近裁剪面。
深度缓冲中每个点的初始值应该是最远的可能深度,即 1.0
。
请注意,
clearValues
的顺序应与附件的顺序相同。
深度与模板状态
深度附件已经可以使用了,但仍然需要在图形管线中启用深度测试。
现在转到 createGraphicsPipeline
函数中添加 vk::PipelineDepthStencilStateCreateInfo
结构体配置:
vk::PipelineDepthStencilStateCreateInfo depthStencil;
depthStencil.depthTestEnable = true;
depthStencil.depthWriteEnable = true;
depthTestEnable
指定是否将新片段与深度缓冲中的片段对比。
depthWriteEnable
指定是否将通过测试的片段写入深度缓冲。
然后还需通过 depthCompareOp
字段定义比较方式,我们坚持更小的深度=更近,所以应该这样:
depthStencil.depthCompareOp = vk::CompareOp::eLess;
还可以用 depthBoundsTestEnable
进行边界测试,从而只保留指定深度范围的片段,我们不启用它:
depthStencil.depthBoundsTestEnable = false; // Optional
depthStencil.minDepthBounds = 0.0f; // Optional if depthBoundsTestEnable is false
depthStencil.maxDepthBounds = 1.0f; // Optional if depthBoundsTestEnable is false
还有三个字段配置模板缓冲操作,我们也不使用它。
depthStencil.stencilTestEnable = false; // Optional
depthStencil.front = vk::StencilOpState{}; // Optional if stencilTestEnable is false
depthStencil.back = vk::StencilOpState{}; // Optional if stencilTestEnable is false
然后更新 vk::GraphicsPipelineCreateInfo
结构体,引用我们刚刚填写的内容:
pipelineInfo.pDepthStencilState = &depthStencil;
如果您现在运行程序,那么您应该看到几何体的片段现在已正确排序:
处理窗口大小调整
当窗口大小调整以匹配新的颜色附件分辨率时,深度缓冲的分辨率应更改。
扩展 recreateSwapChain
函数以在此情况下重建深度资源:
void recreateSwapChain() {
int width = 0, height = 0;
glfwGetFramebufferSize(m_window, &width, &height);
while (width == 0 || height == 0) {
glfwGetFramebufferSize(m_window, &width, &height);
glfwWaitEvents();
}
m_device.waitIdle();
m_swapChainFramebuffers.clear();
m_depthImageView = nullptr;
m_depthImage = nullptr;
m_depthImageMemory = nullptr;
m_swapChainImageViews.clear();
m_swapChainImages.clear(); // optional
m_swapChain = nullptr;
createSwapChain();
createImageViews();
createDepthResources();
createFramebuffers();
m_framebufferResized = false;
}
恭喜,您的应用程序现在终于可以渲染任意 3D 几何体并使其看起来正确了。我们将在下一章中尝试这一点,绘制一个纹理模型!