a-simple-triangle / Part 29 - Window resize events

In this article we will fix a bug related to resizing our window at runtime which causes our rendering to become incorrect.


Resize window bug

You may have noticed a subtle visual anomaly when resizing our application window for MacOS and Windows. Although our application copes with the resize and Vulkan correctly destroys and regenerates its render context the actual 3D scene becomes distorted afterward. For example our OpenGL normally looks like this:

However if we resize its window to make it much wider it starts to look a bit like this:

In our Vulkan renderer it normally looks like this:

But after resizing the window looks like this:

For our OpenGL application the distortion happens because we haven’t reissued the glViewport command again with the updated size of the viewport to render to. In addition, our camera in our main scene still thinks it is projecting a view with the older window size.

For our Vulkan application the viewport is already updating correctly so it’s fine, but similar to the OpenGL renderer the out of date camera is causing the distortion.

We don’t yet have a mechanism in our application to know when a user has resized their window, but the SDL library offers us a range of system level events that we can hook into including a family of events under the SDL_WINDOWEVENT type: https://wiki.libsdl.org/SDL_WindowEvent.

One of these events is SDL_WINDOWEVENT_RESIZED which will be dispatched after the SDL window has detected a change in size. We will use this event as the way to trigger some window size handling code in our application.


Window size class

First up we are going to create a new convenience class to hold a width and height for representing a window size. Previously we had packed two uint32_t values into a std::pair object but that approach is a bit clunky.

Create a new header file named window-size.hpp in the main/core folder. Note that we do not need a .cpp implementation for it. Enter the following into it:

#pragma once

namespace ast
{
    struct WindowSize
    {
        const uint32_t width{0};
        const uint32_t height{0};
    };
} // namespace ast

Super basic yeah? We could have instead used other third party classes that hold two unsigned integer values however creating our own class is light weight and makes our intentions clear in its use.

In order to find out the size of the window on demand we need to split the existing getDisplaySize function into two new functions instead:

If we didn’t split our existing code into these two functions we would keep receiving a window size of 640 x 480 for the desktop platforms when we call the existing getDisplaySize function even when the window is no longer that size.

Edit core/sdl-wrapper.hpp and change it from this:

#pragma once

#include <SDL.h>
#ifndef __EMSCRIPTEN__
#include <SDL_vulkan.h>
#endif
#include <utility>

namespace ast::sdl
{
    std::pair<uint32_t, uint32_t> getDisplaySize();

    SDL_Window* createWindow(const uint32_t& windowFlags);
} // namespace ast::sdl

to this:

#pragma once

#include <SDL.h>
#ifndef __EMSCRIPTEN__
#include <SDL_vulkan.h>
#endif
#include "window-size.hpp"

namespace ast::sdl
{
    ast::WindowSize getInitialWindowSize();

    ast::WindowSize getWindowSize(SDL_Window* window);

    SDL_Window* createWindow(const uint32_t& windowFlags);
} // namespace ast::sdl

Note that we now have separate getInitialWindowSize and getWindowSize functions. Now open core/sdl-wrapper.cpp and start by adding a new free function in the anonymous namespace that fetches the HTML canvas size if we are running on the Emscripten platform:

namespace
{
    ...

#ifdef __EMSCRIPTEN__
    ast::WindowSize getEmscriptenCanvasSize()
    {
        // For Emscripten targets we will invoke some Javascript
        // to find out the dimensions of the canvas in the HTML
        // document. Note that the 'width' and 'height' attributes
        // need to be set on the <canvas /> HTML element, like so:
        // <canvas id="canvas" width="600", height="360"></canvas>
        uint32_t width{static_cast<uint32_t>(EM_ASM_INT({
            return document.getElementById('canvas').width;
		}))};

        uint32_t height{static_cast<uint32_t>(EM_ASM_INT({
            return document.getElementById('canvas').height;
		}))};

        return ast::WindowSize{width, height};
    }
#endif
} // namespace

Note that we are using the #ifdef __EMSCRIPTEN__ conditional to guard against compilation against non Emscripten platform targets. The code in the function was plucked out of the existing getDisplaySize function but returns an ast::WindowSize object now instead of a std::pair<uint32_t, uint32_t>.

After the anonymous namespace, implement the new getWindowSize function like so:

