Skip to content

Lesson 28: Improving the Render Loop - Part 2: Stability and Performance

Alright, now that we’ve sorted out the fundamental synchronization flaws in our render loop, let’s make a few more improvements to it so that we’re well prepared for the more interesting things to come like lighting and texturing.

The first thing I want to do is tackle two issues with the way we handle the ‘window minimized’ case. In our current render loop this looks like this:

main.cpp
...
void onFramebufferSizeChanged( [[maybe_unused]] GLFWwindow* window, int width, int height )
{
windowMinimized = width == 0 && height == 0;
framebufferSizeChanged = true;
}
...
while ( !glfwWindowShouldClose( window.get() ) )
{
glfwPollEvents();
if ( windowMinimized )
continue;
...
}

There are two issues here, one bug and one pretty significant shortcoming:

The bug is that we consider the window to be minimized only when both dimensions are 0. If you try to resize the window so that only either height or width are 0, you will get an exception and the program will terminate because it tries to recreate the swapchain with one dimension being 0, which is not allowed. That’s trivial to fix, so let’s do it:

main.cpp
...
void onFramebufferSizeChanged( [[maybe_unused]] GLFWwindow* window, int width, int height )
{
windowMinimized = width == 0 || height == 0;
framebufferSizeChanged = true;
}
...

By considering the window minimized if height or width are 0, we don’t try to create a swapchain in those cases and don’t run into the crash anymore.

The second issue is technically not a bug, the code is correct and works. However: because glfwPollEvents is non-blocking, we’re essentially creating a tight spinning loop that burns ~100% of one CPU core doing nothing for as long as the window is minimized. That’s not good, but it’s fortunately also pretty trivial to fix:

void glfwWaitEvents();

This function blocks until new events come in. So we can improve our loop like this:

main.cpp
while ( !glfwWindowShouldClose( window.get() ) )
{
glfwPollEvents();
if ( windowMinimized )
{
glfwWaitEvents();
continue;
}
...
}

This makes sure that we’re not wasting resources while waiting, and that all incoming glfw events are handled before the render loop picks up again.1

In our current implementation, we advance the rotation of the cube by a fixed amount for every frame. The problem is that this directly couples the rotation speed to the framerate, which is in turn synced to the display refresh rate (we’re waiting on a VSYNC to show the next frame). In other words: our cube will rotate more than twice as fast on a 144Hz display than it will on a 60Hz display. That’s not ideal, we’d want our animation to be framerate-independent2.

The solution is to not add a fixed increment to the rotation angle with each frame, but instead to define a rotation speed and calculate the increment by measuring the time since the last render:

main.cpp
...
float rotationAngle = 0.f;
constexpr float rotationSpeed = glm::radians( 45.f );
auto lastFrameTime = std::chrono::steady_clock::now();
...
while ( !glfwWindowShouldClose( window.get() ) )
{
...
const auto now = std::chrono::steady_clock::now();
const float deltaTime = std::chrono::duration< float >( now - lastFrameTime ).count();
lastFrameTime = now;
rotationAngle += deltaTime * rotationSpeed;
const auto model = glm::rotate( glm::identity< glm::mat4 >(), rotationAngle, glm::vec3{ 0.f, 1.f, 0.f } );
const auto modelViewProjection = projection * view * model;
for ( std::uint32_t i = 0; i < vertexCount; ++i )
{
verticesTemp[ i ].position = modelViewProjection * vertices[ i ].position;
}
...
}

Note that we update the rotation angle before calculating the model matrix. Otherwise every frame would be rendered with the angle of the previous frame, and our animation would always lag one frame behind.

While we’re at it, we also combine the three matrices into a single model-view-projection matrix once per frame instead of multiplying all three for every single vertex. With 36 vertices that’s not a big deal yet, but there’s no reason to do the same calculation over and over again. For the same reason, model is now a local constant inside the loop, so you can delete its declaration before the loop.

As you can see, we’re using std::chrono for the time measurement. That’s the modern C++ way to handle times, and steady_clock is guaranteed to be monotonic - it never jumps backwards, unlike system_clock, which can be adjusted while our program is running.

