Build a Game Engine

Creating a Window

Prerequisites

Article

With some fundamentals down, let's go back to something basic: creating a window. First, I've gone back and forth many times on where to put this class... it ended up in Core simply because so many other systems need access to the application window (the rendering system, the audio system, the IO system, etc.) that it hasn't made a lot of sense to put it anywhere else. It started out in the rendering system, because, well, you render things on it. But then the IO and the audio systems depended on the rendering system.. that makes no sense. Plus, a window can have multiple rendering targets (think about a 3D modelling program that shows a perspective, right, and top-down view of the same scene). So it has ended up back in the Core part of the engine, while the rendering system now uses a View object to specify the render target.

To create the window, we'll use a library called GLFW. This is well suited to our engine, since it integrates nicely with OpenGL. You could also use SDL if you're going to take full advantage of everything it has to offer or you could roll your own, but GLFW is smaller and it only does windowing and device input.

For windowing, we really want it to abstract 3 key processes that are all highly platform dependent: creating a window, processing operating system messages, and swapping the buffer to update the display.

Let's take a look at our Window class:

class UWindow : public UObject {
public:
    UWindow() : m_window(0) { }
    ~UWindow() = default;

    /**
     * Create a render window
     */
    void Create(std::string title, int width, int height, bool fullscreen);

    /**
     * Message loop
     */
    void ProcessMessages();

    /**
     * Swap render buffer
     */
    void SwapBuffer();

    /**
     * Whether the window is closing or not
     */
    bool IsClosing();

    /**
     * Get framebuffer size
     */
    void Size(int& width, int& height);
    void FramebufferSize(int& width, int& height);

    /**
     * Get internal window handle
     */
    GLFWwindow* Handle() { return m_window; }

protected:
    // Internal window handle
    GLFWwindow* m_window;
};

As you can see, we have functions that do just that: Create, ProcessMessages and SwapBuffer. Note that you cannot call the function CreateWindow on Windows. It drives me crazy every time, but even though it's wrapped in a class and is going to be called completely differently than the native CreateWindow, you will get a compiler error if you try to call it that.

Let's take a look at the Create function:

void UWindow::Create(std::string title, int width, int height, bool fullscreen) {
    // If we have not yet, initialize GLFW system (GLFW will just return if already initialized)
    glfwInit();

    // Specify the minimum OpenGL version
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 0);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
    glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
    glfwWindowHint(GLFW_SAMPLES, 4);

    // Get our primary monitor
    GLFWmonitor* primary = glfwGetPrimaryMonitor();
    const GLFWvidmode* mode = glfwGetVideoMode(primary);
    unsigned int w = mode->width;
    unsigned int h = mode->height;

    // Create our window
    m_window = glfwCreateWindow(fullscreen ? w : width, fullscreen ? h : height, title.c_str(), fullscreen ? primary : NULL, NULL);
    if (m_window == NULL) {
        glfwTerminate();
        UASSERT(false, "Unable to create window.");
    }

    // Make our OpenGL context current
    glfwMakeContextCurrent((GLFWwindow*)m_window);

    // Get our actual framebuffer size
    int framebufferWidth, framebufferHeight;
    glfwGetFramebufferSize((GLFWwindow*)m_window, &framebufferWidth, &framebufferHeight);
}

The first thing we do is try to initialize GLFW. Fortunately for us, GLFW will just return right away if already initialized, so no need to track that. Then, we'll give GLFW some hints about the OpenGL context we would prefer - we would like to use version 4.0 or higher, the core profile only, and have GLFW ignore or remove deprecated functions and backwards compatibility. The reason for the last two is that on OS X, these are the required settings to initialize an OpenGL context greater than version 3.2 (and 3.0 and 3.1 are not supported).

Next, we get the primary monitor and it's current display resolution - we will use these are the starting point if we create a full screen window.

Now we have enough information to call glfwCreateWindow and do the actual window creation. In addition to creating a window for us, GLFW will also create an OpenGL context. The context is like the global state of OpenGL. If you change a setting such as alpha blending (to blend lighting or semi-transparent pixels), you're changing it at the context level. So we set the context that GLFW created for us on this window to current, thus enabling all other OpenGL function calls.

Finally, even though we created a window of a certain size, the actual number of pixels used to render an image to the screen might be different. For example, on Retina displays, you typically get 2x the number of pixels for your window size. The call to glfwGetFramebufferSize gives us the width and height of the buffer and image that will be used to render things on screen.

Window creation is the bulk of the work that this class will do. After that, the message processing loop and swapping buffers is just a function call each:

void UWindow::ProcessMessages() {
    glfwPollEvents();
}

void UWindow::SwapBuffer() {
    glfwSwapBuffers(m_window);
}

To anyone who has ever had to write their own operating system specific message handling loops, this abstraction is amazing. If you want to truly appreciate it, take a look at some of the GLFW code on Github or the Microsoft documentation on the WindowProc callback.

Since we don't have access directly to the messages, we'll need to hook into specific callbacks to get access to the events we want to do something with. To start, we need to know when the user closes the window, so that we can terminate our main game loop. GLFW provides a handy function called glfwWindowShouldClose, which we'll wrap in a function called IsClosing:

bool UWindow::IsClosing() {
    return(glfwWindowShouldClose(m_window));
}

With these three functions, we can now write a game loop:

UWindow* window = new UWindow();
window->Create("Test Window", 800, 600, false);

if(window->IsClosing() == false) {
    window->ProcessMessages();

    // Do amazing game things here

    window->SwapBuffer();
}

This will create a window with a width and height of 800 and 600 respectively and while the user has not closed the window, it will check for new messages (which we'll use later to do I/O) and swaps the buffer each frame. If you've read the article on Dates and Times, you can also flush this out to add a frame delta or frame counter and a sleep to relinquish control back to the OS for a fairly robust game loop (without actually doing anything of substance yet of course).

Finally, later on other areas of our engine are going to need to know how big our window or back buffer image are, so we have two functions to pass that information back:

void UWindow::Size(int& width, int& height) {
    glfwGetWindowSize(m_window, &width, &height);
}

void UWindow::FramebufferSize(int& width, int& height) {
    glfwGetFramebufferSize(m_window, &width, &height);
}

Next Steps

This article gets us started: we can create a main game window and will be able to render things and process I/O. It can be extended in a few ways that you may need depending on your use case: if you'll be using windowed mode, you'll probably want to implement your own callback around glfwSetWindowSizeCallback, which will let you know when the user changes the window size and you can reinitialize your graphics pipeline. You may also want to have multiple windows, which this class could support but won't be focused on.

With this class (and our TimeSystem from Dates and Times), we can now build a complete cross-platform game loop. Next, we'll put it all together.