Refactor the Win32 Window into a Class

Turn the procedural Win32 window setup into a small C++ Window class, split the project into files, and prepare the codebase for DirectX 12.

Early Draft Work in progress — GitHub links coming soon.

In the previous chapter we created a Win32 window with a small set of free functions. That version is useful because it shows the raw Win32 flow clearly: register a class, create a window, pump messages, and handle WndProc.

Now we will refactor that working code into a shape that can grow. By the end of this chapter you will have a small Window class that:

We are still not rendering anything with DX12 yet. This chapter is about structure: taking code that works and arranging it so the renderer has a clean place to plug in.

Why Make a Window Class?

The C Win32 API is built around free functions and callbacks. That is fine for tiny examples, but a renderer quickly needs state:

A Window class gives that state a home.

There is one awkward part: Windows does not know how to call a C++ member function directly as a window procedure. Win32 expects this shape:

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);

A non-static member function has a hidden this parameter, so its shape does not match. The usual solution is:

  1. Give Win32 a static callback with the correct signature.
  2. During window creation, pass this into CreateWindowExW.
  3. Store that pointer on the HWND with SetWindowLongPtrW.
  4. Forward later messages to an instance method like HandleMessage.

That sounds like a lot, but once you see the pattern, it becomes reusable boilerplate.

Create the Project Directory

Before writing the window code, make a small project folder. We will keep headers and source files separate from the start because the renderer will grow quickly.

dx12-sandbox/
    CMakeLists.txt
    src/
        main.cpp
        Window.cpp
        Window.h

Create it from a terminal like this:

mkdir dx12-sandbox
cd dx12-sandbox
mkdir src
New-Item CMakeLists.txt
New-Item src\main.cpp
New-Item src\Window.cpp
New-Item src\Window.h

The names are plain on purpose. main.cpp owns application startup and the message loop. Window.h declares the C++ wrapper. Window.cpp hides the Win32 setup details.

CMakeLists.txt

For now, put placeholder CMake content here. We will tighten this up after testing the compiler and Visual Studio workflow.

cmake_minimum_required(VERSION 3.24)

project(DX12Sandbox LANGUAGES CXX)

# Placeholder for now.
# We will fill this in after testing the project setup.

The important thing for this chapter is the file layout. Once the CMake target is finalized, it will include src/main.cpp and src/Window.cpp, use C++20 or newer, and build as a Windows subsystem application.

Window.h

The header declares the public surface of the Window class:

#pragma once

#define WIN32_LEAN_AND_MEAN
#include <windows.h>

class Window
{
public:
    bool Create(HINSTANCE hInstance, int clientWidth, int clientHeight, int nCmdShow);

    HWND Handle() const { return hwnd_; }
    int Width() const { return clientWidth_; }
    int Height() const { return clientHeight_; }
    bool IsMinimized() const { return minimized_; }

private:
    static constexpr const wchar_t* ClassName = L"GameDevInstitute.MainWindow";

    static LRESULT CALLBACK StaticWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
    LRESULT HandleMessage(UINT msg, WPARAM wParam, LPARAM lParam);

    static bool RegisterWindowClass(HINSTANCE hInstance);
    static RECT ComputeWindowRect(int clientWidth, int clientHeight, DWORD style, DWORD exStyle);

    HWND hwnd_ = nullptr;
    int clientWidth_ = 0;
    int clientHeight_ = 0;
    bool minimized_ = false;
};

The public surface is intentionally small. Outside code should not need to know about WNDCLASSEXW, AdjustWindowRectEx, or the callback forwarding trick. It should just create a window and ask for the native handle when DX12 needs it.

Window.cpp

The implementation file owns the Win32 details.

First, include the header:

#include "Window.h"

Register the Win32 Window Class

Win32 still needs a window class. We hide that registration behind a private static helper:

bool Window::RegisterWindowClass(HINSTANCE hInstance)
{
    WNDCLASSEXW wc = {};
    wc.cbSize        = sizeof(WNDCLASSEXW);
    wc.style         = CS_HREDRAW | CS_VREDRAW;
    wc.lpfnWndProc   = Window::StaticWndProc;
    wc.hInstance     = hInstance;
    wc.hCursor       = LoadCursorW(nullptr, IDC_ARROW);
    wc.hbrBackground = nullptr;
    wc.lpszClassName = ClassName;

    return RegisterClassExW(&wc) != 0 || GetLastError() == ERROR_CLASS_ALREADY_EXISTS;
}

The most important field is lpfnWndProc. Instead of pointing at a free WndProc, it points at Window::StaticWndProc.

The ERROR_CLASS_ALREADY_EXISTS check is useful because window classes are registered per process. If you ever create more than one Window, the second call should not fail just because the class was already registered.