ast::WindowSize ast::sdl::getWindowSize(SDL_Window* window)
{
#ifdef __EMSCRIPTEN__
    return ::getEmscriptenCanvasSize();
#else
    int width{0};
    int height{0};
    SDL_GetWindowSize(window, &width, &height);
    return ast::WindowSize{static_cast<uint32_t>(width), static_cast<uint32_t>(height)};
#endif
}

If we are running Emscripten we simply return the HTML canvas size using the free function we wrote, otherwise we query the window argument for its current size and return its dimensions through the ast::WindowSize class.

Next we will completely replace the existing getDisplaySize function with the implementation of our new getInitialWindowSize function:

ast::WindowSize ast::sdl::getInitialWindowSize()
{
#ifdef __EMSCRIPTEN__
    return ::getEmscriptenCanvasSize();
#else
    const ast::Platform platform{ast::getCurrentPlatform()};

    if (platform == ast::Platform::ios || platform == ast::Platform::android)
    {
        // For mobile platforms we will fetch the full screen size.
        SDL_DisplayMode displayMode;
        SDL_GetDesktopDisplayMode(0, &displayMode);
        return ast::WindowSize{static_cast<uint32_t>(displayMode.w), static_cast<uint32_t>(displayMode.h)};
    }

    // For other platforms we'll just show a fixed size window.
    return ast::WindowSize{640, 480};
#endif
}

This function does more or less the same thing the original getDisplaySize function did - including returning the hard coded 640 x 480 size for desktop platforms.

The last change is to update the createWindow function to use getInitialWindowSize:

SDL_Window* ast::sdl::createWindow(const uint32_t& windowFlags)
{
    ast::WindowSize windowSize{ast::sdl::getInitialWindowSize()};

    SDL_Window* window{SDL_CreateWindow(
        "A Simple Triangle",
        SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
        windowSize.width, windowSize.height,
        windowFlags)};

    if (::shouldDisplayFullScreen())
    {
        SDL_SetWindowFullscreen(window, SDL_TRUE);
    }

    return window;
}

Use window size in scene

I think our window size class should be used consistently when passing around the width and height of the display so we will revisit our main scene class to adopt it. Pop open scenes/scene-main.hpp and add the window size header:

#include "../core/window-size.hpp"

The change the constructor signature to take a window size instead of two floats:

namespace ast
{
    struct SceneMain : public ast::Scene
    {
        SceneMain(const ast::WindowSize& frameSize);

Now edit scenes/scene-main.cpp and start by updating our createCamera free function to now take a window size which it unpacks into two floats rather than having the two floats passed into it:

namespace
{
    ast::PerspectiveCamera createCamera(const ast::WindowSize& size)
    {
        return ast::PerspectiveCamera(static_cast<float>(size.width),
                                      static_cast<float>(size.height));
    }
} // namespace

Also update the Internal constructor to take a window size and use it when creating the camera:

struct SceneMain::Internal
{
    ...

    Internal(const ast::WindowSize& size) : camera(::createCamera(size)) {}

And of course we need to update the public constructor to take the window size as well:

SceneMain::SceneMain(const ast::WindowSize& size)
    : internal(ast::make_internal_ptr<Internal>(size)) {}

Update OpenGL application

If we tried to run our application now we would get a bunch of compilation errors due to the changes we made in our scene. Edit application/opengl/opengl-application.cpp and change the createMainScene function from this:

std::unique_ptr<ast::Scene> createMainScene(ast::OpenGLAssetManager& assetManager)
{
    std::pair<uint32_t, uint32_t> displaySize{ast::sdl::getDisplaySize()};
    std::unique_ptr<ast::Scene> scene{std::make_unique<ast::SceneMain>(
        static_cast<float>(displaySize.first),
        static_cast<float>(displaySize.second))};

    assetManager.loadAssetManifest(scene->getAssetManifest());
    scene->prepare();

    return scene;
}

to this:

std::unique_ptr<ast::Scene> createMainScene(const ast::SDLWindow& window, ast::OpenGLAssetManager& assetManager)
{
    std::unique_ptr<ast::Scene> scene{std::make_unique<ast::SceneMain>(ast::sdl::getWindowSize(window.getWindow()))};
    assetManager.loadAssetManifest(scene->getAssetManifest());
    scene->prepare();

    return scene;
}

Note that we now need to pass in the window argument and that we call getWindowSize to figure out how big the scene should be. We also need to add the window argument to the following line within the getScene function:

ast::Scene& getScene()
{
    if (!scene)
    {
        scene = ::createMainScene(window, *assetManager);
    }

    return *scene;
}

Update Vulkan application

We need to perform some similar updates in our Vulkan application but with a few subtle differences. The first difference to our OpenGL application is that ast::VulkanApplication does not actually have access to the current SDL_Window object as it lives inside our ast::VulkanContext class privately. We need to access the window if we want to get its size. Rather than accessing the window object itself we will instead give our Vulkan context class a new function which will return the current window size. Edit vulkan-context.hpp and add the header file for our window size class:

#include "../../core/window-size.hpp"

Then add a new function signature to return the current window size:

namespace ast
{
    struct VulkanContext : public ast::Renderer
    {
        ...

