Let's Make an Engine: Window

2022-12-26

In the previous entry we built a skeleton project with a terminal application. Nothing against text mode games but we want a fully graphical engine. To this end we first need to create a window.

To make our window we need to choose our target platform and integrate its SDK. It will be fun eventually to port our engine to other platforms but for now we’re going to focus on Win32, the primay API used to target 2 of the 3 largest game markets (PC & Xbox).

Window

We have more choices to make for our interface. We’ll go with a thin RAII wrapper around the Window and a factory function that returns a unique_ptr to the Window resource. It doesn’t make sense for Window to be Copyable. We could make it a Movable type but we’re only going to make one of these (for now) so we can keep it simple.

There are some parameters we need in order to create our Window that have to be provided by the application. We’ll pass a WindowConfig to our MakeWindow factory function to provide that configuration data.

class Window {
    ...
};

// \brief Creates a Window with the given configuration
std::unique_ptr<Window> MakeWindow(const WindowConfig& aConfig);

WindowConfig

For years I used a pattern of chaining accessors for classes configuration classes like this one and it creates a pleasing interface albeit at the cost of a bit of a verbose implementation.

/// \brief Configuration for making a Window. Implements chainable property setters.
class WindowConfig {
public:
    /// \brief Construct a default Window Config
    WindowConfig() : _width(1), _height(1), _mode(WindowMode::windowed) {}

    /// \brief Set title.
    const tstring& title() const { return _title; }
    /// \brief Get title.
    WindowConfig& title(const tstring& title_) { _title = title_; return *this; }

    /// \brief Get width.
    std::uint32_t width() const { return _width; }
    /// \brief Set width.
    WindowConfig& width(std::uint32_t width_) { _width = width_; return *this; }

    /// \brief Get height.
    std::uint32_t height() const { return _height; }
    /// \brief Set height.
    WindowConfig& height(std::uint32_t height_) { _height = height_; return *this; }

    /// \brief Get mode.
    WindowMode mode() const { return _mode; }
    /// \brief Set mode.
    WindowConfig& mode(WindowMode mode_) { _mode = mode_; return *this; }
private:
    tstring          _title;
    std::uint32_t    _width;
    std::uint32_t    _height;
    WindowMode       _mode;
};

////////////////////////////////////////
// USAGE
auto window = MakeWindow( WindowConfig()
                          .title("Let's Make an Engine")
                          .width(1024)
                          .height(768) );

But we can use 2 bits of modern syntax to improve this idea.

  • default member initializers (C++11)1
  • designated initializer syntax (C++20)2

This results in a blissfully simple and elegant implementation and interface.

/// \brief Configuration for making a Window.
struct WindowConfig {
    tstring          title;
    std::uint32_t    width = 1;
    std::uint32_t    height = 1;
    WindowMode       mode = WindowMode::windowed;
};

////////////////////////////////////////
// USAGE
auto window = MakeWindow( WindowConfig{
                          .title = "Let's Make an Engine",
                          .width = 1024,
                          .height = 768 });

Window Creation

First we’ll build the skeleton of the factory function.

std::unique_ptr<Window> MakeWindow(WindowConfig aConfig) {
    auto window = std::make_unique<Window>();
    window->_config = std::move(aConfig);

    ...

    return window;
}

There are already some interesting decisions here. First we name our method MakeWindow instead of CreateWindow because <windows.h annoyingly #defines CreateWindow as part of its own API interface. We could fight against this with #undef and modules make this a lot more appealing of an option as they won’t export the macros. But as projects get larger and others contribute it’s likely to get included somewhere else and sneak into the code. It’s easier to just avoid the fight and change our name instead.

Second our WindowConfig here is taken by value. We’re going to store our initial config in the Window object. We have options we can create an overload for rvalues and lvalue references, we can make it a template taking a forwarding reference, or we can take it by value. By value ends up costing an extra move in the case of an rvalue being passed to the make function. This is acceptable to me for the simplification, we can always go back and duplicate the code later.

Now to fill in the rest. First we need to register our window class.


    WNDCLASSEX wcex = {};
    wcex.cbSize = sizeof(WNDCLASSEX);
    wcex.style = CS_OWNDC | CS_HREDRAW | CS_VREDRAW;
    wcex.lpfnWndProc = &WndProc;
    wcex.hInstance = GetModuleHandle(nullptr);
    wcex.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1);
    wcex.lpszClassName = "LetsMakeEngineWindow";
    wcex.hIconSm = LoadIcon(0, IDI_APPLICATION);
    auto wndclassAtom = RegisterClassEx(&wcex);
    if (!wndclassAtom) {
        throw std::system_error{ std::error_code(static_cast<int>(GetLastError()), std::system_category()) };
    }
    ...