We set hbrBackground to nullptr because this will become a graphics window. DX12 will own drawing. Asking GDI to erase the background can cause flicker or a white flash between frames.

Compute the Outer Window Size

For graphics programming, the client area matters more than the total outer window size. The client area is the drawable rectangle inside the title bar and borders. Your swap chain back buffers will match this area.

RECT Window::ComputeWindowRect(int clientWidth, int clientHeight, DWORD style, DWORD exStyle)
{
    RECT rect = { 0, 0, clientWidth, clientHeight };
    AdjustWindowRectEx(&rect, style, FALSE, exStyle);
    return rect;
}

If you pass 1280, 720 directly to CreateWindowExW, you are asking for an outer window that includes borders and a title bar. AdjustWindowRectEx does the math so the usable client area is the size you requested.

Create the Window Instance

Now Window::Create can register the class, calculate the outer rectangle, create the HWND, and show the window:

bool Window::Create(HINSTANCE hInstance, int clientWidth, int clientHeight, int nCmdShow)
{
    if (!RegisterWindowClass(hInstance))
    {
        return false;
    }

    const DWORD style = WS_OVERLAPPEDWINDOW;
    const DWORD exStyle = 0;

    RECT rect = ComputeWindowRect(clientWidth, clientHeight, style, exStyle);

    hwnd_ = CreateWindowExW(
        exStyle,
        ClassName,
        L"DX12 Sandbox",
        style,
        CW_USEDEFAULT,
        CW_USEDEFAULT,
        rect.right - rect.left,
        rect.bottom - rect.top,
        nullptr,
        nullptr,
        hInstance,
        this);

    if (!hwnd_)
    {
        return false;
    }

    clientWidth_ = clientWidth;
    clientHeight_ = clientHeight;
    minimized_ = false;

    ShowWindow(hwnd_, nCmdShow);
    UpdateWindow(hwnd_);

    return true;
}

The final argument to CreateWindowExW is the key detail:

this

Win32 passes that value to the window procedure during WM_NCCREATE. We will use it to connect the native HWND back to the C++ object that owns it.

Forward Messages to the Window Object

The static callback is the bridge between Win32 and your C++ class:

LRESULT CALLBACK Window::StaticWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    Window* window = nullptr;

    if (msg == WM_NCCREATE)
    {
        auto* create = reinterpret_cast<CREATESTRUCTW*>(lParam);
        window = static_cast<Window*>(create->lpCreateParams);

        SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(window));
        window->hwnd_ = hwnd;
    }
    else
    {
        window = reinterpret_cast<Window*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
    }

    if (window)
    {
        return window->HandleMessage(msg, wParam, lParam);
    }

    return DefWindowProcW(hwnd, msg, wParam, lParam);
}

WM_NCCREATE is one of the earliest messages a window receives. At that point, lParam points to a CREATESTRUCTW, and its lpCreateParams field contains the value we passed as the last argument to CreateWindowExW.

So the first time through:

  1. Pull this out of CREATESTRUCTW.
  2. Store it on the HWND using GWLP_USERDATA.
  3. Save the HWND back onto the C++ object.

After that, every message can retrieve the same pointer with GetWindowLongPtrW and forward to HandleMessage.

Handle Messages as a Member Function

Now message handling can use normal object state:

LRESULT Window::HandleMessage(UINT msg, WPARAM wParam, LPARAM lParam)
{
    switch (msg)
    {
        case WM_SIZE:
        {
            clientWidth_ = LOWORD(lParam);
            clientHeight_ = HIWORD(lParam);
            minimized_ = (wParam == SIZE_MINIMIZED);

            if (!minimized_ && clientWidth_ > 0 && clientHeight_ > 0)
            {
                // Later: resize the DX12 swap chain buffers here.
            }

            return 0;
        }

        case WM_CLOSE:
            DestroyWindow(hwnd_);
            return 0;

        case WM_DESTROY:
            PostQuitMessage(0);
            return 0;
    }

    return DefWindowProcW(hwnd_, msg, wParam, lParam);
}

This is the payoff. Resize messages update the Window object’s width, height, and minimized state. Later, when the renderer exists, this method can notify it or set a pending resize flag.

WM_CLOSE and WM_DESTROY are separate on purpose:

Calling DestroyWindow from WM_CLOSE accepts the close request. Calling PostQuitMessage(0) from WM_DESTROY tells the message loop to stop.

Full Window.cpp

Put those pieces together and Window.cpp looks like this:

#include "Window.h"

bool Window::RegisterWindowClass(HINSTANCE hInstance)
{
    WNDCLASSEXW wc = {};
    wc.cbSize        = sizeof(WNDCLASSEXW);
    wc.style         = CS_HREDRAW | CS_VREDRAW;
    wc.lpfnWndProc   = Window::StaticWndProc;
    wc.hInstance     = hInstance;
    wc.hCursor       = LoadCursorW(nullptr, IDC_ARROW);
    wc.hbrBackground = nullptr;
    wc.lpszClassName = ClassName;

    return RegisterClassExW(&wc) != 0 || GetLastError() == ERROR_CLASS_ALREADY_EXISTS;
}

