-
Notifications
You must be signed in to change notification settings - Fork 4
Using the Frame Graph
To render our scene we use a Frame Graph, which automatically places memory barriers and optimizes the flow of commands. There are a couple more smaller benefits, which I will mention along the way.
The FrameGraphRenderPass is basically a Vulkan pipeline with a virtual RecordCommands function. A render pass inherits from the FrameGraphRenderPass and is passed into a FrameGraphNode. To create a new render pass, just include frame_graph.hpp, inherit from FrameGraphRenderPass and override the RecordCommands function. Let's first take a look at how a render pass RecordCommands function would look like:
void RenderPass::RecordCommands(vk::CommandBuffer commandBuffer, uint32_t currentFrame, const RenderSceneDescription& scene)
{
// 1. Populate vk::RenderingInfoKHR with data (no code shown for brevity as this is the same)
// 2. Begin rendering and bind pipeline
commandBuffer.beginRenderingKHR(&renderingInfo, _brain.dldi);
commandBuffer.bindPipeline(vk::PipelineBindPoint::eGraphics, _pipeline);
// 3. Push constats and bind descriptors (no code shown for brevity as this is the same)
// 4. Bind vertex/index buffers
commandBuffer.bindVertexBuffers(0, { vertexBuffer }, { 0 });
commandBuffer.bindIndexBuffer(indexBuffer, 0, scene.batchBuffer.IndexType());
// 5. Draw the primitive
commandBuffer.drawIndexed(primitive.count, 1, primitive.indexOffset, primitive.vertexOffset, 0);
_brain.drawStats.indexCount += primitive.count;
_brain.drawStats.drawCalls++;
// 6. End rendering
commandBuffer.endRenderingKHR(_brain.dldi);
}Notice that some things are missing and are different from how we did it with pipelines, namely:
- Placing debug labels is no longer necessary (this is done via
FrameGraphNode) - Viewports and scissors are no longer being set (this is done automatically by the render graph)
Next let's take a look at how to pass the render pass to the Frame Graph. This is done by using FrameGraphNodeCreation:
FrameGraphNodeCreation lightingPass { *_lightingRenderPass, FrameGraphRenderPassType::eGraphics }; // eGraphics can be omitted as it is the default value, only relevant when doing compute
lightingPass.SetName("Lighting pass")
.SetDebugLabelColor(glm::vec3 { 255.0f, 209.0f, 102.0f } / 255.0f)
.AddInput(_gBuffers->Attachments()[0], FrameGraphResourceType::eTexture)
.AddInput(_gBuffers->Attachments()[1], FrameGraphResourceType::eTexture)
.AddInput(_gBuffers->Attachments()[2], FrameGraphResourceType::eTexture)
.AddInput(_gBuffers->Attachments()[3], FrameGraphResourceType::eTexture)
.AddInput(_gBuffers->Shadow(), FrameGraphResourceType::eTexture)
.AddOutput(_hdrTarget, FrameGraphResourceType::eAttachment)
.AddOutput(_brightnessTarget, FrameGraphResourceType::eAttachment);We create the FrameGraphNodeCreation using a FrameGraphRenderPass object and an optional FrameGraphRenderPassType. Then using the builder pattern we can set all of the pass's values. Here we first set the name and debug label color, which are used for debugging. Next we get to adding inputs and outputs. One important thing to know about render graph resources is that they are ONLY produced by and used on the GPU. That means we don't specify any resources that are produced by the CPU, like materials, textures used for meshes etc. So the inputs are resources produced by previous passes (their outputs). In this example case we take 4 outputs from a previous pass and use them as eTextures in the current pass, then we produce 2 eAttachments to be used in a next pass, probably as eTextures. There are 3 types of FrameGraphResourceType:
- eAttachment -> Frame buffer that is being rendered to
- eTexture -> Image that is read during the render pass
- eBuffer -> Buffer of data that we can write to or read from
To actually create a node and use it, we pass the RenderGraphNodeCreation to the Frame Graph, which is in our renderer.cpp. We add all the nodes we want and build the graph:
frameGraph.AddNode(geometryPass)
.AddNode(shadowPass)
.AddNode(lightingPass)
.AddNode(skyDomePass)
.AddNode(bloomBlurPass)
.AddNode(toneMappingPass)
.AddNode(debugPass)
.Build();Then when the renderer updates, it calls the RenderCommands on the Frame Graph, which automatically renders all of the render passes for us.
Once working with multiple passes, you may want to reuse an output resources to write data to the same structure. To do this you can just give the resource as an output of a pass like normally. In the example below you can see that _fxaaTarget is reused for multiple passes.
FrameGraphNodeCreation fxaaPass { *_fxaaPipeline };
fxaaPass.SetName("FXAA pass")
.SetDebugLabelColor(glm::vec3 { 139.0f, 190.0f, 16.0f } / 255.0f)
.AddInput(_tonemappingTarget, FrameGraphResourceType::eTexture)
.AddOutput(_fxaaTarget, FrameGraphResourceType::eAttachment);
FrameGraphNodeCreation uiPass { *_uiPipeline };
uiPass.SetName("UI pass")
.SetDebugLabelColor(glm::vec3 { 255.0f, 255.0f, 255.0f })
.AddOutput(_fxaaTarget, FrameGraphResourceType::eAttachment);
FrameGraphNodeCreation debugPass { *_debugPipeline };
debugPass.SetName("Debug pass")
.SetDebugLabelColor(glm::vec3 { 0.0f, 1.0f, 1.0f })
.AddInput(_gBuffers->Depth(), FrameGraphResourceType::eAttachment)
.AddOutput(_fxaaTarget, FrameGraphResourceType::eAttachment);The only caveat you have to keep in mind is that in this case, the frame graph has no way of knowing what the order of the passes are, since the resources can be reused in an arbitrary order. To solve this, you have to make sure you add those passes in the correct order to the frame graph. Based on the example above, it would be this order:
.AddNode(fxaaPass)
.AddNode(uiPass)
.AddNode(debugPass)The frame graph also automatically makes sure the memory barriers are generated so a resources is not written to at the same time. But sometimes you want to write at the same time, for example the lighting and sky dome passes. We can force the frame graph to not generate memory barriers for a reused resource in a render pass, by specifying the allowSimultaneousWrites argument when passing the resource to the node:
FrameGraphNodeCreation lightingPass { *_lightingPipeline };
lightingPass.SetName("Lighting pass")
.SetDebugLabelColor(glm::vec3 { 255.0f, 209.0f, 102.0f } / 255.0f)
.AddInput(_gBuffers->Attachments()[0], FrameGraphResourceType::eTexture)
.AddInput(_gBuffers->Attachments()[1], FrameGraphResourceType::eTexture)
.AddInput(_gBuffers->Attachments()[2], FrameGraphResourceType::eTexture)
.AddInput(_gBuffers->Attachments()[3], FrameGraphResourceType::eTexture)
.AddInput(_ssaoTarget, FrameGraphResourceType::eTexture)
.AddInput(_gBuffers->Shadow(), FrameGraphResourceType::eTexture)
.AddOutput(_hdrTarget, FrameGraphResourceType::eAttachment)
.AddOutput(_brightnessTarget, FrameGraphResourceType::eAttachment);
FrameGraphNodeCreation skyDomePass { *_skydomePipeline };
skyDomePass.SetName("Sky dome pass")
.SetDebugLabelColor(glm::vec3 { 17.0f, 138.0f, 178.0f } / 255.0f)
.AddInput(_gBuffers->Depth(), FrameGraphResourceType::eAttachment)
.AddOutput(_hdrTarget, FrameGraphResourceType::eAttachment, true) // Setting `allowSimultaneousWrites` to true for this resource in this pass
.AddOutput(_brightnessTarget, FrameGraphResourceType::eAttachment, true); // Setting `allowSimultaneousWrites` to true for this resource in this passIn this case, there are no memory barriers and the 2 passes can run in parallel, even though they write to the same resources.
One more important note is the FrameGraphResourceType::eReference bitflag, which can be added to a resource type. This type is exclusively used to ensure correct node ordering when the pass does not actually use a resource. Let's take a look at an example to better understand this:
FrameGraphNodeCreation lightingPass{*_lightingPipeline};
lightingPass.SetName("Lighting pass")
.SetDebugLabelColor(glm::vec3 { 255.0f, 209.0f, 102.0f } / 255.0f)
.AddInput(_gBuffers->Attachments()[0], FrameGraphResourceType::eTexture)
.AddInput(_gBuffers->Attachments()[1], FrameGraphResourceType::eTexture)
.AddInput(_gBuffers->Attachments()[2], FrameGraphResourceType::eTexture)
.AddInput(_gBuffers->Attachments()[3], FrameGraphResourceType::eTexture)
.AddInput(_gBuffers->Shadow(), FrameGraphResourceType::eTexture)
.AddOutput(_hdrTarget, FrameGraphResourceType::eAttachment)
.AddOutput(_brightnessTarget, FrameGraphResourceType::eAttachment);
FrameGraphNodeCreation skyDomePass{*_skydomePipeline};
skyDomePass.SetName("Sky dome pass")
.SetDebugLabelColor(glm::vec3 { 17.0f, 138.0f, 178.0f } / 255.0f)
.AddInput(_gBuffers->Depth(), FrameGraphResourceType::eAttachment)
// Making sure the sky dome pass runs after the lighting pass with a reference
.AddInput(_hdrTarget, FrameGraphResourceType::eAttachment | FrameGraphResourceType::eReference)
// Not needed references, just for clarity this pass also contributes to those targets
.AddOutput(_hdrTarget, FrameGraphResourceType::eAttachment | FrameGraphResourceType::eReference)
.AddOutput(_brightnessTarget, FrameGraphResourceType::eAttachment | FrameGraphResourceType::eReference);Here we want to make sure the sky dome pass runs after the lighting pass to avoid overdraw, but the sky dome pass doesn't use any resources that the lighting pass produces. In this case, we can signal to the Frame Graph using the eReference flag that we want to wait for a resource to be available. In the example above, it's done by referencing the hdrTarget, but the brightnessTarget would have worked as well. You may also notice that the eReference flag is also used on the outputs, but this does not have any effect on the Frame Graph. It only shows the user that this resource is also produced by this pass. The eReference is used, because it is impossible for multiple passes to produce the same resource, so we wouldn't be able to only use eAttachment.