In this article we will fix a bug related to resizing our window at runtime which causes our rendering to become incorrect.
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.
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:
getInitialWindowSize
: This function will calculate the initial window size - which we use when we boot the application. In particular it returns a hard coded 640 x 480
size for our desktop platforms.getWindowSize
: This function will take the current SDL window and calculate the current window size which will usually be different than the initial size when a window size change event is raised.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;
}
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;
}
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:
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