RECT Window::ComputeWindowRect(int clientWidth, int clientHeight, DWORD style, DWORD exStyle)
{
    RECT rect = { 0, 0, clientWidth, clientHeight };
    AdjustWindowRectEx(&rect, style, FALSE, exStyle);
    return rect;
}

bool Window::Create(HINSTANCE hInstance, int clientWidth, int clientHeight, int nCmdShow)
{
    if (!RegisterWindowClass(hInstance))
    {
        return false;
    }

    const DWORD style = WS_OVERLAPPEDWINDOW;
    const DWORD exStyle = 0;

    RECT rect = ComputeWindowRect(clientWidth, clientHeight, style, exStyle);

    hwnd_ = CreateWindowExW(
        exStyle,
        ClassName,
        L"DX12 Sandbox",
        style,
        CW_USEDEFAULT,
        CW_USEDEFAULT,
        rect.right - rect.left,
        rect.bottom - rect.top,
        nullptr,
        nullptr,
        hInstance,
        this);

    if (!hwnd_)
    {
        return false;
    }

    clientWidth_ = clientWidth;
    clientHeight_ = clientHeight;
    minimized_ = false;

    ShowWindow(hwnd_, nCmdShow);
    UpdateWindow(hwnd_);

    return true;
}

LRESULT CALLBACK Window::StaticWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    Window* window = nullptr;

    if (msg == WM_NCCREATE)
    {
        auto* create = reinterpret_cast<CREATESTRUCTW*>(lParam);
        window = static_cast<Window*>(create->lpCreateParams);

        SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(window));
        window->hwnd_ = hwnd;
    }
    else
    {
        window = reinterpret_cast<Window*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
    }

    if (window)
    {
        return window->HandleMessage(msg, wParam, lParam);
    }

    return DefWindowProcW(hwnd, msg, wParam, lParam);
}

LRESULT Window::HandleMessage(UINT msg, WPARAM wParam, LPARAM lParam)
{
    switch (msg)
    {
        case WM_SIZE:
        {
            clientWidth_ = LOWORD(lParam);
            clientHeight_ = HIWORD(lParam);
            minimized_ = (wParam == SIZE_MINIMIZED);

            if (!minimized_ && clientWidth_ > 0 && clientHeight_ > 0)
            {
                // Later: resize the DX12 swap chain buffers here.
            }

            return 0;
        }

        case WM_CLOSE:
            DestroyWindow(hwnd_);
            return 0;

        case WM_DESTROY:
            PostQuitMessage(0);
            return 0;
    }

    return DefWindowProcW(hwnd_, msg, wParam, lParam);
}

main.cpp

main.cpp owns application startup and the message loop.

#include "Window.h"

int RunRenderLoop()
{
    MSG msg = {};

    while (msg.message != WM_QUIT)
    {
        if (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE))
        {
            TranslateMessage(&msg);
            DispatchMessageW(&msg);
        }
        else
        {
            // Later: RenderFrame();
        }
    }

    return static_cast<int>(msg.wParam);
}

int WINAPI wWinMain(
    HINSTANCE hInstance,
    HINSTANCE,
    PWSTR,
    int nCmdShow)
{
    Window window;

    if (!window.Create(hInstance, 1280, 720, nCmdShow))
    {
        MessageBoxW(nullptr, L"Window creation failed.", L"Startup Error", MB_ICONERROR);
        return -1;
    }

    return RunRenderLoop();
}

The app owns a Window object. The Window object owns the HWND. Later, the renderer can use:

HWND hwnd = window.Handle();

That handle is what DXGI will need when we create the swap chain.

Why This Class Is Enough for DX12

DX12 does not need Win32 to draw pixels. It needs Win32 to provide the native window handle:

HWND hwnd = window.Handle();

That HWND is what you will pass into swap chain creation. The swap chain uses it to know which window receives the presented images.

The class also gives us the current client size:

int width = window.Width();
int height = window.Height();

Those values will matter when we create the swap chain buffers and when the user resizes the window.

Common Mistakes

If the window does not appear, check these first:

If the client area is not the size you expected, make sure AdjustWindowRectEx uses the same style and extended style that you pass to CreateWindowExW.

If the app closes but the process keeps running, make sure WM_DESTROY calls PostQuitMessage.

Quick Checkpoint

You are ready to move on if these ideas feel solid:

What’s Next

Next up: DXGI. We will create a factory, choose an adapter, and prepare the path toward the D3D12 device and swap chain.