Render passes and frames¶
Most synchronization in a typical frame is done by the engine: render passes transition their render targets, and the swap chain makes sure that you never draw into an image that is still on screen. This page explains what they do, what they expect from you, and where their responsibility ends.
What a render pass does¶
IRenderPass::begin and IRenderPass::end record barriers into a primary command buffer. By default, the render pass creates that command
buffer itself and submits it when it ends; alternatively, it can record into a command buffer of yours, as described
below. Between the two calls, you only record draw calls, into the secondary command
buffers returned by IRenderPass::commandBuffer.
When the render pass begins:
- Render targets that are cleared are transitioned from
Undefined, which discards their previous contents. They can be in any state before. When the render pass records into a shared command buffer, they are transitioned fromCommoninstead, so they must be in the default state. - Render targets that are loaded, i.e. created without the clear flag, are transitioned from
Common. Their contents are preserved. - Depth/stencil targets are handled per aspect: the depth aspect follows
clearBuffer, the stencil aspect followsclearStencil. You can, for example, clear the stencil buffer while keeping the depth values of a previous pass. - Input attachments are transitioned from
CommonintoShaderResource, so that the shaders can sample them.
When the render pass ends:
- All render targets and input attachments are transitioned back to
Common. Their writes are complete, but not yet visible to any later access; the barrier that starts the next use takes care of that. - The present target is copied to the swap chain or resolved into it, and presented, if the render pass has one.
- The command buffer is submitted to
renderPass.commandQueue(), andendreturns the fence of the submission. With a shared command buffer, nothing is submitted, andendreturns0.
By default, a render pass is a submission of its own, so everything submitted to its queue before has finished when it begins. As a result, render passes that use the same frame buffer on the same queue need no further synchronization.
Sharing a command buffer between render passes¶
Instead of submitting each render pass separately, several render passes can record into one command buffer that you submit yourself. Pass
it to begin:
auto& graphicsQueue = m_device->defaultQueue(QueueType::Graphics);
auto commandBuffer = graphicsQueue.createCommandBuffer();
firstPass.begin(frameBuffer, *commandBuffer);
auto secondary = firstPass.commandBuffer(0);
// ... record draw calls into `secondary` ...
firstPass.end(); // Does not submit, and returns `0`.
secondPass.begin(frameBuffer, *commandBuffer);
// ...
secondPass.end();
auto fence = graphicsQueue.submit(commandBuffer);
m_device->swapChain().present(fence);
This saves a submission per render pass, and it lets the GPU overlap work of consecutive render passes that is not ordered by a barrier, which often improves GPU utilization. In exchange, a few things are your responsibility:
- The command buffer must be a recording primary command buffer created from the render pass's queue (
renderPass.commandQueue()). - Submit it yourself, after the last render pass has ended.
endneither submits nor returns a fence. - Present explicitly. A render pass with a present target only presents on its own when it submits. With a shared command buffer, submit
it and call
ISwapChain::presentwith the returned fence. - Pipeline and binding state is not preserved across render pass boundaries. Bind pipelines, descriptor sets and buffers again in each render pass.
Since the render passes now share a submission, their order is no longer guaranteed by the submission order. The render passes take care of it themselves: in this mode, their barriers wait for all work recorded before them in the command buffer. Commands that you record into the command buffer between two render passes still need barriers of their own, as described in the next section.
The render passes sample records its three passes into one command buffer in this way.
Using render targets outside of render passes¶
Between render passes, the images of a frame buffer are in the default state. To use one in your own commands, for example in a compute
shader, transition it out of Common and back:
auto& colorTarget = frameBuffer.image("Color");
auto preBarrier = commandBuffer->makeBarrier(PipelineStage::None, PipelineStage::Compute);
preBarrier->transition(colorTarget, ResourceAccess::None, ResourceAccess::ShaderReadWrite, ImageLayout::Common, ImageLayout::ReadWrite);
commandBuffer->barrier(*preBarrier);
// ... dispatch ...
auto postBarrier = commandBuffer->makeBarrier(PipelineStage::Compute, PipelineStage::None);
postBarrier->transition(colorTarget, ResourceAccess::ShaderReadWrite, ResourceAccess::None, ImageLayout::ReadWrite, ImageLayout::Common);
commandBuffer->barrier(*postBarrier);
Neither barrier needs to wait for anything outside of this command buffer: the render pass was submitted before, so it has finished when
this submission starts, and the next render pass will only start after this submission has finished. If you record more commands that use
the image after the post barrier in the same submission, use their stages and access as after values instead of None.
The same applies, if the render pass recorded into the same command buffer as your commands (see sharing a command
buffer). Then the render pass is part of the same submission, and the first barrier has to
wait for it: use the stages and access of the render pass's writes, for example PipelineStage::RenderTarget and
ResourceAccess::RenderTarget, as before values instead of None.
If this happens on a different queue than the render pass, the queue has to wait for the fence
returned by end first, and the render pass that uses the image next has to wait for the fence of your work. The compute
sample shows the complete chain.
The swap chain¶
The swap chain owns the images that are shown on screen, the back buffers. Each frame starts by asking for the next one:
swapBackBuffer blocks until the back buffer can be drawn into again, i.e. until the frame that last used it has been presented. It returns the
index of the back buffer, which you use to pick the resources of the frame.
A render pass with a present target presents the frame on its own when it ends, unless it records into a shared command
buffer. If you produce the final image in a different way, for example
by copying the result of a compute shader into the back buffer, transition the back buffer to ImageLayout::Present after your last write, and
present it with the fence of the submission that contains that write:
The swap chain waits for the fence on the GPU before it presents, so you do not need to wait for it yourself.
Frames in flight¶
The swap chain has several back buffers, typically three. While the GPU still renders one frame, the CPU can already record the next one. Those frames are in flight at the same time, and every resource that a frame writes to has to exist once per frame in flight, or two frames would overwrite each other's data:
- Frame buffers, one per back buffer.
- Per-frame buffers, such as the transform buffer of the basic rendering sample, either as separate buffers or as one buffer with one element per back buffer.
- Descriptor sets that point to per-frame resources.
Command buffers are not on this list: since they are used once, each frame creates its own.
Index all of them with the back buffer index returned by swapBackBuffer. Once swapBackBuffer returns, everything that was submitted for the
previous frame with the same index on the presenting queue has finished, so the resources of that index can be written again without any
further wait.
Work that is not part of the frame
swapBackBuffer only covers work that the presentation waited for: everything submitted to the presenting queue before the frame was
presented, and everything on other queues that this queue waited for. Work on another queue that the frame did not wait for, for example a
simulation that runs ahead on the compute queue, is not covered. Track its fences yourself.
Resources that are written only once, for example meshes uploaded at startup, do not need copies per frame. Data that changes every frame but is
only written by the CPU, such as uniform buffers in the Dynamic heap, does: write it with map into the element of the current back buffer.
Resizing¶
When the window is resized, frame buffers and the swap chain need to be recreated, while the GPU may still use them. Wait until the device is idle first:
m_device->wait();
m_device->swapChain().reset(surfaceFormat, renderArea, 3, vsync);
frameBuffer.resize(renderArea);
The same applies to any other operation that releases resources that are possibly still in use, for example switching scenes or shutting down.