The (HBRUSH)(COLOR_WINDOW + 1) is some magic straight form MSDN.3.

The decision to report errors via exceptions is one that can be revisited at a later date. A rust style Result4 to report errors is certainly reasonable, but honestly there’s no recovery here for us if this fails.

Next we need to set up our window style and adjust our window rect to a account for the borders and title bar of our style.

        // Setup window style
        RECT windowRect{ 0, 0, static_cast<LONG>(aConfig.width), static_cast<LONG>(aConfig.height) };
        auto [style, exStyle] = GetWindowStyle(aConfig.mode);
        AdjustWindowRectEx( &windowRect, style, FALSE, exStyle );
        ... 

This uses a helper function to translate our window mode enum into Win32 window styles.

std::pair<DWORD, DWORD> GetWindowStyle(WindowMode mode) {
    DWORD style;
    DWORD exStyle;
    switch (mode) {
    case WindowMode::windowed:
        style = WS_OVERLAPPEDWINDOW;
        exStyle = WS_EX_OVERLAPPEDWINDOW;
        break;
    case WindowMode::borderless_window:
        style = WS_POPUP;
        exStyle = 0;
        break;
    case WindowMode::fullscreen:
        style = WS_POPUP;
        exStyle = WS_EX_TOPMOST;
        break;
    }
    return std::make_pair(style, exStyle);
}

We can finally create the window.

    auto hwnd = CreateWindowEx(
        exStyle,
        MAKEINTATOM(wndclassAtom),
        window->_config.title.c_str(),
        style,
        CW_USEDEFAULT,
        CW_USEDEFAULT,
        windowRect.right - windowRect.left, // width
        windowRect.bottom - windowRect.top, // height
        HWND_DESKTOP, // parent
        0, // menu
        0, // application instance
        window.get() // lpCreateParams
    );

    if( !hwnd ) {
        std::error_code ec( static_cast<int>(GetLastError()), std::system_category() );
        UnregisterClass(MAKEINTATOM(wndclassAtom), GetModuleHandle(nullptr));
        throw std::system_error{ ec };
    }
}

The interesting line here is window.get() which gets forwarded to the window via the CREATESTRUCT structure. This allows us to associate our Window object with our Win32 window for event handling as we’ll see shortly.

The last bit in the creation process is to show our window as it is not visible initially.

    if (aConfig.mode == WindowMode::fullscreen) {
        ShowWindow(hwnd, SW_SHOWMAXIMIZED);
        window->_isFullscreen = true;
    } else {
        ShowWindow(hwnd, SW_SHOW);
    }

    return window;
}

And with that we finally have a window. I remember finding the Win32 API completely bewildering when I was first learning programming. I know a lot more now and I respect it as brilliant in a lot of ways. Knowing the backstory behind how it evolved from a 16-bit API into what it is now explains many of the strange decisions like why exStyle is the first parameter of CreateWindowEx.5 The longevity of the API, the history of support and evolution are all beyond impressive. And yet it’s still confusing as hell and requires constant manual consulting.

Event Handling

In order for our window to behave like we expect we’re going to need to handle some events from the operating system. There is a line we glossed over earlier that will help us do just that.

wcex.lpfnWndProc = &WndProc;

WndProc

The WndProc is a function called every time a window receives an event (or message in windows parlance).