This works, but there’s one edge case we should consider: if the window is minimized or the render loop is blocked for another reason, our deltaTime can become pretty large, which would lead to jumps in the animation. One standard way to handle this is to clamp the delta to a maximum. So let’s do that:

main.cpp
...
const float deltaTime = std::min( std::chrono::duration< float >( now - lastFrameTime ).count(), 0.1f );
...

And with this, we have addressed another issue with our render loop.

One more shortcoming of the current implementation is that we need to drop and re-create the pipeline whenever the window size (or, to be more exact, the framebuffer/swapchain size) changes. Pipeline recreation can be expensive, and structurally nothing changes when the viewport is resized. The configuration of the pipeline’s stages, the shaders, the command buffers - it all stays the same.

It would therefore be much better if we could just communicate the new values of width and height to the existing pipeline instead of tearing it down entirely and rebuilding it.

The solution to this is to make viewport and scissor so-called dynamic state: the pipeline declares only that they exist, while the actual values are supplied in the command buffer each frame. Let’s do that.

The first step is to remove the viewport and scissor definition from our pipeline creation function. Instead of setting the viewport and scissor directly, we just set a count to let the pipeline know how many viewports and scissors to expect and declare them dynamic state:

pipelines.cpp
auto createGraphicsPipeline(
const vk::Device& logicalDevice,
const vk::PipelineLayout& pipelineLayout,
const vk::ShaderModule& vertexShader,
const vk::ShaderModule& fragmentShader,
const vk::RenderPass& renderPass,
std::span< const vk::Format > vertexFormats
) -> vk::UniquePipeline
{
...
// remove the existing viewport and scissor creation
const auto viewportState = vk::PipelineViewportStateCreateInfo{}
.setViewportCount( 1 )
.setScissorCount( 1 );
...
const auto dynamicStates = std::array< vk::DynamicState, 2 >{
vk::DynamicState::eViewport,
vk::DynamicState::eScissor
};
const auto dynamicState = vk::PipelineDynamicStateCreateInfo{}
.setDynamicStates( dynamicStates );
const auto pipelineCreateInfo = vk::GraphicsPipelineCreateInfo{}
.setStages( shaderStageInfos )
.setPDynamicState( &dynamicState )
...
}

The vk::PipelineDynamicStateCreateInfo struct is so small that I don’t want to waste your time explaining it in detail - it’s essentially only the one function setDynamicStates, and that should be straightforward enough. So I hope this part of the code is self-explanatory.

With this change, the pipeline now expects the viewport and scissor definitions to be passed with the command buffer for every frame, so let’s move our existing setup code for those over to rendering.cpp:

rendering.cpp
auto recordCommandBuffer(
const vk::CommandBuffer& commandBuffer,
const vk::Pipeline& pipeline,
const vk::RenderPass& renderPass,
const vk::Framebuffer& frameBuffer,
const vk::Extent2D& renderExtent,
const vk::Buffer& vertexBuffer,
std::uint32_t vertexCount
) -> void
{
...
commandBuffer.bindPipeline( vk::PipelineBindPoint::eGraphics, pipeline );
const auto viewport = vk::Viewport{}
.setX( 0.f )
.setY( 0.f )
.setWidth( static_cast< float >( renderExtent.width ) )
.setHeight( static_cast< float >( renderExtent.height ) )
.setMinDepth( 0.f )
.setMaxDepth( 1.f );
commandBuffer.setViewport( 0, viewport );
const auto scissor = vk::Rect2D{ { 0, 0 }, renderExtent };
commandBuffer.setScissor( 0, scissor );
...
}

This should also be pretty self-explanatory. With this change, we no longer need the viewport extent when creating the graphics pipeline, so we can remove that parameter.

Compiling and running this version should work fine, which means our refactoring was successful. However, we’re not reaping the benefits of it yet, as the pipeline is still destroyed and recreated whenever the window size changes. That’s the last step for this improvement.

