从零开始的 Vulkan(附一):多Subpass实现延迟渲染
对于拥有大量光照的场景,经典的前向渲染(Forward Shading)由于其逐片元的计算通常会产生大量的开销,因此出现了许多用于解决这一问题的算法,常见的有延迟渲染(Deferred Shading)和Forward+渲染,在这一节中我们主要来研究使用Vulkan的Subpass技术来简化延迟渲染的实现(这也是Subpass的主要用途之一)。
原理
延迟渲染的主要优化方案是不使用传统光栅化的将每个图形片元的位置计算出来,再逐片元依次计算光照,取而代之的,只保留屏幕空间中所有需要显示的像素及其片元信息(通常称作G-Buffer),再依次为每个像素计算光照。延迟渲染的最大优点是在拥有极大量光源的场景中能大大减少计算量,最大缺点便是无法绘制任何透明物体(因为颜色混合操作的不可行)。
藉由这样的思路,我们得知需要实现延迟渲染至少要经过两个Pass,而在Vulkan中,这两个Pass可以放在一个RenderPass中由两个Subpass实现,第一个Subpass负责将需要的片元数据输出(获取G-Buffer),第二个Pass则负责进行逐片元的光照计算。
输出着色器
在第一个Subpass中,我们使用的顶点着色器和通常的顶点着色器功能是完全一致的,即进行逐顶点的数据计算。我将所有着色器共用的数据放在Common.hlsl中,之后只需要所有其他HLSL包含这个文件即可。
1 | //Common.hlsl |
VertexShader的代码如下所示:
1 | //VertexShader.hlsl |
而这里我们使用的像素着色器却不需要进行任何的光照计算,光照计算将在第二个Subpass中完成,在这一个像素着色器中只需要完成一些必须的工作就行了,例如计算法向量(采样法线贴图),获取材质信息:
1 | //DeferredShadingOutput.hlsl |
在这个像素着色器中,我总共设定了5个输出量(漫反射颜色,法向量,材质属性,位置,阴影坐标),而这5个输出量就构成了我们用于延迟渲染的G-Buffer,它将作为输入值传递至下一个Subpass中进行处理。
处理着色器
第二个Subpass的重点在像素着色器上,顶点着色器只是简单地绘制一个屏幕空间矩形,并不传递任何数据:
1 | //DeferredShadingQuad.hlsl |
在像素着色器中,我们通过InputAttachment接收G-Buffer数据(已在第五节中讲解过),并完成最后的数据处理和光照计算(光照算法来自DX12龙书,LightingUtil.hlsl的代码会放在文章末尾附录中,这并不是本文的重点):
1 | //DeferredShadingProcessing.hlsl |
构建RenderPass
关于构建RenderPass的相关细节已在第五节中讲解过,这里不再赘述。我们需要创建一个RenderPass和其下的两个Subpass,在第一个Subpass中,我们需要一个深度附件用于剔除被遮挡的物体,同时需要5个颜色附件用于储存输出的G-Buffer,其对应代码如下所示:
1 | std::vector<vk::AttachmentDescription> attachments(7); |
在第二个Subpass中,我们将第一个Subpass输出的G-Buffer(包括深度缓冲)作为InputAttachment输入,同时只需要一个RenderTarget来存储最终输出的结果:
1 | //render target |
需要注意,着色器不可以直接引用InputAttachment,而必须要先将InputAttachment在PipelineLayout中和描述符绑定才可使用,这部分在正篇第五节中已经讲过,有需要可以去翻看。
在构建完两个Subpass后,进行SubpassDependency的构建以及完成RenderPass的创建:
1 | std::vector<vk::SubpassDependency> subpassDependencies(1); |
最后再小小地花一点时间完成所有需要用到的附件对应的Framebuffer的创建:
1 | vk::ImageView imageViewAttachments[7]; |
构建Pipeline
我们将使用两个Subpass完成绘制,这也就意味着我们需要准备两个拥有不同着色器的渲染管线,它们的创建如下所示(不必要的步骤已略去):
1 | auto vertexShader = CreateShaderModule("Shaders\\vertex.spv", vkInfo->device); |
绘制
在开始每一帧的绘制之前,还是照例需要启动RenderPass并完成清屏,由于我们使用的Framebuffer比较多,所以清屏需要指定的内容也比较多(注意和RenderPass绑定的附件之间的对应关系):
1 | vk::ClearValue clearValue[7]; |
在绘制时,和通常一样首先绑定顶点缓冲(和索引缓冲),管线和描述符集,然后进行第一个Subpass中的绘制,即绘制场景中所有物体:
1 | cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, vkInfo->pipelineLayout["scene"], 2, 1, &scenePassDesc, 0, 0); |
在结束第一个Subpass后,切换至第二个Subpass,这时我们的DrawCall只是简单的绘制一个屏幕空间矩形(而且顶点坐标都是写死在顶点着色器里的),剩下的工作都交给像素着色器完成就行了!
1 | cmd.nextSubpass(vk::SubpassContents::eInline); |
附录
LightingUtil.hlsl源码:
1 | //LightingUtil.hlsl |