LRESULT CALLBACK WndProc(HWND aHwnd, UINT aMsg, WPARAM aWparam, LPARAM aLparam) {
    Window* self = nullptr;

    if ( aMsg != WM_NCCREATE ) {
        // GetWindowLongPtr returns 0 if GWLP_USERDATA has not been set yet.
        self = reinterpret_cast<Window*>(GetWindowLongPtr(aHwnd, GWLP_USERDATA));
    } else {
        auto createStruct = reinterpret_cast<LPCREATESTRUCT>(aLparam);
        self = reinterpret_cast<Window*>(createStruct->lpCreateParams);
        self->_hwnd = aHwnd; // initialize the window handle
        SetWindowLongPtr(aHwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
    }

    if (self != nullptr) {
        return self->ProcessMessage(aMsg, aWparam, aLparam);
    }
    return DefWindowProc(aHwnd, aMsg, aWparam, aLparam);
}

The first part of this function is a trick used to bind our Window object to our Win32 window handle. We pass our pointer into CreateWindowEx as mentioned earlier and then we store it where we can retrieve it from the hwnd later by using GWLP_USERDATA. We do this when we process the WM_NCCREATE as it is one of the very first messages our window receives.6 We also take this opportunity to store the hwnd in our Window object as this occurs even before the call to CreateWindowEx returns.7 From this point on all messages can be processed by our Window object directly in the ProcessMessage member.

Events

There are an enormous number of events that you can handle in the WndProc and we will undoubtedly come back and add additional event handlers as add features and flesh out our project. For now we have a starter set of events to handle for our window to feel normal.

WM_ACTIVATEAPP

This message gets sent when the user alt+tabs away, minimizes, or performs any other action that causes our app to lose focus and become inactive. I’ve seen numerous wild implementations for detecting alt+tabs from WM_ACTIVATE to WM_KILLFOCUS to even more exotic attempts. For a single window application this message does everything we need.

case WM_ACTIVATEAPP:
    if (_config.pauseAppWhenInactive) {
        const bool isBecomingActive = (aWparam == TRUE);
        const auto priorityClass = isBecomingActive ? NORMAL_PRIORITY_CLASS : IDLE_PRIORITY_CLASS;
        SetPriorityClass(GetCurrentProcess(), priorityClass);
    }
    break;

This is a little trick I use in my projects to save headaches. What this does is drastically lower the priority of the game with the OS scheduler when it’s not your focus. This means effectively that your computer becomes a lot more usable and you are less inclined to have to close the game. Once there is actual game logic running I would typically bring up the pause menu here as well.

WM_SYSKEYDOWN

case WM_SYSKEYDOWN:
    if (aWparam == VK_RETURN && (aLparam & 0x60000000) == 0x20000000) {
        // Implements the classic ALT+ENTER fullscreen toggle
        if (_isFullscreen) {
            auto [style, exStyle] = GetWindowStyle(WindowMode::windowed);
            SetWindowLongPtr(_hwnd, GWL_STYLE, style);
            SetWindowLongPtr(_hwnd, GWL_EXSTYLE, exStyle);
            SetWindowPlacement(_hwnd, &_restorePlacement);
            SetWindowPos(_hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
        } else {
            GetWindowPlacement(_hwnd, &_restorePlacement);
            auto [style, exStyle] = GetWindowStyle(WindowMode::fullscreen);
            SetWindowLongPtr(_hwnd, GWL_STYLE, style);
            SetWindowLongPtr(_hwnd, GWL_EXSTYLE, exStyle);
            SetWindowPos(_hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
            ShowWindow(_hwnd, SW_SHOWMAXIMIZED);
        }
        _isFullscreen = !_isFullscreen;
    }
    break;
case WM_CREATE:
    GetWindowPlacement(_hwnd, &_restorePlacement);
    break;

We use this message to implement the classic ALT+ENTER fullscreen toggle. If we’re in windowed mode we cache off our size and position using GetWindowPlacement then we switch to fullscreen by changing our window style to remove all border elements and maximizing the window. If we’re toggling back to windowed mode we restore our placement and styles. Note we also store our placement in WM_CREATE in case we are starting in fullscreen mode since this will occur before we maximize our window in MakeWindow.

WM_MENUCHAR

case WM_MENUCHAR:
    // Ignore so we don't produce an error beep.
    return MAKELRESULT(0, MNC_CLOSE);

Avoid the annoying error beep if you press a key while holding alt.

WM_PAINT

case WM_PAINT: {
    PAINTSTRUCT ps;
    std::ignore = BeginPaint(_hwnd, &ps);
    EndPaint(_hwnd, &ps);
    break;
}

Redraw the window when asked to.

WM_PAINT

case WM_PAINT: {
    PAINTSTRUCT ps;
    std::ignore = BeginPaint(_hwnd, &ps);
    EndPaint(_hwnd, &ps);
    break;
}

WM_GETMINMAXINFO

static constexpr LONG MinWidth = 320;
static constexpr LONG MinHeight = 240;
case WM_GETMINMAXINFO: {
    auto info = reinterpret_cast<MINMAXINFO*>(aLparam);
    info->ptMinTrackSize.x = MinWidth;
    info->ptMinTrackSize.y = MinHeight;
    break;
}

Sets the minimum size our window can shrink to (aside from being minimized). This prevents use from having to deal with any unnatural funkiness like 0 sized windows.

WM_SIZING

case WM_SIZING: {
    // maintain aspec ratio when resizing
    auto& rect = *reinterpret_cast<RECT*>(aLparam);
    const float aspectRatio = float(_config.width) / _config.height;
    const DWORD style = GetWindowLong(_hwnd, GWL_STYLE);
    const DWORD exStyle = GetWindowLong(_hwnd, GWL_EXSTYLE);

    RECT emptyRect{};
    AdjustWindowRectEx(&emptyRect, style, false, exStyle);
    rect.left -= emptyRect.left;
    rect.right -= emptyRect.right;
    rect.top -= emptyRect.top;
    rect.bottom -= emptyRect.bottom;

    auto newWidth = std::max(rect.right - rect.left, MinWidth);
    auto newHeight = std::max(rect.bottom - rect.top, MinHeight);

    switch (aWparam) {
    case WMSZ_LEFT:
        newHeight = static_cast<LONG>(newWidth / aspectRatio);
        rect.left = rect.right - newWidth;
        rect.bottom = rect.top + newHeight;
        break;
    case WMSZ_RIGHT:
        newHeight = static_cast<LONG>(newWidth / aspectRatio);
        rect.right = rect.left + newWidth;
        rect.bottom = rect.top + newHeight;
        break;
    case WMSZ_TOP:
    case WMSZ_TOPRIGHT:
        newWidth = static_cast<LONG>(newHeight * aspectRatio);
        rect.right = rect.left + newWidth;
        rect.top = rect.bottom - newHeight;
        break;
    case WMSZ_BOTTOM:
    case WMSZ_BOTTOMRIGHT:
        newWidth = static_cast<LONG>(newHeight * aspectRatio);
        rect.right = rect.left + newWidth;
        rect.bottom = rect.top + newHeight;
        break;
    case WMSZ_TOPLEFT:
        newWidth = static_cast<LONG>(newHeight * aspectRatio);
        rect.left = rect.right - newWidth;
        rect.top = rect.bottom - newHeight;
        break;
    case WMSZ_BOTTOMLEFT:
        newWidth = static_cast<LONG>(newHeight * aspectRatio);
        rect.left = rect.right - newWidth;
        rect.bottom = rect.top + newHeight;
        break;            
    }
    AdjustWindowRectEx(&rect, style, false, exStyle);
    return TRUE;
}

This one is a beast but the idea is simple. When you’re resizing a window by dragging on the borders you are getting WM_SIZING calls every time the window is resized. In order to not deal with weirdness we force our window the maintain its initial aspect ratio while it’s being resized. There a bit of devil in the details regarding window styles and the extra border space they require as well as sticking to reasonable minimum dimensions. Handling diagonal resize drags while maintaining aspect ratio is particularly tricky to make feel good, so we punt and just ignore any horizontal resizing the user does.

WM_NCDESTROY

case WM_NCDESTROY:
    PostQuitMessage(0);
    _hwnd = nullptr;
    SetWindowLongPtr(_hwnd, GWLP_USERDATA, LONG_PTR(0));
    break;
}

This is the very last message a WndProc receives when the window is being destroyed.8 Thus it makes for a great place to teardown what its paired WM_NCCREATE message setup. We also post a WM_QUIT message. This is a special message that will not go to the WndProc but can be handled by the message pump loop to let us know the user closed the window and we can quit.

Event Pump

Now that we’ve handled all of the events we care about we have to actually pump those events from the OS in our application in order to consume them. Win32 is very flexible and allows us to run our message pump on whatever thread creates our window. This means we could create a dedicated thread to create the window and run the event pump behind the scenes so we never have to think about it. This design greatly simplifies the application staying responsive while running long tasks such as loading a game. But it sets us up for troubles with portability as other OSes like Mac OSX require you to run your event pump on the main thread of your application.

So we’re going with a straight forward function that must be called by our application main thread regularly.

bool PumpEvents() {
    MSG msg{};
    while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
        if (msg.message == WM_QUIT) {
            return false;
        }
    }
    return true;
}

PumpEvents will consume all of the pending events for both our Window and Thread. It will return true if the game should continue or false if we’ve be notified to quit. Note that this will end up calling into our WndProc to handle the events.

Updating the App

We can finally bring it all together and update our application in order to create a window and pump events.

import LetsMakeEngine;

using namespace LetsMakeEngine;

int main() {
    // Initialize systems
    auto window = MakeWindow( WindowConfig{
        .title = "Let's Make an Engine",
        .width = 1024,
        .height = 768,
        .mode = WindowMode::windowed
    });

    while (PumpEvents()) {
        // Update game state
        // Render frame
    }

    // Shutdown systems
}

Note that we are no longer a console application but not a full Win32 GUI app. However we can still use main as our entry point instead of having to switch to WinMain thanks to a little trick in our build settings.9

Wrapping Up

We now have a functional window and our engine contains some interesting code finally. There is a wide ocean of possibilities in front of us. We can begin to think about topics like making a renderer to draw something useful to it. We can consider an entity system in order to add some gameplay so we have something to render. We can even consider an input system so we can add user interaction to our engine.

Our next step will remain a surprise for now. Don’t you just love cliffhangers?


Series: Lets Make an Engine