        ast::WindowSize getCurrentWindowSize() const;

Then pop over to vulkan-context.cpp and add the public getCurrentWindowSize function implementation at the bottom of the file:

ast::WindowSize VulkanContext::getCurrentWindowSize() const
{
    return ast::sdl::getWindowSize(internal->window.getWindow());
}

We can now find out the current window size whenever we have a Vulkan context object, which we just happen to have in our Vulkan application. Edit vulkan-application.cpp and change the existing createMainScene function from this:

std::unique_ptr<ast::Scene> createMainScene(ast::VulkanContext& context)
{
    std::pair<uint32_t, uint32_t> displaySize{ast::sdl::getDisplaySize()};
    std::unique_ptr<ast::Scene> scene{std::make_unique<ast::SceneMain>(
        static_cast<float>(displaySize.first),
        static_cast<float>(displaySize.second))};

    context.loadAssetManifest(scene->getAssetManifest());
    scene->prepare();

    return scene;
}

to this:

std::unique_ptr<ast::Scene> createMainScene(ast::VulkanContext& context)
{
    std::unique_ptr<ast::Scene> scene{std::make_unique<ast::SceneMain>(context.getCurrentWindowSize())};
    context.loadAssetManifest(scene->getAssetManifest());
    scene->prepare();

    return scene;
}

Listen for window size events

We now have a way to know what window size to use when our application boots up, but also what size the window is at any point in time after that. We can now introduce the mechanism to listen for window size change events from SDL. Both our OpenGL and Vulkan application classes share a common base class ast::Application which drives the main loop of our engine. In fact we are already listening for SDL events in the base application which is how we are able to press the ESC key to quit the program:

bool Application::runMainLoop()
{
    SDL_Event event;

    // Each loop we will process any events that are waiting for us.
    while (SDL_PollEvent(&event))
    {
        switch (event.type)
        {
            case SDL_QUIT:
                return false;

            case SDL_KEYDOWN:
                if (event.key.keysym.sym == SDLK_ESCAPE)
                {
                    return false;
                }
                break;
            default:
                break;
        }
    }

All we need to do is add another case statement to look for a window resize event. Before we do that we will add a new pure virtual function to our base application class which will be invoked when event triggers. Edit application/application.hpp and add the following function signature which will force its subclasses to provide an implementation:

namespace ast
{
    struct Application
    {
        ...

        virtual void onWindowResized() = 0;

Now add the SDL_WINDOWEVENT case statement to core/application.cpp to detect the window size change event and invoke the onWindowResized function:

bool Application::runMainLoop()
{
    SDL_Event event;

    // Each loop we will process any events that are waiting for us.
    while (SDL_PollEvent(&event))
    {
        switch (event.type)
        {
            case SDL_WINDOWEVENT:
                if (event.window.event == SDL_WINDOWEVENT_RESIZED)
                {
                    onWindowResized();
                }
                break;

            case SDL_QUIT:
                return false;

            case SDL_KEYDOWN:
                if (event.key.keysym.sym == SDLK_ESCAPE)
                {
                    return false;
                }
                break;
            default:
                break;
        }
    }

We now need to implement the onWindowResized function in our OpenGL and Vulkan application classes.

Update OpenGL application

We’ll start with opengl-application.hpp, adding the onWindowResized signature with the override keyword:

namespace ast
{
    struct OpenGLApplication : public ast::Application
    {
        ...

        void onWindowResized() override;

Now edit opengl-application.cpp for the implementation. Add the following new function inside the Internal structure:

struct OpenGLApplication::Internal
{
    ...

    void onWindowResized()
    {
        getScene().onWindowResized(ast::sdl::getWindowSize(window.getWindow()));
        ::updateViewport(window.getWindow());
    }

You will get syntax errors with both invocations in this function but we’ll fix that soon. Add in the public function implementation of onWindowResized at the bottom of the file which simply delegates to the internal structure:

void OpenGLApplication::onWindowResized()
{
    internal->onWindowResized();
}

The first syntax error we’ll fix is getScene().onWindowResized which is happening because our scene class doesn’t have an onWindowResized function yet. Edit scene/scene.hpp and add the following header:

#include "../core/window-size.hpp"

Then add a new function signature that can be called for any scene to inform it that the window size has changed, supplying the new window size as an argument. We will implement this function in our main scene later:

namespace ast
{
    struct Scene
    {
        ...

        virtual void onWindowResized(const ast::WindowSize& size) = 0;

Go back to opengl-application.cpp and the syntax error should be gone from the onWindowResized function. The next error is specific to OpenGL and is about updating the OpenGL viewport with the updated window size so it renders to the correct area of the screen. Add a new free function in the anonymous namespace which queries the current window size and issues the OpenGL glViewport command to update it:

namespace
{
    void updateViewport(SDL_Window* window)
    {
        static const std::string logTag{"ast::OpenGLApplication::updateViewport"};

        int viewportWidth;
        int viewportHeight;
        SDL_GL_GetDrawableSize(window, &viewportWidth, &viewportHeight);
        ast::log(logTag, "Created OpenGL context with viewport size: " + std::to_string(viewportWidth) + " x " + std::to_string(viewportHeight));
        
        glViewport(0, 0, viewportWidth, viewportHeight);
    }

Note that we were already doing this before within createContext but only once when the application started. Update the createContext function to now call updateViewport instead:

namespace
{
    ...

    SDL_GLContext createContext(SDL_Window* window)
    {
        static const std::string logTag{"ast::OpenGLApplication::createContext"};

        SDL_GLContext context{SDL_GL_CreateContext(window)};

#ifdef WIN32
        glewInit();
#endif

        glClearDepthf(1.0f);
        glEnable(GL_DEPTH_TEST);
        glDepthFunc(GL_LEQUAL);
        glEnable(GL_CULL_FACE);
        
        ::updateViewport(window);

        return context;
    }

If you revisit the internal onWindowResized function again you should see the syntax errors have been resolved now.

Update Vulkan application

Open vulkan-application.hpp and add the onWindowResized signature with the override keyword:

namespace ast
{
    struct VulkanApplication : public ast::Application
    {
        ...

        void onWindowResized() override;

Edit vulkan-application.cpp and add the following function to the internal structure:

struct VulkanApplication::Internal
{
    ...

    void onWindowResized()
    {
        getScene().onWindowResized(context.getCurrentWindowSize());
    }

Note that unlike the OpenGL application we do not need to do anything to fix the viewport - the natural regeneration of the Vulkan swapchain during lifecycle changes will take care of that already.

Add the public function implementation to the bottom of the file and we are done with the Vulkan application class:

void VulkanApplication::onWindowResized()
{
    internal->onWindowResized();
}

Update main scene

The last thing we need to do is update our main scene class to implement the pure virtual onWindowResized function of its base class. The implementation will basically recreate our camera using the updated window size. Edit scene/scene-main.hpp and add the function override signature:

namespace ast
{
    struct SceneMain : public ast::Scene
    {
        ...

        void onWindowResized(const ast::WindowSize& size) override;

Then edit scene/scene-main.cpp first adding the following function to the internal structure:

struct SceneMain::Internal
{
    ...

    void onWindowResized(const ast::WindowSize& size)
    {
        camera = ::createCamera(size);
    }

You can see that in this function we simply recreate our camera with the updated window size. If you were paying close attention a long time ago you might have noticed that our camera field wasn’t marked as const - this is the reason!

Finally we add the public function implementation that delegates to the internal structure:

void SceneMain::onWindowResized(const ast::WindowSize& size)
{
    internal->onWindowResized(size);
}

Cool, now if you run the application and resize the window the 3D scene should correct itself and still render nicely:

OpenGL window stretched horizontally after the fixes:

Vulkan window stretched horizontally after the fixes:


Summary

That bug has been annoying me for a little while so it was nice to get it fixed.

Next up will be the final article in this series where we add some really basic user input to move around our 3D scene.

The code for this article can be found here.

Continue to Part 30: Basic user input.

End of part 29