We therefore move the pipeline creation out of the render loop and delete any code that touches it from the if ( framebufferSizeChanged ) block. The same goes for the view matrix: it doesn’t depend on the window size at all, so we can calculate it once before the loop and make it const. So your main function should look something like this:

main.cpp
...
auto main() -> int
{
constexpr int windowWidth = 800;
constexpr int windowHeight = 600;
constexpr std::uint32_t requestedSwapchainImageCount = 2u;
constexpr std::uint32_t maxFramesInFlight = 2u;
try
{
...
const auto pipeline = vcpp::createGraphicsPipeline(
logicalDevice,
*pipelineLayout,
*vertexShader,
*fragmentShader,
*renderPass,
vertexFormats
);
...
const auto view = glm::translate( glm::identity< glm::mat4 >(), glm::vec3{ 0.f, 0.f, -3.f } );
auto projection = glm::identity< glm::mat4 >();
...
while ( !glfwWindowShouldClose( window.get() ) )
{
glfwPollEvents();
if ( windowMinimized )
{
glfwWaitEvents();
continue;
}
if ( framebufferSizeChanged )
{
logicalDevice->waitIdle();
swapchain.reset();
const auto capabilities = physicalDevice.getSurfaceCapabilitiesKHR( *surface );
swapchainExtent = capabilities.currentExtent;
swapchain = vcpp::createSwapchain(
physicalDevice,
logicalDevice,
*renderPass,
*surface,
surfaceFormats[0],
swapchainExtent,
maxFramesInFlight,
requestedSwapchainImageCount
);
projection = glm::perspective(
glm::radians( 30.0f ),
swapchainExtent.width / static_cast< float >( swapchainExtent.height ),
0.1f,
10.0f
);
framebufferSizeChanged = false;
}
...
}
logicalDevice->waitIdle();
}
catch( const std::exception& e )
{
std::cerr << "Exception thrown: " << e.what() << "\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}

Compile and run this version, play around with resizing and minimizing the window. You shouldn’t experience any errors or changes in behaviour.

The last thing I want to do before calling it a day is to make our main function a bit more readable by extracting part of the render loop into its own function. The inner try-catch block seems like a good candidate.

If we extract it naively though, we end up with a function taking about ten parameters, and that wouldn’t be much of a readability win. But if you look at those ten parameters a little closer, you will find that most of them never change: the logical device, the queue, the command buffers, the pipeline, the render pass, the vertex buffers and the vertex count are all created once before the loop and then stay exactly the same for the rest of the program’s life. Only the swapchain and the render extent ever change when the window is resized, and the vertex data we write into the buffers changes every frame.

That’s a strong hint that we might want to group them into a parameter object, so let’s declare one in rendering.hpp:

rendering.hpp
struct RenderResources
{
vk::Device logicalDevice;
vk::Queue queue;
std::span< const vk::CommandBuffer > commandBuffers;
vk::Pipeline pipeline;
vk::RenderPass renderPass;
std::span< const GPUBuffer > vertexBuffers;
std::uint32_t vertexCount;
};

We pass all the command and all the vertex buffers, not just one, so that we can pick the correct ones depending on the frame in flight inside of the drawing function.

With that, we can define drawFrame with only four parameters:

rendering.cpp
auto drawFrame(
const RenderResources& resources,
Swapchain& swapchain,
const vk::Extent2D& renderExtent,
std::span< const std::byte > vertexData
) -> vk::Result
{
try
{
const auto frame = swapchain.getNextFrame();
const auto& commandBuffer = resources.commandBuffers[ frame.frameInFlightIndex ];
const auto& vertexBuffer = resources.vertexBuffers[ frame.frameInFlightIndex ];
copyDataToBuffer( resources.logicalDevice, vertexData, vertexBuffer );
recordCommandBuffer(
commandBuffer,
resources.pipeline,
resources.renderPass,
frame.framebuffer,
renderExtent,
*vertexBuffer.buffer,
resources.vertexCount
);
const auto waitStages = std::array< vk::PipelineStageFlags, 1 >{
vk::PipelineStageFlagBits::eColorAttachmentOutput
};
const auto submitInfo = vk::SubmitInfo{}
.setCommandBuffers( commandBuffer )
.setWaitSemaphores( frame.readyForRenderingSemaphore )
.setSignalSemaphores( frame.readyForPresentingSemaphore )
.setWaitDstStageMask( waitStages );
resources.queue.submit( submitInfo, frame.inFlightFence );
const auto swapchains = std::array< vk::SwapchainKHR, 1 >{ swapchain };
const auto presentInfo = vk::PresentInfoKHR{}
.setSwapchains( swapchains )
.setImageIndices( frame.swapchainImageIndex )
.setWaitSemaphores( frame.readyForPresentingSemaphore );
return resources.queue.presentKHR( presentInfo );
}
catch ( const vk::OutOfDateKHRError& )
{
// the swapchain no longer matches the surface and needs to be recreated
return vk::Result::eErrorOutOfDateKHR;
}
}

There’s just one small problem: since our Vertex struct lives in main.cpp, drawFrame can’t know the exact array type and we can’t actually use the existing copyDataToBuffer function here. Moving Vertex here would hardcode the specific format to our rendering code, which is supposed to be generic. The better solution is to give the function a non-template overload for byte spans, and let the template forward to it:

memory.hpp
auto copyDataToBuffer(
const vk::Device& logicalDevice,
std::span< const std::byte > data,
const GPUBuffer& buffer
) -> void;
template< typename T, size_t N >
auto copyDataToBuffer(
const vk::Device& logicalDevice,
const std::array< T, N >& data,
const GPUBuffer& buffer
) -> void
{
copyDataToBuffer( logicalDevice, std::as_bytes( std::span{ data } ), buffer );
}
memory.cpp
auto copyDataToBuffer(
const vk::Device& logicalDevice,
std::span< const std::byte > data,
const GPUBuffer& buffer
) -> void
{
const auto mappedMemory = logicalDevice.mapMemory( *buffer.memory, 0, data.size() );
std::memcpy( mappedMemory, data.data(), data.size() );
logicalDevice.unmapMemory( *buffer.memory );
}

Existing calls with a std::array keep working exactly as before, and the actual copying code now lives in one place only.

With that out of the way, we can now fill the struct in once before the render loop, after the vertex buffers have been created:

main.cpp
const auto renderResources = vcpp::RenderResources{
.logicalDevice = logicalDevice,
.queue = queue,
.commandBuffers = commandBuffers,
.pipeline = *pipeline,
.renderPass = *renderPass,
.vertexBuffers = gpuVertexBuffers,
.vertexCount = vertexCount
};

Those are C++20’s designated initializers, which let us name every member as we assign it. Well worth using for a struct like this one, where a handful of similar-looking handles would otherwise be very easy to mix up.

And with that, the call in the render loop is short enough to take in at a glance:

main.cpp
while ( !glfwWindowShouldClose( window.get() ) )
{
...
const auto now = std::chrono::steady_clock::now();
const float deltaTime = std::min( std::chrono::duration< float >( now - lastFrameTime ).count(), 0.1f );
lastFrameTime = now;
rotationAngle += deltaTime * rotationSpeed;
...
const auto result = vcpp::drawFrame(
renderResources,
*swapchain,
swapchainExtent,
std::as_bytes( std::span{ verticesTemp } )
);
if ( result == vk::Result::eSuboptimalKHR || result == vk::Result::eErrorOutOfDateKHR )
framebufferSizeChanged = true;
}
...

And that’s it for today. Build and run this version again - you still shouldn’t get any errors or visual changes. We’ve greatly improved our render loop over the last two sessions. Next time we’re going to work on making our scene actually look better again.


  1. If your application still needs to do work while the window is minimized - background tasks, network I/O, animations that should keep running - you can use glfwWaitEventsTimeout( double timeout ) instead. It behaves just like glfwWaitEvents, but returns at the latest after timeout seconds so you get a chance to do periodic work. ↩

  2. In fact, we’d ideally want to decouple the simulation entirely from the rendering, but that’s out of scope for this lesson. ↩